mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 05:16:20 -07:00
175 lines
5.8 KiB
TypeScript
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;
|
|
}
|