fix(stats): review fuzzy AniList duplicates before merging

- Preserve merged title aliases for future episodes
- Fail closed when queued writes cannot drain
This commit is contained in:
2026-08-11 23:11:39 -07:00
parent d82a4fd8c6
commit d8c5fc321a
28 changed files with 1625 additions and 44 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
type: added
area: stats
- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, and the emptied entries disappear from the grid.
- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, the emptied entries disappear, and remembered title aliases keep future episodes on the merged card.
- Library: episodes can be reassigned to another library entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (e.g. an episode name parsed as the series). Emptying an entry this way removes it and returns to the grid.
- Library: once cover art resolves a title to an AniList entry, cards that resolve to the same AniList id are folded together automatically instead of being left as duplicates. Entries whose parsed seasons disagree are left alone.
- Library: exact AniList title matches with compatible seasons fold duplicate cards automatically. Fuzzy same-AniList matches appear as dismissible "Possible duplicate" reviews instead of changing the library without confirmation; conflicting explicit seasons are left alone.
+2 -2
View File
@@ -59,10 +59,10 @@ When YouTube channel metadata is available, the Library tab groups videos by cre
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over.
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. If that was the entry's last episode, the now-empty card is removed and you are returned to the grid.
Once cover art resolves a series to an AniList entry, cards that resolve to the same AniList id are folded together automatically. Entries with conflicting season numbers are left alone rather than merged.
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
Open a title and use **Delete Entry** in its header to remove a mistakenly tracked show outright. This deletes every episode of that title along with their sessions, subtitle lines, rollups and cover art, drops the words and kanji that were only seen there, and removes the card from the Library grid. Individual episodes and sessions can still be deleted on their own from the episode list and session rows. Entry deletion is refused while that title is the one currently playing.
+1 -1
View File
@@ -24,7 +24,7 @@ Read when: you need to find the owner module for a behavior or test surface
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
- Immersion tracking: `src/core/services/immersion-tracker/`
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata. Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Window trackers: `src/window-trackers/`
@@ -35,8 +35,85 @@ interface TrackerInternals {
recordWrite: (write: Record<string, unknown>) => void;
mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise<unknown>;
moveVideoToAnime: (videoId: number, targetAnimeId: number) => Promise<unknown>;
rebuildLifetimeSummaries: () => Promise<unknown>;
flushNow: () => void;
}
test('mergeAnime fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
assert.deepEqual(
internals.db
.prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id')
.all()
.map((row) => (row as { animeId: number }).animeId),
[1, 2],
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
assert.equal(
(
internals.db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2')
.get() as {
animeId: number;
}
).animeId,
2,
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
function seedTwoEntries(db: DatabaseSync): void {
db.exec(`
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
@@ -1053,6 +1053,55 @@ describe('stats server API routes', () => {
assert.equal(body[0].canonicalTitle, 'Little Witch Academia');
});
it('GET /api/stats/anime/merge-recommendations returns pending duplicate pairs', async () => {
const app = createStatsApp(
createMockTracker({
getAnimeMergeRecommendations: async () => [{ recommendationId: 4, animeIds: [1, 2] }],
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/merge-recommendations');
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
recommendations: [{ recommendationId: 4, animeIds: [1, 2] }],
});
});
it('DELETE /api/stats/anime/merge-recommendations/:id dismisses a pending pair', async () => {
let dismissedId: number | null = null;
const app = createStatsApp(
createMockTracker({
dismissAnimeMergeRecommendation: async (recommendationId: number) => {
dismissedId = recommendationId;
return true;
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/merge-recommendations/4', {
method: 'DELETE',
});
assert.equal(res.status, 200);
assert.equal(dismissedId, 4);
assert.deepEqual(await res.json(), { ok: true });
});
it('DELETE /api/stats/anime/merge-recommendations/:id reports missing recommendations', async () => {
const app = createStatsApp(
createMockTracker({
dismissAnimeMergeRecommendation: async () => false,
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/merge-recommendations/99', {
method: 'DELETE',
});
assert.equal(res.status, 404);
});
it('GET /api/stats/anime/:animeId returns anime detail with episodes', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anime/1');
@@ -327,6 +327,7 @@ export function createCoverArtFetcher(
titleEnglish: selected.title?.english ?? null,
titleNative: selected.title?.native ?? null,
episodesTotal: selected.episodes ?? null,
exactTitleMatch: resolution?.exactTitleMatch ?? false,
});
logger.info(
@@ -156,6 +156,37 @@ test('season 1 resolves to the anchor without relation lookups', async () => {
assert.deepEqual(relationLookups, []);
});
test('reports an exact normalized synonym match as strong evidence', async () => {
const { execute } = createExecutor([
{
id: 1,
episodes: 12,
format: 'TV',
title: { english: 'Hitori Gotoh Story' },
synonyms: ['BOCCHI THE ROCK'],
},
]);
const result = await resolveAnilistSeasonMedia({ title: 'Bocchi the Rock!' }, { execute });
assert.equal(result?.exactTitleMatch, true);
});
test('reports a fuzzy-only search result as weak evidence', async () => {
const { execute } = createExecutor([
{
id: 1,
episodes: 12,
format: 'TV',
title: { english: 'Actual Show' },
},
]);
const result = await resolveAnilistSeasonMedia({ title: 'Unrelated Release' }, { execute });
assert.equal(result?.exactTitleMatch, false);
});
test('strips a season marker already present in the parsed title', async () => {
const { execute, searches } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
+15 -5
View File
@@ -42,6 +42,8 @@ export interface AnilistSeasonResolution {
seasonResolved: boolean;
requestedSeason: number | null;
via: AnilistSeasonResolutionVia;
/** Exact normalized match against an AniList title or synonym. */
exactTitleMatch: boolean;
}
export interface ResolveAnilistSeasonMediaInput {
@@ -116,7 +118,12 @@ const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
const MAX_SEQUEL_HOPS = 12;
function normalizeTitle(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ');
return value
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ');
}
/**
@@ -176,6 +183,7 @@ function toResolution(
season: number | null,
via: AnilistSeasonResolutionVia,
seasonResolved: boolean,
exactTitleMatch: boolean,
): AnilistSeasonResolution {
return {
id: media.id,
@@ -185,6 +193,7 @@ function toResolution(
seasonResolved,
requestedSeason: season,
via,
exactTitleMatch,
};
}
@@ -367,9 +376,10 @@ export async function resolveAnilistSeasonMedia(
episode: season === null || season <= 1 ? input.episode : null,
});
if (!anchor) return null;
const exactTitleMatch = mediaTitles(anchor).includes(normalizeTitle(searchTitle));
if (season === null || season <= 1) {
return toResolution(anchor, searchTitle, season, 'anchor', true);
return toResolution(anchor, searchTitle, season, 'anchor', true, exactTitleMatch);
}
let chainError: unknown = null;
@@ -383,7 +393,7 @@ export async function resolveAnilistSeasonMedia(
deps.logInfo?.(
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
);
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true, exactTitleMatch);
}
const viaAirOrder = pickByAirOrder(anchor, season, media);
@@ -391,7 +401,7 @@ export async function resolveAnilistSeasonMedia(
deps.logInfo?.(
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
);
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true, exactTitleMatch);
}
// The chain failed for transport reasons rather than because the season is absent;
@@ -403,5 +413,5 @@ export async function resolveAnilistSeasonMedia(
deps.logInfo?.(
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
);
return toResolution(anchor, searchTitle, season, 'anchor', false);
return toResolution(anchor, searchTitle, season, 'anchor', false, exactTitleMatch);
}
+24 -5
View File
@@ -93,8 +93,11 @@ import {
} from './immersion-tracker/query-maintenance';
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
import {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
repairLegacySeasonlessAnimeRows,
resolveAnimeAnilistConflict,
type AnimeMergeRecommendation,
} from './immersion-tracker/anime-season-repair';
import {
mergeAnimeRecords,
@@ -602,7 +605,7 @@ export class ImmersionTrackerService {
}
async rebuildLifetimeSummaries(): Promise<LifetimeRebuildSummary> {
this.drainWriteQueue('rebuilding lifetime summaries');
this.requireWriteQueueDrained('rebuilding lifetime summaries');
return rebuildLifetimeSummaryTables(this.db);
}
@@ -669,6 +672,14 @@ export class ImmersionTrackerService {
return getAnimeLibrary(this.db);
}
async getAnimeMergeRecommendations(): Promise<AnimeMergeRecommendation[]> {
return getAnimeMergeRecommendations(this.db);
}
async dismissAnimeMergeRecommendation(recommendationId: number): Promise<boolean> {
return dismissAnimeMergeRecommendation(this.db, recommendationId);
}
async getAnimeDetail(animeId: number): Promise<AnimeDetailRow | null> {
this.relinkYoutubeAnimeLibrary();
return getAnimeDetail(this.db, animeId);
@@ -774,13 +785,13 @@ export class ImmersionTrackerService {
// This rebuilds the lifetime summaries, which recompute from the database:
// queued writes have to land first or the active session is dropped from
// the merged totals.
this.drainWriteQueue('merging library entries');
this.requireWriteQueueDrained('merging library entries');
return mergeAnimeRecords(this.db, targetAnimeId, sourceAnimeIds);
}
async moveVideoToAnime(videoId: number, targetAnimeId: number): Promise<VideoMoveSummary> {
await this.pendingAnimeMetadataUpdates.get(videoId);
this.drainWriteQueue('moving an episode');
this.requireWriteQueueDrained('moving an episode');
return moveVideoToAnimeQuery(this.db, videoId, targetAnimeId);
}
@@ -794,8 +805,8 @@ export class ImmersionTrackerService {
* soon as a pass makes no progress — a rolled-back batch is pushed back onto
* the queue, and looping on that would spin forever.
*
* Returns false when the queue could not be emptied, in which case the
* rebuild runs against a database still missing those writes.
* Returns false when the queue could not be emptied. Summary-rebuilding
* callers fail closed in that case.
*/
private drainWriteQueue(context: string): boolean {
this.flushTelemetry(true);
@@ -812,6 +823,12 @@ export class ImmersionTrackerService {
return true;
}
private requireWriteQueueDrained(context: string): void {
if (!this.drainWriteQueue(context)) {
throw new Error(`Immersion tracker queue did not drain before ${context}`);
}
}
async reassignAnimeAnilist(
animeId: number,
info: {
@@ -828,7 +845,9 @@ export class ImmersionTrackerService {
// another row already claims the same AniList id.
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, {
survivor: 'target',
matchConfidence: 'manual',
});
if (repair.anilistAssignmentBlocked) return;
this.db
.prepare(
`
@@ -5,9 +5,14 @@ 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 { applyPragmas, ensureSchema, getOrCreateAnimeRecord } from '../storage.js';
import { mergeAnimeRecords, moveVideoToAnime } from '../anime-merge.js';
import { resolveAnimeAnilistConflict } from '../anime-season-repair.js';
import {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
resolveAnimeAnilistConflict,
} from '../anime-season-repair.js';
import { updateAnimeAnilistInfo } from '../query-maintenance.js';
const BASE_MS = 1_700_000_000_000;
@@ -211,6 +216,54 @@ test('mergeAnimeRecords inherits metadata the target is missing without clobberi
});
});
test('mergeAnimeRecords preserves source title identities as aliases of the survivor', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
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 });
db.prepare(
`INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id, CREATED_DATE, LAST_UPDATE_DATE)
VALUES ('show s01', 2, ?, ?)`,
).run(BASE_MS, BASE_MS);
mergeAnimeRecords(db, 1, [2]);
const fromSourceTitle = getOrCreateAnimeRecord(db, {
parsedTitle: 'Show Season 1',
canonicalTitle: 'Show Season 1',
seasonScope: 1,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
const fromTransferredAlias = getOrCreateAnimeRecord(db, {
parsedTitle: 'Show S01',
canonicalTitle: 'Show S01',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
assert.equal(fromSourceTitle, 1);
assert.equal(fromTransferredAlias, 1);
assert.deepEqual(animeIds(db), [1]);
assert.equal(
(
db.prepare('SELECT canonical_title AS title FROM imm_anime WHERE anime_id = 1').get() as {
title: string;
}
).title,
'Show',
);
});
});
test('mergeAnimeRecords ignores unknown targets and self-merges', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
@@ -311,6 +364,126 @@ test('resolveAnimeAnilistConflict folds a seasonless duplicate into the entry th
});
});
test('resolveAnimeAnilistConflict recommends a weak title collision instead of merging it', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(summary.repaired, 0);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 2), 2);
assert.deepEqual(getAnimeMergeRecommendations(db), [{ recommendationId: 1, animeIds: [1, 2] }]);
});
});
test('automatic AniList update leaves a weak collision unassigned for user review', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
updateAnimeAnilistInfo(db, 2, {
anilistId: 163132,
titleRomaji: 'Actual Show',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
exactTitleMatch: false,
});
const target = db
.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2')
.get() as {
anilistId: number | null;
};
assert.equal(target.anilistId, null);
assert.deepEqual(getAnimeMergeRecommendations(db), [{ recommendationId: 1, animeIds: [1, 2] }]);
});
});
test('dismissed weak collision stays dismissed when automatic resolution repeats', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(dismissAnimeMergeRecommendation(db, 1), true);
resolveAnimeAnilistConflict(db, 2, 163132);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('dismissed recommendation prevents a later exact automatic merge of the pair', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'weak' });
assert.equal(dismissAnimeMergeRecommendation(db, 1), true);
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'exact' });
assert.equal(summary.repaired, 0);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 2), 2);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('manual merge clears recommendations involving the absorbed entry', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132);
mergeAnimeRecords(db, 1, [2]);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('resolveAnimeAnilistConflict keeps the target entry when the user drove the change', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
@@ -353,6 +526,76 @@ test('resolveAnimeAnilistConflict falls back to season redistribution for multi-
});
});
test('resolveAnimeAnilistConflict leaves explicit incompatible seasons and assignments unchanged', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'show season 1',
title: 'Show Season 1',
anilistId: 163132,
titleRomaji: 'Show',
});
insertAnime(db, { animeId: 2, key: 'show season 2', title: 'Show Season 2' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'exact' });
assert.equal(summary.repaired, 0);
assert.equal(summary.movedVideos, 0);
assert.equal(summary.deletedAnimeRows, 0);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(videoAnimeId(db, 2), 2);
const assignments = db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all() as Array<{ animeId: number; anilistId: number | null }>;
assert.deepEqual(assignments, [
{ animeId: 1, anilistId: 163132 },
{ animeId: 2, anilistId: null },
]);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('automatic AniList update does not transfer an assignment across explicit seasons', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'show season 1',
title: 'Show Season 1',
anilistId: 163132,
titleRomaji: 'Show',
});
insertAnime(db, { animeId: 2, key: 'show season 2', title: 'Show Season 2' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
updateAnimeAnilistInfo(db, 2, {
anilistId: 163132,
titleRomaji: 'Show',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
exactTitleMatch: true,
});
const assignments = db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all() as Array<{ animeId: number; anilistId: number | null }>;
assert.deepEqual(assignments, [
{ animeId: 1, anilistId: 163132 },
{ animeId: 2, anilistId: null },
]);
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(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 });
@@ -360,9 +603,20 @@ test('resolveAnimeAnilistConflict leaves an entry that already links elsewhere a
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
resolveAnimeAnilistConflict(db, 2, 163132);
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(videoAnimeId(db, 2), 2);
assert.ok(animeIds(db).includes(2));
assert.equal(
(
db.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2').get() as {
anilistId: number;
}
).anilistId,
999,
);
assert.equal(summary.repaired, 0);
assert.equal(summary.movedVideos, 0);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
@@ -0,0 +1,169 @@
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 = ?`,
)
.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(normalizeAnimeIdentityKey)
.filter(Boolean);
return targetKeys.some((key) => anilistTitleKeys.includes(key));
}
export function shouldRecommendAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
conflictAnimeId: number,
options: AnimeConflictRecommendationOptions,
): boolean {
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 {
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'
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;
}
@@ -23,6 +23,7 @@ export interface VideoMoveSummary {
}
interface AnimeMetadataRow {
normalized_title_key: string;
anilist_id: number | null;
title_romaji: string | null;
title_english: string | null;
@@ -51,7 +52,7 @@ function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow
return (db
.prepare(
`
SELECT anilist_id, title_romaji, title_english, title_native, episodes_total, description
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description
FROM imm_anime
WHERE anime_id = ?
`,
@@ -177,6 +178,19 @@ export function mergeAnimeRecordsInTransaction(
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
);
const dropLifetimeStmt = db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?');
const sourceAliasesStmt = db.prepare(
'SELECT normalized_title_key AS normalizedTitleKey FROM imm_anime_title_aliases WHERE anime_id = ?',
);
const upsertAliasStmt = db.prepare(
`INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?)
ON CONFLICT(normalized_title_key) DO UPDATE SET
anime_id = excluded.anime_id,
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
);
const dropSourceAliasesStmt = db.prepare(
'DELETE FROM imm_anime_title_aliases WHERE anime_id = ?',
);
const dropAnimeStmt = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?');
for (const sourceAnimeId of new Set(sourceAnimeIds)) {
@@ -185,6 +199,9 @@ export function mergeAnimeRecordsInTransaction(
}
const sourceMetadata = readAnimeMetadata(db, sourceAnimeId);
const sourceAliases = sourceAliasesStmt.all(sourceAnimeId) as Array<{
normalizedTitleKey: string;
}>;
const sourceVideoIds = (sourceVideosStmt.all(sourceAnimeId) as Array<{ videoId: number }>).map(
(row) => row.videoId,
);
@@ -194,6 +211,13 @@ export function mergeAnimeRecordsInTransaction(
for (const videoId of sourceVideoIds) {
moveLinesStmt.run(targetAnimeId, updatedAt, videoId);
}
dropSourceAliasesStmt.run(sourceAnimeId);
for (const alias of [
...(sourceMetadata ? [sourceMetadata.normalized_title_key] : []),
...sourceAliases.map((row) => row.normalizedTitleKey),
]) {
upsertAliasStmt.run(alias, targetAnimeId, updatedAt, updatedAt);
}
dropLifetimeStmt.run(sourceAnimeId);
dropAnimeStmt.run(sourceAnimeId);
absorbAnimeMetadata(db, targetAnimeId, sourceMetadata, updatedAt);
@@ -4,6 +4,13 @@ import {
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';
@@ -18,9 +25,13 @@ export interface AnimeSeasonRepairSummary {
* 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 {
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
@@ -30,6 +41,12 @@ export interface AnimeAnilistConflictOptions {
survivor?: 'target' | 'existing';
}
export {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
type AnimeMergeRecommendation,
} from './anime-merge-recommendations';
interface AnimeRow {
anime_id: number;
anilist_id: number | null;
@@ -59,6 +76,8 @@ function emptySummary(scanned = 0): AnimeSeasonRepairSummary {
movedVideos: 0,
deletedAnimeRows: 0,
survivingAnimeId: null,
mergeRecommended: false,
anilistAssignmentBlocked: false,
};
}
@@ -71,6 +90,8 @@ function mergeSummary(
target.movedVideos += source.movedVideos;
target.deletedAnimeRows += source.deletedAnimeRows;
target.survivingAnimeId = source.survivingAnimeId ?? target.survivingAnimeId;
target.mergeRecommended ||= source.mergeRecommended;
target.anilistAssignmentBlocked ||= source.anilistAssignmentBlocked;
return target;
}
@@ -324,12 +345,11 @@ 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.
* 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,
@@ -353,6 +373,33 @@ export function resolveAnimeAnilistConflict(
}
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;
@@ -371,6 +418,13 @@ export function resolveAnimeAnilistConflict(
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,
@@ -398,6 +452,13 @@ function canMergeAnilistConflict(
return false;
}
}
if (
options.matchConfidence === 'weak' ||
(options.matchConfidence === undefined &&
!hasExactStoredTitleMatch(db, targetAnimeId, conflictAnimeId))
) {
return false;
}
return animeSeasonsAreMergeCompatible(
getParsedSeasonsForAnime(db, targetAnimeId),
getParsedSeasonsForAnime(db, conflictAnimeId),
@@ -418,6 +418,7 @@ export function updateAnimeAnilistInfo(
titleEnglish: string | null;
titleNative: string | null;
episodesTotal: number | null;
exactTitleMatch?: boolean;
},
): void {
const row = db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?').get(videoId) as {
@@ -425,7 +426,10 @@ export function updateAnimeAnilistInfo(
} | null;
if (!row?.anime_id) return;
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId);
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId, {
matchConfidence: info.exactTitleMatch === false ? 'weak' : 'exact',
});
if (repair.mergeRecommended || repair.anilistAssignmentBlocked) return;
const targetRow = db
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
.get(videoId) as {
+40 -2
View File
@@ -530,6 +530,36 @@ function ensureStatsExcludedWordsTable(db: DatabaseSync): void {
`);
}
function ensureAnimeMergeTables(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS imm_anime_title_aliases(
normalized_title_key TEXT PRIMARY KEY,
anime_id INTEGER NOT NULL,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_anime_title_aliases_anime_id
ON imm_anime_title_aliases(anime_id);
CREATE TABLE IF NOT EXISTS imm_anime_merge_recommendations(
recommendation_id INTEGER PRIMARY KEY AUTOINCREMENT,
first_anime_id INTEGER NOT NULL,
second_anime_id INTEGER NOT NULL,
anilist_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'dismissed')),
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
CHECK(first_anime_id < second_anime_id),
UNIQUE(first_anime_id, second_anime_id, anilist_id),
FOREIGN KEY(first_anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE,
FOREIGN KEY(second_anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_anime_merge_recommendations_status
ON imm_anime_merge_recommendations(status, recommendation_id);
`);
}
export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number {
const seasonScope = normalizeSeasonScope(input.seasonScope);
const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope);
@@ -550,8 +580,14 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
const byNormalizedTitle = db
.prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?')
.get(normalizedTitleKey) as { anime_id: number } | null;
const existing = byAnilistId ?? byNormalizedTitle;
const byTitleAlias = db
.prepare('SELECT anime_id FROM imm_anime_title_aliases WHERE normalized_title_key = ?')
.get(normalizedTitleKey) as { anime_id: number } | null;
const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias;
if (existing?.anime_id) {
// An alias remembers an intentionally merged-away spelling. Reusing it
// must not rename the survivor back to that discarded display title.
const canonicalTitleUpdate = byAnilistId || byNormalizedTitle ? canonicalTitle : null;
db.prepare(
`
UPDATE imm_anime
@@ -566,7 +602,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
WHERE anime_id = ?
`,
).run(
canonicalTitle,
canonicalTitleUpdate,
input.anilistId,
input.titleRomaji,
input.titleEnglish,
@@ -751,6 +787,7 @@ export function ensureSchema(db: DatabaseSync): void {
if (currentVersion?.schema_version === SCHEMA_VERSION) {
ensureLifetimeSummaryTables(db);
ensureStatsExcludedWordsTable(db);
ensureAnimeMergeTables(db);
return;
}
@@ -799,6 +836,7 @@ export function ensureSchema(db: DatabaseSync): void {
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
);
`);
ensureAnimeMergeTables(db);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_sessions(
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
+1 -1
View File
@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 19;
export const SCHEMA_VERSION = 20;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -132,6 +132,19 @@ export function registerStatsLibraryRoutes(
return c.json(statsJson('animeLibrary', rows));
});
app.get('/api/stats/anime/merge-recommendations', async (c) => {
const recommendations = await tracker.getAnimeMergeRecommendations();
return c.json(statsJson('animeMergeRecommendations', { recommendations }));
});
app.delete('/api/stats/anime/merge-recommendations/:recommendationId', async (c) => {
const recommendationId = parseIntQuery(c.req.param('recommendationId'), 0);
if (recommendationId <= 0) return c.body(null, 400);
const dismissed = await tracker.dismissAnimeMergeRecommendation(recommendationId);
if (!dismissed) return c.body(null, 404);
return c.json(statsJson('dismissAnimeMergeRecommendation', { ok: true }));
});
app.get('/api/stats/anime/:animeId', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
+13
View File
@@ -108,6 +108,15 @@ export interface StatsMoveVideoRequest {
animeId: number;
}
export interface StatsAnimeMergeRecommendation {
recommendationId: number;
animeIds: [number, number];
}
export interface StatsAnimeMergeRecommendationsResponse {
recommendations: StatsAnimeMergeRecommendation[];
}
export interface StatsMergeAnimeResponse {
ok: true;
/** Library entry that owns every merged episode afterwards. */
@@ -158,6 +167,7 @@ export interface StatsJsonResponseMap {
mediaLibrary: MediaLibraryItem[];
mediaDetail: MediaDetailData;
animeLibrary: AnimeLibraryItem[];
animeMergeRecommendations: StatsAnimeMergeRecommendationsResponse;
animeDetail: AnimeDetailData;
animeWords: AnimeWord[];
animeRollups: DailyRollup[];
@@ -168,6 +178,7 @@ export interface StatsJsonResponseMap {
deleteAnime: StatsOkResponse;
mergeAnime: StatsMergeAnimeResponse;
moveVideoToAnime: StatsMoveVideoResponse;
dismissAnimeMergeRecommendation: StatsOkResponse;
anilistSearch: StatsAnilistSearchResult[];
knownWords: string[];
knownWordsSummary: StatsKnownWordsSummary;
@@ -225,6 +236,7 @@ export interface StatsHttpClient {
getMediaLibrary: () => Promise<MediaLibraryItem[]>;
getMediaDetail: (videoId: number) => Promise<MediaDetailData>;
getAnimeLibrary: () => Promise<AnimeLibraryItem[]>;
getAnimeMergeRecommendations: () => Promise<StatsAnimeMergeRecommendationsResponse>;
getAnimeDetail: (animeId: number) => Promise<AnimeDetailData>;
getAnimeWords: (animeId: number, limit?: number) => Promise<AnimeWord[]>;
getAnimeRollups: (animeId: number, limit?: number) => Promise<DailyRollup[]>;
@@ -249,6 +261,7 @@ export interface StatsHttpClient {
deleteAnime: (animeId: number) => Promise<void>;
mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise<StatsMergeAnimeResponse>;
moveVideoToAnime: (videoId: number, animeId: number) => Promise<StatsMoveVideoResponse>;
dismissAnimeMergeRecommendation: (recommendationId: number) => Promise<void>;
getKnownWords: () => Promise<string[]>;
getKnownWordsSummary: () => Promise<StatsKnownWordsSummary>;
getAnimeKnownWordsSummary: (animeId: number) => Promise<StatsKnownWordsSummary>;
@@ -0,0 +1,232 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { AnimeLibraryItem } from '../../types/stats';
import { AnimeMergeDialog } from './AnimeMergeDialog';
import { LibraryEntryPicker } from './LibraryEntryPicker';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousIsReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
};
}
function libraryItem(animeId: number, title: string): AnimeLibraryItem {
return {
animeId,
canonicalTitle: title,
anilistId: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
totalTokensSeen: 0,
episodeCount: 1,
episodesTotal: null,
lastWatchedMs: 1,
};
}
test('AnimeMergeDialog focuses its close control, closes on Escape, and restores focus', async () => {
const uninstallDom = installDom();
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
function Harness() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Review merge
</button>
{open ? (
<AnimeMergeDialog
entries={[libraryItem(1, 'Show'), libraryItem(2, 'Show Season 1')]}
onClose={() => setOpen(false)}
onMerged={() => undefined}
/>
) : null}
</>
);
}
await act(async () => root.render(<Harness />));
const trigger = container.querySelector('button') as HTMLButtonElement;
trigger.focus();
await act(async () => trigger.click());
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Close');
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
});
assert.equal(container.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
await act(async () => root.unmount());
} finally {
uninstallDom();
}
});
test('AnimeMergeDialog keeps keyboard focus inside the modal', async () => {
const uninstallDom = installDom();
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
<AnimeMergeDialog
entries={[libraryItem(1, 'Show'), libraryItem(2, 'Show Season 1')]}
onClose={() => undefined}
onMerged={() => undefined}
/>,
);
});
const dialog = container.querySelector('[role="dialog"]') as HTMLElement;
const focusable = [...dialog.querySelectorAll('button:not([disabled])')] as HTMLButtonElement[];
const first = focusable[0];
const last = focusable.at(-1);
assert.ok(first);
assert.ok(last);
last.focus();
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Tab' }));
});
assert.equal(document.activeElement, first);
first.focus();
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }));
});
assert.equal(document.activeElement, last);
await act(async () => root.unmount());
} finally {
uninstallDom();
}
});
test('LibraryEntryPicker focuses search, closes on Escape, and restores focus', async () => {
const uninstallDom = installDom();
const original = apiClient.getAnimeLibrary;
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show'),
]) as typeof apiClient.getAnimeLibrary;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
function Harness() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Move
</button>
{open ? (
<LibraryEntryPicker
heading="Move episode"
onSelect={() => undefined}
onClose={() => setOpen(false)}
/>
) : null}
</>
);
}
await act(async () => root.render(<Harness />));
const trigger = container.querySelector('button') as HTMLButtonElement;
trigger.focus();
await act(async () => trigger.click());
assert.equal(document.activeElement?.getAttribute('placeholder'), 'Search library...');
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
});
assert.equal(container.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
await act(async () => root.unmount());
} finally {
apiClient.getAnimeLibrary = original;
uninstallDom();
}
});
test('LibraryEntryPicker cannot be dismissed while a move is in flight', async () => {
const uninstallDom = installDom();
const original = apiClient.getAnimeLibrary;
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show'),
]) as typeof apiClient.getAnimeLibrary;
let closeCalls = 0;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
<LibraryEntryPicker
heading="Move episode"
busyAnimeId={1}
onSelect={() => undefined}
onClose={() => {
closeCalls += 1;
}}
/>,
);
});
const closeButton = container.querySelector('button[aria-label="Close"]') as HTMLButtonElement;
assert.equal(closeButton.disabled, true);
await act(async () => {
closeButton.click();
container.firstElementChild?.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
});
assert.equal(closeCalls, 0);
await act(async () => root.unmount());
} finally {
apiClient.getAnimeLibrary = original;
uninstallDom();
}
});
@@ -1,6 +1,7 @@
import { useId, useState } from 'react';
import { useId, useRef, useState } from 'react';
import { apiClient } from '../../lib/api-client';
import { formatDuration, formatNumber } from '../../lib/formatters';
import { useModalFocus } from '../../hooks/useModalFocus';
import { AnimeCoverImage } from './AnimeCoverImage';
import type { AnimeLibraryItem } from '../../types/stats';
@@ -20,6 +21,8 @@ function pickDefaultKeeper(entries: AnimeLibraryItem[]): number {
export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialogProps) {
const headingId = useId();
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const [keeperId, setKeeperId] = useState(() => pickDefaultKeeper(entries));
const [merging, setMerging] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -28,6 +31,13 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
const totalCards = entries.reduce((sum, entry) => sum + entry.totalCards, 0);
const totalActiveMs = entries.reduce((sum, entry) => sum + entry.totalActiveMs, 0);
useModalFocus({
dialogRef,
initialFocusRef: closeButtonRef,
dismissDisabled: merging,
onDismiss: onClose,
});
const handleMerge = async () => {
const sourceAnimeIds = entries
.map((entry) => entry.animeId)
@@ -58,6 +68,7 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
@@ -70,6 +81,7 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
Merge {entries.length} Library Entries
</h3>
<button
ref={closeButtonRef}
type="button"
onClick={handleDismiss}
disabled={merging}
@@ -161,3 +161,256 @@ test('AnimeTab merges the selected duplicate entries into the chosen keeper', as
uninstallDom();
}
});
test('AnimeTab keeps a suggested duplicate visible until it is reviewed and merged', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
mergeAnime: apiClient.mergeAnime,
};
let entries = [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
let recommendations = [{ recommendationId: 41, animeIds: [1, 2] }];
let mergeCall: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
apiClient.getAnimeLibrary = (async () => entries) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => ({
recommendations,
})) as typeof apiClient.getAnimeMergeRecommendations;
apiClient.dismissAnimeMergeRecommendation = (async () =>
undefined) as typeof apiClient.dismissAnimeMergeRecommendation;
apiClient.mergeAnime = (async (targetAnimeId: number, sourceAnimeIds: number[]) => {
mergeCall = { targetAnimeId, sourceAnimeIds };
entries = [libraryItem(targetAnimeId, 'Show', 3)];
recommendations = [];
return {
ok: true,
animeId: targetAnimeId,
mergedAnimeIds: sourceAnimeIds,
movedVideos: 1,
} satisfies StatsMergeAnimeResponse;
}) as typeof apiClient.mergeAnime;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(<AnimeTab />);
});
assert.match(container.textContent ?? '', /Possible duplicate/);
assert.match(container.textContent ?? '', /Show/);
assert.match(container.textContent ?? '', /Show Season 1/);
await act(async () => {
findButton(container, 'Review merge').click();
});
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
const keeper = [...container.querySelectorAll('button[aria-pressed]')].find((button) =>
(button.textContent ?? '').includes('Show Season 1'),
) as HTMLButtonElement | undefined;
assert.ok(keeper);
await act(async () => {
keeper.click();
});
await act(async () => {
findButton(container, 'Merge Entries').click();
});
assert.deepEqual(mergeCall, { targetAnimeId: 2, sourceAnimeIds: [1] });
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
assert.doesNotMatch(container.textContent ?? '', /Show Season 1/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
test('AnimeTab dismisses a false-positive duplicate recommendation', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
};
let dismissedId: number | null = null;
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show', 2),
libraryItem(2, 'Different Show', 1),
]) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => ({
recommendations: [{ recommendationId: 73, animeIds: [1, 2] }],
})) as typeof apiClient.getAnimeMergeRecommendations;
apiClient.dismissAnimeMergeRecommendation = (async (recommendationId: number) => {
dismissedId = recommendationId;
}) as typeof apiClient.dismissAnimeMergeRecommendation;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(<AnimeTab />));
await act(async () => {
findButton(container, 'Not duplicates').click();
});
assert.equal(dismissedId, 73);
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
test('AnimeTab refreshes the library and recommendations when the window regains focus', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
};
let entries = [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
let libraryFetches = 0;
let recommendationFetches = 0;
apiClient.getAnimeLibrary = (async () => {
libraryFetches += 1;
return entries;
}) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => {
recommendationFetches += 1;
return { recommendations: [] };
}) as typeof apiClient.getAnimeMergeRecommendations;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(<AnimeTab />));
entries = [libraryItem(1, 'Show', 3)];
await act(async () => {
window.dispatchEvent(new window.Event('focus'));
});
assert.equal(libraryFetches, 2);
assert.equal(recommendationFetches, 2);
assert.doesNotMatch(container.textContent ?? '', /Show Season 1/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
test('AnimeTab keeps a recommendation visible through a transient refresh failure', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
};
let failRecommendations = false;
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show', 2),
libraryItem(2, 'Show Season 1', 1),
]) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => {
if (failRecommendations) throw new Error('temporary failure');
return { recommendations: [{ recommendationId: 41, animeIds: [1, 2] }] };
}) as typeof apiClient.getAnimeMergeRecommendations;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(<AnimeTab />));
assert.match(container.textContent ?? '', /Possible duplicate/);
failRecommendations = true;
await act(async () => window.dispatchEvent(new window.Event('focus')));
assert.match(container.textContent ?? '', /Possible duplicate/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
test('AnimeTab retains a recommendation and reports a failed dismissal', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
};
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show', 2),
libraryItem(2, 'Show Season 1', 1),
]) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => ({
recommendations: [{ recommendationId: 41, animeIds: [1, 2] }],
})) as typeof apiClient.getAnimeMergeRecommendations;
apiClient.dismissAnimeMergeRecommendation = (async () => {
throw new Error('offline');
}) as typeof apiClient.dismissAnimeMergeRecommendation;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(<AnimeTab />));
await act(async () => findButton(container, 'Not duplicates').click());
assert.match(container.textContent ?? '', /Possible duplicate/);
assert.match(container.textContent ?? '', /Could not dismiss this suggestion/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
test('AnimeTab keeps an open recommendation review stable during background refresh', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
};
let recommendations = [{ recommendationId: 41, animeIds: [1, 2] as [number, number] }];
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show', 2),
libraryItem(2, 'Show Season 1', 1),
]) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => ({
recommendations,
})) as typeof apiClient.getAnimeMergeRecommendations;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(<AnimeTab />));
await act(async () => findButton(container, 'Review merge').click());
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
recommendations = [];
await act(async () => window.dispatchEvent(new window.Event('focus')));
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
assert.match(container.textContent ?? '', /Show Season 1/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
+55 -4
View File
@@ -10,6 +10,7 @@ import {
import { AnimeCard } from './AnimeCard';
import { AnimeDetailView } from './AnimeDetailView';
import { AnimeMergeDialog } from './AnimeMergeDialog';
import { DuplicateReviewStrip } from './DuplicateReviewStrip';
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
@@ -54,7 +55,17 @@ export function AnimeTab({
onNavigateToWord,
onOpenEpisodeDetail,
}: AnimeTabProps) {
const { anime, loading, error, reload } = useAnimeLibrary();
const {
anime,
loading,
error,
reload,
recommendations,
dismissRecommendation,
dismissingRecommendationId,
recommendationActionError,
clearRecommendation,
} = useAnimeLibrary();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
@@ -66,6 +77,8 @@ export function AnimeTab({
const [selectionMode, setSelectionMode] = useState(false);
const [checkedAnimeIds, setCheckedAnimeIds] = useState<number[]>([]);
const [showMergeDialog, setShowMergeDialog] = useState(false);
const [reviewRecommendationId, setReviewRecommendationId] = useState<number | null>(null);
const [reviewAnimeIds, setReviewAnimeIds] = useState<[number, number] | null>(null);
function toggleChecked(animeId: number): void {
setCheckedAnimeIds((ids) =>
@@ -105,6 +118,19 @@ export function AnimeTab({
const checkedEntries = checkedAnimeIds
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
const hydratedRecommendations = recommendations
.map((recommendation) => ({
...recommendation,
entries: recommendation.animeIds
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
.filter((entry): entry is (typeof anime)[number] => entry !== undefined),
}))
.filter((recommendation) => recommendation.entries.length >= 2);
const activeRecommendation = hydratedRecommendations[0] ?? null;
const reviewEntries = (reviewAnimeIds ?? [])
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
const mergeEntries = reviewRecommendationId !== null ? reviewEntries : checkedEntries;
if (selectedAnimeId !== null) {
return (
@@ -180,6 +206,22 @@ export function AnimeTab({
</div>
</div>
{activeRecommendation ? (
<DuplicateReviewStrip
entries={activeRecommendation.entries}
current={1}
total={hydratedRecommendations.length}
dismissing={dismissingRecommendationId === activeRecommendation.recommendationId}
error={recommendationActionError}
onReview={() => {
setReviewRecommendationId(activeRecommendation.recommendationId);
setReviewAnimeIds(activeRecommendation.animeIds);
setShowMergeDialog(true);
}}
onDismiss={() => void dismissRecommendation(activeRecommendation.recommendationId)}
/>
) : null}
{selectionMode && (
<div className="flex items-center justify-between gap-3 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2">
<div className="text-xs text-ctp-overlay2">
@@ -216,12 +258,21 @@ export function AnimeTab({
</div>
)}
{showMergeDialog && checkedEntries.length >= 2 && (
{showMergeDialog && mergeEntries.length >= 2 && (
<AnimeMergeDialog
entries={checkedEntries}
onClose={() => setShowMergeDialog(false)}
entries={mergeEntries}
onClose={() => {
setShowMergeDialog(false);
setReviewRecommendationId(null);
setReviewAnimeIds(null);
}}
onMerged={() => {
if (reviewRecommendationId !== null) {
clearRecommendation(reviewRecommendationId);
}
exitSelectionMode();
setReviewRecommendationId(null);
setReviewAnimeIds(null);
reload();
}}
/>
@@ -0,0 +1,66 @@
import type { AnimeLibraryItem } from '../../types/stats';
interface DuplicateReviewStripProps {
entries: AnimeLibraryItem[];
current: number;
total: number;
dismissing: boolean;
error?: string | null;
onReview: () => void;
onDismiss: () => void;
}
export function DuplicateReviewStrip({
entries,
current,
total,
dismissing,
error = null,
onReview,
onDismiss,
}: DuplicateReviewStripProps) {
return (
<aside
aria-label="Possible duplicate library entries"
className="relative overflow-hidden rounded-lg border border-ctp-yellow/25 bg-ctp-yellow/[0.06] px-3 py-2.5"
>
<div className="absolute inset-y-0 left-0 w-0.5 bg-ctp-yellow/70" aria-hidden="true" />
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-ctp-yellow">Possible duplicate</span>
{total > 1 ? (
<span className="text-[10px] tabular-nums text-ctp-overlay1">
{current} of {total}
</span>
) : null}
</div>
<p className="mt-0.5 truncate text-xs text-ctp-subtext0">
{entries.map((entry) => entry.canonicalTitle).join(' · ')}
</p>
</div>
<button
type="button"
disabled={dismissing}
onClick={onDismiss}
className="shrink-0 rounded-md px-2.5 py-1.5 text-xs text-ctp-overlay2 transition-colors hover:bg-ctp-surface0 hover:text-ctp-text disabled:opacity-50"
>
{dismissing ? 'Dismissing…' : 'Not duplicates'}
</button>
<button
type="button"
disabled={dismissing}
onClick={onReview}
className="shrink-0 rounded-md border border-ctp-yellow/35 bg-ctp-yellow/10 px-2.5 py-1.5 text-xs font-medium text-ctp-yellow transition-colors hover:bg-ctp-yellow/20 disabled:opacity-50"
>
Review merge
</button>
</div>
{error ? (
<p role="alert" className="mt-1.5 text-xs text-ctp-red">
{error}
</p>
) : null}
</aside>
);
}
@@ -1,6 +1,7 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { apiClient } from '../../lib/api-client';
import { formatDuration } from '../../lib/formatters';
import { useModalFocus } from '../../hooks/useModalFocus';
import { AnimeCoverImage } from './AnimeCoverImage';
import type { AnimeLibraryItem } from '../../types/stats';
@@ -28,11 +29,12 @@ export function LibraryEntryPicker({
const [loadFailed, setLoadFailed] = useState(false);
const [query, setQuery] = useState(initialQuery);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const headingId = useId();
const searchId = useId();
const busy = busyAnimeId !== null;
useEffect(() => {
inputRef.current?.focus();
let cancelled = false;
apiClient
.getAnimeLibrary()
@@ -51,6 +53,17 @@ export function LibraryEntryPicker({
};
}, []);
useModalFocus({
dialogRef,
initialFocusRef: inputRef,
dismissDisabled: busy,
onDismiss: onClose,
});
const handleDismiss = () => {
if (!busy) onClose();
};
const excluded = useMemo(() => new Set(excludeAnimeIds), [excludeAnimeIds]);
const visible = useMemo(() => {
const term = query.trim().toLowerCase();
@@ -61,9 +74,13 @@ export function LibraryEntryPicker({
}, [entries, excluded, query]);
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
onClick={handleDismiss}
>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
@@ -77,9 +94,10 @@ export function LibraryEntryPicker({
</h3>
<button
type="button"
onClick={onClose}
onClick={handleDismiss}
disabled={busy}
aria-label="Close"
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
>
{'✕'}
</button>
@@ -119,7 +137,7 @@ export function LibraryEntryPicker({
<button
key={entry.animeId}
type="button"
disabled={busyAnimeId !== null}
disabled={busy}
onClick={() => onSelect(entry)}
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-ctp-surface0 transition-colors text-left disabled:opacity-50"
>
+71 -4
View File
@@ -1,11 +1,16 @@
import { useCallback, useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeLibraryItem } from '../types/stats';
import type { AnimeLibraryItem, StatsAnimeMergeRecommendation } from '../types/stats';
const BACKGROUND_REFRESH_MS = 30_000;
export function useAnimeLibrary() {
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [recommendations, setRecommendations] = useState<StatsAnimeMergeRecommendation[]>([]);
const [dismissingRecommendationId, setDismissingRecommendationId] = useState<number | null>(null);
const [recommendationActionError, setRecommendationActionError] = useState<string | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const reload = useCallback(() => {
@@ -14,10 +19,14 @@ export function useAnimeLibrary() {
useEffect(() => {
let cancelled = false;
getStatsClient()
const client = getStatsClient();
client
.getAnimeLibrary()
.then((data) => {
if (!cancelled) setAnime(data);
if (!cancelled) {
setAnime(data);
setError(null);
}
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
@@ -25,10 +34,68 @@ export function useAnimeLibrary() {
.finally(() => {
if (!cancelled) setLoading(false);
});
// Recommendation support is deliberately non-blocking. An older backend
// should still be able to display its library even when this endpoint is
// unavailable.
client
.getAnimeMergeRecommendations()
.then((data) => {
if (!cancelled) {
setRecommendations(data.recommendations);
setRecommendationActionError(null);
}
})
.catch(() => {
// Preserve the last confirmed set. A transient polling failure should
// not make a pending review silently disappear.
});
return () => {
cancelled = true;
};
}, [reloadToken]);
return { anime, loading, error, reload };
useEffect(() => {
const refreshOnFocus = () => reload();
const interval = window.setInterval(reload, BACKGROUND_REFRESH_MS);
window.addEventListener('focus', refreshOnFocus);
return () => {
window.clearInterval(interval);
window.removeEventListener('focus', refreshOnFocus);
};
}, [reload]);
const dismissRecommendation = useCallback(async (recommendationId: number) => {
setDismissingRecommendationId(recommendationId);
setRecommendationActionError(null);
try {
await getStatsClient().dismissAnimeMergeRecommendation(recommendationId);
setRecommendations((current) =>
current.filter((item) => item.recommendationId !== recommendationId),
);
} catch {
setRecommendationActionError('Could not dismiss this suggestion. Try again.');
} finally {
setDismissingRecommendationId(null);
}
}, []);
const clearRecommendation = useCallback((recommendationId: number) => {
setRecommendations((current) =>
current.filter((item) => item.recommendationId !== recommendationId),
);
setRecommendationActionError(null);
}, []);
return {
anime,
loading,
error,
reload,
recommendations,
dismissRecommendation,
dismissingRecommendationId,
recommendationActionError,
clearRecommendation,
};
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, type RefObject } from 'react';
const FOCUSABLE_SELECTOR = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
interface UseModalFocusOptions {
dialogRef: RefObject<HTMLElement | null>;
initialFocusRef: RefObject<HTMLElement | null>;
dismissDisabled?: boolean;
onDismiss: () => void;
}
export function useModalFocus({
dialogRef,
initialFocusRef,
dismissDisabled = false,
onDismiss,
}: UseModalFocusOptions): void {
const dismissDisabledRef = useRef(dismissDisabled);
const onDismissRef = useRef(onDismiss);
dismissDisabledRef.current = dismissDisabled;
onDismissRef.current = onDismiss;
useEffect(() => {
const previouslyFocused =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
initialFocusRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (!dismissDisabledRef.current) {
event.preventDefault();
onDismissRef.current();
}
return;
}
if (event.key !== 'Tab') return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)];
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (
event.shiftKey &&
(document.activeElement === first || !dialog.contains(document.activeElement))
) {
event.preventDefault();
last.focus();
} else if (
!event.shiftKey &&
(document.activeElement === last || !dialog.contains(document.activeElement))
) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previouslyFocused?.focus();
};
}, [dialogRef, initialFocusRef]);
}
+39
View File
@@ -44,6 +44,45 @@ test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
);
});
test('getAnimeMergeRecommendations loads pending duplicate pairs', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(
JSON.stringify({ recommendations: [{ recommendationId: 4, animeIds: [7, 8] }] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof globalThis.fetch;
try {
const result = await apiClient.getAnimeMergeRecommendations();
assert.equal(seenUrl, `${BASE_URL}/api/stats/anime/merge-recommendations`);
assert.deepEqual(result.recommendations, [{ recommendationId: 4, animeIds: [7, 8] }]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('dismissAnimeMergeRecommendation sends DELETE for the selected suggestion', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}) as typeof globalThis.fetch;
try {
await apiClient.dismissAnimeMergeRecommendation(4);
assert.equal(seenUrl, `${BASE_URL}/api/stats/anime/merge-recommendations/4`);
assert.equal(seenMethod, 'DELETE');
} finally {
globalThis.fetch = originalFetch;
}
});
test('getCoverImages batches anime and media cover requests', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
+7
View File
@@ -129,6 +129,8 @@ export const apiClient = {
getMediaLibrary: () => fetchJson('mediaLibrary', '/api/stats/media'),
getMediaDetail: (videoId: number) => fetchJson('mediaDetail', `/api/stats/media/${videoId}`),
getAnimeLibrary: () => fetchJson('animeLibrary', '/api/stats/anime'),
getAnimeMergeRecommendations: () =>
fetchJson('animeMergeRecommendations', '/api/stats/anime/merge-recommendations'),
getAnimeDetail: (animeId: number) => fetchJson('animeDetail', `/api/stats/anime/${animeId}`),
getAnimeWords: (animeId: number, limit = 50) =>
fetchJson('animeWords', `/api/stats/anime/${animeId}/words?limit=${limit}`),
@@ -217,6 +219,11 @@ export const apiClient = {
});
return res.json() as Promise<StatsMoveVideoResponse>;
},
dismissAnimeMergeRecommendation: async (recommendationId: number): Promise<void> => {
await fetchResponse(`/api/stats/anime/merge-recommendations/${recommendationId}`, {
method: 'DELETE',
});
},
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
getAnimeKnownWordsSummary: (animeId: number) =>