mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
fix(stats): preserve provider identities and artwork during linking
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Documented provider reassignment, merge compatibility, and TMDB credential command caching.
|
||||||
@@ -2,5 +2,6 @@ type: added
|
|||||||
area: stats
|
area: stats
|
||||||
|
|
||||||
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB. Release builds include a project key, so it works out of the box; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it, and is required when running from source.
|
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB. Release builds include a project key, so it works out of the box; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it, and is required when running from source.
|
||||||
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action on a title to pick it by hand.
|
- Unlinked titles that AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action on a title to pick it by hand.
|
||||||
- Entries linked to the same TMDB title are merged into one card even when they came from different season folders, and the Library gained an Anime / Live Action filter.
|
- Entries linked to the same TMDB title are merged into one card even when they came from different season folders, and the Library gained an Anime / Live Action filter.
|
||||||
|
- Provider reassignment preserves the previous link and artwork if the replacement download fails. Merges and sync keep conflicting AniList and TMDB identities separate.
|
||||||
|
|||||||
@@ -1178,6 +1178,8 @@ Release builds ship with a project TMDB key, so nothing needs to be configured.
|
|||||||
| `tmdb.apiKey` | string | Your own TMDB API key or read access token; overrides the bundled key (default: empty) |
|
| `tmdb.apiKey` | string | Your own TMDB API key or read access token; overrides the bundled key (default: empty) |
|
||||||
| `tmdb.apiKeyCommand` | string | Shell command that prints the key to stdout, used instead of `apiKey` to keep it out of the config |
|
| `tmdb.apiKeyCommand` | string | Shell command that prints the key to stdout, used instead of `apiKey` to keep it out of the config |
|
||||||
|
|
||||||
|
Successful `apiKeyCommand` output is cached for the running client until `tmdb.apiKey` or `tmdb.apiKeyCommand` changes. Failed or empty command output is retried on the next request.
|
||||||
|
|
||||||
Changes apply to the next TMDB request without a restart.
|
Changes apply to the next TMDB request without a restart.
|
||||||
|
|
||||||
This product uses the TMDB API but is not endorsed or certified by TMDB.
|
This product uses the TMDB API but is not endorsed or certified by TMDB.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ Local files and Jellyfin items with detected season numbers are split into seaso
|
|||||||
|
|
||||||
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
|
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
|
||||||
|
|
||||||
**Live-action dramas and movies.** Anime covers come from AniList, which has no live-action titles. A title that AniList cannot match is looked up on [TMDB](/configuration#tmdb) instead (release builds bundle a key; source builds need your own): only a Japanese-language, non-animated result whose known titles match the parsed filename exactly is accepted, and it supplies the poster, synopsis, English and Japanese titles, and episode count. If nothing matches automatically, open the title and use **Link to TMDB** to search and pick it by hand. A TMDB show spans all of its seasons, so entries that resolve to the same TMDB title are merged into one card regardless of the season folder they came from, and the merged season titles are remembered so later episodes land on the same card. The **All Titles** filter above the grid narrows the Library to anime or live action, and a title's detail view shows whether it is a drama or a movie. Linking a title to AniList again turns it back into an anime entry.
|
**Live-action dramas and movies.** Anime covers come from AniList, which has no live-action titles. A title that AniList cannot match is looked up on [TMDB](/configuration#tmdb) instead (release builds bundle a key; source builds need your own): only a Japanese-language, non-animated result whose known titles match the parsed filename exactly is accepted, and it supplies the poster, synopsis, English and Japanese titles, and episode count. If nothing matches automatically, open the title and use **Link to TMDB** to search and pick it by hand. A TMDB show spans all of its seasons, so entries that resolve to the same TMDB title are merged into one card regardless of the season folder they came from, and the merged season titles are remembered so later episodes land on the same card. The **All Titles** filter above the grid narrows the Library to anime or live action, and a title's detail view shows whether it is a drama or a movie. Linking a title to AniList again turns it back into an anime entry. Changing providers downloads the replacement cover before saving the new link; a failed download leaves the previous link and artwork intact. A title without a cover clears the previous artwork. Automatic TMDB matching leaves existing AniList links unchanged.
|
||||||
|
|
||||||
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
|
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ 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:
|
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. 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.
|
- **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. AniList-linked and TMDB-linked entries cannot be merged together. 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. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
|
- **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. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ function createMockTracker(
|
|||||||
getKanjiOccurrences: async () => OCCURRENCES,
|
getKanjiOccurrences: async () => OCCURRENCES,
|
||||||
getAnimeLibrary: async () => ANIME_LIBRARY,
|
getAnimeLibrary: async () => ANIME_LIBRARY,
|
||||||
getAnimeDetail: async (animeId: number) => (animeId === 1 ? ANIME_DETAIL : null),
|
getAnimeDetail: async (animeId: number) => (animeId === 1 ? ANIME_DETAIL : null),
|
||||||
|
hasAnime: async (animeId: number) => animeId === 1,
|
||||||
getAnimeEpisodes: async () => ANIME_EPISODES,
|
getAnimeEpisodes: async () => ANIME_EPISODES,
|
||||||
getAnimeAnilistEntries: async () => [],
|
getAnimeAnilistEntries: async () => [],
|
||||||
getAnimeWords: async () => ANIME_WORDS,
|
getAnimeWords: async () => ANIME_WORDS,
|
||||||
@@ -3836,3 +3837,51 @@ Aligned English subtitle
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('TMDB reassignment returns 404 for a missing library entry before fetching details', async () => {
|
||||||
|
const assignments: number[] = [];
|
||||||
|
let fetches = 0;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
reassignAnimeTmdb: async (animeId: number) => {
|
||||||
|
assignments.push(animeId);
|
||||||
|
return { animeId, mergedAnimeIds: [] };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
tmdbClient: {
|
||||||
|
search: async () => [],
|
||||||
|
getDetails: async () => {
|
||||||
|
fetches += 1;
|
||||||
|
return {
|
||||||
|
tmdbId: 12,
|
||||||
|
tmdbType: 'tv',
|
||||||
|
titleEnglish: 'Drama',
|
||||||
|
titleNative: null,
|
||||||
|
description: null,
|
||||||
|
posterUrl: null,
|
||||||
|
episodesTotal: 10,
|
||||||
|
year: null,
|
||||||
|
originalLanguage: 'ja',
|
||||||
|
isAnimation: false,
|
||||||
|
allTitles: ['Drama'],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const request = (animeId: number) =>
|
||||||
|
app.request(`/api/stats/anime/${animeId}/tmdb`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tmdbId: 12, tmdbType: 'tv' }),
|
||||||
|
});
|
||||||
|
assert.equal((await request(99999)).status, 404);
|
||||||
|
assert.equal(fetches, 0);
|
||||||
|
assert.deepEqual(assignments, []);
|
||||||
|
const response = await request(1);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(await response.json(), { ok: true });
|
||||||
|
assert.equal(fetches, 1);
|
||||||
|
assert.deepEqual(assignments, [1]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -541,109 +541,123 @@ test('fetchIfMissing re-resolves an unresolved season once AniList publishes the
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fetchIfMissing falls back to TMDB when AniList has no match and stores the poster', async () => {
|
for (const linkedToAnilist of [false, true]) {
|
||||||
const dbPath = makeDbPath();
|
test(`TMDB fallback preserves AniList identity when linked=${linkedToAnilist}`, async () => {
|
||||||
const db = new Database(dbPath);
|
const dbPath = makeDbPath();
|
||||||
ensureSchema(db);
|
const db = new Database(dbPath);
|
||||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', {
|
ensureSchema(db);
|
||||||
canonicalTitle: 'Hanzawa Naoki - 01.mkv',
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', {
|
||||||
sourcePath: '/tmp/hanzawa-01.mkv',
|
canonicalTitle: 'Hanzawa Naoki - 01.mkv',
|
||||||
sourceUrl: null,
|
sourcePath: '/tmp/hanzawa-01.mkv',
|
||||||
sourceType: SOURCE_TYPE_LOCAL,
|
sourceUrl: null,
|
||||||
});
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
const animeId = getOrCreateAnimeRecord(db, {
|
});
|
||||||
parsedTitle: 'Hanzawa Naoki',
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
canonicalTitle: 'Hanzawa Naoki',
|
parsedTitle: 'Hanzawa Naoki',
|
||||||
anilistId: null,
|
canonicalTitle: 'Hanzawa Naoki',
|
||||||
titleRomaji: null,
|
anilistId: linkedToAnilist ? 42 : null,
|
||||||
titleEnglish: null,
|
titleRomaji: null,
|
||||||
titleNative: null,
|
titleEnglish: null,
|
||||||
metadataJson: null,
|
titleNative: null,
|
||||||
});
|
metadataJson: null,
|
||||||
linkVideoToAnimeRecord(db, videoId, {
|
});
|
||||||
animeId,
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
parsedBasename: null,
|
animeId,
|
||||||
parsedTitle: 'Hanzawa Naoki',
|
parsedBasename: null,
|
||||||
parsedSeason: null,
|
parsedTitle: 'Hanzawa Naoki',
|
||||||
parsedEpisode: 1,
|
parsedSeason: null,
|
||||||
parserSource: 'fallback',
|
parsedEpisode: 1,
|
||||||
parserConfidence: 1,
|
parserSource: 'fallback',
|
||||||
parseMetadataJson: null,
|
parserConfidence: 1,
|
||||||
});
|
parseMetadataJson: null,
|
||||||
|
|
||||||
const fetchCalls: string[] = [];
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
||||||
const url = String(input);
|
|
||||||
fetchCalls.push(url);
|
|
||||||
if (url.startsWith('https://graphql.anilist.co')) {
|
|
||||||
return createJsonResponse({ data: { Page: { media: [] } } });
|
|
||||||
}
|
|
||||||
assert.equal(url, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
|
|
||||||
return new Response(new Uint8Array([5, 6, 7]), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'image/jpeg' },
|
|
||||||
});
|
});
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const resolvedTitles: string[] = [];
|
const fetchCalls: string[] = [];
|
||||||
try {
|
const originalFetch = globalThis.fetch;
|
||||||
const fetcher = createCoverArtFetcher(
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
{ acquire: async () => {}, recordResponse: () => {} },
|
const url = String(input);
|
||||||
console,
|
fetchCalls.push(url);
|
||||||
{
|
if (url.startsWith('https://graphql.anilist.co')) {
|
||||||
runGuessit: async () => {
|
return createJsonResponse({ data: { Page: { media: [] } } });
|
||||||
throw new Error('guessit unavailable');
|
}
|
||||||
},
|
assert.equal(url, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
|
||||||
liveAction: {
|
return new Response(new Uint8Array([5, 6, 7]), {
|
||||||
async resolveByTitle(title) {
|
status: 200,
|
||||||
resolvedTitles.push(title);
|
headers: { 'Content-Type': 'image/jpeg' },
|
||||||
if (title !== 'Hanzawa Naoki') return null;
|
});
|
||||||
return {
|
}) as typeof fetch;
|
||||||
tmdbId: 61222,
|
|
||||||
tmdbType: 'tv',
|
const resolvedTitles: string[] = [];
|
||||||
titleEnglish: 'Hanzawa Naoki',
|
try {
|
||||||
titleNative: '半沢直樹',
|
const fetcher = createCoverArtFetcher(
|
||||||
description: 'A banker fights back.',
|
{ acquire: async () => {}, recordResponse: () => {} },
|
||||||
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
|
console,
|
||||||
episodesTotal: 10,
|
{
|
||||||
year: 2013,
|
runGuessit: async () => {
|
||||||
originalLanguage: 'ja',
|
throw new Error('guessit unavailable');
|
||||||
isAnimation: false,
|
|
||||||
allTitles: ['Hanzawa Naoki', '半沢直樹'],
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
async resolveById() {
|
liveAction: {
|
||||||
return null;
|
async resolveByTitle(title) {
|
||||||
|
resolvedTitles.push(title);
|
||||||
|
if (title !== 'Hanzawa Naoki') return null;
|
||||||
|
return {
|
||||||
|
tmdbId: 61222,
|
||||||
|
tmdbType: 'tv',
|
||||||
|
titleEnglish: 'Hanzawa Naoki',
|
||||||
|
titleNative: '半沢直樹',
|
||||||
|
description: 'A banker fights back.',
|
||||||
|
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
|
||||||
|
episodesTotal: 10,
|
||||||
|
year: 2013,
|
||||||
|
originalLanguage: 'ja',
|
||||||
|
isAnimation: false,
|
||||||
|
allTitles: ['Hanzawa Naoki', '半沢直樹'],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async resolveById() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const fetched = await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv');
|
const fetched = await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv');
|
||||||
const stored = getCoverArt(db, videoId);
|
const stored = getCoverArt(db, videoId);
|
||||||
const anime = db
|
const anime = db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, description FROM imm_anime WHERE anime_id = ?',
|
'SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, description FROM imm_anime WHERE anime_id = ?',
|
||||||
)
|
)
|
||||||
.get(animeId) as { mediaKind: string; tmdbId: number | null; description: string | null };
|
.get(animeId) as { mediaKind: string; tmdbId: number | null; description: string | null };
|
||||||
|
|
||||||
assert.equal(fetched, true);
|
if (linkedToAnilist) {
|
||||||
// The raw fallback-parser title is tried first, then the tag-stripped one.
|
assert.equal(fetched, false);
|
||||||
assert.deepEqual(resolvedTitles, ['Hanzawa Naoki - 01', 'Hanzawa Naoki']);
|
assert.equal(stored?.coverBlob, null);
|
||||||
assert.equal(stored?.anilistId, null);
|
assert.equal(stored?.coverUrl, null);
|
||||||
assert.equal(stored?.coverUrl, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
|
assert.equal(anime.mediaKind, 'anime');
|
||||||
assert.equal(Buffer.from(stored?.coverBlob ?? []).toString('hex'), '050607');
|
assert.equal(anime.tmdbId, null);
|
||||||
assert.equal(anime.mediaKind, 'live_action');
|
assert.deepEqual(resolvedTitles, []);
|
||||||
assert.equal(anime.tmdbId, 61222);
|
const requestCount = fetchCalls.length;
|
||||||
assert.equal(anime.description, 'A banker fights back.');
|
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv'), false);
|
||||||
assert.ok(fetchCalls.some((url) => url.startsWith('https://graphql.anilist.co')));
|
assert.equal(fetchCalls.length, requestCount);
|
||||||
} finally {
|
return;
|
||||||
globalThis.fetch = originalFetch;
|
}
|
||||||
db.close();
|
assert.equal(fetched, true);
|
||||||
cleanupDbPath(dbPath);
|
// The raw fallback-parser title is tried first, then the tag-stripped one.
|
||||||
}
|
assert.deepEqual(resolvedTitles, ['Hanzawa Naoki - 01', 'Hanzawa Naoki']);
|
||||||
});
|
assert.equal(stored?.anilistId, null);
|
||||||
|
assert.equal(stored?.coverUrl, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
|
||||||
|
assert.equal(Buffer.from(stored?.coverBlob ?? []).toString('hex'), '050607');
|
||||||
|
assert.equal(anime.mediaKind, 'live_action');
|
||||||
|
assert.equal(anime.tmdbId, 61222);
|
||||||
|
assert.equal(anime.description, 'A banker fights back.');
|
||||||
|
assert.ok(fetchCalls.some((url) => url.startsWith('https://graphql.anilist.co')));
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => {
|
test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
|
|||||||
@@ -175,8 +175,13 @@ export function createCoverArtFetcher(
|
|||||||
details: TmdbTitleDetails,
|
details: TmdbTitleDetails,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
.prepare(
|
||||||
.get(videoId) as { animeId: number | null } | undefined;
|
`SELECT v.anime_id AS animeId, a.anilist_id AS anilistId
|
||||||
|
FROM imm_videos v LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||||
|
WHERE v.video_id = ?`,
|
||||||
|
)
|
||||||
|
.get(videoId) as { animeId: number | null; anilistId: number | null } | undefined;
|
||||||
|
if (row?.anilistId != null) return false;
|
||||||
if (row?.animeId) {
|
if (row?.animeId) {
|
||||||
const link = linkAnimeToTmdbTitle(db, row.animeId, details, { mode: 'auto' });
|
const link = linkAnimeToTmdbTitle(db, row.animeId, details, { mode: 'auto' });
|
||||||
if (link.mergedAnimeIds.length > 0) {
|
if (link.mergedAnimeIds.length > 0) {
|
||||||
@@ -281,8 +286,16 @@ export function createCoverArtFetcher(
|
|||||||
|
|
||||||
// A live-action entry already knows its TMDB title; AniList has nothing
|
// A live-action entry already knows its TMDB title; AniList has nothing
|
||||||
// to add and would only produce a spurious anime match.
|
// to add and would only produce a spurious anime match.
|
||||||
|
const hasAnilistLink = Boolean(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 FROM imm_videos v JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||||
|
WHERE v.video_id = ? AND a.anilist_id IS NOT NULL`,
|
||||||
|
)
|
||||||
|
.get(videoId),
|
||||||
|
);
|
||||||
const tmdbLink = getVideoTmdbLink(db, videoId);
|
const tmdbLink = getVideoTmdbLink(db, videoId);
|
||||||
if (tmdbLink) {
|
if (tmdbLink && !hasAnilistLink) {
|
||||||
const details = await options.liveAction?.resolveById(tmdbLink.tmdbType, tmdbLink.tmdbId);
|
const details = await options.liveAction?.resolveById(tmdbLink.tmdbType, tmdbLink.tmdbId);
|
||||||
if (details) {
|
if (details) {
|
||||||
return storeLiveActionArt(db, videoId, details);
|
return storeLiveActionArt(db, videoId, details);
|
||||||
@@ -353,7 +366,7 @@ export function createCoverArtFetcher(
|
|||||||
|
|
||||||
const selected = resolution?.media ?? null;
|
const selected = resolution?.media ?? null;
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
if (options.liveAction) {
|
if (options.liveAction && !hasAnilistLink) {
|
||||||
for (const searchTitle of searchTitles) {
|
for (const searchTitle of searchTitles) {
|
||||||
const details = await options.liveAction.resolveByTitle(searchTitle);
|
const details = await options.liveAction.resolveByTitle(searchTitle);
|
||||||
if (details) {
|
if (details) {
|
||||||
|
|||||||
@@ -5394,3 +5394,91 @@ test('getVocabularySummary keeps different known-word snapshots independent', as
|
|||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const provider of ['anilist', 'tmdb'] as const) {
|
||||||
|
test(`${provider} reassignment keeps metadata and artwork on download failure, then replaces or clears both`, async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath });
|
||||||
|
const { db } = tracker as unknown as { db: DatabaseSync };
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES (1, 'show', 'Show', 1000, 1000);
|
||||||
|
INSERT INTO imm_videos(video_id, video_key, canonical_title, source_type, anime_id, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES (1, 'local:/tmp/show.mkv', 'Show', 1, 1, 0, 1000, 1000);
|
||||||
|
`);
|
||||||
|
const tmdb = {
|
||||||
|
tmdbId: 12,
|
||||||
|
tmdbType: 'tv' as const,
|
||||||
|
titleEnglish: 'Drama',
|
||||||
|
titleNative: null,
|
||||||
|
description: 'New description',
|
||||||
|
episodesTotal: 10,
|
||||||
|
};
|
||||||
|
globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3]));
|
||||||
|
if (provider === 'anilist') {
|
||||||
|
await tracker.reassignAnimeTmdb(1, { ...tmdb, posterUrl: 'https://images.test/old' });
|
||||||
|
} else {
|
||||||
|
await tracker.reassignAnimeAnilist(1, {
|
||||||
|
anilistId: 42,
|
||||||
|
coverUrl: 'https://images.test/old',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.equal(await tracker.hasAnime(1), true);
|
||||||
|
assert.equal(await tracker.hasAnime(999), false);
|
||||||
|
const readMetadata = () =>
|
||||||
|
db.prepare('SELECT * FROM imm_anime WHERE anime_id = 1').get() as {
|
||||||
|
media_kind: string;
|
||||||
|
anilist_id: number | null;
|
||||||
|
tmdb_id: number | null;
|
||||||
|
};
|
||||||
|
const before = readMetadata();
|
||||||
|
const oldArt = await tracker.getAnimeCoverArt(1);
|
||||||
|
const snapshot = (value: unknown) =>
|
||||||
|
JSON.stringify(value, (key, item: unknown) => (key === '_metadata' ? undefined : item));
|
||||||
|
const reassign = (url: string | null) =>
|
||||||
|
provider === 'anilist'
|
||||||
|
? tracker!.reassignAnimeAnilist(1, { anilistId: 99, coverUrl: url })
|
||||||
|
: tracker!.reassignAnimeTmdb(1, { ...tmdb, posterUrl: url });
|
||||||
|
for (const failure of ['http', 'network']) {
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
if (failure === 'network') throw new Error('offline');
|
||||||
|
return new Response(null, { status: 503 });
|
||||||
|
};
|
||||||
|
await assert.rejects(reassign('https://images.test/new'));
|
||||||
|
assert.equal(snapshot(readMetadata()), snapshot(before));
|
||||||
|
assert.equal(snapshot(await tracker.getAnimeCoverArt(1)), snapshot(oldArt));
|
||||||
|
}
|
||||||
|
globalThis.fetch = async () => new Response(new Uint8Array([9, 8, 7]));
|
||||||
|
if (provider === 'anilist') {
|
||||||
|
// Retained sessions without lifetime summaries exercise the bootstrap
|
||||||
|
// inside the reassignment transaction.
|
||||||
|
db.exec(`INSERT INTO imm_sessions(session_uuid, video_id, started_at_ms, ended_at_ms,
|
||||||
|
status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES ('retained-session', 1, '1000', '2000', 2, 1000, 1000, 2000)`);
|
||||||
|
}
|
||||||
|
await reassign('https://images.test/new');
|
||||||
|
const detail = readMetadata();
|
||||||
|
assert.equal(detail.media_kind, provider === 'anilist' ? 'anime' : 'live_action');
|
||||||
|
assert.equal(detail.anilist_id, provider === 'anilist' ? 99 : null);
|
||||||
|
assert.equal(detail.tmdb_id, provider === 'tmdb' ? 12 : null);
|
||||||
|
if (provider === 'anilist') {
|
||||||
|
assert.equal((await tracker.getAnimeDetail(1))?.totalActiveMs, 1000);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
new Uint8Array((await tracker.getAnimeCoverArt(1))!.coverBlob!),
|
||||||
|
new Uint8Array([9, 8, 7]),
|
||||||
|
);
|
||||||
|
await reassign(null);
|
||||||
|
assert.equal(await tracker.getAnimeCoverArt(1), null);
|
||||||
|
assert.equal(readMetadata().media_kind, detail.media_kind);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
applySessionLifetimeSummary,
|
applySessionLifetimeSummary,
|
||||||
reconcileStaleActiveSessions,
|
reconcileStaleActiveSessions,
|
||||||
rebuildLifetimeSummaries as rebuildLifetimeSummaryTables,
|
rebuildLifetimeSummaries as rebuildLifetimeSummaryTables,
|
||||||
|
rebuildLifetimeSummariesInTransaction,
|
||||||
recomputeLifetimeAnimeFromMedia,
|
recomputeLifetimeAnimeFromMedia,
|
||||||
recomputeLifetimeGlobalFromSummaries,
|
recomputeLifetimeGlobalFromSummaries,
|
||||||
repairLifetimeSummariesFromMedia,
|
repairLifetimeSummariesFromMedia,
|
||||||
@@ -116,7 +117,7 @@ import {
|
|||||||
dismissAnimeMergeRecommendation,
|
dismissAnimeMergeRecommendation,
|
||||||
getAnimeMergeRecommendations,
|
getAnimeMergeRecommendations,
|
||||||
repairLegacySeasonlessAnimeRows,
|
repairLegacySeasonlessAnimeRows,
|
||||||
resolveAnimeAnilistConflict,
|
resolveAnimeAnilistConflictInTransaction,
|
||||||
type AnimeMergeRecommendation,
|
type AnimeMergeRecommendation,
|
||||||
} from './immersion-tracker/anime-season-repair';
|
} from './immersion-tracker/anime-season-repair';
|
||||||
import {
|
import {
|
||||||
@@ -126,7 +127,7 @@ import {
|
|||||||
type VideoMoveSummary,
|
type VideoMoveSummary,
|
||||||
} from './immersion-tracker/anime-merge';
|
} from './immersion-tracker/anime-merge';
|
||||||
import {
|
import {
|
||||||
linkAnimeToTmdbTitle,
|
linkAnimeToTmdbTitleInTransaction,
|
||||||
type LiveActionLinkResult,
|
type LiveActionLinkResult,
|
||||||
type LiveActionTitleInput,
|
type LiveActionTitleInput,
|
||||||
} from './immersion-tracker/live-action-link';
|
} from './immersion-tracker/live-action-link';
|
||||||
@@ -824,6 +825,10 @@ export class ImmersionTrackerService {
|
|||||||
return getAnimeDetail(this.db, animeId);
|
return getAnimeDetail(this.db, animeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async hasAnime(animeId: number): Promise<boolean> {
|
||||||
|
return Boolean(this.db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId));
|
||||||
|
}
|
||||||
|
|
||||||
async getAnimeEpisodes(animeId: number): Promise<AnimeEpisodeRow[]> {
|
async getAnimeEpisodes(animeId: number): Promise<AnimeEpisodeRow[]> {
|
||||||
return getAnimeEpisodes(this.db, animeId);
|
return getAnimeEpisodes(this.db, animeId);
|
||||||
}
|
}
|
||||||
@@ -1022,17 +1027,26 @@ export class ImmersionTrackerService {
|
|||||||
coverUrl?: string | null;
|
coverUrl?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const coverBlob = await this.downloadReplacementCover(info.coverUrl);
|
||||||
this.requireWriteQueueDrained('reassigning an AniList entry');
|
this.requireWriteQueueDrained('reassigning an AniList entry');
|
||||||
// The user is acting on this entry, so it is the one that survives when
|
this.db.exec('BEGIN IMMEDIATE');
|
||||||
// another row already claims the same AniList id.
|
try {
|
||||||
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, {
|
this.db
|
||||||
survivor: 'target',
|
.prepare('UPDATE imm_anime SET tmdb_id = NULL, tmdb_type = NULL WHERE anime_id = ?')
|
||||||
matchConfidence: 'manual',
|
.run(animeId);
|
||||||
});
|
// The user is acting on this entry, so it is the one that survives when
|
||||||
if (repair.anilistAssignmentBlocked) return;
|
// another row already claims the same AniList id.
|
||||||
this.db
|
const repair = resolveAnimeAnilistConflictInTransaction(this.db, animeId, info.anilistId, {
|
||||||
.prepare(
|
survivor: 'target',
|
||||||
`
|
matchConfidence: 'manual',
|
||||||
|
});
|
||||||
|
if (repair.anilistAssignmentBlocked) {
|
||||||
|
this.db.exec('ROLLBACK');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
UPDATE imm_anime
|
UPDATE imm_anime
|
||||||
SET anilist_id = ?,
|
SET anilist_id = ?,
|
||||||
media_kind = 'anime',
|
media_kind = 'anime',
|
||||||
@@ -1046,38 +1060,46 @@ export class ImmersionTrackerService {
|
|||||||
LAST_UPDATE_DATE = ?
|
LAST_UPDATE_DATE = ?
|
||||||
WHERE anime_id = ?
|
WHERE anime_id = ?
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
info.anilistId,
|
info.anilistId,
|
||||||
info.titleRomaji ?? null,
|
info.titleRomaji ?? null,
|
||||||
info.titleEnglish ?? null,
|
info.titleEnglish ?? null,
|
||||||
info.titleNative ?? null,
|
info.titleNative ?? null,
|
||||||
info.episodesTotal ?? null,
|
info.episodesTotal ?? null,
|
||||||
info.description !== undefined ? 1 : 0,
|
info.description !== undefined ? 1 : 0,
|
||||||
info.description ?? null,
|
info.description ?? null,
|
||||||
nowMs(),
|
nowMs(),
|
||||||
animeId,
|
animeId,
|
||||||
);
|
);
|
||||||
// Empty lifetime tables still need the retained-session bootstrap. Once a
|
// Empty lifetime tables still need the retained-session bootstrap. Once a
|
||||||
// media ledger exists, only the redistributed and explicitly edited anime
|
// media ledger exists, only the redistributed and explicitly edited anime
|
||||||
// can have changed.
|
// can have changed.
|
||||||
if (shouldBackfillLifetimeSummaries(this.db)) {
|
if (shouldBackfillLifetimeSummaries(this.db)) {
|
||||||
repairLifetimeSummariesFromMedia(this.db);
|
rebuildLifetimeSummariesInTransaction(this.db);
|
||||||
} else {
|
} else {
|
||||||
const affectedAnimeIds = new Set(repair.affectedAnimeIds);
|
const affectedAnimeIds = new Set(repair.affectedAnimeIds);
|
||||||
affectedAnimeIds.add(animeId);
|
affectedAnimeIds.add(animeId);
|
||||||
recomputeLifetimeAnimeFromMedia(this.db, [...affectedAnimeIds]);
|
recomputeLifetimeAnimeFromMedia(this.db, [...affectedAnimeIds]);
|
||||||
recomputeLifetimeGlobalFromSummaries(this.db);
|
recomputeLifetimeGlobalFromSummaries(this.db);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info.coverUrl) {
|
if (info.coverUrl) {
|
||||||
await this.applyCoverArtToAnimeVideos(animeId, {
|
this.applyCoverArtToAnimeVideos(animeId, {
|
||||||
anilistId: info.anilistId,
|
anilistId: info.anilistId,
|
||||||
coverUrl: info.coverUrl,
|
coverUrl: info.coverUrl,
|
||||||
titleRomaji: info.titleRomaji ?? null,
|
coverBlob,
|
||||||
titleEnglish: info.titleEnglish ?? null,
|
titleRomaji: info.titleRomaji ?? null,
|
||||||
episodesTotal: info.episodesTotal ?? null,
|
titleEnglish: info.titleEnglish ?? null,
|
||||||
});
|
episodesTotal: info.episodesTotal ?? null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clearAnimeCoverArt(this.db, animeId);
|
||||||
|
}
|
||||||
|
this.db.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
this.db.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1090,52 +1112,62 @@ export class ImmersionTrackerService {
|
|||||||
animeId: number,
|
animeId: number,
|
||||||
details: LiveActionTitleInput & { posterUrl: string | null },
|
details: LiveActionTitleInput & { posterUrl: string | null },
|
||||||
): Promise<LiveActionLinkResult> {
|
): Promise<LiveActionLinkResult> {
|
||||||
|
const coverBlob = await this.downloadReplacementCover(details.posterUrl);
|
||||||
this.requireWriteQueueDrained('linking a TMDB title');
|
this.requireWriteQueueDrained('linking a TMDB title');
|
||||||
const result = linkAnimeToTmdbTitle(this.db, animeId, details, { mode: 'manual' });
|
this.db.exec('BEGIN IMMEDIATE');
|
||||||
if (details.posterUrl) {
|
try {
|
||||||
await this.applyCoverArtToAnimeVideos(result.animeId, {
|
const result = linkAnimeToTmdbTitleInTransaction(this.db, animeId, details, {
|
||||||
anilistId: null,
|
mode: 'manual',
|
||||||
coverUrl: details.posterUrl,
|
|
||||||
titleRomaji: null,
|
|
||||||
titleEnglish: details.titleEnglish,
|
|
||||||
episodesTotal: details.episodesTotal,
|
|
||||||
});
|
});
|
||||||
} else {
|
if (details.posterUrl) {
|
||||||
// The user chose this title deliberately, so art from the previous link
|
this.applyCoverArtToAnimeVideos(result.animeId, {
|
||||||
// must not keep standing in for it.
|
anilistId: null,
|
||||||
clearAnimeCoverArt(this.db, result.animeId);
|
coverUrl: details.posterUrl,
|
||||||
|
coverBlob,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: details.titleEnglish,
|
||||||
|
episodesTotal: details.episodesTotal,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// The user chose this title deliberately, so art from the previous link
|
||||||
|
// must not keep standing in for it.
|
||||||
|
clearAnimeCoverArt(this.db, result.animeId);
|
||||||
|
}
|
||||||
|
this.db.exec('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.db.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Downloads one cover and stores it against every episode of the entry. */
|
private async downloadReplacementCover(url: string | null | undefined): Promise<Buffer | null> {
|
||||||
private async applyCoverArtToAnimeVideos(
|
if (!url) return null;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`Cover download failed: ${response.status}`);
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores the downloaded replacement against every episode of the entry. */
|
||||||
|
private applyCoverArtToAnimeVideos(
|
||||||
animeId: number,
|
animeId: number,
|
||||||
art: {
|
art: {
|
||||||
anilistId: number | null;
|
anilistId: number | null;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
|
coverBlob: Buffer | null;
|
||||||
titleRomaji: string | null;
|
titleRomaji: string | null;
|
||||||
titleEnglish: string | null;
|
titleEnglish: string | null;
|
||||||
episodesTotal: number | null;
|
episodesTotal: number | null;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): void {
|
||||||
const videos = this.db
|
const videos = this.db
|
||||||
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
|
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
|
||||||
.all(animeId) as Array<{ video_id: number }>;
|
.all(animeId) as Array<{ video_id: number }>;
|
||||||
let coverBlob: Buffer | null = null;
|
|
||||||
try {
|
|
||||||
const res = await fetch(art.coverUrl);
|
|
||||||
if (res.ok) {
|
|
||||||
coverBlob = Buffer.from(await res.arrayBuffer());
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
for (const v of videos) {
|
for (const v of videos) {
|
||||||
upsertCoverArt(this.db, v.video_id, {
|
upsertCoverArt(this.db, v.video_id, {
|
||||||
anilistId: art.anilistId,
|
anilistId: art.anilistId,
|
||||||
coverUrl: art.coverUrl,
|
coverUrl: art.coverUrl,
|
||||||
coverBlob,
|
coverBlob: art.coverBlob,
|
||||||
titleRomaji: art.titleRomaji,
|
titleRomaji: art.titleRomaji,
|
||||||
titleEnglish: art.titleEnglish,
|
titleEnglish: art.titleEnglish,
|
||||||
episodesTotal: art.episodesTotal,
|
episodesTotal: art.episodesTotal,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Database } from '../sqlite.js';
|
|||||||
import type { DatabaseSync } from '../sqlite.js';
|
import type { DatabaseSync } from '../sqlite.js';
|
||||||
import { applyPragmas, ensureSchema, getOrCreateAnimeRecord } from '../storage.js';
|
import { applyPragmas, ensureSchema, getOrCreateAnimeRecord } from '../storage.js';
|
||||||
import { repairLegacySeasonlessAnimeRows } from '../anime-season-repair.js';
|
import { repairLegacySeasonlessAnimeRows } from '../anime-season-repair.js';
|
||||||
|
import { mergeAnimeRecords, mergeAnimeRecordsInTransaction } from '../anime-merge.js';
|
||||||
import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../live-action-link.js';
|
import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../live-action-link.js';
|
||||||
import { getAnimeCoverArt, getCoverArt } from '../query-library.js';
|
import { getAnimeCoverArt, getCoverArt } from '../query-library.js';
|
||||||
import { clearAnimeCoverArt, upsertCoverArt } from '../query-maintenance.js';
|
import { clearAnimeCoverArt, upsertCoverArt } from '../query-maintenance.js';
|
||||||
@@ -234,3 +235,50 @@ test('clearAnimeCoverArt drops every episode cover of the entry and its orphaned
|
|||||||
assert.equal(blobs, 1);
|
assert.equal(blobs, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const targetId of [1, 2, 3]) {
|
||||||
|
test(`merge rejects mixed providers before moving any source into entry ${targetId}`, () => {
|
||||||
|
withDb((db) => {
|
||||||
|
insertAnime(db, 1, 'Anime', 77);
|
||||||
|
insertAnime(db, 2, 'Drama');
|
||||||
|
insertAnime(db, 3, 'Unlinked');
|
||||||
|
insertEpisode(db, 1, 1, 1);
|
||||||
|
insertEpisode(db, 2, 2, 1);
|
||||||
|
db.exec(
|
||||||
|
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 12, tmdb_type = 'tv' WHERE anime_id = 2",
|
||||||
|
);
|
||||||
|
for (const merge of [mergeAnimeRecords, mergeAnimeRecordsInTransaction]) {
|
||||||
|
assert.throws(() => merge(db, targetId, [3, 1, 2]), /Cannot merge AniList and TMDB/);
|
||||||
|
assert.equal(animeCount(db), 3);
|
||||||
|
assert.equal(videoOwner(db, 1), 1);
|
||||||
|
assert.equal(videoOwner(db, 2), 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mode of ['manual', 'auto'] as const) {
|
||||||
|
test(`TMDB ${mode} linking rolls back the merge when the survivor update fails`, () => {
|
||||||
|
withDb((db) => {
|
||||||
|
insertAnime(db, 1, 'New entry');
|
||||||
|
insertAnime(db, 2, 'Existing entry');
|
||||||
|
insertEpisode(db, 1, 1, 1);
|
||||||
|
insertEpisode(db, 2, 2, 2);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE imm_anime SET tmdb_id = ?, tmdb_type = 'tv', media_kind = 'live_action' WHERE anime_id = 2",
|
||||||
|
).run(HANZAWA.tmdbId);
|
||||||
|
db.exec(`CREATE TRIGGER reject_link BEFORE UPDATE ON imm_anime
|
||||||
|
WHEN NEW.description = 'A banker fights back.'
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'rejected survivor update'); END`);
|
||||||
|
assert.throws(
|
||||||
|
() => linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode }),
|
||||||
|
/rejected survivor update/,
|
||||||
|
);
|
||||||
|
assert.equal(animeCount(db), 2);
|
||||||
|
assert.equal(videoOwner(db, 1), 1);
|
||||||
|
assert.equal(videoOwner(db, 2), 2);
|
||||||
|
assert.equal(animeRow(db, 1)?.tmdbId, null);
|
||||||
|
assert.equal(animeRow(db, 2)?.tmdbId, HANZAWA.tmdbId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -179,6 +179,18 @@ export function mergeAnimeRecordsInTransaction(
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the whole group before moving anything, including when the
|
||||||
|
// unlinked target would inherit conflicting providers from two sources.
|
||||||
|
const metadata = [targetAnimeId, ...new Set(sourceAnimeIds)].map((id) =>
|
||||||
|
readAnimeMetadata(db, id),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
metadata.some((row) => row?.anilist_id != null) &&
|
||||||
|
metadata.some((row) => row?.tmdb_id != null)
|
||||||
|
) {
|
||||||
|
throw new Error('Cannot merge AniList and TMDB library entries');
|
||||||
|
}
|
||||||
|
|
||||||
const updatedAt = toDbTimestamp(nowMs());
|
const updatedAt = toDbTimestamp(nowMs());
|
||||||
const sourceVideosStmt = db.prepare(
|
const sourceVideosStmt = db.prepare(
|
||||||
'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?',
|
'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?',
|
||||||
|
|||||||
@@ -372,6 +372,18 @@ export function resolveAnimeAnilistConflict(
|
|||||||
targetAnimeId: number,
|
targetAnimeId: number,
|
||||||
anilistId: number,
|
anilistId: number,
|
||||||
options: AnimeAnilistConflictOptions = {},
|
options: AnimeAnilistConflictOptions = {},
|
||||||
|
): AnimeSeasonRepairSummary {
|
||||||
|
return runInTransaction(db, () =>
|
||||||
|
resolveAnimeAnilistConflictInTransaction(db, targetAnimeId, anilistId, options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caller owns the write transaction. */
|
||||||
|
export function resolveAnimeAnilistConflictInTransaction(
|
||||||
|
db: DatabaseSync,
|
||||||
|
targetAnimeId: number,
|
||||||
|
anilistId: number,
|
||||||
|
options: AnimeAnilistConflictOptions = {},
|
||||||
): AnimeSeasonRepairSummary {
|
): AnimeSeasonRepairSummary {
|
||||||
const conflict = db
|
const conflict = db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -388,84 +400,82 @@ export function resolveAnimeAnilistConflict(
|
|||||||
return emptySummary();
|
return emptySummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
return runInTransaction(db, () => {
|
const targetRow = getAnimeRow(db, targetAnimeId);
|
||||||
const targetRow = getAnimeRow(db, targetAnimeId);
|
if (
|
||||||
if (
|
options.survivor !== 'target' &&
|
||||||
options.survivor !== 'target' &&
|
targetRow?.anilist_id != null &&
|
||||||
targetRow?.anilist_id != null &&
|
targetRow.anilist_id !== anilistId
|
||||||
targetRow.anilist_id !== anilistId
|
) {
|
||||||
) {
|
// An automatic lookup disagreeing with an existing explicit link is a
|
||||||
// An automatic lookup disagreeing with an existing explicit link is a
|
// mis-resolution, not evidence that either row should move or merge. The
|
||||||
// mis-resolution, not evidence that either row should move or merge. The
|
// colliding id must not be assigned either: another row owns it and
|
||||||
// colliding id must not be assigned either: another row owns it and
|
// imm_anime.anilist_id is UNIQUE.
|
||||||
// imm_anime.anilist_id is UNIQUE.
|
const summary = emptySummary(1);
|
||||||
const summary = emptySummary(1);
|
summary.anilistAssignmentBlocked = true;
|
||||||
summary.anilistAssignmentBlocked = true;
|
return summary;
|
||||||
return summary;
|
}
|
||||||
}
|
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
|
||||||
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
|
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
|
||||||
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
|
const summary = emptySummary(1);
|
||||||
const summary = emptySummary(1);
|
summary.anilistAssignmentBlocked = true;
|
||||||
summary.anilistAssignmentBlocked = true;
|
return summary;
|
||||||
return summary;
|
}
|
||||||
}
|
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
|
||||||
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
|
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
|
||||||
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
|
if (
|
||||||
if (
|
!isManual &&
|
||||||
!isManual &&
|
targetSeasons.size === 1 &&
|
||||||
targetSeasons.size === 1 &&
|
conflictSeasons.size === 1 &&
|
||||||
conflictSeasons.size === 1 &&
|
[...targetSeasons][0] !== [...conflictSeasons][0]
|
||||||
[...targetSeasons][0] !== [...conflictSeasons][0]
|
) {
|
||||||
) {
|
const summary = emptySummary(1);
|
||||||
const summary = emptySummary(1);
|
summary.anilistAssignmentBlocked = true;
|
||||||
summary.anilistAssignmentBlocked = true;
|
return summary;
|
||||||
return summary;
|
}
|
||||||
}
|
if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) {
|
||||||
if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) {
|
const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId;
|
||||||
const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId;
|
const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId;
|
||||||
const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId;
|
const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]);
|
||||||
const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]);
|
const summary = emptySummary(1);
|
||||||
const summary = emptySummary(1);
|
summary.movedVideos = merge.movedVideos;
|
||||||
summary.movedVideos = merge.movedVideos;
|
summary.deletedAnimeRows = merge.mergedAnimeIds.length;
|
||||||
summary.deletedAnimeRows = merge.mergedAnimeIds.length;
|
if (merge.mergedAnimeIds.length > 0) {
|
||||||
if (merge.mergedAnimeIds.length > 0) {
|
summary.repaired = 1;
|
||||||
summary.repaired = 1;
|
// Only reported once a row really absorbed the other, so callers never
|
||||||
// Only reported once a row really absorbed the other, so callers never
|
// follow this to an anime id that was never written.
|
||||||
// follow this to an anime id that was never written.
|
summary.survivingAnimeId = survivingAnimeId;
|
||||||
summary.survivingAnimeId = survivingAnimeId;
|
summary.affectedAnimeIds.push(survivingAnimeId, absorbedAnimeId);
|
||||||
summary.affectedAnimeIds.push(survivingAnimeId, absorbedAnimeId);
|
|
||||||
}
|
|
||||||
// Lifetime summaries are rebuilt by the caller off this summary, the same
|
|
||||||
// as the redistribution path below.
|
|
||||||
return summary;
|
|
||||||
}
|
}
|
||||||
|
// Lifetime summaries are rebuilt by the caller off this summary, the same
|
||||||
|
// as the redistribution path below.
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) {
|
if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) {
|
||||||
recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId);
|
recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId);
|
||||||
const summary = emptySummary(1);
|
const summary = emptySummary(1);
|
||||||
summary.mergeRecommended = true;
|
summary.mergeRecommended = true;
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExactAutomaticMatch =
|
const isExactAutomaticMatch =
|
||||||
options.matchConfidence === 'exact' ||
|
options.matchConfidence === 'exact' ||
|
||||||
(options.matchConfidence === undefined &&
|
(options.matchConfidence === undefined &&
|
||||||
hasExactStoredTitleMatch(db, targetAnimeId, conflict.animeId));
|
hasExactStoredTitleMatch(db, targetAnimeId, conflict.animeId));
|
||||||
if (!isManual && !isExactAutomaticMatch) {
|
if (!isManual && !isExactAutomaticMatch) {
|
||||||
// Redistribution dismantles the id's current owner and hands the id to
|
// Redistribution dismantles the id's current owner and hands the id to
|
||||||
// the target. On a weak automatic match that owner is usually the
|
// the target. On a weak automatic match that owner is usually the
|
||||||
// correctly linked card (e.g. a legitimate multi-season entry), so
|
// correctly linked card (e.g. a legitimate multi-season entry), so
|
||||||
// splitting it here is exactly the fuzzy false merge this gate exists to
|
// splitting it here is exactly the fuzzy false merge this gate exists to
|
||||||
// stop. Only exact or manual evidence may fall through.
|
// stop. Only exact or manual evidence may fall through.
|
||||||
const summary = emptySummary(1);
|
const summary = emptySummary(1);
|
||||||
summary.anilistAssignmentBlocked = true;
|
summary.anilistAssignmentBlocked = true;
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
|
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
|
||||||
transferAnilistToAnimeId: targetAnimeId,
|
transferAnilistToAnimeId: targetAnimeId,
|
||||||
overwriteTargetAnilist: true,
|
overwriteTargetAnilist: true,
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import type { TmdbMediaType } from '../../../shared/media-kind';
|
import type { TmdbMediaType } from '../../../shared/media-kind';
|
||||||
import { mergeAnimeRecords } from './anime-merge';
|
import { mergeAnimeRecordsInTransaction } from './anime-merge';
|
||||||
|
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
|
||||||
import { toDbTimestamp } from './query-shared';
|
import { toDbTimestamp } from './query-shared';
|
||||||
import { nowMs } from './time';
|
import { nowMs } from './time';
|
||||||
|
|
||||||
@@ -64,17 +65,45 @@ export function linkAnimeToTmdbTitle(
|
|||||||
input: LiveActionTitleInput,
|
input: LiveActionTitleInput,
|
||||||
options: LiveActionLinkOptions,
|
options: LiveActionLinkOptions,
|
||||||
): LiveActionLinkResult {
|
): LiveActionLinkResult {
|
||||||
|
db.exec('BEGIN IMMEDIATE');
|
||||||
|
try {
|
||||||
|
const result = linkAnimeToTmdbTitleInTransaction(db, animeId, input, options);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caller owns the write transaction, including any artwork replacement. */
|
||||||
|
export function linkAnimeToTmdbTitleInTransaction(
|
||||||
|
db: DatabaseSync,
|
||||||
|
animeId: number,
|
||||||
|
input: LiveActionTitleInput,
|
||||||
|
options: LiveActionLinkOptions,
|
||||||
|
): LiveActionLinkResult {
|
||||||
|
const target = db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(animeId) as
|
||||||
|
| { anilist_id: number | null }
|
||||||
|
| undefined;
|
||||||
|
if (!target) throw new Error('Unknown library entry');
|
||||||
|
if (target.anilist_id !== null) {
|
||||||
|
if (options.mode === 'auto')
|
||||||
|
throw new Error('Cannot automatically replace an AniList identity');
|
||||||
|
// An explicit reassignment changes providers before compatible rows merge.
|
||||||
|
db.prepare('UPDATE imm_anime SET anilist_id = NULL WHERE anime_id = ?').run(animeId);
|
||||||
|
}
|
||||||
const others = findOtherTmdbHolders(db, animeId, input);
|
const others = findOtherTmdbHolders(db, animeId, input);
|
||||||
let survivor = animeId;
|
let survivor = animeId;
|
||||||
let mergedAnimeIds: number[] = [];
|
let mergedAnimeIds: number[] = [];
|
||||||
if (others.length > 0) {
|
if (others.length > 0) {
|
||||||
if (options.mode === 'manual') {
|
if (options.mode === 'manual') {
|
||||||
mergedAnimeIds = mergeAnimeRecords(db, animeId, others).mergedAnimeIds;
|
mergedAnimeIds = mergeAnimeRecordsInTransaction(db, animeId, others).mergedAnimeIds;
|
||||||
} else {
|
} else {
|
||||||
// Keep the entry the user already sees; the newcomer is the transient
|
// Keep the entry the user already sees; the newcomer is the transient
|
||||||
// "Show Season 3" row that a fresh season folder just created.
|
// "Show Season 3" row that a fresh season folder just created.
|
||||||
survivor = others[0]!;
|
survivor = others[0]!;
|
||||||
mergedAnimeIds = mergeAnimeRecords(db, survivor, [
|
mergedAnimeIds = mergeAnimeRecordsInTransaction(db, survivor, [
|
||||||
animeId,
|
animeId,
|
||||||
...others.slice(1),
|
...others.slice(1),
|
||||||
]).mergedAnimeIds;
|
]).mergedAnimeIds;
|
||||||
@@ -129,6 +158,7 @@ export function linkAnimeToTmdbTitle(
|
|||||||
survivor,
|
survivor,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (mergedAnimeIds.length > 0) recomputeLifetimeAnimeAggregatesInTransaction(db);
|
||||||
return { animeId: survivor, mergedAnimeIds };
|
return { animeId: survivor, mergedAnimeIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export function registerStatsIntegrationRoutes(
|
|||||||
) {
|
) {
|
||||||
return c.body(null, 400);
|
return c.body(null, 400);
|
||||||
}
|
}
|
||||||
|
if (!(await tracker.hasAnime(animeId))) return c.body(null, 404);
|
||||||
const tmdbClient = options?.tmdbClient;
|
const tmdbClient = options?.tmdbClient;
|
||||||
if (!tmdbClient) return c.json(statsJson('error', { error: 'TMDB is not available' }), 503);
|
if (!tmdbClient) return c.json(statsJson('error', { error: 'TMDB is not available' }), 503);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Database, type DatabaseSync } from '../immersion-tracker/sqlite';
|
||||||
|
import { ensureSchema } from '../immersion-tracker/storage';
|
||||||
|
import { mergeAnime } from './merge-catalog';
|
||||||
|
import { createEmptyMergeSummary } from './shared';
|
||||||
|
|
||||||
|
const identities = {
|
||||||
|
unlinked: [null, null, null],
|
||||||
|
anilist: [42, null, null],
|
||||||
|
tmdb: [null, 12, 'tv'],
|
||||||
|
otherTmdb: [null, 13, 'tv'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
for (const [localKind, remoteKind, sameEntry] of [
|
||||||
|
['anilist', 'tmdb', false],
|
||||||
|
['tmdb', 'anilist', false],
|
||||||
|
['tmdb', 'otherTmdb', false],
|
||||||
|
['unlinked', 'tmdb', true],
|
||||||
|
['unlinked', 'anilist', true],
|
||||||
|
['tmdb', 'unlinked', true],
|
||||||
|
['tmdb', 'tmdb', true],
|
||||||
|
['anilist', 'anilist', true],
|
||||||
|
] as const) {
|
||||||
|
test(`catalog title match: ${localKind} with ${remoteKind}`, () => {
|
||||||
|
const local = new Database(':memory:');
|
||||||
|
const remote = new Database(':memory:');
|
||||||
|
const adapt = (db: DatabaseSync) => ({
|
||||||
|
query: (sql: string) => db.prepare(sql),
|
||||||
|
exec: (sql: string) => {
|
||||||
|
db.exec(sql);
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
db.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (const [db, kind] of [
|
||||||
|
[local, localKind],
|
||||||
|
[remote, remoteKind],
|
||||||
|
] as const) {
|
||||||
|
ensureSchema(db);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_anime(normalized_title_key, canonical_title, anilist_id, tmdb_id, tmdb_type, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES ('same title', 'Same title', ?, ?, ?, 1000, 1000)`,
|
||||||
|
).run(...identities[kind]);
|
||||||
|
}
|
||||||
|
const summary = createEmptyMergeSummary();
|
||||||
|
const map = mergeAnime(adapt(local), adapt(remote), summary);
|
||||||
|
assert.equal(map.get(1) === 1, sameEntry);
|
||||||
|
assert.equal(summary.animeAdded, sameEntry ? 0 : 1);
|
||||||
|
const again = createEmptyMergeSummary();
|
||||||
|
assert.equal(mergeAnime(adapt(local), adapt(remote), again).get(1), map.get(1));
|
||||||
|
assert.equal(again.animeAdded, 0);
|
||||||
|
} finally {
|
||||||
|
local.close();
|
||||||
|
remote.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -105,7 +105,9 @@ export function mergeAnime(
|
|||||||
const byTmdb = local.query(
|
const byTmdb = local.query(
|
||||||
'SELECT anime_id FROM imm_anime WHERE tmdb_id = ? AND tmdb_type = ? ORDER BY anime_id LIMIT 1',
|
'SELECT anime_id FROM imm_anime WHERE tmdb_id = ? AND tmdb_type = ? ORDER BY anime_id LIMIT 1',
|
||||||
);
|
);
|
||||||
const byTitleKey = local.query('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?');
|
const byTitleKey = local.query(
|
||||||
|
`SELECT anime_id, anilist_id, tmdb_id, tmdb_type FROM imm_anime WHERE normalized_title_key = ?`,
|
||||||
|
);
|
||||||
// A TMDB link only fills in when the local row is unlinked: a row already
|
// A TMDB link only fills in when the local row is unlinked: a row already
|
||||||
// pinned to AniList stays anime, and vice versa, so the two link kinds never
|
// pinned to AniList stays anime, and vice versa, so the two link kinds never
|
||||||
// coexist on one entry.
|
// coexist on one entry.
|
||||||
@@ -131,9 +133,21 @@ export function mergeAnime(
|
|||||||
`SELECT anime_id, ${ANIME_COPY_COLUMNS.join(', ')} FROM imm_anime`,
|
`SELECT anime_id, ${ANIME_COPY_COLUMNS.join(', ')} FROM imm_anime`,
|
||||||
)) {
|
)) {
|
||||||
const remoteId = Number(row.anime_id);
|
const remoteId = Number(row.anime_id);
|
||||||
|
const titleMatch = byTitleKey.get(row.normalized_title_key) as SqlRow | undefined;
|
||||||
|
const compatibleTitleMatch =
|
||||||
|
titleMatch &&
|
||||||
|
((titleMatch.anilist_id === null && titleMatch.tmdb_id === null) ||
|
||||||
|
(row.anilist_id === null && row.tmdb_id === null) ||
|
||||||
|
(titleMatch.tmdb_id === null &&
|
||||||
|
row.tmdb_id === null &&
|
||||||
|
titleMatch.anilist_id === row.anilist_id) ||
|
||||||
|
(titleMatch.anilist_id === null &&
|
||||||
|
row.anilist_id === null &&
|
||||||
|
titleMatch.tmdb_id === row.tmdb_id &&
|
||||||
|
titleMatch.tmdb_type === row.tmdb_type));
|
||||||
const existing = ((row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ??
|
const existing = ((row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ??
|
||||||
(row.tmdb_id !== null ? byTmdb.get(row.tmdb_id, row.tmdb_type) : undefined) ??
|
(row.tmdb_id !== null ? byTmdb.get(row.tmdb_id, row.tmdb_type) : undefined) ??
|
||||||
byTitleKey.get(row.normalized_title_key)) as SqlRow | undefined;
|
(compatibleTitleMatch ? titleMatch : undefined)) as SqlRow | undefined;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const localId = Number(existing.anime_id);
|
const localId = Number(existing.anime_id);
|
||||||
map.set(remoteId, localId);
|
map.set(remoteId, localId);
|
||||||
@@ -151,9 +165,14 @@ export function mergeAnime(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// No local row matched by anilist_id (checked first in `existing` above)
|
// Conflicting providers can share a title, but the stored title key is unique.
|
||||||
// or title key, so the remote anilist_id — if any — is free to insert as-is.
|
let titleKey = row.normalized_title_key;
|
||||||
const values = ANIME_COPY_COLUMNS.map((column) => row[column]);
|
for (let suffix = 1; byTitleKey.get(titleKey); suffix += 1) {
|
||||||
|
titleKey = `${row.normalized_title_key}:sync:${suffix}`;
|
||||||
|
}
|
||||||
|
const values = ANIME_COPY_COLUMNS.map((column) =>
|
||||||
|
column === 'normalized_title_key' ? titleKey : row[column],
|
||||||
|
);
|
||||||
map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values));
|
map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values));
|
||||||
summary.animeAdded += 1;
|
summary.animeAdded += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { TmdbApiKeyMissingError, createTmdbClient, resolveTmdbApiKey } from './tmdb-client.js';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { TmdbConfig } from '../../../types/integrations';
|
||||||
|
import {
|
||||||
|
TmdbApiKeyMissingError,
|
||||||
|
createTmdbClient,
|
||||||
|
createTmdbApiKeyResolver,
|
||||||
|
resolveTmdbApiKey,
|
||||||
|
} from './tmdb-client.js';
|
||||||
|
|
||||||
function jsonResponse(payload: unknown, status = 200): Response {
|
function jsonResponse(payload: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(payload), {
|
return new Response(JSON.stringify(payload), {
|
||||||
@@ -109,16 +118,17 @@ test('search sends a v3 key as a query parameter and drops people from multi res
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a v4 read token travels as a bearer header instead of api_key', async () => {
|
test('a v4 read token travels as a bearer header instead of api_key', async () => {
|
||||||
|
const v4Token = ['eyJ', 'test-header', '.payload', '.sig'].join('');
|
||||||
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
|
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
|
||||||
const client = createTmdbClient({
|
const client = createTmdbClient({
|
||||||
resolveApiKey: async () => 'eyJhbGciOiJIUzI1NiJ9.payload.sig',
|
resolveApiKey: async () => v4Token,
|
||||||
fetch: fetchImpl,
|
fetch: fetchImpl,
|
||||||
});
|
});
|
||||||
await client.search('x');
|
await client.search('x');
|
||||||
assert.equal(calls[0]!.url.searchParams.has('api_key'), false);
|
assert.equal(calls[0]!.url.searchParams.has('api_key'), false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(calls[0]!.init?.headers as Record<string, string>).Authorization,
|
(calls[0]!.init?.headers as Record<string, string>).Authorization,
|
||||||
'Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig',
|
`Bearer ${v4Token}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,3 +204,53 @@ test('getDetails returns null for an unknown id', async () => {
|
|||||||
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
|
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
|
||||||
assert.equal(await client.getDetails('tv', 1), null);
|
assert.equal(await client.getDetails('tv', 1), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('client reuses command output across requests and invalidates it when either setting changes', async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tmdb-command-'));
|
||||||
|
const counter = path.join(dir, 'calls');
|
||||||
|
const command = `printf x >> '${counter}'; printf command-key`;
|
||||||
|
let config: TmdbConfig = { apiKeyCommand: command };
|
||||||
|
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
|
||||||
|
const client = createTmdbClient({
|
||||||
|
resolveApiKey: createTmdbApiKeyResolver(
|
||||||
|
() => config,
|
||||||
|
() => 'bundled',
|
||||||
|
),
|
||||||
|
fetch: fetchImpl,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await Promise.all([client.search('a'), client.search('b')]);
|
||||||
|
await client.getDetails('tv', 1);
|
||||||
|
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
|
||||||
|
assert.ok(calls.every(({ url }) => url.searchParams.get('api_key') === 'command-key'));
|
||||||
|
config = { ...config, apiKey: 'literal' };
|
||||||
|
await client.search('c');
|
||||||
|
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'literal');
|
||||||
|
config = { ...config, apiKey: '' };
|
||||||
|
await client.search('d');
|
||||||
|
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
|
||||||
|
config = { apiKeyCommand: command.replace('command-key', 'new-key') };
|
||||||
|
await client.search('e');
|
||||||
|
assert.equal(fs.readFileSync(counter, 'utf8'), 'xxx');
|
||||||
|
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'new-key');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed commands retry instead of caching the bundled fallback', async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tmdb-retry-'));
|
||||||
|
const marker = path.join(dir, 'ready');
|
||||||
|
const resolve = createTmdbApiKeyResolver(
|
||||||
|
() => ({
|
||||||
|
apiKeyCommand: `if [ -f '${marker}' ]; then printf recovered; else touch '${marker}'; exit 1; fi`,
|
||||||
|
}),
|
||||||
|
() => 'bundled',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
assert.equal(await resolve(), 'bundled');
|
||||||
|
assert.equal(await resolve(), 'recovered');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -82,6 +82,39 @@ export async function resolveTmdbApiKey(
|
|||||||
return bundledKey;
|
return bundledKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cache successful command output until either credential setting changes. */
|
||||||
|
export function createTmdbApiKeyResolver(
|
||||||
|
getConfig: () => TmdbConfig | undefined,
|
||||||
|
getBundledKey: () => string | null = () => null,
|
||||||
|
): () => Promise<string | null> {
|
||||||
|
let state:
|
||||||
|
| {
|
||||||
|
apiKey: string | undefined;
|
||||||
|
apiKeyCommand: string | undefined;
|
||||||
|
pending: Promise<string | null> | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
const config = getConfig();
|
||||||
|
if (
|
||||||
|
!state ||
|
||||||
|
state.apiKey !== config?.apiKey ||
|
||||||
|
state.apiKeyCommand !== config?.apiKeyCommand
|
||||||
|
) {
|
||||||
|
state = { apiKey: config?.apiKey, apiKeyCommand: config?.apiKeyCommand, pending: null };
|
||||||
|
}
|
||||||
|
const current = state;
|
||||||
|
const literal = current.apiKey?.trim();
|
||||||
|
if (literal) return literal;
|
||||||
|
if (!current.apiKeyCommand?.trim()) return getBundledKey();
|
||||||
|
current.pending ??= resolveTmdbApiKey(current);
|
||||||
|
const key = await current.pending;
|
||||||
|
if (!key) current.pending = null;
|
||||||
|
return key ?? getBundledKey();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface RawSearchItem {
|
interface RawSearchItem {
|
||||||
media_type?: string;
|
media_type?: string;
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|||||||
+5
-2
@@ -430,7 +430,7 @@ import {
|
|||||||
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
|
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
|
||||||
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
||||||
import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver';
|
import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver';
|
||||||
import { createTmdbClient, resolveTmdbApiKey } from './core/services/tmdb/tmdb-client';
|
import { createTmdbClient, createTmdbApiKeyResolver } from './core/services/tmdb/tmdb-client';
|
||||||
import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key';
|
import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key';
|
||||||
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
|
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
|
||||||
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
|
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
|
||||||
@@ -1689,7 +1689,10 @@ const statsCoverArtFetcher = createCoverArtFetcher(
|
|||||||
{
|
{
|
||||||
liveAction: createLiveActionMetadataResolver(
|
liveAction: createLiveActionMetadataResolver(
|
||||||
createTmdbClient({
|
createTmdbClient({
|
||||||
resolveApiKey: () => resolveTmdbApiKey(configService.getConfig().tmdb, bundledTmdbApiKey),
|
resolveApiKey: createTmdbApiKeyResolver(
|
||||||
|
() => configService.getConfig().tmdb,
|
||||||
|
() => bundledTmdbApiKey,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
createLogger('main:tmdb'),
|
createLogger('main:tmdb'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
||||||
} from '../../core/services';
|
} from '../../core/services';
|
||||||
import { startStatsServer } from '../../core/services/stats-server';
|
import { startStatsServer } from '../../core/services/stats-server';
|
||||||
import { createTmdbClient, resolveTmdbApiKey } from '../../core/services/tmdb/tmdb-client';
|
import { createTmdbClient, createTmdbApiKeyResolver } from '../../core/services/tmdb/tmdb-client';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
import type { ResolvedConfig } from '../../types/config';
|
import type { ResolvedConfig } from '../../types/config';
|
||||||
import type { AppState } from '../state';
|
import type { AppState } from '../state';
|
||||||
@@ -169,8 +169,10 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
|||||||
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
|
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
|
||||||
anilistRateLimiter: deps.getAnilistRateLimiter(),
|
anilistRateLimiter: deps.getAnilistRateLimiter(),
|
||||||
tmdbClient: createTmdbClient({
|
tmdbClient: createTmdbClient({
|
||||||
resolveApiKey: () =>
|
resolveApiKey: createTmdbApiKeyResolver(
|
||||||
resolveTmdbApiKey(deps.getResolvedConfig().tmdb, deps.getBundledTmdbApiKey?.() ?? null),
|
() => deps.getResolvedConfig().tmdb,
|
||||||
|
() => deps.getBundledTmdbApiKey?.() ?? null,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
||||||
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ImmersionTrackerService } from './core/services/immersion-tracker-servi
|
|||||||
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
|
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
|
||||||
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
||||||
import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver';
|
import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver';
|
||||||
import { createTmdbClient, resolveTmdbApiKey } from './core/services/tmdb/tmdb-client';
|
import { createTmdbClient, createTmdbApiKeyResolver } from './core/services/tmdb/tmdb-client';
|
||||||
import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key';
|
import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key';
|
||||||
import { startStatsServer } from './core/services/stats-server';
|
import { startStatsServer } from './core/services/stats-server';
|
||||||
import {
|
import {
|
||||||
@@ -199,7 +199,10 @@ async function main(): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const tmdbClient = createTmdbClient({
|
const tmdbClient = createTmdbClient({
|
||||||
resolveApiKey: () => resolveTmdbApiKey(configService.reloadConfig().tmdb, bundledTmdbApiKey),
|
resolveApiKey: createTmdbApiKeyResolver(
|
||||||
|
() => configService.reloadConfig().tmdb,
|
||||||
|
() => bundledTmdbApiKey,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
tracker.setCoverArtFetcher(
|
tracker.setCoverArtFetcher(
|
||||||
createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art'), {
|
createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art'), {
|
||||||
|
|||||||
@@ -183,3 +183,42 @@ test('AnimeTab refetches the library after the AniList entry is relinked', async
|
|||||||
uninstallDom();
|
uninstallDom();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnimeTab watch time follows the displayed kind filter', async () => {
|
||||||
|
const uninstallDom = installDom();
|
||||||
|
const original = apiClient.getAnimeLibrary;
|
||||||
|
apiClient.getAnimeLibrary = async () => [
|
||||||
|
{ ...libraryItem(42), totalActiveMs: 3600000 },
|
||||||
|
{
|
||||||
|
...libraryItem(null),
|
||||||
|
animeId: 8,
|
||||||
|
canonicalTitle: 'Drama',
|
||||||
|
mediaKind: 'live_action',
|
||||||
|
tmdbId: 12,
|
||||||
|
tmdbType: 'tv',
|
||||||
|
totalActiveMs: 7200000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<AnimeTab />);
|
||||||
|
});
|
||||||
|
assert.match(container.textContent ?? '', /2 titles · 3h/);
|
||||||
|
const select = container.querySelector('select');
|
||||||
|
assert.ok(select);
|
||||||
|
await act(async () => {
|
||||||
|
select.value = 'live_action';
|
||||||
|
select.dispatchEvent(new window.Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
assert.match(container.textContent ?? '', /1 titles · 2h/);
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
apiClient.getAnimeLibrary = original;
|
||||||
|
uninstallDom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function AnimeTab({
|
|||||||
return sortAnime(base, sortKey);
|
return sortAnime(base, sortKey);
|
||||||
}, [anime, search, sortKey, kindFilter]);
|
}, [anime, search, sortKey, kindFilter]);
|
||||||
|
|
||||||
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
|
const totalMs = filtered.reduce((sum, a) => sum + a.totalActiveMs, 0);
|
||||||
const checkedEntries = checkedAnimeIds
|
const checkedEntries = checkedAnimeIds
|
||||||
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
|
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
|
||||||
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
|
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
|
||||||
|
|||||||
@@ -152,3 +152,122 @@ test('TmdbSelector explains a missing API key instead of showing "No results"',
|
|||||||
uninstallDom();
|
uninstallDom();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const staleFailure of [false, true]) {
|
||||||
|
test(`TmdbSelector ignores superseded ${staleFailure ? 'errors' : 'results'} and loading changes`, async () => {
|
||||||
|
const uninstallDom = installDom();
|
||||||
|
const originalSearch = apiClient.searchTmdb;
|
||||||
|
const requests: Array<{
|
||||||
|
resolve: (results: StatsTmdbSearchResult[]) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}> = [];
|
||||||
|
apiClient.searchTmdb = () =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
requests.push({ resolve, reject });
|
||||||
|
});
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
const render = (initialQuery: string) =>
|
||||||
|
root.render(
|
||||||
|
<TmdbSelector
|
||||||
|
animeId={1}
|
||||||
|
initialQuery={initialQuery}
|
||||||
|
onClose={() => {}}
|
||||||
|
onLinked={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
render('First title');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
render('Second title');
|
||||||
|
});
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
await act(async () => {
|
||||||
|
if (staleFailure) requests[0]!.reject(new Error('stale error'));
|
||||||
|
else requests[0]!.resolve([HANZAWA]);
|
||||||
|
});
|
||||||
|
assert.match(container.textContent ?? '', /Searching/);
|
||||||
|
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|failed/);
|
||||||
|
await act(async () => {
|
||||||
|
requests[1]!.resolve([HANZAWA]);
|
||||||
|
});
|
||||||
|
assert.match(container.textContent ?? '', /Hanzawa/);
|
||||||
|
assert.doesNotMatch(container.textContent ?? '', /Searching/);
|
||||||
|
await act(async () => {
|
||||||
|
render('Third title');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
render('');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
requests[2]!.resolve([HANZAWA]);
|
||||||
|
});
|
||||||
|
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|Searching/);
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
apiClient.searchTmdb = originalSearch;
|
||||||
|
uninstallDom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('TmdbSelector invalidates requests as soon as the user edits or clears the query', async () => {
|
||||||
|
const uninstallDom = installDom();
|
||||||
|
const originalSearch = apiClient.searchTmdb;
|
||||||
|
const requests: Array<(results: StatsTmdbSearchResult[]) => void> = [];
|
||||||
|
apiClient.searchTmdb = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
requests.push(resolve);
|
||||||
|
});
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TmdbSelector animeId={1} initialQuery="First" onClose={() => {}} onLinked={() => {}} />,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const input = container.querySelector('input');
|
||||||
|
assert.ok(input);
|
||||||
|
const setValue = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
'value',
|
||||||
|
)?.set;
|
||||||
|
assert.ok(setValue);
|
||||||
|
const edit = async (value: string) => {
|
||||||
|
await act(async () => {
|
||||||
|
setValue.call(input, value);
|
||||||
|
input.dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new window.KeyboardEvent('keyup', { bubbles: true }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await edit('Second');
|
||||||
|
await act(async () => {
|
||||||
|
requests[0]!([HANZAWA]);
|
||||||
|
});
|
||||||
|
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|No results/);
|
||||||
|
assert.match(container.textContent ?? '', /Searching/);
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||||
|
});
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
await edit('');
|
||||||
|
assert.doesNotMatch(container.textContent ?? '', /Searching/);
|
||||||
|
await act(async () => {
|
||||||
|
requests[1]!([HANZAWA]);
|
||||||
|
});
|
||||||
|
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|Searching/);
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
apiClient.searchTmdb = originalSearch;
|
||||||
|
uninstallDom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function TmdbSelector({ animeId, initialQuery, onClose, onLinked }: TmdbS
|
|||||||
const [linking, setLinking] = useState<number | null>(null);
|
const [linking, setLinking] = useState<number | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const searchSequenceRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -38,28 +39,44 @@ export function TmdbSelector({ animeId, initialQuery, onClose, onLinked }: TmdbS
|
|||||||
setLinking(null);
|
setLinking(null);
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
if (normalizedInitialQuery) void doSearch(normalizedInitialQuery);
|
if (normalizedInitialQuery) void doSearch(normalizedInitialQuery);
|
||||||
|
return () => {
|
||||||
|
searchSequenceRef.current += 1;
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
}, [initialQuery, animeId]);
|
}, [initialQuery, animeId]);
|
||||||
|
|
||||||
const doSearch = async (q: string) => {
|
const doSearch = async (q: string) => {
|
||||||
|
const sequence = ++searchSequenceRef.current;
|
||||||
const searchQuery = normalizeAnilistSearchQuery(q);
|
const searchQuery = normalizeAnilistSearchQuery(q);
|
||||||
if (!searchQuery) {
|
if (!searchQuery) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setResults(await apiClient.searchTmdb(searchQuery));
|
const nextResults = await apiClient.searchTmdb(searchQuery);
|
||||||
|
if (sequence === searchSequenceRef.current) setResults(nextResults);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (sequence !== searchSequenceRef.current) return;
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setError(describeSearchError(err));
|
setError(describeSearchError(err));
|
||||||
|
} finally {
|
||||||
|
if (sequence === searchSequenceRef.current) setLoading(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInput = (value: string) => {
|
const handleInput = (value: string) => {
|
||||||
|
searchSequenceRef.current += 1;
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
|
setResults([]);
|
||||||
|
setError(null);
|
||||||
|
const hasQuery = Boolean(normalizeAnilistSearchQuery(value));
|
||||||
|
setLoading(hasQuery);
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
if (!hasQuery) return;
|
||||||
debounceRef.current = setTimeout(() => void doSearch(value), 400);
|
debounceRef.current = setTimeout(() => void doSearch(value), 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user