mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
feat(stats): add sentence search tab and fix mining card behavior
- Add Search tab with realtime subtitle sentence search, media context, and mining actions - Persist library card size selection across reloads - Fix selection text written only for sentence cards, not word/audio cards - Fix word mining audio routed to SentenceAudio field when present - Fix sentence card created immediately before slow media generation completes - Fix Anki config resolved at request time for sentence mining - Prefer non-Signs/Songs tracks when auto-selecting secondary subtitle language
This commit is contained in:
@@ -338,7 +338,9 @@ function withTempDir<T>(fn: (dir: string) => Promise<T> | T): Promise<T> | T {
|
||||
type CapturedAnkiRequest = {
|
||||
action?: string;
|
||||
params?: {
|
||||
notes?: number[];
|
||||
note?: {
|
||||
id?: number;
|
||||
deckName?: string;
|
||||
modelName?: string;
|
||||
fields?: Record<string, string>;
|
||||
@@ -365,6 +367,23 @@ async function withFakeAnkiConnect<T>(
|
||||
} else {
|
||||
body = { result: 12345, error: null };
|
||||
}
|
||||
} else if (payload.action === 'notesInfo') {
|
||||
const noteIds = payload.params?.notes ?? [];
|
||||
body = {
|
||||
result: noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
Expression: { value: '猫' },
|
||||
ExpressionAudio: { value: '[sound:word.mp3]' },
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
MiscInfo: { value: '' },
|
||||
SelectionText: { value: '' },
|
||||
},
|
||||
})),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -1149,6 +1168,370 @@ describe('stats server API routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card resolves Anki config at request time', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
|
||||
await withFakeAnkiConnect(async (requests, url) => {
|
||||
const app = createStatsApp(createMockTracker(), {
|
||||
getAnkiConnectConfig: () => ({
|
||||
url,
|
||||
deck: 'Mining',
|
||||
tags: ['SubMiner'],
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
translation: 'SelectionText',
|
||||
},
|
||||
media: {
|
||||
generateAudio: false,
|
||||
generateImage: false,
|
||||
},
|
||||
isLapis: {
|
||||
enabled: true,
|
||||
sentenceCardModel: 'Lapis Morph',
|
||||
},
|
||||
}),
|
||||
} as Parameters<typeof createStatsApp>[1]);
|
||||
|
||||
const res = await app.request('/api/stats/mine-card?mode=sentence', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourcePath,
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
sentence: '猫を見た',
|
||||
word: '猫',
|
||||
secondaryText: 'I saw a cat',
|
||||
videoTitle: 'Episode 1',
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200, JSON.stringify(body));
|
||||
const addNoteRequest = requests.find((request) => request.action === 'addNote');
|
||||
assert.equal(addNoteRequest?.params?.note?.deckName, 'Mining');
|
||||
assert.equal(addNoteRequest?.params?.note?.modelName, 'Lapis Morph');
|
||||
assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
|
||||
await withFakeAnkiConnect(async (requests, url) => {
|
||||
const mediaRelease: {
|
||||
audio?: () => void;
|
||||
image?: () => void;
|
||||
} = {};
|
||||
const app = createStatsApp(createMockTracker(), {
|
||||
createMediaGenerator: () => ({
|
||||
generateAudio: async () =>
|
||||
await new Promise<Buffer>((resolve) => {
|
||||
mediaRelease.audio = () => resolve(Buffer.from('audio'));
|
||||
}),
|
||||
generateScreenshot: async () =>
|
||||
await new Promise<Buffer>((resolve) => {
|
||||
mediaRelease.image = () => resolve(Buffer.from('image'));
|
||||
}),
|
||||
generateAnimatedImage: async () => null,
|
||||
}),
|
||||
ankiConnectConfig: {
|
||||
url,
|
||||
deck: 'Mining',
|
||||
tags: ['SubMiner'],
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
sentence: 'Sentence',
|
||||
translation: 'SelectionText',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageType: 'static',
|
||||
},
|
||||
isLapis: {
|
||||
enabled: true,
|
||||
sentenceCardModel: 'Lapis Morph',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pendingResponse = app.request('/api/stats/mine-card?mode=sentence', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourcePath,
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
sentence: '猫を見た',
|
||||
word: '猫',
|
||||
secondaryText: 'I saw a cat',
|
||||
videoTitle: 'Episode 1',
|
||||
}),
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (requests.some((request) => request.action === 'addNote')) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
const addedBeforeMediaFinished = requests.some((request) => request.action === 'addNote');
|
||||
mediaRelease.audio?.();
|
||||
mediaRelease.image?.();
|
||||
|
||||
const res = await pendingResponse;
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200, JSON.stringify(body));
|
||||
assert.equal(addedBeforeMediaFinished, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary fields', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
|
||||
await withFakeAnkiConnect(async (requests, url) => {
|
||||
const app = createStatsApp(createMockTracker(), {
|
||||
addYomitanNote: async () => 777,
|
||||
ankiConnectConfig: {
|
||||
url,
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
sentence: 'Sentence',
|
||||
miscInfo: 'MiscInfo',
|
||||
translation: 'SelectionText',
|
||||
},
|
||||
media: {
|
||||
generateAudio: false,
|
||||
generateImage: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourcePath,
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
sentence: '猫を見た',
|
||||
word: '猫',
|
||||
secondaryText: 'I saw a cat',
|
||||
videoTitle: 'Episode 1',
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200, JSON.stringify(body));
|
||||
assert.equal(body.noteId, 777);
|
||||
|
||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||
assert.equal(updateRequest?.params?.note?.id, 777);
|
||||
assert.equal(updateRequest?.params?.note?.fields?.Sentence, '<b>猫</b>を見た');
|
||||
assert.equal(updateRequest?.params?.note?.fields?.SelectionText, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card writes word mining audio to SentenceAudio when present', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
|
||||
await withFakeAnkiConnect(async (requests, url) => {
|
||||
const app = createStatsApp(createMockTracker(), {
|
||||
addYomitanNote: async () => 777,
|
||||
createMediaGenerator: () => ({
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
}),
|
||||
ankiConnectConfig: {
|
||||
url,
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
sentence: 'Sentence',
|
||||
miscInfo: 'MiscInfo',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourcePath,
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
sentence: '猫を見た',
|
||||
word: '猫',
|
||||
videoTitle: 'Episode 1',
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200, JSON.stringify(body));
|
||||
|
||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
||||
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
||||
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card records timing for slow sentence mining phases', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
|
||||
await withFakeAnkiConnect(async (requests, url) => {
|
||||
let now = 0;
|
||||
const timings: Array<{ mode: string; phase: string; elapsedMs: number; noteId?: number }> =
|
||||
[];
|
||||
const app = createStatsApp(createMockTracker(), {
|
||||
nowMs: () => {
|
||||
now += 10;
|
||||
return now;
|
||||
},
|
||||
onMiningTiming: (event) => {
|
||||
timings.push(event);
|
||||
},
|
||||
createMediaGenerator: () => ({
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateScreenshot: async () => Buffer.from('image'),
|
||||
generateAnimatedImage: async () => Buffer.from('animated'),
|
||||
}),
|
||||
ankiConnectConfig: {
|
||||
url,
|
||||
deck: 'Mining',
|
||||
tags: ['SubMiner'],
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
sentence: 'Sentence',
|
||||
miscInfo: 'MiscInfo',
|
||||
translation: 'SelectionText',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageType: 'static',
|
||||
},
|
||||
isLapis: {
|
||||
enabled: true,
|
||||
sentenceCardModel: 'Lapis Morph',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.request('/api/stats/mine-card?mode=sentence', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourcePath,
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
sentence: '猫を見た',
|
||||
word: '猫',
|
||||
secondaryText: 'I saw a cat',
|
||||
videoTitle: 'Episode 1',
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200, JSON.stringify(body));
|
||||
assert.deepEqual(
|
||||
timings.map((entry) => entry.phase),
|
||||
[
|
||||
'generateAudio',
|
||||
'generateScreenshot',
|
||||
'addNote',
|
||||
'uploadAudio',
|
||||
'uploadImage',
|
||||
'updateNoteFields',
|
||||
],
|
||||
);
|
||||
assert.ok(timings.every((entry) => entry.mode === 'sentence' && entry.elapsedMs >= 0));
|
||||
assert.equal(timings.find((entry) => entry.phase === 'addNote')?.noteId, 12345);
|
||||
|
||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
||||
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
||||
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, audioValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card only writes selection text for sentence cards', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
|
||||
await withFakeAnkiConnect(async (requests, url) => {
|
||||
const app = createStatsApp(createMockTracker(), {
|
||||
ankiConnectConfig: {
|
||||
url,
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
translation: 'SelectionText',
|
||||
},
|
||||
media: {
|
||||
generateAudio: false,
|
||||
generateImage: false,
|
||||
},
|
||||
isLapis: {
|
||||
enabled: true,
|
||||
sentenceCardModel: 'Lapis Morph',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.request('/api/stats/mine-card?mode=audio', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourcePath,
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
sentence: '猫を見た',
|
||||
word: '猫',
|
||||
secondaryText: 'I saw a cat',
|
||||
videoTitle: 'Episode 1',
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200, JSON.stringify(body));
|
||||
|
||||
const addNoteRequest = requests.find((request) => request.action === 'addNote');
|
||||
assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, undefined);
|
||||
assert.equal(addNoteRequest?.params?.note?.fields?.IsAudioCard, 'x');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /api/stats/episode/:videoId/detail returns episode detail', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const res = await app.request('/api/stats/episode/1/detail');
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
getStatsExcludedWords,
|
||||
getVocabularyStats,
|
||||
replaceStatsExcludedWords,
|
||||
searchSubtitleSentences,
|
||||
getWordAnimeAppearances,
|
||||
getWordDetail,
|
||||
getWordOccurrences,
|
||||
@@ -148,6 +149,7 @@ import {
|
||||
type MediaLibraryRow,
|
||||
type NewAnimePerDayRow,
|
||||
type QueuedWrite,
|
||||
type SentenceSearchResultRow,
|
||||
type SessionEventRow,
|
||||
type SessionState,
|
||||
type SessionSummaryQueryRow,
|
||||
@@ -568,6 +570,10 @@ export class ImmersionTrackerService {
|
||||
return getKanjiOccurrences(this.db, kanji, limit, offset);
|
||||
}
|
||||
|
||||
async searchSubtitleSentences(query: string, limit = 50): Promise<SentenceSearchResultRow[]> {
|
||||
return searchSubtitleSentences(this.db, query, limit);
|
||||
}
|
||||
|
||||
async getSessionEvents(
|
||||
sessionId: number,
|
||||
limit = 500,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
getSessionTimeline,
|
||||
getSessionWordsByLine,
|
||||
getWordOccurrences,
|
||||
searchSubtitleSentences,
|
||||
upsertCoverArt,
|
||||
} from '../query.js';
|
||||
import {
|
||||
@@ -3686,6 +3687,101 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
|
||||
}
|
||||
});
|
||||
|
||||
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Dungeon Meshi',
|
||||
canonicalTitle: 'Dungeon Meshi',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: '{"source":"test"}',
|
||||
});
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/dungeon-meshi-01.mkv', {
|
||||
canonicalTitle: 'Episode 1',
|
||||
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: 'Dungeon Meshi 01.mkv',
|
||||
parsedTitle: 'Dungeon Meshi',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: '{"episode":1}',
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, 3_000_000);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO imm_subtitle_lines (
|
||||
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
|
||||
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
sessionId,
|
||||
null,
|
||||
videoId,
|
||||
animeId,
|
||||
7,
|
||||
4_000,
|
||||
5_500,
|
||||
'魔物を食べるなんて信じられない',
|
||||
'I cannot believe we are eating monsters',
|
||||
3_000,
|
||||
3_000,
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_subtitle_lines (
|
||||
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
|
||||
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
sessionId,
|
||||
null,
|
||||
videoId,
|
||||
animeId,
|
||||
8,
|
||||
6_000,
|
||||
7_000,
|
||||
'これは別の行です',
|
||||
'Another line',
|
||||
2_000,
|
||||
2_000,
|
||||
);
|
||||
|
||||
const rows = searchSubtitleSentences(db, '魔物 食べる', 10);
|
||||
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
animeId,
|
||||
animeTitle: 'Dungeon Meshi',
|
||||
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
|
||||
secondaryText: 'I cannot believe we are eating monsters',
|
||||
videoId,
|
||||
videoTitle: 'Episode 1',
|
||||
sessionId,
|
||||
lineIndex: 7,
|
||||
segmentStartMs: 4_000,
|
||||
segmentEndMs: 5_500,
|
||||
text: '魔物を食べるなんて信じられない',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(searchSubtitleSentences(db, 'monsters', 10), []);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
KanjiOccurrenceRow,
|
||||
KanjiStatsRow,
|
||||
KanjiWordRow,
|
||||
SentenceSearchResultRow,
|
||||
SessionEventRow,
|
||||
SimilarWordRow,
|
||||
StatsExcludedWordRow,
|
||||
@@ -21,6 +22,19 @@ import { nowMs } from './time';
|
||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
|
||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
|
||||
|
||||
function splitSearchTerms(query: string): string[] {
|
||||
return query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((term) => term.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function escapeLikeTerm(term: string): string {
|
||||
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
|
||||
}
|
||||
|
||||
function toVocabularyToken(row: VocabularyStatsRow): MergedToken {
|
||||
const partOfSpeech =
|
||||
row.partOfSpeech && Object.values(PartOfSpeech).includes(row.partOfSpeech as PartOfSpeech)
|
||||
@@ -211,6 +225,54 @@ export function getKanjiOccurrences(
|
||||
.all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[];
|
||||
}
|
||||
|
||||
export function searchSubtitleSentences(
|
||||
db: DatabaseSync,
|
||||
query: string,
|
||||
limit = 50,
|
||||
): SentenceSearchResultRow[] {
|
||||
const terms = splitSearchTerms(query);
|
||||
if (terms.length === 0) return [];
|
||||
|
||||
const clauses: string[] = [];
|
||||
const params: string[] = [];
|
||||
for (const term of terms) {
|
||||
const likeTerm = `%${escapeLikeTerm(term)}%`;
|
||||
clauses.push(`
|
||||
(
|
||||
l.text LIKE ? ESCAPE '\\'
|
||||
OR v.canonical_title LIKE ? ESCAPE '\\'
|
||||
OR COALESCE(a.canonical_title, '') LIKE ? ESCAPE '\\'
|
||||
)
|
||||
`);
|
||||
params.push(likeTerm, likeTerm, likeTerm);
|
||||
}
|
||||
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
l.anime_id AS animeId,
|
||||
a.canonical_title AS animeTitle,
|
||||
l.video_id AS videoId,
|
||||
v.canonical_title AS videoTitle,
|
||||
v.source_path AS sourcePath,
|
||||
l.secondary_text AS secondaryText,
|
||||
l.session_id AS sessionId,
|
||||
l.line_index AS lineIndex,
|
||||
l.segment_start_ms AS segmentStartMs,
|
||||
l.segment_end_ms AS segmentEndMs,
|
||||
l.text AS text
|
||||
FROM imm_subtitle_lines l
|
||||
JOIN imm_videos v ON v.video_id = l.video_id
|
||||
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY l.CREATED_DATE DESC, l.line_id DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(...params, limit) as unknown as SentenceSearchResultRow[];
|
||||
}
|
||||
|
||||
export function getSessionEvents(
|
||||
db: DatabaseSync,
|
||||
sessionId: number,
|
||||
|
||||
@@ -367,6 +367,20 @@ export interface KanjiOccurrenceRow {
|
||||
occurrenceCount: number;
|
||||
}
|
||||
|
||||
export interface SentenceSearchResultRow {
|
||||
animeId: number | null;
|
||||
animeTitle: string | null;
|
||||
videoId: number;
|
||||
videoTitle: string;
|
||||
sourcePath: string | null;
|
||||
secondaryText: string | null;
|
||||
sessionId: number;
|
||||
lineIndex: number;
|
||||
segmentStartMs: number | null;
|
||||
segmentEndMs: number | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SessionEventRow {
|
||||
eventType: number;
|
||||
tsMs: number;
|
||||
|
||||
@@ -235,6 +235,27 @@ test('dispatchMpvProtocolMessage prefers the already selected matching secondary
|
||||
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage skips signs and songs when choosing secondary subtitles', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
getResolvedConfig: () => ({
|
||||
secondarySub: { secondarySubLanguages: ['eng', 'en'] },
|
||||
}),
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{
|
||||
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
data: [
|
||||
{ type: 'sub', id: 2, lang: 'eng', title: 'English Signs & Songs' },
|
||||
{ type: 'sub', id: 3, lang: 'eng', title: 'English Dialogue' },
|
||||
],
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
|
||||
@@ -149,6 +149,11 @@ function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string {
|
||||
return `id:${track.id}`;
|
||||
}
|
||||
|
||||
function isSignsOrSongsSubtitleTrack(track: SubtitleTrackCandidate): boolean {
|
||||
const label = `${track.title} ${track.externalFilename ?? ''}`.toLowerCase();
|
||||
return /\b(signs?|songs?)\b/.test(label);
|
||||
}
|
||||
|
||||
function pickSecondarySubtitleTrackId(
|
||||
tracks: Array<Record<string, unknown>>,
|
||||
preferredLanguages: string[],
|
||||
@@ -177,12 +182,19 @@ function pickSecondarySubtitleTrackId(
|
||||
const uniqueTracks = [...dedupedTracks.values()];
|
||||
|
||||
for (const language of normalizedLanguages) {
|
||||
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
|
||||
const languageTracks = uniqueTracks.filter((track) => track.lang === language);
|
||||
if (languageTracks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const cleanTracks = languageTracks.filter((track) => !isSignsOrSongsSubtitleTrack(track));
|
||||
const candidateTracks = cleanTracks.length > 0 ? cleanTracks : languageTracks;
|
||||
|
||||
const selectedMatch = candidateTracks.find((track) => track.selected);
|
||||
if (selectedMatch) {
|
||||
return selectedMatch.id;
|
||||
}
|
||||
|
||||
const match = uniqueTracks.find((track) => track.lang === language);
|
||||
const match = candidateTracks[0];
|
||||
if (match) {
|
||||
return match.id;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Readable } from 'node:stream';
|
||||
import { MediaGenerator } from '../../media-generator.js';
|
||||
import { AnkiConnectClient } from '../../anki-connect.js';
|
||||
import type { AnkiConnectConfig } from '../../types.js';
|
||||
import { createLogger } from '../../logger.js';
|
||||
import {
|
||||
getConfiguredSentenceFieldName,
|
||||
getConfiguredTranslationFieldName,
|
||||
@@ -21,6 +22,23 @@ type StatsServerNoteInfo = {
|
||||
fields: Record<string, { value: string }>;
|
||||
};
|
||||
|
||||
type StatsServerMediaGenerator = {
|
||||
generateAudio: (...args: Parameters<MediaGenerator['generateAudio']>) => Promise<Buffer | null>;
|
||||
generateScreenshot: (
|
||||
...args: Parameters<MediaGenerator['generateScreenshot']>
|
||||
) => Promise<Buffer | null>;
|
||||
generateAnimatedImage: (
|
||||
...args: Parameters<MediaGenerator['generateAnimatedImage']>
|
||||
) => Promise<Buffer | null>;
|
||||
};
|
||||
|
||||
export type StatsMiningTimingEvent = {
|
||||
mode: 'word' | 'sentence' | 'audio';
|
||||
phase: string;
|
||||
elapsedMs: number;
|
||||
noteId?: number;
|
||||
};
|
||||
|
||||
type StatsExcludedWordPayload = {
|
||||
headword: string;
|
||||
word: string;
|
||||
@@ -122,6 +140,52 @@ function resolveStatsNoteFieldName(
|
||||
return null;
|
||||
}
|
||||
|
||||
function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const fieldName of fieldNames) {
|
||||
const normalized = fieldName?.trim();
|
||||
if (!normalized) continue;
|
||||
const key = normalized.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getStatsWordMiningAudioFieldName(
|
||||
ankiConfig: AnkiConnectConfig,
|
||||
noteInfo: StatsServerNoteInfo | null,
|
||||
): string {
|
||||
return (
|
||||
(noteInfo
|
||||
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', ankiConfig.fields?.audio)
|
||||
: null) ??
|
||||
ankiConfig.fields?.audio ??
|
||||
'ExpressionAudio'
|
||||
);
|
||||
}
|
||||
|
||||
function getStatsDirectMiningAudioFieldNames(
|
||||
ankiConfig: AnkiConnectConfig,
|
||||
noteInfo: StatsServerNoteInfo | null,
|
||||
): string[] {
|
||||
const configuredAudioField = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
||||
if (!ankiConfig.isLapis?.enabled && !ankiConfig.isKiku?.enabled) {
|
||||
return [configuredAudioField];
|
||||
}
|
||||
|
||||
const sentenceAudioField = noteInfo
|
||||
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', configuredAudioField)
|
||||
: 'SentenceAudio';
|
||||
const expressionAudioField = noteInfo
|
||||
? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
|
||||
: null;
|
||||
|
||||
return uniqueFieldNames(sentenceAudioField, expressionAudioField);
|
||||
}
|
||||
|
||||
function toFetchHeaders(headers: IncomingMessage['headers']): Headers {
|
||||
const fetchHeaders = new Headers();
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
@@ -291,6 +355,7 @@ export interface StatsServerConfig {
|
||||
knownWordCachePath?: string;
|
||||
mpvSocketPath?: string;
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
@@ -314,6 +379,11 @@ const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
|
||||
const statsMiningLogger = createLogger('stats:mining');
|
||||
|
||||
function defaultNowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function buildAnkiNotePreview(
|
||||
fields: Record<string, { value: string }>,
|
||||
@@ -375,12 +445,53 @@ export function createStatsApp(
|
||||
knownWordCachePath?: string;
|
||||
mpvSocketPath?: string;
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
createMediaGenerator?: () => StatsServerMediaGenerator;
|
||||
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
|
||||
nowMs?: () => number;
|
||||
},
|
||||
) {
|
||||
const app = new Hono();
|
||||
const nowMs = options?.nowMs ?? defaultNowMs;
|
||||
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
|
||||
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
|
||||
|
||||
const recordMiningTiming = (event: StatsMiningTimingEvent): void => {
|
||||
options?.onMiningTiming?.(event);
|
||||
statsMiningLogger.debug(
|
||||
`[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`,
|
||||
event,
|
||||
);
|
||||
};
|
||||
|
||||
const timeMiningPhase = async <T>(
|
||||
mode: StatsMiningTimingEvent['mode'],
|
||||
phase: string,
|
||||
fn: () => Promise<T>,
|
||||
details?: (value: T) => Partial<StatsMiningTimingEvent>,
|
||||
): Promise<T> => {
|
||||
const startedAtMs = nowMs();
|
||||
try {
|
||||
const value = await fn();
|
||||
recordMiningTiming({
|
||||
mode,
|
||||
phase,
|
||||
elapsedMs: nowMs() - startedAtMs,
|
||||
...details?.(value),
|
||||
});
|
||||
return value;
|
||||
} catch (err) {
|
||||
recordMiningTiming({
|
||||
mode,
|
||||
phase,
|
||||
elapsedMs: nowMs() - startedAtMs,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/stats/overview', async (c) => {
|
||||
const [rawSessions, rollups, hints] = await Promise.all([
|
||||
@@ -544,6 +655,14 @@ export function createStatsApp(
|
||||
return c.json(occurrences);
|
||||
});
|
||||
|
||||
app.get('/api/stats/sentences/search', async (c) => {
|
||||
const query = (c.req.query('q') ?? '').trim();
|
||||
if (!query) return c.json([]);
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
|
||||
const rows = await tracker.searchSubtitleSentences(query, limit);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
app.get('/api/stats/kanji', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||
const kanji = await tracker.getKanjiStats(limit);
|
||||
@@ -863,7 +982,7 @@ export function createStatsApp(
|
||||
return c.json(
|
||||
(result.result ?? []).map((note) => ({
|
||||
...note,
|
||||
preview: buildAnkiNotePreview(note.fields, options?.ankiConnectConfig),
|
||||
preview: buildAnkiNotePreview(note.fields, getAnkiConnectConfig()),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
@@ -891,13 +1010,13 @@ export function createStatsApp(
|
||||
return c.json({ error: 'File not found' }, 404);
|
||||
}
|
||||
|
||||
const ankiConfig = options?.ankiConnectConfig;
|
||||
const ankiConfig = getAnkiConnectConfig();
|
||||
if (!ankiConfig) {
|
||||
return c.json({ error: 'AnkiConnect is not configured' }, 500);
|
||||
}
|
||||
|
||||
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
|
||||
const mediaGen = new MediaGenerator();
|
||||
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
||||
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||
@@ -921,7 +1040,9 @@ export function createStatsApp(
|
||||
imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false;
|
||||
|
||||
const audioPromise = generateAudio
|
||||
? mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding)
|
||||
? timeMiningPhase(mode, 'generateAudio', () =>
|
||||
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
|
||||
)
|
||||
: Promise.resolve(null);
|
||||
|
||||
const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => {
|
||||
@@ -930,22 +1051,26 @@ export function createStatsApp(
|
||||
}
|
||||
|
||||
if (imageType === 'avif') {
|
||||
return mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
|
||||
fps: ankiConfig.media?.animatedFps ?? 10,
|
||||
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
|
||||
maxHeight: ankiConfig.media?.animatedMaxHeight,
|
||||
crf: ankiConfig.media?.animatedCrf ?? 35,
|
||||
leadingStillDuration: animatedLeadInSeconds,
|
||||
});
|
||||
return timeMiningPhase(mode, 'generateAnimatedImage', () =>
|
||||
mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
|
||||
fps: ankiConfig.media?.animatedFps ?? 10,
|
||||
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
|
||||
maxHeight: ankiConfig.media?.animatedMaxHeight,
|
||||
crf: ankiConfig.media?.animatedCrf ?? 35,
|
||||
leadingStillDuration: animatedLeadInSeconds,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const midpointSec = (startSec + clampedEndSec) / 2;
|
||||
return mediaGen.generateScreenshot(sourcePath, midpointSec, {
|
||||
format: ankiConfig.media?.imageFormat ?? 'jpg',
|
||||
quality: ankiConfig.media?.imageQuality ?? 92,
|
||||
maxWidth: ankiConfig.media?.imageMaxWidth,
|
||||
maxHeight: ankiConfig.media?.imageMaxHeight,
|
||||
});
|
||||
return timeMiningPhase(mode, 'generateScreenshot', () =>
|
||||
mediaGen.generateScreenshot(sourcePath, midpointSec, {
|
||||
format: ankiConfig.media?.imageFormat ?? 'jpg',
|
||||
quality: ankiConfig.media?.imageQuality ?? 92,
|
||||
maxWidth: ankiConfig.media?.imageMaxWidth,
|
||||
maxHeight: ankiConfig.media?.imageMaxHeight,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const imagePromise =
|
||||
@@ -962,7 +1087,12 @@ export function createStatsApp(
|
||||
}
|
||||
|
||||
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
||||
options.addYomitanNote(word),
|
||||
timeMiningPhase(
|
||||
'word',
|
||||
'addYomitanNote',
|
||||
() => options.addYomitanNote!(word),
|
||||
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
|
||||
),
|
||||
audioPromise,
|
||||
imagePromise,
|
||||
]);
|
||||
@@ -984,10 +1114,19 @@ export function createStatsApp(
|
||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
|
||||
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||
let noteInfo: StatsServerNoteInfo | null = null;
|
||||
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) {
|
||||
try {
|
||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||
const noteInfo = noteInfoResult[0] ?? null;
|
||||
noteInfo = noteInfoResult[0] ?? null;
|
||||
} catch (err) {
|
||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||
errors.push(`image: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||
try {
|
||||
const animatedLeadInSeconds = noteInfo
|
||||
? await resolveAnimatedImageLeadInSeconds({
|
||||
config: ankiConfig,
|
||||
@@ -1006,18 +1145,17 @@ export function createStatsApp(
|
||||
const mediaFields: Record<string, string> = {};
|
||||
const timestamp = Date.now();
|
||||
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
||||
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
||||
const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo);
|
||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||
|
||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||
if (secondaryText) {
|
||||
mediaFields[ankiConfig.fields?.translation ?? 'SelectionText'] = secondaryText;
|
||||
}
|
||||
|
||||
if (audioBuffer) {
|
||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
||||
try {
|
||||
await client.storeMediaFile(audioFilename, audioBuffer);
|
||||
await timeMiningPhase('word', 'uploadAudio', () =>
|
||||
client.storeMediaFile(audioFilename, audioBuffer),
|
||||
);
|
||||
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
|
||||
} catch (err) {
|
||||
errors.push(`audio upload: ${(err as Error).message}`);
|
||||
@@ -1028,7 +1166,9 @@ export function createStatsApp(
|
||||
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
|
||||
try {
|
||||
await client.storeMediaFile(imageFilename, imageBuffer);
|
||||
await timeMiningPhase('word', 'uploadImage', () =>
|
||||
client.storeMediaFile(imageFilename, imageBuffer),
|
||||
);
|
||||
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
||||
} catch (err) {
|
||||
errors.push(`image upload: ${(err as Error).message}`);
|
||||
@@ -1056,7 +1196,9 @@ export function createStatsApp(
|
||||
|
||||
if (Object.keys(mediaFields).length > 0) {
|
||||
try {
|
||||
await client.updateNoteFields(noteId, mediaFields);
|
||||
await timeMiningPhase('word', 'updateNoteFields', () =>
|
||||
client.updateNoteFields(noteId, mediaFields),
|
||||
);
|
||||
} catch (err) {
|
||||
errors.push(`update fields: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -1065,19 +1207,9 @@ export function createStatsApp(
|
||||
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
|
||||
}
|
||||
|
||||
const [audioResult, imageResult] = await Promise.allSettled([audioPromise, imagePromise]);
|
||||
|
||||
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||
if (audioResult.status === 'rejected')
|
||||
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||
if (imageResult.status === 'rejected')
|
||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
|
||||
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
|
||||
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
||||
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
|
||||
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
|
||||
|
||||
@@ -1085,7 +1217,7 @@ export function createStatsApp(
|
||||
[sentenceFieldName]: highlightedSentence,
|
||||
};
|
||||
|
||||
if (secondaryText) {
|
||||
if (mode === 'sentence' && secondaryText) {
|
||||
fields[translationFieldName] = secondaryText;
|
||||
}
|
||||
|
||||
@@ -1104,20 +1236,58 @@ export function createStatsApp(
|
||||
const deck = ankiConfig.deck?.trim() || 'Default';
|
||||
const tags = ankiConfig.tags ?? ['SubMiner'];
|
||||
|
||||
try {
|
||||
noteId = await client.addNote(deck, model, fields, tags);
|
||||
} catch (err) {
|
||||
return c.json({ error: `Failed to add note: ${(err as Error).message}` }, 502);
|
||||
const addNotePromise = timeMiningPhase(
|
||||
mode,
|
||||
'addNote',
|
||||
() => client.addNote(deck, model, fields, tags),
|
||||
(id) => ({
|
||||
noteId: id,
|
||||
}),
|
||||
);
|
||||
|
||||
const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([
|
||||
audioPromise,
|
||||
imagePromise,
|
||||
addNotePromise,
|
||||
]);
|
||||
|
||||
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||
if (audioResult.status === 'rejected')
|
||||
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||
if (imageResult.status === 'rejected')
|
||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
|
||||
if (addNoteResult.status === 'rejected') {
|
||||
return c.json(
|
||||
{ error: `Failed to add note: ${(addNoteResult.reason as Error).message}` },
|
||||
502,
|
||||
);
|
||||
}
|
||||
noteId = addNoteResult.value;
|
||||
|
||||
const mediaFields: Record<string, string> = {};
|
||||
const timestamp = Date.now();
|
||||
let noteInfo: StatsServerNoteInfo | null = null;
|
||||
if (audioBuffer) {
|
||||
try {
|
||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||
noteInfo = noteInfoResult[0] ?? null;
|
||||
} catch {
|
||||
noteInfo = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (audioBuffer) {
|
||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
||||
try {
|
||||
await client.storeMediaFile(audioFilename, audioBuffer);
|
||||
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
|
||||
await timeMiningPhase(mode, 'uploadAudio', () =>
|
||||
client.storeMediaFile(audioFilename, audioBuffer),
|
||||
);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo)) {
|
||||
mediaFields[fieldName] = audioValue;
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`audio upload: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -1127,7 +1297,9 @@ export function createStatsApp(
|
||||
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
|
||||
try {
|
||||
await client.storeMediaFile(imageFilename, imageBuffer);
|
||||
await timeMiningPhase(mode, 'uploadImage', () =>
|
||||
client.storeMediaFile(imageFilename, imageBuffer),
|
||||
);
|
||||
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
||||
} catch (err) {
|
||||
errors.push(`image upload: ${(err as Error).message}`);
|
||||
@@ -1155,7 +1327,9 @@ export function createStatsApp(
|
||||
|
||||
if (Object.keys(mediaFields).length > 0) {
|
||||
try {
|
||||
await client.updateNoteFields(noteId, mediaFields);
|
||||
await timeMiningPhase(mode, 'updateNoteFields', () =>
|
||||
client.updateNoteFields(noteId, mediaFields),
|
||||
);
|
||||
} catch (err) {
|
||||
errors.push(`update fields: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -1195,6 +1369,7 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
||||
knownWordCachePath: config.knownWordCachePath,
|
||||
mpvSocketPath: config.mpvSocketPath,
|
||||
ankiConnectConfig: config.ankiConnectConfig,
|
||||
getAnkiConnectConfig: config.getAnkiConnectConfig,
|
||||
anilistRateLimiter: config.anilistRateLimiter,
|
||||
addYomitanNote: config.addYomitanNote,
|
||||
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
||||
|
||||
Reference in New Issue
Block a user