Replace anidb with anilist

This commit is contained in:
DariusIII
2025-12-05 13:21:26 +01:00
parent 928b413808
commit 7471218bb0
17 changed files with 1384 additions and 143 deletions
+81 -16
View File
@@ -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;
}
}
+7 -1
View File
@@ -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;
+640
View File
@@ -0,0 +1,640 @@
<?php
namespace Blacklight;
use App\Models\AnidbInfo;
use App\Models\AnidbTitle;
use App\Models\Settings;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Support\Carbon;
class PopulateAniList
{
/**
* AniList GraphQL API endpoint
*/
private const API_URL = 'https://graphql.anilist.co';
/**
* Rate limit: 90 requests per minute
*/
private const RATE_LIMIT_PER_MINUTE = 90;
/**
* Whether to echo message output.
*/
public bool $echooutput;
/**
* The directory to store anime covers.
*/
public string $imgSavePath;
/**
* HTTP client for API requests
*/
protected Client $client;
/**
* Rate limiting: track requests and timestamps
*/
private array $rateLimitQueue = [];
protected ColorCLI $colorCli;
/**
* @throws \Exception
*/
public function __construct()
{
$this->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);
}
}
}
+148 -82
View File
@@ -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<string,int>
*/
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 {
@@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class MigrateAnimeCovers extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'anime:migrate-covers {--dry-run : Show what would be renamed without actually renaming}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate anime cover files from old format ({id}.jpg) to new format ({id}-cover.jpg)';
/**
* Execute the console command.
*/
public function handle(): int
{
$animeCoverPath = storage_path('covers/anime/');
$dryRun = $this->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;
}
}
+13 -10
View File
@@ -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');
}
}
-1
View File
@@ -82,7 +82,6 @@ class NntmuxResetDb extends Command
'releases',
'anidb_titles',
'anidb_info',
'anidb_episodes',
'releases_groups',
];
foreach ($arr as &$value) {
+11
View File
@@ -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}");
}
@@ -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']);
+9 -5
View File
@@ -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');
}
}
-6
View File
@@ -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');
@@ -0,0 +1,142 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Drop anidb_episodes table (AniList doesn't support episodes)
Schema::dropIfExists('anidb_episodes');
// Add anilist_id, mal_id, country, and media_type columns to anidb_info
Schema::table('anidb_info', function (Blueprint $table) {
if (! Schema::hasColumn('anidb_info', 'anilist_id')) {
$table->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;
}
};
+13 -11
View File
@@ -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`;
-2
View File
@@ -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');
+28 -2
View File
@@ -125,11 +125,11 @@
</label>
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-900">
@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)
<img src="{{ asset('covers/anime/' . $anime['anidbid'] . '.jpg') }}"
<img src="{{ url('/covers/anime/' . $anime['anidbid'] . '-cover.jpg') }}"
alt="{{ $anime['title'] }}"
class="max-w-full h-auto mx-auto rounded shadow-lg"
style="max-height: 400px;">
@@ -137,10 +137,36 @@
<div class="flex flex-col items-center justify-center py-12 text-gray-400 dark:text-gray-500">
<i class="fas fa-dragon text-6xl mb-3"></i>
<p>No cover image available</p>
<p class="text-xs mt-2">Cover will be downloaded from AniList when data is populated</p>
</div>
@endif
</div>
</div>
<!-- External Links -->
@php
$anilistId = $anime['anilist_id'] ?? null;
$malId = $anime['mal_id'] ?? null;
@endphp
@if(!empty($anilistId) || !empty($malId))
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
External Links
</label>
<div class="flex flex-wrap gap-2">
@if(!empty($anilistId))
<a href="{{ $site['dereferrer_link'] ?? '' }}https://anilist.co/anime/{{ $anilistId }}" target="_blank" class="inline-flex items-center px-3 py-2 bg-blue-100 text-blue-800 rounded-lg hover:bg-blue-200 transition text-sm">
<i class="fas fa-external-link-alt mr-2"></i> AniList
</a>
@endif
@if(!empty($malId))
<a href="{{ $site['dereferrer_link'] ?? '' }}https://myanimelist.net/anime/{{ $malId }}" target="_blank" class="inline-flex items-center px-3 py-2 bg-yellow-100 text-yellow-800 rounded-lg hover:bg-yellow-200 transition text-sm">
<i class="fas fa-external-link-alt mr-2"></i> MyAnimeList
</a>
@endif
</div>
</div>
@endif
<!-- Description -->
<div>
+2 -2
View File
@@ -61,11 +61,11 @@
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap">
@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)
<img src="{{ asset('covers/anime/' . $anime->anidbid . '.jpg') }}"
<img src="{{ url('/covers/anime/' . $anime->anidbid . '-cover.jpg') }}"
alt="{{ $anime->title }}"
class="h-16 w-12 object-cover rounded shadow"
loading="lazy">
+191 -5
View File
@@ -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
<div class="bg-gradient-to-r from-pink-50 to-purple-50 dark:from-pink-900 dark:to-purple-900 rounded-lg p-6 border border-pink-100 dark:border-pink-800">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
<i class="fas fa-dragon mr-2 text-pink-600 dark:text-pink-400"></i> Anime Information
<i class="fas fa-dragon mr-2 text-pink-600 dark:text-pink-400"></i>
@if($anidbMediaType === 'MANGA')
Manga Information
@else
Anime Information
@endif
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@if(!empty($anidbTitle))
@if(!empty($anidbEnglishTitle) || !empty($anidbOriginalTitle) || !empty($anidbHashtag))
<div class="md:col-span-2">
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Titles</dt>
<dd class="mt-1 space-y-2">
@if(!empty($anidbEnglishTitle))
<div class="text-sm text-gray-900 dark:text-gray-100 font-semibold">
<span class="text-xs text-gray-500 dark:text-gray-400 font-normal mr-2">English:</span>
{{ $anidbEnglishTitle }}
</div>
@endif
@if(!empty($anidbOriginalTitle))
<div class="text-sm text-gray-900 dark:text-gray-100 font-semibold">
<span class="text-xs text-gray-500 dark:text-gray-400 font-normal mr-2">
@if($anidbOriginalLang === 'ja')
Native:
@elseif($anidbOriginalLang === 'x-jat')
Romaji:
@else
Original:
@endif
</span>
{{ $anidbOriginalTitle }}
</div>
@endif
@if(!empty($anidbHashtag))
<div class="text-sm text-gray-900 dark:text-gray-100">
<span class="text-xs text-gray-500 dark:text-gray-400 font-normal mr-2">Hashtag:</span>
<span class="font-mono text-blue-600 dark:text-blue-400">{{ $anidbHashtag }}</span>
</div>
@endif
@if(empty($anidbEnglishTitle) && empty($anidbOriginalTitle) && !empty($anidbTitle))
<div class="text-sm text-gray-900 dark:text-gray-100 font-semibold">
{{ $anidbTitle }}
</div>
@endif
</dd>
</div>
@elseif(!empty($anidbTitle))
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Title</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100 font-semibold">{{ $anidbTitle }}</dd>
</div>
@endif
@if(!empty($anidbMediaType))
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Media Type</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium
{{ $anidbMediaType === 'ANIME' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' }}">
{{ $anidbMediaType === 'ANIME' ? 'Anime' : 'Manga' }}
</span>
</dd>
</div>
@endif
@if(!empty($anidbType))
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Type</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $anidbType }}</dd>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Format</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ ucfirst(str_replace('_', ' ', strtolower($anidbType))) }}</dd>
</div>
@endif
@if(!empty($countryName))
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Country</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<span class="inline-flex items-center">
@if(!empty($country))
<img src="{{ asset('assets/images/flags/' . strtolower($anidbCountry) . '.png') }}"
alt="{{ $countryName }}"
class="w-4 h-3 mr-1"
onerror="this.style.display='none'">
@endif
{{ $countryName }}
</span>
</dd>
</div>
@endif
@if(!empty($anidbStatus))
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Status</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $anidbStatusLabel }}</dd>
</div>
@endif
@if(!empty($anidbSource))
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Source</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $anidbSourceLabel }}</dd>
</div>
@endif
@if(!empty($anidbEpisodes))
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Episodes</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ number_format($anidbEpisodes) }}</dd>
</div>
@endif
@if(!empty($anidbDuration))
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Duration</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $anidbDuration }} minutes</dd>
</div>
@endif
@if(!empty($anidbStartDate))
@@ -482,17 +622,40 @@
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $anidbStartDate }}</dd>
</div>
@endif
@if(!empty($anidbEndDate))
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">End Date</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $anidbEndDate }}</dd>
</div>
@endif
@if(!empty($anidbRating))
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Rating</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<span class="inline-flex items-center">
<i class="fas fa-star text-yellow-500 dark:text-yellow-400 mr-1"></i>
{{ $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
</span>
</dd>
</div>
@endif
@if(!empty($anidbCategories))
<div class="md:col-span-2">
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Genres</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $anidbCategories }}</dd>
</div>
@endif
@if(!empty($anidbCreators))
<div class="md:col-span-2">
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Studios</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $anidbCreators }}</dd>
</div>
@endif
@if(!empty($anidbDescription))
<div class="md:col-span-2">
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Description</dt>
@@ -500,6 +663,29 @@
</div>
@endif
</div>
<!-- External Links -->
@php
$anilistId = $anidbData['anilist_id'] ?? ($anidb->anilist_id ?? null);
$malId = $anidbData['mal_id'] ?? ($anidb->mal_id ?? null);
@endphp
@if(!empty($anilistId) || !empty($malId))
<div class="mt-4 pt-4 border-t border-pink-200 dark:border-pink-700">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">External Links</h4>
<div class="flex flex-wrap gap-3">
@if(!empty($anilistId))
<a href="{{ $site['dereferrer_link'] ?? '' }}https://anilist.co/anime/{{ $anilistId }}" target="_blank" class="inline-flex items-center px-4 py-2 bg-blue-100 text-blue-800 rounded-lg hover:bg-blue-200 transition">
<i class="fas fa-external-link-alt mr-2"></i> View on AniList
</a>
@endif
@if(!empty($malId))
<a href="{{ $site['dereferrer_link'] ?? '' }}https://myanimelist.net/anime/{{ $malId }}" target="_blank" class="inline-flex items-center px-4 py-2 bg-yellow-100 text-yellow-800 rounded-lg hover:bg-yellow-200 transition">
<i class="fas fa-external-link-alt mr-2"></i> View on MyAnimeList
</a>
@endif
</div>
</div>
@endif
</div>
@endif