first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Actions\Titles;
use App\Models\Episode;
use App\Models\Season;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class DeleteSeasons
{
public function execute(array|Collection $seasonIds): void
{
// seasons
DB::table('creditables')
->whereIn('creditable_id', $seasonIds)
->where('creditable_type', Season::MODEL_TYPE)
->delete();
app(Season::class)
->whereIn('id', $seasonIds)
->delete();
// episodes
$episodeIds = app(Episode::class)
->whereIn('season_id', $seasonIds)
->pluck('id');
DB::table('creditables')
->whereIn('creditable_id', $episodeIds)
->where('creditable_type', Episode::MODEL_TYPE)
->delete();
app(Episode::class)
->whereIn('id', $episodeIds)
->delete();
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Actions\Titles;
use App\Models\Image;
use App\Models\Listable;
use App\Models\Review;
use App\Models\Season;
use App\Models\Title;
use App\Models\Video;
use Common\Comments\Comment;
use Illuminate\Support\Facades\DB;
class DeleteTitles
{
public function execute(array $titleIds): void
{
$seasonIds = app(Season::class)
->whereIn('title_id', $titleIds)
->pluck('id');
app(DeleteSeasons::class)->execute($seasonIds);
// credits
DB::table('creditables')
->whereIn('creditable_id', $titleIds)
->where('creditable_type', Title::MODEL_TYPE)
->delete();
// images
app(Image::class)
->whereIn('model_id', $titleIds)
->where('model_type', Title::MODEL_TYPE)
->delete();
// list items
app(Listable::class)
->whereIn('listable_id', $titleIds)
->where('listable_type', Title::MODEL_TYPE)
->delete();
// channel items
DB::table('channelables')
->whereIn('channelable_id', $titleIds)
->where('channelable_type', Title::MODEL_TYPE)
->delete();
// reviews
Review::whereIn('reviewable_id', $titleIds)
->where('reviewable_type', Title::MODEL_TYPE)
->delete();
// comments
Comment::whereIn('commentable_id', $titleIds)
->where('commentable_type', Title::MODEL_TYPE)
->delete();
// tags
DB::table('taggables')
->whereIn('taggable_id', $titleIds)
->where('taggable_type', Title::MODEL_TYPE)
->delete();
// keywords
DB::table('keyword_title')
->whereIn('title_id', $titleIds)
->delete();
// countries
DB::table('country_title')
->whereIn('title_id', $titleIds)
->delete();
// genres
DB::table('genre_title')
->whereIn('title_id', $titleIds)
->delete();
// videos
$videoIds = app(Video::class)
->whereIn('title_id', $titleIds)
->pluck('id');
app(Video::class)
->whereIn('id', $videoIds)
->delete();
DB::table('video_votes')
->whereIn('video_id', $videoIds)
->delete();
DB::table('video_captions')
->whereIn('video_id', $videoIds)
->delete();
DB::table('video_reports')
->whereIn('video_id', $videoIds)
->delete();
DB::table('video_plays')
->whereIn('video_id', $videoIds)
->delete();
// titles
Title::withoutGlobalScope('adult')
->whereIn('id', $titleIds)
->delete();
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Actions\Titles;
trait HandlesEncodedTmdbId
{
public static function encodeTmdbId(
string $provider,
string $type,
string|int $id,
): string {
return base64_encode("$provider|$type|$id");
}
public static function decodeTmdbId($encodedId): array
{
$encodedId = base64_decode($encodedId);
$parts = explode('|', $encodedId);
if (count($parts) === 3) {
return [
'provider' => $parts[0],
'type' => $parts[1],
'id' => $parts[2],
];
} else {
return ['provider' => null, 'type' => null, 'id' => null];
}
}
public static function decodeTmdbIdOrFail(string $encodedId): array
{
[$provider, $type, $id] = array_values(
static::decodeTmdbId($encodedId),
);
if (!$provider || !$type || !$id) {
abort(404);
}
return [(int) $id, $type];
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Actions\Titles;
use App\Models\Person;
use DB;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
trait HasCreditableRelation
{
public function credits(): BelongsToMany
{
$query = $this->morphToMany(Person::class, 'creditable')->withPivot([
'id',
'job',
'department',
'order',
'character',
]);
$query = $query->select(['people.id', 'name', 'poster']);
// order by department first, so we always get director,
// writers and creators, even if limit is applied to this query
$prefix = DB::getTablePrefix();
return $query
->orderBy(
DB::raw(
"FIELD(department, 'directing', 'creators', 'writing', 'actors')",
),
)
// should be "desc" and not "asc" because "minus" is added which will reverse order
->orderBy(DB::raw("-{$prefix}creditables.order"), 'desc');
}
public function updateCredit(int $pivotId, array $payload): void
{
// lowercase payload
$payload = collect($payload)
->mapWithKeys(function ($value, $key) {
if ($key === 'department' || $key === 'job') {
$value = strtolower($value);
}
return [$key => $value];
})
->toArray();
$this->credits()
->newPivotQuery()
->where('id', $pivotId)
->update($payload);
}
public function createCredit(array $payload): void
{
// lowercase payload
$payload = collect($payload)
->mapWithKeys(function ($value, $key) {
if ($key === 'department' || $key === 'job') {
$value = strtolower($value);
}
return [$key => $value];
})
->toArray();
if ($payload['department'] === 'actors') {
$payload['order'] =
$this->credits()
->wherePivot('department', 'actors')
->count() + 1;
}
$this->credits()->attach($payload['person_id'], $payload);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Actions\Titles;
use App\Models\Episode;
use App\Models\Video;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
trait HasVideoRelation
{
public function videos(): HasMany
{
return $this->hasMany(Video::class)->applySelectedSort();
}
public function primaryVideo(): HasOne
{
$preferFull = settings('streaming.prefer_full');
return $this->hasOne(Video::class)
->when($preferFull, function ($query) {
$query->where('category', 'full');
})
->select([
'id',
'title_id',
'name',
'category',
'episode_id',
'season_num',
'episode_num',
])
->when(
// is series or movie and not specific episode
fn() => static::class !== Episode::class && $preferFull,
function ($builder) {
return $builder->where(function ($builder) {
// video attached directly to movie or series
$builder
->where(
fn($builder) => $builder
->whereNull('season_num')
->whereNull('episode_num'),
)
// video attached to first episode of series
->orWhere(
fn($builder) => $builder
->where('season_num', 1)
->where('episode_num', 1),
);
});
},
)
->applySelectedSort();
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Actions\Titles;
use App\Models\Person;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
trait InsertsTmdbTitleOrPerson
{
public function insertOrRetrieve(Collection $tmdbItems): Collection
{
$tmdbItems = $tmdbItems->map(function ($value) {
unset($value['relation_data']);
unset($value['model_type']);
unset($value['id']);
return $value;
});
$select =
static::class === Person::class
? ['id', 'tmdb_id', 'name']
: ['id', 'tmdb_id', 'name', 'is_series'];
$existing = $this->withoutGlobalScope('adult')
->select($select)
->whereIn('tmdb_id', $tmdbItems->pluck('tmdb_id'))
->get()
->mapWithKeys(fn($item) => [$item['tmdb_id'] => $item]);
$new = $tmdbItems
->filter(fn($item) => !isset($existing[$item['tmdb_id']]))
->values();
if ($new->isNotEmpty()) {
$new->transform(function ($item) {
$item['created_at'] = Arr::get(
$item,
'created_at',
Carbon::now(),
);
return $item;
});
$this->insert($new->toArray());
return $this->whereIn('tmdb_id', $tmdbItems->pluck('tmdb_id'))
->select($select)
->get();
} else {
return $existing;
}
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Actions\Titles;
use Illuminate\Support\Facades\DB;
class LoadSeasonEpisodeNumbers
{
public function execute(int $titleId, int $seasonNumber): array
{
return DB::table('episodes')
->where('title_id', $titleId)
->where('season_number', $seasonNumber)
->orderBy('episode_number', 'asc')
->pluck('episode_number')
->toArray();
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Actions\Titles\Retrieve;
use App\Models\Title;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class GetRelatedTitles
{
public function execute(Title $title, array $params = []): Collection
{
$titleIds = $this->getByTags($title, $params);
if ($titleIds->isNotEmpty()) {
return Title::whereIn('id', $titleIds)
->with(['primaryVideo'])
->when(isset($params['compact']), fn($q) => $q->compact())
->get();
}
return collect();
}
private function getByTags(Title $title, array $params): Collection
{
$prefix = DB::getTablePrefix();
$keywordIds = $title->keywords->pluck('id');
$genreIds = $title->genres->pluck('id');
return DB::table('titles')
->join('keyword_title as k', 'k.title_id', '=', 'titles.id')
->join('genre_title as g', 'g.title_id', '=', 'titles.id')
->select(
DB::raw(
"{$prefix}titles.id, COUNT({$prefix}k.id) + COUNT({$prefix}g.id) as total_count",
),
)
->whereIn('k.keyword_id', $keywordIds)
->whereIn('g.genre_id', $genreIds)
->when($title->release_date, function ($q) use ($title) {
$q->whereBetween('release_date', [
$title->release_date->subYears(5)->format('Y-m-d'),
$title->release_date->addYears(5)->format('Y-m-d'),
]);
})
->where('titles.id', '!=', $title->id)
->groupBy('titles.id')
->orderBy('total_count', 'desc')
->limit(Arr::get($params, 'limit', 10))
->pluck('titles.id');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Actions\Titles\Retrieve;
use App\Models\Title;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PaginateSeasonEpisodes
{
public function execute(
Title $title,
int $seasonNumber,
array $params = [],
): AbstractPaginator {
$builder = $title->episodes()->where('season_number', $seasonNumber);
$builder->with(['primaryVideo']);
$orderBy = Arr::get($params, 'orderBy', 'episode_number');
$orderDir = Arr::get($params, 'orderDir', 'asc');
$pagination = $builder
->orderBy($orderBy, $orderDir)
->simplePaginate(Arr::get($params, 'perPage', 30));
// only show first paragraph of description
$pagination->through(function ($episode) use ($params) {
$episode->description = explode("\n", $episode->description)[0];
if (Arr::get($params, 'excludeDescription')) {
$episode->description = null;
} elseif (Arr::get($params, 'truncateDescriptions')) {
$episode->description = Str::limit($episode->description, 200);
}
return $episode;
});
return $pagination;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Actions\Titles\Retrieve;
use App\Models\Title;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
class PaginateTitleSeasons
{
public function execute(Title $title, array $params = []): AbstractPaginator
{
return $title
->seasons()
->select([
'seasons.id',
'seasons.poster',
'seasons.release_date',
'number',
'title_id',
])
->withCount('episodes')
->orderBy('number', 'desc')
->paginate(Arr::get($params, 'perPage', 8));
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Actions\Titles\Retrieve;
use App\Models\Title;
use Common\Database\Datasource\Datasource;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class PaginateTitles
{
public function execute(array $params, $builder = null): AbstractPaginator
{
if (!$builder) {
$builder = Title::query();
}
if ($type = Arr::get($params, 'type')) {
$builder->where('is_series', $type === 'series');
}
$builder->with(['primaryVideo']);
$datasource = new Datasource($builder, $params);
// prevent duplicate items when ordering by columns that are not
// guaranteed to be unique (budget, popularity , revenue etc.)
$datasource->secondaryOrderCol = 'id';
// only show titles with more than 50 votes when filtering by rating
if ($datasource->filters->has('tmdb_vote_average')) {
$builder->where('tmdb_vote_count', '>=', 50);
}
$order = $datasource->getOrder();
$order['col'] = str_replace(
['user_score', 'rating'],
config('common.site.rating_column'),
$order['col'],
);
// show titles with less than 50 votes on tmdb last, regardless of their average
if (Str::contains($order['col'], 'tmdb_vote_average')) {
$builder->orderBy(DB::raw('tmdb_vote_count > 100'), 'desc');
}
$datasource->order = $order;
return $datasource->paginate();
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Actions\Titles\Retrieve;
use App\Models\Title;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class ShowTitle
{
public function execute(int|string $id, array $params): array
{
if (defined('SHOULD_PRERENDER')) {
$params['skipUpdating'] = true;
$params['load'] =
'images,genres,productionCountries,keywords,videos,primaryVideo,seasons,compactCredits';
$params['loadCount'] = 'seasons';
}
if (is_numeric($id) || ctype_digit($id)) {
$title = Title::findOrFail($id);
} else {
$title = Title::firstOrCreateFromEncodedTmdbId($id);
}
if (!Arr::get($params, 'skipUpdating')) {
$title = $title->maybeUpdateFromExternal();
if (!$title) {
abort(404);
}
}
$response = ['title' => $title->loadCount('seasons')];
foreach (explode(',', Arr::get($params, 'load', '')) as $relation) {
$methodName = sprintf('load%s', Str::camel($relation));
if (method_exists($this, $methodName)) {
$response = $this->$methodName($title, $params, $response);
} elseif (method_exists($title, $relation)) {
$title->load($relation);
}
}
foreach (
explode(',', Arr::get($params, 'loadCount', ''))
as $relation
) {
if (method_exists($title, $relation)) {
$title->loadCount($relation);
}
}
return $response;
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Actions\Titles\Store;
use App\Models\Episode;
use App\Models\Person;
use App\Models\Season;
use App\Models\Title;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class StoreCredits
{
public const UNIQUE_KEY = 'tmdb_id';
private Title|Episode|Season|Person|null $model = null;
public function __construct(private Person $person, private Title $title)
{
}
/**
* @param array $originalMediaItems
*/
public function execute(Title|Episode|Season|Person $model, $originalMediaItems)
{
$this->model = $model;
if (empty($originalMediaItems)) return;
// generate records we will insert into "creditables" pivot table
$newPivotRecords = $this->generatePivotRecords($originalMediaItems);
if ($newPivotRecords->isEmpty()) return;
// fetch all existing "creditable" pivot rows for this creditable
$existingPivotRecords = $this->getExistingRecords($newPivotRecords);
// merge new and existing pivot records, so "order" column value is not lost
$mergedPivotRecords = $this->mergeNewAndExistingRecords($existingPivotRecords, $newPivotRecords);
// delete all "creditables" pivot table records for this creditable
$this->detachExistingRecords($existingPivotRecords);
// insert new pivot records
DB::table('creditables')->insert($mergedPivotRecords->toArray());
}
/**
* @param array $originalMediaItems
* @return Collection
*/
private function generatePivotRecords($originalMediaItems)
{
// need to remove series, otherwise it will attach all guest starts to main series, because
// tmdb API does not specify whether credit belongs to series, season or episode.
$originalMediaItems = $this->filterOutSeries($originalMediaItems);
$dbMediaItems = $this->insertOrRetrieveMediaItems($originalMediaItems);
return collect($originalMediaItems)->map(function($originalMediaItem) use($dbMediaItems) {
$creditable = $dbMediaItems->first(fn($item, $uniqueKey) => $uniqueKey === $originalMediaItem[self::UNIQUE_KEY] && $item['is_series'] === Arr::get($originalMediaItem, 'is_series'));
if ( ! $creditable) return null;
// either attaching multiple titles to a person
// or attaching multiple people to title/season/episode
if ($this->model->model_type === Person::MODEL_TYPE) {
$personId = $this->model->id;
$creditableId = $creditable->id;
} else {
$personId = $creditable->id;
$creditableId = $this->model->id;
}
// build relation records for attaching all credits to title
// (same person might be attached to title multiple times)
return [
'id' => null,
'person_id' => $personId,
'creditable_id' => $creditableId,
'creditable_type' => $this->getCreditableType(),
'character' => Arr::get($originalMediaItem, 'relation_data.character'),
'order' => Arr::get($originalMediaItem, 'relation_data.order'),
'department' => Arr::get($originalMediaItem, 'relation_data.department'),
'job' => Arr::get($originalMediaItem, 'relation_data.job'),
];
})->filter()->values();
}
/**
* Merge existing "creditables" pivot table records with the ones
* we are about to insert. This needs to be done because sometimes
* "order" property does not exist on "new" records, but exists on
* "old" ones and since we will delete "old" ones, "order" would be lost.
*
* @param Collection $existingRecords
* @param Collection $newRecords
* @return Collection
*/
private function mergeNewAndExistingRecords($existingRecords, $newRecords)
{
// all of these properties on both arrays must match for them to be considered equal
$matchProps = collect(['person_id', 'creditable_id', 'creditable_type', 'character', 'department', 'job']);
return $newRecords->map(function($newRecord) use($existingRecords, $matchProps) {
$oldRecord = $existingRecords->first(fn($existingRecord) => $matchProps->every(fn($prop) => $existingRecord[$prop] === $newRecord[$prop]), []);
return $this->arrayMergeIfNotNull($newRecord, $oldRecord);
});
}
/**
* @param array $arr1
* @param array $arr2
* @return array
*/
private function arrayMergeIfNotNull($arr1, $arr2) {
foreach($arr2 as $key => $val) {
$is_set_and_not_null = isset($arr1[$key]);
if ( $val == null && $is_set_and_not_null ) {
$arr2[$key] = $arr1[$key];
}
}
return array_merge($arr1, $arr2);
}
/**
* Insert or retrieve titles or people that need to be attached.
*
* @param Collection|(Title|Person)[] $mediaItems
* @return Collection
*/
private function insertOrRetrieveMediaItems(Collection|array $mediaItems)
{
// make sure we only insert person/title once, even
// if they appear multiple time in title credits
$uniqueMediaItems = collect($mediaItems)->unique(self::UNIQUE_KEY)->values();
if ($uniqueMediaItems->isEmpty()) return collect();
if ($uniqueMediaItems[0]['model_type'] === Person::MODEL_TYPE) {
$mediaItems = $this->person->insertOrRetrieve($uniqueMediaItems);
} else {
$mediaItems = $this->title->insertOrRetrieve($uniqueMediaItems);
}
return $mediaItems->mapWithKeys(fn($item) => [$item[self::UNIQUE_KEY] => $item]);
}
/**
* @param Collection $records
* @return Collection
*/
private function getExistingRecords($records)
{
// select only fields needed to do the diff
return DB::table('creditables')
->whereIn('person_id', $records->pluck('person_id'))
->whereIn('job', $records->pluck('job'))
->where('creditable_type', $records[0]['creditable_type'])
->whereIn('creditable_id', $records->pluck('creditable_id'))
->get()
->map(fn($record) => (array) $record);
}
/**
* Delete creditable records that already exist.
*
* @param Collection $records
*/
private function detachExistingRecords($records)
{
if ($records->isEmpty()) return;
// select only fields needed to do the diff
$existingRecords = DB::table('creditables')
->whereIn('person_id', $records->pluck('person_id'))
->whereIn('job', $records->pluck('job'))
->where('creditable_type', $records[0]['creditable_type'])
->whereIn('creditable_id', $records->pluck('creditable_id'))
->get();
DB::table('creditables')->whereIn('id', $existingRecords->pluck('id'))->delete();
}
/**
* Get creditable type for morphToMany or morphedByMany relation.
*/
private function getCreditableType(): string
{
if ($this->model::class === Person::class) {
// won't be attaching seasons or episodes here
// so can just return title type instantly
return Title::MODEL_TYPE;
} else {
return $this->model::MODEL_TYPE;
}
}
/**
* Remove all series and episodes from specified array.
*
* @param array $originalMediaItems
* @return array
*/
private function filterOutSeries($originalMediaItems)
{
return array_filter($originalMediaItems, fn($creditable) => !Arr::get($creditable, 'is_series'));
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Actions\Titles\Store;
use App\Models\Episode;
use App\Models\Season;
use App\Models\Title;
use Illuminate\Support\Collection;
class StoreEpisodeData
{
public function execute(Title $title, Season $season, $episodes): Title
{
$existingEpisodes = $season->episodes()->get();
foreach ($episodes as $episodeData) {
$episode = $this->storePrimaryData(
$season,
$existingEpisodes,
$episodeData,
);
app(StoreCredits::class)->execute($episode, $episodeData['cast']);
}
return $title;
}
private function storePrimaryData(
Season $season,
Collection $existingEpisodes,
array $episodeData,
): Episode {
$episodeData = array_filter(
$episodeData,
fn($value) => !is_array($value) && $value !== Episode::MODEL_TYPE,
);
$episodeData['title_id'] = $season->title_id;
unset($episodeData['id']);
$existingEpisode = $existingEpisodes->firstWhere(
'episode_number',
$episodeData['episode_number'],
);
if ($existingEpisode) {
$existingEpisode->update($episodeData);
return $existingEpisode;
} else {
return $season->episodes()->create($episodeData);
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Actions\Titles\Store;
use App\Models\Season;
use App\Models\Title;
class StoreSeasonData
{
private ?Title $title = null;
public function execute(Title $title, array $data): Title
{
if (empty($data)) {
return $title;
}
$this->title = $title;
$season = $this->persistData($data);
if (isset($data['cast'])) {
app(StoreCredits::class)->execute($season, $data['cast']);
}
if (isset($data['episodes'])) {
app(StoreEpisodeData::class)->execute(
$title,
$season,
$data['episodes'],
);
}
return $this->title;
}
private function persistData(array $data): Season
{
// remove all relation data
$data = array_filter(
$data,
fn($value) => !is_array($value) && $value !== Season::MODEL_TYPE,
);
// if season data did not change then timestamps
// will not be updated because model is not dirty
$data['updated_at'] = now();
return Season::updateOrCreate(
[
'title_id' => $this->title->id,
'number' => $data['number'],
],
$data,
);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Actions\Titles\Store;
use App\Actions\Titles\StoresMediaImages;
use App\Models\Season;
use App\Models\Title;
use App\Models\Video;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class StoreTitleData
{
use StoresMediaImages;
private ?Title $title = null;
private ?array $data = null;
private ?array $options = null;
public function execute(
Title $title,
array $data,
array $options = [],
): Title {
$this->title = $title;
$this->data = $data;
$this->options = $options;
$this->persistData();
$this->persistRelations();
return $this->title;
}
private function persistData(): void
{
$titleData = array_filter(
$this->data,
fn(
$value, // make sure we don't overwrite existing values with null
) => !is_array($value) &&
($this->options['overrideWithEmptyValues'] ?? !is_null($value)),
);
$this->title->fill($titleData)->save();
}
private function persistRelations(): void
{
$relations = array_filter($this->data, fn($value) => is_array($value));
foreach ($relations as $name => $values) {
switch ($name) {
case 'videos':
$this->persistVideos($values);
break;
case 'images':
$this->storeImages($values, $this->title);
break;
case 'genres':
$this->persistTags($values, 'genre');
break;
case 'countries':
$this->persistTags($values, 'production_country');
break;
case 'cast':
app(StoreCredits::class)->execute($this->title, $values);
break;
case 'keywords':
$this->persistTags($values, 'keyword');
break;
case 'seasons':
$this->persistSeasons($values);
}
}
}
private function persistSeasons(array $seasons): void
{
$newSeasons = collect($seasons)
->map(function ($season) {
$season['title_id'] = $this->title->id;
return $season;
})
->filter(
fn($season) => !$this->title->seasons->contains(
'number',
$season['number'],
),
);
if ($newSeasons->isNotEmpty()) {
Season::insert($newSeasons->toArray());
}
}
private function persistTags(array $tags, string $type): void
{
$values = collect($tags)->map(
fn($tag) => [
'name' => $tag['name'],
'display_name' => Arr::get(
$tag,
'display_name',
ucfirst($tag['name']),
),
],
);
$tags = app(modelTypeToNamespace($type))->insertOrRetrieve(
$values,
$type,
);
$relation = $this->title->{Str::camel(Str::plural($type))}();
$relation->syncWithoutDetaching($tags->pluck('id'));
}
private function persistVideos(array $values): void
{
$videos = collect($values)
->unique(fn($v) => strtolower($v['name']))
->values()
->map(function ($value, $i) {
$value['title_id'] = $this->title->id;
$value['order'] = $i + 1;
$value['created_at'] = now();
$value['updated_at'] = now();
return $value;
});
Video::where('origin', '!=', 'local')
->where('title_id', $this->title->id)
->whereNull('episode_num')
->delete();
Video::insert($videos->toArray());
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Actions\Titles;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
class StoreMediaImageOnDisk
{
// sizes should be ordered by size (desc), to avoid blurry images
private array $sizes = [
'original' => null,
'large' => 500,
'medium' => 300,
'small' => 92,
];
public function execute(UploadedFile $file): string
{
$hash = Str::random(30);
$manager = new ImageManager(new Driver());
$img = $manager->read($file);
$extension = $file->extension() ?? 'jpeg';
foreach ($this->sizes as $key => $size) {
if ($size) {
$img->scale($size);
}
Storage::disk('public')->put(
"media-images/backdrops/$hash/$key.$extension",
$extension === 'png' ? $img->toPng() : $img->toJpeg(),
);
}
$endpoint = config('common.site.file_preview_endpoint');
$uri = "media-images/backdrops/$hash/original.$extension";
return $endpoint
? "$endpoint/storage/$uri"
: Storage::disk('public')->url($uri);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Actions\Titles;
use App\Models\Person;
use App\Models\Title;
trait StoresMediaImages
{
public function storeImages(array $values, Title|Person $model): void
{
$values = array_map(function ($value) use ($model) {
$value['model_id'] = $model->id;
$value['model_type'] = $model->getMorphClass();
return $value;
}, $values);
$model
->images()
->where('source', '!=', 'local')
->delete();
$model->images()->insert($values);
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Actions\Titles;
use App\Models\Episode;
use App\Models\Person;
use App\Models\Season;
use App\Models\Title;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class TitleCredits
{
public function loadCompact(
Title $title,
Season $season = null,
Episode $episode = null,
): Collection {
$builder = $this->getBaseQuery($title, $season, $episode)
->whereIn('creditables.department', [
'actors',
'writing',
'directing',
'creators',
])
->limit(50);
$credits = $this->groupByDepartment($builder);
if (isset($credits['actors'])) {
$credits['actors'] = $credits['actors']->take(15)->values();
}
if (isset($credits['writing'])) {
$credits['writing'] = $credits['writing']->take(3)->values();
}
if (isset($credits['directing'])) {
$credits['directing'] = $credits['directing']
->filter(fn($c) => $c['pivot']['job'] === 'director')
->take(3)
->values();
}
if (isset($credits['creators'])) {
$credits['creators'] = $credits['creators']->take(3)->values();
}
return $credits;
}
public function loadFull(
Title $title,
Season $season = null,
Episode $episode = null,
): Collection {
$builder = $this->getBaseQuery($title, $season, $episode);
return $this->groupByDepartment($builder);
}
protected function getBaseQuery(
Title $title = null,
Season $season = null,
Episode $episode = null,
): Builder {
$prefix = DB::getTablePrefix();
$builder = Person::join(
'creditables',
'people.id',
'=',
'creditables.person_id',
)
->select([
'people.id',
'people.name',
'people.poster',
DB::raw("{$prefix}creditables.id as pivot_id"),
DB::raw(
"{$prefix}creditables.creditable_id as pivot_creditable_id",
),
DB::raw(
"{$prefix}creditables.creditable_type as pivot_creditable_type",
),
DB::raw("{$prefix}creditables.job as pivot_job"),
DB::raw("{$prefix}creditables.department as pivot_department"),
DB::raw("{$prefix}creditables.order as pivot_order"),
DB::raw("{$prefix}creditables.character as pivot_character"),
])
// Need to keep same person in different departments (as actor and producer for example)
// while still removing duplicates when loading credits for title, season and episode
->groupBy(['people.id', 'creditables.department']);
$builder->where(function ($q) use ($title, $season, $episode) {
if ($title) {
$q->orWhere(
fn($q) => $q
->where('creditables.creditable_id', $title->id)
->where(
'creditables.creditable_type',
$title->getMorphClass(),
),
);
}
if ($episode) {
$q->orWhere(
fn($q) => $q
->where('creditables.creditable_id', $episode->id)
->where(
'creditables.creditable_type',
$episode->getMorphClass(),
),
);
}
if ($season) {
$q->orWhere(
fn($q) => $q
->where('creditables.creditable_id', $season->id)
->where(
'creditables.creditable_type',
$season->getMorphClass(),
),
);
}
});
// order by department first, so we always get director,
// writers and creators, even if limit is applied to this query
$prefix = DB::getTablePrefix();
return $builder
->orderBy(
DB::raw(
"FIELD(department, 'directing', 'creators', 'writing', 'actors')",
),
)
// should be "desc" and not "asc" because "minus" is added which will reverse order
->orderBy(DB::raw("-{$prefix}creditables.order"), 'desc');
}
protected function groupByDepartment(Builder $builder): Collection
{
$credits = $builder->get()->map(function ($credit) {
$credit->pivot = [
'id' => $credit->pivot_id,
'job' => $credit->pivot_job,
'department' => $credit->pivot_department,
'character' => $credit->pivot_character,
];
unset(
$credit->pivot_id,
$credit->pivot_creditable_id,
$credit->pivot_creditable_type,
$credit->pivot_job,
$credit->pivot_department,
$credit->pivot_order,
$credit->pivot_character,
);
return $credit;
});
return $credits->groupBy('pivot.department');
}
}