Update Trakt support

This commit is contained in:
DariusIII
2026-06-19 23:22:16 +02:00
parent 50c7f27547
commit 8d1d238f5f
8 changed files with 400 additions and 84 deletions
+4
View File
@@ -224,7 +224,11 @@ FANARTTV_APIKEY=
GOOGLE_BOOKS_API_KEY=
ISBNDB_API_KEY=
OMDB_APIKEY=
# Trakt public metadata API client ID. OAuth tokens are not needed for TV/movie metadata lookups.
TRAKTTV_APIKEY=
TRAKTTV_TIMEOUT=30
TRAKTTV_RETRY_TIMES=3
TRAKTTV_RETRY_DELAY=100
# Temp paths for extraction
TEMP_UNRAR_PATH='/var/www/nntmux/resources/tmp/unrar/'
+14 -11
View File
@@ -189,25 +189,28 @@ class MovieService
$data['trailer']
);
}
$imdbId = (str_starts_with($data['ids']['imdb'], 'tt')) ? substr($data['ids']['imdb'], 2) : $data['ids']['imdb'];
$imdbId = (string) $data['ids']['imdb'];
$imdbId = str_starts_with($imdbId, 'tt') ? substr($imdbId, 2) : $imdbId;
$genres = $data['genres'] ?? [];
$genre = is_array($genres) ? implode(', ', $genres) : (string) $genres;
$cover = 0;
if (File::isFile($this->imgSavePath.$imdbId.'-cover.jpg')) {
$cover = 1;
}
return $this->update([
'genre' => implode(', ', $data['genres']),
'genre' => $genre,
'imdbid' => $this->checkTraktValue($imdbId),
'language' => $this->checkTraktValue($data['language']),
'plot' => $this->checkTraktValue($data['overview']),
'rating' => $this->checkTraktValue($data['rating']),
'tagline' => $this->checkTraktValue($data['tagline']),
'title' => $this->checkTraktValue($data['title']),
'tmdbid' => $this->checkTraktValue($data['ids']['tmdb']),
'traktid' => $this->checkTraktValue($data['ids']['trakt']),
'trailer' => $this->checkTraktValue($data['trailer']),
'language' => $this->checkTraktValue($data['language'] ?? ''),
'plot' => $this->checkTraktValue($data['overview'] ?? ''),
'rating' => $this->checkTraktValue($data['rating'] ?? ''),
'tagline' => $this->checkTraktValue($data['tagline'] ?? ''),
'title' => $this->checkTraktValue($data['title'] ?? ''),
'tmdbid' => $this->checkTraktValue($data['ids']['tmdb'] ?? 0),
'traktid' => $this->checkTraktValue($data['ids']['trakt'] ?? 0),
'trailer' => $this->checkTraktValue($data['trailer'] ?? ''),
'cover' => $cover,
'year' => $this->checkTraktValue($data['year']),
'year' => $this->checkTraktValue($data['year'] ?? ''),
]);
}
+155 -43
View File
@@ -18,7 +18,11 @@ use Illuminate\Support\Str;
* A modern service wrapper for the Trakt.tv API.
* Provides methods to fetch movie and TV show information.
*
* API Documentation: https://trakt.docs.apiary.io/
* API Documentation: https://docs.trakt.tv/
*
* This wrapper intentionally uses public metadata endpoints only. OAuth should
* only be added if a future feature needs user-scoped Trakt data such as
* watchlists, watched history, private lists, ratings, or sync endpoints.
*/
class TraktService
{
@@ -30,6 +34,8 @@ class TraktService
protected const ID_TYPES = ['imdb', 'tmdb', 'trakt', 'tvdb'];
protected const SEARCH_TYPES = ['movie', 'show', 'episode', 'person', 'list'];
/** Minimum seconds between requests to avoid rate limiting (Trakt/Cloudflare). */
protected const THROTTLE_SECONDS = 1;
@@ -46,7 +52,7 @@ class TraktService
public function __construct(?string $clientId = null)
{
$this->clientId = $clientId ?? (string) config('nntmux_api.trakttv_api_key', '');
$this->clientId = trim($clientId ?? (string) config('nntmux_api.trakttv_api_key', ''));
$this->timeout = (int) config('nntmux_api.trakttv_timeout', 30);
$this->retryTimes = (int) config('nntmux_api.trakttv_retry_times', 3);
$this->retryDelay = (int) config('nntmux_api.trakttv_retry_delay', 100);
@@ -84,8 +90,9 @@ class TraktService
protected function getHeaders(): array
{
return [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'trakt-api-version' => self::API_VERSION,
'trakt-api-version' => (string) self::API_VERSION,
'trakt-api-key' => $this->clientId,
];
}
@@ -232,10 +239,7 @@ class TraktService
return null;
}
$extended = match ($extended) {
'aliases', 'full', 'full,aliases' => $extended,
default => 'min',
};
$extended = $this->normalizeExtended($extended, ['min', 'full', 'aliases', 'full,aliases']);
// Format the show ID based on type for cache key and API call
$formattedId = $this->formatShowIdForApi($showId, $idType);
@@ -243,7 +247,7 @@ class TraktService
return null;
}
$cacheKey = "trakt_episode_{$idType}_{$showId}_{$season}_{$episode}_{$extended}";
$cacheKey = "trakt_episode_{$idType}_{$formattedId}_{$season}_{$episode}_{$extended}";
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($formattedId, $season, $episode, $extended) {
return $this->get("shows/{$formattedId}/seasons/{$season}/episodes/{$episode}", [
@@ -272,8 +276,9 @@ class TraktService
$idPriority = ['trakt', 'tmdb', 'tvdb', 'imdb'];
foreach ($idPriority as $idType) {
if (! empty($ids[$idType]) && $ids[$idType] > 0) {
$result = $this->getEpisodeSummary($ids[$idType], $season, $episode, $extended, $idType);
$id = $ids[$idType] ?? null;
if ($this->isUsableExternalId($id, $idType)) {
$result = $this->getEpisodeSummary($id, $season, $episode, $extended, $idType);
if ($result !== null) {
return $result;
}
@@ -297,10 +302,16 @@ class TraktService
return null;
}
return match ($idType) {
'imdb' => is_numeric($id) ? 'tt'.str_pad((string) $id, 8, '0', STR_PAD_LEFT) : (string) $id,
'trakt', 'tmdb', 'tvdb' => (string) $id,
};
$formattedId = trim((string) $id);
if ($formattedId === '') {
return null;
}
if ($idType === 'imdb') {
return $this->formatImdbId($formattedId);
}
return $formattedId;
}
/**
@@ -387,8 +398,12 @@ class TraktService
{
if (empty($startDate)) {
$startDate = date('Y-m-d');
} elseif (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
return null;
}
$days = max(1, min($days, 33));
$cacheKey = "trakt_calendar_{$startDate}_{$days}";
return Cache::remember($cacheKey, now()->addHours(6), function () use ($startDate, $days) {
@@ -409,12 +424,16 @@ class TraktService
return null;
}
$extended = $extended === 'full' ? 'full' : 'min';
$slug = Str::slug($movie);
$cacheKey = "trakt_movie_{$slug}_{$extended}";
$extended = $this->normalizeExtended($extended, ['min', 'full']);
$movieId = $this->formatMediaId($movie);
if ($movieId === null) {
return null;
}
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($slug, $extended) {
return $this->get("movies/{$slug}", ['extended' => $extended]);
$cacheKey = 'trakt_movie_'.md5($movieId).'_'.$extended;
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($movieId, $extended) {
return $this->get("movies/{$movieId}", ['extended' => $extended]);
});
}
@@ -447,24 +466,35 @@ class TraktService
return null;
}
// Format IMDB ID with 'tt' prefix if needed
if ($idType === 'imdb' && is_numeric($id)) {
$id = 'tt'.$id;
$formattedId = trim((string) $id);
if ($formattedId === '') {
return null;
}
if ($idType === 'imdb') {
$formattedId = $this->formatImdbId($formattedId);
if ($formattedId === null) {
return null;
}
}
$params = [
'id_type' => $idType,
'id' => $id,
'id' => $formattedId,
];
if (! empty($mediaType)) {
if ($mediaType !== '' && ! in_array($mediaType, self::SEARCH_TYPES, true)) {
return null;
}
if ($mediaType !== '') {
$params['type'] = $mediaType;
}
$cacheKey = 'trakt_search_'.$idType.'_'.preg_replace('/[^a-zA-Z0-9_-]/', '_', (string) $id).'_'.($mediaType ?: 'all');
$cacheKey = 'trakt_search_'.$idType.'_'.preg_replace('/[^a-zA-Z0-9_-]/', '_', $formattedId).'_'.($params['type'] ?? 'all');
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($id, $idType, $mediaType) {
return $this->get('search/'.$idType.'/'.$id, ['type' => $mediaType ?: null]);
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($formattedId, $idType, $params) {
return $this->get('search/'.$idType.'/'.$formattedId, array_intersect_key($params, ['type' => true]));
});
}
@@ -477,7 +507,8 @@ class TraktService
*/
public function searchShows(string $query, string $type = 'show'): ?array
{
if (empty($query)) {
$query = trim($query);
if ($query === '' || ! in_array($type, self::SEARCH_TYPES, true)) {
return null;
}
@@ -504,12 +535,16 @@ class TraktService
return null;
}
$extended = $extended === 'full' ? 'full' : 'min';
$slug = Str::slug($showKey);
$cacheKey = "trakt_show_{$slug}_{$extended}";
$extended = $this->normalizeExtended($extended, ['min', 'full']);
$showId = $this->formatMediaId($showKey);
if ($showId === null) {
return null;
}
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($slug, $extended) {
return $this->get("shows/{$slug}", ['extended' => $extended]);
$cacheKey = 'trakt_show_'.md5($showId).'_'.$extended;
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($showId, $extended) {
return $this->get("shows/{$showId}", ['extended' => $extended]);
});
}
@@ -522,15 +557,16 @@ class TraktService
*/
public function getShowSeasons(string $show, string $extended = 'full'): ?array
{
if (empty($show)) {
$showId = $this->formatMediaId($show);
if ($showId === null) {
return null;
}
$slug = Str::slug($show);
$cacheKey = "trakt_seasons_{$slug}_{$extended}";
$extended = $this->normalizeExtended($extended, ['min', 'full', 'episodes', 'full,episodes']);
$cacheKey = 'trakt_seasons_'.md5($showId).'_'.$extended;
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($slug, $extended) {
return $this->get("shows/{$slug}/seasons", ['extended' => $extended]);
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($showId, $extended) {
return $this->get("shows/{$showId}/seasons", ['extended' => $extended]);
});
}
@@ -544,15 +580,16 @@ class TraktService
*/
public function getSeasonEpisodes(string $show, int $season, string $extended = 'full'): ?array
{
if (empty($show)) {
$showId = $this->formatMediaId($show);
if ($showId === null || $season < 0) {
return null;
}
$slug = Str::slug($show);
$cacheKey = "trakt_season_episodes_{$slug}_{$season}_{$extended}";
$extended = $this->normalizeExtended($extended, ['min', 'full']);
$cacheKey = 'trakt_season_episodes_'.md5($showId)."_{$season}_{$extended}";
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($slug, $season, $extended) {
return $this->get("shows/{$slug}/seasons/{$season}", ['extended' => $extended]);
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($showId, $season, $extended) {
return $this->get("shows/{$showId}/seasons/{$season}", ['extended' => $extended]);
});
}
@@ -565,6 +602,8 @@ class TraktService
*/
public function getTrendingShows(int $limit = 10, string $extended = 'full'): ?array
{
$limit = $this->normalizeLimit($limit);
$extended = $this->normalizeExtended($extended, ['min', 'full']);
$cacheKey = "trakt_trending_shows_{$limit}_{$extended}";
return Cache::remember($cacheKey, now()->addHours(1), function () use ($limit, $extended) {
@@ -584,6 +623,8 @@ class TraktService
*/
public function getTrendingMovies(int $limit = 10, string $extended = 'full'): ?array
{
$limit = $this->normalizeLimit($limit);
$extended = $this->normalizeExtended($extended, ['min', 'full']);
$cacheKey = "trakt_trending_movies_{$limit}_{$extended}";
return Cache::remember($cacheKey, now()->addHours(1), function () use ($limit, $extended) {
@@ -603,6 +644,8 @@ class TraktService
*/
public function getPopularShows(int $limit = 10, string $extended = 'full'): ?array
{
$limit = $this->normalizeLimit($limit);
$extended = $this->normalizeExtended($extended, ['min', 'full']);
$cacheKey = "trakt_popular_shows_{$limit}_{$extended}";
return Cache::remember($cacheKey, now()->addHours(6), function () use ($limit, $extended) {
@@ -622,6 +665,8 @@ class TraktService
*/
public function getPopularMovies(int $limit = 10, string $extended = 'full'): ?array
{
$limit = $this->normalizeLimit($limit);
$extended = $this->normalizeExtended($extended, ['min', 'full']);
$cacheKey = "trakt_popular_movies_{$limit}_{$extended}";
return Cache::remember($cacheKey, now()->addHours(6), function () use ($limit, $extended) {
@@ -642,6 +687,16 @@ class TraktService
Cache::forget("trakt_show_{$slug}_full");
Cache::forget("trakt_seasons_{$slug}_full");
Cache::forget("trakt_seasons_{$slug}_min");
$showId = $this->formatMediaId($show);
if ($showId !== null) {
Cache::forget('trakt_show_'.md5($showId).'_min');
Cache::forget('trakt_show_'.md5($showId).'_full');
Cache::forget('trakt_seasons_'.md5($showId).'_min');
Cache::forget('trakt_seasons_'.md5($showId).'_full');
Cache::forget('trakt_seasons_'.md5($showId).'_episodes');
Cache::forget('trakt_seasons_'.md5($showId).'_full,episodes');
}
}
/**
@@ -652,5 +707,62 @@ class TraktService
$slug = Str::slug($movie);
Cache::forget("trakt_movie_{$slug}_min");
Cache::forget("trakt_movie_{$slug}_full");
$movieId = $this->formatMediaId($movie);
if ($movieId !== null) {
Cache::forget('trakt_movie_'.md5($movieId).'_min');
Cache::forget('trakt_movie_'.md5($movieId).'_full');
}
}
/**
* Normalize IMDb IDs for Trakt endpoints without padding away meaningful
* leading zeroes (for example, tt0137523 must stay tt0137523).
*/
protected function formatImdbId(string $id): ?string
{
if (preg_match('/^tt\d{6,}$/i', $id) === 1) {
return $id;
}
$digits = preg_replace('/\D/', '', $id);
return $digits !== null && preg_match('/^\d{6,}$/', $digits) === 1 ? 'tt'.$digits : null;
}
protected function formatMediaId(int|string $id): ?string
{
$id = trim((string) $id);
if ($id === '') {
return null;
}
return str_starts_with(strtolower($id), 'tt') ? $this->formatImdbId($id) : Str::slug($id);
}
/**
* @param list<string> $allowed
*/
protected function normalizeExtended(string $extended, array $allowed): string
{
return in_array($extended, $allowed, true) ? $extended : $allowed[0];
}
protected function normalizeLimit(int $limit): int
{
return max(1, min($limit, 100));
}
protected function isUsableExternalId(mixed $id, string $idType): bool
{
if ($id === null || $id === '') {
return false;
}
if ($idType === 'imdb') {
return $this->formatImdbId((string) $id) !== null;
}
return is_numeric($id) ? (int) $id > 0 : trim((string) $id) !== '';
}
}
@@ -13,10 +13,10 @@ use App\Services\TvProcessing\TvProcessingResult;
*/
class TraktPipe extends AbstractTvProviderPipe
{
// Video type and source constants (matching Videos class protected constants)
// Video type and source constants (matching AbstractTvProvider constants)
private const TYPE_TV = 0;
private const SOURCE_TRAKT = 5;
private const SOURCE_TRAKT = 4;
protected int $priority = 50;
@@ -520,7 +520,7 @@ abstract class AbstractTvProvider extends BaseVideoProvider
$following = '[^a-z0-9]([(|\[]\w+[)|\]]\s)*?(\d\d-\d\d|\d{1,3}x\d{2,3}|\(?(19|20)\d{2}\)?|(480|720|1080|2160)[ip]|AAC2?|BD-?Rip|Blu-?Ray|D0?\d|DD5|DiVX|DLMux|DTS|DVD(-?Rip)?|E\d{2,3}|[HX][\-_. ]?26[45]|ITA(-ENG)?|HEVC|[HPS]DTV|PROPER|REPACK|Season|Episode|S\d+[^a-z0-9]?((E\d+)[abr]?)*|WEB[\-_. ]?(DL|Rip)|XViD)[^a-z0-9]?';
// Handle fansub/release group prefixes like [SubsPlease], [Erai-raws], [ASW], etc.
$cleanRelname = preg_replace('/^\[[^\]]+\][_\s]*/i', '', $relname);
$cleanRelname = preg_replace('/^\[[^]]+][_\s]*/i', '', $relname);
if (preg_match('/^([^a-z0-9]{2,}|(sample|proof|repost)-)(?P<name>[\w .\-!&]+?)'.$following.'/i', $cleanRelname, $hits)) {
$showName = $hits['name'];
@@ -888,10 +888,10 @@ abstract class AbstractTvProvider extends BaseVideoProvider
$required = ['name', 'season_number', 'episode_number', 'air_date', 'overview'];
break;
case 'traktS':
$required = ['title', 'ids', 'overview', 'first_aired', 'airs', 'country'];
$required = ['title', 'ids'];
break;
case 'traktE':
$required = ['title', 'season', 'number', 'overview', 'first_aired'];
$required = ['title', 'season', 'number'];
break;
}
@@ -31,12 +31,12 @@ class TraktProvider extends AbstractTvProvider
/**
* The URL to grab the TV fanart.
*/
public string $fanartUrl;
public string $fanartUrl = '';
/**
* The localized (network airing) timezone of the show.
*/
private string $localizedTZ;
private string $localizedTZ = '';
/**
* @throws \Exception
@@ -278,6 +278,12 @@ class TraktProvider extends AbstractTvProvider
{
$return = false;
if ($videoId > 0 && (int) $series === -1 && (int) $episode === -1) {
$this->addEpisodesForShow($siteId, $videoId);
return false;
}
// If we have a video ID, get all available external IDs for fallback
if ($videoId > 0) {
$ids = $this->getAllSiteIdsFromVideoID($videoId);
@@ -352,15 +358,20 @@ class TraktProvider extends AbstractTvProvider
sleep(1);
foreach ($response as $show) {
if (! is_bool($show)) {
if (is_array($show) && isset($show['show']) && is_array($show['show'])) {
$showTitle = (string) ($show['show']['title'] ?? '');
if ($showTitle === '') {
continue;
}
// Check for exact title match first and then terminate if found
if ($show['show']['title'] === $name) {
if (strcasecmp($showTitle, $name) === 0) {
$highest = $show;
break;
}
// Check each show title for similarity and then find the highest similar value
$matchPercent = $this->checkMatch($show['show']['title'], $name, self::MATCH_PROBABILITY);
$matchPercent = $this->checkMatch($showTitle, $name, self::MATCH_PROBABILITY);
// If new match has a higher percentage, set as new matched title
if ($matchPercent > $highestMatch) {
@@ -369,7 +380,7 @@ class TraktProvider extends AbstractTvProvider
}
}
}
if ($highest !== null) {
if ($highest !== null && ! empty($highest['show']['ids']['trakt'])) {
$fullShow = $this->client->getShowSummary($highest['show']['ids']['trakt']);
if ($this->checkRequiredAttr($fullShow, 'traktS')) {
$return = $this->formatShowInfo($fullShow);
@@ -387,31 +398,39 @@ class TraktProvider extends AbstractTvProvider
*/
public function formatShowInfo(mixed $show): array
{
preg_match('/tt(?P<imdbid>\d{6,})$/i', (string) ($show['ids']['imdb'] ?? ''), $imdb);
$this->posterUrl = $show['images']['poster']['thumb'] ?? '';
$this->fanartUrl = $show['images']['fanart']['thumb'] ?? '';
$this->localizedTZ = $show['airs']['timezone'] ?? '';
if (! is_array($show)) {
return [];
}
$ids = is_array($show['ids'] ?? null) ? $show['ids'] : [];
$airs = is_array($show['airs'] ?? null) ? $show['airs'] : [];
$images = is_array($show['images'] ?? null) ? $show['images'] : [];
preg_match('/tt(?P<imdbid>\d{6,})$/i', (string) ($ids['imdb'] ?? ''), $imdb);
$this->posterUrl = is_array($images['poster'] ?? null) ? (string) ($images['poster']['thumb'] ?? '') : '';
$this->fanartUrl = is_array($images['fanart'] ?? null) ? (string) ($images['fanart']['thumb'] ?? '') : '';
$this->localizedTZ = (string) ($airs['timezone'] ?? '');
$imdbId = (string) ($imdb['imdbid'] ?? '');
$tvdbId = $show['ids']['tvdb'] ?? 0;
$tvdbId = (int) ($ids['tvdb'] ?? 0);
// Look up TVMaze ID using TVDB or IMDB
$tvmazeId = $this->lookupTvMazeId($tvdbId, $imdbId);
return [
'type' => parent::TYPE_TV,
'title' => $show['title'],
'summary' => $show['overview'],
'started' => Carbon::parse($show['first_aired'], $this->localizedTZ)->format('Y-m-d'),
'publisher' => $show['network'],
'country' => $show['country'],
'title' => (string) ($show['title'] ?? ''),
'summary' => (string) ($show['overview'] ?? ''),
'started' => $this->formatTraktDate($show['first_aired'] ?? null),
'publisher' => (string) ($show['network'] ?? ''),
'country' => (string) ($show['country'] ?? ''),
'source' => parent::SOURCE_TRAKT,
'imdb' => $imdbId,
'tvdb' => $tvdbId,
'trakt' => $show['ids']['trakt'],
'tvrage' => $show['ids']['tvrage'] ?? 0,
'trakt' => (int) ($ids['trakt'] ?? 0),
'tvrage' => (int) ($ids['tvrage'] ?? 0),
'tvmaze' => $tvmazeId,
'tmdb' => $show['ids']['tmdb'] ?? 0,
'tmdb' => (int) ($ids['tmdb'] ?? 0),
'aliases' => isset($show['aliases']) && ! empty($show['aliases']) ? $show['aliases'] : '',
'localzone' => $this->localizedTZ,
];
@@ -460,14 +479,63 @@ class TraktProvider extends AbstractTvProvider
*/
public function formatEpisodeInfo(mixed $episode): array
{
$season = (int) ($episode['season'] ?? 0);
$episodeNumber = (int) ($episode['number'] ?? ($episode['episode'] ?? 0));
return [
'title' => (string) $episode['title'],
'series' => (int) $episode['season'],
'title' => (string) ($episode['title'] ?? ''),
'series' => $season,
// Prefer the Trakt 'number' field for episode number, fall back to 'episode' if present
'episode' => (int) ($episode['number'] ?? ($episode['episode'] ?? 0)),
'se_complete' => 'S'.sprintf('%02d', $episode['season']).'E'.sprintf('%02d', ($episode['number'] ?? ($episode['episode'] ?? 0))),
'firstaired' => Carbon::parse($episode['first_aired'], $this->localizedTZ)->format('Y-m-d'),
'summary' => (string) $episode['overview'],
'episode' => $episodeNumber,
'se_complete' => 'S'.sprintf('%02d', $season).'E'.sprintf('%02d', $episodeNumber),
'firstaired' => $this->formatTraktDate($episode['first_aired'] ?? null),
'summary' => (string) ($episode['overview'] ?? ''),
];
}
private function addEpisodesForShow(int|string $siteId, int $videoId): void
{
$seasons = $this->client->getShowSeasons((string) $siteId, 'episodes');
if (! is_array($seasons)) {
return;
}
foreach ($seasons as $seasonInfo) {
if (! is_array($seasonInfo)) {
continue;
}
$seasonNumber = (int) ($seasonInfo['number'] ?? -1);
if ($seasonNumber < 0) {
continue;
}
$episodes = is_array($seasonInfo['episodes'] ?? null)
? $seasonInfo['episodes']
: $this->client->getSeasonEpisodes((string) $siteId, $seasonNumber, 'full');
if (! is_array($episodes)) {
continue;
}
foreach ($episodes as $singleEpisode) {
if (is_array($singleEpisode) && $this->checkRequiredAttr($singleEpisode, 'traktE')) {
$this->addEpisode($videoId, $this->formatEpisodeInfo($singleEpisode));
}
}
}
}
private function formatTraktDate(mixed $date): string
{
if (empty($date)) {
return '';
}
try {
return Carbon::parse((string) $date, $this->localizedTZ !== '' ? $this->localizedTZ : null)->format('Y-m-d');
} catch (\Throwable) {
return '';
}
}
}
+3
View File
@@ -11,4 +11,7 @@ return [
'imdbapi_dev_cooldown_seconds' => env('IMDBAPI_DEV_COOLDOWN_SECONDS', 300),
'omdb_api_key' => env('OMDB_APIKEY', ''),
'trakttv_api_key' => env('TRAKTTV_APIKEY', ''),
'trakttv_timeout' => env('TRAKTTV_TIMEOUT', 30),
'trakttv_retry_times' => env('TRAKTTV_RETRY_TIMES', 3),
'trakttv_retry_delay' => env('TRAKTTV_RETRY_DELAY', 100),
];
+126
View File
@@ -50,4 +50,130 @@ class TraktServiceTest extends TestCase
&& $request['extended'] === 'full';
});
}
public function test_public_metadata_requests_use_api_key_headers_without_oauth(): void
{
Http::fake([
'https://api.trakt.tv/movies/tt0137523*' => Http::response([
'title' => 'Fight Club',
'ids' => [
'imdb' => 'tt0137523',
],
], 200),
]);
$summary = $this->service->getMovieSummary('tt0137523', 'full');
$this->assertNotNull($summary);
Http::assertSent(function ($request) {
$headers = array_change_key_case($request->headers(), CASE_LOWER);
return ($headers['trakt-api-key'][0] ?? null) === 'test_trakt_key'
&& ($headers['trakt-api-version'][0] ?? null) === '2'
&& ! array_key_exists('authorization', $headers);
});
}
public function test_search_by_id_formats_imdb_id_without_padding(): void
{
Http::fake([
'https://api.trakt.tv/search/imdb/tt0137523*' => Http::response([
[
'type' => 'movie',
'movie' => [
'title' => 'Fight Club',
],
],
], 200),
]);
$results = $this->service->searchById('0137523', 'imdb', 'movie');
$this->assertIsArray($results);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'https://api.trakt.tv/search/imdb/tt0137523')
&& ! str_contains($request->url(), 'tt00137523')
&& $request['type'] === 'movie';
});
}
public function test_episode_summary_with_imdb_id_preserves_leading_zeroes(): void
{
Http::fake([
'https://api.trakt.tv/shows/tt0137523/seasons/1/episodes/1*' => Http::response([
'title' => 'Pilot',
'season' => 1,
'number' => 1,
], 200),
]);
$episode = $this->service->getEpisodeSummary('0137523', 1, 1, 'full', 'imdb');
$this->assertIsArray($episode);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'https://api.trakt.tv/shows/tt0137523/seasons/1/episodes/1')
&& ! str_contains($request->url(), 'tt00137523')
&& $request['extended'] === 'full';
});
}
public function test_episode_summary_fallback_accepts_tt_prefixed_imdb_id(): void
{
Http::fake([
'https://api.trakt.tv/shows/tt0137523/seasons/1/episodes/1*' => Http::response([
'title' => 'Pilot',
'season' => 1,
'number' => 1,
], 200),
]);
$episode = $this->service->getEpisodeSummaryWithFallback(['imdb' => 'tt0137523'], 1, 1, 'full');
$this->assertIsArray($episode);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'https://api.trakt.tv/shows/tt0137523/seasons/1/episodes/1')
&& $request['extended'] === 'full';
});
}
public function test_search_by_id_omits_empty_type_query_parameter(): void
{
Http::fake([
'https://api.trakt.tv/search/trakt/1390*' => Http::response([], 200),
]);
$this->service->searchById(1390, 'trakt');
Http::assertSent(function ($request) {
parse_str(parse_url($request->url(), PHP_URL_QUERY) ?: '', $query);
return str_contains($request->url(), 'https://api.trakt.tv/search/trakt/1390')
&& ! array_key_exists('type', $query);
});
}
public function test_get_show_seasons_supports_episode_extended_public_endpoint(): void
{
Http::fake([
'https://api.trakt.tv/shows/1390/seasons*' => Http::response([
[
'number' => 1,
'episodes' => [],
],
], 200),
]);
$seasons = $this->service->getShowSeasons('1390', 'episodes');
$this->assertIsArray($seasons);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'https://api.trakt.tv/shows/1390/seasons')
&& $request['extended'] === 'episodes';
});
}
}