mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -07:00
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:
@@ -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' });
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user