feat(stats): add library entry merge and episode move

- Add multi-select "Merge Selected" flow to fold duplicate library cards into one, preserving sessions, mined cards, and watch time
- Add per-episode "move to another entry" action for reassigning stray episodes, pruning the source entry when emptied
- Auto-merge library entries that resolve to the same AniList id when their parsed seasons are compatible
- Add mergeAnime/moveVideoToAnime service methods, stats-server routes, and HTTP contract types
This commit is contained in:
2026-08-10 22:52:34 -07:00
parent 2fefc83e3f
commit 5938095d92
19 changed files with 1629 additions and 9 deletions
@@ -3024,6 +3024,109 @@ Aligned English subtitle
assert.equal(deleteCalls, 0);
});
it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => {
let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
const app = createStatsApp(
createMockTracker({
mergeAnime: async (targetAnimeId: number, sourceAnimeIds: number[]) => {
merged = { targetAnimeId, sourceAnimeIds };
return {
survivingAnimeId: targetAnimeId,
mergedAnimeIds: sourceAnimeIds,
movedVideos: 3,
};
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The target repeated in the sources must not delete the entry we keep.
body: '{"sourceAnimeIds":[8,9,8,7]}',
});
assert.equal(res.status, 200);
assert.deepEqual(merged, { targetAnimeId: 7, sourceAnimeIds: [8, 9] });
assert.deepEqual(await res.json(), {
ok: true,
animeId: 7,
mergedAnimeIds: [8, 9],
movedVideos: 3,
});
});
it('POST /api/stats/anime/:animeId/merge rejects an empty or malformed source list', async () => {
let mergeCalls = 0;
const app = createStatsApp(
createMockTracker({
mergeAnime: async () => {
mergeCalls += 1;
return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 };
},
} as Partial<ImmersionTrackerService>),
);
for (const body of [
'{"sourceAnimeIds":[]}',
'{"sourceAnimeIds":[7]}',
'{"sourceAnimeIds":0}',
]) {
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
assert.equal(res.status, 400);
}
assert.equal(mergeCalls, 0);
});
it('PATCH /api/stats/media/:videoId/anime moves the episode to another entry', async () => {
let moved: { videoId: number; animeId: number } | null = null;
const app = createStatsApp(
createMockTracker({
moveVideoToAnime: async (videoId: number, animeId: number) => {
moved = { videoId, animeId };
return { targetAnimeId: animeId, previousAnimeId: 4, removedPreviousAnime: true };
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/media/12/anime', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: '{"animeId":7}',
});
assert.equal(res.status, 200);
assert.deepEqual(moved, { videoId: 12, animeId: 7 });
assert.deepEqual(await res.json(), {
ok: true,
animeId: 7,
previousAnimeId: 4,
removedPreviousAnime: true,
});
});
it('PATCH /api/stats/media/:videoId/anime reports an unknown target as 404', async () => {
const app = createStatsApp(
createMockTracker({
moveVideoToAnime: async () => {
throw new Error('Unknown episode or target library entry');
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/media/12/anime', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: '{"animeId":99}',
});
assert.equal(res.status, 404);
});
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
+29 -1
View File
@@ -96,6 +96,12 @@ import {
repairLegacySeasonlessAnimeRows,
resolveAnimeAnilistConflict,
} from './immersion-tracker/anime-season-repair';
import {
mergeAnimeRecords,
moveVideoToAnime as moveVideoToAnimeQuery,
type AnimeMergeSummary,
type VideoMoveSummary,
} from './immersion-tracker/anime-merge';
import {
buildVideoKey,
deriveCanonicalTitle,
@@ -756,6 +762,24 @@ export class ImmersionTrackerService {
deleteAnimeQuery(this.db, animeId);
}
/**
* Fold duplicate library entries into one. Sources that hold the currently
* playing episode are fine: the videos move, nothing is deleted out from
* under the active session.
*/
async mergeAnime(targetAnimeId: number, sourceAnimeIds: number[]): Promise<AnimeMergeSummary> {
const pendingVideoId = this.sessionState?.videoId;
if (pendingVideoId !== undefined) {
await this.pendingAnimeMetadataUpdates.get(pendingVideoId);
}
return mergeAnimeRecords(this.db, targetAnimeId, sourceAnimeIds);
}
async moveVideoToAnime(videoId: number, targetAnimeId: number): Promise<VideoMoveSummary> {
await this.pendingAnimeMetadataUpdates.get(videoId);
return moveVideoToAnimeQuery(this.db, videoId, targetAnimeId);
}
async reassignAnimeAnilist(
animeId: number,
info: {
@@ -768,7 +792,11 @@ export class ImmersionTrackerService {
coverUrl?: string | null;
},
): Promise<void> {
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId);
// The user is acting on this entry, so it is the one that survives when
// another row already claims the same AniList id.
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, {
survivor: 'target',
});
this.db
.prepare(
`
@@ -0,0 +1,320 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
import { applyPragmas, ensureSchema } from '../storage.js';
import { mergeAnimeRecords, moveVideoToAnime } from '../anime-merge.js';
import { resolveAnimeAnilistConflict } from '../anime-season-repair.js';
const BASE_MS = 1_700_000_000_000;
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anime-merge-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) return;
fs.rmSync(dir, { recursive: true, force: true });
}
function withDb(work: (db: DatabaseSync) => void): void {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
applyPragmas(db);
ensureSchema(db);
work(db);
} finally {
db.close();
cleanupDbPath(dbPath);
}
}
interface AnimeSeed {
animeId: number;
key: string;
title: string;
anilistId?: number | null;
titleRomaji?: string | null;
}
function insertAnime(db: DatabaseSync, seed: AnimeSeed): void {
db.prepare(
`INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, anilist_id, title_romaji, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
seed.animeId,
seed.key,
seed.title,
seed.anilistId ?? null,
seed.titleRomaji ?? null,
BASE_MS,
BASE_MS,
);
}
interface EpisodeSeed {
videoId: number;
animeId: number;
season?: number | null;
episode?: number;
activeMs?: number;
cards?: number;
}
/** One episode with one ended session, so lifetime rebuilds have something to sum. */
function insertEpisode(db: DatabaseSync, seed: EpisodeSeed): void {
const activeMs = seed.activeMs ?? 1000;
const cards = seed.cards ?? 1;
db.prepare(
`INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, parsed_title, parsed_season, parsed_episode, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, 1, 'Show', ?, ?, 1, 1440000, ?, ?)`,
).run(
seed.videoId,
`local:/tmp/show-${seed.videoId}.mkv`,
seed.animeId,
`Show ${seed.videoId}`,
seed.season ?? null,
seed.episode ?? seed.videoId,
BASE_MS,
BASE_MS,
);
db.prepare(
`INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms, cards_mined, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?, 2, ?, ?, ?, ?)`,
).run(
seed.videoId,
`session-${seed.videoId}`,
seed.videoId,
String(BASE_MS),
String(BASE_MS + activeMs),
activeMs,
cards,
BASE_MS,
BASE_MS,
);
db.prepare(
`INSERT INTO imm_subtitle_lines(session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, 1, ?, ?, ?)`,
).run(seed.videoId, seed.videoId, seed.animeId, `line ${seed.videoId}`, BASE_MS, BASE_MS);
}
function animeIds(db: DatabaseSync): number[] {
return (
db.prepare('SELECT anime_id AS id FROM imm_anime ORDER BY anime_id').all() as Array<{
id: number;
}>
).map((row) => row.id);
}
function videoAnimeId(db: DatabaseSync, videoId: number): number | null {
return (
db.prepare('SELECT anime_id AS id FROM imm_videos WHERE video_id = ?').get(videoId) as {
id: number | null;
}
).id;
}
function lineAnimeIds(db: DatabaseSync, animeId: number): number {
return Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
.get(animeId) as { total: number }
).total,
);
}
test('mergeAnimeRecords folds episodes, lines and lifetime totals into the target', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1', anilistId: 555 });
insertEpisode(db, { videoId: 1, animeId: 1, activeMs: 1000, cards: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1, activeMs: 2000, cards: 3 });
const summary = mergeAnimeRecords(db, 1, [2]);
assert.equal(summary.survivingAnimeId, 1);
assert.deepEqual(summary.mergedAnimeIds, [2]);
assert.equal(summary.movedVideos, 1);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 2), 1);
assert.equal(lineAnimeIds(db, 1), 2);
const lifetime = db
.prepare(
'SELECT total_active_ms AS activeMs, total_cards AS cards, episodes_started AS episodes FROM imm_lifetime_anime WHERE anime_id = 1',
)
.get() as { activeMs: number; cards: number; episodes: number };
assert.equal(lifetime.activeMs, 3000);
assert.equal(lifetime.cards, 4);
assert.equal(lifetime.episodes, 2);
});
});
test('mergeAnimeRecords inherits metadata the target is missing without clobbering its own', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', titleRomaji: 'Shou' });
insertAnime(db, {
animeId: 2,
key: 'show season 1',
title: 'Show Season 1',
anilistId: 555,
titleRomaji: 'Show Romaji',
});
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
mergeAnimeRecords(db, 1, [2]);
const row = db
.prepare(
'SELECT canonical_title AS title, anilist_id AS anilistId, title_romaji AS romaji FROM imm_anime WHERE anime_id = 1',
)
.get() as { title: string; anilistId: number | null; romaji: string | null };
assert.equal(row.title, 'Show');
// anilist_id is UNIQUE, so inheriting it proves the source row was gone first.
assert.equal(row.anilistId, 555);
assert.equal(row.romaji, 'Shou');
});
});
test('mergeAnimeRecords ignores unknown targets and self-merges', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertEpisode(db, { videoId: 1, animeId: 1 });
assert.deepEqual(mergeAnimeRecords(db, 99, [1]).mergedAnimeIds, []);
assert.deepEqual(mergeAnimeRecords(db, 1, [1]).mergedAnimeIds, []);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 1), 1);
});
});
test('moveVideoToAnime moves one episode and prunes the emptied entry', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'stray', title: 'Stray Episode Title', anilistId: 777 });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, activeMs: 5000, cards: 2 });
const summary = moveVideoToAnime(db, 2, 1);
assert.equal(summary.targetAnimeId, 1);
assert.equal(summary.previousAnimeId, 2);
assert.equal(summary.removedPreviousAnime, true);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 2), 1);
assert.equal(lineAnimeIds(db, 1), 2);
const lifetime = db
.prepare('SELECT total_active_ms AS activeMs FROM imm_lifetime_anime WHERE anime_id = 1')
.get() as { activeMs: number };
assert.equal(lifetime.activeMs, 6000);
});
});
test('moveVideoToAnime keeps the source entry when other episodes remain', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'other', title: 'Other' });
insertEpisode(db, { videoId: 1, animeId: 2 });
insertEpisode(db, { videoId: 2, animeId: 2 });
const summary = moveVideoToAnime(db, 2, 1);
assert.equal(summary.removedPreviousAnime, false);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 1), 2);
assert.equal(videoAnimeId(db, 2), 1);
});
});
test('moveVideoToAnime rejects unknown episodes and targets', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertEpisode(db, { videoId: 1, animeId: 1 });
assert.throws(() => moveVideoToAnime(db, 99, 1));
assert.throws(() => moveVideoToAnime(db, 1, 99));
assert.equal(videoAnimeId(db, 1), 1);
});
});
test('resolveAnimeAnilistConflict folds a seasonless duplicate into the entry that owns the id', () => {
withDb((db) => {
// Same show, split because one release tagged S01 and the other did not.
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(summary.survivingAnimeId, 1);
assert.equal(summary.movedVideos, 1);
assert.equal(summary.deletedAnimeRows, 1);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 2), 1);
});
});
test('resolveAnimeAnilistConflict keeps the target entry when the user drove the change', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { survivor: 'target' });
assert.equal(summary.survivingAnimeId, 2);
assert.deepEqual(animeIds(db), [2]);
assert.equal(videoAnimeId(db, 1), 2);
const row = db.prepare('SELECT anilist_id AS id FROM imm_anime WHERE anime_id = 2').get() as {
id: number | null;
};
assert.equal(row.id, 163132);
});
});
test('resolveAnimeAnilistConflict falls back to season redistribution for multi-season rows', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 1, season: 2 });
insertEpisode(db, { videoId: 3, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132);
// The mixed row is split by season instead of being poured onto one card.
const titles = (
db.prepare('SELECT canonical_title AS title FROM imm_anime ORDER BY title').all() as Array<{
title: string;
}>
).map((row) => row.title);
assert.deepEqual(titles, ['Show Season 1', 'Show Season 2']);
assert.equal(videoAnimeId(db, 1), 2);
assert.equal(videoAnimeId(db, 3), 2);
assert.notEqual(videoAnimeId(db, 2), 2);
});
});
test('resolveAnimeAnilistConflict leaves an entry that already links elsewhere alone', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show s2', title: 'Show Season 2', anilistId: 999 });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(videoAnimeId(db, 2), 2);
assert.ok(animeIds(db).includes(2));
});
});
@@ -0,0 +1,252 @@
import type { DatabaseSync } from './sqlite';
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';
export interface AnimeMergeSummary {
/** Library entry that owns every moved episode once the merge finishes. */
survivingAnimeId: number;
/** Entries that were folded into the survivor and deleted. */
mergedAnimeIds: number[];
movedVideos: number;
}
export interface VideoMoveSummary {
targetAnimeId: number;
/** Previous owner, or null when the episode had no library entry yet. */
previousAnimeId: number | null;
/** True when the previous owner was left empty and pruned. */
removedPreviousAnime: boolean;
}
interface AnimeMetadataRow {
anilist_id: number | null;
title_romaji: string | null;
title_english: string | null;
title_native: string | null;
episodes_total: number | null;
description: string | null;
}
function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary {
return { survivingAnimeId, mergedAnimeIds: [], movedVideos: 0 };
}
function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
db.exec('BEGIN IMMEDIATE');
try {
const result = work();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow | null {
return (db
.prepare(
`
SELECT anilist_id, title_romaji, title_english, title_native, episodes_total, description
FROM imm_anime
WHERE anime_id = ?
`,
)
.get(animeId) ?? null) as AnimeMetadataRow | null;
}
function animeExists(db: DatabaseSync, animeId: number): boolean {
return Boolean(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId));
}
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);
}
/**
* Distinct explicit seasons behind a library entry. Videos with no parsed
* season are ignored, so an entry built from `Show - 03.mkv` style filenames
* reports an empty set rather than a bogus season.
*/
export function getParsedSeasonsForAnime(db: DatabaseSync, animeId: number): Set<number> {
const rows = db
.prepare(
`
SELECT DISTINCT parsed_season AS season
FROM imm_videos
WHERE anime_id = ?
AND parsed_season IS NOT NULL
AND parsed_season > 0
`,
)
.all(animeId) as Array<{ season: number }>;
return new Set(rows.map((row) => row.season));
}
/**
* Two entries are safe to fold together when neither spans more than one
* explicit season and they do not disagree about which season that is. A
* seasonless entry is compatible with anything single-season: those are the
* `Show - 03.mkv` vs `Show.S01E03.mkv` splits that produce duplicate cards.
*/
export function animeSeasonsAreMergeCompatible(a: Set<number>, b: Set<number>): boolean {
if (a.size > 1 || b.size > 1) return false;
if (a.size === 0 || b.size === 0) return true;
return [...a][0] === [...b][0];
}
/**
* Fill in whatever the target is missing from a source row that is on its way
* out. Must run after the source row is deleted: imm_anime.anilist_id is
* UNIQUE, so the two rows cannot hold the same id at once.
*/
function absorbAnimeMetadata(
db: DatabaseSync,
targetAnimeId: number,
source: AnimeMetadataRow | null,
updatedAt: string,
): void {
if (!source) return;
db.prepare(
`
UPDATE imm_anime
SET
anilist_id = COALESCE(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,
);
}
/**
* Fold `sourceAnimeIds` into `targetAnimeId`: every episode and subtitle line
* is repointed, metadata the target is missing is inherited from the sources,
* and the emptied source rows are deleted.
*
* Assumes the caller already holds a write transaction and rebuilds the
* lifetime summaries afterwards; use {@link mergeAnimeRecords} otherwise.
*/
export function mergeAnimeRecordsInTransaction(
db: DatabaseSync,
targetAnimeId: number,
sourceAnimeIds: number[],
): AnimeMergeSummary {
const summary = emptyMergeSummary(targetAnimeId);
if (!animeExists(db, targetAnimeId)) {
return summary;
}
const updatedAt = toDbTimestamp(nowMs());
const moveVideosStmt = db.prepare(
'UPDATE imm_videos SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE anime_id = ?',
);
const moveLinesStmt = db.prepare(
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE anime_id = ?',
);
const dropLifetimeStmt = db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?');
const dropAnimeStmt = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?');
for (const sourceAnimeId of new Set(sourceAnimeIds)) {
if (sourceAnimeId === targetAnimeId || !animeExists(db, sourceAnimeId)) {
continue;
}
const sourceMetadata = readAnimeMetadata(db, sourceAnimeId);
const moved = moveVideosStmt.run(targetAnimeId, updatedAt, sourceAnimeId) as {
changes: number;
};
moveLinesStmt.run(targetAnimeId, updatedAt, sourceAnimeId);
dropLifetimeStmt.run(sourceAnimeId);
dropAnimeStmt.run(sourceAnimeId);
absorbAnimeMetadata(db, targetAnimeId, sourceMetadata, updatedAt);
summary.mergedAnimeIds.push(sourceAnimeId);
summary.movedVideos += moved.changes;
}
return summary;
}
export function mergeAnimeRecords(
db: DatabaseSync,
targetAnimeId: number,
sourceAnimeIds: number[],
): AnimeMergeSummary {
return runInTransaction(db, () => {
const summary = mergeAnimeRecordsInTransaction(db, targetAnimeId, sourceAnimeIds);
if (summary.mergedAnimeIds.length > 0) {
rebuildLifetimeSummariesInTransaction(db);
}
return summary;
});
}
/**
* Move a single episode to another library entry, pruning the previous owner
* when it is left with nothing.
*/
export function moveVideoToAnime(
db: DatabaseSync,
videoId: number,
targetAnimeId: number,
): VideoMoveSummary {
return runInTransaction(db, () => {
const videoRow = db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | null;
if (!videoRow || !animeExists(db, targetAnimeId)) {
throw new Error('Unknown episode or target library entry');
}
const previousAnimeId = videoRow.animeId;
if (previousAnimeId === targetAnimeId) {
return { targetAnimeId, previousAnimeId, removedPreviousAnime: false };
}
const updatedAt = toDbTimestamp(nowMs());
db.prepare('UPDATE imm_videos SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run(
targetAnimeId,
updatedAt,
videoId,
);
db.prepare(
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
).run(targetAnimeId, updatedAt, videoId);
let removedPreviousAnime = false;
if (previousAnimeId !== null && !hasAnimeReferences(db, previousAnimeId)) {
const sourceMetadata = readAnimeMetadata(db, previousAnimeId);
db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?').run(previousAnimeId);
db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(previousAnimeId);
absorbAnimeMetadata(db, targetAnimeId, sourceMetadata, updatedAt);
removedPreviousAnime = true;
}
rebuildLifetimeSummariesInTransaction(db);
return { targetAnimeId, previousAnimeId, removedPreviousAnime };
});
}
@@ -1,4 +1,9 @@
import type { DatabaseSync } from './sqlite';
import {
animeSeasonsAreMergeCompatible,
getParsedSeasonsForAnime,
mergeAnimeRecordsInTransaction,
} from './anime-merge';
import { getOrCreateAnimeRecord } from './storage';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';
@@ -8,6 +13,21 @@ export interface AnimeSeasonRepairSummary {
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;
}
export interface AnimeAnilistConflictOptions {
/**
* 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';
}
interface AnimeRow {
@@ -38,6 +58,7 @@ function emptySummary(scanned = 0): AnimeSeasonRepairSummary {
repaired: 0,
movedVideos: 0,
deletedAnimeRows: 0,
survivingAnimeId: null,
};
}
@@ -49,6 +70,7 @@ function mergeSummary(
target.repaired += source.repaired;
target.movedVideos += source.movedVideos;
target.deletedAnimeRows += source.deletedAnimeRows;
target.survivingAnimeId = source.survivingAnimeId ?? target.survivingAnimeId;
return target;
}
@@ -301,10 +323,19 @@ export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRe
});
}
/**
* Two library entries cannot both hold the same AniList id (imm_anime.anilist_id
* is UNIQUE), and two entries resolving to the same id are the same show split
* by a title or season-suffix mismatch. Fold them together when their parsed
* seasons agree; fall back to the legacy season redistribution when the
* conflicting row actually spans several seasons, since merging there would
* pile unrelated seasons onto one card.
*/
export function resolveAnimeAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions = {},
): AnimeSeasonRepairSummary {
const conflict = db
.prepare(
@@ -321,10 +352,47 @@ export function resolveAnimeAnilistConflict(
return emptySummary();
}
return runInTransaction(db, () =>
redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
return runInTransaction(db, () => {
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;
summary.survivingAnimeId = survivingAnimeId;
if (merge.mergedAnimeIds.length > 0) {
summary.repaired = 1;
}
// Lifetime summaries are rebuilt by the caller off this summary, the same
// as the redistribution path below.
return summary;
}
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
transferAnilistToAnimeId: targetAnimeId,
overwriteTargetAnilist: true,
}),
});
});
}
function canMergeAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
conflictAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions,
): boolean {
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.
const targetRow = getAnimeRow(db, targetAnimeId);
if (targetRow?.anilist_id != null && targetRow.anilist_id !== anilistId) {
return false;
}
}
return animeSeasonsAreMergeCompatible(
getParsedSeasonsForAnime(db, targetAnimeId),
getParsedSeasonsForAnime(db, conflictAnimeId),
);
}
@@ -7,6 +7,7 @@ import {
parseBooleanQuery,
parseExcludedWordsBody,
parseIntQuery,
parsePositiveIdList,
} from './route-support.js';
export function registerStatsLibraryRoutes(
@@ -197,4 +198,42 @@ export function registerStatsLibraryRoutes(
await tracker.deleteAnime(animeId);
return c.json(statsJson('deleteAnime', { ok: true }));
});
app.post('/api/stats/anime/:animeId/merge', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const sourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds).filter((id) => id !== animeId);
if (sourceAnimeIds.length === 0) return c.body(null, 400);
const summary = await tracker.mergeAnime(animeId, sourceAnimeIds);
return c.json(
statsJson('mergeAnime', {
ok: true,
animeId: summary.survivingAnimeId,
mergedAnimeIds: summary.mergedAnimeIds,
movedVideos: summary.movedVideos,
}),
);
});
app.patch('/api/stats/media/:videoId/anime', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const animeId = Number.isSafeInteger(body?.animeId) ? (body.animeId as number) : 0;
if (animeId <= 0) return c.body(null, 400);
try {
const summary = await tracker.moveVideoToAnime(videoId, animeId);
return c.json(
statsJson('moveVideoToAnime', {
ok: true,
animeId: summary.targetAnimeId,
previousAnimeId: summary.previousAnimeId,
removedPreviousAnime: summary.removedPreviousAnime,
}),
);
} catch {
return c.body(null, 404);
}
});
}
@@ -170,6 +170,18 @@ export async function enrichSessionsWithKnownWordMetrics<
);
}
/** Deduplicated positive integer ids from an untrusted JSON body field. */
export function parsePositiveIdList(raw: unknown): number[] {
if (!Array.isArray(raw)) return [];
const ids = new Set<number>();
for (const value of raw) {
if (Number.isSafeInteger(value) && (value as number) > 0) {
ids.add(value as number);
}
}
return [...ids];
}
export function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
if (raw === undefined) return fallback;
const normalized = raw.trim().toLowerCase();