import type { DatabaseSync } from './sqlite'; import { animeSeasonsAreMergeCompatible, getParsedSeasonsForAnime } from './anime-merge'; import { toDbTimestamp } from './query-shared'; import { normalizeAnimeIdentityKey } from './storage'; import { nowMs } from './time'; export interface AnimeMergeRecommendation { recommendationId: number; animeIds: [number, number]; } export interface AnimeConflictRecommendationOptions { survivor?: 'target' | 'existing'; /** Automatic matches must be exact; manual assignment is authoritative. */ matchConfidence?: 'exact' | 'weak' | 'manual'; } interface AnimeTitleRow { canonical_title: string; title_romaji: string | null; title_english: string | null; title_native: string | null; } function getAnimeTitles(db: DatabaseSync, animeId: number): AnimeTitleRow | null { return db .prepare( `SELECT canonical_title, title_romaji, title_english, title_native FROM imm_anime WHERE anime_id = ? AND media_kind = 'anime'`, ) .get(animeId) as AnimeTitleRow | null; } function getParsedTitles(db: DatabaseSync, animeId: number): Array { return ( db.prepare('SELECT parsed_title FROM imm_videos WHERE anime_id = ?').all(animeId) as Array<{ parsed_title: string | null; }> ).map((row) => row.parsed_title); } function stripSeasonIdentitySuffix(title: string): string { return title .replace(/\bseason\s*\d{1,2}\b/gi, ' ') .replace(/\b\d{1,2}(?:st|nd|rd|th)\s+season\b/gi, ' ') .replace(/\bs\d{1,2}\b/gi, ' '); } export function hasExactStoredTitleMatch( db: DatabaseSync, targetAnimeId: number, conflictAnimeId: number, ): boolean { const target = getAnimeTitles(db, targetAnimeId); const conflict = getAnimeTitles(db, conflictAnimeId); if (!target || !conflict) return false; const targetKeys = [target.canonical_title, ...getParsedTitles(db, targetAnimeId)] .filter((title): title is string => Boolean(title?.trim())) .map((title) => normalizeAnimeIdentityKey(stripSeasonIdentitySuffix(title))) .filter(Boolean); const anilistTitleKeys = [ conflict.title_romaji, conflict.title_english, conflict.title_native, conflict.canonical_title, ] .filter((title): title is string => Boolean(title?.trim())) .map((title) => normalizeAnimeIdentityKey(stripSeasonIdentitySuffix(title))) .filter(Boolean); return targetKeys.some((key) => anilistTitleKeys.includes(key)); } export function shouldRecommendAnilistConflict( db: DatabaseSync, targetAnimeId: number, conflictAnimeId: number, options: AnimeConflictRecommendationOptions, ): boolean { if (!getAnimeTitles(db, targetAnimeId) || !getAnimeTitles(db, conflictAnimeId)) return false; if (options.survivor === 'target' || options.matchConfidence === 'manual') return false; if ( !animeSeasonsAreMergeCompatible( getParsedSeasonsForAnime(db, targetAnimeId), getParsedSeasonsForAnime(db, conflictAnimeId), ) ) { return false; } return ( options.matchConfidence === 'weak' || (options.matchConfidence === undefined && !hasExactStoredTitleMatch(db, targetAnimeId, conflictAnimeId)) ); } export function recordAnimeMergeRecommendation( db: DatabaseSync, firstCandidateAnimeId: number, secondCandidateAnimeId: number, anilistId: number, ): void { if (!getAnimeTitles(db, firstCandidateAnimeId) || !getAnimeTitles(db, secondCandidateAnimeId)) return; const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId); const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId); const timestamp = toDbTimestamp(nowMs()); db.prepare( `INSERT INTO imm_anime_merge_recommendations( first_anime_id, second_anime_id, anilist_id, status, CREATED_DATE, LAST_UPDATE_DATE ) VALUES (?, ?, ?, 'pending', ?, ?) ON CONFLICT(first_anime_id, second_anime_id, anilist_id) DO UPDATE SET LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`, ).run(firstAnimeId, secondAnimeId, anilistId, timestamp, timestamp); } export function hasDismissedAnimeMergeRecommendation( db: DatabaseSync, firstCandidateAnimeId: number, secondCandidateAnimeId: number, ): boolean { const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId); const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId); return Boolean( db .prepare( `SELECT 1 FROM imm_anime_merge_recommendations WHERE first_anime_id = ? AND second_anime_id = ? AND status = 'dismissed' LIMIT 1`, ) .get(firstAnimeId, secondAnimeId), ); } export function getAnimeMergeRecommendations(db: DatabaseSync): AnimeMergeRecommendation[] { return ( db .prepare( `SELECT recommendation_id AS recommendationId, first_anime_id AS firstAnimeId, second_anime_id AS secondAnimeId FROM imm_anime_merge_recommendations WHERE status = 'pending' AND first_anime_id IN (SELECT anime_id FROM imm_anime WHERE media_kind = 'anime') AND second_anime_id IN (SELECT anime_id FROM imm_anime WHERE media_kind = 'anime') ORDER BY recommendation_id ASC`, ) .all() as Array<{ recommendationId: number; firstAnimeId: number; secondAnimeId: number; }> ).map((row) => ({ recommendationId: row.recommendationId, animeIds: [row.firstAnimeId, row.secondAnimeId], })); } export function dismissAnimeMergeRecommendation( db: DatabaseSync, recommendationId: number, ): boolean { const result = db .prepare( `UPDATE imm_anime_merge_recommendations SET status = 'dismissed', LAST_UPDATE_DATE = ? WHERE recommendation_id = ? AND status = 'pending'`, ) .run(toDbTimestamp(nowMs()), recommendationId) as { changes: number }; return result.changes > 0; }