| Total Complexity | 56 |
| Total Lines | 436 |
| Duplicated Lines | 0 % |
| Changes | 0 | ||
Complex classes like AnimeProcessor often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use AnimeProcessor, and based on these observations, apply Extract Interface, too.
| 1 | <?php |
||
| 15 | class AnimeProcessor |
||
| 16 | { |
||
| 17 | private const PROC_EXTFAIL = -1; // Release Anime title/episode # could not be extracted from searchname |
||
| 18 | |||
| 19 | private const PROC_NOMATCH = -2; // AniList ID was not found in anidb table using extracted title |
||
| 20 | |||
| 21 | /** @var bool Whether to echo messages to CLI */ |
||
| 22 | public bool $echooutput; |
||
| 23 | |||
| 24 | public PaList $palist; |
||
| 25 | |||
| 26 | /** @var int number of AniDB releases to process */ |
||
| 27 | private int $aniqty; |
||
| 28 | |||
| 29 | /** @var int|null The status of the release being processed */ |
||
| 30 | private ?int $status; |
||
| 31 | |||
| 32 | protected ColorCLI $colorCli; |
||
| 33 | |||
| 34 | /** |
||
| 35 | * Simple cache of looked up titles -> anidbid to reduce repeat queries within one run. |
||
| 36 | * |
||
| 37 | * @var array<string,int> |
||
| 38 | */ |
||
| 39 | private array $titleCache = []; |
||
| 40 | |||
| 41 | /** |
||
| 42 | * Simple cache of looked up titles -> anilist_id to reduce repeat queries within one run. |
||
| 43 | * |
||
| 44 | * @var array<string,int> |
||
| 45 | */ |
||
| 46 | private array $anilistIdCache = []; |
||
|
|
|||
| 47 | |||
| 48 | /** |
||
| 49 | * @throws \Exception |
||
| 50 | */ |
||
| 51 | public function __construct(bool $echooutput = true) |
||
| 52 | { |
||
| 53 | $this->echooutput = $echooutput && (bool) config('nntmux.echocli'); |
||
| 54 | $this->palist = new PaList; |
||
| 55 | $this->colorCli = new ColorCLI; |
||
| 56 | |||
| 57 | $quantity = (int) Settings::settingValue('maxanidbprocessed'); |
||
| 58 | $this->aniqty = $quantity > 0 ? $quantity : 100; |
||
| 59 | $this->status = null; |
||
| 60 | } |
||
| 61 | |||
| 62 | /** |
||
| 63 | * Main entry point for processing anime releases. |
||
| 64 | * |
||
| 65 | * @param string $groupID (Optional) ID of a group to work on. |
||
| 66 | * @param string $guidChar (Optional) First letter of a release GUID to use to get work. |
||
| 67 | * |
||
| 68 | * @throws \Exception |
||
| 69 | */ |
||
| 70 | public function process(string $groupID = '', string $guidChar = ''): void |
||
| 71 | { |
||
| 72 | if ((int) Settings::settingValue('lookupanidb') === 0) { |
||
| 73 | return; |
||
| 74 | } |
||
| 75 | |||
| 76 | $this->processAnimeReleases($groupID, $guidChar); |
||
| 77 | } |
||
| 78 | |||
| 79 | /** |
||
| 80 | * Queues anime releases for processing. |
||
| 81 | * |
||
| 82 | * @param string $groupID (Optional) ID of a group to work on. |
||
| 83 | * @param string $guidChar (Optional) First letter of a release GUID to use to get work. |
||
| 84 | * |
||
| 85 | * @throws \Exception |
||
| 86 | */ |
||
| 87 | public function processAnimeReleases(string $groupID = '', string $guidChar = ''): void |
||
| 88 | { |
||
| 89 | $query = Release::query() |
||
| 90 | ->whereNull('anidbid') |
||
| 91 | ->where('categories_id', Category::TV_ANIME); |
||
| 92 | |||
| 93 | if ($guidChar !== '') { |
||
| 94 | $query->where('leftguid', 'like', $guidChar.'%'); |
||
| 95 | } |
||
| 96 | |||
| 97 | if ($groupID !== '') { |
||
| 98 | $query->where('groups_id', $groupID); |
||
| 99 | } |
||
| 100 | |||
| 101 | $results = $query->orderByDesc('postdate') |
||
| 102 | ->limit($this->aniqty) |
||
| 103 | ->get(); |
||
| 104 | |||
| 105 | if ($results->count() > 0) { |
||
| 106 | // AniList rate limiting is handled internally in PopulateAniList |
||
| 107 | |||
| 108 | foreach ($results as $release) { |
||
| 109 | $matched = $this->matchAnimeRelease($release); |
||
| 110 | if ($matched === false) { |
||
| 111 | // Persist status so we do not keep retrying hopeless releases immediately. |
||
| 112 | Release::query()->where('id', $release->id)->update(['anidbid' => $this->status]); |
||
| 113 | } |
||
| 114 | } |
||
| 115 | } else { |
||
| 116 | $this->colorCli->info('No anidb releases to process.'); |
||
| 117 | } |
||
| 118 | } |
||
| 119 | |||
| 120 | /** |
||
| 121 | * Extracts anime title from release searchname. |
||
| 122 | * Returns ['title' => string] on success else empty array. |
||
| 123 | * Note: AniList doesn't support episode lookups, so we only extract the title. |
||
| 124 | */ |
||
| 125 | private function extractTitleEpisode(string $cleanName = ''): array |
||
| 126 | { |
||
| 127 | // Fix UTF-8 encoding issues (double-encoding, corrupted sequences) |
||
| 128 | $s = $this->fixEncoding($cleanName); |
||
| 129 | |||
| 130 | // Normalize common separators |
||
| 131 | $s = str_replace(['_', '.'], ' ', $s); |
||
| 132 | $s = preg_replace('/\s+/', ' ', (string) $s); |
||
| 133 | $s = trim((string) $s); |
||
| 134 | |||
| 135 | // Strip leading group tags like [Group] |
||
| 136 | $s = preg_replace('/^(?:\[[^\]]+\]\s*)+/', '', $s); |
||
| 137 | $s = trim((string) $s); |
||
| 138 | |||
| 139 | // Remove language codes and tags (before extracting title) |
||
| 140 | // Common language tags: [ENG], [JAP], [SUB], [DUB], [MULTI], etc. |
||
| 141 | $s = preg_replace('/\[(?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\]/i', ' ', $s); |
||
| 142 | $s = preg_replace('/\((?:ENG|JAP|JPN|SUB|DUB|MULTI|RAW|HARDSUB|SOFTSUB|HARDDUB|SOFTDUB|ITA|SPA|FRE|GER|RUS|CHI|KOR)\)/i', ' ', $s); |
||
| 143 | |||
| 144 | // Remove episode patterns and extract title |
||
| 145 | $title = ''; |
||
| 146 | |||
| 147 | // Try to extract title by removing episode patterns |
||
| 148 | // 1) Look for " S01E01" or " S1E1" pattern |
||
| 149 | if (preg_match('/\sS\d+E\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) { |
||
| 150 | $title = substr($s, 0, (int) $m[0][1]); |
||
| 151 | } |
||
| 152 | // 2) Look for " 1x18" or " 2x05" pattern (season x episode) |
||
| 153 | elseif (preg_match('/\s\d+x\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) { |
||
| 154 | $title = substr($s, 0, (int) $m[0][1]); |
||
| 155 | } |
||
| 156 | // 3) Look for " - NNN" and extract title before it |
||
| 157 | elseif (preg_match('/\s-\s*(\d{1,3})\b/', $s, $m, PREG_OFFSET_CAPTURE)) { |
||
| 158 | $title = substr($s, 0, (int) $m[0][1]); |
||
| 159 | } |
||
| 160 | // 4) If not found, look for " E0*NNN" or " Ep NNN" |
||
| 161 | elseif (preg_match('/\sE(?:p(?:isode)?)?\s*0*(\d{1,3})\b/i', $s, $m, PREG_OFFSET_CAPTURE)) { |
||
| 162 | $title = substr($s, 0, (int) $m[0][1]); |
||
| 163 | } |
||
| 164 | // 4) Keywords Movie/OVA/Complete Series |
||
| 165 | elseif (preg_match('/\b(Movie|OVA|Complete Series|Complete|Full Series)\b/i', $s, $m, PREG_OFFSET_CAPTURE)) { |
||
| 166 | $title = substr($s, 0, (int) $m[0][1]); |
||
| 167 | } |
||
| 168 | // 5) BD/resolution releases: pick title before next bracket token |
||
| 169 | elseif (preg_match('/\[(?:BD|BDRip|BluRay|Blu-Ray|\d{3,4}[ipx]|HEVC|x264|x265|H264|H265)\]/i', $s, $m, PREG_OFFSET_CAPTURE)) { |
||
| 170 | $title = substr($s, 0, (int) $m[0][1]); |
||
| 171 | } else { |
||
| 172 | // No episode pattern found, use the whole string as title |
||
| 173 | $title = $s; |
||
| 174 | } |
||
| 175 | |||
| 176 | $title = $this->cleanTitle((string) $title); |
||
| 177 | |||
| 178 | if ($title === '') { |
||
| 179 | $this->status = self::PROC_EXTFAIL; |
||
| 180 | |||
| 181 | return []; |
||
| 182 | } |
||
| 183 | |||
| 184 | return ['title' => $title]; |
||
| 185 | } |
||
| 186 | |||
| 187 | /** |
||
| 188 | * Fix UTF-8 encoding issues in strings (double-encoding, corrupted sequences). |
||
| 189 | */ |
||
| 190 | private function fixEncoding(string $text): string |
||
| 226 | } |
||
| 227 | |||
| 228 | /** |
||
| 229 | * Strip stray separators, language codes, episode numbers, and other release tags from title. |
||
| 230 | */ |
||
| 231 | private function cleanTitle(string $title): string |
||
| 283 | } |
||
| 284 | |||
| 285 | /** |
||
| 286 | * Retrieve AniList anime by searching for title. |
||
| 287 | * First checks local database, then searches AniList API if not found. |
||
| 288 | */ |
||
| 289 | private function getAnidbByName(string $searchName = ''): ?AnidbTitle |
||
| 290 | { |
||
| 291 | if ($searchName === '') { |
||
| 292 | return null; |
||
| 293 | } |
||
| 294 | |||
| 295 | $key = strtolower($searchName); |
||
| 296 | |||
| 297 | // Check cache first |
||
| 298 | if (isset($this->titleCache[$key])) { |
||
| 299 | return AnidbTitle::query()->select(['anidbid', 'title'])->where('anidbid', $this->titleCache[$key])->first(); |
||
| 300 | } |
||
| 301 | |||
| 302 | // Try exact match in local database first |
||
| 303 | $exact = AnidbTitle::query()->whereRaw('LOWER(title) = ?', [$key])->select(['anidbid', 'title'])->first(); |
||
| 304 | if ($exact) { |
||
| 305 | $this->titleCache[$key] = (int) $exact->anidbid; |
||
| 306 | |||
| 307 | return $exact; |
||
| 308 | } |
||
| 309 | |||
| 310 | // Try partial match in local database |
||
| 311 | $partial = AnidbTitle::query()->where('title', 'like', '%'.$searchName.'%')->select(['anidbid', 'title'])->first(); |
||
| 312 | if ($partial) { |
||
| 313 | $this->titleCache[$key] = (int) $partial->anidbid; |
||
| 314 | |||
| 315 | return $partial; |
||
| 316 | } |
||
| 317 | |||
| 318 | // Not found locally, search AniList API |
||
| 319 | try { |
||
| 320 | $searchResults = $this->palist->searchAnime($searchName, 1); |
||
| 321 | if ($searchResults && ! empty($searchResults)) { |
||
| 322 | $anilistData = $searchResults[0]; |
||
| 323 | $anilistId = $anilistData['id'] ?? null; |
||
| 324 | |||
| 325 | if ($anilistId) { |
||
| 326 | // Use anilist_id as anidbid for new entries |
||
| 327 | $anidbid = AnidbInfo::query()->where('anilist_id', $anilistId)->value('anidbid'); |
||
| 328 | |||
| 329 | if (! $anidbid) { |
||
| 330 | // Create new entry using anilist_id as anidbid |
||
| 331 | $anidbid = (int) $anilistId; |
||
| 332 | $this->palist->populateTable('info', $anilistId); |
||
| 333 | } |
||
| 334 | |||
| 335 | // Get the title from database after insertion |
||
| 336 | $title = AnidbTitle::query() |
||
| 337 | ->where('anidbid', $anidbid) |
||
| 338 | ->where('lang', 'en') |
||
| 339 | ->value('title'); |
||
| 340 | |||
| 341 | if ($title) { |
||
| 342 | $this->titleCache[$key] = $anidbid; |
||
| 343 | |||
| 344 | return AnidbTitle::query() |
||
| 345 | ->where('anidbid', $anidbid) |
||
| 346 | ->where('title', $title) |
||
| 347 | ->first(); |
||
| 348 | } |
||
| 349 | } |
||
| 350 | } |
||
| 351 | } catch (\Exception $e) { |
||
| 352 | if ($this->echooutput) { |
||
| 353 | $this->colorCli->error('AniList search failed: '.$e->getMessage()); |
||
| 354 | } |
||
| 355 | } |
||
| 356 | |||
| 357 | return null; |
||
| 358 | } |
||
| 359 | |||
| 360 | /** |
||
| 361 | * Matches the anime release to AniList Info; fetches remotely if needed. |
||
| 362 | * Note: AniList doesn't support episode lookups, so we only match by title. |
||
| 363 | * |
||
| 364 | * @throws \Exception |
||
| 365 | */ |
||
| 366 | private function matchAnimeRelease($release): bool |
||
| 433 | } |
||
| 434 | |||
| 435 | private function updateRelease(int $anidbId, int $relId): void |
||
| 438 | } |
||
| 439 | |||
| 440 | /** |
||
| 441 | * Determine if we should attempt a remote AniDB info fetch (missing or stale > 1 week). |
||
| 442 | */ |
||
| 443 | private function shouldUpdateInfo(int $anidbId): bool |
||
| 451 | } |
||
| 452 | } |
||
| 453 |