From 7471218bb077ac7e7aca6cb0eadb385e6db62dec Mon Sep 17 00:00:00 2001 From: DariusIII Date: Fri, 5 Dec 2025 13:21:26 +0100 Subject: [PATCH] Replace anidb with anilist --- Blacklight/AniDB.php | 97 ++- Blacklight/Categorize.php | 8 +- Blacklight/PopulateAniList.php | 640 ++++++++++++++++++ Blacklight/processing/post/AniDB.php | 230 ++++--- app/Console/Commands/MigrateAnimeCovers.php | 83 +++ app/Console/Commands/NntmuxPopulateAniDB.php | 23 +- app/Console/Commands/NntmuxResetDb.php | 1 - app/Http/Controllers/CoverController.php | 11 + app/Http/Controllers/DetailsController.php | 16 + app/Models/AnidbInfo.php | 14 +- app/Models/AnidbTitle.php | 6 - ...2_05_000000_replace_anidb_with_anilist.php | 142 ++++ database/schema/mariadb-schema.sql | 24 +- misc/testing/DB/reset_postprocessing.php | 2 - resources/views/admin/anidb/edit.blade.php | 30 +- resources/views/admin/anidb/index.blade.php | 4 +- resources/views/details/index.blade.php | 196 +++++- 17 files changed, 1384 insertions(+), 143 deletions(-) create mode 100644 Blacklight/PopulateAniList.php create mode 100644 app/Console/Commands/MigrateAnimeCovers.php create mode 100644 database/migrations/2025_12_05_000000_replace_anidb_with_anilist.php diff --git a/Blacklight/AniDB.php b/Blacklight/AniDB.php index 114378ae8..325284963 100755 --- a/Blacklight/AniDB.php +++ b/Blacklight/AniDB.php @@ -15,20 +15,19 @@ class AniDB public function __construct() {} /** - * Updates stored AniDB entries in the database. + * Updates stored AniList entries in the database. + * Note: AniList doesn't support episodes, so episode-related parameters are ignored. */ - public function updateTitle(int $anidbID, string $title, string $type, string $startdate, string $enddate, string $related, string $similar, string $creators, string $description, string $rating, string $categories, string $characters, string $epnos, string $airdates, string $episodetitles): void + public function updateTitle(int $anidbID, string $title, string $type, string $startdate, string $enddate, string $related, string $similar, string $creators, string $description, string $rating, string $categories, string $characters, string $epnos = '', string $airdates = '', string $episodetitles = ''): void { DB::update( sprintf( ' UPDATE anidb_titles at INNER JOIN anidb_info ai ON ai.anidbid = at.anidbid - INNER JOIN anidb_episodes ae ON ae.anidbid = at.anidbid SET title = %s, type = %s, startdate = %s, enddate = %s, related = %s, similar = %s, creators = %s, description = %s, rating = %s, - categories = %s, characters = %s, epnos = %s, airdates = %s, - episodetitles = %s WHERE anidbid = %d', + categories = %s, characters = %s WHERE anidbid = %d', escapeString($title), escapeString($type), escapeString($startdate), @@ -40,9 +39,6 @@ class AniDB escapeString($rating), escapeString($categories), escapeString($characters), - escapeString($epnos), - escapeString($airdates), - escapeString($episodetitles), $anidbID ) ); @@ -57,10 +53,9 @@ class AniDB DB::delete( sprintf( ' - DELETE at, ai, ae + DELETE at, ai FROM anidb_titles AS at LEFT OUTER JOIN anidb_info ai USING (anidbid) - LEFT OUTER JOIN anidb_episodes ae USING (anidbid) WHERE anidbid = %d', $anidbID ) @@ -125,25 +120,95 @@ class AniDB } /** - * Retrieves all info for a specific AniDB ID. + * Retrieves all info for a specific AniList ID. + * Note: AniList doesn't support episodes, so episode data is not included. */ public function getAnimeInfo(int $anidbID): mixed { + // Get main info with primary title (prefer English, fallback to any) $animeInfo = DB::select( sprintf( ' SELECT at.anidbid, at.lang, at.title, + ai.anilist_id, ai.mal_id, ai.country, ai.media_type, ai.episodes, ai.duration, ai.status, ai.source, ai.hashtag, ai.startdate, ai.enddate, ai.updated, ai.related, ai.creators, ai.description, - ai.rating, ai.picture, ai.categories, ai.characters, ai.type, ai.similar, ae.episodeid, ae - .episode_title, ae.episode_no, ae.airdate + ai.rating, ai.picture, ai.categories, ai.characters, ai.type, ai.similar FROM anidb_titles AS at LEFT JOIN anidb_info AS ai USING (anidbid) - LEFT JOIN anidb_episodes ae USING (anidbid) - WHERE at.anidbid = %d', + WHERE at.anidbid = %d + ORDER BY CASE + WHEN at.lang = "en" THEN 1 + WHEN at.lang = "x-jat" THEN 2 + ELSE 3 + END + LIMIT 1', $anidbID ) ); - return $animeInfo[0] ?? false; + $result = $animeInfo[0] ?? false; + + if ($result) { + // Get English title separately + $englishTitle = DB::selectOne( + sprintf( + ' + SELECT title + FROM anidb_titles + WHERE anidbid = %d AND lang = "en" + LIMIT 1', + $anidbID + ) + ); + + // Get native title (prefer ja, then x-jat, then any non-English title) + $nativeTitle = DB::selectOne( + sprintf( + ' + SELECT title, lang + FROM anidb_titles + WHERE anidbid = %d AND lang NOT IN ("en") + ORDER BY CASE + WHEN lang = "ja" THEN 1 + WHEN lang = "x-jat" THEN 2 + ELSE 3 + END + LIMIT 1', + $anidbID + ) + ); + + // If we found a native title, use it; otherwise try to get romaji + $originalTitle = $nativeTitle; + if (!$originalTitle) { + $originalTitle = DB::selectOne( + sprintf( + ' + SELECT title, lang + FROM anidb_titles + WHERE anidbid = %d AND lang = "x-jat" + LIMIT 1', + $anidbID + ) + ); + } + + // Add titles to result + if ($englishTitle && isset($englishTitle->title)) { + $result->english_title = $englishTitle->title; + } else { + $result->english_title = null; + } + + if ($originalTitle && isset($originalTitle->title)) { + $result->original_title = $originalTitle->title; + $result->original_lang = $originalTitle->lang; + } else { + $result->original_title = null; + $result->original_lang = null; + } + } + + return $result; } } diff --git a/Blacklight/Categorize.php b/Blacklight/Categorize.php index 4822de971..2639b2277 100755 --- a/Blacklight/Categorize.php +++ b/Blacklight/Categorize.php @@ -316,6 +316,7 @@ class Categorize public function isAnimeTV(): bool { + if ($this->hasAdultMarkers()) { return false; } @@ -325,7 +326,12 @@ class Categorize return true; } - if (preg_match('/(ANiHLS|HaiKU|ANiURL)/i', $this->releaseName)) { + if (preg_match('/((\[[a-fA-F0-9]{8}\])|(\[[a-z0-9]{8}\])|ANiHLS|HaiKU|ANiURL|SkyAnime|2jzgte|^shiteater|(Erai|New)\-raws)|(LostYears|Vodes)$/i', $this->releaseName)) { + $this->tmpCat = Category::TV_ANIME; + + return true; + } + if (preg_match('/Sokudo|Ninja Kamui|Synduality Noir|Komyo|Yoohoo/i', $this->releaseName)) { $this->tmpCat = Category::TV_ANIME; return true; diff --git a/Blacklight/PopulateAniList.php b/Blacklight/PopulateAniList.php new file mode 100644 index 000000000..efbbca2d2 --- /dev/null +++ b/Blacklight/PopulateAniList.php @@ -0,0 +1,640 @@ +echooutput = config('nntmux.echocli'); + $this->colorCli = new ColorCLI; + + // Use storage_path directly to match CoverController expectations + $this->imgSavePath = storage_path('covers/anime/'); + $this->client = new Client([ + 'base_uri' => self::API_URL, + 'timeout' => 30, + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + ]); + } + + /** + * Main switch that initiates AniList table population. + * + * @throws \Exception + */ + public function populateTable(string $type = '', int|string $anilistId = ''): void + { + switch ($type) { + case 'info': + $this->populateInfoTable($anilistId); + break; + } + } + + /** + * Search for anime by title using AniList GraphQL API. + * + * @return array|false + * + * @throws \Exception + */ + public function searchAnime(string $title, int $limit = 10) + { + $query = ' + query ($search: String, $perPage: Int) { + Page (perPage: $perPage) { + media (search: $search, type: ANIME) { + id + idMal + title { + romaji + english + native + } + type + format + status + countryOfOrigin + episodes + duration + source + popularity + favourites + startDate { + year + month + day + } + endDate { + year + month + day + } + description + averageScore + hashtag + coverImage { + large + } + genres + studios { + nodes { + name + } + } + characters { + nodes { + name { + full + } + } + } + relations { + edges { + relationType + node { + id + title { + romaji + english + } + } + } + } + } + } + } + '; + + $variables = [ + 'search' => $title, + 'perPage' => $limit, + ]; + + $response = $this->makeGraphQLRequest($query, $variables); + + if ($response === false || ! isset($response['data']['Page']['media'])) { + return false; + } + + return $response['data']['Page']['media']; + } + + /** + * Get anime by AniList ID. + * + * @return array|false + * + * @throws \Exception + */ + public function getAnimeById(int $anilistId) + { + $query = ' + query ($id: Int) { + Media (id: $id, type: ANIME) { + id + idMal + title { + romaji + english + native + } + type + format + status + countryOfOrigin + episodes + duration + source + popularity + favourites + startDate { + year + month + day + } + endDate { + year + month + day + } + description + averageScore + hashtag + coverImage { + large + } + genres + studios { + nodes { + name + } + } + characters { + nodes { + name { + full + } + } + } + relations { + edges { + relationType + node { + id + title { + romaji + english + } + } + } + } + } + } + '; + + $variables = ['id' => $anilistId]; + + $response = $this->makeGraphQLRequest($query, $variables); + + if ($response === false || ! isset($response['data']['Media'])) { + return false; + } + + return $response['data']['Media']; + } + + /** + * Make a GraphQL request to AniList API with rate limiting. + * + * @return array|false + * + * @throws \Exception + */ + private function makeGraphQLRequest(string $query, array $variables = []) + { + // Enforce rate limiting + $this->enforceRateLimit(); + + try { + $response = $this->client->post('', [ + 'json' => [ + 'query' => $query, + 'variables' => $variables, + ], + ]); + + $statusCode = $response->getStatusCode(); + $body = json_decode($response->getBody()->getContents(), true); + + // Track rate limit from headers + $remaining = (int) ($response->getHeader('X-RateLimit-Remaining')[0] ?? self::RATE_LIMIT_PER_MINUTE); + $resetAt = (int) ($response->getHeader('X-RateLimit-Reset')[0] ?? time() + 60); + + // Record this request + $this->rateLimitQueue[] = [ + 'timestamp' => time(), + 'remaining' => $remaining, + 'resetAt' => $resetAt, + ]; + + // Clean old entries (older than 1 minute) + $this->rateLimitQueue = array_filter($this->rateLimitQueue, function ($entry) { + return $entry['timestamp'] > (time() - 60); + }); + + if ($statusCode === 200 && isset($body['data'])) { + return $body; + } + + if (isset($body['errors'])) { + if ($this->echooutput) { + $this->colorCli->error('AniList API Error: '.json_encode($body['errors'])); + } + + return false; + } + + return false; + } catch (GuzzleException $e) { + if ($this->echooutput) { + $this->colorCli->error('AniList API Request Failed: '.$e->getMessage()); + } + + return false; + } + } + + /** + * Enforce rate limiting: 90 requests per minute. + * + * @throws \Exception + */ + private function enforceRateLimit(): void + { + // Count requests in the last minute + $recentRequests = array_filter($this->rateLimitQueue, function ($entry) { + return $entry['timestamp'] > (time() - 60); + }); + + $requestCount = count($recentRequests); + + // If we're approaching the limit, wait + if ($requestCount >= (self::RATE_LIMIT_PER_MINUTE - 5)) { + // Wait until the oldest request is more than 1 minute old + if (! empty($this->rateLimitQueue)) { + $oldestRequest = min(array_column($this->rateLimitQueue, 'timestamp')); + $waitTime = 60 - (time() - $oldestRequest) + 1; + + if ($waitTime > 0 && $waitTime <= 60) { + if ($this->echooutput) { + $this->colorCli->warning("Rate limit approaching. Waiting {$waitTime} seconds..."); + } + sleep($waitTime); + } + } + } + } + + /** + * Insert or update AniList title data. + */ + private function insertAniListTitle(int $anidbid, string $type, string $lang, string $title): void + { + $check = AnidbTitle::query()->where([ + 'anidbid' => $anidbid, + 'type' => $type, + 'lang' => $lang, + 'title' => $title, + ])->first(); + + if ($check === null) { + AnidbTitle::insertOrIgnore([ + 'anidbid' => $anidbid, + 'type' => $type, + 'lang' => $lang, + 'title' => $title, + ]); + } + } + + /** + * Insert or update AniList info data. + */ + private function insertAniListInfo(int $anidbid, array $anilistData): void + { + // Extract data from AniList response + $startDate = null; + if (isset($anilistData['startDate']['year'], $anilistData['startDate']['month'], $anilistData['startDate']['day'])) { + $startDate = sprintf( + '%04d-%02d-%02d', + $anilistData['startDate']['year'], + $anilistData['startDate']['month'] ?? 1, + $anilistData['startDate']['day'] ?? 1 + ); + } + + $endDate = null; + if (isset($anilistData['endDate']['year'], $anilistData['endDate']['month'], $anilistData['endDate']['day'])) { + $endDate = sprintf( + '%04d-%02d-%02d', + $anilistData['endDate']['year'], + $anilistData['endDate']['month'] ?? 1, + $anilistData['endDate']['day'] ?? 1 + ); + } + + $description = $anilistData['description'] ?? ''; + // Remove HTML tags from description + $description = strip_tags($description); + $description = html_entity_decode($description, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + $rating = isset($anilistData['averageScore']) ? (string) $anilistData['averageScore'] : null; + + $picture = null; + if (isset($anilistData['coverImage']['large'])) { + $picture = basename(parse_url($anilistData['coverImage']['large'], PHP_URL_PATH)); + } + + $genres = ''; + if (isset($anilistData['genres']) && is_array($anilistData['genres'])) { + $genres = implode(', ', $anilistData['genres']); + } + + $creators = ''; + if (isset($anilistData['studios']['nodes']) && is_array($anilistData['studios']['nodes'])) { + $studioNames = array_map(function ($studio) { + return $studio['name'] ?? ''; + }, $anilistData['studios']['nodes']); + $creators = implode(', ', array_filter($studioNames)); + } + + $characters = ''; + if (isset($anilistData['characters']['nodes']) && is_array($anilistData['characters']['nodes'])) { + $characterNames = array_map(function ($character) { + return $character['name']['full'] ?? ''; + }, array_slice($anilistData['characters']['nodes'], 0, 10)); // Limit to 10 characters + $characters = implode(', ', array_filter($characterNames)); + } + + $related = ''; + $similar = ''; + if (isset($anilistData['relations']['edges']) && is_array($anilistData['relations']['edges'])) { + $relatedItems = []; + $similarItems = []; + foreach ($anilistData['relations']['edges'] as $edge) { + $relationType = $edge['relationType'] ?? ''; + $title = $edge['node']['title']['english'] ?? $edge['node']['title']['romaji'] ?? ''; + $id = $edge['node']['id'] ?? ''; + + if (in_array($relationType, ['SEQUEL', 'PREQUEL', 'SIDE_STORY', 'PARENT', 'SPIN_OFF'])) { + $relatedItems[] = $title.' ('.$id.')'; + } elseif (in_array($relationType, ['ALTERNATIVE', 'CHARACTER'])) { + $similarItems[] = $title.' ('.$id.')'; + } + } + $related = implode(', ', array_slice($relatedItems, 0, 20)); + $similar = implode(', ', array_slice($similarItems, 0, 20)); + } + + $anilistId = $anilistData['id'] ?? null; + $malId = $anilistData['idMal'] ?? null; + $country = $anilistData['countryOfOrigin'] ?? null; + $mediaType = $anilistData['type'] ?? null; // ANIME or MANGA + $episodes = isset($anilistData['episodes']) ? (int) $anilistData['episodes'] : null; + $duration = isset($anilistData['duration']) ? (int) $anilistData['duration'] : null; + $status = $anilistData['status'] ?? null; + $source = $anilistData['source'] ?? null; + $hashtag = $anilistData['hashtag'] ?? null; + + $check = AnidbInfo::query()->where('anidbid', $anidbid)->first(); + + if ($check === null) { + AnidbInfo::insert([ + 'anidbid' => $anidbid, + 'anilist_id' => $anilistId, + 'mal_id' => $malId, + 'country' => $country, + 'media_type' => $mediaType, + 'type' => $anilistData['format'] ?? null, + 'episodes' => $episodes, + 'duration' => $duration, + 'status' => $status, + 'source' => $source, + 'hashtag' => $hashtag, + 'startdate' => $startDate, + 'enddate' => $endDate, + 'description' => $description, + 'rating' => $rating, + 'picture' => $picture, + 'categories' => $genres, + 'characters' => $characters, + 'creators' => $creators, + 'related' => $related, + 'similar' => $similar, + 'updated' => now(), + ]); + } else { + AnidbInfo::query() + ->where('anidbid', $anidbid) + ->update([ + 'anilist_id' => $anilistId ?? $check->anilist_id, + 'mal_id' => $malId ?? $check->mal_id, + 'country' => $country ?? $check->country, + 'media_type' => $mediaType ?? $check->media_type, + 'type' => $anilistData['format'] ?? $anilistData['type'] ?? $check->type, + 'episodes' => $episodes ?? $check->episodes, + 'duration' => $duration ?? $check->duration, + 'status' => $status ?? $check->status, + 'source' => $source ?? $check->source, + 'hashtag' => $hashtag ?? $check->hashtag, + 'startdate' => $startDate ?? $check->startdate, + 'enddate' => $endDate ?? $check->enddate, + 'description' => $description ?: $check->description, + 'rating' => $rating ?? $check->rating, + 'picture' => $picture ?? $check->picture, + 'categories' => $genres ?: $check->categories, + 'characters' => $characters ?: $check->characters, + 'creators' => $creators ?: $check->creators, + 'related' => $related ?: $check->related, + 'similar' => $similar ?: $check->similar, + 'updated' => now(), + ]); + } + + // Insert titles + if (isset($anilistData['title']['romaji']) && ! empty($anilistData['title']['romaji'])) { + $this->insertAniListTitle($anidbid, 'main', 'x-jat', $anilistData['title']['romaji']); + } + if (isset($anilistData['title']['english']) && ! empty($anilistData['title']['english'])) { + $this->insertAniListTitle($anidbid, 'official', 'en', $anilistData['title']['english']); + } + if (isset($anilistData['title']['native']) && ! empty($anilistData['title']['native'])) { + $this->insertAniListTitle($anidbid, 'main', 'ja', $anilistData['title']['native']); + } + + // Download cover image if available + if (! empty($picture) && isset($anilistData['coverImage']['large'])) { + $this->downloadCoverImage($anidbid, $anilistData['coverImage']['large']); + } + } + + /** + * Download cover image from AniList. + */ + private function downloadCoverImage(int $anidbid, string $imageUrl): void + { + // Use the format expected by getReleaseCover: {id}-cover.jpg + // This matches the format used by movies: {id}-cover.jpg + $coverFilename = $anidbid.'-cover.jpg'; + $coverPath = $this->imgSavePath.$coverFilename; + + if (file_exists($coverPath)) { + return; // Already exists + } + + try { + // Ensure directory exists with proper permissions + if (! is_dir($this->imgSavePath)) { + if (! mkdir($this->imgSavePath, 0755, true) && ! is_dir($this->imgSavePath)) { + throw new \RuntimeException(sprintf('Directory "%s" was not created', $this->imgSavePath)); + } + } + + $response = $this->client->get($imageUrl); + $imageData = $response->getBody()->getContents(); + + // Write the image file + $bytesWritten = file_put_contents($coverPath, $imageData); + + if ($bytesWritten === false) { + throw new \RuntimeException("Failed to write cover image to {$coverPath}"); + } + + // Set proper file permissions + chmod($coverPath, 0644); + + if ($this->echooutput) { + $this->colorCli->info("Downloaded cover image for ID {$anidbid} from AniList to {$coverPath}"); + } + } catch (GuzzleException $e) { + if ($this->echooutput) { + $this->colorCli->warning("Failed to download cover image for ID {$anidbid}: ".$e->getMessage()); + } + } catch (\Exception $e) { + if ($this->echooutput) { + $this->colorCli->error("Error saving cover image for ID {$anidbid}: ".$e->getMessage()); + } + } + } + + /** + * Directs flow for populating the AniList Info table. + * + * @throws \Exception + */ + private function populateInfoTable(string $anilistId = ''): void + { + if (empty($anilistId)) { + // Get titles that need updating (missing anilist_id or stale) + $anidbIds = AnidbTitle::query() + ->selectRaw('DISTINCT anidb_titles.anidbid') + ->leftJoin('anidb_info as ai', 'ai.anidbid', '=', 'anidb_titles.anidbid') + ->where(function ($query) { + $query->whereNull('ai.anilist_id') + ->orWhere('ai.updated', '<', now()->subWeek()); + }) + ->limit(100) // Process in batches + ->get(); + + foreach ($anidbIds as $anidb) { + // Try to find by title first + $title = AnidbTitle::query() + ->where('anidbid', $anidb->anidbid) + ->where('lang', 'en') + ->value('title'); + + if ($title) { + $searchResults = $this->searchAnime($title, 1); + if ($searchResults && ! empty($searchResults)) { + $anilistData = $searchResults[0]; + $this->insertAniListInfo($anidb->anidbid, $anilistData); + // Rate limiting is handled in makeGraphQLRequest + } + } + } + } else { + // Get specific anime by AniList ID + $anilistData = $this->getAnimeById((int) $anilistId); + + if ($anilistData === false) { + if ($this->echooutput) { + $this->colorCli->info("Anime with AniList ID: {$anilistId} not found."); + } + + return; + } + + // We need to find or create an anidbid for this + // For now, use anilist_id as anidbid if not found + $anidbid = AnidbInfo::query() + ->where('anilist_id', $anilistId) + ->value('anidbid'); + + if (! $anidbid) { + // Create a new entry using anilist_id as anidbid + $anidbid = (int) $anilistId; + } + + $this->insertAniListInfo($anidbid, $anilistData); + } + } +} + + diff --git a/Blacklight/processing/post/AniDB.php b/Blacklight/processing/post/AniDB.php index d0c4618ee..4c411a2a6 100755 --- a/Blacklight/processing/post/AniDB.php +++ b/Blacklight/processing/post/AniDB.php @@ -4,25 +4,24 @@ declare(strict_types=1); namespace Blacklight\processing\post; -use App\Models\AnidbEpisode; use App\Models\AnidbInfo; use App\Models\AnidbTitle; use App\Models\Category; use App\Models\Release; use App\Models\Settings; use Blacklight\ColorCLI; -use Blacklight\PopulateAniDB as PaDb; +use Blacklight\PopulateAniList as PaList; class AniDB { private const PROC_EXTFAIL = -1; // Release Anime title/episode # could not be extracted from searchname - private const PROC_NOMATCH = -2; // AniDB ID was not found in anidb table using extracted title/episode # + private const PROC_NOMATCH = -2; // AniList ID was not found in anidb table using extracted title /** @var bool Whether to echo messages to CLI */ public bool $echooutput; - public PaDb $padb; + public PaList $palist; /** @var int number of AniDB releases to process */ private int $aniqty; @@ -39,13 +38,20 @@ class AniDB */ 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->padb = new PaDb; + $this->palist = new PaList; $this->colorCli = new ColorCLI; $quantity = (int) Settings::settingValue('maxanidbprocessed'); @@ -80,8 +86,7 @@ class AniDB ->get(); if ($results->count() > 0) { - // Honor AniDB API cooldown before starting a batch. - $this->doRandomSleep(); + // AniList rate limiting is handled internally in PopulateAniList foreach ($results as $release) { $matched = $this->matchAnimeRelease($release); @@ -96,32 +101,19 @@ class AniDB } /** - * Retrieves episode info if present. + * 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 { - $q = AnidbEpisode::query()->where(['anidbid' => $anidbId]); - if ($episode >= 0) { - $q->where('episode_no', $episode); - } - $result = $q->select(['anidbid', 'episode_no', 'airdate', 'episode_title'])->first(); - - return $result ? $result->toArray() : []; + // AniList doesn't support episode lookups + return []; } /** - * Sleeps between 10 and 15 seconds for AniDB API cooldown. - * - * @throws \Exception - */ - private function doRandomSleep(): void - { - sleep(random_int(10, 15)); - } - - /** - * Extracts anime title and episode info from release searchname. - * Returns ['title' => string, 'epno' => int] on success else empty array. + * 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 { @@ -134,68 +126,92 @@ class AniDB $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 = ''; - $ep = -1; - // 1) Look for " - NNN" first + // Try to extract title by removing episode patterns + // 1) Look for " - NNN" and extract title before it if (preg_match('/\s-\s*(\d{1,3})\b/', $s, $m, PREG_OFFSET_CAPTURE)) { - $ep = (int) $m[1][0]; $title = substr($s, 0, (int) $m[0][1]); } - - // 2) If not found, look for " E0*NNN" - if ($ep < 0 && preg_match('/\sE0*(\d{1,3})\b/i', $s, $m, PREG_OFFSET_CAPTURE)) { - $ep = (int) $m[1][0]; + // 2) 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]); } - - // 3) Keywords Movie/OVA/Complete Series - if ($ep < 0 && preg_match('/\b(Movie|OVA|Complete Series)\b/i', $s, $m, PREG_OFFSET_CAPTURE)) { - $kind = strtolower($m[1][0]); - $ep = match ($kind) { - 'movie', 'ova' => 1, - 'complete series' => 0, - default => -1, - }; + // 3) Look for " S01E01" or " S1E1" pattern + elseif (preg_match('/\sS\d+E\d+/i', $s, $m, PREG_OFFSET_CAPTURE)) { $title = substr($s, 0, (int) $m[0][1]); } - - // 4) BD/resolution releases: pick title before next bracket token - if ($ep < 0 && preg_match('/\[(?:BD|\d{3,4}[ipx])\]/i', $s, $m, PREG_OFFSET_CAPTURE)) { - $ep = 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 === '' || $ep < 0) { + if ($title === '') { $this->status = self::PROC_EXTFAIL; return []; } - return ['title' => $title, 'epno' => $ep]; + return ['title' => $title]; } /** - * Strip stray separators or tokens accidentally captured at the end of title. + * Strip stray separators, language codes, episode numbers, and other release tags from title. */ private function cleanTitle(string $title): string { - // Remove trailing "- 123", trailing dash, trailing E/E0/Episode tokens, and trailing Vol. - $patterns = [ - '/\s*-\s*\d+\s*$/i', - '/\s*-\s*$/', - '/\s+E(?:pi(?:sode)?)?\s*0?\s*$/i', - '/\s+Vol\.?\s*$/i', - ]; - $title = preg_replace($patterns, '', $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 episode patterns + $title = preg_replace('/\s*-\s*\d{1,4}\s*$/i', '', $title); + $title = preg_replace('/\s*-\s*$/i', '', $title); + $title = preg_replace('/\s+E(?:p(?:isode)?)?\s*0*\d{1,4}\s*$/i', '', $title); + $title = preg_replace('/\s+S\d+E\d+\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 AniDB title row (id + title) by name attempt exact then partial. + * Retrieve AniList anime by searching for title. + * First checks local database, then searches AniList API if not found. */ private function getAnidbByName(string $searchName = ''): ?AnidbTitle { @@ -204,29 +220,70 @@ class AniDB } $key = strtolower($searchName); + + // Check cache first if (isset($this->titleCache[$key])) { return AnidbTitle::query()->select(['anidbid', 'title'])->where('anidbid', $this->titleCache[$key])->first(); } - // Exact (case-insensitive) 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; } - // Partial fallback + // 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; } - 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 AniDB Info; fetches remotely if needed. + * 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 */ @@ -241,11 +298,9 @@ class AniDB } $title = $cleanArr['title']; - $epno = $cleanArr['epno']; - // We ignore episode number 0 (Complete Series) for matching episodes but still link the title. if ($this->echooutput) { - $this->colorCli->info('Looking Up: Title: '.$title.' Episode: '.$epno); + $this->colorCli->info('Looking Up: Title: '.$title); } $anidbTitle = $this->getAnidbByName($title); @@ -258,29 +313,40 @@ class AniDB if ($anidbTitle && is_numeric($anidbTitle->anidbid) && (int) $anidbTitle->anidbid > 0) { $anidbId = (int) $anidbTitle->anidbid; - $episodeInfo = ($epno > 0) ? $this->checkAniDBInfo($anidbId, $epno) : []; + // 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 (empty($episodeInfo) && $this->shouldUpdateInfo($anidbId)) { - // Fetch remote info - $this->padb->populateTable('info', $anidbId); - $this->doRandomSleep(); - $episodeInfo = ($epno > 0) ? $this->checkAniDBInfo($anidbId, $epno) : []; - $type = 'Remote'; + if ($anilistId) { + // Fetch remote info from AniList + $this->palist->populateTable('info', $anilistId); + $type = 'Remote'; + } } - $episodeTitle = $episodeInfo['episode_title'] ?? 'Unknown'; $this->updateRelease($anidbId, (int) $release->id); if ($this->echooutput) { - // Break chained calls into sequential ones; methods return void and are not chainable. - $this->colorCli->headerOver('Matched '.$type.' AniDB ID: '); + $this->colorCli->headerOver('Matched '.$type.' AniList ID: '); $this->colorCli->primary((string) $anidbId); $this->colorCli->alternateOver(' Title: '); $this->colorCli->primary($anidbTitle->title); - $this->colorCli->alternateOver(' Episode #: '); - $this->colorCli->primary((string) $epno); - $this->colorCli->alternateOver(' Episode Title: '); - $this->colorCli->primary($episodeTitle); } $matched = true; } else { diff --git a/app/Console/Commands/MigrateAnimeCovers.php b/app/Console/Commands/MigrateAnimeCovers.php new file mode 100644 index 000000000..8f08459e8 --- /dev/null +++ b/app/Console/Commands/MigrateAnimeCovers.php @@ -0,0 +1,83 @@ +option('dry-run'); + + if (!is_dir($animeCoverPath)) { + $this->error("Anime covers directory does not exist: {$animeCoverPath}"); + return 1; + } + + $this->info("Scanning anime covers directory: {$animeCoverPath}"); + + $files = File::files($animeCoverPath); + $renamed = 0; + $skipped = 0; + + foreach ($files as $file) { + $filename = $file->getFilename(); + + // Check if file matches old format: {id}.jpg (where id is numeric) + if (preg_match('/^(\d+)\.jpg$/', $filename, $matches)) { + $anidbid = $matches[1]; + $newFilename = "{$anidbid}-cover.jpg"; + $newPath = $animeCoverPath . $newFilename; + + // Skip if new format already exists + if (file_exists($newPath)) { + $this->warn("Skipping {$filename} - {$newFilename} already exists"); + $skipped++; + continue; + } + + if ($dryRun) { + $this->line("Would rename: {$filename} -> {$newFilename}"); + $renamed++; + } else { + if (rename($file->getPathname(), $newPath)) { + $this->info("Renamed: {$filename} -> {$newFilename}"); + $renamed++; + } else { + $this->error("Failed to rename: {$filename}"); + } + } + } + } + + if ($dryRun) { + $this->info("\nDry run complete. Would rename {$renamed} file(s), skipped {$skipped}."); + $this->info("Run without --dry-run to perform the migration."); + } else { + $this->info("\nMigration complete. Renamed {$renamed} file(s), skipped {$skipped}."); + } + + return 0; + } +} + diff --git a/app/Console/Commands/NntmuxPopulateAniDB.php b/app/Console/Commands/NntmuxPopulateAniDB.php index 7724e0aff..635ed5a50 100644 --- a/app/Console/Commands/NntmuxPopulateAniDB.php +++ b/app/Console/Commands/NntmuxPopulateAniDB.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Blacklight\PopulateAniDB; +use Blacklight\PopulateAniList; use Illuminate\Console\Command; class NntmuxPopulateAniDB extends Command @@ -13,29 +13,32 @@ class NntmuxPopulateAniDB extends Command * @var string */ protected $signature = 'nntmux:populate-anidb - {--full : Populate full data} {--info : Populate info only} - {--anidbid : Populate tables for specific anidbid}'; + {--anilistid : Populate tables for specific AniList ID}'; /** * The console command description. * * @var string */ - protected $description = 'Populate AniDB table'; + protected $description = 'Populate AniList table (replaces AniDB)'; /** * Execute the console command. */ public function handle(): void { - if ($this->option('full')) { - (new PopulateAniDB(['Echo' => true]))->populateTable('full'); - } elseif ($this->option('info') && is_numeric($this->option('anidbid'))) { - (new PopulateAniDB(['Echo' => true]))->populateTable('info', $this->option('anidbid')); + $palist = new PopulateAniList; + + if ($this->option('info') && is_numeric($this->option('anilistid'))) { + $palist->populateTable('info', $this->option('anilistid')); } elseif ($this->option('info')) { - (new PopulateAniDB(['Echo' => true]))->populateTable('info'); + $palist->populateTable('info'); + } else { + $this->error('Please specify --info option'); + return; } - $this->info('AniDB tables populated with requested data'); + + $this->info('AniList tables populated with requested data'); } } diff --git a/app/Console/Commands/NntmuxResetDb.php b/app/Console/Commands/NntmuxResetDb.php index 54f1c82b8..4153bf1a1 100644 --- a/app/Console/Commands/NntmuxResetDb.php +++ b/app/Console/Commands/NntmuxResetDb.php @@ -82,7 +82,6 @@ class NntmuxResetDb extends Command 'releases', 'anidb_titles', 'anidb_info', - 'anidb_episodes', 'releases_groups', ]; foreach ($arr as &$value) { diff --git a/app/Http/Controllers/CoverController.php b/app/Http/Controllers/CoverController.php index f895c3671..a8a5dd151 100644 --- a/app/Http/Controllers/CoverController.php +++ b/app/Http/Controllers/CoverController.php @@ -37,6 +37,17 @@ class CoverController extends Controller } else { $filePath = storage_path("covers/{$type}/{$filename}"); } + } elseif ($type === 'anime') { + // For anime, try the requested filename first, then fall back to old format (without -cover) + $filePath = storage_path("covers/{$type}/{$filename}"); + + // If file doesn't exist and filename ends with -cover.jpg, try old format + if (!file_exists($filePath) && preg_match('/^(\d+)-cover\.jpg$/', $filename, $matches)) { + $oldFormatPath = storage_path("covers/{$type}/{$matches[1]}.jpg"); + if (file_exists($oldFormatPath)) { + $filePath = $oldFormatPath; + } + } } else { $filePath = storage_path("covers/{$type}/{$filename}"); } diff --git a/app/Http/Controllers/DetailsController.php b/app/Http/Controllers/DetailsController.php index 783e4e3e9..425298a7c 100644 --- a/app/Http/Controllers/DetailsController.php +++ b/app/Http/Controllers/DetailsController.php @@ -121,6 +121,22 @@ class DetailsController extends BasePageController $AniDBAPIArray = ''; if ($data['anidbid'] > 0) { $AniDBAPIArray = (new AniDB)->getAnimeInfo($data['anidbid']); + + // If we have anilist_id but missing details, fetch from AniList + if ($AniDBAPIArray && !empty($AniDBAPIArray->anilist_id)) { + $anilistId = is_object($AniDBAPIArray) ? $AniDBAPIArray->anilist_id : ($AniDBAPIArray['anilist_id'] ?? null); + if ($anilistId && (empty($AniDBAPIArray->country) && empty($AniDBAPIArray->media_type))) { + // Fetch fresh data from AniList if country/media_type is missing + try { + $palist = new \Blacklight\PopulateAniList; + $palist->populateTable('info', $anilistId); + // Refresh the data + $AniDBAPIArray = (new AniDB)->getAnimeInfo($data['anidbid']); + } catch (\Exception $e) { + // Silently fail, use existing data + } + } + } } $pre = Predb::getForRelease($data['predb_id']); diff --git a/app/Models/AnidbInfo.php b/app/Models/AnidbInfo.php index d83d950cf..ba832d1fe 100644 --- a/app/Models/AnidbInfo.php +++ b/app/Models/AnidbInfo.php @@ -8,7 +8,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * App\Models\AnidbInfo. * - * @property-read AnidbEpisode $episode * @property-read AnidbTitle $title * * @mixin \Eloquent @@ -26,6 +25,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string|null $picture * @property string|null $categories * @property string|null $characters + * @property int|null $anilist_id ID from AniList + * @property int|null $mal_id ID from MyAnimeList + * @property string|null $country ISO 3166-1 alpha-2 country code + * @property string|null $media_type ANIME or MANGA + * @property int|null $episodes Number of episodes + * @property int|null $duration Duration in minutes + * @property string|null $status Media status (FINISHED, RELEASING, etc.) + * @property string|null $source Original source (MANGA, ORIGINAL, etc.) + * @property string|null $hashtag AniList hashtag * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\AnidbInfo newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\AnidbInfo newQuery() @@ -81,8 +89,4 @@ class AnidbInfo extends Model return $this->belongsTo(AnidbTitle::class, 'anidbid'); } - public function episode(): BelongsTo - { - return $this->belongsTo(AnidbEpisode::class, 'anidbid'); - } } diff --git a/app/Models/AnidbTitle.php b/app/Models/AnidbTitle.php index 90c405aef..00285b6cd 100644 --- a/app/Models/AnidbTitle.php +++ b/app/Models/AnidbTitle.php @@ -12,7 +12,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property string $type type of title. * @property string $lang * @property string $title - * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\AnidbEpisode[] $episode * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\AnidbInfo[] $info * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\AnidbTitle whereAnidbid($value) @@ -53,11 +52,6 @@ class AnidbTitle extends Model */ protected $guarded = []; - public function episode(): HasMany - { - return $this->hasMany(AnidbEpisode::class, 'anidbid'); - } - public function info(): HasMany { return $this->hasMany(AnidbInfo::class, 'anidbid'); diff --git a/database/migrations/2025_12_05_000000_replace_anidb_with_anilist.php b/database/migrations/2025_12_05_000000_replace_anidb_with_anilist.php new file mode 100644 index 000000000..3225bea73 --- /dev/null +++ b/database/migrations/2025_12_05_000000_replace_anidb_with_anilist.php @@ -0,0 +1,142 @@ +unsignedInteger('anilist_id')->nullable()->after('anidbid'); + } + if (! Schema::hasColumn('anidb_info', 'mal_id')) { + $table->unsignedInteger('mal_id')->nullable()->after('anilist_id'); + } + if (! Schema::hasColumn('anidb_info', 'country')) { + $table->string('country', 2)->nullable()->after('mal_id')->comment('ISO 3166-1 alpha-2 country code'); + } + if (! Schema::hasColumn('anidb_info', 'media_type')) { + $table->string('media_type', 10)->nullable()->after('country')->comment('ANIME or MANGA'); + } + if (! Schema::hasColumn('anidb_info', 'episodes')) { + $table->unsignedInteger('episodes')->nullable()->after('media_type'); + } + if (! Schema::hasColumn('anidb_info', 'duration')) { + $table->unsignedInteger('duration')->nullable()->after('episodes')->comment('Duration in minutes'); + } + if (! Schema::hasColumn('anidb_info', 'status')) { + $table->string('status', 20)->nullable()->after('duration')->comment('Media status (FINISHED, RELEASING, etc.)'); + } + if (! Schema::hasColumn('anidb_info', 'source')) { + $table->string('source', 20)->nullable()->after('status')->comment('Original source (MANGA, ORIGINAL, etc.)'); + } + if (! Schema::hasColumn('anidb_info', 'hashtag')) { + $table->string('hashtag', 255)->nullable()->after('source')->comment('AniList hashtag'); + } + }); + + // Add indexes for faster lookups + Schema::table('anidb_info', function (Blueprint $table) { + if (! $this->indexExists('anidb_info', 'ix_anidb_info_anilist_id')) { + $table->index('anilist_id', 'ix_anidb_info_anilist_id'); + } + if (! $this->indexExists('anidb_info', 'ix_anidb_info_mal_id')) { + $table->index('mal_id', 'ix_anidb_info_mal_id'); + } + if (! $this->indexExists('anidb_info', 'ix_anidb_info_country')) { + $table->index('country', 'ix_anidb_info_country'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Remove indexes + Schema::table('anidb_info', function (Blueprint $table) { + if ($this->indexExists('anidb_info', 'ix_anidb_info_anilist_id')) { + $table->dropIndex('ix_anidb_info_anilist_id'); + } + if ($this->indexExists('anidb_info', 'ix_anidb_info_mal_id')) { + $table->dropIndex('ix_anidb_info_mal_id'); + } + if ($this->indexExists('anidb_info', 'ix_anidb_info_country')) { + $table->dropIndex('ix_anidb_info_country'); + } + }); + + // Remove columns + Schema::table('anidb_info', function (Blueprint $table) { + if (Schema::hasColumn('anidb_info', 'anilist_id')) { + $table->dropColumn('anilist_id'); + } + if (Schema::hasColumn('anidb_info', 'mal_id')) { + $table->dropColumn('mal_id'); + } + if (Schema::hasColumn('anidb_info', 'country')) { + $table->dropColumn('country'); + } + if (Schema::hasColumn('anidb_info', 'media_type')) { + $table->dropColumn('media_type'); + } + if (Schema::hasColumn('anidb_info', 'episodes')) { + $table->dropColumn('episodes'); + } + if (Schema::hasColumn('anidb_info', 'duration')) { + $table->dropColumn('duration'); + } + if (Schema::hasColumn('anidb_info', 'status')) { + $table->dropColumn('status'); + } + if (Schema::hasColumn('anidb_info', 'source')) { + $table->dropColumn('source'); + } + if (Schema::hasColumn('anidb_info', 'hashtag')) { + $table->dropColumn('hashtag'); + } + }); + + // Recreate anidb_episodes table (if needed for rollback) + if (! Schema::hasTable('anidb_episodes')) { + Schema::create('anidb_episodes', function (Blueprint $table) { + $table->unsignedInteger('anidbid')->comment('ID of title from AniDB'); + $table->unsignedInteger('episodeid')->default(0)->comment('anidb id for this episode'); + $table->unsignedSmallInteger('episode_no')->comment('Numeric version of episode (leave 0 for combined episodes).'); + $table->string('episode_title', 255)->comment('Title of the episode (en, x-jat)'); + $table->date('airdate'); + $table->primary(['anidbid', 'episodeid']); + }); + } + } + + /** + * Check if an index exists on a table. + */ + private function indexExists(string $table, string $index): bool + { + $connection = Schema::getConnection(); + $database = $connection->getDatabaseName(); + $result = $connection->select( + "SELECT COUNT(*) as count FROM information_schema.statistics + WHERE table_schema = ? AND table_name = ? AND index_name = ?", + [$database, $table, $index] + ); + + return $result[0]->count > 0; + } +}; + + diff --git a/database/schema/mariadb-schema.sql b/database/schema/mariadb-schema.sql index b32320c6f..e9e9920ca 100644 --- a/database/schema/mariadb-schema.sql +++ b/database/schema/mariadb-schema.sql @@ -1,20 +1,19 @@ SET FOREIGN_KEY_CHECKS = 0; -DROP TABLE IF EXISTS `anidb_episodes`; - -CREATE TABLE `anidb_episodes` ( - `anidbid` int(10) unsigned NOT NULL COMMENT 'ID of title from AniDB', - `episodeid` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'anidb id for this episode', - `episode_no` smallint(5) unsigned NOT NULL COMMENT 'Numeric version of episode (leave 0 for combined episodes).', - `episode_title` varchar(255) NOT NULL COMMENT 'Title of the episode (en, x-jat)', - `airdate` date NOT NULL, - PRIMARY KEY (`anidbid`,`episodeid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci; DROP TABLE IF EXISTS `anidb_info`; CREATE TABLE `anidb_info` ( `anidbid` int(10) unsigned NOT NULL COMMENT 'ID of title from AniDB', + `anilist_id` int(10) unsigned DEFAULT NULL COMMENT 'ID from AniList', + `mal_id` int(10) unsigned DEFAULT NULL COMMENT 'ID from MyAnimeList', + `country` char(2) DEFAULT NULL COMMENT 'ISO 3166-1 alpha-2 country code', + `media_type` varchar(10) DEFAULT NULL COMMENT 'ANIME or MANGA', `type` varchar(32) DEFAULT NULL, + `episodes` int(10) unsigned DEFAULT NULL, + `duration` int(10) unsigned DEFAULT NULL COMMENT 'Duration in minutes', + `status` varchar(20) DEFAULT NULL COMMENT 'Media status (FINISHED, RELEASING, etc.)', + `source` varchar(20) DEFAULT NULL COMMENT 'Original source (MANGA, ORIGINAL, etc.)', + `hashtag` varchar(255) DEFAULT NULL COMMENT 'AniList hashtag', `startdate` date DEFAULT NULL, `enddate` date DEFAULT NULL, `updated` timestamp NOT NULL DEFAULT current_timestamp(), @@ -27,7 +26,10 @@ CREATE TABLE `anidb_info` ( `categories` varchar(1024) DEFAULT NULL, `characters` varchar(1024) DEFAULT NULL, PRIMARY KEY (`anidbid`), - KEY `ix_anidb_info_datetime` (`startdate`,`enddate`,`updated`) + KEY `ix_anidb_info_datetime` (`startdate`,`enddate`,`updated`), + KEY `ix_anidb_info_anilist_id` (`anilist_id`), + KEY `ix_anidb_info_mal_id` (`mal_id`), + KEY `ix_anidb_info_country` (`country`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci; DROP TABLE IF EXISTS `anidb_titles`; diff --git a/misc/testing/DB/reset_postprocessing.php b/misc/testing/DB/reset_postprocessing.php index a62cd23a0..84b452c02 100755 --- a/misc/testing/DB/reset_postprocessing.php +++ b/misc/testing/DB/reset_postprocessing.php @@ -29,7 +29,6 @@ if (isset($argv[1]) && $argv[1] === 'all' && isset($argv[2]) && $argv[2] === 'tr DB::select('TRUNCATE TABLE tv_info'); DB::select('TRUNCATE TABLE tv_episodes'); DB::select('TRUNCATE TABLE anidb_info'); - DB::select('TRUNCATE TABLE anidb_episodes'); } $colorCli->header('Resetting all postprocessing'); $qry = DB::select('SELECT id FROM releases'); @@ -209,7 +208,6 @@ if (isset($argv[1]) && ($argv[1] === 'anime' || $argv[1] === 'all')) { $ran = true; if (isset($argv[3]) && $argv[3] === 'truncate') { DB::select('TRUNCATE TABLE anidb_info'); - DB::select('TRUNCATE TABLE anidb_episodes'); } if (isset($argv[2]) && $argv[2] === 'true') { $colorCli->header('Resetting all Anime postprocessing'); diff --git a/resources/views/admin/anidb/edit.blade.php b/resources/views/admin/anidb/edit.blade.php index 7d5593f08..b804339d3 100644 --- a/resources/views/admin/anidb/edit.blade.php +++ b/resources/views/admin/anidb/edit.blade.php @@ -125,11 +125,11 @@
@php - $coverPath = public_path('covers/anime/' . $anime['anidbid'] . '.jpg'); + $coverPath = storage_path('covers/anime/' . $anime['anidbid'] . '-cover.jpg'); $hasCover = file_exists($coverPath); @endphp @if($hasCover) - {{ $anime['title'] }} @@ -137,10 +137,36 @@

No cover image available

+

Cover will be downloaded from AniList when data is populated

@endif
+ + + @php + $anilistId = $anime['anilist_id'] ?? null; + $malId = $anime['mal_id'] ?? null; + @endphp + @if(!empty($anilistId) || !empty($malId)) +
+ +
+ @if(!empty($anilistId)) + + AniList + + @endif + @if(!empty($malId)) + + MyAnimeList + + @endif +
+
+ @endif
diff --git a/resources/views/admin/anidb/index.blade.php b/resources/views/admin/anidb/index.blade.php index 445d8ddbb..55f4b3a0d 100644 --- a/resources/views/admin/anidb/index.blade.php +++ b/resources/views/admin/anidb/index.blade.php @@ -61,11 +61,11 @@ @php - $coverPath = public_path('covers/anime/' . $anime->anidbid . '.jpg'); + $coverPath = storage_path('covers/anime/' . $anime->anidbid . '-cover.jpg'); $hasCover = file_exists($coverPath); @endphp @if($hasCover) - {{ $anime->title }} diff --git a/resources/views/details/index.blade.php b/resources/views/details/index.blade.php index f8e1c6794..021725e50 100644 --- a/resources/views/details/index.blade.php +++ b/resources/views/details/index.blade.php @@ -454,26 +454,166 @@ @php $anidbData = is_object($anidb) ? get_object_vars($anidb) : $anidb; $anidbTitle = $anidbData['title'] ?? ($anidb->title ?? null); + $anidbEnglishTitle = $anidbData['english_title'] ?? ($anidb->english_title ?? null); + $anidbOriginalTitle = $anidbData['original_title'] ?? ($anidb->original_title ?? null); + $anidbOriginalLang = $anidbData['original_lang'] ?? ($anidb->original_lang ?? null); + $anidbHashtag = $anidbData['hashtag'] ?? ($anidb->hashtag ?? null); $anidbType = $anidbData['type'] ?? ($anidb->type ?? null); + $anidbMediaType = $anidbData['media_type'] ?? ($anidb->media_type ?? null); + $anidbCountry = $anidbData['country'] ?? ($anidb->country ?? null); + $anidbEpisodes = $anidbData['episodes'] ?? ($anidb->episodes ?? null); + $anidbDuration = $anidbData['duration'] ?? ($anidb->duration ?? null); + $anidbStatus = $anidbData['status'] ?? ($anidb->status ?? null); + $anidbSource = $anidbData['source'] ?? ($anidb->source ?? null); $anidbStartDate = $anidbData['startdate'] ?? ($anidb->startdate ?? null); + $anidbEndDate = $anidbData['enddate'] ?? ($anidb->enddate ?? null); $anidbRating = $anidbData['rating'] ?? ($anidb->rating ?? null); $anidbDescription = $anidbData['description'] ?? ($anidb->description ?? null); + $anidbCategories = $anidbData['categories'] ?? ($anidb->categories ?? null); + $anidbCreators = $anidbData['creators'] ?? ($anidb->creators ?? null); + + // Get country name from country code + $countryName = null; + if (!empty($anidbCountry)) { + $country = \App\Models\Country::where('iso_3166_2', $anidbCountry)->first(); + $countryName = $country->name ?? $anidbCountry; + } + + // Format status + $statusLabels = [ + 'FINISHED' => 'Finished', + 'RELEASING' => 'Releasing', + 'NOT_YET_RELEASED' => 'Not Yet Released', + 'CANCELLED' => 'Cancelled', + 'HIATUS' => 'Hiatus', + ]; + $anidbStatusLabel = $statusLabels[$anidbStatus] ?? $anidbStatus; + + // Format source + $sourceLabels = [ + 'ORIGINAL' => 'Original', + 'MANGA' => 'Manga', + 'LIGHT_NOVEL' => 'Light Novel', + 'VISUAL_NOVEL' => 'Visual Novel', + 'VIDEO_GAME' => 'Video Game', + 'OTHER' => 'Other', + 'NOVEL' => 'Novel', + 'DOUJINSHI' => 'Doujinshi', + 'ANIME' => 'Anime', + 'WEB_MANGA' => 'Web Manga', + 'MUSIC' => 'Music', + '4_KOMA_MANGA' => '4-Koma Manga', + ]; + $anidbSourceLabel = $sourceLabels[$anidbSource] ?? $anidbSource; @endphp

- Anime Information + + @if($anidbMediaType === 'MANGA') + Manga Information + @else + Anime Information + @endif

- @if(!empty($anidbTitle)) + @if(!empty($anidbEnglishTitle) || !empty($anidbOriginalTitle) || !empty($anidbHashtag)) +
+
Titles
+
+ @if(!empty($anidbEnglishTitle)) +
+ English: + {{ $anidbEnglishTitle }} +
+ @endif + @if(!empty($anidbOriginalTitle)) +
+ + @if($anidbOriginalLang === 'ja') + Native: + @elseif($anidbOriginalLang === 'x-jat') + Romaji: + @else + Original: + @endif + + {{ $anidbOriginalTitle }} +
+ @endif + @if(!empty($anidbHashtag)) +
+ Hashtag: + {{ $anidbHashtag }} +
+ @endif + @if(empty($anidbEnglishTitle) && empty($anidbOriginalTitle) && !empty($anidbTitle)) +
+ {{ $anidbTitle }} +
+ @endif +
+
+ @elseif(!empty($anidbTitle))
Title
{{ $anidbTitle }}
@endif + @if(!empty($anidbMediaType)) +
+
Media Type
+
+ + {{ $anidbMediaType === 'ANIME' ? 'Anime' : 'Manga' }} + +
+
+ @endif @if(!empty($anidbType))
-
Type
-
{{ $anidbType }}
+
Format
+
{{ ucfirst(str_replace('_', ' ', strtolower($anidbType))) }}
+
+ @endif + @if(!empty($countryName)) +
+
Country
+
+ + @if(!empty($country)) + {{ $countryName }} + @endif + {{ $countryName }} + +
+
+ @endif + @if(!empty($anidbStatus)) +
+
Status
+
{{ $anidbStatusLabel }}
+
+ @endif + @if(!empty($anidbSource)) +
+
Source
+
{{ $anidbSourceLabel }}
+
+ @endif + @if(!empty($anidbEpisodes)) +
+
Episodes
+
{{ number_format($anidbEpisodes) }}
+
+ @endif + @if(!empty($anidbDuration)) +
+
Duration
+
{{ $anidbDuration }} minutes
@endif @if(!empty($anidbStartDate)) @@ -482,17 +622,40 @@
{{ $anidbStartDate }}
@endif + @if(!empty($anidbEndDate)) +
+
End Date
+
{{ $anidbEndDate }}
+
+ @endif @if(!empty($anidbRating))
Rating
- {{ $anidbRating }} + @php + $ratingValue = is_numeric($anidbRating) ? (float)$anidbRating : 0; + // AniList rating is out of 100, convert to /10 + $displayRating = $ratingValue > 10 ? number_format($ratingValue / 10, 1) : number_format($ratingValue, 1); + @endphp + {{ $displayRating }} / 10
@endif + @if(!empty($anidbCategories)) +
+
Genres
+
{{ $anidbCategories }}
+
+ @endif + @if(!empty($anidbCreators)) +
+
Studios
+
{{ $anidbCreators }}
+
+ @endif @if(!empty($anidbDescription))
Description
@@ -500,6 +663,29 @@
@endif
+ + + @php + $anilistId = $anidbData['anilist_id'] ?? ($anidb->anilist_id ?? null); + $malId = $anidbData['mal_id'] ?? ($anidb->mal_id ?? null); + @endphp + @if(!empty($anilistId) || !empty($malId)) +
+

External Links

+
+ @if(!empty($anilistId)) + + View on AniList + + @endif + @if(!empty($malId)) + + View on MyAnimeList + + @endif +
+
+ @endif
@endif