Files
SubMiner/src/core/services/immersion-tracker/anime-merge-recommendations.ts
T

175 lines
5.8 KiB
TypeScript

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<string | null> {
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;
}