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

This commit is contained in:
2026-08-14 21:54:17 -07:00
committed by GitHub
parent c5a77ac067
commit 6fb3d2eb13
42 changed files with 4850 additions and 89 deletions
+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 = '';
+30
View File
@@ -11,6 +11,10 @@ import type {
StatsExcludedWordsRequest,
StatsHttpClient,
StatsJsonResponseMap,
StatsMergeAnimeRequest,
StatsMergeAnimeResponse,
StatsMoveVideoRequest,
StatsMoveVideoResponse,
StatsTrendGroupBy,
StatsTrendRange,
StatsVideoWatchedRequest,
@@ -140,6 +144,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}`),
@@ -209,6 +215,30 @@ export const apiClient = {
fetchResponse(`/api/stats/anime/${animeId}`, { method: 'DELETE' }),
);
},
mergeAnime: async (
targetAnimeId: number,
sourceAnimeIds: number[],
): Promise<StatsMergeAnimeResponse> => {
const res = await fetchResponse(`/api/stats/anime/${targetAnimeId}/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceAnimeIds } satisfies StatsMergeAnimeRequest),
});
return res.json() as Promise<StatsMergeAnimeResponse>;
},
moveVideoToAnime: async (videoId: number, animeId: number): Promise<StatsMoveVideoResponse> => {
const res = await fetchResponse(`/api/stats/media/${videoId}/anime`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ animeId } satisfies StatsMoveVideoRequest),
});
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) =>