mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
fix(stats): persist filter prefs, fix cover MIME types, and dedup alass
- Remember Hide Known/Hide Kana filter state in localStorage across sessions - Detect PNG and WebP cover art MIME types instead of hardcoding image/jpeg - Use configured AnkiConnect URL for the browse action - Deduplicate concurrent in-flight alass retime calls via promise caching - Prefer request-provided secondary subtitle text over retimed sidecar fallback - Fix cover image record key types from string to number
This commit is contained in:
@@ -966,7 +966,7 @@ describe('stats server API routes', () => {
|
||||
videoId,
|
||||
anilistId: null,
|
||||
coverUrl: null,
|
||||
coverBlob: Buffer.from([0x89, 0x50]),
|
||||
coverBlob: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
episodesTotal: null,
|
||||
@@ -997,8 +997,8 @@ describe('stats server API routes', () => {
|
||||
},
|
||||
media: {
|
||||
7: {
|
||||
contentType: 'image/jpeg',
|
||||
dataUrl: 'data:image/jpeg;base64,iVA=',
|
||||
contentType: 'image/png',
|
||||
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
|
||||
},
|
||||
99999: null,
|
||||
},
|
||||
@@ -1365,7 +1365,7 @@ describe('stats server API routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card prefers retimed sidecar secondary text for sentence cards', async () => {
|
||||
it('POST /api/stats/mine-card prefers request secondary text over retimed fallback', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
@@ -1414,7 +1414,7 @@ describe('stats server API routes', () => {
|
||||
const addNoteRequest = requests.find((request) => request.action === 'addNote');
|
||||
assert.equal(
|
||||
addNoteRequest?.params?.note?.fields?.SelectionText,
|
||||
'Aligned English subtitle',
|
||||
'Stale stored English subtitle',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1484,6 +1484,74 @@ Aligned English subtitle
|
||||
});
|
||||
});
|
||||
|
||||
it('shares in-flight retimed secondary subtitle work for concurrent requests', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
const japanesePath = path.join(dir, 'episode.ja.srt');
|
||||
const englishPath = path.join(dir, 'episode.en.srt');
|
||||
const alassPath = path.join(dir, 'alass-cli');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
fs.writeFileSync(alassPath, 'fake alass');
|
||||
fs.writeFileSync(
|
||||
japanesePath,
|
||||
`1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
猫を見た
|
||||
`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
englishPath,
|
||||
`1
|
||||
00:00:09,000 --> 00:00:10,000
|
||||
Stale English subtitle
|
||||
`,
|
||||
);
|
||||
|
||||
let alassRuns = 0;
|
||||
let releaseAlass!: () => void;
|
||||
const alassGate = new Promise<void>((resolve) => {
|
||||
releaseAlass = resolve;
|
||||
});
|
||||
const input = {
|
||||
sourcePath,
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
alassPath,
|
||||
runAlass: async (
|
||||
_alassPath: string,
|
||||
_referencePath: string,
|
||||
_inputPath: string,
|
||||
outputPath: string,
|
||||
) => {
|
||||
alassRuns += 1;
|
||||
await alassGate;
|
||||
fs.writeFileSync(
|
||||
outputPath,
|
||||
`1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
Aligned English subtitle
|
||||
`,
|
||||
);
|
||||
return { ok: true, code: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const first = resolveRetimedSecondarySubtitleTextFromSidecar(input);
|
||||
const second = resolveRetimedSecondarySubtitleTextFromSidecar(input);
|
||||
releaseAlass();
|
||||
|
||||
assert.deepEqual(await Promise.all([first, second]), [
|
||||
'Aligned English subtitle',
|
||||
'Aligned English subtitle',
|
||||
]);
|
||||
assert.equal(alassRuns, 1);
|
||||
} finally {
|
||||
clearRetimedSecondarySubtitleCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card adds direct sentence cards before slow media finishes', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
@@ -2387,6 +2455,20 @@ Aligned English subtitle
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('POST /api/stats/anki/browse uses configured AnkiConnect URL', async () => {
|
||||
await withFakeAnkiConnect(async (requests, url) => {
|
||||
const app = createStatsApp(createMockTracker(), {
|
||||
ankiConnectConfig: { url },
|
||||
});
|
||||
|
||||
const res = await app.request('/api/stats/anki/browse?noteId=12345', { method: 'POST' });
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(requests[0]?.action, 'guiBrowse');
|
||||
assert.deepEqual(requests[0]?.params, { query: 'nid:12345' });
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /api/stats/anilist/search uses the configured AniList rate limiter', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let acquireCalls = 0;
|
||||
|
||||
@@ -29,6 +29,7 @@ type SidecarCandidate = {
|
||||
type RetimedSubtitleCacheEntry = {
|
||||
path: string;
|
||||
cleanupDir: string;
|
||||
promise?: Promise<string>;
|
||||
};
|
||||
|
||||
export type RetimedSubtitleCommandRunner = (
|
||||
@@ -353,6 +354,9 @@ async function retimeSecondarySubtitle(input: {
|
||||
if (!key) return '';
|
||||
|
||||
const cached = retimedSubtitleCache.get(key);
|
||||
if (cached?.promise) {
|
||||
return cached.promise;
|
||||
}
|
||||
if (cached && existsSync(cached.path)) {
|
||||
return cached.path;
|
||||
}
|
||||
@@ -371,19 +375,25 @@ async function retimeSecondarySubtitle(input: {
|
||||
`${parsedSecondary.name}.retimed${parsedSecondary.ext || '.srt'}`,
|
||||
);
|
||||
|
||||
const result = await input.runAlass(
|
||||
input.alassPath,
|
||||
input.primaryPath,
|
||||
input.secondaryPath,
|
||||
outputPath,
|
||||
);
|
||||
if (!result.ok || !existsSync(outputPath)) {
|
||||
rmSync(cleanupDir, { recursive: true, force: true });
|
||||
return '';
|
||||
}
|
||||
|
||||
retimedSubtitleCache.set(key, { path: outputPath, cleanupDir });
|
||||
return outputPath;
|
||||
const entry: RetimedSubtitleCacheEntry = { path: outputPath, cleanupDir };
|
||||
entry.promise = input
|
||||
.runAlass(input.alassPath, input.primaryPath, input.secondaryPath, outputPath)
|
||||
.then((result) => {
|
||||
if (!result.ok || !existsSync(outputPath)) {
|
||||
rmSync(cleanupDir, { recursive: true, force: true });
|
||||
retimedSubtitleCache.delete(key);
|
||||
return '';
|
||||
}
|
||||
entry.promise = undefined;
|
||||
return outputPath;
|
||||
})
|
||||
.catch(() => {
|
||||
rmSync(cleanupDir, { recursive: true, force: true });
|
||||
retimedSubtitleCache.delete(key);
|
||||
return '';
|
||||
});
|
||||
retimedSubtitleCache.set(key, entry);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
export function resolveSecondarySubtitleTextFromSidecar(input: {
|
||||
|
||||
@@ -52,7 +52,7 @@ type StatsExcludedWordPayload = {
|
||||
};
|
||||
|
||||
type StatsCoverImagePayload = {
|
||||
contentType: 'image/jpeg';
|
||||
contentType: string;
|
||||
dataUrl: string;
|
||||
} | null;
|
||||
|
||||
@@ -126,12 +126,43 @@ function coverImagePayload(
|
||||
art: { coverBlob?: Uint8Array | null } | null | undefined,
|
||||
): StatsCoverImagePayload {
|
||||
if (!art?.coverBlob) return null;
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
const contentType = detectImageContentType(bytes);
|
||||
return {
|
||||
contentType: 'image/jpeg',
|
||||
dataUrl: `data:image/jpeg;base64,${Buffer.from(art.coverBlob).toString('base64')}`,
|
||||
contentType,
|
||||
dataUrl: `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`,
|
||||
};
|
||||
}
|
||||
|
||||
function detectImageContentType(bytes: Uint8Array): string {
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47
|
||||
) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function resolveStatsNoteFieldName(
|
||||
noteInfo: StatsServerNoteInfo,
|
||||
...preferredNames: (string | undefined)[]
|
||||
@@ -960,17 +991,17 @@ export function createStatsApp(
|
||||
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
|
||||
const animeIds = parsePositiveIdList(body?.animeIds);
|
||||
const videoIds = parsePositiveIdList(body?.videoIds);
|
||||
const anime: Record<string, StatsCoverImagePayload> = {};
|
||||
const media: Record<string, StatsCoverImagePayload> = {};
|
||||
const anime: Record<number, StatsCoverImagePayload> = {};
|
||||
const media: Record<number, StatsCoverImagePayload> = {};
|
||||
|
||||
await Promise.all(
|
||||
animeIds.map(async (animeId) => {
|
||||
anime[String(animeId)] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
|
||||
anime[animeId] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
|
||||
}),
|
||||
);
|
||||
await Promise.all(
|
||||
videoIds.map(async (videoId) => {
|
||||
media[String(videoId)] = coverImagePayload(await tracker.getCoverArt(videoId));
|
||||
media[videoId] = coverImagePayload(await tracker.getCoverArt(videoId));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1024,8 +1055,9 @@ export function createStatsApp(
|
||||
app.post('/api/stats/anki/browse', async (c) => {
|
||||
const noteId = parseIntQuery(c.req.query('noteId'), 0);
|
||||
if (noteId <= 0) return c.body(null, 400);
|
||||
const ankiConfig = getAnkiConnectConfig();
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:8765', {
|
||||
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||
@@ -1136,8 +1168,8 @@ export function createStatsApp(
|
||||
}
|
||||
}
|
||||
const secondaryText =
|
||||
retimedSecondaryText ||
|
||||
bodySecondaryText ||
|
||||
retimedSecondaryText ||
|
||||
resolveSecondarySubtitleTextFromSidecar({
|
||||
sourcePath,
|
||||
startMs,
|
||||
|
||||
Reference in New Issue
Block a user