mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -07:00
d8c5fc321a
- Preserve merged title aliases for future episodes - Fail closed when queued writes cannot drain
467 lines
13 KiB
TypeScript
467 lines
13 KiB
TypeScript
import type { DatabaseSync } from './sqlite';
|
|
import {
|
|
animeSeasonsAreMergeCompatible,
|
|
getParsedSeasonsForAnime,
|
|
mergeAnimeRecordsInTransaction,
|
|
} from './anime-merge';
|
|
import {
|
|
hasExactStoredTitleMatch,
|
|
hasDismissedAnimeMergeRecommendation,
|
|
recordAnimeMergeRecommendation,
|
|
shouldRecommendAnilistConflict,
|
|
type AnimeConflictRecommendationOptions,
|
|
} from './anime-merge-recommendations';
|
|
import { getOrCreateAnimeRecord } from './storage';
|
|
import { toDbTimestamp } from './query-shared';
|
|
import { nowMs } from './time';
|
|
|
|
export interface AnimeSeasonRepairSummary {
|
|
scanned: number;
|
|
repaired: number;
|
|
movedVideos: number;
|
|
deletedAnimeRows: number;
|
|
/**
|
|
* Entry that owns the videos afterwards when two rows were folded together,
|
|
* so callers can keep pointing at a row that still exists.
|
|
*/
|
|
survivingAnimeId: number | null;
|
|
/** True when an ambiguous AniList collision was saved for user review. */
|
|
mergeRecommended: boolean;
|
|
/** True when automatic metadata must not assign the colliding AniList id. */
|
|
anilistAssignmentBlocked: boolean;
|
|
}
|
|
|
|
export interface AnimeAnilistConflictOptions extends AnimeConflictRecommendationOptions {
|
|
/**
|
|
* Which row keeps its identity when two entries claim the same AniList id.
|
|
* `existing` (the default) keeps the row that already held the id, so
|
|
* automatic cover-art resolution does not rename a card under the user;
|
|
* `target` keeps the row the user is acting on.
|
|
*/
|
|
survivor?: 'target' | 'existing';
|
|
}
|
|
|
|
export {
|
|
dismissAnimeMergeRecommendation,
|
|
getAnimeMergeRecommendations,
|
|
type AnimeMergeRecommendation,
|
|
} from './anime-merge-recommendations';
|
|
|
|
interface AnimeRow {
|
|
anime_id: number;
|
|
anilist_id: number | null;
|
|
title_romaji: string | null;
|
|
title_english: string | null;
|
|
title_native: string | null;
|
|
episodes_total: number | null;
|
|
description: string | null;
|
|
}
|
|
|
|
interface ParsedVideoRow {
|
|
video_id: number;
|
|
parsed_title: string | null;
|
|
parsed_season: number | null;
|
|
}
|
|
|
|
interface RedistributeOptions {
|
|
transferAnilistToAnimeId?: number | null;
|
|
transferLegacyAnilist?: boolean;
|
|
overwriteTargetAnilist?: boolean;
|
|
}
|
|
|
|
function emptySummary(scanned = 0): AnimeSeasonRepairSummary {
|
|
return {
|
|
scanned,
|
|
repaired: 0,
|
|
movedVideos: 0,
|
|
deletedAnimeRows: 0,
|
|
survivingAnimeId: null,
|
|
mergeRecommended: false,
|
|
anilistAssignmentBlocked: false,
|
|
};
|
|
}
|
|
|
|
function mergeSummary(
|
|
target: AnimeSeasonRepairSummary,
|
|
source: AnimeSeasonRepairSummary,
|
|
): AnimeSeasonRepairSummary {
|
|
target.scanned += source.scanned;
|
|
target.repaired += source.repaired;
|
|
target.movedVideos += source.movedVideos;
|
|
target.deletedAnimeRows += source.deletedAnimeRows;
|
|
target.survivingAnimeId = source.survivingAnimeId ?? target.survivingAnimeId;
|
|
target.mergeRecommended ||= source.mergeRecommended;
|
|
target.anilistAssignmentBlocked ||= source.anilistAssignmentBlocked;
|
|
return target;
|
|
}
|
|
|
|
function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
|
|
db.exec('BEGIN');
|
|
try {
|
|
const result = work();
|
|
db.exec('COMMIT');
|
|
return result;
|
|
} catch (error) {
|
|
db.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function normalizeSeason(value: number | null): number | null {
|
|
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
|
return null;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null {
|
|
return db
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
anime_id,
|
|
anilist_id,
|
|
title_romaji,
|
|
title_english,
|
|
title_native,
|
|
episodes_total,
|
|
description
|
|
FROM imm_anime
|
|
WHERE anime_id = ?
|
|
`,
|
|
)
|
|
.get(animeId) as AnimeRow | null;
|
|
}
|
|
|
|
function getParsedVideos(db: DatabaseSync, animeId: number): ParsedVideoRow[] {
|
|
return db
|
|
.prepare(
|
|
`
|
|
SELECT video_id, parsed_title, parsed_season
|
|
FROM imm_videos
|
|
WHERE anime_id = ?
|
|
ORDER BY video_id ASC
|
|
`,
|
|
)
|
|
.all(animeId) as ParsedVideoRow[];
|
|
}
|
|
|
|
function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean {
|
|
const row = db
|
|
.prepare(
|
|
`
|
|
SELECT 1 AS found
|
|
WHERE EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
|
|
OR EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
|
|
`,
|
|
)
|
|
.get(animeId, animeId) as { found: number } | null;
|
|
return Boolean(row);
|
|
}
|
|
|
|
function assignAnilistToTarget(
|
|
db: DatabaseSync,
|
|
source: AnimeRow,
|
|
targetAnimeId: number,
|
|
overwriteTarget: boolean,
|
|
updatedAt: string,
|
|
): boolean {
|
|
if (source.anilist_id === null || targetAnimeId === source.anime_id) {
|
|
return false;
|
|
}
|
|
|
|
const target = getAnimeRow(db, targetAnimeId);
|
|
if (!target) {
|
|
return false;
|
|
}
|
|
if (!overwriteTarget && target.anilist_id !== null && target.anilist_id !== source.anilist_id) {
|
|
return false;
|
|
}
|
|
|
|
db.prepare(
|
|
`
|
|
UPDATE imm_anime
|
|
SET anilist_id = NULL,
|
|
LAST_UPDATE_DATE = ?
|
|
WHERE anime_id = ?
|
|
`,
|
|
).run(updatedAt, source.anime_id);
|
|
|
|
const updated = db
|
|
.prepare(
|
|
`
|
|
UPDATE imm_anime
|
|
SET
|
|
anilist_id = ?,
|
|
title_romaji = COALESCE(?, title_romaji),
|
|
title_english = COALESCE(?, title_english),
|
|
title_native = COALESCE(?, title_native),
|
|
episodes_total = COALESCE(?, episodes_total),
|
|
description = COALESCE(?, description),
|
|
LAST_UPDATE_DATE = ?
|
|
WHERE anime_id = ?
|
|
`,
|
|
)
|
|
.run(
|
|
source.anilist_id,
|
|
source.title_romaji,
|
|
source.title_english,
|
|
source.title_native,
|
|
source.episodes_total,
|
|
source.description,
|
|
updatedAt,
|
|
targetAnimeId,
|
|
) as { changes: number };
|
|
return updated.changes > 0;
|
|
}
|
|
|
|
function redistributeAnimeRowByParsedSeasonsInTransaction(
|
|
db: DatabaseSync,
|
|
animeId: number,
|
|
options: RedistributeOptions = {},
|
|
): AnimeSeasonRepairSummary {
|
|
const source = getAnimeRow(db, animeId);
|
|
if (!source) {
|
|
return emptySummary(1);
|
|
}
|
|
|
|
const videos = getParsedVideos(db, animeId);
|
|
const summary = emptySummary(1);
|
|
const updatedAt = toDbTimestamp(nowMs());
|
|
const targetBySeason = new Map<number, number>();
|
|
|
|
for (const video of videos) {
|
|
const parsedTitle = video.parsed_title?.trim();
|
|
const season = normalizeSeason(video.parsed_season);
|
|
if (!parsedTitle || season === null) {
|
|
continue;
|
|
}
|
|
|
|
const targetAnimeId = getOrCreateAnimeRecord(db, {
|
|
parsedTitle,
|
|
canonicalTitle: parsedTitle,
|
|
seasonScope: season,
|
|
anilistId: null,
|
|
titleRomaji: null,
|
|
titleEnglish: null,
|
|
titleNative: null,
|
|
metadataJson: null,
|
|
});
|
|
targetBySeason.set(season, targetAnimeId);
|
|
|
|
if (targetAnimeId === animeId) {
|
|
continue;
|
|
}
|
|
|
|
const videoUpdate = db
|
|
.prepare(
|
|
`
|
|
UPDATE imm_videos
|
|
SET anime_id = ?,
|
|
LAST_UPDATE_DATE = ?
|
|
WHERE video_id = ?
|
|
`,
|
|
)
|
|
.run(targetAnimeId, updatedAt, video.video_id) as { changes: number };
|
|
const lineUpdate = db
|
|
.prepare(
|
|
`
|
|
UPDATE imm_subtitle_lines
|
|
SET anime_id = ?,
|
|
LAST_UPDATE_DATE = ?
|
|
WHERE video_id = ?
|
|
`,
|
|
)
|
|
.run(targetAnimeId, updatedAt, video.video_id) as { changes: number };
|
|
|
|
if (videoUpdate.changes > 0 || lineUpdate.changes > 0) {
|
|
summary.movedVideos += 1;
|
|
}
|
|
}
|
|
|
|
const transferTarget =
|
|
options.transferAnilistToAnimeId ??
|
|
(options.transferLegacyAnilist
|
|
? (targetBySeason.get(1) ??
|
|
(targetBySeason.size === 1 ? [...targetBySeason.values()][0] : null))
|
|
: null);
|
|
if (transferTarget) {
|
|
const transferred = assignAnilistToTarget(
|
|
db,
|
|
source,
|
|
transferTarget,
|
|
options.overwriteTargetAnilist ?? false,
|
|
updatedAt,
|
|
);
|
|
if (transferred) {
|
|
summary.repaired += 1;
|
|
}
|
|
}
|
|
|
|
if (!hasAnimeReferences(db, animeId)) {
|
|
const deleted = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(animeId) as {
|
|
changes: number;
|
|
};
|
|
if (deleted.changes > 0) {
|
|
summary.deletedAnimeRows += 1;
|
|
}
|
|
}
|
|
|
|
if (summary.movedVideos > 0 || summary.deletedAnimeRows > 0) {
|
|
summary.repaired += 1;
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRepairSummary {
|
|
return runInTransaction(db, () => {
|
|
const candidates = db
|
|
.prepare(
|
|
`
|
|
SELECT a.anime_id AS animeId
|
|
FROM imm_anime a
|
|
JOIN imm_videos v ON v.anime_id = a.anime_id
|
|
WHERE v.parsed_title IS NOT NULL
|
|
AND TRIM(v.parsed_title) != ''
|
|
AND v.parsed_season IS NOT NULL
|
|
AND v.parsed_season > 0
|
|
GROUP BY a.anime_id
|
|
HAVING COUNT(DISTINCT v.parsed_season) > 1
|
|
ORDER BY a.anime_id ASC
|
|
`,
|
|
)
|
|
.all() as Array<{ animeId: number }>;
|
|
const summary = emptySummary();
|
|
for (const candidate of candidates) {
|
|
mergeSummary(
|
|
summary,
|
|
redistributeAnimeRowByParsedSeasonsInTransaction(db, candidate.animeId, {
|
|
transferLegacyAnilist: true,
|
|
}),
|
|
);
|
|
}
|
|
return summary;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Two library entries cannot both hold the same AniList id
|
|
* (`imm_anime.anilist_id` is UNIQUE). Fold an automatic collision only when
|
|
* exact title evidence and compatible parsed seasons make it safe. Persist a
|
|
* review recommendation for compatible weak matches. Fall back to legacy
|
|
* season redistribution when the conflicting row spans several seasons.
|
|
*/
|
|
export function resolveAnimeAnilistConflict(
|
|
db: DatabaseSync,
|
|
targetAnimeId: number,
|
|
anilistId: number,
|
|
options: AnimeAnilistConflictOptions = {},
|
|
): AnimeSeasonRepairSummary {
|
|
const conflict = db
|
|
.prepare(
|
|
`
|
|
SELECT anime_id AS animeId
|
|
FROM imm_anime
|
|
WHERE anilist_id = ?
|
|
AND anime_id != ?
|
|
LIMIT 1
|
|
`,
|
|
)
|
|
.get(anilistId, targetAnimeId) as { animeId: number } | null;
|
|
if (!conflict) {
|
|
return emptySummary();
|
|
}
|
|
|
|
return runInTransaction(db, () => {
|
|
const targetRow = getAnimeRow(db, targetAnimeId);
|
|
if (
|
|
options.survivor !== 'target' &&
|
|
targetRow?.anilist_id != null &&
|
|
targetRow.anilist_id !== anilistId
|
|
) {
|
|
// An automatic lookup disagreeing with an existing explicit link is a
|
|
// mis-resolution, not evidence that either row should move or merge.
|
|
return emptySummary(1);
|
|
}
|
|
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
|
|
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
|
|
const summary = emptySummary(1);
|
|
summary.anilistAssignmentBlocked = true;
|
|
return summary;
|
|
}
|
|
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
|
|
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
|
|
if (
|
|
targetSeasons.size === 1 &&
|
|
conflictSeasons.size === 1 &&
|
|
[...targetSeasons][0] !== [...conflictSeasons][0]
|
|
) {
|
|
const summary = emptySummary(1);
|
|
summary.anilistAssignmentBlocked = true;
|
|
return summary;
|
|
}
|
|
if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) {
|
|
const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId;
|
|
const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId;
|
|
const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]);
|
|
const summary = emptySummary(1);
|
|
summary.movedVideos = merge.movedVideos;
|
|
summary.deletedAnimeRows = merge.mergedAnimeIds.length;
|
|
if (merge.mergedAnimeIds.length > 0) {
|
|
summary.repaired = 1;
|
|
// Only reported once a row really absorbed the other, so callers never
|
|
// follow this to an anime id that was never written.
|
|
summary.survivingAnimeId = survivingAnimeId;
|
|
}
|
|
// Lifetime summaries are rebuilt by the caller off this summary, the same
|
|
// as the redistribution path below.
|
|
return summary;
|
|
}
|
|
|
|
if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) {
|
|
recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId);
|
|
const summary = emptySummary(1);
|
|
summary.mergeRecommended = true;
|
|
return summary;
|
|
}
|
|
|
|
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
|
|
transferAnilistToAnimeId: targetAnimeId,
|
|
overwriteTargetAnilist: true,
|
|
});
|
|
});
|
|
}
|
|
|
|
function canMergeAnilistConflict(
|
|
db: DatabaseSync,
|
|
targetAnimeId: number,
|
|
conflictAnimeId: number,
|
|
anilistId: number,
|
|
options: AnimeAnilistConflictOptions,
|
|
): boolean {
|
|
const targetRow = getAnimeRow(db, targetAnimeId);
|
|
if (!targetRow) {
|
|
// Nothing to merge with a row that no longer exists (a stale id from the
|
|
// caller); fall through to the redistribution path.
|
|
return false;
|
|
}
|
|
if (options.survivor !== 'target') {
|
|
// The target is the row about to disappear here, so an existing link of its
|
|
// own means this is a mis-resolution rather than a duplicate: leave it be.
|
|
if (targetRow.anilist_id != null && targetRow.anilist_id !== anilistId) {
|
|
return false;
|
|
}
|
|
}
|
|
if (
|
|
options.matchConfidence === 'weak' ||
|
|
(options.matchConfidence === undefined &&
|
|
!hasExactStoredTitleMatch(db, targetAnimeId, conflictAnimeId))
|
|
) {
|
|
return false;
|
|
}
|
|
return animeSeasonsAreMergeCompatible(
|
|
getParsedSeasonsForAnime(db, targetAnimeId),
|
|
getParsedSeasonsForAnime(db, conflictAnimeId),
|
|
);
|
|
}
|