From 8ec0b3f6ba9d6a374b7a8d95b83194e015bc354d Mon Sep 17 00:00:00 2001 From: DariusIII Date: Fri, 19 Dec 2025 14:22:14 +0100 Subject: [PATCH] Move AniDB processing to AnimeProcessing completely --- Blacklight/processing/post/AniDB.php | 442 --------------------------- app/Services/AnimeProcessor.php | 441 +++++++++++++++++++++++++- tests/Unit/AniDBParseTest.php | 36 +-- tests/Unit/AniDBProcessingTest.php | 17 +- 4 files changed, 458 insertions(+), 478 deletions(-) delete mode 100755 Blacklight/processing/post/AniDB.php diff --git a/Blacklight/processing/post/AniDB.php b/Blacklight/processing/post/AniDB.php deleted file mode 100755 index 6fdbf0cd8..000000000 --- a/Blacklight/processing/post/AniDB.php +++ /dev/null @@ -1,442 +0,0 @@ - anidbid to reduce repeat queries within one run. - * - * @var array - */ - private array $titleCache = []; - - /** - * Simple cache of looked up titles -> anilist_id to reduce repeat queries within one run. - * - * @var array - */ - private array $anilistIdCache = []; - - /** - * @throws \Exception - */ - public function __construct() - { - $this->echooutput = (bool) config('nntmux.echocli'); - $this->palist = new PaList; - $this->colorCli = new ColorCLI; - - $quantity = (int) Settings::settingValue('maxanidbprocessed'); - $this->aniqty = $quantity > 0 ? $quantity : 100; - $this->status = null; - } - - /** - * Queues anime releases for processing. - * - * @param string $groupID (Optional) ID of a group to work on. - * @param string $guidChar (Optional) First letter of a release GUID to use to get work. - * - * @throws \Exception - */ - public function processAnimeReleases(string $groupID = '', string $guidChar = ''): void - { - $query = Release::query() - ->whereNull('anidbid') - ->where('categories_id', Category::TV_ANIME); - - if ($guidChar !== '') { - $query->where('leftguid', 'like', $guidChar.'%'); - } - - if ($groupID !== '') { - $query->where('groups_id', $groupID); - } - - $results = $query->orderByDesc('postdate') - ->limit($this->aniqty) - ->get(); - - if ($results->count() > 0) { - // AniList rate limiting is handled internally in PopulateAniList - - foreach ($results as $release) { - $matched = $this->matchAnimeRelease($release); - if ($matched === false) { - // Persist status so we do not keep retrying hopeless releases immediately. - Release::query()->where('id', $release->id)->update(['anidbid' => $this->status]); - } - } - } else { - $this->colorCli->info('No anidb releases to process.'); - } - } - - /** - * AniList doesn't support episode lookups, so this method is deprecated. - * Kept for compatibility but returns empty array. - */ - private function checkAniDBInfo(int $anidbId, int $episode = -1): array - { - // AniList doesn't support episode lookups - return []; - } - - /** - * Extracts anime title from release searchname. - * Returns ['title' => string] on success else empty array. - * Note: AniList doesn't support episode lookups, so we only extract the title. - */ - private function extractTitleEpisode(string $cleanName = ''): array - { - // Fix UTF-8 encoding issues (double-encoding, corrupted sequences) - $s = $this->fixEncoding($cleanName); - - // Normalize common separators - $s = str_replace(['_', '.'], ' ', $s); - $s = preg_replace('/\s+/', ' ', (string) $s); - $s = trim((string) $s); - - // Strip leading group tags like [Group] - $s = preg_replace('/^(?:\[[^\]]+\]\s*)+/', '', $s); - $s = trim((string) $s); - - // Remove language codes and tags (before extracting title) - // Common language tags: [ENG], [JAP], [SUB], [DUB], [MULTI], etc. - $s = preg_replace('/\[(?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\]/i', ' ', $s); - $s = preg_replace('/\((?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\)/i', ' ', $s); - - // Remove episode patterns and extract title - $title = ''; - - // Try to extract title by removing episode patterns - // 1) Look for " S01E01" or " S1E1" pattern - if (preg_match('/\sS\d+E\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) { - $title = substr($s, 0, (int) $m[0][1]); - } - // 2) Look for " 1x18" or " 2x05" pattern (season x episode) - elseif (preg_match('/\s\d+x\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) { - $title = substr($s, 0, (int) $m[0][1]); - } - // 3) Look for " - NNN" and extract title before it - elseif (preg_match('/\s-\s*(\d{1,3})\b/', $s, $m, PREG_OFFSET_CAPTURE)) { - $title = substr($s, 0, (int) $m[0][1]); - } - // 4) If not found, look for " E0*NNN" or " Ep NNN" - elseif (preg_match('/\sE(?:p(?:isode)?)?\s*0*(\d{1,3})\b/i', $s, $m, PREG_OFFSET_CAPTURE)) { - $title = substr($s, 0, (int) $m[0][1]); - } - // 4) Keywords Movie/OVA/Complete Series - elseif (preg_match('/\b(Movie|OVA|Complete Series|Complete|Full Series)\b/i', $s, $m, PREG_OFFSET_CAPTURE)) { - $title = substr($s, 0, (int) $m[0][1]); - } - // 5) BD/resolution releases: pick title before next bracket token - elseif (preg_match('/\[(?:BD|BDRip|BluRay|Blu-Ray|\d{3,4}[ipx]|HEVC|x264|x265|H264|H265)\]/i', $s, $m, PREG_OFFSET_CAPTURE)) { - $title = substr($s, 0, (int) $m[0][1]); - } else { - // No episode pattern found, use the whole string as title - $title = $s; - } - - $title = $this->cleanTitle((string) $title); - - if ($title === '') { - $this->status = self::PROC_EXTFAIL; - - return []; - } - - return ['title' => $title]; - } - - /** - * Fix UTF-8 encoding issues in strings (double-encoding, corrupted sequences). - */ - private function fixEncoding(string $text): string - { - // Remove common corrupted character sequences (encoding artifacts) - // Pattern: âÂ_Â, â Â, âÂ, etc. - $text = preg_replace('/âÂ[_\sÂ]*/u', '', $text); - $text = preg_replace('/Ã[¢Â©€£]/u', '', $text); - - // Remove standalone  characters (common encoding artifact) - $text = preg_replace('/Â+/u', '', $text); - - // Remove any remaining à sequences (encoding artifacts) - $text = preg_replace('/Ã[^\s]*/u', '', $text); - - // Try to detect and fix double-encoding issues - // Common patterns: é, Ã, etc. (UTF-8 interpreted as ISO-8859-1) - if (preg_match('/Ã[^\s]/u', $text)) { - // Try ISO-8859-1 -> UTF-8 conversion (common double-encoding fix) - $converted = @mb_convert_encoding($text, 'UTF-8', 'ISO-8859-1'); - if ($converted !== false && !preg_match('/Ã[^\s]/u', $converted)) { - $text = $converted; - } - } - - // Remove any remaining non-printable or control characters except spaces - $text = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/u', '', $text); - - // Normalize Unicode (NFD -> NFC) if available - if (function_exists('normalizer_normalize')) { - $text = normalizer_normalize($text, \Normalizer::FORM_C); - } - - // Final cleanup: remove any remaining isolated non-ASCII control-like characters - // This catches any remaining encoding artifacts - $text = preg_replace('/[\xC0-\xC1\xC2-\xC5]/u', '', $text); - - return $text; - } - - /** - * Strip stray separators, language codes, episode numbers, and other release tags from title. - */ - private function cleanTitle(string $title): string - { - // Fix encoding issues first - $title = $this->fixEncoding($title); - - // Remove all bracketed tags (language, quality, etc.) - $title = preg_replace('/\[[^\]]+\]/', ' ', $title); - - // Remove all parenthesized tags - $title = preg_replace('/\([^)]+\)/', ' ', $title); - - // Remove language codes (standalone or with separators) - $title = preg_replace('/\b(ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\b/i', ' ', $title); - - // Remove metadata words (JAV, Uncensored, Censored, etc.) - $title = preg_replace('/\b(JAV|Uncensored|Censored|Mosaic|Mosaic-less|HD|SD|FHD|UHD)\b/i', ' ', $title); - - // Remove date patterns (6-digit dates like 091919, 200101, etc.) - $title = preg_replace('/\b\d{6}\b/', ' ', $title); - - // Remove trailing numbers/underscores (like _01, 01, _001, etc.) - $title = preg_replace('/[-_]\s*\d{1,4}\s*$/i', '', $title); - $title = preg_replace('/\s+\d{1,4}\s*$/i', '', $title); - - // Remove episode patterns (including episode titles that follow) - // Remove " - 1x18 - Episode Title" or " - 1x18" patterns - $title = preg_replace('/\s*-\s*\d+x\d+.*$/i', '', $title); - // Remove " S01E01" or " S1E1" pattern - $title = preg_replace('/\s+S\d+E\d+.*$/i', '', $title); - // Remove " - NNN" or " - NNN - Episode Title" patterns - $title = preg_replace('/\s*-\s*\d{1,4}(?:\s*-\s*.*)?\s*$/i', '', $title); - $title = preg_replace('/\s*-\s*$/i', '', $title); - // Remove " E0*NNN" or " Ep NNN" patterns - $title = preg_replace('/\s+E(?:p(?:isode)?)?\s*0*\d{1,4}\s*$/i', '', $title); - - // Remove quality/resolution tags - $title = preg_replace('/\b(480p|720p|1080p|2160p|4K|BD|BDRip|BluRay|Blu-Ray|HEVC|x264|x265|H264|H265|WEB|WEBRip|DVDRip|TVRip)\b/i', ' ', $title); - - // Remove common release tags - $title = preg_replace('/\b(PROPER|REPACK|RIP|ISO|CRACK|BETA|ALPHA|FINAL|COMPLETE|FULL)\b/i', ' ', $title); - - // Remove volume/chapter markers - $title = preg_replace('/\s+Vol\.?\s*\d*\s*$/i', '', $title); - $title = preg_replace('/\s+Ch\.?\s*\d*\s*$/i', '', $title); - - // Remove trailing dashes and separators - $title = preg_replace('/\s*[-_]\s*$/', '', $title); - - // Normalize whitespace - $title = preg_replace('/\s+/', ' ', $title); - - return trim((string) $title); - } - - /** - * Retrieve AniList anime by searching for title. - * First checks local database, then searches AniList API if not found. - */ - private function getAnidbByName(string $searchName = ''): ?AnidbTitle - { - if ($searchName === '') { - return null; - } - - $key = strtolower($searchName); - - // Check cache first - if (isset($this->titleCache[$key])) { - return AnidbTitle::query()->select(['anidbid', 'title'])->where('anidbid', $this->titleCache[$key])->first(); - } - - // Try exact match in local database first - $exact = AnidbTitle::query()->whereRaw('LOWER(title) = ?', [$key])->select(['anidbid', 'title'])->first(); - if ($exact) { - $this->titleCache[$key] = (int) $exact->anidbid; - return $exact; - } - - // Try partial match in local database - $partial = AnidbTitle::query()->where('title', 'like', '%'.$searchName.'%')->select(['anidbid', 'title'])->first(); - if ($partial) { - $this->titleCache[$key] = (int) $partial->anidbid; - return $partial; - } - - // Not found locally, search AniList API - try { - $searchResults = $this->palist->searchAnime($searchName, 1); - if ($searchResults && !empty($searchResults)) { - $anilistData = $searchResults[0]; - $anilistId = $anilistData['id'] ?? null; - - if ($anilistId) { - // Use anilist_id as anidbid for new entries - $anidbid = AnidbInfo::query()->where('anilist_id', $anilistId)->value('anidbid'); - - if (!$anidbid) { - // Create new entry using anilist_id as anidbid - $anidbid = (int) $anilistId; - $this->palist->populateTable('info', $anilistId); - } - - // Get the title from database after insertion - $title = AnidbTitle::query() - ->where('anidbid', $anidbid) - ->where('lang', 'en') - ->value('title'); - - if ($title) { - $this->titleCache[$key] = $anidbid; - return AnidbTitle::query() - ->where('anidbid', $anidbid) - ->where('title', $title) - ->first(); - } - } - } - } catch (\Exception $e) { - if ($this->echooutput) { - $this->colorCli->error('AniList search failed: '.$e->getMessage()); - } - } - - return null; - } - - /** - * Matches the anime release to AniList Info; fetches remotely if needed. - * Note: AniList doesn't support episode lookups, so we only match by title. - * - * @throws \Exception - */ - private function matchAnimeRelease($release): bool - { - $matched = false; - $type = 'Local'; - - $cleanArr = $this->extractTitleEpisode((string) $release->searchname); - if (empty($cleanArr)) { - return false; - } - - $title = $cleanArr['title']; - - if ($this->echooutput) { - $this->colorCli->info('Looking Up: Title: '.$title); - } - - $anidbTitle = $this->getAnidbByName($title); - if (! $anidbTitle) { - // Try with spaces replaced by % for broader matching - $tmpName = preg_replace('/\s+/', '%', $title); - $anidbTitle = $this->getAnidbByName($tmpName); - } - - if ($anidbTitle && is_numeric($anidbTitle->anidbid) && (int) $anidbTitle->anidbid > 0) { - $anidbId = (int) $anidbTitle->anidbid; - - // Check if we need to update info from AniList - $info = AnidbInfo::query()->where('anidbid', $anidbId)->first(); - if (! $info || $this->shouldUpdateInfo($anidbId)) { - // Try to get anilist_id if we have it - $anilistId = $info->anilist_id ?? null; - - if (! $anilistId) { - // Search AniList for this title - try { - $searchResults = $this->palist->searchAnime($title, 1); - if ($searchResults && !empty($searchResults)) { - $anilistId = $searchResults[0]['id'] ?? null; - } - } catch (\Exception $e) { - if ($this->echooutput) { - $this->colorCli->warning('AniList search failed: '.$e->getMessage()); - } - } - } - - if ($anilistId) { - // Fetch remote info from AniList - $this->palist->populateTable('info', $anilistId); - $type = 'Remote'; - } - } - - $this->updateRelease($anidbId, (int) $release->id); - - if ($this->echooutput) { - $this->colorCli->headerOver('Matched '.$type.' AniList ID: '); - $this->colorCli->primary((string) $anidbId); - $this->colorCli->alternateOver(' Title: '); - $this->colorCli->primary($anidbTitle->title); - } - $matched = true; - } else { - $this->status = self::PROC_NOMATCH; - } - - return $matched; - } - - private function updateRelease(int $anidbId, int $relId): void - { - Release::query()->where('id', $relId)->update(['anidbid' => $anidbId]); - } - - /** - * Determine if we should attempt a remote AniDB info fetch (missing or stale > 1 week). - */ - private function shouldUpdateInfo(int $anidbId): bool - { - $info = AnidbInfo::query()->where('anidbid', $anidbId)->first(['updated']); - if ($info === null) { - return true; // no info yet - } - - return $info->updated < now()->subWeek(); - } -} diff --git a/app/Services/AnimeProcessor.php b/app/Services/AnimeProcessor.php index 40f71ad79..27f8b7ae0 100644 --- a/app/Services/AnimeProcessor.php +++ b/app/Services/AnimeProcessor.php @@ -1,23 +1,452 @@ anidbid to reduce repeat queries within one run. + * + * @var array + */ + private array $titleCache = []; + + /** + * Simple cache of looked up titles -> anilist_id to reduce repeat queries within one run. + * + * @var array + */ + private array $anilistIdCache = []; + + /** + * @throws \Exception + */ + public function __construct(bool $echooutput = true) { - $this->echooutput = $echooutput; + $this->echooutput = $echooutput && (bool) config('nntmux.echocli'); + $this->palist = new PaList; + $this->colorCli = new ColorCLI; + + $quantity = (int) Settings::settingValue('maxanidbprocessed'); + $this->aniqty = $quantity > 0 ? $quantity : 100; + $this->status = null; } + /** + * Main entry point for processing anime releases. + * + * @param string $groupID (Optional) ID of a group to work on. + * @param string $guidChar (Optional) First letter of a release GUID to use to get work. + * + * @throws \Exception + */ public function process(string $groupID = '', string $guidChar = ''): void { - if ((int) Settings::settingValue('lookupanidb') !== 0) { - (new AniDB)->processAnimeReleases($groupID, $guidChar); + if ((int) Settings::settingValue('lookupanidb') === 0) { + return; + } + + $this->processAnimeReleases($groupID, $guidChar); + } + + /** + * Queues anime releases for processing. + * + * @param string $groupID (Optional) ID of a group to work on. + * @param string $guidChar (Optional) First letter of a release GUID to use to get work. + * + * @throws \Exception + */ + public function processAnimeReleases(string $groupID = '', string $guidChar = ''): void + { + $query = Release::query() + ->whereNull('anidbid') + ->where('categories_id', Category::TV_ANIME); + + if ($guidChar !== '') { + $query->where('leftguid', 'like', $guidChar.'%'); + } + + if ($groupID !== '') { + $query->where('groups_id', $groupID); + } + + $results = $query->orderByDesc('postdate') + ->limit($this->aniqty) + ->get(); + + if ($results->count() > 0) { + // AniList rate limiting is handled internally in PopulateAniList + + foreach ($results as $release) { + $matched = $this->matchAnimeRelease($release); + if ($matched === false) { + // Persist status so we do not keep retrying hopeless releases immediately. + Release::query()->where('id', $release->id)->update(['anidbid' => $this->status]); + } + } + } else { + $this->colorCli->info('No anidb releases to process.'); } } + + /** + * Extracts anime title from release searchname. + * Returns ['title' => string] on success else empty array. + * Note: AniList doesn't support episode lookups, so we only extract the title. + */ + private function extractTitleEpisode(string $cleanName = ''): array + { + // Fix UTF-8 encoding issues (double-encoding, corrupted sequences) + $s = $this->fixEncoding($cleanName); + + // Normalize common separators + $s = str_replace(['_', '.'], ' ', $s); + $s = preg_replace('/\s+/', ' ', (string) $s); + $s = trim((string) $s); + + // Strip leading group tags like [Group] + $s = preg_replace('/^(?:\[[^\]]+\]\s*)+/', '', $s); + $s = trim((string) $s); + + // Remove language codes and tags (before extracting title) + // Common language tags: [ENG], [JAP], [SUB], [DUB], [MULTI], etc. + $s = preg_replace('/\[(?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\]/i', ' ', $s); + $s = preg_replace('/\((?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\)/i', ' ', $s); + + // Remove episode patterns and extract title + $title = ''; + + // Try to extract title by removing episode patterns + // 1) Look for " S01E01" or " S1E1" pattern + if (preg_match('/\sS\d+E\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) { + $title = substr($s, 0, (int) $m[0][1]); + } + // 2) Look for " 1x18" or " 2x05" pattern (season x episode) + elseif (preg_match('/\s\d+x\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) { + $title = substr($s, 0, (int) $m[0][1]); + } + // 3) Look for " - NNN" and extract title before it + elseif (preg_match('/\s-\s*(\d{1,3})\b/', $s, $m, PREG_OFFSET_CAPTURE)) { + $title = substr($s, 0, (int) $m[0][1]); + } + // 4) If not found, look for " E0*NNN" or " Ep NNN" + elseif (preg_match('/\sE(?:p(?:isode)?)?\s*0*(\d{1,3})\b/i', $s, $m, PREG_OFFSET_CAPTURE)) { + $title = substr($s, 0, (int) $m[0][1]); + } + // 4) Keywords Movie/OVA/Complete Series + elseif (preg_match('/\b(Movie|OVA|Complete Series|Complete|Full Series)\b/i', $s, $m, PREG_OFFSET_CAPTURE)) { + $title = substr($s, 0, (int) $m[0][1]); + } + // 5) BD/resolution releases: pick title before next bracket token + elseif (preg_match('/\[(?:BD|BDRip|BluRay|Blu-Ray|\d{3,4}[ipx]|HEVC|x264|x265|H264|H265)\]/i', $s, $m, PREG_OFFSET_CAPTURE)) { + $title = substr($s, 0, (int) $m[0][1]); + } else { + // No episode pattern found, use the whole string as title + $title = $s; + } + + $title = $this->cleanTitle((string) $title); + + if ($title === '') { + $this->status = self::PROC_EXTFAIL; + + return []; + } + + return ['title' => $title]; + } + + /** + * Fix UTF-8 encoding issues in strings (double-encoding, corrupted sequences). + */ + private function fixEncoding(string $text): string + { + // Remove common corrupted character sequences (encoding artifacts) + // Pattern: âÂ_Â, â Â, âÂ, etc. + $text = preg_replace('/âÂ[_\sÂ]*/u', '', $text); + $text = preg_replace('/Ã[¢Â©€£]/u', '', $text); + + // Remove standalone  characters (common encoding artifact) + $text = preg_replace('/Â+/u', '', $text); + + // Remove any remaining à sequences (encoding artifacts) + $text = preg_replace('/Ã[^\s]*/u', '', $text); + + // Try to detect and fix double-encoding issues + // Common patterns: é, Ã, etc. (UTF-8 interpreted as ISO-8859-1) + if (preg_match('/Ã[^\s]/u', $text)) { + // Try ISO-8859-1 -> UTF-8 conversion (common double-encoding fix) + $converted = @mb_convert_encoding($text, 'UTF-8', 'ISO-8859-1'); + if ($converted !== false && ! preg_match('/Ã[^\s]/u', $converted)) { + $text = $converted; + } + } + + // Remove any remaining non-printable or control characters except spaces + $text = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/u', '', $text); + + // Normalize Unicode (NFD -> NFC) if available + if (function_exists('normalizer_normalize')) { + $text = normalizer_normalize($text, \Normalizer::FORM_C); + } + + // Final cleanup: remove any remaining isolated non-ASCII control-like characters + // This catches any remaining encoding artifacts + $text = preg_replace('/[\xC0-\xC1\xC2-\xC5]/u', '', $text); + + return $text; + } + + /** + * Strip stray separators, language codes, episode numbers, and other release tags from title. + */ + private function cleanTitle(string $title): string + { + // Fix encoding issues first + $title = $this->fixEncoding($title); + + // Remove all bracketed tags (language, quality, etc.) + $title = preg_replace('/\[[^\]]+\]/', ' ', $title); + + // Remove all parenthesized tags + $title = preg_replace('/\([^)]+\)/', ' ', $title); + + // Remove language codes (standalone or with separators) + $title = preg_replace('/\b(ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\b/i', ' ', $title); + + // Remove metadata words (JAV, Uncensored, Censored, etc.) + $title = preg_replace('/\b(JAV|Uncensored|Censored|Mosaic|Mosaic-less|HD|SD|FHD|UHD)\b/i', ' ', $title); + + // Remove date patterns (6-digit dates like 091919, 200101, etc.) + $title = preg_replace('/\b\d{6}\b/', ' ', $title); + + // Remove trailing numbers/underscores (like _01, 01, _001, etc.) + $title = preg_replace('/[-_]\s*\d{1,4}\s*$/i', '', $title); + $title = preg_replace('/\s+\d{1,4}\s*$/i', '', $title); + + // Remove episode patterns (including episode titles that follow) + // Remove " - 1x18 - Episode Title" or " - 1x18" patterns + $title = preg_replace('/\s*-\s*\d+x\d+.*$/i', '', $title); + // Remove " S01E01" or " S1E1" pattern + $title = preg_replace('/\s+S\d+E\d+.*$/i', '', $title); + // Remove " - NNN" or " - NNN - Episode Title" patterns + $title = preg_replace('/\s*-\s*\d{1,4}(?:\s*-\s*.*)?\s*$/i', '', $title); + $title = preg_replace('/\s*-\s*$/i', '', $title); + // Remove " E0*NNN" or " Ep NNN" patterns + $title = preg_replace('/\s+E(?:p(?:isode)?)?\s*0*\d{1,4}\s*$/i', '', $title); + + // Remove quality/resolution tags + $title = preg_replace('/\b(480p|720p|1080p|2160p|4K|BD|BDRip|BluRay|Blu-Ray|HEVC|x264|x265|H264|H265|WEB|WEBRip|DVDRip|TVRip)\b/i', ' ', $title); + + // Remove common release tags + $title = preg_replace('/\b(PROPER|REPACK|RIP|ISO|CRACK|BETA|ALPHA|FINAL|COMPLETE|FULL)\b/i', ' ', $title); + + // Remove volume/chapter markers + $title = preg_replace('/\s+Vol\.?\s*\d*\s*$/i', '', $title); + $title = preg_replace('/\s+Ch\.?\s*\d*\s*$/i', '', $title); + + // Remove trailing dashes and separators + $title = preg_replace('/\s*[-_]\s*$/', '', $title); + + // Normalize whitespace + $title = preg_replace('/\s+/', ' ', $title); + + return trim((string) $title); + } + + /** + * Retrieve AniList anime by searching for title. + * First checks local database, then searches AniList API if not found. + */ + private function getAnidbByName(string $searchName = ''): ?AnidbTitle + { + if ($searchName === '') { + return null; + } + + $key = strtolower($searchName); + + // Check cache first + if (isset($this->titleCache[$key])) { + return AnidbTitle::query()->select(['anidbid', 'title'])->where('anidbid', $this->titleCache[$key])->first(); + } + + // Try exact match in local database first + $exact = AnidbTitle::query()->whereRaw('LOWER(title) = ?', [$key])->select(['anidbid', 'title'])->first(); + if ($exact) { + $this->titleCache[$key] = (int) $exact->anidbid; + + return $exact; + } + + // Try partial match in local database + $partial = AnidbTitle::query()->where('title', 'like', '%'.$searchName.'%')->select(['anidbid', 'title'])->first(); + if ($partial) { + $this->titleCache[$key] = (int) $partial->anidbid; + + return $partial; + } + + // Not found locally, search AniList API + try { + $searchResults = $this->palist->searchAnime($searchName, 1); + if ($searchResults && ! empty($searchResults)) { + $anilistData = $searchResults[0]; + $anilistId = $anilistData['id'] ?? null; + + if ($anilistId) { + // Use anilist_id as anidbid for new entries + $anidbid = AnidbInfo::query()->where('anilist_id', $anilistId)->value('anidbid'); + + if (! $anidbid) { + // Create new entry using anilist_id as anidbid + $anidbid = (int) $anilistId; + $this->palist->populateTable('info', $anilistId); + } + + // Get the title from database after insertion + $title = AnidbTitle::query() + ->where('anidbid', $anidbid) + ->where('lang', 'en') + ->value('title'); + + if ($title) { + $this->titleCache[$key] = $anidbid; + + return AnidbTitle::query() + ->where('anidbid', $anidbid) + ->where('title', $title) + ->first(); + } + } + } + } catch (\Exception $e) { + if ($this->echooutput) { + $this->colorCli->error('AniList search failed: '.$e->getMessage()); + } + } + + return null; + } + + /** + * Matches the anime release to AniList Info; fetches remotely if needed. + * Note: AniList doesn't support episode lookups, so we only match by title. + * + * @throws \Exception + */ + private function matchAnimeRelease($release): bool + { + $matched = false; + $type = 'Local'; + + $cleanArr = $this->extractTitleEpisode((string) $release->searchname); + if (empty($cleanArr)) { + return false; + } + + $title = $cleanArr['title']; + + if ($this->echooutput) { + $this->colorCli->info('Looking Up: Title: '.$title); + } + + $anidbTitle = $this->getAnidbByName($title); + if (! $anidbTitle) { + // Try with spaces replaced by % for broader matching + $tmpName = preg_replace('/\s+/', '%', $title); + $anidbTitle = $this->getAnidbByName($tmpName); + } + + if ($anidbTitle && is_numeric($anidbTitle->anidbid) && (int) $anidbTitle->anidbid > 0) { + $anidbId = (int) $anidbTitle->anidbid; + + // Check if we need to update info from AniList + $info = AnidbInfo::query()->where('anidbid', $anidbId)->first(); + if (! $info || $this->shouldUpdateInfo($anidbId)) { + // Try to get anilist_id if we have it + $anilistId = $info->anilist_id ?? null; + + if (! $anilistId) { + // Search AniList for this title + try { + $searchResults = $this->palist->searchAnime($title, 1); + if ($searchResults && ! empty($searchResults)) { + $anilistId = $searchResults[0]['id'] ?? null; + } + } catch (\Exception $e) { + if ($this->echooutput) { + $this->colorCli->warning('AniList search failed: '.$e->getMessage()); + } + } + } + + if ($anilistId) { + // Fetch remote info from AniList + $this->palist->populateTable('info', $anilistId); + $type = 'Remote'; + } + } + + $this->updateRelease($anidbId, (int) $release->id); + + if ($this->echooutput) { + $this->colorCli->headerOver('Matched '.$type.' AniList ID: '); + $this->colorCli->primary((string) $anidbId); + $this->colorCli->alternateOver(' Title: '); + $this->colorCli->primary($anidbTitle->title); + } + $matched = true; + } else { + $this->status = self::PROC_NOMATCH; + } + + return $matched; + } + + private function updateRelease(int $anidbId, int $relId): void + { + Release::query()->where('id', $relId)->update(['anidbid' => $anidbId]); + } + + /** + * Determine if we should attempt a remote AniDB info fetch (missing or stale > 1 week). + */ + private function shouldUpdateInfo(int $anidbId): bool + { + $info = AnidbInfo::query()->where('anidbid', $anidbId)->first(['updated']); + if ($info === null) { + return true; // no info yet + } + + return $info->updated < now()->subWeek(); + } } diff --git a/tests/Unit/AniDBParseTest.php b/tests/Unit/AniDBParseTest.php index 8e0800ccb..f996b668d 100644 --- a/tests/Unit/AniDBParseTest.php +++ b/tests/Unit/AniDBParseTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit; +use App\Services\AnimeProcessor; use PHPUnit\Framework\TestCase; use ReflectionClass; use ReflectionMethod; @@ -11,9 +12,9 @@ use ReflectionProperty; class AniDBParseTest extends TestCase { - private function makeAniDBInstance(): object + private function makeAnimeProcessorInstance(): object { - $rc = new ReflectionClass(\Blacklight\processing\post\AniDB::class); + $rc = new ReflectionClass(AnimeProcessor::class); return $rc->newInstanceWithoutConstructor(); } @@ -40,71 +41,66 @@ class AniDBParseTest extends TestCase public function test_standard_bracketed_pattern_with_numeric_episode(): void { - $sut = $this->makeAniDBInstance(); + $sut = $this->makeAnimeProcessorInstance(); $name = '[HorribleSubs] My Hero Academia - 12 [1080p].mkv'; $res = $this->invokeExtract($sut, $name); - $this->assertSame(['title' => 'My Hero Academia', 'epno' => 12], $res); + $this->assertSame(['title' => 'My Hero Academia'], $res); } public function test_e_prefix_with_underscores_and_dots(): void { - $sut = $this->makeAniDBInstance(); + $sut = $this->makeAnimeProcessorInstance(); $name = '[Group] Neon_Genesis.Evangelion E01 [720p]'; $res = $this->invokeExtract($sut, $name); $this->assertSame('Neon Genesis Evangelion', $res['title']); - $this->assertSame(1, $res['epno']); } - public function test_bd_release_without_explicit_episode_defaults_to_one(): void + public function test_bd_release_without_explicit_episode(): void { - $sut = $this->makeAniDBInstance(); + $sut = $this->makeAnimeProcessorInstance(); $name = '[SomeGroup] Cowboy Bebop [BD][1080p]'; $res = $this->invokeExtract($sut, $name); $this->assertSame('Cowboy Bebop', $res['title']); - $this->assertSame(1, $res['epno']); } public function test_simple_dash_pattern(): void { - $sut = $this->makeAniDBInstance(); + $sut = $this->makeAnimeProcessorInstance(); $name = 'Naruto - 3 [480p]'; $res = $this->invokeExtract($sut, $name); - $this->assertSame(['title' => 'Naruto', 'epno' => 3], $res); + $this->assertSame(['title' => 'Naruto'], $res); } - public function test_movie_and_ova_map_to_episode_one(): void + public function test_movie_and_ova_extracts_title(): void { - $sut = $this->makeAniDBInstance(); + $sut = $this->makeAnimeProcessorInstance(); $movieName = '[Group] Cowboy Bebop - Movie - Something [BD]'; $resMovie = $this->invokeExtract($sut, $movieName); $this->assertSame('Cowboy Bebop', $resMovie['title']); - $this->assertSame(1, $resMovie['epno']); $ovaName = 'FLCL - OVA - Disc 1 [720p]'; $resOva = $this->invokeExtract($sut, $ovaName); $this->assertSame('FLCL', $resOva['title']); - $this->assertSame(1, $resOva['epno']); } - public function test_complete_series_maps_to_episode_zero(): void + public function test_complete_series_extracts_title(): void { - $sut = $this->makeAniDBInstance(); + $sut = $this->makeAnimeProcessorInstance(); $name = 'Attack on Titan - Complete Series [BD]'; $res = $this->invokeExtract($sut, $name); $this->assertSame('Attack on Titan', $res['title']); - $this->assertSame(0, $res['epno']); } public function test_invalid_name_sets_status_and_returns_empty(): void { - $sut = $this->makeAniDBInstance(); - $name = 'Invalid Release Name Without Episode'; + $sut = $this->makeAnimeProcessorInstance(); + $name = ''; $res = $this->invokeExtract($sut, $name); $this->assertSame([], $res); diff --git a/tests/Unit/AniDBProcessingTest.php b/tests/Unit/AniDBProcessingTest.php index a900e3027..e6d0f0cee 100644 --- a/tests/Unit/AniDBProcessingTest.php +++ b/tests/Unit/AniDBProcessingTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Tests\Unit; -use Blacklight\processing\post\AniDB as AniDBProcessing; +use App\Services\AnimeProcessor; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -12,9 +12,9 @@ final class AniDBProcessingTest extends TestCase { private function invokeExtract(string $name, ?int &$status = null): array { - $ref = new ReflectionClass(AniDBProcessing::class); + $ref = new ReflectionClass(AnimeProcessor::class); // Bypass constructor to avoid DB / config calls. - /** @var AniDBProcessing $instance */ + /** @var AnimeProcessor $instance */ $instance = $ref->newInstanceWithoutConstructor(); // Ensure status property exists and set to null before call. @@ -39,24 +39,22 @@ final class AniDBProcessingTest extends TestCase { $status = null; $res = $this->invokeExtract('[SubsPlease] My Hero Academia - 05 [1080p].mkv', $status); - $this->assertSame(['title' => 'My Hero Academia', 'epno' => 5], $res); + $this->assertSame(['title' => 'My Hero Academia'], $res); $this->assertNull($status, 'Status should remain null on successful extraction'); } - public function test_movie_token_maps_to_episode_one(): void + public function test_movie_token_extracts_title(): void { $status = null; $res = $this->invokeExtract('Spirited Away - Movie [BD 1080p]', $status); - $this->assertSame(1, $res['epno']); $this->assertSame('Spirited Away', $res['title']); $this->assertNull($status); } - public function test_complete_series_maps_to_episode_zero(): void + public function test_complete_series_extracts_title(): void { $status = null; $res = $this->invokeExtract('Great Show - Complete Series [BD 1080p]', $status); - $this->assertSame(0, $res['epno']); $this->assertSame('Great Show', $res['title']); $this->assertNull($status); } @@ -66,14 +64,13 @@ final class AniDBProcessingTest extends TestCase $status = null; $res = $this->invokeExtract('[Group] My_Show.Name - 07 [720p]', $status); $this->assertSame('My Show Name', $res['title']); - $this->assertSame(7, $res['epno']); $this->assertNull($status); } public function test_failed_extraction_sets_status_and_returns_empty(): void { $status = null; - $res = $this->invokeExtract('ThisWillNotMatchPattern', $status); + $res = $this->invokeExtract('', $status); $this->assertSame([], $res); $this->assertIsInt($status); $this->assertLessThan(0, $status, 'Status should be negative on failure (PROC_EXTFAIL)');