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,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());
}
}