124 lines
4.2 KiB
TypeScript
124 lines
4.2 KiB
TypeScript
import { parseRecord, readVoiceFields, renderNote } from './note';
|
||
import type { Env, NoteStore, RecordingStore, TranscribeResult, Transcriber } from './ports';
|
||
import { NO_SPEECH, transition, type VoiceRecord } from './record';
|
||
import { due, isStaleSent, staleOwn, type Located } from './schedule';
|
||
|
||
export interface QueueDeps {
|
||
recordings: RecordingStore;
|
||
notes: NoteStore;
|
||
transcriber: Transcriber;
|
||
env: Env;
|
||
}
|
||
|
||
export const INTERRUPTED_SEND = 'отправка прервана';
|
||
|
||
interface Change {
|
||
record: VoiceRecord;
|
||
text?: string;
|
||
}
|
||
|
||
export class QueueRunner {
|
||
private running = false;
|
||
private rerun = false;
|
||
private readonly controller = new AbortController();
|
||
|
||
constructor(private readonly deps: QueueDeps) {}
|
||
|
||
// Повторный вызов во время работы не запускает параллельный проход,
|
||
// а просит ровно один дополнительный после текущего.
|
||
async tick(): Promise<void> {
|
||
if (this.running) {
|
||
this.rerun = true;
|
||
return;
|
||
}
|
||
this.running = true;
|
||
try {
|
||
do {
|
||
this.rerun = false;
|
||
await this.pass();
|
||
} while (this.rerun);
|
||
} finally {
|
||
this.running = false;
|
||
}
|
||
}
|
||
|
||
dispose(): void {
|
||
this.controller.abort();
|
||
}
|
||
|
||
private async pass(): Promise<void> {
|
||
const { env } = this.deps;
|
||
const items = await this.load();
|
||
const now = env.now();
|
||
for (const item of staleOwn(items, now, env.deviceId)) {
|
||
await this.update(
|
||
item.path,
|
||
(r) => r.id === item.record.id && r.device === env.deviceId && isStaleSent(r, now),
|
||
(r) => ({ record: transition(r, { type: 'temporary', message: INTERRUPTED_SEND }, now) }),
|
||
);
|
||
}
|
||
for (const item of due(items, now, env.deviceId)) await this.send(item);
|
||
}
|
||
|
||
private async send(item: Located): Promise<void> {
|
||
const { recordings, transcriber, env } = this.deps;
|
||
const now = env.now();
|
||
// Список мог устареть (metadataCache) — решение принимаем по актуальному файлу.
|
||
const sent = await this.update(
|
||
item.path,
|
||
(r) => r.id === item.record.id && due([{ path: item.path, record: r }], now, env.deviceId).length > 0,
|
||
(r) => ({ record: transition(r, { type: 'send' }, now) }),
|
||
);
|
||
if (!sent) return;
|
||
|
||
let result: TranscribeResult;
|
||
try {
|
||
const audio = await recordings.read(sent.audio);
|
||
result = await transcriber.transcribe(audio, sent.mime, this.controller.signal);
|
||
} catch (e) {
|
||
result = { ok: false, kind: 'temporary', message: e instanceof Error ? e.message : String(e) };
|
||
}
|
||
if (result.ok && result.text.trim() === '') {
|
||
result = { ok: false, kind: 'permanent', message: NO_SPEECH };
|
||
}
|
||
|
||
const done = env.now();
|
||
const final = result;
|
||
await this.update(
|
||
item.path,
|
||
(r) => r.id === sent.id && r.status === 'sent',
|
||
(r) =>
|
||
final.ok
|
||
? { record: transition(r, { type: 'success' }, done), text: final.text }
|
||
: { record: transition(r, { type: final.kind, message: final.message }, done) },
|
||
);
|
||
}
|
||
|
||
// Атомарно: перечитать запись из актуального содержимого, проверить ожидание,
|
||
// применить изменение. null — заметки нет или запись не в ожидаемом состоянии.
|
||
private async update(
|
||
path: string,
|
||
expect: (r: VoiceRecord) => boolean,
|
||
change: (r: VoiceRecord) => Change,
|
||
): Promise<VoiceRecord | null> {
|
||
const box: { record: VoiceRecord | null } = { record: null };
|
||
const exists = await this.deps.notes.process(path, (content) => {
|
||
const current = parseRecord(readVoiceFields(content));
|
||
if (!current || !expect(current)) return content;
|
||
const next = change(current);
|
||
box.record = next.record;
|
||
return renderNote(content, next.record, next.text);
|
||
});
|
||
return exists ? box.record : null;
|
||
}
|
||
|
||
private async load(): Promise<Located[]> {
|
||
const located: Located[] = [];
|
||
for (const { path, frontmatter } of await this.deps.notes.list()) {
|
||
const record = parseRecord(frontmatter);
|
||
if (record) located.push({ path, record });
|
||
}
|
||
return located;
|
||
}
|
||
}
|