mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-12 01:55:55 -07:00
fix(stats): drain full write queue before anime merge/move rebuilds
- Replace single flushNow() with drainWriteQueue loop so forced telemetry appended after a full batch isn't left unwritten before merge/move/rebuild summaries recompute - Add dialog a11y to AnimeMergeDialog/LibraryEntryPicker: aria-modal, labelled headings, alert roles for errors, labelled search input, close button labels
This commit is contained in:
@@ -2498,6 +2498,63 @@ test('Jellyfin link repair removes merged leaked anime rows and sanitizes orphan
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('mergeAnime drains a queue larger than one batch before rebuilding summaries', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
// A batch size well below the queued write count: one flushNow() pass would
|
||||||
|
// leave the forced telemetry sample, appended last, unwritten.
|
||||||
|
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||||
|
const privateApi = tracker as unknown as {
|
||||||
|
db: DatabaseSync;
|
||||||
|
queue: unknown[];
|
||||||
|
mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
privateApi.db.exec(`
|
||||||
|
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000);
|
||||||
|
INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000),
|
||||||
|
(2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000);
|
||||||
|
INSERT INTO imm_sessions (session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES (1, 'drain-session', 2, '1000', '2000', 2, 1000, 1000, 2000);
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (let index = 0; index < 8; index += 1) {
|
||||||
|
(tracker as unknown as { recordWrite: (write: Record<string, unknown>) => void }).recordWrite(
|
||||||
|
{
|
||||||
|
kind: 'subtitleLine',
|
||||||
|
sessionId: 1,
|
||||||
|
videoId: 2,
|
||||||
|
lineIndex: index,
|
||||||
|
segmentStartMs: index * 1000,
|
||||||
|
segmentEndMs: index * 1000 + 900,
|
||||||
|
text: `line ${index}`,
|
||||||
|
wordOccurrences: [],
|
||||||
|
kanjiOccurrences: [],
|
||||||
|
firstSeen: 1000,
|
||||||
|
lastSeen: 2000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.ok(privateApi.queue.length > 2, 'expected more queued writes than one batch');
|
||||||
|
|
||||||
|
await privateApi.mergeAnime(1, [2]);
|
||||||
|
|
||||||
|
assert.equal(privateApi.queue.length, 0);
|
||||||
|
const lines = privateApi.db
|
||||||
|
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = 1')
|
||||||
|
.get() as { total: number };
|
||||||
|
assert.equal(Number(lines.total), 8);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('applies configurable queue, flush, and retention policy', async () => {
|
test('applies configurable queue, flush, and retention policy', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -602,8 +602,7 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async rebuildLifetimeSummaries(): Promise<LifetimeRebuildSummary> {
|
async rebuildLifetimeSummaries(): Promise<LifetimeRebuildSummary> {
|
||||||
this.flushTelemetry(true);
|
this.drainWriteQueue('rebuilding lifetime summaries');
|
||||||
this.flushNow();
|
|
||||||
return rebuildLifetimeSummaryTables(this.db);
|
return rebuildLifetimeSummaryTables(this.db);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,21 +771,47 @@ export class ImmersionTrackerService {
|
|||||||
if (pendingVideoId !== undefined) {
|
if (pendingVideoId !== undefined) {
|
||||||
await this.pendingAnimeMetadataUpdates.get(pendingVideoId);
|
await this.pendingAnimeMetadataUpdates.get(pendingVideoId);
|
||||||
}
|
}
|
||||||
// Both of these rebuild the lifetime summaries, which recompute from the
|
// This rebuilds the lifetime summaries, which recompute from the database:
|
||||||
// database: queued telemetry has to land first or the active session's
|
// queued writes have to land first or the active session is dropped from
|
||||||
// watch time is dropped from the merged totals.
|
// the merged totals.
|
||||||
this.flushTelemetry(true);
|
this.drainWriteQueue('merging library entries');
|
||||||
this.flushNow();
|
|
||||||
return mergeAnimeRecords(this.db, targetAnimeId, sourceAnimeIds);
|
return mergeAnimeRecords(this.db, targetAnimeId, sourceAnimeIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveVideoToAnime(videoId: number, targetAnimeId: number): Promise<VideoMoveSummary> {
|
async moveVideoToAnime(videoId: number, targetAnimeId: number): Promise<VideoMoveSummary> {
|
||||||
await this.pendingAnimeMetadataUpdates.get(videoId);
|
await this.pendingAnimeMetadataUpdates.get(videoId);
|
||||||
this.flushTelemetry(true);
|
this.drainWriteQueue('moving an episode');
|
||||||
this.flushNow();
|
|
||||||
return moveVideoToAnimeQuery(this.db, videoId, targetAnimeId);
|
return moveVideoToAnimeQuery(this.db, videoId, targetAnimeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist every queued write before a caller recomputes summaries from the
|
||||||
|
* database.
|
||||||
|
*
|
||||||
|
* A single `flushNow()` is not enough: forced telemetry is appended to the
|
||||||
|
* back of the queue while `flushNow()` writes at most `batchSize` entries off
|
||||||
|
* the front, so a busy session leaves the newest sample unwritten. Stops as
|
||||||
|
* soon as a pass makes no progress — a rolled-back batch is pushed back onto
|
||||||
|
* the queue, and looping on that would spin forever.
|
||||||
|
*
|
||||||
|
* Returns false when the queue could not be emptied, in which case the
|
||||||
|
* rebuild runs against a database still missing those writes.
|
||||||
|
*/
|
||||||
|
private drainWriteQueue(context: string): boolean {
|
||||||
|
this.flushTelemetry(true);
|
||||||
|
while (this.queue.length > 0) {
|
||||||
|
const pending = this.queue.length;
|
||||||
|
this.flushNow();
|
||||||
|
if (this.queue.length >= pending) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Immersion tracker queue did not drain before ${context}; summaries may lag by ${this.queue.length} writes`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async reassignAnimeAnilist(
|
async reassignAnimeAnilist(
|
||||||
animeId: number,
|
animeId: number,
|
||||||
info: {
|
info: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useId, useState } from 'react';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||||
import { AnimeCoverImage } from './AnimeCoverImage';
|
import { AnimeCoverImage } from './AnimeCoverImage';
|
||||||
@@ -19,6 +19,7 @@ function pickDefaultKeeper(entries: AnimeLibraryItem[]): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialogProps) {
|
export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialogProps) {
|
||||||
|
const headingId = useId();
|
||||||
const [keeperId, setKeeperId] = useState(() => pickDefaultKeeper(entries));
|
const [keeperId, setKeeperId] = useState(() => pickDefaultKeeper(entries));
|
||||||
const [merging, setMerging] = useState(false);
|
const [merging, setMerging] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -57,18 +58,22 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
|
|||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
|
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={headingId}
|
||||||
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
|
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="p-4 border-b border-ctp-surface1">
|
<div className="p-4 border-b border-ctp-surface1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-ctp-text">
|
<h3 id={headingId} className="text-sm font-semibold text-ctp-text">
|
||||||
Merge {entries.length} Library Entries
|
Merge {entries.length} Library Entries
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
disabled={merging}
|
disabled={merging}
|
||||||
|
aria-label="Close"
|
||||||
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
|
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{'✕'}
|
{'✕'}
|
||||||
@@ -86,6 +91,7 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
|
|||||||
key={entry.animeId}
|
key={entry.animeId}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={merging}
|
disabled={merging}
|
||||||
|
aria-pressed={keeperId === entry.animeId}
|
||||||
onClick={() => setKeeperId(entry.animeId)}
|
onClick={() => setKeeperId(entry.animeId)}
|
||||||
className={`w-full flex items-center gap-3 p-2.5 rounded-lg transition-colors text-left disabled:opacity-50 ${
|
className={`w-full flex items-center gap-3 p-2.5 rounded-lg transition-colors text-left disabled:opacity-50 ${
|
||||||
keeperId === entry.animeId ? 'bg-ctp-surface1' : 'hover:bg-ctp-surface0'
|
keeperId === entry.animeId ? 'bg-ctp-surface1' : 'hover:bg-ctp-surface0'
|
||||||
@@ -120,7 +126,11 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border-t border-ctp-surface1 space-y-2">
|
<div className="p-4 border-t border-ctp-surface1 space-y-2">
|
||||||
{error ? <div className="text-xs text-ctp-red">{error}</div> : null}
|
{error ? (
|
||||||
|
<div role="alert" className="text-xs text-ctp-red">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-xs text-ctp-overlay2">
|
<div className="text-xs text-ctp-overlay2">
|
||||||
Result: {totalEpisodes} episode{totalEpisodes !== 1 ? 's' : ''} ·{' '}
|
Result: {totalEpisodes} episode{totalEpisodes !== 1 ? 's' : ''} ·{' '}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { formatDuration } from '../../lib/formatters';
|
import { formatDuration } from '../../lib/formatters';
|
||||||
import { AnimeCoverImage } from './AnimeCoverImage';
|
import { AnimeCoverImage } from './AnimeCoverImage';
|
||||||
@@ -28,6 +28,8 @@ export function LibraryEntryPicker({
|
|||||||
const [loadFailed, setLoadFailed] = useState(false);
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
const [query, setQuery] = useState(initialQuery);
|
const [query, setQuery] = useState(initialQuery);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const headingId = useId();
|
||||||
|
const searchId = useId();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -62,40 +64,56 @@ export function LibraryEntryPicker({
|
|||||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
|
||||||
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
|
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={headingId}
|
||||||
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
|
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="p-4 border-b border-ctp-surface1">
|
<div className="p-4 border-b border-ctp-surface1">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-ctp-text">{heading}</h3>
|
<h3 id={headingId} className="text-sm font-semibold text-ctp-text">
|
||||||
|
{heading}
|
||||||
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
|
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
|
||||||
>
|
>
|
||||||
{'✕'}
|
{'✕'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<label htmlFor={searchId} className="sr-only">
|
||||||
|
Search library
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
id={searchId}
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search library..."
|
placeholder="Search library..."
|
||||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||||
/>
|
/>
|
||||||
{error ? <div className="text-xs text-ctp-red mt-2">{error}</div> : null}
|
{error ? (
|
||||||
|
<div role="alert" className="text-xs text-ctp-red mt-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
{entries === null && <div className="text-xs text-ctp-overlay2 p-3">Loading...</div>}
|
{entries === null && <div className="text-xs text-ctp-overlay2 p-3">Loading...</div>}
|
||||||
{loadFailed && (
|
{loadFailed && (
|
||||||
<div className="text-xs text-ctp-red p-3">
|
<div role="alert" className="text-xs text-ctp-red p-3">
|
||||||
Could not load the library. Close this dialog and try again.
|
Could not load the library. Close this dialog and try again.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loadFailed && entries !== null && visible.length === 0 && (
|
{!loadFailed && entries !== null && visible.length === 0 && (
|
||||||
<div className="text-xs text-ctp-overlay2 p-3">No other titles</div>
|
<div className="text-xs text-ctp-overlay2 p-3">
|
||||||
|
{query.trim() ? 'No matches' : 'No other titles'}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{visible.map((entry) => (
|
{visible.map((entry) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user