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,225 @@
<?php
namespace App\Services\Data\Tmdb;
use App\Actions\People\DeletePeople;
use App\Actions\Titles\DeleteSeasons;
use App\Actions\Titles\DeleteTitles;
use App\Models\Person;
use App\Models\Title;
use Common\Core\HttpClient;
use Common\Settings\Settings;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class TmdbApi
{
public const TMDB_BASE = 'https://api.themoviedb.org/3/';
public const DEFAULT_TMDB_LANGUAGE = 'en-US';
protected bool $includeAdult = false;
protected string $language = self::DEFAULT_TMDB_LANGUAGE;
protected HttpClient $http;
public function __construct(private Settings $settings)
{
$this->http = new HttpClient(['exceptions' => true]);
$this->language = $this->settings->get(
'tmdb.language',
self::DEFAULT_TMDB_LANGUAGE,
);
$this->includeAdult = $this->settings->get('tmdb.includeAdult', false);
}
public function getPerson(Person $person): array|null
{
$appends = [];
// only import filmography if it's set by user
if ($this->settings->get('content.automate_filmography')) {
$appends[] = 'combined_credits';
}
$response = $this->call("person/{$person->tmdb_id}", [
'append_to_response' => implode(',', $appends),
]);
// person does not exist anymore on themoviedb
if (
Arr::get($response, 'success') === false &&
Arr::get($response, 'status_code') === 34
) {
if (config('common.site.tmdb_delete_when_sync')) {
app(DeletePeople::class)->execute([$person->id]);
return null;
} else {
return [];
}
}
$response['fully_synced'] = true;
return app(TransformData::class)
->execute([$response])
->first();
}
public function getSeason(Title $title, $seasonNumber)
{
if (!$title->tmdb_id) {
return [];
}
$response = $this->call("tv/{$title->tmdb_id}/season/{$seasonNumber}", [
'append_to_response' => 'credits',
]);
// season does not exist anymore on themoviedb
if (
Arr::get($response, 'success') === false &&
Arr::get($response, 'status_code') === 34
) {
if (config('common.site.tmdb_delete_when_sync')) {
$seasonId = $title
->seasons()
->where('number', $seasonNumber)
->value('id');
app(DeleteSeasons::class)->execute([$seasonId]);
return null;
} else {
return [];
}
}
$data = app(TransformData::class)
->execute([$response])
->first();
$data['fully_synced'] = true;
return $data;
}
public function getTitle(Title $title): array|null
{
$appends = [
'credits',
'external_ids',
'images',
'content_ratings',
'keywords',
'release_dates',
'videos',
'seasons',
];
$uri = $title->is_series ? 'tv' : 'movie';
$response = $this->call("$uri/{$title->tmdb_id}", [
'append_to_response' => implode(',', $appends),
]);
// title does not exist anymore on themoviedb
if (
Arr::get($response, 'success') === false &&
Arr::get($response, 'status_code') === 34
) {
if (config('common.site.tmdb_delete_when_sync')) {
app(DeleteTitles::class)->execute([$title->id]);
return null;
} else {
return [];
}
}
$data = app(TransformData::class)
->execute([$response])
->first();
// fall back to english videos if there are no videos in the current language
if (!Str::startsWith($this->language, 'en') && empty($data['videos'])) {
$videos = $this->call("$uri/{$title->tmdb_id}/videos", [
'language' => 'en-US',
]);
$videos = app(TransformData::class)->formatVideos(
$videos['results'],
);
$data['videos'] = $videos;
}
$data['fully_synced'] = true;
return $data;
}
public function search(string $query, array $params = []): Collection
{
$response = $this->call('search/multi', ['query' => $query]);
$results = app(TransformData::class)->execute($response['results']);
$type = Arr::get($params, 'type');
$limit = Arr::get($params, 'limit', 8);
if ($type) {
$results = $results->filter(
fn($result) => $result['type'] === $type,
);
}
return $results
->sortByDesc('popularity')
->slice(0, $limit)
->values();
}
public function browse($page = 1, $type = 'movie', $queryParams = []): array
{
if ($page > 500) {
throw new Exception('Maximum page is 500');
}
if ($type === 'series') {
$type = 'tv';
}
$apiParams = array_merge(
['sort_by' => 'popularity.desc', 'page' => $page],
$queryParams,
);
$response = $this->call("discover/$type", $apiParams);
$response['results'] = app(TransformData::class)->execute(
$response['results'],
);
return $response;
}
public function trendingPeople(): Collection
{
$response = $this->call('person/popular');
return app(TransformData::class)->execute($response['results']);
}
protected function call(string $uri, array $queryParams = []): array
{
$key = config('services.tmdb.key');
$url = self::TMDB_BASE . "$uri?api_key=$key";
$queryParams = array_merge(
[
// need to send "true" and not "1" otherwise tmdb will not work
'include_adult' => $this->includeAdult ? 'true' : 'false',
'language' => $this->language,
'region' => 'US',
'include_image_language' => 'en,null',
],
$queryParams,
);
$url .= '&' . urldecode(http_build_query($queryParams));
return $this->http->get($url, [
'verify' => false,
]);
}
}

View File

@@ -0,0 +1,522 @@
<?php
namespace App\Services\Data\Tmdb;
use App\Actions\Titles\HandlesEncodedTmdbId;
use App\Models\Episode;
use App\Models\Person;
use App\Models\Season;
use App\Models\Title;
use App\Models\Video;
use Carbon\Carbon;
use Common\Settings\Settings;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class TransformData
{
use HandlesEncodedTmdbId;
public const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p/original';
public const BACKDROP_BASE_URI = 'https://image.tmdb.org/t/p/w1280';
public const PROFILE_BASE_URI = 'https://image.tmdb.org/t/p/w185';
public const YOUTUBE_BASE_URI = 'https://youtube.com/embed/';
public const SERIES_ENDED_STATUS = ['Ended', 'Canceled'];
public function __construct(private Settings $settings)
{
}
public function execute(array $tmdbMedia): Collection
{
return collect($tmdbMedia)->map(
fn($mediaItem) => $this->transformMediaItem($mediaItem),
);
}
public function transformMediaItem($mediaItem): ?array
{
$type = $this->getType($mediaItem);
if ($type === Person::MODEL_TYPE) {
return $this->transformPerson($mediaItem);
} elseif ($type === Episode::MODEL_TYPE) {
return $this->transformEpisode($mediaItem);
} elseif ($type === Season::MODEL_TYPE) {
return $this->transformSeason($mediaItem);
} else {
return $this->transformTitle($mediaItem, $type);
}
}
private function transformTitle($data, $type): array
{
$releaseKey =
$type === Title::MOVIE_TYPE ? 'release_date' : 'first_air_date';
$releaseDate = $this->getReleaseDate($releaseKey, $data);
$name = $this->getTitle($data);
$transformed = [
'id' => $this->encodeTmdbId('tmdb', $type, $data['id']),
'is_series' => $type === Title::SERIES_TYPE,
'model_type' => Title::MODEL_TYPE,
'poster' => $this->getPoster(Arr::get($data, 'poster_path')),
'release_date' => $releaseDate,
'cast' => $this->getCredits($data),
'name' => $name,
'description' => $data['overview'],
'tmdb_vote_count' => $data['vote_count'] ?? null,
'tmdb_vote_average' => isset($data['vote_average'])
? round($data['vote_average'], 1)
: null,
'original_title' => $this->getOriginalName($data),
'popularity' => Arr::get($data, 'popularity'),
'language' => Arr::get($data, 'original_language'),
'certification' => $this->getCertification($data, $type),
'countries' => $this->getCountries($data),
'tagline' => Arr::get($data, 'tagline'),
'budget' => Arr::get($data, 'budget') ?: null,
'revenue' => Arr::get($data, 'revenue') ?: null,
'runtime' => $this->getRuntime($data),
'videos' => $this->formatVideos(
Arr::get($data, 'videos.results', []),
),
'images' => $this->transformImages($data),
'backdrop' => $this->getBackdrop($data),
'genres' => $this->getGenres($data),
'imdb_id' => Arr::get($data, 'external_ids.imdb_id') ?: null,
'tmdb_id' => $data['id'],
'keywords' => $this->getKeywords($data),
'series_ended' => (bool) in_array(
Arr::get($data, 'status'),
self::SERIES_ENDED_STATUS,
),
'adult' => Arr::get($data, 'adult', false),
];
if (Arr::get($data, 'seasons')) {
$transformed['seasons'] = $this->getSeasons($data);
}
return $transformed;
}
/**
* Get US certification for title.
*
* @param array $data
* @param $type
* @return string|null
*/
private function getCertification($data, $type)
{
if ($type === Title::SERIES_TYPE) {
$firstKey = 'content_ratings.results';
$secondKey = 'rating';
} else {
$firstKey = 'release_dates.results';
$secondKey = 'release_dates.*.certification';
}
$rating = collect(Arr::get($data, $firstKey, []))
->where('iso_3166_1', 'US')
->pluck($secondKey)
->flatten()
->filter()
->first();
return $rating ? str_replace('tv-', '', strtolower($rating)) : null;
}
private function getCountries($data)
{
return array_map(
fn($country) => [
'name' => strtolower($country['iso_3166_1']),
'display_name' => $country['name'],
],
Arr::get($data, 'production_countries', []),
);
}
private function transformEpisode(array $data): array
{
$releaseDate = $this->getReleaseDate('air_date', $data);
$epNum = Arr::get($data, 'episode_number');
$sNum = Arr::get($data, 'season_number');
return [
'id' => $this->encodeTmdbId(
'tmdb',
Episode::MODEL_TYPE,
$data['id'],
),
'model_type' => Episode::MODEL_TYPE,
'poster' => $this->getPoster(Arr::get($data, 'still_path')),
'release_date' => $releaseDate,
'cast' => $this->getCredits($data),
// episode might not have a name sometimes, auto generate it in that case
'name' => $this->getTitle($data) ?? "Episode #$sNum.$epNum",
'description' => $data['overview'],
'tmdb_vote_count' => $data['vote_count'],
'tmdb_vote_average' => round($data['vote_average'], 1) ?: null,
'popularity' => Arr::get($data, 'popularity'),
'episode_number' => $epNum,
'season_number' => $sNum,
'runtime' => $data['runtime'] ?? null,
];
}
private function getPoster(?string $path): ?string
{
return $path ? self::TMDB_IMAGE_BASE . $path : null;
}
private function getBackdrop($data): ?string
{
$backdrop = Arr::get($data, 'backdrop_path');
return $backdrop ? self::BACKDROP_BASE_URI . $backdrop : null;
}
private function getSeasons(array $data): ?array
{
if (!Arr::has($data, 'seasons')) {
return null;
}
// skip "specials" season with number of "0"
$seasons = array_filter(
Arr::get($data, 'seasons', []),
fn($season) => $season['season_number'] !== 0,
);
return array_map(
fn($season) => Arr::except(
$this->transformSeason($season),
'model_type',
),
$seasons,
);
}
private function transformSeason(array $data): array
{
$releaseDate = $this->getReleaseDate(
['first_air_date', 'air_date'],
$data,
);
$transformedData = [
'id' => $this->encodeTmdbId(
'tmdb',
Season::MODEL_TYPE,
$data['id'],
),
'model_type' => Season::MODEL_TYPE,
'poster' => $this->getPoster(Arr::get($data, 'poster_path')),
'release_date' => $releaseDate,
'number' => Arr::get($data, 'season_number'),
];
if (isset($data['credits'])) {
$transformedData['cast'] = $this->getCredits($data);
}
if (isset($data['episodes'])) {
$transformedData['episodes'] = array_map(
fn($episode) => $this->transformEpisode($episode),
$data['episodes'],
);
}
return $transformedData;
}
private function getKeywords(array $data): array
{
$keywords = array_merge(
Arr::get($data, 'keywords.results', []),
Arr::get($data, 'keywords.keywords', []),
);
return array_map(
fn($keyword) => ['name' => $keyword['name']],
$keywords,
);
}
private function getRuntime(array $data): ?int
{
$runtime = Arr::get(
$data,
'runtime',
Arr::get($data, 'episode_run_time'),
);
if (is_array($runtime)) {
$runtime = !empty($runtime) ? min($runtime) : null;
}
return $runtime;
}
private function transformPerson($tmdbPerson): ?array
{
if (!isset($tmdbPerson['id'])) {
return null;
}
$syncCredits = $this->settings->get('content.automate_filmography');
$hasCredits = Arr::has($tmdbPerson, 'combined_credits') && $syncCredits;
$hasKnownForCredits =
Arr::has($tmdbPerson, 'known_for') && $syncCredits;
$data = [
'id' => $this->encodeTmdbId(
'tmdb',
Person::MODEL_TYPE,
$tmdbPerson['id'],
),
'name' => $tmdbPerson['name'],
'tmdb_id' => $tmdbPerson['id'],
'imdb_id' => Arr::get($tmdbPerson, 'imdb_id'),
'gender' => $this->transformGender(Arr::get($tmdbPerson, 'gender')),
'poster' => $this->getPoster($tmdbPerson['profile_path']),
'model_type' => Person::MODEL_TYPE,
'adult' => Arr::get($tmdbPerson, 'adult', false),
'fully_synced' => Arr::get($tmdbPerson, 'fully_synced') ?: false,
'relation_data' => [
'character' => Arr::get($tmdbPerson, 'character') ?: null,
'order' => Arr::get($tmdbPerson, 'order', 0),
'department' => strtolower(
Arr::get($tmdbPerson, 'department', 'actors'),
),
'job' => strtolower(Arr::get($tmdbPerson, 'job', 'actor')),
],
];
// "known_for" credits will only be returned from "search" tmdb api call.
if (
!$hasCredits &&
$hasKnownForCredits &&
isset($tmdbPerson['known_for'][0])
) {
$data['primary_credit'] = $this->transformMediaItem(
$tmdbPerson['known_for'][0],
);
}
if ($hasCredits) {
$credits = array_merge(
Arr::get($tmdbPerson, 'combined_credits.cast'),
Arr::get($tmdbPerson, 'combined_credits.crew'),
);
$credits = array_map(function ($credit) {
$title = array_filter(
$this->transformMediaItem($credit),
fn($value) => !is_array($value),
);
$title['relation_data'] = [
'department' => strtolower(
Arr::get($credit, 'department', 'actors'),
),
'job' => strtolower(Arr::get($credit, 'job', 'actor')),
'character' => Arr::get($credit, 'character') ?: null,
'order' => Arr::get($credit, 'order', 0),
];
return $title;
}, $credits);
$credits = array_filter(
$credits,
fn($credit) => !Arr::get($credit, 'adult') ||
$this->settings->get('tmdb.includeAdult'),
);
$data['credits'] = $credits;
}
$optionalProps = [
'biography' => 'description',
'birthday' => 'birth_date',
'deathday' => 'death_date',
'place_of_birth' => 'birth_place',
'known_for_department' => 'known_for',
'popularity' => 'popularity',
];
// can't set these as "null" as some data might not be contained
// when getting people via movie/series filmography
foreach ($optionalProps as $tmdbKey => $localKey) {
if (Arr::has($tmdbPerson, $tmdbKey)) {
$data[$localKey] = $tmdbPerson[$tmdbKey];
}
}
return $data;
}
private function getCredits(array $tmdbTitle): array
{
// cast/crew from series, movies and episodes
$credits = array_merge(
Arr::get($tmdbTitle, 'credits.cast', []),
Arr::get($tmdbTitle, 'credits.crew', []),
Arr::get($tmdbTitle, 'crew', []),
Arr::get($tmdbTitle, 'guest_stars', []),
);
if ($createdBy = Arr::get($tmdbTitle, 'created_by')) {
$creators = array_map(function ($person) {
$person['job'] = 'creator';
$person['department'] = 'creators';
$person['known_for_department'] = 'creators';
$person['popularity'] = 3;
return $person;
}, $createdBy);
$credits = array_merge($credits, $creators);
}
$transformedCredits = array_map(
fn($person) => $this->transformPerson($person),
$credits,
);
return array_filter($transformedCredits);
}
/**
* @param int|null $gender
* @return null|string
*/
private function transformGender($gender)
{
if ($gender === 1) {
return 'female';
} elseif ($gender === 2) {
return 'male';
} else {
return null;
}
}
private function transformImages(array $tmdbTitle): array
{
$images = Arr::get($tmdbTitle, 'images.backdrops', []);
return array_map(
fn($image) => [
'type' => 'backdrop',
'source' => 'tmdb',
'url' => self::TMDB_IMAGE_BASE . $image['file_path'],
],
$images,
);
}
/**
* @param $tmdbTitle
* @return array
*/
private function getGenres($tmdbTitle)
{
return array_map(
fn($genre) => ['name' => $genre['name']],
Arr::get($tmdbTitle, 'genres', []),
);
}
public function formatVideos(array $videos): array
{
$videos = array_map(
fn($video) => [
'name' => trim($video['name']),
'src' => self::YOUTUBE_BASE_URI . $video['key'],
'type' => Video::VIDEO_TYPE_EMBED,
'origin' => 'tmdb',
'category' => strtolower(Arr::get($video, 'type', 'trailer')),
],
$videos,
);
// show trailers first
usort($videos, function ($a, $b) {
if ($a['category'] === 'trailer') {
return -1;
} elseif ($b['category'] === 'trailer') {
return 1;
} else {
return 0;
}
});
return $videos;
}
/**
* @param array $data
* @return string
*/
private function getType($data)
{
$hasSeasonNumber = Arr::get($data, 'season_number');
$hasEpisodeNumber = Arr::get($data, 'episode_number');
if ($hasEpisodeNumber && $hasSeasonNumber) {
return Episode::MODEL_TYPE;
} elseif ($hasSeasonNumber) {
return Season::MODEL_TYPE;
} elseif (
Arr::get($data, 'media_type') === 'person' ||
Arr::has($data, 'gender')
) {
return Person::MODEL_TYPE;
} elseif (Arr::has($data, 'first_air_date')) {
return Title::SERIES_TYPE;
} else {
return Title::MOVIE_TYPE;
}
}
private function getReleaseDate(string|array $key, array $data): ?Carbon
{
$potentials = !is_array($key) ? [$key] : $key;
foreach ($potentials as $potential) {
if (isset($data[$potential])) {
return Carbon::parse($data[$potential]);
}
}
return null;
}
/**
* @param array $tmdbTitle
* @return string|null
*/
private function getTitle($tmdbTitle)
{
if (isset($tmdbTitle['title'])) {
return $tmdbTitle['title'];
} elseif (isset($tmdbTitle['name'])) {
return $tmdbTitle['name'];
} else {
return null;
}
}
/**
* @param array $tmdbTitle
* @return string|null
*/
private function getOriginalName($tmdbTitle)
{
if (isset($tmdbTitle['original_title'])) {
return $tmdbTitle['original_title'];
} elseif (isset($tmdbTitle['original_name'])) {
return $tmdbTitle['original_name'];
} else {
return null;
}
}
}