mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-12 13:55:51 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d74c7e1235
|
|||
| 57ddd19953 | |||
| ee25536d90 | |||
| 7b0fbdf254 |
@@ -41,7 +41,7 @@
|
||||
"fast-uri": "3.1.5",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.0",
|
||||
"js-yaml": "4.3.1",
|
||||
"lodash": "4.18.0",
|
||||
"minimatch": "10.2.5",
|
||||
"picomatch": "4.0.4",
|
||||
@@ -498,7 +498,7 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
||||
"js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
type: fixed
|
||||
area: character dictionary
|
||||
|
||||
- A large character dictionary no longer fails to install. The Yomitan import (and the delete that precedes it) used a flat 7 second budget, so a series like One Piece failed with `importYomitanDictionary(merged.zip) timed out after 7000ms` while the import was still healthy. The budget is now sized from the merged ZIP (2 minutes plus 6 seconds per MB, capped at 30 minutes); the quick queries keep the 7 second budget.
|
||||
- The "Generating character dictionary" notification now reports what it is doing instead of one static message for the whole run: the AniList page and character count while characters download (`page 12, 587 characters`), then `image 240/1220, ~4m left` per downloaded character/voice actor image, then `name 800/1220` for MeCab name splits and `saving snapshot` at the end. Updates are throttled to one per second, and every stage change and final item always reports.
|
||||
- Every long phase also carries an elapsed clock (`· 1m 35s`), refreshed every 5 seconds even when nothing else moves, so a stalled step is visibly different from a frozen app. Long imports tick the same way.
|
||||
- Image downloads are also logged every 100 files, and the import logs the timeout it computed.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: added
|
||||
area: stats
|
||||
|
||||
- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, the emptied entries disappear, and remembered title aliases keep future episodes on the merged card.
|
||||
- Library: episodes can be reassigned to another library entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (e.g. an episode name parsed as the series). Emptying an entry this way removes it and returns to the grid.
|
||||
- Library: exact AniList title matches with compatible seasons fold duplicate cards automatically. Fuzzy same-AniList matches appear as dismissible "Possible duplicate" reviews instead of changing the library without confirmation; conflicting explicit seasons are left alone.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Heavily typeset ASS scripts (karaoke OP/ED, sign work) no longer fill the subtitle sidebar with garbage. Vector drawing runs (`\p1` … `\p0`) are no longer shown as subtitle text (e.g. `m 20 0 b 10 0 0 10 0 20 …`), and duplicate events in a parsed subtitle file collapse into one cue: identical text over an identical span (layered "shadow" copies), and per-frame animation bursts. An ASS burst has to prove itself with authoring evidence — a temporal tag (`\t`, `\move`, karaoke timing), an animated `Effect` column, or override values that change from event to event — plus one shared style and actor, so three rapid `えっ` reactions from three characters, or a sign repeated with the same static `\clip`, stay separate. SRT and VTT carry no such metadata, so there the run has to be at least five contiguous events all shorter than 0.1s, which is where ASS-to-SRT conversion leaves karaoke frames.
|
||||
- Subtitle text is now decoded from ASS exactly once, where it enters the app, and matches how mpv renders the same line (including `\N`, `\n`, `\h`, unclosed `{`, and the fact that `\{` is not an escape). Renderer, timing tracker, tokenizer and the tokenization cache take that decoded text as-is instead of each re-deriving it, so one authored line can no longer produce two different cache keys, and a cue that normalizes to nothing is no longer stored as subtitle text or cached under an empty key.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed X11/XWayland overlays being oversized and offset from mpv under fractional or mixed-monitor display scaling.
|
||||
@@ -57,13 +57,6 @@ Jellyfin stream URLs are normalized to stable item links before stats titles are
|
||||
|
||||
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
|
||||
|
||||
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
|
||||
|
||||
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
|
||||
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. If that was the entry's last episode, the now-empty card is removed and you are returned to the grid.
|
||||
|
||||
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
|
||||
|
||||
Open a title and use **Delete Entry** in its header to remove a mistakenly tracked show outright. This deletes every episode of that title along with their sessions, subtitle lines, rollups and cover art, drops the words and kanji that were only seen there, and removes the card from the Library grid. Individual episodes and sessions can still be deleted on their own from the episode list and session rows. Entry deletion is refused while that title is the one currently playing.
|
||||
|
||||

|
||||
|
||||
@@ -405,8 +405,9 @@ On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and othe
|
||||
|
||||
SubMiner handles this automatically:
|
||||
|
||||
- It launches its own window under XWayland (it sets `--ozone-platform-hint=x11`).
|
||||
- It launches its own window under XWayland (it sets `--ozone-platform=x11`).
|
||||
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11vk,x11egl,x11`) is applied. Only the window context is overridden; your `vo`/`gpu-api` and user shaders are left alone.
|
||||
- Fractional and mixed-monitor display scaling is handled per screen when SubMiner maps XWayland mpv coordinates to the overlay.
|
||||
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
|
||||
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
|
||||
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
|
||||
|
||||
@@ -64,18 +64,23 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
|
||||
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
||||
|
||||
**Parsed cue structure:**
|
||||
|
||||
```typescript
|
||||
interface SubtitleCue {
|
||||
startTime: number; // seconds
|
||||
endTime: number; // seconds
|
||||
text: string; // raw subtitle text
|
||||
startTime: number; // seconds
|
||||
endTime: number; // seconds
|
||||
text: string; // plain text, decoded from the source format
|
||||
}
|
||||
```
|
||||
|
||||
**Supported formats:**
|
||||
|
||||
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
||||
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing.
|
||||
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
|
||||
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, read the field order from the `Format:` row, and take everything after the Text field index as the text (Text can itself contain commas).
|
||||
|
||||
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
|
||||
|
||||
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
|
||||
|
||||
#### Prefetch Service Lifecycle
|
||||
|
||||
@@ -153,6 +158,7 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
|
||||
### Dependency Analysis
|
||||
|
||||
All annotations either depend on MeCab POS data or benefit from running after it:
|
||||
|
||||
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
||||
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
||||
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
||||
@@ -169,18 +175,14 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
|
||||
|
||||
// Single pass: known word + frequency filtering + JLPT computed together
|
||||
const annotated = tokens.map((token) => {
|
||||
const isKnown = nPlusOneEnabled
|
||||
? token.isKnown || computeIsKnown(token, deps)
|
||||
: false;
|
||||
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
|
||||
|
||||
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
||||
const frequencyRank = frequencyEnabled
|
||||
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
||||
: undefined;
|
||||
|
||||
const jlptLevel = jlptEnabled
|
||||
? computeJlptLevel(token, deps.getJlptLevel)
|
||||
: undefined;
|
||||
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
|
||||
|
||||
return { ...token, isKnown, frequencyRank, jlptLevel };
|
||||
});
|
||||
@@ -221,6 +223,7 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
|
||||
### Current Behavior
|
||||
|
||||
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
||||
|
||||
1. Clears DOM with `innerHTML = ''`
|
||||
2. Creates a `DocumentFragment`
|
||||
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
||||
@@ -256,27 +259,30 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
|
||||
|
||||
## Combined Impact Summary
|
||||
|
||||
| Scenario | Before | After | Improvement |
|
||||
|----------|--------|-------|-------------|
|
||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||
| Scenario | Before | After | Improvement |
|
||||
| --------------------------------- | ---------- | ---------- | ----------- |
|
||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/core/services/subtitle-prefetch.ts`
|
||||
- `src/core/services/subtitle-cue-parser.ts`
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
||||
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
||||
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
||||
- `src/main.ts` (wire up prefetch service)
|
||||
|
||||
### Test Files
|
||||
|
||||
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
||||
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
||||
- Updated tests for annotation stage (same behavior, new implementation)
|
||||
|
||||
@@ -24,7 +24,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
|
||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata. Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
|
||||
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||
- Window trackers: `src/window-trackers/`
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@
|
||||
"fast-uri": "3.1.5",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.0",
|
||||
"js-yaml": "4.3.1",
|
||||
"lodash": "4.18.0",
|
||||
"minimatch": "10.2.5",
|
||||
"picomatch": "4.0.4",
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { DatabaseSync } from '../immersion-tracker/sqlite';
|
||||
|
||||
type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService;
|
||||
type ImmersionTrackerServiceCtor =
|
||||
typeof import('../immersion-tracker-service').ImmersionTrackerService;
|
||||
|
||||
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
|
||||
|
||||
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
|
||||
if (trackerCtor) return trackerCtor;
|
||||
const mod = await import('../immersion-tracker-service');
|
||||
trackerCtor = mod.ImmersionTrackerService;
|
||||
return trackerCtor;
|
||||
}
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
|
||||
return path.join(dir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
function cleanupDbPath(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) return;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
interface TrackerInternals {
|
||||
db: DatabaseSync;
|
||||
queue: unknown[];
|
||||
recordWrite: (write: Record<string, unknown>) => void;
|
||||
mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise<unknown>;
|
||||
moveVideoToAnime: (videoId: number, targetAnimeId: number) => Promise<unknown>;
|
||||
rebuildLifetimeSummaries: () => Promise<unknown>;
|
||||
flushNow: () => void;
|
||||
}
|
||||
|
||||
test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
|
||||
|
||||
assert.deepEqual(
|
||||
internals.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id')
|
||||
.all()
|
||||
.map((row) => (row as { animeId: number }).animeId),
|
||||
[1, 2],
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
|
||||
assert.equal(
|
||||
(
|
||||
internals.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2')
|
||||
.get() as {
|
||||
animeId: number;
|
||||
}
|
||||
).animeId,
|
||||
2,
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
function seedTwoEntries(db: DatabaseSync): void {
|
||||
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);
|
||||
`);
|
||||
}
|
||||
|
||||
function queueSubtitleLines(tracker: TrackerInternals, count: number): void {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
tracker.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queued last so it sits past the first batch. Lifetime `total_lines_seen`
|
||||
* reads this counter, not a COUNT over imm_subtitle_lines, so the rebuilt
|
||||
* summary only reflects the session once the queue is drained all the way.
|
||||
*/
|
||||
function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void {
|
||||
tracker.recordWrite({
|
||||
kind: 'telemetry',
|
||||
sessionId: 1,
|
||||
sampleMs: 3000,
|
||||
lastMediaMs: 3000,
|
||||
totalWatchedMs: 4000,
|
||||
activeWatchedMs: 3500,
|
||||
linesSeen,
|
||||
tokensSeen: linesSeen * 5,
|
||||
cardsMined: 2,
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
pauseCount: 0,
|
||||
pauseMs: 0,
|
||||
seekForwardCount: 0,
|
||||
seekBackwardCount: 0,
|
||||
mediaBufferEvents: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function lifetimeForAnime(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
): { linesSeen: number; activeMs: number; cards: number } | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT total_lines_seen AS linesSeen, total_active_ms AS activeMs, total_cards AS cards
|
||||
FROM imm_lifetime_anime WHERE anime_id = ?`,
|
||||
)
|
||||
.get(animeId) as { linesSeen: number; activeMs: number; cards: number } | undefined;
|
||||
return row
|
||||
? { linesSeen: Number(row.linesSeen), activeMs: Number(row.activeMs), cards: Number(row.cards) }
|
||||
: null;
|
||||
}
|
||||
|
||||
function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
|
||||
.get(animeId) as { total: number };
|
||||
return Number(row.total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Both entry points rebuild the lifetime summaries, which recompute from the
|
||||
* database. A single flushNow() only writes one batch off the front of the
|
||||
* queue, so anything past `batchSize` would still be unwritten when the rebuild
|
||||
* reads.
|
||||
*/
|
||||
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();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 8);
|
||||
queueTelemetry(internals, 8);
|
||||
assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch');
|
||||
|
||||
await internals.mergeAnime(1, [2]);
|
||||
|
||||
assert.equal(internals.queue.length, 0);
|
||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||
// The surviving entry's summary was rebuilt from the fully drained queue.
|
||||
const lifetime = lifetimeForAnime(internals.db, 1);
|
||||
assert.equal(lifetime?.linesSeen, 8);
|
||||
assert.equal(lifetime?.activeMs, 3500);
|
||||
assert.equal(lifetime?.cards, 2);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('moveVideoToAnime drains a queue larger than one batch before rebuilding summaries', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 8);
|
||||
queueTelemetry(internals, 8);
|
||||
|
||||
await internals.moveVideoToAnime(2, 1);
|
||||
|
||||
assert.equal(internals.queue.length, 0);
|
||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||
const lifetime = lifetimeForAnime(internals.db, 1);
|
||||
assert.equal(lifetime?.linesSeen, 8);
|
||||
assert.equal(lifetime?.activeMs, 3500);
|
||||
assert.equal(lifetime?.cards, 2);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
@@ -1053,55 +1053,6 @@ describe('stats server API routes', () => {
|
||||
assert.equal(body[0].canonicalTitle, 'Little Witch Academia');
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime/merge-recommendations returns pending duplicate pairs', async () => {
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getAnimeMergeRecommendations: async () => [{ recommendationId: 4, animeIds: [1, 2] }],
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/merge-recommendations');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), {
|
||||
recommendations: [{ recommendationId: 4, animeIds: [1, 2] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('DELETE /api/stats/anime/merge-recommendations/:id dismisses a pending pair', async () => {
|
||||
let dismissedId: number | null = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
dismissAnimeMergeRecommendation: async (recommendationId: number) => {
|
||||
dismissedId = recommendationId;
|
||||
return true;
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/merge-recommendations/4', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(dismissedId, 4);
|
||||
assert.deepEqual(await res.json(), { ok: true });
|
||||
});
|
||||
|
||||
it('DELETE /api/stats/anime/merge-recommendations/:id reports missing recommendations', async () => {
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
dismissAnimeMergeRecommendation: async () => false,
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/merge-recommendations/99', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime/:animeId returns anime detail with episodes', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const res = await app.request('/api/stats/anime/1');
|
||||
@@ -3073,148 +3024,6 @@ Aligned English subtitle
|
||||
assert.equal(deleteCalls, 0);
|
||||
});
|
||||
|
||||
it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => {
|
||||
let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
mergeAnime: async (targetAnimeId: number, sourceAnimeIds: number[]) => {
|
||||
merged = { targetAnimeId, sourceAnimeIds };
|
||||
return {
|
||||
survivingAnimeId: targetAnimeId,
|
||||
mergedAnimeIds: sourceAnimeIds,
|
||||
movedVideos: 3,
|
||||
};
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/7/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// The target repeated in the sources must not delete the entry we keep.
|
||||
body: '{"sourceAnimeIds":[8,9,8,7]}',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(merged, { targetAnimeId: 7, sourceAnimeIds: [8, 9] });
|
||||
assert.deepEqual(await res.json(), {
|
||||
ok: true,
|
||||
animeId: 7,
|
||||
mergedAnimeIds: [8, 9],
|
||||
movedVideos: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/anime/:animeId/merge rejects an empty or malformed source list', async () => {
|
||||
let mergeCalls = 0;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
mergeAnime: async () => {
|
||||
mergeCalls += 1;
|
||||
return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 };
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
for (const body of [
|
||||
'{"sourceAnimeIds":[]}',
|
||||
'{"sourceAnimeIds":[7]}',
|
||||
'{"sourceAnimeIds":0}',
|
||||
]) {
|
||||
const res = await app.request('/api/stats/anime/7/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
}
|
||||
assert.equal(mergeCalls, 0);
|
||||
});
|
||||
|
||||
it('PATCH /api/stats/media/:videoId/anime moves the episode to another entry', async () => {
|
||||
let moved: { videoId: number; animeId: number } | null = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
moveVideoToAnime: async (videoId: number, animeId: number) => {
|
||||
moved = { videoId, animeId };
|
||||
return { targetAnimeId: animeId, previousAnimeId: 4, removedPreviousAnime: true };
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/media/12/anime', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"animeId":7}',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(moved, { videoId: 12, animeId: 7 });
|
||||
assert.deepEqual(await res.json(), {
|
||||
ok: true,
|
||||
animeId: 7,
|
||||
previousAnimeId: 4,
|
||||
removedPreviousAnime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/anime/:animeId/merge reports a merge that folded nothing as 404', async () => {
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
mergeAnime: async (targetAnimeId: number) => ({
|
||||
survivingAnimeId: targetAnimeId,
|
||||
mergedAnimeIds: [],
|
||||
movedVideos: 0,
|
||||
}),
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/7/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"sourceAnimeIds":[8]}',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
it('PATCH /api/stats/media/:videoId/anime reports an unknown target as 404', async () => {
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
moveVideoToAnime: async () => {
|
||||
throw new Error('Unknown episode or target library entry');
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/media/12/anime', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"animeId":99}',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
it('PATCH /api/stats/media/:videoId/anime does not disguise storage failures as 404', async () => {
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
moveVideoToAnime: async () => {
|
||||
throw new Error('database is locked');
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/media/12/anime', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"animeId":7}',
|
||||
});
|
||||
|
||||
assert.notEqual(res.status, 404);
|
||||
assert.equal(res.status >= 500, true);
|
||||
});
|
||||
|
||||
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
||||
|
||||
@@ -327,7 +327,6 @@ export function createCoverArtFetcher(
|
||||
titleEnglish: selected.title?.english ?? null,
|
||||
titleNative: selected.title?.native ?? null,
|
||||
episodesTotal: selected.episodes ?? null,
|
||||
exactTitleMatch: resolution?.exactTitleMatch ?? false,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -156,49 +156,6 @@ test('season 1 resolves to the anchor without relation lookups', async () => {
|
||||
assert.deepEqual(relationLookups, []);
|
||||
});
|
||||
|
||||
test('season 2 preserves the anchor exact-title evidence through a sequel resolution', async () => {
|
||||
const { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 1 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 20698);
|
||||
assert.equal(result?.via, 'sequel-chain');
|
||||
assert.equal(result?.exactTitleMatch, true);
|
||||
});
|
||||
|
||||
test('reports an exact normalized synonym match as strong evidence', async () => {
|
||||
const { execute } = createExecutor([
|
||||
{
|
||||
id: 1,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
title: { english: 'Hitori Gotoh Story' },
|
||||
synonyms: ['BOCCHI THE ROCK'],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await resolveAnilistSeasonMedia({ title: 'Bocchi the Rock!' }, { execute });
|
||||
|
||||
assert.equal(result?.exactTitleMatch, true);
|
||||
});
|
||||
|
||||
test('reports a fuzzy-only search result as weak evidence', async () => {
|
||||
const { execute } = createExecutor([
|
||||
{
|
||||
id: 1,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
title: { english: 'Actual Show' },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await resolveAnilistSeasonMedia({ title: 'Unrelated Release' }, { execute });
|
||||
|
||||
assert.equal(result?.exactTitleMatch, false);
|
||||
});
|
||||
|
||||
test('strips a season marker already present in the parsed title', async () => {
|
||||
const { execute, searches } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
* reports `seasonResolved: false` so callers can refuse to act instead of guessing.
|
||||
*/
|
||||
|
||||
import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
||||
|
||||
export interface AnilistSeasonMediaTitle {
|
||||
romaji?: string | null;
|
||||
english?: string | null;
|
||||
@@ -44,8 +42,6 @@ export interface AnilistSeasonResolution {
|
||||
seasonResolved: boolean;
|
||||
requestedSeason: number | null;
|
||||
via: AnilistSeasonResolutionVia;
|
||||
/** Exact normalized match against an AniList title or synonym. */
|
||||
exactTitleMatch: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveAnilistSeasonMediaInput {
|
||||
@@ -119,6 +115,10 @@ const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
|
||||
|
||||
const MAX_SEQUEL_HOPS = 12;
|
||||
|
||||
function normalizeTitle(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops season markers a release name carries but AniList titles never do,
|
||||
* so "Some Show Season 3" and "Some Show S3" both search as "Some Show".
|
||||
@@ -136,7 +136,7 @@ function mediaTitles(media: AnilistSeasonMedia): string[] {
|
||||
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
|
||||
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => normalizeTitleIdentity(value));
|
||||
.map((value) => normalizeTitle(value));
|
||||
}
|
||||
|
||||
function displayTitle(media: AnilistSeasonMedia, fallback: string): string {
|
||||
@@ -176,7 +176,6 @@ function toResolution(
|
||||
season: number | null,
|
||||
via: AnilistSeasonResolutionVia,
|
||||
seasonResolved: boolean,
|
||||
exactTitleMatch: boolean,
|
||||
): AnilistSeasonResolution {
|
||||
return {
|
||||
id: media.id,
|
||||
@@ -186,7 +185,6 @@ function toResolution(
|
||||
seasonResolved,
|
||||
requestedSeason: season,
|
||||
via,
|
||||
exactTitleMatch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,10 +209,9 @@ export function pickAnchorMedia(
|
||||
: media;
|
||||
const pool = episodeFiltered.length > 0 ? episodeFiltered : media;
|
||||
|
||||
const targets = [
|
||||
normalizeTitleIdentity(title),
|
||||
normalizeTitleIdentity(stripSeasonSuffix(title)),
|
||||
].filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||
const targets = [normalizeTitle(title), normalizeTitle(stripSeasonSuffix(title))].filter(
|
||||
(value, index, all) => value.length > 0 && all.indexOf(value) === index,
|
||||
);
|
||||
|
||||
const scored = pool.map((entry, index) => {
|
||||
const candidateTitles = mediaTitles(entry);
|
||||
@@ -370,10 +367,9 @@ export async function resolveAnilistSeasonMedia(
|
||||
episode: season === null || season <= 1 ? input.episode : null,
|
||||
});
|
||||
if (!anchor) return null;
|
||||
const exactTitleMatch = mediaTitles(anchor).includes(normalizeTitleIdentity(searchTitle));
|
||||
|
||||
if (season === null || season <= 1) {
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', true, exactTitleMatch);
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', true);
|
||||
}
|
||||
|
||||
let chainError: unknown = null;
|
||||
@@ -387,7 +383,7 @@ export async function resolveAnilistSeasonMedia(
|
||||
deps.logInfo?.(
|
||||
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
|
||||
);
|
||||
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true, exactTitleMatch);
|
||||
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
|
||||
}
|
||||
|
||||
const viaAirOrder = pickByAirOrder(anchor, season, media);
|
||||
@@ -395,7 +391,7 @@ export async function resolveAnilistSeasonMedia(
|
||||
deps.logInfo?.(
|
||||
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
|
||||
);
|
||||
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true, exactTitleMatch);
|
||||
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
|
||||
}
|
||||
|
||||
// The chain failed for transport reasons rather than because the season is absent;
|
||||
@@ -407,5 +403,5 @@ export async function resolveAnilistSeasonMedia(
|
||||
deps.logInfo?.(
|
||||
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
|
||||
);
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', false, exactTitleMatch);
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
assOverrideSignature,
|
||||
assToPlainText,
|
||||
collectAssOverrideCommands,
|
||||
extractAssOverrideBlocks,
|
||||
hasAssTemporalOverride,
|
||||
isAnimatedAssEffectKind,
|
||||
isAssTemporalCommand,
|
||||
normalizePlainSubtitleText,
|
||||
parseAssEffectField,
|
||||
} from './ass-text';
|
||||
|
||||
test('assToPlainText drops vector drawing runs', () => {
|
||||
assert.equal(
|
||||
assToPlainText(
|
||||
'{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
|
||||
),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('assToPlainText keeps text around drawing runs on the same event', () => {
|
||||
assert.equal(
|
||||
assToPlainText('{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き'),
|
||||
'本文続き',
|
||||
);
|
||||
});
|
||||
|
||||
test('assToPlainText leaves \\pos alone when no drawing mode is active', () => {
|
||||
assert.equal(assToPlainText('{\\pos(960,1068)\\bord3}位置指定'), '位置指定');
|
||||
});
|
||||
|
||||
test('assToPlainText does not read \\pos as a drawing tag', () => {
|
||||
assert.equal(assToPlainText('{\\p1\\pos(1,2)}m 0 0 l 5 5'), '');
|
||||
});
|
||||
|
||||
test('assToPlainText resolves line-break and space escapes', () => {
|
||||
assert.equal(assToPlainText('一行目\\N二行目'), '一行目\n二行目');
|
||||
assert.equal(assToPlainText('一行目\\n二行目'), '一行目\n二行目');
|
||||
assert.equal(assToPlainText('一行目\\N二行目', ' '), '一行目 二行目');
|
||||
assert.equal(assToPlainText('間\\h隔'), '間 隔');
|
||||
});
|
||||
|
||||
test('assToPlainText matches mpv on brace and backslash sequences', () => {
|
||||
// mpv has no `\{` / `\}` / `\\` escapes: the backslashes are literal text and the
|
||||
// braces still open and close an override block.
|
||||
assert.equal(assToPlainText('\\{注\\}'), '\\');
|
||||
assert.equal(assToPlainText('\\\\N'), '\\\n');
|
||||
});
|
||||
|
||||
test('assToPlainText renders an unclosed override block verbatim', () => {
|
||||
// mpv shows the stray brace; guessing where the block ended can eat a whole line.
|
||||
assert.equal(assToPlainText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||
});
|
||||
|
||||
test('assToPlainText is idempotent', () => {
|
||||
const samples = [
|
||||
'{\\an5\\p1}m 0 0 l 5 5{\\p0}本文',
|
||||
'\\{注\\}',
|
||||
'\\\\N',
|
||||
'本文{\\pos(1,2)',
|
||||
'一行目\\N二行目\\h終わり',
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
const once = assToPlainText(sample);
|
||||
assert.equal(assToPlainText(once), once, sample);
|
||||
}
|
||||
});
|
||||
|
||||
test('assToPlainText normalizes CRLF before converting', () => {
|
||||
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
|
||||
});
|
||||
|
||||
test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => {
|
||||
// A brace reaching this layer is literal text mpv chose to show, not markup.
|
||||
assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||
assert.equal(normalizePlainSubtitleText('一行目\\N二行目'), '一行目\n二行目');
|
||||
assert.equal(
|
||||
normalizePlainSubtitleText('一行目\\N二行目', { collapseLineBreaks: true }),
|
||||
'一行目 二行目',
|
||||
);
|
||||
assert.equal(normalizePlainSubtitleText(' 余白 ', { trim: false }), ' 余白 ');
|
||||
});
|
||||
|
||||
test('normalizePlainSubtitleText is idempotent', () => {
|
||||
for (const sample of ['一行目\\N二行目', '間\\h隔', '本文{\\pos(1,2)', ' 余白 ']) {
|
||||
const once = normalizePlainSubtitleText(sample);
|
||||
assert.equal(normalizePlainSubtitleText(once), once, sample);
|
||||
}
|
||||
});
|
||||
|
||||
test('extractAssOverrideBlocks returns block contents', () => {
|
||||
assert.deepEqual(extractAssOverrideBlocks('{\\an8}上{\\fad(200,200)}下'), [
|
||||
'\\an8',
|
||||
'\\fad(200,200)',
|
||||
]);
|
||||
assert.deepEqual(extractAssOverrideBlocks('括弧なし'), []);
|
||||
});
|
||||
|
||||
test('collectAssOverrideCommands captures names and arguments from blocks only', () => {
|
||||
const commands = collectAssOverrideCommands('{\\pos(1,2)\\1c&HFFFFFF&\\kf30}歌詞');
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
{ name: 'pos', args: '1,2', animated: false },
|
||||
{ name: '1c', args: '&HFFFFFF&', animated: false },
|
||||
{ name: 'kf', args: '30', animated: false },
|
||||
]);
|
||||
|
||||
// A `\pos(...)` sitting in visible text is not typesetting markup.
|
||||
assert.deepEqual(collectAssOverrideCommands('\\pos(730,1042) と書いてある'), []);
|
||||
});
|
||||
|
||||
test('collectAssOverrideCommands marks tags animated by a wrapping \\t', () => {
|
||||
const commands = collectAssOverrideCommands('{\\clip(0,0,10,10)\\t(0,500,\\frz30)}文字');
|
||||
|
||||
assert.deepEqual(
|
||||
commands.map((command) => [command.name, command.animated]),
|
||||
[
|
||||
['clip', false],
|
||||
['t', false],
|
||||
['frz', true],
|
||||
],
|
||||
);
|
||||
assert.equal(hasAssTemporalOverride(commands), true);
|
||||
});
|
||||
|
||||
test('collectAssOverrideCommands stops descending into deeply nested \\t tags', () => {
|
||||
// Nested far past the recursion cap. Uncapped, this recurses once per level, and a
|
||||
// pathological line (real files reach one or two levels) overflows the stack.
|
||||
const nesting = 32;
|
||||
const block = `{${'\\t(0,500,'.repeat(nesting)}\\frz30${')'.repeat(nesting)}}文字`;
|
||||
|
||||
const commands = collectAssOverrideCommands(block);
|
||||
|
||||
// The outer `\t` plus one per allowed recursion level, and nothing from below the cap.
|
||||
assert.equal(commands.length, 9);
|
||||
assert.deepEqual(new Set(commands.map((command) => command.name)), new Set(['t']));
|
||||
assert.equal(hasAssTemporalOverride(commands), true);
|
||||
});
|
||||
|
||||
test('hasAssTemporalOverride ignores static placement and shape tags', () => {
|
||||
assert.equal(
|
||||
hasAssTemporalOverride(collectAssOverrideCommands('{\\pos(1,2)\\clip(m 1 1)\\blur2}文字')),
|
||||
false,
|
||||
);
|
||||
assert.equal(hasAssTemporalOverride(collectAssOverrideCommands('{\\move(1,2,3,4)}文字')), true);
|
||||
});
|
||||
|
||||
test('isAssTemporalCommand covers only intrinsically animated tags', () => {
|
||||
for (const command of ['t', 'move', 'k', 'kf', 'ko', 'K']) {
|
||||
assert.equal(isAssTemporalCommand(command), true, command);
|
||||
}
|
||||
for (const command of ['clip', 'iclip', 'frz', 'fscx', 'blur', 'be', 'pos', 'fad']) {
|
||||
assert.equal(isAssTemporalCommand(command), false, command);
|
||||
}
|
||||
});
|
||||
|
||||
test('assOverrideSignature distinguishes events by their override values', () => {
|
||||
const first = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}歌詞'));
|
||||
const second = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 2 2)}歌詞'));
|
||||
const repeat = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}別の行'));
|
||||
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(first, repeat);
|
||||
});
|
||||
|
||||
test('parseAssEffectField classifies the event-level Effect column', () => {
|
||||
assert.equal(parseAssEffectField(''), 'none');
|
||||
assert.equal(parseAssEffectField(' '), 'none');
|
||||
assert.equal(parseAssEffectField('Banner;20;1;0'), 'banner');
|
||||
assert.equal(parseAssEffectField('Scroll up;0;0;30;10'), 'scroll');
|
||||
assert.equal(parseAssEffectField('Scroll down;0;0;30;10'), 'scroll');
|
||||
assert.equal(parseAssEffectField('Karaoke'), 'karaoke');
|
||||
assert.equal(parseAssEffectField('fx-template'), 'other');
|
||||
});
|
||||
|
||||
test('parseAssEffectField matches stock effect names exactly', () => {
|
||||
// Custom effect names that merely start with a stock name are not stock effects.
|
||||
assert.equal(parseAssEffectField('scrolling-credit'), 'other');
|
||||
assert.equal(parseAssEffectField('bannerfx;1'), 'other');
|
||||
assert.equal(parseAssEffectField('karaoke-template'), 'other');
|
||||
assert.equal(parseAssEffectField('Scroll'), 'other');
|
||||
});
|
||||
|
||||
test('isAnimatedAssEffectKind covers the stock animated effects only', () => {
|
||||
assert.equal(isAnimatedAssEffectKind('karaoke'), true);
|
||||
assert.equal(isAnimatedAssEffectKind('banner'), true);
|
||||
assert.equal(isAnimatedAssEffectKind('scroll'), true);
|
||||
// Typesetting groups put static template names in this column too.
|
||||
assert.equal(isAnimatedAssEffectKind('other'), false);
|
||||
assert.equal(isAnimatedAssEffectKind('none'), false);
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* ASS/SSA text handling, split into two deliberately distinct contracts:
|
||||
*
|
||||
* assToPlainText() raw ASS event text -> plain text. Ingestion only.
|
||||
* normalizePlainSubtitleText() already-decoded text -> display/lookup form.
|
||||
*
|
||||
* Subtitle text is decoded from ASS exactly once, at the point it enters the app: the
|
||||
* file cue parser does it for sidecar/embedded scripts, and mpv does it for live text
|
||||
* (`sub-text` is already run through mpv's own `ass_to_plaintext`). Everything
|
||||
* downstream -- renderer, timing tracker, tokenizer, tokenization cache keys -- gets
|
||||
* plain text and only normalizes whitespace, so no layer decodes the same string twice.
|
||||
*
|
||||
* assToPlainText mirrors mpv's `ass_to_plaintext` rather than inventing its own rules,
|
||||
* so a cue parsed from a file reads the same as the same line arriving live:
|
||||
* - `{...}` override blocks are markup
|
||||
* - `\pN ... \p0` runs are vector paths, not dialogue
|
||||
* - `\N`, `\n` and `\h` are the only escapes; `\{`, `\}` and `\\` are NOT escapes,
|
||||
* so `\{注\}` decodes to a lone backslash exactly as mpv renders it
|
||||
* - an unclosed `{` is rendered verbatim instead of swallowing the rest of the line
|
||||
* Because the decoder never emits an escape or a closed brace, running it twice is a
|
||||
* no-op -- but downstream code should still use normalizePlainSubtitleText.
|
||||
*/
|
||||
|
||||
/** What `\N` and `\n` become. */
|
||||
export type AssLineBreak = '\n' | ' ';
|
||||
|
||||
// `\p<n>` with n > 0 switches libass into vector-drawing mode: everything until the
|
||||
// next `\p0` is a path (`m 20 0 b 10 0 ...`), not dialogue. The negative lookahead keeps
|
||||
// `\pos(...)` from being read as a drawing tag.
|
||||
const ASS_DRAWING_SCALE_PATTERN = /\\p(?![a-zA-Z])(\d*)/g;
|
||||
|
||||
function readDrawingScale(block: string): number | null {
|
||||
ASS_DRAWING_SCALE_PATTERN.lastIndex = 0;
|
||||
let scale: number | null = null;
|
||||
let match: RegExpExecArray | null;
|
||||
// Drawing mode is whatever the last `\p` tag in this block set it to.
|
||||
while ((match = ASS_DRAWING_SCALE_PATTERN.exec(block)) !== null) {
|
||||
scale = match[1] ? Number(match[1]) : 0;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
/** Resolve `\N`, `\n` and `\h`. The only text-level escapes libass recognises. */
|
||||
function resolveWhitespaceEscapes(text: string, lineBreak: AssLineBreak): string {
|
||||
return text.replace(/\\([Nnh])/g, (_match, escaped: string) =>
|
||||
escaped === 'h' ? ' ' : lineBreak,
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip `{...}` override blocks and the drawing runs they enable. */
|
||||
function stripAssMarkup(raw: string): string {
|
||||
let out = '';
|
||||
let cursor = 0;
|
||||
let drawing = false;
|
||||
|
||||
while (cursor < raw.length) {
|
||||
if (raw[cursor] !== '{') {
|
||||
if (!drawing) {
|
||||
out += raw[cursor];
|
||||
}
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const close = raw.indexOf('}', cursor + 1);
|
||||
if (close === -1) {
|
||||
// mpv shows an unclosed `{` and everything after it. Guessing where the block was
|
||||
// meant to end can eat a whole line of dialogue.
|
||||
if (!drawing) {
|
||||
out += raw.slice(cursor);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const scale = readDrawingScale(raw.slice(cursor, close + 1));
|
||||
if (scale !== null) {
|
||||
drawing = scale > 0;
|
||||
}
|
||||
cursor = close + 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a raw ASS/SSA event text field. Call this once, where the text enters the app;
|
||||
* downstream layers take the result as plain text.
|
||||
*/
|
||||
export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): string {
|
||||
if (!text) return '';
|
||||
return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak);
|
||||
}
|
||||
|
||||
export interface NormalizePlainSubtitleTextOptions {
|
||||
/** Fold every line break into a single space. */
|
||||
collapseLineBreaks?: boolean;
|
||||
trim?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitespace normalization for text that has already been decoded -- by mpv for live
|
||||
* subtitles, by the cue parser for files. Override blocks and drawing runs are none of
|
||||
* this function's business; a `{` that reaches here is literal text mpv chose to show.
|
||||
*
|
||||
* `\N`/`\n`/`\h` are still folded, because subtitle sources outside the ASS path (asbplayer
|
||||
* and other websocket clients) forward them raw and the display layer has to cope.
|
||||
*/
|
||||
export function normalizePlainSubtitleText(
|
||||
text: string,
|
||||
options: NormalizePlainSubtitleTextOptions = {},
|
||||
): string {
|
||||
if (!text) return '';
|
||||
const { collapseLineBreaks = false, trim = true } = options;
|
||||
|
||||
let normalized = resolveWhitespaceEscapes(
|
||||
text.replace(/\r\n/g, '\n'),
|
||||
collapseLineBreaks ? ' ' : '\n',
|
||||
);
|
||||
if (collapseLineBreaks) {
|
||||
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
return trim ? normalized.trim() : normalized;
|
||||
}
|
||||
|
||||
/** The contents of each `{...}` block, without the braces. */
|
||||
export function extractAssOverrideBlocks(text: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const open = text.indexOf('{', cursor);
|
||||
if (open === -1) {
|
||||
break;
|
||||
}
|
||||
const close = text.indexOf('}', open + 1);
|
||||
if (close === -1) {
|
||||
break;
|
||||
}
|
||||
blocks.push(text.slice(open + 1, close));
|
||||
cursor = close + 1;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export interface AssOverrideCommand {
|
||||
/** Tag name without the backslash, e.g. `pos`, `kf`, `1c`. */
|
||||
name: string;
|
||||
/** Everything the tag was given, e.g. `960,1068` for `\pos(960,1068)`. */
|
||||
args: string;
|
||||
/** Nested inside a `\t(...)` argument, so its value is animated over the event. */
|
||||
animated: boolean;
|
||||
}
|
||||
|
||||
const ASS_OVERRIDE_NAME_PATTERN = /[1-4]?[a-zA-Z]+/y;
|
||||
|
||||
function readCommandArgs(block: string, start: number): { args: string; next: number } {
|
||||
if (block[start] === '(') {
|
||||
let depth = 0;
|
||||
for (let i = start; i < block.length; i += 1) {
|
||||
if (block[i] === '(') depth += 1;
|
||||
else if (block[i] === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return { args: block.slice(start + 1, i), next: i + 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { args: block.slice(start + 1), next: block.length };
|
||||
}
|
||||
|
||||
const nextTag = block.indexOf('\\', start);
|
||||
const end = nextTag === -1 ? block.length : nextTag;
|
||||
return { args: block.slice(start, end), next: end };
|
||||
}
|
||||
|
||||
// `\t(...)` can wrap another `\t(...)`, and nothing in the format stops an author (or a
|
||||
// malformed file) from nesting them thousands deep. Real typesetting never goes past one
|
||||
// or two levels, so stop recursing well before the call stack is at risk.
|
||||
const MAX_ANIMATION_NESTING_DEPTH = 8;
|
||||
|
||||
function parseOverrideBlock(
|
||||
block: string,
|
||||
animated: boolean,
|
||||
into: AssOverrideCommand[],
|
||||
depth = 0,
|
||||
): void {
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < block.length) {
|
||||
if (block[cursor] !== '\\') {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
ASS_OVERRIDE_NAME_PATTERN.lastIndex = cursor + 1;
|
||||
const nameMatch = ASS_OVERRIDE_NAME_PATTERN.exec(block);
|
||||
if (!nameMatch) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = nameMatch[0];
|
||||
const { args, next } = readCommandArgs(block, cursor + 1 + name.length);
|
||||
into.push({ name, args: args.trim(), animated });
|
||||
// `\t(0,500,\frz30)` animates whatever it wraps, so record the inner tags too.
|
||||
if (name === 't' && args.includes('\\') && depth < MAX_ANIMATION_NESTING_DEPTH) {
|
||||
parseOverrideBlock(args, true, into, depth + 1);
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override commands with their arguments, in source order. Only `{...}` blocks are
|
||||
* inspected, so a `\pos(...)` sitting in visible text is never mistaken for markup.
|
||||
*/
|
||||
export function collectAssOverrideCommands(text: string): AssOverrideCommand[] {
|
||||
const commands: AssOverrideCommand[] = [];
|
||||
for (const block of extractAssOverrideBlocks(text)) {
|
||||
parseOverrideBlock(block, false, commands);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
// Tags that are animated by definition: `\t` interpolates, `\move` travels, and the
|
||||
// karaoke tags advance a highlight across the event's own duration. Everything else --
|
||||
// `\pos`, `\clip`, `\frz`, `\blur`, `\fad` -- is a static value for the event, so its
|
||||
// presence says nothing about whether neighbouring events form one animation.
|
||||
const ASS_TEMPORAL_COMMANDS = new Set(['t', 'move', 'k', 'kf', 'ko', 'K']);
|
||||
|
||||
export function isAssTemporalCommand(name: string): boolean {
|
||||
return ASS_TEMPORAL_COMMANDS.has(name);
|
||||
}
|
||||
|
||||
/** True when the event animates on its own, or animates a static tag through `\t(...)`. */
|
||||
export function hasAssTemporalOverride(commands: readonly AssOverrideCommand[]): boolean {
|
||||
return commands.some((command) => command.animated || isAssTemporalCommand(command.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form of an event's override values, for comparing consecutive events. Two
|
||||
* events with the same signature were typeset identically, so neither is a frame of an
|
||||
* animation the other belongs to.
|
||||
*/
|
||||
export function assOverrideSignature(commands: readonly AssOverrideCommand[]): string {
|
||||
return commands.map((command) => `${command.name}(${command.args})`).join('|');
|
||||
}
|
||||
|
||||
export type AssEffectKind = 'none' | 'banner' | 'scroll' | 'karaoke' | 'other';
|
||||
|
||||
// The stock effects, matched exactly. Typesetting groups put their own template names in
|
||||
// this column -- `scrolling-credit` is a static sign, not libass's `Scroll up` -- so a
|
||||
// prefix match would hand out animation evidence to arbitrary custom effects.
|
||||
const STOCK_ASS_EFFECTS = new Map<string, AssEffectKind>([
|
||||
['banner', 'banner'],
|
||||
['scroll up', 'scroll'],
|
||||
['scroll down', 'scroll'],
|
||||
['karaoke', 'karaoke'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* The event-level `Effect` column. The stock values (`Banner;...`, `Scroll up;...`,
|
||||
* `Scroll down;...`, `Karaoke`) all animate; anything else is a custom name and lands in
|
||||
* `other`.
|
||||
*/
|
||||
export function parseAssEffectField(raw: string): AssEffectKind {
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (!value) return 'none';
|
||||
|
||||
const name = value.split(';', 1)[0]!.trim();
|
||||
return STOCK_ASS_EFFECTS.get(name) ?? 'other';
|
||||
}
|
||||
|
||||
const ANIMATED_ASS_EFFECT_KINDS = new Set<AssEffectKind>(['banner', 'scroll', 'karaoke']);
|
||||
|
||||
export function isAnimatedAssEffectKind(kind: AssEffectKind): boolean {
|
||||
return ANIMATED_ASS_EFFECT_KINDS.has(kind);
|
||||
}
|
||||
@@ -93,18 +93,9 @@ import {
|
||||
} from './immersion-tracker/query-maintenance';
|
||||
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
|
||||
import {
|
||||
dismissAnimeMergeRecommendation,
|
||||
getAnimeMergeRecommendations,
|
||||
repairLegacySeasonlessAnimeRows,
|
||||
resolveAnimeAnilistConflict,
|
||||
type AnimeMergeRecommendation,
|
||||
} from './immersion-tracker/anime-season-repair';
|
||||
import {
|
||||
mergeAnimeRecords,
|
||||
moveVideoToAnime as moveVideoToAnimeQuery,
|
||||
type AnimeMergeSummary,
|
||||
type VideoMoveSummary,
|
||||
} from './immersion-tracker/anime-merge';
|
||||
import {
|
||||
buildVideoKey,
|
||||
deriveCanonicalTitle,
|
||||
@@ -605,7 +596,8 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
|
||||
async rebuildLifetimeSummaries(): Promise<LifetimeRebuildSummary> {
|
||||
this.requireWriteQueueDrained('rebuilding lifetime summaries');
|
||||
this.flushTelemetry(true);
|
||||
this.flushNow();
|
||||
return rebuildLifetimeSummaryTables(this.db);
|
||||
}
|
||||
|
||||
@@ -672,14 +664,6 @@ export class ImmersionTrackerService {
|
||||
return getAnimeLibrary(this.db);
|
||||
}
|
||||
|
||||
async getAnimeMergeRecommendations(): Promise<AnimeMergeRecommendation[]> {
|
||||
return getAnimeMergeRecommendations(this.db);
|
||||
}
|
||||
|
||||
async dismissAnimeMergeRecommendation(recommendationId: number): Promise<boolean> {
|
||||
return dismissAnimeMergeRecommendation(this.db, recommendationId);
|
||||
}
|
||||
|
||||
async getAnimeDetail(animeId: number): Promise<AnimeDetailRow | null> {
|
||||
this.relinkYoutubeAnimeLibrary();
|
||||
return getAnimeDetail(this.db, animeId);
|
||||
@@ -772,63 +756,6 @@ export class ImmersionTrackerService {
|
||||
deleteAnimeQuery(this.db, animeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold duplicate library entries into one. Sources that hold the currently
|
||||
* playing episode are fine: the videos move, nothing is deleted out from
|
||||
* under the active session.
|
||||
*/
|
||||
async mergeAnime(targetAnimeId: number, sourceAnimeIds: number[]): Promise<AnimeMergeSummary> {
|
||||
const pendingVideoId = this.sessionState?.videoId;
|
||||
if (pendingVideoId !== undefined) {
|
||||
await this.pendingAnimeMetadataUpdates.get(pendingVideoId);
|
||||
}
|
||||
// This rebuilds the lifetime summaries, which recompute from the database:
|
||||
// queued writes have to land first or the active session is dropped from
|
||||
// the merged totals.
|
||||
this.requireWriteQueueDrained('merging library entries');
|
||||
return mergeAnimeRecords(this.db, targetAnimeId, sourceAnimeIds);
|
||||
}
|
||||
|
||||
async moveVideoToAnime(videoId: number, targetAnimeId: number): Promise<VideoMoveSummary> {
|
||||
await this.pendingAnimeMetadataUpdates.get(videoId);
|
||||
this.requireWriteQueueDrained('moving an episode');
|
||||
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. Summary-rebuilding
|
||||
* callers fail closed in that case.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
private requireWriteQueueDrained(context: string): void {
|
||||
if (!this.drainWriteQueue(context)) {
|
||||
throw new Error(`Immersion tracker queue did not drain before ${context}`);
|
||||
}
|
||||
}
|
||||
|
||||
async reassignAnimeAnilist(
|
||||
animeId: number,
|
||||
info: {
|
||||
@@ -841,13 +768,7 @@ export class ImmersionTrackerService {
|
||||
coverUrl?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
// The user is acting on this entry, so it is the one that survives when
|
||||
// another row already claims the same AniList id.
|
||||
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, {
|
||||
survivor: 'target',
|
||||
matchConfidence: 'manual',
|
||||
});
|
||||
if (repair.anilistAssignmentBlocked) return;
|
||||
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId);
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
|
||||
@@ -1,700 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { Database } from '../sqlite.js';
|
||||
import type { DatabaseSync } from '../sqlite.js';
|
||||
import { applyPragmas, ensureSchema, getOrCreateAnimeRecord } from '../storage.js';
|
||||
import { mergeAnimeRecords, moveVideoToAnime } from '../anime-merge.js';
|
||||
import {
|
||||
dismissAnimeMergeRecommendation,
|
||||
getAnimeMergeRecommendations,
|
||||
resolveAnimeAnilistConflict,
|
||||
} from '../anime-season-repair.js';
|
||||
import { updateAnimeAnilistInfo } from '../query-maintenance.js';
|
||||
|
||||
const BASE_MS = 1_700_000_000_000;
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anime-merge-test-'));
|
||||
return path.join(dir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
function cleanupDbPath(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) return;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function withDb(work: (db: DatabaseSync) => void): void {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
work(db);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
}
|
||||
|
||||
interface AnimeSeed {
|
||||
animeId: number;
|
||||
key: string;
|
||||
title: string;
|
||||
anilistId?: number | null;
|
||||
titleRomaji?: string | null;
|
||||
}
|
||||
|
||||
function insertAnime(db: DatabaseSync, seed: AnimeSeed): void {
|
||||
db.prepare(
|
||||
`INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, anilist_id, title_romaji, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
seed.animeId,
|
||||
seed.key,
|
||||
seed.title,
|
||||
seed.anilistId ?? null,
|
||||
seed.titleRomaji ?? null,
|
||||
BASE_MS,
|
||||
BASE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
interface EpisodeSeed {
|
||||
videoId: number;
|
||||
animeId: number;
|
||||
season?: number | null;
|
||||
episode?: number;
|
||||
activeMs?: number;
|
||||
cards?: number;
|
||||
}
|
||||
|
||||
/** One episode with one ended session, so lifetime rebuilds have something to sum. */
|
||||
function insertEpisode(db: DatabaseSync, seed: EpisodeSeed): void {
|
||||
const activeMs = seed.activeMs ?? 1000;
|
||||
const cards = seed.cards ?? 1;
|
||||
db.prepare(
|
||||
`INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, parsed_title, parsed_season, parsed_episode, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?, 1, 'Show', ?, ?, 1, 1440000, ?, ?)`,
|
||||
).run(
|
||||
seed.videoId,
|
||||
`local:/tmp/show-${seed.videoId}.mkv`,
|
||||
seed.animeId,
|
||||
`Show ${seed.videoId}`,
|
||||
seed.season ?? null,
|
||||
seed.episode ?? seed.videoId,
|
||||
BASE_MS,
|
||||
BASE_MS,
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms, cards_mined, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?, ?, 2, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
seed.videoId,
|
||||
`session-${seed.videoId}`,
|
||||
seed.videoId,
|
||||
String(BASE_MS),
|
||||
String(BASE_MS + activeMs),
|
||||
activeMs,
|
||||
cards,
|
||||
BASE_MS,
|
||||
BASE_MS,
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_subtitle_lines(session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?)`,
|
||||
).run(seed.videoId, seed.videoId, seed.animeId, `line ${seed.videoId}`, BASE_MS, BASE_MS);
|
||||
}
|
||||
|
||||
function animeIds(db: DatabaseSync): number[] {
|
||||
return (
|
||||
db.prepare('SELECT anime_id AS id FROM imm_anime ORDER BY anime_id').all() as Array<{
|
||||
id: number;
|
||||
}>
|
||||
).map((row) => row.id);
|
||||
}
|
||||
|
||||
function videoAnimeId(db: DatabaseSync, videoId: number): number | null {
|
||||
return (
|
||||
db.prepare('SELECT anime_id AS id FROM imm_videos WHERE video_id = ?').get(videoId) as {
|
||||
id: number | null;
|
||||
}
|
||||
).id;
|
||||
}
|
||||
|
||||
function lineAnimeIds(db: DatabaseSync, animeId: number): number {
|
||||
return Number(
|
||||
(
|
||||
db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
|
||||
.get(animeId) as { total: number }
|
||||
).total,
|
||||
);
|
||||
}
|
||||
|
||||
test('mergeAnimeRecords folds episodes, lines and lifetime totals into the target', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1', anilistId: 555 });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, activeMs: 1000, cards: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1, activeMs: 2000, cards: 3 });
|
||||
|
||||
const summary = mergeAnimeRecords(db, 1, [2]);
|
||||
|
||||
assert.equal(summary.survivingAnimeId, 1);
|
||||
assert.deepEqual(summary.mergedAnimeIds, [2]);
|
||||
assert.equal(summary.movedVideos, 1);
|
||||
assert.deepEqual(animeIds(db), [1]);
|
||||
assert.equal(videoAnimeId(db, 2), 1);
|
||||
assert.equal(lineAnimeIds(db, 1), 2);
|
||||
|
||||
const lifetime = db
|
||||
.prepare(
|
||||
'SELECT total_active_ms AS activeMs, total_cards AS cards, episodes_started AS episodes FROM imm_lifetime_anime WHERE anime_id = 1',
|
||||
)
|
||||
.get() as { activeMs: number; cards: number; episodes: number };
|
||||
assert.equal(lifetime.activeMs, 3000);
|
||||
assert.equal(lifetime.cards, 4);
|
||||
assert.equal(lifetime.episodes, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('mergeAnimeRecords repoints subtitle lines recorded before the anime link landed', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
// Lines are written with the video's anime_id at the time, which is NULL
|
||||
// until the async title parse assigns one.
|
||||
db.prepare(
|
||||
`INSERT INTO imm_subtitle_lines(session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (2, 2, NULL, 2, 'unlinked line', ?, ?)`,
|
||||
).run(BASE_MS, BASE_MS);
|
||||
|
||||
mergeAnimeRecords(db, 1, [2]);
|
||||
|
||||
assert.equal(lineAnimeIds(db, 1), 3);
|
||||
const orphaned = Number(
|
||||
(
|
||||
db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id IS NULL')
|
||||
.get() as { total: number }
|
||||
).total,
|
||||
);
|
||||
assert.equal(orphaned, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('mergeAnimeRecords inherits metadata the target is missing without clobbering its own', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', titleRomaji: 'Shou' });
|
||||
insertAnime(db, {
|
||||
animeId: 2,
|
||||
key: 'show season 1',
|
||||
title: 'Show Season 1',
|
||||
anilistId: 555,
|
||||
titleRomaji: 'Show Romaji',
|
||||
});
|
||||
insertEpisode(db, { videoId: 1, animeId: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
|
||||
mergeAnimeRecords(db, 1, [2]);
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT canonical_title AS title, anilist_id AS anilistId, title_romaji AS romaji FROM imm_anime WHERE anime_id = 1',
|
||||
)
|
||||
.get() as { title: string; anilistId: number | null; romaji: string | null };
|
||||
assert.equal(row.title, 'Show');
|
||||
// anilist_id is UNIQUE, so inheriting it proves the source row was gone first.
|
||||
assert.equal(row.anilistId, 555);
|
||||
assert.equal(row.romaji, 'Shou');
|
||||
});
|
||||
});
|
||||
|
||||
test('mergeAnimeRecords preserves source title identities as aliases of the survivor', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES ('show s01', 2, ?, ?)`,
|
||||
).run(BASE_MS, BASE_MS);
|
||||
|
||||
mergeAnimeRecords(db, 1, [2]);
|
||||
|
||||
const fromSourceTitle = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Show Season 1',
|
||||
canonicalTitle: 'Show Season 1',
|
||||
seasonScope: 1,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
const fromTransferredAlias = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Show S01',
|
||||
canonicalTitle: 'Show S01',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
|
||||
assert.equal(fromSourceTitle, 1);
|
||||
assert.equal(fromTransferredAlias, 1);
|
||||
assert.deepEqual(animeIds(db), [1]);
|
||||
assert.equal(
|
||||
(
|
||||
db.prepare('SELECT canonical_title AS title FROM imm_anime WHERE anime_id = 1').get() as {
|
||||
title: string;
|
||||
}
|
||||
).title,
|
||||
'Show',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('mergeAnimeRecords ignores unknown targets and self-merges', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1 });
|
||||
|
||||
assert.deepEqual(mergeAnimeRecords(db, 99, [1]).mergedAnimeIds, []);
|
||||
assert.deepEqual(mergeAnimeRecords(db, 1, [1]).mergedAnimeIds, []);
|
||||
assert.deepEqual(animeIds(db), [1]);
|
||||
assert.equal(videoAnimeId(db, 1), 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('moveVideoToAnime moves one episode and prunes the emptied entry', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
insertAnime(db, { animeId: 2, key: 'stray', title: 'Stray Episode Title', anilistId: 777 });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, activeMs: 5000, cards: 2 });
|
||||
|
||||
const summary = moveVideoToAnime(db, 2, 1);
|
||||
|
||||
assert.equal(summary.targetAnimeId, 1);
|
||||
assert.equal(summary.previousAnimeId, 2);
|
||||
assert.equal(summary.removedPreviousAnime, true);
|
||||
assert.deepEqual(animeIds(db), [1]);
|
||||
assert.equal(videoAnimeId(db, 2), 1);
|
||||
assert.equal(lineAnimeIds(db, 1), 2);
|
||||
const lifetime = db
|
||||
.prepare('SELECT total_active_ms AS activeMs FROM imm_lifetime_anime WHERE anime_id = 1')
|
||||
.get() as { activeMs: number };
|
||||
assert.equal(lifetime.activeMs, 6000);
|
||||
// The stray entry's AniList link is dropped, not inherited: a move makes no
|
||||
// claim that the two entries are the same show.
|
||||
const target = db
|
||||
.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 1')
|
||||
.get() as { anilistId: number | null };
|
||||
assert.equal(target.anilistId, null);
|
||||
});
|
||||
});
|
||||
|
||||
test('moveVideoToAnime is a no-op when the episode is already in the target entry', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1 });
|
||||
|
||||
const summary = moveVideoToAnime(db, 1, 1);
|
||||
|
||||
assert.equal(summary.targetAnimeId, 1);
|
||||
assert.equal(summary.previousAnimeId, 1);
|
||||
assert.equal(summary.removedPreviousAnime, false);
|
||||
assert.deepEqual(animeIds(db), [1]);
|
||||
assert.equal(videoAnimeId(db, 1), 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('moveVideoToAnime keeps the source entry when other episodes remain', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
insertAnime(db, { animeId: 2, key: 'other', title: 'Other' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 2 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2 });
|
||||
|
||||
const summary = moveVideoToAnime(db, 2, 1);
|
||||
|
||||
assert.equal(summary.removedPreviousAnime, false);
|
||||
assert.deepEqual(animeIds(db), [1, 2]);
|
||||
assert.equal(videoAnimeId(db, 1), 2);
|
||||
assert.equal(videoAnimeId(db, 2), 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('moveVideoToAnime rejects unknown episodes and targets', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1 });
|
||||
|
||||
assert.throws(() => moveVideoToAnime(db, 99, 1));
|
||||
assert.throws(() => moveVideoToAnime(db, 1, 99));
|
||||
assert.equal(videoAnimeId(db, 1), 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveAnimeAnilistConflict folds a seasonless duplicate into the entry that owns the id', () => {
|
||||
withDb((db) => {
|
||||
// Same show, split because one release tagged S01 and the other did not.
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
|
||||
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
|
||||
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
|
||||
|
||||
assert.equal(summary.survivingAnimeId, 1);
|
||||
assert.equal(summary.movedVideos, 1);
|
||||
assert.equal(summary.deletedAnimeRows, 1);
|
||||
assert.deepEqual(animeIds(db), [1]);
|
||||
assert.equal(videoAnimeId(db, 2), 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveAnimeAnilistConflict recommends a weak title collision instead of merging it', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, {
|
||||
animeId: 1,
|
||||
key: 'actual show',
|
||||
title: 'Actual Show',
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Actual Show',
|
||||
});
|
||||
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
|
||||
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
|
||||
|
||||
assert.equal(summary.repaired, 0);
|
||||
assert.deepEqual(animeIds(db), [1, 2]);
|
||||
assert.equal(videoAnimeId(db, 2), 2);
|
||||
assert.deepEqual(getAnimeMergeRecommendations(db), [{ recommendationId: 1, animeIds: [1, 2] }]);
|
||||
});
|
||||
});
|
||||
|
||||
test('automatic AniList update leaves a weak collision unassigned for user review', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, {
|
||||
animeId: 1,
|
||||
key: 'actual show',
|
||||
title: 'Actual Show',
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Actual Show',
|
||||
});
|
||||
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
|
||||
updateAnimeAnilistInfo(db, 2, {
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Actual Show',
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
episodesTotal: 12,
|
||||
exactTitleMatch: false,
|
||||
});
|
||||
|
||||
const target = db
|
||||
.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2')
|
||||
.get() as {
|
||||
anilistId: number | null;
|
||||
};
|
||||
assert.equal(target.anilistId, null);
|
||||
assert.deepEqual(getAnimeMergeRecommendations(db), [{ recommendationId: 1, animeIds: [1, 2] }]);
|
||||
});
|
||||
});
|
||||
|
||||
test('dismissed weak collision stays dismissed when automatic resolution repeats', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, {
|
||||
animeId: 1,
|
||||
key: 'actual show',
|
||||
title: 'Actual Show',
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Actual Show',
|
||||
});
|
||||
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
|
||||
resolveAnimeAnilistConflict(db, 2, 163132);
|
||||
assert.equal(dismissAnimeMergeRecommendation(db, 1), true);
|
||||
resolveAnimeAnilistConflict(db, 2, 163132);
|
||||
|
||||
assert.deepEqual(getAnimeMergeRecommendations(db), []);
|
||||
});
|
||||
});
|
||||
|
||||
test('dismissed recommendation prevents a later exact automatic merge of the pair', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, {
|
||||
animeId: 1,
|
||||
key: 'actual show',
|
||||
title: 'Actual Show',
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Actual Show',
|
||||
});
|
||||
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
|
||||
resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'weak' });
|
||||
assert.equal(dismissAnimeMergeRecommendation(db, 1), true);
|
||||
|
||||
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'exact' });
|
||||
|
||||
assert.equal(summary.repaired, 0);
|
||||
assert.deepEqual(animeIds(db), [1, 2]);
|
||||
assert.equal(videoAnimeId(db, 2), 2);
|
||||
assert.deepEqual(getAnimeMergeRecommendations(db), []);
|
||||
});
|
||||
});
|
||||
|
||||
test('manual merge clears recommendations involving the absorbed entry', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, {
|
||||
animeId: 1,
|
||||
key: 'actual show',
|
||||
title: 'Actual Show',
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Actual Show',
|
||||
});
|
||||
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
|
||||
resolveAnimeAnilistConflict(db, 2, 163132);
|
||||
mergeAnimeRecords(db, 1, [2]);
|
||||
|
||||
assert.deepEqual(getAnimeMergeRecommendations(db), []);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveAnimeAnilistConflict keeps the target entry when the user drove the change', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
|
||||
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
|
||||
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { survivor: 'target' });
|
||||
|
||||
assert.equal(summary.survivingAnimeId, 2);
|
||||
assert.deepEqual(animeIds(db), [2]);
|
||||
assert.equal(videoAnimeId(db, 1), 2);
|
||||
const row = db.prepare('SELECT anilist_id AS id FROM imm_anime WHERE anime_id = 2').get() as {
|
||||
id: number | null;
|
||||
};
|
||||
assert.equal(row.id, 163132);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveAnimeAnilistConflict falls back to season redistribution for multi-season rows', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
|
||||
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 1, season: 2 });
|
||||
insertEpisode(db, { videoId: 3, animeId: 2, season: 1 });
|
||||
|
||||
resolveAnimeAnilistConflict(db, 2, 163132);
|
||||
|
||||
// The mixed row is split by season instead of being poured onto one card.
|
||||
const titles = (
|
||||
db.prepare('SELECT canonical_title AS title FROM imm_anime ORDER BY title').all() as Array<{
|
||||
title: string;
|
||||
}>
|
||||
).map((row) => row.title);
|
||||
assert.deepEqual(titles, ['Show Season 1', 'Show Season 2']);
|
||||
assert.equal(videoAnimeId(db, 1), 2);
|
||||
assert.equal(videoAnimeId(db, 3), 2);
|
||||
assert.notEqual(videoAnimeId(db, 2), 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveAnimeAnilistConflict leaves explicit incompatible seasons and assignments unchanged', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, {
|
||||
animeId: 1,
|
||||
key: 'show season 1',
|
||||
title: 'Show Season 1',
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Show',
|
||||
});
|
||||
insertAnime(db, { animeId: 2, key: 'show season 2', title: 'Show Season 2' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
|
||||
|
||||
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'exact' });
|
||||
|
||||
assert.equal(summary.repaired, 0);
|
||||
assert.equal(summary.movedVideos, 0);
|
||||
assert.equal(summary.deletedAnimeRows, 0);
|
||||
assert.deepEqual(animeIds(db), [1, 2]);
|
||||
assert.equal(videoAnimeId(db, 1), 1);
|
||||
assert.equal(videoAnimeId(db, 2), 2);
|
||||
const assignments = db
|
||||
.prepare(
|
||||
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
|
||||
)
|
||||
.all() as Array<{ animeId: number; anilistId: number | null }>;
|
||||
assert.deepEqual(assignments, [
|
||||
{ animeId: 1, anilistId: 163132 },
|
||||
{ animeId: 2, anilistId: null },
|
||||
]);
|
||||
assert.deepEqual(getAnimeMergeRecommendations(db), []);
|
||||
});
|
||||
});
|
||||
|
||||
test('manual AniList resolution reassigns across explicit seasons without merging them', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, {
|
||||
animeId: 1,
|
||||
key: 'show season 1',
|
||||
title: 'Show Season 1',
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Show',
|
||||
});
|
||||
insertAnime(db, { animeId: 2, key: 'show season 2', title: 'Show Season 2' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
|
||||
|
||||
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { survivor: 'target' });
|
||||
|
||||
assert.equal(summary.anilistAssignmentBlocked, false);
|
||||
assert.deepEqual(animeIds(db), [1, 2]);
|
||||
const assignments = db
|
||||
.prepare(
|
||||
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
|
||||
)
|
||||
.all() as Array<{ animeId: number; anilistId: number | null }>;
|
||||
assert.deepEqual(assignments, [
|
||||
{ animeId: 1, anilistId: null },
|
||||
{ animeId: 2, anilistId: 163132 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('automatic AniList update does not transfer an assignment across explicit seasons', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, {
|
||||
animeId: 1,
|
||||
key: 'show season 1',
|
||||
title: 'Show Season 1',
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Show',
|
||||
});
|
||||
insertAnime(db, { animeId: 2, key: 'show season 2', title: 'Show Season 2' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
|
||||
|
||||
updateAnimeAnilistInfo(db, 2, {
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Show',
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
episodesTotal: 12,
|
||||
exactTitleMatch: true,
|
||||
});
|
||||
|
||||
const assignments = db
|
||||
.prepare(
|
||||
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
|
||||
)
|
||||
.all() as Array<{ animeId: number; anilistId: number | null }>;
|
||||
assert.deepEqual(assignments, [
|
||||
{ animeId: 1, anilistId: 163132 },
|
||||
{ animeId: 2, anilistId: null },
|
||||
]);
|
||||
assert.equal(videoAnimeId(db, 1), 1);
|
||||
assert.equal(videoAnimeId(db, 2), 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('automatic AniList update with unknown match confidence validates stored titles', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, {
|
||||
animeId: 1,
|
||||
key: 'actual show',
|
||||
title: 'Actual Show',
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Actual Show',
|
||||
});
|
||||
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
|
||||
updateAnimeAnilistInfo(db, 2, {
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Actual Show',
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
episodesTotal: 12,
|
||||
});
|
||||
|
||||
assert.deepEqual(animeIds(db), [1, 2]);
|
||||
assert.equal(videoAnimeId(db, 2), 2);
|
||||
assert.deepEqual(getAnimeMergeRecommendations(db), [{ recommendationId: 1, animeIds: [1, 2] }]);
|
||||
});
|
||||
});
|
||||
|
||||
test('stored AniList titles ignore season suffixes when validating an automatic merge', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, {
|
||||
animeId: 1,
|
||||
key: 'legacy show',
|
||||
title: 'Show Season 1',
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Show Season 1',
|
||||
});
|
||||
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
|
||||
|
||||
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
|
||||
|
||||
assert.equal(summary.deletedAnimeRows, 1);
|
||||
assert.deepEqual(animeIds(db), [1]);
|
||||
assert.equal(videoAnimeId(db, 2), 1);
|
||||
assert.deepEqual(getAnimeMergeRecommendations(db), []);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveAnimeAnilistConflict leaves an entry that already links elsewhere alone', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
|
||||
insertAnime(db, { animeId: 2, key: 'show s2', title: 'Show Season 2', anilistId: 999 });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
|
||||
|
||||
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
|
||||
|
||||
assert.equal(videoAnimeId(db, 2), 2);
|
||||
assert.ok(animeIds(db).includes(2));
|
||||
assert.equal(
|
||||
(
|
||||
db.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2').get() as {
|
||||
anilistId: number;
|
||||
}
|
||||
).anilistId,
|
||||
999,
|
||||
);
|
||||
assert.equal(summary.repaired, 0);
|
||||
assert.equal(summary.movedVideos, 0);
|
||||
assert.deepEqual(getAnimeMergeRecommendations(db), []);
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { animeSeasonsAreMergeCompatible, getParsedSeasonsForAnime } from './anime-merge';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
import { normalizeAnimeIdentityKey } from './storage';
|
||||
import { nowMs } from './time';
|
||||
|
||||
export interface AnimeMergeRecommendation {
|
||||
recommendationId: number;
|
||||
animeIds: [number, number];
|
||||
}
|
||||
|
||||
export interface AnimeConflictRecommendationOptions {
|
||||
survivor?: 'target' | 'existing';
|
||||
/** Automatic matches must be exact; manual assignment is authoritative. */
|
||||
matchConfidence?: 'exact' | 'weak' | 'manual';
|
||||
}
|
||||
|
||||
interface AnimeTitleRow {
|
||||
canonical_title: string;
|
||||
title_romaji: string | null;
|
||||
title_english: string | null;
|
||||
title_native: string | null;
|
||||
}
|
||||
|
||||
function getAnimeTitles(db: DatabaseSync, animeId: number): AnimeTitleRow | null {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT canonical_title, title_romaji, title_english, title_native
|
||||
FROM imm_anime
|
||||
WHERE anime_id = ?`,
|
||||
)
|
||||
.get(animeId) as AnimeTitleRow | null;
|
||||
}
|
||||
|
||||
function getParsedTitles(db: DatabaseSync, animeId: number): Array<string | null> {
|
||||
return (
|
||||
db.prepare('SELECT parsed_title FROM imm_videos WHERE anime_id = ?').all(animeId) as Array<{
|
||||
parsed_title: string | null;
|
||||
}>
|
||||
).map((row) => row.parsed_title);
|
||||
}
|
||||
|
||||
function stripSeasonIdentitySuffix(title: string): string {
|
||||
return title
|
||||
.replace(/\bseason\s*\d{1,2}\b/gi, ' ')
|
||||
.replace(/\b\d{1,2}(?:st|nd|rd|th)\s+season\b/gi, ' ')
|
||||
.replace(/\bs\d{1,2}\b/gi, ' ');
|
||||
}
|
||||
|
||||
export function hasExactStoredTitleMatch(
|
||||
db: DatabaseSync,
|
||||
targetAnimeId: number,
|
||||
conflictAnimeId: number,
|
||||
): boolean {
|
||||
const target = getAnimeTitles(db, targetAnimeId);
|
||||
const conflict = getAnimeTitles(db, conflictAnimeId);
|
||||
if (!target || !conflict) return false;
|
||||
const targetKeys = [target.canonical_title, ...getParsedTitles(db, targetAnimeId)]
|
||||
.filter((title): title is string => Boolean(title?.trim()))
|
||||
.map((title) => normalizeAnimeIdentityKey(stripSeasonIdentitySuffix(title)))
|
||||
.filter(Boolean);
|
||||
const anilistTitleKeys = [
|
||||
conflict.title_romaji,
|
||||
conflict.title_english,
|
||||
conflict.title_native,
|
||||
conflict.canonical_title,
|
||||
]
|
||||
.filter((title): title is string => Boolean(title?.trim()))
|
||||
.map((title) => normalizeAnimeIdentityKey(stripSeasonIdentitySuffix(title)))
|
||||
.filter(Boolean);
|
||||
return targetKeys.some((key) => anilistTitleKeys.includes(key));
|
||||
}
|
||||
|
||||
export function shouldRecommendAnilistConflict(
|
||||
db: DatabaseSync,
|
||||
targetAnimeId: number,
|
||||
conflictAnimeId: number,
|
||||
options: AnimeConflictRecommendationOptions,
|
||||
): boolean {
|
||||
if (options.survivor === 'target' || options.matchConfidence === 'manual') return false;
|
||||
if (
|
||||
!animeSeasonsAreMergeCompatible(
|
||||
getParsedSeasonsForAnime(db, targetAnimeId),
|
||||
getParsedSeasonsForAnime(db, conflictAnimeId),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
options.matchConfidence === 'weak' ||
|
||||
(options.matchConfidence === undefined &&
|
||||
!hasExactStoredTitleMatch(db, targetAnimeId, conflictAnimeId))
|
||||
);
|
||||
}
|
||||
|
||||
export function recordAnimeMergeRecommendation(
|
||||
db: DatabaseSync,
|
||||
firstCandidateAnimeId: number,
|
||||
secondCandidateAnimeId: number,
|
||||
anilistId: number,
|
||||
): void {
|
||||
const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId);
|
||||
const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId);
|
||||
const timestamp = toDbTimestamp(nowMs());
|
||||
db.prepare(
|
||||
`INSERT INTO imm_anime_merge_recommendations(
|
||||
first_anime_id, second_anime_id, anilist_id, status, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, 'pending', ?, ?)
|
||||
ON CONFLICT(first_anime_id, second_anime_id, anilist_id) DO UPDATE SET
|
||||
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
|
||||
).run(firstAnimeId, secondAnimeId, anilistId, timestamp, timestamp);
|
||||
}
|
||||
|
||||
export function hasDismissedAnimeMergeRecommendation(
|
||||
db: DatabaseSync,
|
||||
firstCandidateAnimeId: number,
|
||||
secondCandidateAnimeId: number,
|
||||
): boolean {
|
||||
const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId);
|
||||
const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId);
|
||||
return Boolean(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM imm_anime_merge_recommendations
|
||||
WHERE first_anime_id = ?
|
||||
AND second_anime_id = ?
|
||||
AND status = 'dismissed'
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(firstAnimeId, secondAnimeId),
|
||||
);
|
||||
}
|
||||
|
||||
export function getAnimeMergeRecommendations(db: DatabaseSync): AnimeMergeRecommendation[] {
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT recommendation_id AS recommendationId,
|
||||
first_anime_id AS firstAnimeId,
|
||||
second_anime_id AS secondAnimeId
|
||||
FROM imm_anime_merge_recommendations
|
||||
WHERE status = 'pending'
|
||||
ORDER BY recommendation_id ASC`,
|
||||
)
|
||||
.all() as Array<{
|
||||
recommendationId: number;
|
||||
firstAnimeId: number;
|
||||
secondAnimeId: number;
|
||||
}>
|
||||
).map((row) => ({
|
||||
recommendationId: row.recommendationId,
|
||||
animeIds: [row.firstAnimeId, row.secondAnimeId],
|
||||
}));
|
||||
}
|
||||
|
||||
export function dismissAnimeMergeRecommendation(
|
||||
db: DatabaseSync,
|
||||
recommendationId: number,
|
||||
): boolean {
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE imm_anime_merge_recommendations
|
||||
SET status = 'dismissed', LAST_UPDATE_DATE = ?
|
||||
WHERE recommendation_id = ? AND status = 'pending'`,
|
||||
)
|
||||
.run(toDbTimestamp(nowMs()), recommendationId) as { changes: number };
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
import { nowMs } from './time';
|
||||
|
||||
/** Thrown when a move names an episode or destination entry that is not there. */
|
||||
export const UNKNOWN_MOVE_TARGET_MESSAGE = 'Unknown episode or target library entry';
|
||||
|
||||
export interface AnimeMergeSummary {
|
||||
/** Library entry that owns every moved episode once the merge finishes. */
|
||||
survivingAnimeId: number;
|
||||
/** Entries that were folded into the survivor and deleted. */
|
||||
mergedAnimeIds: number[];
|
||||
movedVideos: number;
|
||||
}
|
||||
|
||||
export interface VideoMoveSummary {
|
||||
targetAnimeId: number;
|
||||
/** Previous owner, or null when the episode had no library entry yet. */
|
||||
previousAnimeId: number | null;
|
||||
/** True when the previous owner was left empty and pruned. */
|
||||
removedPreviousAnime: boolean;
|
||||
}
|
||||
|
||||
interface AnimeMetadataRow {
|
||||
normalized_title_key: string;
|
||||
anilist_id: number | null;
|
||||
title_romaji: string | null;
|
||||
title_english: string | null;
|
||||
title_native: string | null;
|
||||
episodes_total: number | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary {
|
||||
return { survivingAnimeId, mergedAnimeIds: [], movedVideos: 0 };
|
||||
}
|
||||
|
||||
function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = work();
|
||||
db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow | null {
|
||||
return (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description
|
||||
FROM imm_anime
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
)
|
||||
.get(animeId) ?? null) as AnimeMetadataRow | null;
|
||||
}
|
||||
|
||||
function animeExists(db: DatabaseSync, animeId: number): boolean {
|
||||
return Boolean(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId));
|
||||
}
|
||||
|
||||
function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean {
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT 1 AS found
|
||||
WHERE EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
|
||||
OR EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
|
||||
`,
|
||||
)
|
||||
.get(animeId, animeId) as { found: number } | null;
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct explicit seasons behind a library entry. Videos with no parsed
|
||||
* season are ignored, so an entry built from `Show - 03.mkv` style filenames
|
||||
* reports an empty set rather than a bogus season.
|
||||
*/
|
||||
export function getParsedSeasonsForAnime(db: DatabaseSync, animeId: number): Set<number> {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT parsed_season AS season
|
||||
FROM imm_videos
|
||||
WHERE anime_id = ?
|
||||
AND parsed_season IS NOT NULL
|
||||
AND parsed_season > 0
|
||||
`,
|
||||
)
|
||||
.all(animeId) as Array<{ season: number }>;
|
||||
return new Set(rows.map((row) => row.season));
|
||||
}
|
||||
|
||||
/**
|
||||
* Two entries are safe to fold together when neither spans more than one
|
||||
* explicit season and they do not disagree about which season that is. A
|
||||
* seasonless entry is compatible with anything single-season: those are the
|
||||
* `Show - 03.mkv` vs `Show.S01E03.mkv` splits that produce duplicate cards.
|
||||
*/
|
||||
export function animeSeasonsAreMergeCompatible(a: Set<number>, b: Set<number>): boolean {
|
||||
if (a.size > 1 || b.size > 1) return false;
|
||||
if (a.size === 0 || b.size === 0) return true;
|
||||
return [...a][0] === [...b][0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in whatever the target is missing from a source row that is on its way
|
||||
* out. Must run after the source row is deleted: imm_anime.anilist_id is
|
||||
* UNIQUE, so the two rows cannot hold the same id at once.
|
||||
*/
|
||||
function absorbAnimeMetadata(
|
||||
db: DatabaseSync,
|
||||
targetAnimeId: number,
|
||||
source: AnimeMetadataRow | null,
|
||||
updatedAt: string,
|
||||
): void {
|
||||
if (!source) return;
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_anime
|
||||
SET
|
||||
anilist_id = COALESCE(anilist_id, ?),
|
||||
title_romaji = COALESCE(title_romaji, ?),
|
||||
title_english = COALESCE(title_english, ?),
|
||||
title_native = COALESCE(title_native, ?),
|
||||
episodes_total = COALESCE(episodes_total, ?),
|
||||
description = COALESCE(description, ?),
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
).run(
|
||||
source.anilist_id,
|
||||
source.title_romaji,
|
||||
source.title_english,
|
||||
source.title_native,
|
||||
source.episodes_total,
|
||||
source.description,
|
||||
updatedAt,
|
||||
targetAnimeId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold `sourceAnimeIds` into `targetAnimeId`: every episode and subtitle line
|
||||
* is repointed, metadata the target is missing is inherited from the sources,
|
||||
* and the emptied source rows are deleted.
|
||||
*
|
||||
* Assumes the caller already holds a write transaction and rebuilds the
|
||||
* lifetime summaries afterwards; use {@link mergeAnimeRecords} otherwise.
|
||||
*/
|
||||
export function mergeAnimeRecordsInTransaction(
|
||||
db: DatabaseSync,
|
||||
targetAnimeId: number,
|
||||
sourceAnimeIds: number[],
|
||||
): AnimeMergeSummary {
|
||||
const summary = emptyMergeSummary(targetAnimeId);
|
||||
if (!animeExists(db, targetAnimeId)) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
const updatedAt = toDbTimestamp(nowMs());
|
||||
const sourceVideosStmt = db.prepare(
|
||||
'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?',
|
||||
);
|
||||
const moveVideosStmt = db.prepare(
|
||||
'UPDATE imm_videos SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE anime_id = ?',
|
||||
);
|
||||
// Repointed per video rather than by anime_id: lines recorded before the
|
||||
// async title parse assigns the link are stored with a NULL anime_id, and
|
||||
// matching on the source id would strand them unattributed.
|
||||
const moveLinesStmt = db.prepare(
|
||||
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
|
||||
);
|
||||
const dropLifetimeStmt = db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?');
|
||||
const sourceAliasesStmt = db.prepare(
|
||||
'SELECT normalized_title_key AS normalizedTitleKey FROM imm_anime_title_aliases WHERE anime_id = ?',
|
||||
);
|
||||
const upsertAliasStmt = db.prepare(
|
||||
`INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(normalized_title_key) DO UPDATE SET
|
||||
anime_id = excluded.anime_id,
|
||||
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
|
||||
);
|
||||
const dropSourceAliasesStmt = db.prepare(
|
||||
'DELETE FROM imm_anime_title_aliases WHERE anime_id = ?',
|
||||
);
|
||||
const dropAnimeStmt = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?');
|
||||
|
||||
for (const sourceAnimeId of new Set(sourceAnimeIds)) {
|
||||
if (sourceAnimeId === targetAnimeId || !animeExists(db, sourceAnimeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceMetadata = readAnimeMetadata(db, sourceAnimeId);
|
||||
const sourceAliases = sourceAliasesStmt.all(sourceAnimeId) as Array<{
|
||||
normalizedTitleKey: string;
|
||||
}>;
|
||||
const sourceVideoIds = (sourceVideosStmt.all(sourceAnimeId) as Array<{ videoId: number }>).map(
|
||||
(row) => row.videoId,
|
||||
);
|
||||
const moved = moveVideosStmt.run(targetAnimeId, updatedAt, sourceAnimeId) as {
|
||||
changes: number;
|
||||
};
|
||||
for (const videoId of sourceVideoIds) {
|
||||
moveLinesStmt.run(targetAnimeId, updatedAt, videoId);
|
||||
}
|
||||
dropSourceAliasesStmt.run(sourceAnimeId);
|
||||
for (const alias of [
|
||||
...(sourceMetadata ? [sourceMetadata.normalized_title_key] : []),
|
||||
...sourceAliases.map((row) => row.normalizedTitleKey),
|
||||
]) {
|
||||
upsertAliasStmt.run(alias, targetAnimeId, updatedAt, updatedAt);
|
||||
}
|
||||
dropLifetimeStmt.run(sourceAnimeId);
|
||||
dropAnimeStmt.run(sourceAnimeId);
|
||||
absorbAnimeMetadata(db, targetAnimeId, sourceMetadata, updatedAt);
|
||||
|
||||
summary.mergedAnimeIds.push(sourceAnimeId);
|
||||
summary.movedVideos += moved.changes;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function mergeAnimeRecords(
|
||||
db: DatabaseSync,
|
||||
targetAnimeId: number,
|
||||
sourceAnimeIds: number[],
|
||||
): AnimeMergeSummary {
|
||||
return runInTransaction(db, () => {
|
||||
const summary = mergeAnimeRecordsInTransaction(db, targetAnimeId, sourceAnimeIds);
|
||||
if (summary.mergedAnimeIds.length > 0) {
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
}
|
||||
return summary;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a single episode to another library entry, pruning the previous owner
|
||||
* when it is left with nothing.
|
||||
*/
|
||||
export function moveVideoToAnime(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
targetAnimeId: number,
|
||||
): VideoMoveSummary {
|
||||
return runInTransaction(db, () => {
|
||||
const videoRow = db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { animeId: number | null } | null;
|
||||
if (!videoRow || !animeExists(db, targetAnimeId)) {
|
||||
throw new Error(UNKNOWN_MOVE_TARGET_MESSAGE);
|
||||
}
|
||||
|
||||
const previousAnimeId = videoRow.animeId;
|
||||
if (previousAnimeId === targetAnimeId) {
|
||||
return { targetAnimeId, previousAnimeId, removedPreviousAnime: false };
|
||||
}
|
||||
|
||||
const updatedAt = toDbTimestamp(nowMs());
|
||||
db.prepare('UPDATE imm_videos SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run(
|
||||
targetAnimeId,
|
||||
updatedAt,
|
||||
videoId,
|
||||
);
|
||||
db.prepare(
|
||||
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
|
||||
).run(targetAnimeId, updatedAt, videoId);
|
||||
|
||||
let removedPreviousAnime = false;
|
||||
if (previousAnimeId !== null && !hasAnimeReferences(db, previousAnimeId)) {
|
||||
// The emptied entry's metadata is deliberately dropped rather than
|
||||
// absorbed. A move says "this episode belongs elsewhere", not "these are
|
||||
// the same show", and the entry being emptied is usually a mis-parse
|
||||
// whose AniList link would be wrong for the target.
|
||||
db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?').run(previousAnimeId);
|
||||
db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(previousAnimeId);
|
||||
removedPreviousAnime = true;
|
||||
}
|
||||
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
return { targetAnimeId, previousAnimeId, removedPreviousAnime };
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,4 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import {
|
||||
animeSeasonsAreMergeCompatible,
|
||||
getParsedSeasonsForAnime,
|
||||
mergeAnimeRecordsInTransaction,
|
||||
} from './anime-merge';
|
||||
import {
|
||||
hasExactStoredTitleMatch,
|
||||
hasDismissedAnimeMergeRecommendation,
|
||||
recordAnimeMergeRecommendation,
|
||||
shouldRecommendAnilistConflict,
|
||||
type AnimeConflictRecommendationOptions,
|
||||
} from './anime-merge-recommendations';
|
||||
import { getOrCreateAnimeRecord } from './storage';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
import { nowMs } from './time';
|
||||
@@ -20,33 +8,8 @@ export interface AnimeSeasonRepairSummary {
|
||||
repaired: number;
|
||||
movedVideos: number;
|
||||
deletedAnimeRows: number;
|
||||
/**
|
||||
* Entry that owns the videos afterwards when two rows were folded together,
|
||||
* so callers can keep pointing at a row that still exists.
|
||||
*/
|
||||
survivingAnimeId: number | null;
|
||||
/** True when an ambiguous AniList collision was saved for user review. */
|
||||
mergeRecommended: boolean;
|
||||
/** True when automatic metadata must not assign the colliding AniList id. */
|
||||
anilistAssignmentBlocked: boolean;
|
||||
}
|
||||
|
||||
export interface AnimeAnilistConflictOptions extends AnimeConflictRecommendationOptions {
|
||||
/**
|
||||
* Which row keeps its identity when two entries claim the same AniList id.
|
||||
* `existing` (the default) keeps the row that already held the id, so
|
||||
* automatic cover-art resolution does not rename a card under the user;
|
||||
* `target` keeps the row the user is acting on.
|
||||
*/
|
||||
survivor?: 'target' | 'existing';
|
||||
}
|
||||
|
||||
export {
|
||||
dismissAnimeMergeRecommendation,
|
||||
getAnimeMergeRecommendations,
|
||||
type AnimeMergeRecommendation,
|
||||
} from './anime-merge-recommendations';
|
||||
|
||||
interface AnimeRow {
|
||||
anime_id: number;
|
||||
anilist_id: number | null;
|
||||
@@ -75,9 +38,6 @@ function emptySummary(scanned = 0): AnimeSeasonRepairSummary {
|
||||
repaired: 0,
|
||||
movedVideos: 0,
|
||||
deletedAnimeRows: 0,
|
||||
survivingAnimeId: null,
|
||||
mergeRecommended: false,
|
||||
anilistAssignmentBlocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,9 +49,6 @@ function mergeSummary(
|
||||
target.repaired += source.repaired;
|
||||
target.movedVideos += source.movedVideos;
|
||||
target.deletedAnimeRows += source.deletedAnimeRows;
|
||||
target.survivingAnimeId = source.survivingAnimeId ?? target.survivingAnimeId;
|
||||
target.mergeRecommended ||= source.mergeRecommended;
|
||||
target.anilistAssignmentBlocked ||= source.anilistAssignmentBlocked;
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -344,18 +301,10 @@ export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRe
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Two library entries cannot both hold the same AniList id
|
||||
* (`imm_anime.anilist_id` is UNIQUE). Fold an automatic collision only when
|
||||
* exact title evidence and compatible parsed seasons make it safe. Persist a
|
||||
* review recommendation for compatible weak matches. Fall back to legacy
|
||||
* season redistribution when the conflicting row spans several seasons.
|
||||
*/
|
||||
export function resolveAnimeAnilistConflict(
|
||||
db: DatabaseSync,
|
||||
targetAnimeId: number,
|
||||
anilistId: number,
|
||||
options: AnimeAnilistConflictOptions = {},
|
||||
): AnimeSeasonRepairSummary {
|
||||
const conflict = db
|
||||
.prepare(
|
||||
@@ -372,96 +321,10 @@ export function resolveAnimeAnilistConflict(
|
||||
return emptySummary();
|
||||
}
|
||||
|
||||
return runInTransaction(db, () => {
|
||||
const targetRow = getAnimeRow(db, targetAnimeId);
|
||||
if (
|
||||
options.survivor !== 'target' &&
|
||||
targetRow?.anilist_id != null &&
|
||||
targetRow.anilist_id !== anilistId
|
||||
) {
|
||||
// An automatic lookup disagreeing with an existing explicit link is a
|
||||
// mis-resolution, not evidence that either row should move or merge.
|
||||
return emptySummary(1);
|
||||
}
|
||||
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
|
||||
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
|
||||
const summary = emptySummary(1);
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
|
||||
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
|
||||
if (
|
||||
!isManual &&
|
||||
targetSeasons.size === 1 &&
|
||||
conflictSeasons.size === 1 &&
|
||||
[...targetSeasons][0] !== [...conflictSeasons][0]
|
||||
) {
|
||||
const summary = emptySummary(1);
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) {
|
||||
const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId;
|
||||
const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId;
|
||||
const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]);
|
||||
const summary = emptySummary(1);
|
||||
summary.movedVideos = merge.movedVideos;
|
||||
summary.deletedAnimeRows = merge.mergedAnimeIds.length;
|
||||
if (merge.mergedAnimeIds.length > 0) {
|
||||
summary.repaired = 1;
|
||||
// Only reported once a row really absorbed the other, so callers never
|
||||
// follow this to an anime id that was never written.
|
||||
summary.survivingAnimeId = survivingAnimeId;
|
||||
}
|
||||
// Lifetime summaries are rebuilt by the caller off this summary, the same
|
||||
// as the redistribution path below.
|
||||
return summary;
|
||||
}
|
||||
|
||||
if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) {
|
||||
recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId);
|
||||
const summary = emptySummary(1);
|
||||
summary.mergeRecommended = true;
|
||||
return summary;
|
||||
}
|
||||
|
||||
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
|
||||
return runInTransaction(db, () =>
|
||||
redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
|
||||
transferAnilistToAnimeId: targetAnimeId,
|
||||
overwriteTargetAnilist: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function canMergeAnilistConflict(
|
||||
db: DatabaseSync,
|
||||
targetAnimeId: number,
|
||||
conflictAnimeId: number,
|
||||
anilistId: number,
|
||||
options: AnimeAnilistConflictOptions,
|
||||
): boolean {
|
||||
const targetRow = getAnimeRow(db, targetAnimeId);
|
||||
if (!targetRow) {
|
||||
// Nothing to merge with a row that no longer exists (a stale id from the
|
||||
// caller); fall through to the redistribution path.
|
||||
return false;
|
||||
}
|
||||
if (options.survivor !== 'target') {
|
||||
// The target is the row about to disappear here, so an existing link of its
|
||||
// own means this is a mis-resolution rather than a duplicate: leave it be.
|
||||
if (targetRow.anilist_id != null && targetRow.anilist_id !== anilistId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (
|
||||
options.matchConfidence === 'weak' ||
|
||||
(options.matchConfidence === undefined &&
|
||||
!hasExactStoredTitleMatch(db, targetAnimeId, conflictAnimeId))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return animeSeasonsAreMergeCompatible(
|
||||
getParsedSeasonsForAnime(db, targetAnimeId),
|
||||
getParsedSeasonsForAnime(db, conflictAnimeId),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -418,7 +418,6 @@ export function updateAnimeAnilistInfo(
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
episodesTotal: number | null;
|
||||
exactTitleMatch?: boolean;
|
||||
},
|
||||
): void {
|
||||
const row = db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?').get(videoId) as {
|
||||
@@ -426,11 +425,7 @@ export function updateAnimeAnilistInfo(
|
||||
} | null;
|
||||
if (!row?.anime_id) return;
|
||||
|
||||
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId, {
|
||||
matchConfidence:
|
||||
info.exactTitleMatch === true ? 'exact' : info.exactTitleMatch === false ? 'weak' : undefined,
|
||||
});
|
||||
if (repair.mergeRecommended || repair.anilistAssignmentBlocked) return;
|
||||
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId);
|
||||
const targetRow = db
|
||||
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||
import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { nowMs } from './time';
|
||||
import { SCHEMA_VERSION } from './types';
|
||||
@@ -320,7 +319,14 @@ export function applyPragmas(db: DatabaseSync): void {
|
||||
db.exec(`PRAGMA journal_size_limit = ${WAL_JOURNAL_SIZE_LIMIT_BYTES}`);
|
||||
}
|
||||
|
||||
export const normalizeAnimeIdentityKey = normalizeTitleIdentity;
|
||||
export function normalizeAnimeIdentityKey(title: string): string {
|
||||
return title
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function normalizeSeasonScope(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
||||
@@ -524,36 +530,6 @@ function ensureStatsExcludedWordsTable(db: DatabaseSync): void {
|
||||
`);
|
||||
}
|
||||
|
||||
function ensureAnimeMergeTables(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_anime_title_aliases(
|
||||
normalized_title_key TEXT PRIMARY KEY,
|
||||
anime_id INTEGER NOT NULL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_anime_title_aliases_anime_id
|
||||
ON imm_anime_title_aliases(anime_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imm_anime_merge_recommendations(
|
||||
recommendation_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
first_anime_id INTEGER NOT NULL,
|
||||
second_anime_id INTEGER NOT NULL,
|
||||
anilist_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'dismissed')),
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
CHECK(first_anime_id < second_anime_id),
|
||||
UNIQUE(first_anime_id, second_anime_id, anilist_id),
|
||||
FOREIGN KEY(first_anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(second_anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_anime_merge_recommendations_status
|
||||
ON imm_anime_merge_recommendations(status, recommendation_id);
|
||||
`);
|
||||
}
|
||||
|
||||
export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number {
|
||||
const seasonScope = normalizeSeasonScope(input.seasonScope);
|
||||
const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope);
|
||||
@@ -574,14 +550,8 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
|
||||
const byNormalizedTitle = db
|
||||
.prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?')
|
||||
.get(normalizedTitleKey) as { anime_id: number } | null;
|
||||
const byTitleAlias = db
|
||||
.prepare('SELECT anime_id FROM imm_anime_title_aliases WHERE normalized_title_key = ?')
|
||||
.get(normalizedTitleKey) as { anime_id: number } | null;
|
||||
const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias;
|
||||
const existing = byAnilistId ?? byNormalizedTitle;
|
||||
if (existing?.anime_id) {
|
||||
// An alias remembers an intentionally merged-away spelling. Reusing it
|
||||
// must not rename the survivor back to that discarded display title.
|
||||
const canonicalTitleUpdate = byAnilistId || byNormalizedTitle ? canonicalTitle : null;
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_anime
|
||||
@@ -596,7 +566,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
).run(
|
||||
canonicalTitleUpdate,
|
||||
canonicalTitle,
|
||||
input.anilistId,
|
||||
input.titleRomaji,
|
||||
input.titleEnglish,
|
||||
@@ -781,7 +751,6 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
if (currentVersion?.schema_version === SCHEMA_VERSION) {
|
||||
ensureLifetimeSummaryTables(db);
|
||||
ensureStatsExcludedWordsTable(db);
|
||||
ensureAnimeMergeTables(db);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -830,7 +799,6 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
ensureAnimeMergeTables(db);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_sessions(
|
||||
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SCHEMA_VERSION = 20;
|
||||
export const SCHEMA_VERSION = 19;
|
||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||
export const DEFAULT_BATCH_SIZE = 25;
|
||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Hono } from 'hono';
|
||||
import { statsJson } from '../../../types/stats-http-contract.js';
|
||||
import { UNKNOWN_MOVE_TARGET_MESSAGE } from '../immersion-tracker/anime-merge.js';
|
||||
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
||||
import {
|
||||
buildSentenceSearchOptions,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
parseBooleanQuery,
|
||||
parseExcludedWordsBody,
|
||||
parseIntQuery,
|
||||
parsePositiveIdList,
|
||||
} from './route-support.js';
|
||||
|
||||
export function registerStatsLibraryRoutes(
|
||||
@@ -132,19 +130,6 @@ export function registerStatsLibraryRoutes(
|
||||
return c.json(statsJson('animeLibrary', rows));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/merge-recommendations', async (c) => {
|
||||
const recommendations = await tracker.getAnimeMergeRecommendations();
|
||||
return c.json(statsJson('animeMergeRecommendations', { recommendations }));
|
||||
});
|
||||
|
||||
app.delete('/api/stats/anime/merge-recommendations/:recommendationId', async (c) => {
|
||||
const recommendationId = parseIntQuery(c.req.param('recommendationId'), 0);
|
||||
if (recommendationId <= 0) return c.body(null, 400);
|
||||
const dismissed = await tracker.dismissAnimeMergeRecommendation(recommendationId);
|
||||
if (!dismissed) return c.body(null, 404);
|
||||
return c.json(statsJson('dismissAnimeMergeRecommendation', { ok: true }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
if (animeId <= 0) return c.body(null, 400);
|
||||
@@ -212,50 +197,4 @@ export function registerStatsLibraryRoutes(
|
||||
await tracker.deleteAnime(animeId);
|
||||
return c.json(statsJson('deleteAnime', { ok: true }));
|
||||
});
|
||||
|
||||
app.post('/api/stats/anime/:animeId/merge', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
if (animeId <= 0) return c.body(null, 400);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const sourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds).filter((id) => id !== animeId);
|
||||
if (sourceAnimeIds.length === 0) return c.body(null, 400);
|
||||
const summary = await tracker.mergeAnime(animeId, sourceAnimeIds);
|
||||
// Nothing folded means the target or every source was already gone, so the
|
||||
// caller should not be told the merge succeeded.
|
||||
if (summary.mergedAnimeIds.length === 0) return c.body(null, 404);
|
||||
return c.json(
|
||||
statsJson('mergeAnime', {
|
||||
ok: true,
|
||||
animeId: summary.survivingAnimeId,
|
||||
mergedAnimeIds: summary.mergedAnimeIds,
|
||||
movedVideos: summary.movedVideos,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.patch('/api/stats/media/:videoId/anime', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.body(null, 400);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const animeId = Number.isSafeInteger(body?.animeId) ? (body.animeId as number) : 0;
|
||||
if (animeId <= 0) return c.body(null, 400);
|
||||
try {
|
||||
const summary = await tracker.moveVideoToAnime(videoId, animeId);
|
||||
return c.json(
|
||||
statsJson('moveVideoToAnime', {
|
||||
ok: true,
|
||||
animeId: summary.targetAnimeId,
|
||||
previousAnimeId: summary.previousAnimeId,
|
||||
removedPreviousAnime: summary.removedPreviousAnime,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
// Only a missing episode or entry is a 404; storage failures must not be
|
||||
// reported to the caller as "not found".
|
||||
if (error instanceof Error && error.message === UNKNOWN_MOVE_TARGET_MESSAGE) {
|
||||
return c.body(null, 404);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,18 +170,6 @@ export async function enrichSessionsWithKnownWordMetrics<
|
||||
);
|
||||
}
|
||||
|
||||
/** Deduplicated positive integer ids from an untrusted JSON body field. */
|
||||
export function parsePositiveIdList(raw: unknown): number[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const ids = new Set<number>();
|
||||
for (const value of raw) {
|
||||
if (Number.isSafeInteger(value) && (value as number) > 0) {
|
||||
ids.add(value as number);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
|
||||
if (raw === undefined) return fallback;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Duplicate/animation-burst collapsing for parsed subtitle cues.
|
||||
*
|
||||
* Split out of the cue parser so the parsing rules and the "is this run one animation?"
|
||||
* heuristics can be read -- and tested -- on their own. The parser owns the cue shape;
|
||||
* this module only decides which cues survive.
|
||||
*/
|
||||
|
||||
import { hasAssTemporalOverride, isAnimatedAssEffectKind } from './ass-text';
|
||||
import type {
|
||||
AnnotatedSubtitleCue,
|
||||
SubtitleCue,
|
||||
SubtitleSourceFormat,
|
||||
} from './subtitle-cue-parser';
|
||||
|
||||
// Back-to-back frames of the same animation are authored flush against each other; a
|
||||
// tiny tolerance absorbs the centisecond rounding of the ASS timestamp format.
|
||||
const DUPLICATE_CUE_GAP_TOLERANCE_SECONDS = 0.05;
|
||||
// A burst is a *sequence*. Two adjacent events are two events, not an animation --
|
||||
// characters do repeat each other, and a repeated line can legitimately be short.
|
||||
const MIN_BURST_EVENTS = 3;
|
||||
// Real dialogue holds on screen for about a second, so a run with a couple of much
|
||||
// shorter events among them looks like frames. Used only alongside authoring evidence.
|
||||
const ANIMATION_FRAME_MAX_SECONDS = 0.3;
|
||||
// A karaoke run usually ends on a long "hold" frame, so not every event is short.
|
||||
const MIN_TAGGED_BURST_FRAMES = 2;
|
||||
// SRT and VTT carry no authoring metadata at all, so timing is the only signal available
|
||||
// -- which makes it the easiest one to get wrong. ASS->SRT conversion leaves frames at
|
||||
// ~0.04s, well under any real utterance, and a burst leaves many of them behind. Both
|
||||
// bounds are deliberately far stricter than the ASS path: a run of ordinary short lines
|
||||
// (`えっ` traded between characters) must not clear them.
|
||||
const TIMING_ONLY_FRAME_MAX_SECONDS = 0.1;
|
||||
const MIN_TIMING_ONLY_FRAMES = 5;
|
||||
|
||||
function cueKey(cue: SubtitleCue): string {
|
||||
return `${cue.startTime}|${cue.endTime}|${cue.text}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identical text over an identical span is redundant however it was authored -- most
|
||||
* often a layered ASS event stacking a shadow copy under the visible one.
|
||||
*/
|
||||
function collapseExactDuplicates(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
||||
const seen = new Set<string>();
|
||||
return cues.filter((cue) => {
|
||||
const key = cueKey(cue);
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function countFramesShorterThan(run: AnnotatedSubtitleCue[], maxSeconds: number): number {
|
||||
return run.filter((cue) => cue.endTime - cue.startTime < maxSeconds).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evidence that a run of ASS events is one animation rather than several authored lines.
|
||||
* A static tag says nothing on its own -- three events sharing one `\clip(...)` are three
|
||||
* signs -- so the tag has to be temporal by nature (`\t`, `\move`, karaoke timing, or
|
||||
* anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually
|
||||
* changes from event to event, which is how per-frame typesetting is authored.
|
||||
*/
|
||||
export function hasAssAnimationEvidence(run: AnnotatedSubtitleCue[]): boolean {
|
||||
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
|
||||
return true;
|
||||
}
|
||||
if (run.every((cue) => isAnimatedAssEffectKind(cue.effectKind))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [first] = run;
|
||||
const everyEventTypeset = run.every((cue) => cue.overrides.length > 0);
|
||||
const signatureChanges = run.some((cue) => cue.overrideSignature !== first!.overrideSignature);
|
||||
return everyEventTypeset && signatureChanges;
|
||||
}
|
||||
|
||||
export function isAnimationBurst(
|
||||
run: AnnotatedSubtitleCue[],
|
||||
format: SubtitleSourceFormat,
|
||||
): boolean {
|
||||
if (run.length < MIN_BURST_EVENTS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (format === 'srt') {
|
||||
return (
|
||||
run.length >= MIN_TIMING_ONLY_FRAMES &&
|
||||
countFramesShorterThan(run, TIMING_ONLY_FRAME_MAX_SECONDS) === run.length
|
||||
);
|
||||
}
|
||||
|
||||
if (countFramesShorterThan(run, ANIMATION_FRAME_MAX_SECONDS) < MIN_TAGGED_BURST_FRAMES) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// One animation belongs to one styled, one named source line. Two characters trading
|
||||
// the same short word are two styles or two actors, and never merge.
|
||||
const [first] = run;
|
||||
if (run.some((cue) => cue.style !== first!.style || cue.name !== first!.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasAssAnimationEvidence(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Karaoke and sign typesetting emits one Dialogue event per animation frame, all carrying
|
||||
* the same visible text over a contiguous span. Collapse each such run into a single cue.
|
||||
*
|
||||
* Only runs that look like animation collapse. Two ordinary lines that happen to repeat
|
||||
* -- several characters each saying `おはよう` in turn, a positioned sign redrawn with a
|
||||
* different fade -- stay separate, because merging them would destroy real mineable lines.
|
||||
*/
|
||||
function collapseAnimationBursts(
|
||||
cues: AnnotatedSubtitleCue[],
|
||||
format: SubtitleSourceFormat,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
const indicesByText = new Map<string, number[]>();
|
||||
cues.forEach((cue, index) => {
|
||||
const bucket = indicesByText.get(cue.text);
|
||||
if (bucket) {
|
||||
bucket.push(index);
|
||||
} else {
|
||||
indicesByText.set(cue.text, [index]);
|
||||
}
|
||||
});
|
||||
|
||||
const dropped = new Set<number>();
|
||||
const extendedEnd = new Map<number, number>();
|
||||
|
||||
for (const indices of indicesByText.values()) {
|
||||
if (indices.length < MIN_BURST_EVENTS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let runStart = 0;
|
||||
while (runStart < indices.length) {
|
||||
let runEnd = runStart;
|
||||
let chainEnd = cues[indices[runStart]!]!.endTime;
|
||||
|
||||
while (runEnd + 1 < indices.length) {
|
||||
const next = cues[indices[runEnd + 1]!]!;
|
||||
if (next.startTime > chainEnd + DUPLICATE_CUE_GAP_TOLERANCE_SECONDS) {
|
||||
break;
|
||||
}
|
||||
chainEnd = Math.max(chainEnd, next.endTime);
|
||||
runEnd += 1;
|
||||
}
|
||||
|
||||
const run = indices.slice(runStart, runEnd + 1).map((index) => cues[index]!);
|
||||
if (isAnimationBurst(run, format)) {
|
||||
for (let i = runStart + 1; i <= runEnd; i += 1) {
|
||||
dropped.add(indices[i]!);
|
||||
}
|
||||
extendedEnd.set(indices[runStart]!, chainEnd);
|
||||
}
|
||||
|
||||
runStart = runEnd + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (dropped.size === 0) {
|
||||
return cues;
|
||||
}
|
||||
|
||||
const merged: AnnotatedSubtitleCue[] = [];
|
||||
cues.forEach((cue, index) => {
|
||||
if (dropped.has(index)) {
|
||||
return;
|
||||
}
|
||||
const end = extendedEnd.get(index);
|
||||
merged.push(end !== undefined && end > cue.endTime ? { ...cue, endTime: end } : cue);
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse redundant cues. Input must already be sorted by non-decreasing `startTime`,
|
||||
* ties broken by `endTime` then source `order` -- burst detection chains events by
|
||||
* comparing each one against the running end of the events before it, so an unsorted
|
||||
* list breaks runs apart and leaves the frames behind.
|
||||
*/
|
||||
export function mergeDuplicateCues(
|
||||
cues: AnnotatedSubtitleCue[],
|
||||
format: SubtitleSourceFormat,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
return collapseAnimationBursts(collapseExactDuplicates(cues), format);
|
||||
}
|
||||
@@ -91,6 +91,17 @@ test('parseSrtCues skips malformed timing lines gracefully', () => {
|
||||
assert.equal(cues[0]!.text, '有効');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues strips complete brace blocks from SRT and VTT text', () => {
|
||||
const content = ['1', '00:00:01,000 --> 00:00:02,000', '彼は{謎}と言った', ''].join('\n');
|
||||
|
||||
for (const filename of ['test.srt', 'test.vtt']) {
|
||||
const cues = parseSubtitleCues(content, filename);
|
||||
|
||||
assert.equal(cues.length, 1, filename);
|
||||
assert.equal(cues[0]!.text, '彼はと言った', filename);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseAssCues parses basic ASS dialogue lines', () => {
|
||||
const content = [
|
||||
'[Script Info]',
|
||||
@@ -137,7 +148,9 @@ test('parseAssCues handles text containing commas', () => {
|
||||
assert.equal(cues[0]!.text, 'はい、そうです、ね');
|
||||
});
|
||||
|
||||
test('parseAssCues handles \\N line breaks', () => {
|
||||
test('parseAssCues decodes \\N line breaks into real newlines', () => {
|
||||
// ASS is decoded once, here at ingestion, so cue text matches what mpv hands over for
|
||||
// the same line played live.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
@@ -146,7 +159,7 @@ test('parseAssCues handles \\N line breaks', () => {
|
||||
|
||||
const cues = parseAssCues(content);
|
||||
|
||||
assert.equal(cues[0]!.text, '一行目\\N二行目');
|
||||
assert.equal(cues[0]!.text, '一行目\n二行目');
|
||||
});
|
||||
|
||||
test('parseAssCues strips HTML-like markup while preserving ASS line breaks', () => {
|
||||
@@ -158,7 +171,46 @@ test('parseAssCues strips HTML-like markup while preserving ASS line breaks', ()
|
||||
|
||||
const cues = parseAssCues(content);
|
||||
|
||||
assert.equal(cues[0]!.text, '一行目\\N二行目');
|
||||
assert.equal(cues[0]!.text, '一行目\n二行目');
|
||||
});
|
||||
|
||||
test('parseAssCues drops vector drawing runs enabled by \\p', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
|
||||
'Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,これは字幕',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseAssCues(content);
|
||||
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]!.text, 'これは字幕');
|
||||
});
|
||||
|
||||
test('parseAssCues keeps text that follows a \\p0 reset on the same line', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseAssCues(content);
|
||||
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]!.text, '本文続き');
|
||||
});
|
||||
|
||||
test('parseAssCues leaves \\pos untouched when no drawing mode is active', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(960,1068)\\bord3}位置指定',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseAssCues(content);
|
||||
|
||||
assert.equal(cues[0]!.text, '位置指定');
|
||||
});
|
||||
|
||||
test('parseAssCues returns empty for content without Events section', () => {
|
||||
@@ -258,6 +310,344 @@ test('parseSubtitleCues returns cues sorted by start time', () => {
|
||||
assert.equal(cues[1]!.text, '二番目');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses per-frame karaoke duplicates into one cue', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}過ぎ去ってしまう瞬間を',
|
||||
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}過ぎ去ってしまう瞬間を',
|
||||
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,,{\\clip(m 3 3)}過ぎ去ってしまう瞬間を',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]!.startTime, 1.0);
|
||||
assert.equal(cues[0]!.endTime, 3.55);
|
||||
assert.equal(cues[0]!.text, '過ぎ去ってしまう瞬間を');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps back-to-back plain dialogue repeats separate', () => {
|
||||
// Several characters greeting in turn: distinct utterances that happen to abut.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:04:05.67,0:04:06.82,Dial_JP,,0,0,0,,おはよう',
|
||||
'Dialogue: 0,0:04:06.82,0:04:07.56,Dial_JP,,0,0,0,,おはよう',
|
||||
'Dialogue: 0,0:04:07.56,0:04:08.78,Dial_JP,,0,0,0,,おはよう',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 3);
|
||||
assert.equal(cues[0]!.endTime, 246.82);
|
||||
assert.equal(cues[2]!.startTime, 247.56);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses exact duplicate cues even without effect tags', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,重なった行',
|
||||
'Dialogue: 1,0:00:01.00,0:00:04.00,Default,,0,0,0,,重なった行',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 1);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses tag-less animation frames in converted SRT', () => {
|
||||
// ASS -> SRT conversion drops override tags, so only the ~0.04s frame timing remains.
|
||||
const lines = ['1', '00:00:07,870 --> 00:00:07,910', 'Kaguya Wants to be Confessed to', ''];
|
||||
for (let i = 1; i < 8; i++) {
|
||||
const start = 7910 + (i - 1) * 40;
|
||||
const end = start + 40;
|
||||
const at = (ms: number) =>
|
||||
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
|
||||
lines.push(String(i + 1), `${at(start)} --> ${at(end)}`, 'Kaguya Wants to be Confessed to', '');
|
||||
}
|
||||
|
||||
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
|
||||
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]!.startTime, 7.87);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps identical lines that recur far apart', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,なんで',
|
||||
'Dialogue: 0,0:05:00.00,0:05:01.00,Default,,0,0,0,,なんで',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 2);
|
||||
assert.equal(cues[0]!.startTime, 1.0);
|
||||
assert.equal(cues[1]!.startTime, 300.0);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps two positioned signs that repeat the same text', () => {
|
||||
// Both carry override tags, but `\pos` and `\fad` are static placement, not animation.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:01:00.00,0:01:03.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(200,200)}第一話',
|
||||
'Dialogue: 0,0:01:03.00,0:01:06.00,Sign,,0,0,0,,{\\pos(960,900)\\fad(200,200)}第一話',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 2);
|
||||
assert.equal(cues[1]!.startTime, 63.0);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps a run of ordinary positioned lines separate', () => {
|
||||
// Three events is a sequence, but none of them runs at animation-frame speed.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:01:00.00,0:01:02.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
||||
'Dialogue: 0,0:01:02.00,0:01:04.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
||||
'Dialogue: 0,0:01:04.00,0:01:06.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 3);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps a short repeated SRT pair without burst evidence', () => {
|
||||
const content = [
|
||||
'1',
|
||||
'00:00:01,000 --> 00:00:01,200',
|
||||
'えっ',
|
||||
'',
|
||||
'2',
|
||||
'00:00:01,200 --> 00:00:01,400',
|
||||
'えっ',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.srt');
|
||||
|
||||
assert.equal(cues.length, 2);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses a burst marked only by the Effect column', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,Karaoke,歌詞',
|
||||
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,Karaoke,歌詞',
|
||||
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,Karaoke,歌詞',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]!.endTime, 3.55);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps a second karaoke burst that starts after a gap', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}リフレイン',
|
||||
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}リフレイン',
|
||||
'Dialogue: 0,0:00:01.09,0:00:03.00,OP_JP,,0,0,0,,{\\clip(m 3 3)}リフレイン',
|
||||
'Dialogue: 0,0:00:20.00,0:00:20.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}リフレイン',
|
||||
'Dialogue: 0,0:00:20.05,0:00:20.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}リフレイン',
|
||||
'Dialogue: 0,0:00:20.09,0:00:22.00,OP_JP,,0,0,0,,{\\clip(m 3 3)}リフレイン',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 2);
|
||||
assert.equal(cues[0]!.endTime, 3.0);
|
||||
assert.equal(cues[1]!.startTime, 20.0);
|
||||
assert.equal(cues[1]!.endTime, 22.0);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues does not merge a burst into unrelated dialogue between frames', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}歌詞',
|
||||
'Dialogue: 0,0:00:01.02,0:00:03.00,Dial_JP,,0,0,0,,別のセリフ',
|
||||
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}歌詞',
|
||||
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,,{\\clip(m 3 3)}歌詞',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 2);
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
['歌詞', '別のセリフ'],
|
||||
);
|
||||
assert.equal(cues[0]!.endTime, 3.55);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps rapid ASS lines from different actors separate', () => {
|
||||
// Three 200ms `えっ` reactions traded between characters. Fast, adjacent and identical,
|
||||
// but authored as three lines: different styles and different actors.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_A,アリス,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_B,ボブ,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_C,キャロル,0,0,0,,えっ',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 3);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues reads the speaker column when it is spelled Actor', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Actor, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_JP,アリス,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_JP,ボブ,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_JP,キャロル,0,0,0,,えっ',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 3);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues does not treat a custom Effect name as animation', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,scrolling-credit,制作',
|
||||
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,scrolling-credit,制作',
|
||||
'Dialogue: 0,0:00:01.40,0:00:01.60,Sign,,0,0,0,scrolling-credit,制作',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 3);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps rapid ASS lines that share a style but not an actor', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_JP,アリス,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_JP,ボブ,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_JP,キャロル,0,0,0,,えっ',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 3);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps untagged rapid ASS repeats separate', () => {
|
||||
// No overrides at all: timing-only evidence is an SRT/VTT fallback and must not apply
|
||||
// to ASS, where the absence of typesetting is itself evidence of plain dialogue.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.05,Dial_JP,,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.05,0:00:01.10,Dial_JP,,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.10,0:00:01.15,Dial_JP,,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.15,0:00:01.20,Dial_JP,,0,0,0,,えっ',
|
||||
'Dialogue: 0,0:00:01.20,0:00:01.25,Dial_JP,,0,0,0,,えっ',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 5);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps repeated signs sharing one static clip', () => {
|
||||
// `\clip` is a static shape for the event. Three events with the identical clip were
|
||||
// typeset the same way, so none of them is a frame of the others.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
|
||||
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
|
||||
'Dialogue: 0,0:00:01.40,0:00:01.60,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 3);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses a sign animated through \\t', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
|
||||
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
|
||||
'Dialogue: 0,0:00:01.40,0:00:03.00,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]!.endTime, 3.0);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps a short repeated SRT run above the frame threshold', () => {
|
||||
// Five contiguous 200ms cues: a sequence, but nowhere near animation-frame speed.
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const start = 1000 + i * 200;
|
||||
const at = (ms: number) =>
|
||||
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
|
||||
lines.push(String(i + 1), `${at(start)} --> ${at(start + 200)}`, 'えっ', '');
|
||||
}
|
||||
|
||||
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
|
||||
|
||||
assert.equal(cues.length, 5);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps a short SRT frame run below the minimum length', () => {
|
||||
// Four 40ms frames: frame-speed, but too few to tell an animation from an artefact.
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const start = 7870 + i * 40;
|
||||
const at = (ms: number) =>
|
||||
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
|
||||
lines.push(String(i + 1), `${at(start)} --> ${at(start + 40)}`, 'タイトル', '');
|
||||
}
|
||||
|
||||
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
|
||||
|
||||
assert.equal(cues.length, 4);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues applies ASS burst rules to ASS content behind an .srt filename', () => {
|
||||
// The extension lies, so the SRT parser finds nothing and the content-sniffing fallback
|
||||
// takes over -- which has to carry the `ass` source format with it, or the far stricter
|
||||
// timing-only thresholds would let this karaoke burst through as three cues.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.20,Karaoke,,0,0,0,,{\\k20}歌詞',
|
||||
'Dialogue: 0,0:00:01.20,0:00:01.40,Karaoke,,0,0,0,,{\\k20}歌詞',
|
||||
'Dialogue: 0,0:00:01.40,0:00:03.00,Karaoke,,0,0,0,,{\\k20}歌詞',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.srt');
|
||||
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]!.startTime, 1.0);
|
||||
assert.equal(cues[0]!.endTime, 3.0);
|
||||
assert.equal(cues[0]!.text, '歌詞');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
|
||||
const assContent = [
|
||||
'[Events]',
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
import {
|
||||
assOverrideSignature,
|
||||
assToPlainText,
|
||||
collectAssOverrideCommands,
|
||||
parseAssEffectField,
|
||||
type AssEffectKind,
|
||||
type AssOverrideCommand,
|
||||
} from './ass-text';
|
||||
import { mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||
|
||||
export interface SubtitleCue {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the parser knows about a source event, shared only with the dedup engine.
|
||||
* Deduplication needs the authoring context -- which style the line belongs to, which
|
||||
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
|
||||
* burst apart from two characters saying the same word in turn. None of it is meaningful
|
||||
* outside the parser, so the public API stays `{startTime, endTime, text}`.
|
||||
*/
|
||||
export interface AnnotatedSubtitleCue extends SubtitleCue {
|
||||
/** Text exactly as authored, override blocks and all. */
|
||||
rawText: string;
|
||||
style: string;
|
||||
layer: number;
|
||||
/** ASS `Name`/`Actor` column. */
|
||||
name: string;
|
||||
/** ASS `Effect` column, verbatim. */
|
||||
effect: string;
|
||||
effectKind: AssEffectKind;
|
||||
/** Override commands found in `{...}` blocks, with their arguments. */
|
||||
overrides: readonly AssOverrideCommand[];
|
||||
/** Canonical form of `overrides`, for spotting values that change across a run. */
|
||||
overrideSignature: string;
|
||||
/** Position in the source file, so sorting by time stays deterministic across layers. */
|
||||
order: number;
|
||||
}
|
||||
|
||||
export type SubtitleSourceFormat = 'ass' | 'srt';
|
||||
|
||||
const HTML_SUBTITLE_TAG_PATTERN = /<\/?[A-Za-z][^>\n]*>/g;
|
||||
|
||||
const SRT_TIMING_PATTERN =
|
||||
@@ -23,12 +60,21 @@ function parseTimestamp(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single ASS decode for the file path: cues leave the parser as plain text with real
|
||||
* line breaks, matching what mpv hands over for the same line played live. No layer
|
||||
* downstream decodes ASS again.
|
||||
*/
|
||||
function sanitizeSubtitleCueText(text: string): string {
|
||||
return text.replace(ASS_OVERRIDE_TAG_PATTERN, '').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim();
|
||||
return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim();
|
||||
}
|
||||
|
||||
export function parseSrtCues(content: string): SubtitleCue[] {
|
||||
const cues: SubtitleCue[] = [];
|
||||
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||
return cues.map(({ startTime, endTime, text }) => ({ startTime, endTime, text }));
|
||||
}
|
||||
|
||||
function parseAnnotatedSrtCues(content: string): AnnotatedSubtitleCue[] {
|
||||
const cues: AnnotatedSubtitleCue[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
let i = 0;
|
||||
|
||||
@@ -60,20 +106,39 @@ export function parseSrtCues(content: string): SubtitleCue[] {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const text = sanitizeSubtitleCueText(textLines.join('\n'));
|
||||
const rawText = textLines.join('\n');
|
||||
const text = sanitizeSubtitleCueText(rawText);
|
||||
if (text) {
|
||||
cues.push({ startTime, endTime, text });
|
||||
cues.push({
|
||||
startTime,
|
||||
endTime,
|
||||
text,
|
||||
rawText,
|
||||
style: '',
|
||||
layer: 0,
|
||||
name: '',
|
||||
effect: '',
|
||||
effectKind: 'none',
|
||||
// SRT and VTT carry no authoring metadata, and the dedup engine never reads
|
||||
// overrides for those formats -- collecting them would be parsing for nobody.
|
||||
overrides: [],
|
||||
overrideSignature: '',
|
||||
order: cues.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return cues;
|
||||
}
|
||||
|
||||
const ASS_OVERRIDE_TAG_PATTERN = /\{[^}]*\}/g;
|
||||
export function parseSrtCues(content: string): SubtitleCue[] {
|
||||
return toPublicCues(parseAnnotatedSrtCues(content));
|
||||
}
|
||||
|
||||
const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/;
|
||||
const ASS_FORMAT_PREFIX = 'Format:';
|
||||
const ASS_DIALOGUE_PREFIX = 'Dialogue:';
|
||||
const ASS_NAME_FIELD_ALIASES = ['name', 'actor'];
|
||||
|
||||
function parseAssTimestamp(raw: string): number | null {
|
||||
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
||||
@@ -87,13 +152,43 @@ function parseAssTimestamp(raw: string): number | null {
|
||||
return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
|
||||
}
|
||||
|
||||
export function parseAssCues(content: string): SubtitleCue[] {
|
||||
const cues: SubtitleCue[] = [];
|
||||
function readField(fields: string[], index: number): string {
|
||||
return index >= 0 && index < fields.length ? fields[index]!.trim() : '';
|
||||
}
|
||||
|
||||
function findFieldIndex(formatFields: string[], aliases: string[]): number {
|
||||
for (const alias of aliases) {
|
||||
const index = formatFields.indexOf(alias);
|
||||
if (index >= 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
const cues: AnnotatedSubtitleCue[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
let inEventsSection = false;
|
||||
let startFieldIndex = -1;
|
||||
let endFieldIndex = -1;
|
||||
let textFieldIndex = -1;
|
||||
const fieldIndex = {
|
||||
start: -1,
|
||||
end: -1,
|
||||
text: -1,
|
||||
style: -1,
|
||||
layer: -1,
|
||||
name: -1,
|
||||
effect: -1,
|
||||
};
|
||||
|
||||
const resetFieldIndex = () => {
|
||||
fieldIndex.start = -1;
|
||||
fieldIndex.end = -1;
|
||||
fieldIndex.text = -1;
|
||||
fieldIndex.style = -1;
|
||||
fieldIndex.layer = -1;
|
||||
fieldIndex.name = -1;
|
||||
fieldIndex.effect = -1;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
@@ -101,9 +196,7 @@ export function parseAssCues(content: string): SubtitleCue[] {
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
inEventsSection = trimmed.toLowerCase() === '[events]';
|
||||
if (!inEventsSection) {
|
||||
startFieldIndex = -1;
|
||||
endFieldIndex = -1;
|
||||
textFieldIndex = -1;
|
||||
resetFieldIndex();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -117,9 +210,15 @@ export function parseAssCues(content: string): SubtitleCue[] {
|
||||
.slice(ASS_FORMAT_PREFIX.length)
|
||||
.split(',')
|
||||
.map((field) => field.trim().toLowerCase());
|
||||
startFieldIndex = formatFields.indexOf('start');
|
||||
endFieldIndex = formatFields.indexOf('end');
|
||||
textFieldIndex = formatFields.indexOf('text');
|
||||
fieldIndex.start = formatFields.indexOf('start');
|
||||
fieldIndex.end = formatFields.indexOf('end');
|
||||
fieldIndex.text = formatFields.indexOf('text');
|
||||
fieldIndex.style = formatFields.indexOf('style');
|
||||
fieldIndex.layer = formatFields.indexOf('layer');
|
||||
// Aegisub writes the speaker column as `Actor`; the v4+ spec calls it `Name`.
|
||||
// Missing it costs the burst check its speaker guard, so both spellings count.
|
||||
fieldIndex.name = findFieldIndex(formatFields, ASS_NAME_FIELD_ALIASES);
|
||||
fieldIndex.effect = formatFields.indexOf('effect');
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -127,34 +226,57 @@ export function parseAssCues(content: string): SubtitleCue[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startFieldIndex < 0 || endFieldIndex < 0 || textFieldIndex < 0) {
|
||||
if (fieldIndex.start < 0 || fieldIndex.end < 0 || fieldIndex.text < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(',');
|
||||
if (
|
||||
startFieldIndex >= fields.length ||
|
||||
endFieldIndex >= fields.length ||
|
||||
textFieldIndex >= fields.length
|
||||
fieldIndex.start >= fields.length ||
|
||||
fieldIndex.end >= fields.length ||
|
||||
fieldIndex.text >= fields.length
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startTime = parseAssTimestamp(fields[startFieldIndex]!);
|
||||
const endTime = parseAssTimestamp(fields[endFieldIndex]!);
|
||||
const startTime = parseAssTimestamp(fields[fieldIndex.start]!);
|
||||
const endTime = parseAssTimestamp(fields[fieldIndex.end]!);
|
||||
if (startTime === null || endTime === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = sanitizeSubtitleCueText(fields.slice(textFieldIndex).join(','));
|
||||
if (text) {
|
||||
cues.push({ startTime, endTime, text });
|
||||
const rawText = fields.slice(fieldIndex.text).join(',');
|
||||
const text = sanitizeSubtitleCueText(rawText);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const effect = readField(fields, fieldIndex.effect);
|
||||
const layer = Number(readField(fields, fieldIndex.layer));
|
||||
const overrides = collectAssOverrideCommands(rawText);
|
||||
cues.push({
|
||||
startTime,
|
||||
endTime,
|
||||
text,
|
||||
rawText,
|
||||
style: readField(fields, fieldIndex.style),
|
||||
layer: Number.isFinite(layer) ? layer : 0,
|
||||
name: readField(fields, fieldIndex.name),
|
||||
effect,
|
||||
effectKind: parseAssEffectField(effect),
|
||||
overrides,
|
||||
overrideSignature: assOverrideSignature(overrides),
|
||||
order: cues.length,
|
||||
});
|
||||
}
|
||||
|
||||
return cues;
|
||||
}
|
||||
|
||||
export function parseAssCues(content: string): SubtitleCue[] {
|
||||
return toPublicCues(parseAnnotatedAssCues(content));
|
||||
}
|
||||
|
||||
function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
|
||||
const [normalizedSource = source] =
|
||||
(() => {
|
||||
@@ -173,27 +295,31 @@ function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | n
|
||||
|
||||
export function parseSubtitleCues(content: string, filename: string): SubtitleCue[] {
|
||||
const format = detectSubtitleFormat(filename);
|
||||
let cues: SubtitleCue[];
|
||||
let cues: AnnotatedSubtitleCue[];
|
||||
let sourceFormat: SubtitleSourceFormat = 'srt';
|
||||
|
||||
switch (format) {
|
||||
case 'srt':
|
||||
case 'vtt':
|
||||
cues = parseSrtCues(content);
|
||||
cues = parseAnnotatedSrtCues(content);
|
||||
break;
|
||||
case 'ass':
|
||||
case 'ssa':
|
||||
cues = parseAssCues(content);
|
||||
cues = parseAnnotatedAssCues(content);
|
||||
sourceFormat = 'ass';
|
||||
break;
|
||||
default:
|
||||
cues = [];
|
||||
}
|
||||
|
||||
if (cues.length === 0) {
|
||||
const assCues = parseAssCues(content);
|
||||
const srtCues = parseSrtCues(content);
|
||||
cues = assCues.length >= srtCues.length ? assCues : srtCues;
|
||||
const assCues = parseAnnotatedAssCues(content);
|
||||
const srtCues = parseAnnotatedSrtCues(content);
|
||||
const preferAss = assCues.length >= srtCues.length;
|
||||
cues = preferAss ? assCues : srtCues;
|
||||
sourceFormat = preferAss && assCues.length > 0 ? 'ass' : 'srt';
|
||||
}
|
||||
|
||||
cues.sort((a, b) => a.startTime - b.startTime);
|
||||
return cues;
|
||||
cues.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime || a.order - b.order);
|
||||
return toPublicCues(mergeDuplicateCues(cues, sourceFormat));
|
||||
}
|
||||
|
||||
@@ -115,6 +115,21 @@ test('subtitle processing does not emit plain payload for cached lines', async (
|
||||
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
|
||||
});
|
||||
|
||||
test('text that normalizes to nothing is never cached', () => {
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: () => {},
|
||||
});
|
||||
|
||||
// Two different inputs both reduce to an empty key; sharing one entry would serve the
|
||||
// first one's tokens for the second.
|
||||
controller.preCacheTokenization(' ', { text: ' ', tokens: [] });
|
||||
|
||||
assert.equal(controller.hasCachedSubtitle(' '), false);
|
||||
assert.equal(controller.hasCachedSubtitle('\\n'), false);
|
||||
assert.equal(controller.consumeCachedSubtitle('\\n'), null);
|
||||
});
|
||||
|
||||
test('subtitle processing shows plain line while tokenization is still pending', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
import { normalizePlainSubtitleText } from './ass-text';
|
||||
|
||||
export interface SubtitleProcessingControllerDeps {
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
@@ -47,8 +48,15 @@ export interface SubtitleProcessingController {
|
||||
hasCachedSubtitle: (text: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetched cues and live mpv text are both already decoded from ASS, so the key only
|
||||
* has to settle whitespace for one authored line to resolve to one entry.
|
||||
*
|
||||
* An empty key is not a line: it is whatever normalization reduced to nothing. Callers
|
||||
* must skip the cache for it rather than let every such input share one entry.
|
||||
*/
|
||||
export function normalizeSubtitleCacheKey(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim();
|
||||
return normalizePlainSubtitleText(text);
|
||||
}
|
||||
|
||||
export function createSubtitleProcessingController(
|
||||
@@ -72,6 +80,9 @@ export function createSubtitleProcessingController(
|
||||
|
||||
const getCachedTokenization = (text: string): SubtitleData | null => {
|
||||
const cacheKey = normalizeSubtitleCacheKey(text);
|
||||
if (!cacheKey) {
|
||||
return null;
|
||||
}
|
||||
const cached = tokenizationCache.get(cacheKey);
|
||||
if (!cached) {
|
||||
return null;
|
||||
@@ -83,7 +94,11 @@ export function createSubtitleProcessingController(
|
||||
};
|
||||
|
||||
const setCachedTokenization = (text: string, payload: SubtitleData): void => {
|
||||
tokenizationCache.set(normalizeSubtitleCacheKey(text), payload);
|
||||
const cacheKey = normalizeSubtitleCacheKey(text);
|
||||
if (!cacheKey) {
|
||||
return;
|
||||
}
|
||||
tokenizationCache.set(cacheKey, payload);
|
||||
while (tokenizationCache.size > SUBTITLE_TOKENIZATION_CACHE_LIMIT) {
|
||||
const firstKey = tokenizationCache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
@@ -254,7 +269,8 @@ export function createSubtitleProcessingController(
|
||||
return cached;
|
||||
},
|
||||
hasCachedSubtitle: (text: string) => {
|
||||
return tokenizationCache.has(normalizeSubtitleCacheKey(text));
|
||||
const cacheKey = normalizeSubtitleCacheKey(text);
|
||||
return cacheKey.length > 0 && tokenizationCache.has(cacheKey);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1651,9 +1651,11 @@ test('tokenizeSubtitle clears JLPT level from standalone Yomitan particle token'
|
||||
assert.equal(result.tokens?.[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle returns null tokens for empty normalized text', async () => {
|
||||
test('tokenizeSubtitle returns the normalized text when it comes out empty', async () => {
|
||||
// Handing back the original would push whatever normalization dropped into app state
|
||||
// as if it were subtitle text.
|
||||
const result = await tokenizeSubtitle(' \\n ', makeDeps());
|
||||
assert.deepEqual(result, { text: ' \\n ', tokens: null });
|
||||
assert.deepEqual(result, { text: '', tokens: null });
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from './tokenizer/yomitan-parser-runtime';
|
||||
import type { YomitanTermFrequency } from './tokenizer/yomitan-parser-runtime';
|
||||
import { isKanaChar } from './tokenizer/token-classification';
|
||||
import { normalizePlainSubtitleText } from './ass-text';
|
||||
|
||||
const logger = createLogger('main:tokenizer');
|
||||
|
||||
@@ -886,14 +887,14 @@ export async function tokenizeSubtitle(
|
||||
text: string,
|
||||
deps: TokenizerServiceDeps,
|
||||
): Promise<SubtitleData> {
|
||||
const displayText = text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\\N/g, '\n')
|
||||
.replace(/\\n/g, '\n')
|
||||
.trim();
|
||||
const displayText = normalizePlainSubtitleText(text);
|
||||
|
||||
// ASS decoding already happened upstream (cue parser for files, mpv for live text), so
|
||||
// all this drops is whitespace -- but a whitespace-only line still normalizes to empty.
|
||||
// Return the normalized form anyway: handing back the original would put a blank line
|
||||
// into application state as if it were subtitle text.
|
||||
if (!displayText) {
|
||||
return { text, tokens: null };
|
||||
return { text: displayText, tokens: null };
|
||||
}
|
||||
|
||||
const tokenizeText = displayText
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { shouldForceX11ElectronBackend } from './electron-backend';
|
||||
import { resolveX11ElectronRelaunchArgs, shouldForceX11ElectronBackend } from './electron-backend';
|
||||
|
||||
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
|
||||
const original = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
@@ -32,3 +32,70 @@ test('shouldForceX11ElectronBackend is false off Linux', () => {
|
||||
assert.equal(shouldForceX11ElectronBackend({}), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveX11ElectronRelaunchArgs adds the raw X11 Ozone argument on unsupported Linux', () => {
|
||||
assert.deepEqual(
|
||||
resolveX11ElectronRelaunchArgs(
|
||||
['--start'],
|
||||
{
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_CURRENT_DESKTOP: 'KDE',
|
||||
},
|
||||
'linux',
|
||||
),
|
||||
['--start', '--ozone-platform=x11'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveX11ElectronRelaunchArgs avoids loops and preserves native Wayland backends', () => {
|
||||
const kdeWayland = {
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_CURRENT_DESKTOP: 'KDE',
|
||||
};
|
||||
assert.equal(
|
||||
resolveX11ElectronRelaunchArgs(['--start', '--ozone-platform=x11'], kdeWayland, 'linux'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveX11ElectronRelaunchArgs(
|
||||
['--start'],
|
||||
{ ...kdeWayland, HYPRLAND_INSTANCE_SIGNATURE: 'hypr' },
|
||||
'linux',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveX11ElectronRelaunchArgs(['--start'], kdeWayland, 'darwin'), null);
|
||||
assert.equal(
|
||||
resolveX11ElectronRelaunchArgs(
|
||||
[],
|
||||
{
|
||||
...kdeWayland,
|
||||
SUBMINER_APP_ARGC: '1',
|
||||
SUBMINER_APP_ARG_0: '--start',
|
||||
},
|
||||
'linux',
|
||||
)?.at(-1),
|
||||
'--ozone-platform=x11',
|
||||
);
|
||||
assert.equal(
|
||||
resolveX11ElectronRelaunchArgs([], { ...kdeWayland, SUBMINER_X11_BOOTSTRAPPED: '1' }, 'linux'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveX11ElectronRelaunchArgs replaces an explicit unsupported Wayland argument', () => {
|
||||
assert.deepEqual(
|
||||
resolveX11ElectronRelaunchArgs(
|
||||
['--start', '--ozone-platform', 'wayland'],
|
||||
{
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_CURRENT_DESKTOP: 'KDE',
|
||||
},
|
||||
'linux',
|
||||
),
|
||||
['--start', '--ozone-platform=x11'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,9 @@ import { isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
|
||||
|
||||
const logger = createLogger('core:electron-backend');
|
||||
|
||||
export const X11_ELECTRON_BOOTSTRAP_ENV = 'SUBMINER_X11_BOOTSTRAPPED';
|
||||
const X11_ELECTRON_OZONE_ARG = '--ozone-platform=x11';
|
||||
|
||||
function getElectronOzonePlatformHint(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
const hint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
|
||||
if (hint) return hint;
|
||||
@@ -24,11 +27,42 @@ function getElectronOzonePlatformHint(env: NodeJS.ProcessEnv = process.env): str
|
||||
* Electron Wayland backend is unsupported); the Hyprland/Sway case is left untouched so
|
||||
* {@link enforceUnsupportedWaylandMode} can report it.
|
||||
*/
|
||||
export function shouldForceX11ElectronBackend(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (process.platform !== 'linux') return false;
|
||||
export function shouldForceX11ElectronBackend(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
if (platform !== 'linux') return false;
|
||||
return !isSupportedWaylandCompositor(env);
|
||||
}
|
||||
|
||||
export function resolveX11ElectronRelaunchArgs(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string[] | null {
|
||||
if (!shouldForceX11ElectronBackend(env, platform)) return null;
|
||||
if (env[X11_ELECTRON_BOOTSTRAP_ENV] === '1') return null;
|
||||
|
||||
const retainedArgs: string[] = [];
|
||||
let alreadyForced = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--ozone-platform') {
|
||||
const value = args[index + 1];
|
||||
alreadyForced = value?.trim().toLowerCase() === 'x11';
|
||||
if (value && !value.startsWith('--')) index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg?.startsWith('--ozone-platform=')) {
|
||||
alreadyForced = arg.slice('--ozone-platform='.length).trim().toLowerCase() === 'x11';
|
||||
continue;
|
||||
}
|
||||
if (arg) retainedArgs.push(arg);
|
||||
}
|
||||
|
||||
return alreadyForced ? null : [...retainedArgs, X11_ELECTRON_OZONE_ARG];
|
||||
}
|
||||
|
||||
export function forceX11Backend(args: CliArgs): void {
|
||||
if (!shouldStartApp(args)) return;
|
||||
if (!shouldForceX11ElectronBackend()) return;
|
||||
|
||||
@@ -52,9 +52,15 @@ function resolveRuntimeDefaultNotificationIconPath(): string | null {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Live notifications keyed by `replaceId`. Electron exposes no native "replace this notification"
|
||||
* flag, so a repeated status closes its predecessor instead of stacking a fresh toast per update.
|
||||
*/
|
||||
const notificationsByReplaceId = new Map<string, Electron.Notification>();
|
||||
|
||||
export function showDesktopNotification(
|
||||
title: string,
|
||||
options: { body?: string; icon?: string },
|
||||
options: { body?: string; icon?: string; replaceId?: string },
|
||||
): void {
|
||||
const notificationOptions: {
|
||||
title: string;
|
||||
@@ -98,5 +104,15 @@ export function showDesktopNotification(
|
||||
}
|
||||
|
||||
const notification = new Notification(notificationOptions);
|
||||
const replaceId = options.replaceId?.trim();
|
||||
if (replaceId) {
|
||||
notificationsByReplaceId.get(replaceId)?.close();
|
||||
notificationsByReplaceId.set(replaceId, notification);
|
||||
notification.once('close', () => {
|
||||
if (notificationsByReplaceId.get(replaceId) === notification) {
|
||||
notificationsByReplaceId.delete(replaceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
notification.show();
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeTitleIdentity } from './title-normalization';
|
||||
|
||||
test('normalizeTitleIdentity produces a Unicode-aware comparison key', () => {
|
||||
assert.equal(normalizeTitleIdentity(' BOCCHI・The ROCK!! '), 'bocchi the rock');
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
export function normalizeTitleIdentity(title: string): string {
|
||||
return title
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
@@ -25,8 +25,24 @@ import {
|
||||
applyBackgroundBootstrapCommandLineSwitches,
|
||||
applyEarlyLinuxCommandLineSwitches,
|
||||
resolveLinuxPasswordStoreValue,
|
||||
spawnDetachedApp,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('detached app launch policy stays in the startup runtime utilities', () => {
|
||||
const entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8');
|
||||
const runtimeSource = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src/main-entry-runtime.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.equal(typeof spawnDetachedApp, 'function');
|
||||
assert.doesNotMatch(entrySource, /function spawnDetachedApp/);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/child\.once\('error', \(error\) => \{\s*console\.error\([^;]*error\);\s*\}\);\s*child\.unref\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('background bootstrap exits through Electron so Chromium children shut down', () => {
|
||||
const exitCodes: number[] = [];
|
||||
exitBackgroundBootstrap({ exit: (code) => exitCodes.push(code) });
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
||||
import { resolveConfigDir } from './config/path-resolution';
|
||||
import { resolveAppImageMountKeepaliveInvocation } from './main/appimage-mount-keepalive';
|
||||
|
||||
const BACKGROUND_ARG = '--background';
|
||||
const START_ARG = '--start';
|
||||
@@ -265,6 +267,25 @@ export function exitBackgroundBootstrap(app: BackgroundBootstrapAppLike): void {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
export function spawnDetachedApp(childArgs: string[], env: NodeJS.ProcessEnv): void {
|
||||
const keepalive = resolveAppImageMountKeepaliveInvocation(env);
|
||||
const child = keepalive
|
||||
? spawn(keepalive.command, [...keepalive.args, ...childArgs], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env,
|
||||
})
|
||||
: spawn(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env,
|
||||
});
|
||||
child.once('error', (error) => {
|
||||
console.error('Failed to spawn detached SubMiner app:', error);
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||
const args = parseCliArgs(argv);
|
||||
|
||||
+19
-16
@@ -1,5 +1,4 @@
|
||||
import os from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import { printHelp } from './cli/help';
|
||||
import {
|
||||
@@ -20,9 +19,9 @@ import {
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
spawnDetachedApp,
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import { resolveAppImageMountKeepaliveInvocation } from './main/appimage-mount-keepalive';
|
||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
||||
import {
|
||||
@@ -44,6 +43,10 @@ import {
|
||||
resolveDefaultLogFilePath,
|
||||
type LogRotation,
|
||||
} from './shared/log-files';
|
||||
import {
|
||||
resolveX11ElectronRelaunchArgs,
|
||||
X11_ELECTRON_BOOTSTRAP_ENV,
|
||||
} from './core/utils/electron-backend';
|
||||
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
|
||||
@@ -296,26 +299,26 @@ async function runEntryProcess(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
||||
const x11ChildArgs = resolveX11ElectronRelaunchArgs(childArgs, process.env);
|
||||
|
||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
||||
const keepalive = resolveAppImageMountKeepaliveInvocation(process.env);
|
||||
const child = keepalive
|
||||
? spawn(keepalive.command, [...keepalive.args, ...childArgs], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: sanitizeBackgroundEnv(process.env),
|
||||
})
|
||||
: spawn(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: sanitizeBackgroundEnv(process.env),
|
||||
});
|
||||
child.unref();
|
||||
const childEnv = sanitizeBackgroundEnv(process.env);
|
||||
if (x11ChildArgs) childEnv[X11_ELECTRON_BOOTSTRAP_ENV] = '1';
|
||||
spawnDetachedApp(x11ChildArgs ?? childArgs, childEnv);
|
||||
// Let Electron stop bootstrap Chromium children before its AppImage mount is released.
|
||||
exitBackgroundBootstrap(app);
|
||||
return;
|
||||
}
|
||||
|
||||
if (x11ChildArgs) {
|
||||
const childEnv = sanitizeStartupEnv(process.env);
|
||||
childEnv[X11_ELECTRON_BOOTSTRAP_ENV] = '1';
|
||||
spawnDetachedApp(x11ChildArgs, childEnv);
|
||||
exitBackgroundBootstrap(app);
|
||||
return;
|
||||
}
|
||||
|
||||
startMainProcess();
|
||||
}
|
||||
|
||||
|
||||
-10
@@ -256,7 +256,6 @@ import {
|
||||
import {
|
||||
enforceUnsupportedWaylandMode,
|
||||
forceX11Backend,
|
||||
shouldForceX11ElectronBackend,
|
||||
generateDefaultConfigFile,
|
||||
resolveConfiguredShortcuts,
|
||||
resolveKeybindings,
|
||||
@@ -597,15 +596,6 @@ if (process.platform === 'linux') {
|
||||
);
|
||||
app.commandLine.appendSwitch('password-store', passwordStore);
|
||||
createLogger('main').debug(`Applied --password-store ${passwordStore}`);
|
||||
// Pin the overlay to XWayland on unsupported Wayland sessions (everything except
|
||||
// Hyprland/Sway). `setAlwaysOnTop`/`moveTop` are no-ops under a native Wayland surface,
|
||||
// so the overlay can only stay above mpv under X11/XWayland. The command-line switch is
|
||||
// applied at module load (before app init) so it reliably wins over the late env-var
|
||||
// fallback in forceX11Backend().
|
||||
if (shouldForceX11ElectronBackend(process.env)) {
|
||||
app.commandLine.appendSwitch('ozone-platform-hint', 'x11');
|
||||
createLogger('main').debug('Forced ozone-platform-hint=x11 for XWayland overlay stacking');
|
||||
}
|
||||
}
|
||||
|
||||
app.setName('SubMiner');
|
||||
|
||||
@@ -1903,7 +1903,7 @@ test('generateForCurrentMedia logs progress while resolving and rebuilding snaps
|
||||
'[dictionary] current anime guess: The Eminence in Shadow (episode 5)',
|
||||
'[dictionary] AniList match: The Eminence in Shadow -> AniList 130298',
|
||||
'[dictionary] snapshot miss for AniList 130298, fetching characters',
|
||||
'[dictionary] downloaded AniList character page 1 for AniList 130298',
|
||||
'[dictionary] downloaded AniList character page 1 for AniList 130298 (1 characters)',
|
||||
'[dictionary] downloading 1 images for AniList 130298',
|
||||
'[dictionary] stored snapshot for AniList 130298: 16 terms',
|
||||
'[dictionary] building ZIP for AniList 130298',
|
||||
|
||||
@@ -64,6 +64,7 @@ export type {
|
||||
CharacterDictionarySnapshotProgress,
|
||||
CharacterDictionarySnapshotProgressCallbacks,
|
||||
CharacterDictionarySnapshotResult,
|
||||
CharacterDictionarySnapshotStageProgress,
|
||||
MergedCharacterDictionaryBuildResult,
|
||||
} from './character-dictionary-runtime/types';
|
||||
|
||||
@@ -363,19 +364,28 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
deps.logInfo?.(`[dictionary] snapshot stale for AniList ${mediaId}: ${refreshReason}`);
|
||||
}
|
||||
|
||||
const progressMediaTitle = mediaTitleHint || `AniList ${mediaId}`;
|
||||
progress?.onGenerating?.({
|
||||
mediaId,
|
||||
mediaTitle: mediaTitleHint || `AniList ${mediaId}`,
|
||||
mediaTitle: progressMediaTitle,
|
||||
});
|
||||
deps.logInfo?.(`[dictionary] snapshot miss for AniList ${mediaId}, fetching characters`);
|
||||
|
||||
const { mediaTitle: fetchedMediaTitle, characters } = await fetchCharactersForMedia(
|
||||
mediaId,
|
||||
beforeRequest,
|
||||
(page) => {
|
||||
(page, charactersSoFar) => {
|
||||
deps.logInfo?.(
|
||||
`[dictionary] downloaded AniList character page ${page} for AniList ${mediaId}`,
|
||||
`[dictionary] downloaded AniList character page ${page} for AniList ${mediaId} (${charactersSoFar} characters)`,
|
||||
);
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId,
|
||||
mediaTitle: progressMediaTitle,
|
||||
stage: 'characters',
|
||||
completed: charactersSoFar,
|
||||
total: null,
|
||||
page,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (characters.length === 0) {
|
||||
@@ -403,12 +413,26 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
);
|
||||
}
|
||||
let hasAttemptedImageDownload = false;
|
||||
let attemptedImageCount = 0;
|
||||
for (const entry of allImageUrls) {
|
||||
if (hasAttemptedImageDownload) {
|
||||
await sleepMs(CHARACTER_IMAGE_DOWNLOAD_DELAY_MS);
|
||||
}
|
||||
hasAttemptedImageDownload = true;
|
||||
const image = await downloadCharacterImage(entry.url, entry.id);
|
||||
attemptedImageCount += 1;
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId,
|
||||
mediaTitle: progressMediaTitle,
|
||||
stage: 'images',
|
||||
completed: attemptedImageCount,
|
||||
total: allImageUrls.length,
|
||||
});
|
||||
if (attemptedImageCount % 100 === 0) {
|
||||
deps.logInfo?.(
|
||||
`[dictionary] downloaded ${attemptedImageCount}/${allImageUrls.length} images for AniList ${mediaId}`,
|
||||
);
|
||||
}
|
||||
if (!image) continue;
|
||||
if (entry.kind === 'character') {
|
||||
imagesByCharacterId.set(entry.id, {
|
||||
@@ -425,11 +449,31 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
|
||||
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
|
||||
const resolvedNameSplits = nameSplitTokenizerAvailable
|
||||
? await resolveJapaneseNameSplits(characters, deps.tokenizeJapaneseName!, deps.logWarn)
|
||||
? await resolveJapaneseNameSplits(
|
||||
characters,
|
||||
deps.tokenizeJapaneseName!,
|
||||
deps.logWarn,
|
||||
(completed, total) => {
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId,
|
||||
mediaTitle: progressMediaTitle,
|
||||
stage: 'names',
|
||||
completed,
|
||||
total,
|
||||
});
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
const nameSplitSource =
|
||||
resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic';
|
||||
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId,
|
||||
mediaTitle: progressMediaTitle,
|
||||
stage: 'saving',
|
||||
completed: 0,
|
||||
total: null,
|
||||
});
|
||||
const snapshot = buildSnapshotFromCharacters(
|
||||
mediaId,
|
||||
fetchedMediaTitle || mediaTitleHint || `AniList ${mediaId}`,
|
||||
|
||||
@@ -278,7 +278,7 @@ export async function fetchAniListMediaCandidateById(
|
||||
export async function fetchCharactersForMedia(
|
||||
mediaId: number,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
onPageFetched?: (page: number) => void,
|
||||
onPageFetched?: (page: number, charactersSoFar: number) => void,
|
||||
): Promise<{
|
||||
mediaTitle: string;
|
||||
characters: CharacterRecord[];
|
||||
@@ -345,7 +345,6 @@ export async function fetchCharactersForMedia(
|
||||
},
|
||||
beforeRequest,
|
||||
);
|
||||
onPageFetched?.(page);
|
||||
|
||||
const media = data.Media;
|
||||
if (!media) {
|
||||
@@ -415,6 +414,8 @@ export async function fetchCharactersForMedia(
|
||||
});
|
||||
}
|
||||
|
||||
onPageFetched?.(page, characters.length);
|
||||
|
||||
const hasNextPage = Boolean(media.characters?.pageInfo?.hasNextPage);
|
||||
if (!hasNextPage) {
|
||||
break;
|
||||
|
||||
@@ -86,8 +86,10 @@ export async function resolveJapaneseNameSplits(
|
||||
characters: CharacterRecord[],
|
||||
tokenize: NameSplitTokenizer,
|
||||
logWarn?: (message: string) => void,
|
||||
onCharacterResolved?: (completed: number, total: number) => void,
|
||||
): Promise<Map<string, ResolvedNameSplit>> {
|
||||
const splits = new Map<string, ResolvedNameSplit>();
|
||||
let resolvedCharacters = 0;
|
||||
for (const character of characters) {
|
||||
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
|
||||
const givenHintReading = buildReadingFromHint(character.firstNameHint?.trim() || '');
|
||||
@@ -113,6 +115,8 @@ export async function resolveJapaneseNameSplits(
|
||||
splits.set(name, { family, given });
|
||||
}
|
||||
}
|
||||
resolvedCharacters += 1;
|
||||
onCharacterResolved?.(resolvedCharacters, characters.length);
|
||||
}
|
||||
return splits;
|
||||
}
|
||||
|
||||
@@ -122,9 +122,24 @@ export type CharacterDictionarySnapshotProgress = {
|
||||
mediaTitle: string;
|
||||
};
|
||||
|
||||
export type CharacterDictionarySnapshotStage = 'characters' | 'images' | 'names' | 'saving';
|
||||
|
||||
/**
|
||||
* Fine-grained generation progress. `total` is null while the work size is still unknown (AniList
|
||||
* paginates characters, so the character count only settles on the last page).
|
||||
*/
|
||||
export type CharacterDictionarySnapshotStageProgress = CharacterDictionarySnapshotProgress & {
|
||||
stage: CharacterDictionarySnapshotStage;
|
||||
completed: number;
|
||||
total: number | null;
|
||||
/** AniList page currently being downloaded; only set during the `characters` stage. */
|
||||
page?: number;
|
||||
};
|
||||
|
||||
export type CharacterDictionarySnapshotProgressCallbacks = {
|
||||
onChecking?: (progress: CharacterDictionarySnapshotProgress) => void;
|
||||
onGenerating?: (progress: CharacterDictionarySnapshotProgress) => void;
|
||||
onGenerateProgress?: (progress: CharacterDictionarySnapshotStageProgress) => void;
|
||||
};
|
||||
|
||||
export type MergedCharacterDictionaryBuildResult = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { buildDictionaryZip } from './zip';
|
||||
import { buildDictionaryZip, readDictionaryZipRevision } from './zip';
|
||||
import type { CharacterDictionaryTermEntry } from './types';
|
||||
|
||||
function makeTempDir(): string {
|
||||
@@ -105,3 +105,63 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
|
||||
cleanupDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('readDictionaryZipRevision reads the built revision and rejects foreign archives', () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const zipPath = path.join(dir, 'merged.zip');
|
||||
buildDictionaryZip(
|
||||
zipPath,
|
||||
'SubMiner Character Dictionary',
|
||||
'Character names',
|
||||
'rev-42',
|
||||
[
|
||||
{
|
||||
term: 'ルフィ',
|
||||
reading: 'ルフィ',
|
||||
role: 'main',
|
||||
glossary: [],
|
||||
} as unknown as CharacterDictionaryTermEntry,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
assert.equal(readDictionaryZipRevision(zipPath), 'rev-42');
|
||||
assert.equal(readDictionaryZipRevision(path.join(dir, 'missing.zip')), null);
|
||||
|
||||
const archive = fs.readFileSync(zipPath);
|
||||
const truncatedPath = path.join(dir, 'truncated.zip');
|
||||
fs.writeFileSync(truncatedPath, archive.subarray(0, 40));
|
||||
assert.equal(readDictionaryZipRevision(truncatedPath), null);
|
||||
|
||||
// An archive cut short after index.json still holds a readable revision, but importing it
|
||||
// would hand Yomitan a half-written file: the missing end-of-central-directory record has to
|
||||
// reject it. One byte off the end is enough to make the record incomplete.
|
||||
for (const missingBytes of [1, 22, archive.length - 200]) {
|
||||
const cutPath = path.join(dir, `cut-${missingBytes}.zip`);
|
||||
fs.writeFileSync(cutPath, archive.subarray(0, archive.length - missingBytes));
|
||||
assert.equal(readDictionaryZipRevision(cutPath), null, `cut of ${missingBytes} bytes`);
|
||||
}
|
||||
|
||||
// Same size, corrupt directory: a record overwritten in place has to be rejected too.
|
||||
const centralStart = archive.readUInt32LE(archive.length - 22 + 16);
|
||||
const brokenSignaturePath = path.join(dir, 'broken-signature.zip');
|
||||
const brokenSignature = Buffer.from(archive);
|
||||
brokenSignature.writeUInt32LE(0xdeadbeef, centralStart);
|
||||
fs.writeFileSync(brokenSignaturePath, brokenSignature);
|
||||
assert.equal(readDictionaryZipRevision(brokenSignaturePath), null);
|
||||
|
||||
const brokenLengthPath = path.join(dir, 'broken-length.zip');
|
||||
const brokenLength = Buffer.from(archive);
|
||||
// Name length that runs the walk past the end of the directory.
|
||||
brokenLength.writeUInt16LE(0xffff, centralStart + 28);
|
||||
fs.writeFileSync(brokenLengthPath, brokenLength);
|
||||
assert.equal(readDictionaryZipRevision(brokenLengthPath), null);
|
||||
|
||||
const foreignPath = path.join(dir, 'foreign.zip');
|
||||
fs.writeFileSync(foreignPath, Buffer.from('not a zip at all', 'utf8'));
|
||||
assert.equal(readDictionaryZipRevision(foreignPath), null);
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as path from 'path';
|
||||
import { writeStoredZip } from '../../shared/stored-zip';
|
||||
import { readStoredZipFirstFile, writeStoredZip } from '../../shared/stored-zip';
|
||||
import { ensureDir } from './fs-utils';
|
||||
import type { CharacterDictionarySnapshotImage, CharacterDictionaryTermEntry } from './types';
|
||||
|
||||
@@ -31,6 +31,23 @@ function createTagBank(): Array<[string, string, number, string, number]> {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Revision recorded inside a built dictionary ZIP, or null when the archive is missing, truncated,
|
||||
* or not one of ours. `index.json` is always the first entry written by {@link buildDictionaryZip}.
|
||||
*/
|
||||
export function readDictionaryZipRevision(zipPath: string): string | null {
|
||||
const firstFile = readStoredZipFirstFile(zipPath);
|
||||
if (!firstFile || firstFile.name !== 'index.json') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const index = JSON.parse(firstFile.data.toString('utf8')) as { revision?: unknown };
|
||||
return typeof index.revision === 'string' && index.revision.length > 0 ? index.revision : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDictionaryZip(
|
||||
outputPath: string,
|
||||
dictionaryTitle: string,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { CharacterDictionarySnapshotStageProgress } from '../character-dictionary-runtime';
|
||||
|
||||
export function buildSyncingMessage(mediaTitle: string): string {
|
||||
return `Updating character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
export function buildCheckingMessage(mediaTitle: string): string {
|
||||
return `Checking character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
export function buildGeneratingMessage(mediaTitle: string, detail?: string): string {
|
||||
return detail
|
||||
? `Generating character dictionary for ${mediaTitle} (${detail})...`
|
||||
: `Generating character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
export function formatCharacterDictionaryProgressDetail(
|
||||
progress: CharacterDictionarySnapshotStageProgress,
|
||||
remainingMs: number | null,
|
||||
): string {
|
||||
if (progress.stage === 'saving') {
|
||||
return 'saving snapshot';
|
||||
}
|
||||
if (progress.stage === 'names') {
|
||||
return progress.total !== null && progress.total > 0
|
||||
? `name ${progress.completed}/${progress.total}`
|
||||
: `${progress.completed} names`;
|
||||
}
|
||||
if (progress.stage === 'images') {
|
||||
const counted =
|
||||
progress.total !== null && progress.total > 0
|
||||
? `image ${progress.completed}/${progress.total}`
|
||||
: `${progress.completed} images`;
|
||||
return remainingMs !== null
|
||||
? `${counted}, ~${formatRemainingDuration(remainingMs)} left`
|
||||
: counted;
|
||||
}
|
||||
const page = typeof progress.page === 'number' ? `page ${progress.page}, ` : '';
|
||||
return `${page}${progress.completed} characters`;
|
||||
}
|
||||
|
||||
export function formatElapsedDuration(elapsedMs: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, '0')}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
/** Coarser than the elapsed clock: an estimate that ticks every second reads as precision it lacks. */
|
||||
export function formatRemainingDuration(remainingMs: number): string {
|
||||
const totalSeconds = Math.max(1, Math.round(remainingMs / 1000));
|
||||
if (totalSeconds >= 60) {
|
||||
return `${Math.max(1, Math.round(totalSeconds / 60))}m`;
|
||||
}
|
||||
return `${Math.max(5, Math.ceil(totalSeconds / 5) * 5)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The elapsed clock is the part that proves the app is alive: a stalled network fetch freezes the
|
||||
* counts, but the clock keeps moving.
|
||||
*/
|
||||
export function joinGeneratingDetail(detail: string | null, elapsedMs: number): string | undefined {
|
||||
const parts = [detail, elapsedMs >= 5_000 ? formatElapsedDuration(elapsedMs) : null].filter(
|
||||
(part): part is string => typeof part === 'string' && part.length > 0,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined;
|
||||
}
|
||||
|
||||
export function buildImportingMessage(mediaTitle: string, elapsedMs?: number): string {
|
||||
const elapsed =
|
||||
typeof elapsedMs === 'number' && elapsedMs >= 1000
|
||||
? ` (${formatElapsedDuration(elapsedMs)})`
|
||||
: '';
|
||||
return `Importing character dictionary for ${mediaTitle}${elapsed}...`;
|
||||
}
|
||||
|
||||
export function buildBuildingMessage(mediaTitle: string): string {
|
||||
return `Building character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
export function buildReadyMessage(mediaTitle: string): string {
|
||||
return `Character dictionary ready for ${mediaTitle}`;
|
||||
}
|
||||
|
||||
export function buildFailedMessage(mediaTitle: string | null, errorMessage: string): string {
|
||||
if (mediaTitle) {
|
||||
return `Character dictionary sync failed for ${mediaTitle}: ${errorMessage}`;
|
||||
}
|
||||
return `Character dictionary sync failed: ${errorMessage}`;
|
||||
}
|
||||
@@ -214,3 +214,69 @@ test('auto sync notifications let startup sequencer own osd-system desktop deliv
|
||||
|
||||
assert.deepEqual(calls, ['osd:importing', 'desktop:SubMiner:importing']);
|
||||
});
|
||||
|
||||
test('auto sync desktop notifications reuse one replace id across every phase', () => {
|
||||
const replaceIds: Array<string | undefined> = [];
|
||||
const deps = {
|
||||
getNotificationType: () => 'system' as const,
|
||||
showOsd: () => undefined,
|
||||
showDesktopNotification: (_title: string, options: { body?: string; replaceId?: string }) => {
|
||||
replaceIds.push(options.replaceId);
|
||||
},
|
||||
};
|
||||
|
||||
for (const phase of ['checking', 'generating', 'importing', 'ready'] as const) {
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent(phase, phase), deps);
|
||||
}
|
||||
|
||||
assert.deepEqual(replaceIds, [
|
||||
'character-dictionary-auto-sync',
|
||||
'character-dictionary-auto-sync',
|
||||
'character-dictionary-auto-sync',
|
||||
'character-dictionary-auto-sync',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay-unavailable desktop fallback shares the same replace id', () => {
|
||||
const replaceIds: Array<string | undefined> = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), {
|
||||
getNotificationType: () => 'overlay',
|
||||
showOsd: () => undefined,
|
||||
showDesktopNotification: (_title, options) => {
|
||||
replaceIds.push(options.replaceId);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(replaceIds, ['character-dictionary-auto-sync']);
|
||||
});
|
||||
|
||||
test('startup lanes keep one desktop notification per lane', () => {
|
||||
const calls: Array<{ body?: string; replaceId?: string }> = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
getNotificationType: () => 'system',
|
||||
showOsd: () => undefined,
|
||||
showDesktopNotification: (_title, options) => {
|
||||
calls.push(options);
|
||||
},
|
||||
});
|
||||
|
||||
sequencer.markTokenizationReady();
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating one'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: () => undefined,
|
||||
showDesktopNotification: () => undefined,
|
||||
startupOsdSequencer: sequencer,
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating two'), {
|
||||
getNotificationType: () => 'osd',
|
||||
showOsd: () => undefined,
|
||||
showDesktopNotification: () => undefined,
|
||||
startupOsdSequencer: sequencer,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.replaceId),
|
||||
['startup-status', 'startup-status'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
|
||||
getNotificationType: () => NotificationType | undefined;
|
||||
showOsd: (message: string) => boolean | void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; replaceId?: string }) => void;
|
||||
startupOsdSequencer?: {
|
||||
notifyCharacterDictionaryStatus: (
|
||||
event: StartupOsdSequencerCharacterDictionaryEvent,
|
||||
@@ -17,6 +17,10 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
|
||||
};
|
||||
}
|
||||
|
||||
// One live desktop notification for the whole sync: progress updates replace each other and the
|
||||
// terminal ready/failed message replaces the last progress one, matching the overlay toast.
|
||||
const CHARACTER_DICTIONARY_DESKTOP_NOTIFICATION_ID = 'character-dictionary-auto-sync';
|
||||
|
||||
function isTerminalPhase(phase: CharacterDictionaryAutoSyncNotificationEvent['phase']): boolean {
|
||||
return phase === 'ready' || phase === 'failed';
|
||||
}
|
||||
@@ -53,7 +57,10 @@ export function notifyCharacterDictionaryAutoSyncStatus(
|
||||
persistent: !isTerminalPhase(event.phase),
|
||||
});
|
||||
} else if (!shouldShowDesktop(type)) {
|
||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||
deps.showDesktopNotification('SubMiner', {
|
||||
body: event.message,
|
||||
replaceId: CHARACTER_DICTIONARY_DESKTOP_NOTIFICATION_ID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +76,9 @@ export function notifyCharacterDictionaryAutoSyncStatus(
|
||||
}
|
||||
|
||||
if (shouldShowDesktop(type) && !startupSequencerShown) {
|
||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||
deps.showDesktopNotification('SubMiner', {
|
||||
body: event.message,
|
||||
replaceId: CHARACTER_DICTIONARY_DESKTOP_NOTIFICATION_ID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import test from 'node:test';
|
||||
import { buildDictionaryZip } from '../character-dictionary-runtime/zip';
|
||||
import {
|
||||
createCharacterDictionaryAutoSyncRuntimeService,
|
||||
getCharacterDictionaryManagerSnapshot,
|
||||
@@ -14,6 +15,14 @@ function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean, label: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
@@ -187,7 +196,7 @@ test('auto sync imports merged dictionary and persists MRU state', async () => {
|
||||
'[dictionary:auto-sync] syncing current anime snapshot',
|
||||
'[dictionary:auto-sync] active AniList media set: 130298 - The Eminence in Shadow',
|
||||
'[dictionary:auto-sync] rebuilding merged dictionary for active anime set',
|
||||
'[dictionary:auto-sync] importing merged dictionary: /tmp/subminer-character-dictionary.zip',
|
||||
'[dictionary:auto-sync] importing merged dictionary: /tmp/subminer-character-dictionary.zip (timeout 120000ms)',
|
||||
'[dictionary:auto-sync] applying Yomitan settings for SubMiner Character Dictionary',
|
||||
'[dictionary:auto-sync] synced AniList 130298: SubMiner Character Dictionary (2544 entries)',
|
||||
]);
|
||||
@@ -367,7 +376,14 @@ test('auto sync reimports existing merged zip without rebuilding on unchanged re
|
||||
const userDataPath = makeTempDir();
|
||||
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(dictionariesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dictionariesDir, 'merged.zip'), 'cached-zip', 'utf8');
|
||||
buildDictionaryZip(
|
||||
path.join(dictionariesDir, 'merged.zip'),
|
||||
'SubMiner Character Dictionary',
|
||||
'Character names',
|
||||
'rev-7',
|
||||
[{ term: 'フリーレン', reading: 'フリーレン', role: 'main', glossary: [] } as never],
|
||||
[],
|
||||
);
|
||||
const mergedBuilds: number[][] = [];
|
||||
const imports: string[] = [];
|
||||
let importedRevision: string | null = null;
|
||||
@@ -958,11 +974,9 @@ test('auto sync emits building while merged dictionary generation is in flight',
|
||||
});
|
||||
|
||||
const syncPromise = runtime.runSyncNow();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(
|
||||
events.some((event) => event.phase === 'building'),
|
||||
true,
|
||||
await waitUntil(
|
||||
() => events.some((event) => event.phase === 'building'),
|
||||
'the building status event',
|
||||
);
|
||||
|
||||
buildDeferred.resolve({
|
||||
@@ -1029,8 +1043,7 @@ test('auto sync waits for tokenization-ready gate before Yomitan mutations', asy
|
||||
});
|
||||
|
||||
const syncPromise = runtime.runSyncNow();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await waitUntil(() => calls.includes('wait'), 'the tokenization-ready gate');
|
||||
|
||||
assert.deepEqual(calls, ['build', 'wait']);
|
||||
|
||||
@@ -1039,3 +1052,395 @@ test('auto sync waits for tokenization-ready gate before Yomitan mutations', asy
|
||||
|
||||
assert.deepEqual(calls, ['build', 'wait', 'info', 'import', 'settings']);
|
||||
});
|
||||
|
||||
test('auto sync scales the import timeout with the merged dictionary size', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(dictionariesDir, { recursive: true });
|
||||
const zipPath = path.join(dictionariesDir, 'merged.zip');
|
||||
// 2 MB of merged dictionary buys ~12s of import budget on top of the base.
|
||||
fs.writeFileSync(zipPath, Buffer.alloc(2 * 1024 * 1024));
|
||||
const events: Array<{ phase: string; message: string }> = [];
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
entryCount: 4000,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath,
|
||||
revision: 'rev-21',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 4000,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async () => {
|
||||
// Far longer than the quick-operation budget, well inside the size-scaled one.
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
importedRevision = 'rev-21';
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
// Comfortable for the stubs that resolve immediately, still far under the import's 400ms.
|
||||
operationTimeoutMs: 100,
|
||||
dictionaryImportTimeoutBaseMs: 20,
|
||||
onSyncStatus: (event) => {
|
||||
events.push({ phase: event.phase, message: event.message });
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.equal(
|
||||
events.some((event) => event.phase === 'failed'),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(events.at(-1), {
|
||||
phase: 'ready',
|
||||
message: 'Character dictionary ready for ONE PIECE',
|
||||
});
|
||||
});
|
||||
|
||||
test('auto sync reports the scaled budget when an import really does hang', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const events: Array<{ phase: string; message: string }> = [];
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
entryCount: 4000,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: path.join(userDataPath, 'character-dictionaries', 'missing.zip'),
|
||||
revision: 'rev-21',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 4000,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: () => new Promise<boolean>(() => {}),
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
dictionaryImportTimeoutBaseMs: 20,
|
||||
onSyncStatus: (event) => {
|
||||
events.push({ phase: event.phase, message: event.message });
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
runtime.runSyncNow(),
|
||||
/importYomitanDictionary\(missing\.zip\) timed out after 20ms/,
|
||||
);
|
||||
assert.equal(events.at(-1)?.phase, 'failed');
|
||||
});
|
||||
|
||||
test('auto sync ticks the importing notification while the import runs', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const events: Array<{ phase: string; message: string }> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const importDeferred = createDeferred<boolean>();
|
||||
let clock = 1000;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
entryCount: 4000,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-21',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 4000,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: () => importDeferred.promise,
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => clock,
|
||||
schedule: (fn) => {
|
||||
scheduled.push(fn);
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearSchedule: () => undefined,
|
||||
onSyncStatus: (event) => {
|
||||
events.push({ phase: event.phase, message: event.message });
|
||||
},
|
||||
});
|
||||
|
||||
const syncPromise = runtime.runSyncNow();
|
||||
await waitUntil(
|
||||
() => events.some((event) => event.phase === 'importing'),
|
||||
'the importing status event',
|
||||
);
|
||||
|
||||
clock = 1000 + 65_000;
|
||||
// The importing heartbeat is the most recently scheduled tick.
|
||||
scheduled.at(-1)!();
|
||||
|
||||
assert.deepEqual(events.at(-1), {
|
||||
phase: 'importing',
|
||||
message: 'Importing character dictionary for ONE PIECE (1m 05s)...',
|
||||
});
|
||||
|
||||
importDeferred.resolve(true);
|
||||
await syncPromise;
|
||||
assert.equal(events.at(-1)?.phase, 'ready');
|
||||
});
|
||||
|
||||
test('auto sync reports character and image counts while generating a snapshot', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const events: Array<{ phase: string; message: string }> = [];
|
||||
let clock = 1000;
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
|
||||
progress?.onGenerating?.({ mediaId: 21, mediaTitle: 'ONE PIECE' });
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'characters',
|
||||
completed: 50,
|
||||
total: null,
|
||||
page: 12,
|
||||
});
|
||||
// Same stage, same clock tick: throttled away so a 33-page fetch cannot spam the overlay.
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'characters',
|
||||
completed: 100,
|
||||
total: null,
|
||||
page: 13,
|
||||
});
|
||||
// A stage change always reports, throttle window or not.
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'images',
|
||||
completed: 1,
|
||||
total: 1220,
|
||||
});
|
||||
clock += 2000;
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'images',
|
||||
completed: 240,
|
||||
total: 1220,
|
||||
});
|
||||
clock += 6000;
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'names',
|
||||
completed: 800,
|
||||
total: 1220,
|
||||
});
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'saving',
|
||||
completed: 0,
|
||||
total: null,
|
||||
});
|
||||
return {
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
entryCount: 4000,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
};
|
||||
},
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-21',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 4000,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async () => {
|
||||
importedRevision = 'rev-21';
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => clock,
|
||||
onSyncStatus: (event) => {
|
||||
events.push({ phase: event.phase, message: event.message });
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.phase === 'generating').map((event) => event.message),
|
||||
[
|
||||
'Generating character dictionary for ONE PIECE...',
|
||||
'Generating character dictionary for ONE PIECE (page 12, 50 characters)...',
|
||||
'Generating character dictionary for ONE PIECE (image 1/1220)...',
|
||||
'Generating character dictionary for ONE PIECE (image 240/1220, ~10s left)...',
|
||||
'Generating character dictionary for ONE PIECE (name 800/1220 · 8s)...',
|
||||
'Generating character dictionary for ONE PIECE (saving snapshot · 8s)...',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('auto sync keeps the generating clock ticking when a stage stalls', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const events: Array<{ phase: string; message: string }> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const snapshotDeferred = createDeferred<{
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
fromCache: boolean;
|
||||
updatedAt: number;
|
||||
}>();
|
||||
let clock = 1000;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
|
||||
progress?.onGenerating?.({ mediaId: 21, mediaTitle: 'ONE PIECE' });
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'images',
|
||||
completed: 240,
|
||||
total: 1220,
|
||||
});
|
||||
return await snapshotDeferred.promise;
|
||||
},
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-21',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 4000,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: async () => true,
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => clock,
|
||||
schedule: (fn) => {
|
||||
scheduled.push(fn);
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearSchedule: () => undefined,
|
||||
onSyncStatus: (event) => {
|
||||
events.push({ phase: event.phase, message: event.message });
|
||||
},
|
||||
});
|
||||
|
||||
const syncPromise = runtime.runSyncNow();
|
||||
await waitUntil(() => scheduled.length > 0, 'the generating heartbeat');
|
||||
|
||||
// No further progress arrives: only the clock moves.
|
||||
clock += 95_000;
|
||||
scheduled.at(-1)!();
|
||||
|
||||
assert.deepEqual(events.at(-1), {
|
||||
phase: 'generating',
|
||||
message: 'Generating character dictionary for ONE PIECE (image 240/1220 · 1m 35s)...',
|
||||
});
|
||||
|
||||
snapshotDeferred.resolve({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
entryCount: 4000,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
});
|
||||
await syncPromise;
|
||||
assert.equal(events.at(-1)?.phase, 'ready');
|
||||
});
|
||||
|
||||
test('auto sync rebuilds instead of importing a cached merged ZIP with a mismatched revision', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(dictionariesDir, { recursive: true });
|
||||
const statePath = path.join(dictionariesDir, 'auto-sync-state.json');
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
activeMediaIds: ['7 - Frieren'],
|
||||
mergedRevision: 'rev-7',
|
||||
mergedDictionaryTitle: 'SubMiner Character Dictionary',
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
// Left over from an interrupted run: the archive on disk is not the revision state recorded.
|
||||
buildDictionaryZip(
|
||||
path.join(dictionariesDir, 'merged.zip'),
|
||||
'SubMiner Character Dictionary',
|
||||
'Character names',
|
||||
'rev-stale',
|
||||
[{ term: 'フリーレン', reading: 'フリーレン', role: 'main', glossary: [] } as never],
|
||||
[],
|
||||
);
|
||||
const mergedBuilds: number[][] = [];
|
||||
const imports: string[] = [];
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 7,
|
||||
mediaTitle: 'Frieren',
|
||||
entryCount: 100,
|
||||
fromCache: true,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async (mediaIds) => {
|
||||
mergedBuilds.push([...mediaIds]);
|
||||
return {
|
||||
zipPath: '/tmp/rebuilt-merged.zip',
|
||||
revision: 'rev-7',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 100,
|
||||
};
|
||||
},
|
||||
// Yomitan does not have the dictionary, so the sync has to import despite the cached state.
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
imports.push(zipPath);
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(mergedBuilds, [[7]]);
|
||||
assert.deepEqual(imports, ['/tmp/rebuilt-merged.zip']);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ensureDir } from '../../shared/fs-utils';
|
||||
import {
|
||||
buildBuildingMessage,
|
||||
buildCheckingMessage,
|
||||
buildFailedMessage,
|
||||
buildGeneratingMessage,
|
||||
buildImportingMessage,
|
||||
buildReadyMessage,
|
||||
formatCharacterDictionaryProgressDetail,
|
||||
joinGeneratingDetail,
|
||||
} from './character-dictionary-auto-sync-messages';
|
||||
import { readDictionaryZipRevision } from '../character-dictionary-runtime/zip';
|
||||
import type { AnilistCharacterDictionaryProfileScope } from '../../types';
|
||||
import type {
|
||||
CharacterDictionarySnapshotProgressCallbacks,
|
||||
CharacterDictionarySnapshotResult,
|
||||
CharacterDictionarySnapshotStageProgress,
|
||||
MergedCharacterDictionaryBuildResult,
|
||||
} from '../character-dictionary-runtime';
|
||||
|
||||
const DEFAULT_IMPORT_TIMEOUT_BASE_MS = 120_000;
|
||||
const IMPORT_TIMEOUT_MS_PER_MB = 6_000;
|
||||
const IMPORT_TIMEOUT_MAX_MS = 1_800_000;
|
||||
|
||||
type AutoSyncMediaEntry = {
|
||||
mediaId: number;
|
||||
label: string;
|
||||
@@ -72,7 +88,15 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
||||
now: () => number;
|
||||
schedule?: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
clearSchedule?: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
/** Budget for the quick Yomitan queries (dictionary info, settings upsert). */
|
||||
operationTimeoutMs?: number;
|
||||
/**
|
||||
* Base budget for the slow Yomitan mutations (delete + import). The effective budget grows with
|
||||
* the merged ZIP size, because importing a large dictionary can take several minutes.
|
||||
*/
|
||||
dictionaryImportTimeoutBaseMs?: number;
|
||||
heartbeatMs?: number;
|
||||
progressThrottleMs?: number;
|
||||
logInfo?: (message: string) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
onSyncStatus?: (event: CharacterDictionaryAutoSyncStatusEvent) => void;
|
||||
@@ -345,37 +369,6 @@ function sameMembership(left: number[], right: number[]): boolean {
|
||||
return arraysEqual(leftSorted, rightSorted);
|
||||
}
|
||||
|
||||
function buildSyncingMessage(mediaTitle: string): string {
|
||||
return `Updating character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
function buildCheckingMessage(mediaTitle: string): string {
|
||||
return `Checking character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
function buildGeneratingMessage(mediaTitle: string): string {
|
||||
return `Generating character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
function buildImportingMessage(mediaTitle: string): string {
|
||||
return `Importing character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
function buildBuildingMessage(mediaTitle: string): string {
|
||||
return `Building character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
function buildReadyMessage(mediaTitle: string): string {
|
||||
return `Character dictionary ready for ${mediaTitle}`;
|
||||
}
|
||||
|
||||
function buildFailedMessage(mediaTitle: string | null, errorMessage: string): string {
|
||||
if (mediaTitle) {
|
||||
return `Character dictionary sync failed for ${mediaTitle}: ${errorMessage}`;
|
||||
}
|
||||
return `Character dictionary sync failed: ${errorMessage}`;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
deps: CharacterDictionaryAutoSyncRuntimeDeps,
|
||||
): {
|
||||
@@ -389,21 +382,31 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
const clearSchedule = deps.clearSchedule ?? ((timer) => clearTimeout(timer));
|
||||
const debounceMs = 800;
|
||||
const operationTimeoutMs = Math.max(1, Math.floor(deps.operationTimeoutMs ?? 7_000));
|
||||
const dictionaryImportTimeoutBaseMs = Math.max(
|
||||
1,
|
||||
Math.floor(deps.dictionaryImportTimeoutBaseMs ?? DEFAULT_IMPORT_TIMEOUT_BASE_MS),
|
||||
);
|
||||
const heartbeatMs = Math.max(1, Math.floor(deps.heartbeatMs ?? 5_000));
|
||||
const progressThrottleMs = Math.max(0, Math.floor(deps.progressThrottleMs ?? 1_000));
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let syncInFlight = false;
|
||||
let runQueued = false;
|
||||
let activeCurrentMediaId: number | null = null;
|
||||
|
||||
const withOperationTimeout = async <T>(label: string, promise: Promise<T>): Promise<T> => {
|
||||
const withTimeout = async <T>(
|
||||
label: string,
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${operationTimeoutMs}ms`));
|
||||
}, operationTimeoutMs);
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
@@ -413,6 +416,57 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
}
|
||||
};
|
||||
|
||||
const withOperationTimeout = <T>(label: string, promise: Promise<T>): Promise<T> =>
|
||||
withTimeout(label, promise, operationTimeoutMs);
|
||||
|
||||
/**
|
||||
* Importing a merged dictionary means Yomitan writing every term and image into IndexedDB, which
|
||||
* scales with the ZIP: a single-season dictionary lands in seconds, One Piece takes minutes. Size
|
||||
* the budget off the archive instead of failing a healthy import on a flat deadline.
|
||||
*/
|
||||
const resolveImportTimeoutMs = (zipPath: string | null): number => {
|
||||
let bytes = 0;
|
||||
if (zipPath) {
|
||||
try {
|
||||
bytes = fs.statSync(zipPath).size;
|
||||
} catch {
|
||||
bytes = 0;
|
||||
}
|
||||
}
|
||||
const sizeAllowanceMs = (bytes / (1024 * 1024)) * IMPORT_TIMEOUT_MS_PER_MB;
|
||||
return Math.min(
|
||||
IMPORT_TIMEOUT_MAX_MS,
|
||||
Math.max(
|
||||
dictionaryImportTimeoutBaseMs,
|
||||
Math.round(dictionaryImportTimeoutBaseMs + sizeAllowanceMs),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** Keeps the persistent notification ticking so a multi-minute import never looks hung. */
|
||||
const withHeartbeat = async <T>(
|
||||
run: () => Promise<T>,
|
||||
onTick: (elapsedMs: number) => void,
|
||||
): Promise<T> => {
|
||||
const startedAt = deps.now();
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const tick = (): void => {
|
||||
if (stopped) return;
|
||||
onTick(deps.now() - startedAt);
|
||||
timer = schedule(tick, heartbeatMs);
|
||||
};
|
||||
timer = schedule(tick, heartbeatMs);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
stopped = true;
|
||||
if (timer !== null) {
|
||||
clearSchedule(timer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runSyncOnce = async (): Promise<void> => {
|
||||
const config = deps.getConfig();
|
||||
if (!config.enabled) {
|
||||
@@ -422,33 +476,112 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
|
||||
let currentMediaId: number | undefined;
|
||||
let currentMediaTitle: string | null = null;
|
||||
let lastProgressAt = Number.NEGATIVE_INFINITY;
|
||||
let lastProgressStage: CharacterDictionarySnapshotStageProgress['stage'] | null = null;
|
||||
let generating: {
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
startedAt: number;
|
||||
detail: string | null;
|
||||
} | null = null;
|
||||
let imageRate: { startedAt: number; startCompleted: number } | null = null;
|
||||
|
||||
const emitGeneratingStatus = (): void => {
|
||||
if (!generating) {
|
||||
return;
|
||||
}
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'generating',
|
||||
mediaId: generating.mediaId,
|
||||
mediaTitle: generating.mediaTitle,
|
||||
message: buildGeneratingMessage(
|
||||
generating.mediaTitle,
|
||||
joinGeneratingDetail(generating.detail, deps.now() - generating.startedAt),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Image downloads are serial and evenly paced, so the observed rate predicts the tail well.
|
||||
const estimateRemainingImageMs = (
|
||||
progress: CharacterDictionarySnapshotStageProgress,
|
||||
nowMs: number,
|
||||
): number | null => {
|
||||
if (progress.stage !== 'images' || progress.total === null || imageRate === null) {
|
||||
return null;
|
||||
}
|
||||
const done = progress.completed - imageRate.startCompleted;
|
||||
const elapsedMs = nowMs - imageRate.startedAt;
|
||||
const remaining = progress.total - progress.completed;
|
||||
if (done <= 0 || elapsedMs <= 0 || remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round((elapsedMs / done) * remaining);
|
||||
};
|
||||
|
||||
try {
|
||||
deps.logInfo?.('[dictionary:auto-sync] syncing current anime snapshot');
|
||||
const snapshot = await deps.getOrCreateCurrentSnapshot(undefined, {
|
||||
onChecking: ({ mediaId, mediaTitle }) => {
|
||||
currentMediaId = mediaId;
|
||||
currentMediaTitle = mediaTitle;
|
||||
activeCurrentMediaId = mediaId;
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'checking',
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
message: buildCheckingMessage(mediaTitle),
|
||||
});
|
||||
},
|
||||
onGenerating: ({ mediaId, mediaTitle }) => {
|
||||
currentMediaId = mediaId;
|
||||
currentMediaTitle = mediaTitle;
|
||||
activeCurrentMediaId = mediaId;
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'generating',
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
message: buildGeneratingMessage(mediaTitle),
|
||||
});
|
||||
},
|
||||
});
|
||||
const snapshot = await withHeartbeat(
|
||||
() =>
|
||||
deps.getOrCreateCurrentSnapshot(undefined, {
|
||||
onChecking: ({ mediaId, mediaTitle }) => {
|
||||
currentMediaId = mediaId;
|
||||
currentMediaTitle = mediaTitle;
|
||||
activeCurrentMediaId = mediaId;
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'checking',
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
message: buildCheckingMessage(mediaTitle),
|
||||
});
|
||||
},
|
||||
onGenerating: ({ mediaId, mediaTitle }) => {
|
||||
currentMediaId = mediaId;
|
||||
currentMediaTitle = mediaTitle;
|
||||
activeCurrentMediaId = mediaId;
|
||||
lastProgressAt = Number.NEGATIVE_INFINITY;
|
||||
lastProgressStage = null;
|
||||
imageRate = null;
|
||||
generating = { mediaId, mediaTitle, startedAt: deps.now(), detail: null };
|
||||
emitGeneratingStatus();
|
||||
},
|
||||
// Long-running work (AniList character pages, then one image download per character
|
||||
// and voice actor, then MeCab name splits) reports counts so the notification shows
|
||||
// movement instead of a frozen "Generating..." for the minutes a large series takes.
|
||||
onGenerateProgress: (progress) => {
|
||||
const nowMs = deps.now();
|
||||
if (!generating) {
|
||||
generating = {
|
||||
mediaId: progress.mediaId,
|
||||
mediaTitle: progress.mediaTitle,
|
||||
startedAt: nowMs,
|
||||
detail: null,
|
||||
};
|
||||
}
|
||||
if (progress.stage === 'images' && imageRate === null) {
|
||||
imageRate = { startedAt: nowMs, startCompleted: progress.completed };
|
||||
}
|
||||
// Stage changes and the last item of a stage always report; the throttle only thins
|
||||
// out the run of identical-looking updates in between.
|
||||
const isFinal = progress.total !== null && progress.completed >= progress.total;
|
||||
const isStageChange = progress.stage !== lastProgressStage;
|
||||
if (!isFinal && !isStageChange && nowMs - lastProgressAt < progressThrottleMs) {
|
||||
return;
|
||||
}
|
||||
lastProgressAt = nowMs;
|
||||
lastProgressStage = progress.stage;
|
||||
generating.mediaId = progress.mediaId;
|
||||
generating.mediaTitle = progress.mediaTitle;
|
||||
generating.detail = formatCharacterDictionaryProgressDetail(
|
||||
progress,
|
||||
estimateRemainingImageMs(progress, nowMs),
|
||||
);
|
||||
emitGeneratingStatus();
|
||||
},
|
||||
}),
|
||||
// Ticks even when a step stalls, so the message keeps moving while the counts do not.
|
||||
() => emitGeneratingStatus(),
|
||||
);
|
||||
generating = null;
|
||||
currentMediaId = snapshot.mediaId;
|
||||
currentMediaTitle = snapshot.mediaTitle;
|
||||
activeCurrentMediaId = snapshot.mediaId;
|
||||
@@ -531,33 +664,61 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
message: buildImportingMessage(snapshot.mediaTitle),
|
||||
});
|
||||
if (existing !== null) {
|
||||
await withOperationTimeout(
|
||||
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||
deps.deleteYomitanDictionary(dictionaryTitle),
|
||||
);
|
||||
}
|
||||
if (merged === null) {
|
||||
const existingMergedZipPath = path.join(dictionariesDir, 'merged.zip');
|
||||
if (fs.existsSync(existingMergedZipPath)) {
|
||||
merged = {
|
||||
zipPath: existingMergedZipPath,
|
||||
revision,
|
||||
dictionaryTitle,
|
||||
entryCount: snapshot.entryCount,
|
||||
};
|
||||
} else {
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIdValues);
|
||||
}
|
||||
}
|
||||
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
|
||||
const imported = await withOperationTimeout(
|
||||
`importYomitanDictionary(${path.basename(merged.zipPath)})`,
|
||||
deps.importYomitanDictionary(merged.zipPath),
|
||||
await withHeartbeat(
|
||||
async () => {
|
||||
const importTimeoutMs = resolveImportTimeoutMs(
|
||||
merged?.zipPath ?? path.join(dictionariesDir, 'merged.zip'),
|
||||
);
|
||||
if (existing !== null) {
|
||||
await withTimeout(
|
||||
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||
deps.deleteYomitanDictionary(dictionaryTitle),
|
||||
importTimeoutMs,
|
||||
);
|
||||
}
|
||||
if (merged === null) {
|
||||
// The cached archive only stands in for the recorded state when its own index.json
|
||||
// agrees. A stale or half-written ZIP would be imported under the wrong revision,
|
||||
// and every later sync would then see a revision mismatch and re-import it.
|
||||
const existingMergedZipPath = path.join(dictionariesDir, 'merged.zip');
|
||||
const existingMergedRevision = readDictionaryZipRevision(existingMergedZipPath);
|
||||
if (existingMergedRevision === revision) {
|
||||
merged = {
|
||||
zipPath: existingMergedZipPath,
|
||||
revision,
|
||||
dictionaryTitle,
|
||||
entryCount: snapshot.entryCount,
|
||||
};
|
||||
} else {
|
||||
deps.logInfo?.(
|
||||
`[dictionary:auto-sync] cached merged ZIP unusable (revision ${existingMergedRevision ?? 'unreadable'}, expected ${revision}); rebuilding`,
|
||||
);
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIdValues);
|
||||
}
|
||||
}
|
||||
const mergedZipPath = merged.zipPath;
|
||||
const mergedImportTimeoutMs = resolveImportTimeoutMs(mergedZipPath);
|
||||
deps.logInfo?.(
|
||||
`[dictionary:auto-sync] importing merged dictionary: ${mergedZipPath} (timeout ${mergedImportTimeoutMs}ms)`,
|
||||
);
|
||||
const imported = await withTimeout(
|
||||
`importYomitanDictionary(${path.basename(mergedZipPath)})`,
|
||||
deps.importYomitanDictionary(mergedZipPath),
|
||||
mergedImportTimeoutMs,
|
||||
);
|
||||
if (!imported) {
|
||||
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
|
||||
}
|
||||
},
|
||||
(elapsedMs) => {
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'importing',
|
||||
mediaId: snapshot.mediaId,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
message: buildImportingMessage(snapshot.mediaTitle, elapsedMs),
|
||||
});
|
||||
},
|
||||
);
|
||||
if (!imported) {
|
||||
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,29 @@ WINDOW=44040194
|
||||
assert.deepEqual(reader.getCursorScreenPoint({ x: 877, y: 718 }), { x: 1700, y: 1050 });
|
||||
});
|
||||
|
||||
test('createLinuxX11CursorPointReader converts physical X11 coordinates to Electron DIP', async () => {
|
||||
const convertedPoints: Array<{ x: number; y: number }> = [];
|
||||
const reader = createLinuxX11CursorPointReader({
|
||||
env: { DISPLAY: ':1' },
|
||||
platform: 'linux',
|
||||
runCommand: async () => `X=1424
|
||||
Y=697
|
||||
SCREEN=0
|
||||
WINDOW=44040194
|
||||
`,
|
||||
screenToDipPoint: (point) => {
|
||||
convertedPoints.push(point);
|
||||
return { x: 1139, y: 557 };
|
||||
},
|
||||
});
|
||||
|
||||
reader.refresh();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(reader.getCursorScreenPoint({ x: 0, y: 0 }), { x: 1139, y: 557 });
|
||||
assert.deepEqual(convertedPoints, [{ x: 1424, y: 697 }]);
|
||||
});
|
||||
|
||||
test('createLinuxX11CursorPointReader does not spawn off X11 Linux', () => {
|
||||
const calls: string[] = [];
|
||||
const reader = createLinuxX11CursorPointReader({
|
||||
|
||||
@@ -36,11 +36,13 @@ export function createLinuxX11CursorPointReader(options?: {
|
||||
now?: () => number;
|
||||
platform?: NodeJS.Platform;
|
||||
runCommand?: CommandRunner;
|
||||
screenToDipPoint?: (point: PointerPoint) => PointerPoint;
|
||||
}) {
|
||||
const env = options?.env ?? process.env;
|
||||
const now = options?.now ?? (() => Date.now());
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const runCommand = options?.runCommand ?? execFileUtf8;
|
||||
const screenToDipPoint = options?.screenToDipPoint ?? ((point: PointerPoint) => point);
|
||||
let latest: { point: PointerPoint; updatedAtMs: number } | null = null;
|
||||
let inFlight = false;
|
||||
let retryAfterMs = 0;
|
||||
@@ -63,7 +65,7 @@ export function createLinuxX11CursorPointReader(options?: {
|
||||
retryAfterMs = now() + COMMAND_FAILURE_RETRY_DELAY_MS;
|
||||
return;
|
||||
}
|
||||
latest = { point, updatedAtMs: now() };
|
||||
latest = { point: screenToDipPoint(point), updatedAtMs: now() };
|
||||
retryAfterMs = 0;
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface StartupOsdSequencerDeps {
|
||||
getNotificationType?: () => NotificationType | undefined;
|
||||
showOsd: (message: string) => boolean | void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showDesktopNotification?: (title: string, options: { body?: string }) => void;
|
||||
showDesktopNotification?: (title: string, options: { body?: string; replaceId?: string }) => void;
|
||||
}
|
||||
|
||||
interface StartupStatusNotificationOptions {
|
||||
@@ -62,7 +62,11 @@ export function createStartupOsdSequencer(deps: StartupOsdSequencerDeps): {
|
||||
shown = deps.showOsd(options.message) !== false || shown;
|
||||
}
|
||||
if (options.desktop !== false && shouldShowDesktop(type)) {
|
||||
deps.showDesktopNotification?.('SubMiner', { body: options.message });
|
||||
// Each startup lane keeps one live desktop notification instead of one per update.
|
||||
deps.showDesktopNotification?.('SubMiner', {
|
||||
body: options.message,
|
||||
replaceId: options.id,
|
||||
});
|
||||
shown = true;
|
||||
}
|
||||
return shown;
|
||||
|
||||
@@ -289,7 +289,9 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
if (initialArgs && isHeadlessInitialCommand(initialArgs)) {
|
||||
return null;
|
||||
}
|
||||
return createWindowTrackerCore(override, targetMpvSocketPath);
|
||||
return createWindowTrackerCore(override, targetMpvSocketPath, (point) =>
|
||||
screen.screenToDipPoint(point),
|
||||
);
|
||||
}
|
||||
|
||||
function bindVisibleOverlayOwner(): void {
|
||||
@@ -627,7 +629,9 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
|
||||
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||
|
||||
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
|
||||
const linuxX11CursorPointReader = createLinuxX11CursorPointReader({
|
||||
screenToDipPoint: (point) => screen.screenToDipPoint(point),
|
||||
});
|
||||
|
||||
function getLinuxOverlayPointerMeasurement() {
|
||||
const measurement = overlayContentMeasurementStore.getLatestByLayer('visible');
|
||||
|
||||
@@ -1004,6 +1004,24 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSubtitle leaves already-decoded text alone', () => {
|
||||
// Primary subtitle text is decoded from ASS once, upstream: by mpv for live lines and
|
||||
// by the cue parser for prefetched ones. A brace that survives that is literal text.
|
||||
assert.equal(normalizeSubtitle('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||
assert.equal(normalizeSubtitle(' 余白 ', false), ' 余白 ');
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines drops ASS vector drawing runs', () => {
|
||||
assert.deepEqual(
|
||||
prepareSecondarySubtitleLines(
|
||||
'{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
|
||||
),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(prepareSecondarySubtitleLines('{\\p1}m 0 0 l 10 10{\\p0}本文'), ['本文']);
|
||||
assert.deepEqual(prepareSecondarySubtitleLines('{\\pos(960,1068)\\bord3}位置指定'), ['位置指定']);
|
||||
});
|
||||
|
||||
test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist', () => {
|
||||
assert.equal(shouldRenderTokenizedSubtitle(5), true);
|
||||
assert.equal(shouldRenderTokenizedSubtitle(0), false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SubtitleData,
|
||||
SubtitleRendererStyleConfig,
|
||||
} from '../types';
|
||||
import { assToPlainText, normalizePlainSubtitleText } from '../core/services/ass-text.js';
|
||||
import type { RendererContext } from './context';
|
||||
import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js';
|
||||
|
||||
@@ -42,17 +43,10 @@ function isWhitespaceOnly(value: string): boolean {
|
||||
return value.trim().length === 0;
|
||||
}
|
||||
|
||||
// Text reaching the overlay has already been decoded from ASS -- by mpv for live lines,
|
||||
// by the cue parser for prefetched ones -- so this only settles line breaks.
|
||||
export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks = false): string {
|
||||
if (!text) return '';
|
||||
|
||||
let normalized = text.replace(/\\N/g, '\n').replace(/\\n/g, '\n');
|
||||
normalized = normalized.replace(/\{[^}]*\}/g, '');
|
||||
if (collapseLineBreaks) {
|
||||
normalized = normalized.replace(/\n/g, ' ');
|
||||
normalized = normalized.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
return trim ? normalized.trim() : normalized;
|
||||
return normalizePlainSubtitleText(text, { trim, collapseLineBreaks });
|
||||
}
|
||||
|
||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
@@ -672,7 +666,10 @@ function isKaraokeLikeLineSet(lines: string[]): boolean {
|
||||
}
|
||||
|
||||
export function prepareSecondarySubtitleLines(text: string): string[] {
|
||||
const normalized = normalizeSubtitle(text, true, false);
|
||||
// The one display-side ASS decode: secondary text also reaches the overlay from
|
||||
// websocket clients that forward their source line untouched, so unlike the primary
|
||||
// path it cannot assume mpv already decoded it.
|
||||
const normalized = assToPlainText(text).trim();
|
||||
|
||||
if (!normalized) return [];
|
||||
|
||||
|
||||
@@ -148,6 +148,145 @@ function createEndOfCentralDirectory(
|
||||
return end;
|
||||
}
|
||||
|
||||
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const LOCAL_FILE_HEADER_SIZE = 30;
|
||||
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||
const END_OF_CENTRAL_DIRECTORY_SIZE = 22;
|
||||
const CENTRAL_FILE_HEADER_SIGNATURE = 0x02014b50;
|
||||
const CENTRAL_FILE_HEADER_SIZE = 46;
|
||||
// 65535 entries with names of a few dozen bytes stay far under this; the cap only stops a corrupt
|
||||
// record length from asking for an allocation the size of the archive.
|
||||
const MAX_CENTRAL_DIRECTORY_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Walks every declared central-directory record, checking each signature and keeping the
|
||||
* variable-length name/extra/comment fields inside the directory. The walk has to land exactly on
|
||||
* the end of the directory, so a record that was overwritten in place fails even though the file
|
||||
* kept its size.
|
||||
*/
|
||||
function isCentralDirectoryIntact(
|
||||
fd: number,
|
||||
centralStart: number,
|
||||
centralSize: number,
|
||||
entryCount: number,
|
||||
): boolean {
|
||||
if (centralSize === 0 || centralSize > MAX_CENTRAL_DIRECTORY_BYTES) {
|
||||
return false;
|
||||
}
|
||||
const central = Buffer.alloc(centralSize);
|
||||
if (fs.readSync(fd, central, 0, centralSize, centralStart) !== centralSize) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (cursor + CENTRAL_FILE_HEADER_SIZE > centralSize) {
|
||||
return false;
|
||||
}
|
||||
if (central.readUInt32LE(cursor) !== CENTRAL_FILE_HEADER_SIGNATURE) {
|
||||
return false;
|
||||
}
|
||||
const nameLength = central.readUInt16LE(cursor + 28);
|
||||
const extraLength = central.readUInt16LE(cursor + 30);
|
||||
const commentLength = central.readUInt16LE(cursor + 32);
|
||||
cursor += CENTRAL_FILE_HEADER_SIZE + nameLength + extraLength + commentLength;
|
||||
if (cursor > centralSize) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return cursor === centralSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start of the central directory, or null when the archive is not a complete one of ours. The
|
||||
* end-of-central-directory record is written last, so finding an intact one is what separates a
|
||||
* finished archive from a half-written one.
|
||||
*/
|
||||
function readCentralDirectoryStart(fd: number, fileSize: number): number | null {
|
||||
if (fileSize < END_OF_CENTRAL_DIRECTORY_SIZE) {
|
||||
return null;
|
||||
}
|
||||
const end = Buffer.alloc(END_OF_CENTRAL_DIRECTORY_SIZE);
|
||||
const endOffset = fileSize - END_OF_CENTRAL_DIRECTORY_SIZE;
|
||||
if (fs.readSync(fd, end, 0, end.length, endOffset) !== end.length) {
|
||||
return null;
|
||||
}
|
||||
// writeStoredZip never writes an archive comment, so the record is exactly the last 22 bytes.
|
||||
if (end.readUInt32LE(0) !== END_OF_CENTRAL_DIRECTORY_SIGNATURE || end.readUInt16LE(20) !== 0) {
|
||||
return null;
|
||||
}
|
||||
const entryCount = end.readUInt16LE(10);
|
||||
if (entryCount === 0) {
|
||||
return null;
|
||||
}
|
||||
const centralSize = end.readUInt32LE(12);
|
||||
const centralStart = end.readUInt32LE(16);
|
||||
if (centralStart + centralSize !== endOffset) {
|
||||
return null;
|
||||
}
|
||||
if (!isCentralDirectoryIntact(fd, centralStart, centralSize, entryCount)) {
|
||||
return null;
|
||||
}
|
||||
return centralStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the first entry of an archive written by {@link writeStoredZip}: every entry is stored
|
||||
* uncompressed with no extra field and no data descriptor, so the leading local header is enough.
|
||||
* Returns null for anything it does not recognize, so callers treat a corrupt, truncated, or
|
||||
* foreign archive the same as a missing one.
|
||||
*/
|
||||
export function readStoredZipFirstFile(zipPath: string): StoredZipFile | null {
|
||||
let fd: number;
|
||||
try {
|
||||
fd = fs.openSync(zipPath, 'r');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileSize = fs.fstatSync(fd).size;
|
||||
const centralStart = readCentralDirectoryStart(fd, fileSize);
|
||||
if (centralStart === null) {
|
||||
return null;
|
||||
}
|
||||
const header = Buffer.alloc(LOCAL_FILE_HEADER_SIZE);
|
||||
if (fs.readSync(fd, header, 0, header.length, 0) !== header.length) {
|
||||
return null;
|
||||
}
|
||||
if (header.readUInt32LE(0) !== LOCAL_FILE_HEADER_SIGNATURE) {
|
||||
return null;
|
||||
}
|
||||
// Compression method 0 (stored) is the only thing writeStoredZip emits.
|
||||
if (header.readUInt16LE(8) !== 0) {
|
||||
return null;
|
||||
}
|
||||
const entrySize = header.readUInt32LE(18);
|
||||
const nameLength = header.readUInt16LE(26);
|
||||
const extraLength = header.readUInt16LE(28);
|
||||
const dataOffset = LOCAL_FILE_HEADER_SIZE + nameLength + extraLength;
|
||||
if (dataOffset + entrySize > centralStart) {
|
||||
return null;
|
||||
}
|
||||
const name = Buffer.alloc(nameLength);
|
||||
if (
|
||||
nameLength > 0 &&
|
||||
fs.readSync(fd, name, 0, nameLength, LOCAL_FILE_HEADER_SIZE) !== nameLength
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const data = Buffer.alloc(entrySize);
|
||||
if (entrySize > 0 && fs.readSync(fd, data, 0, entrySize, dataOffset) !== entrySize) {
|
||||
return null;
|
||||
}
|
||||
return { name: name.toString('utf8'), data };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function writeBuffer(fd: number, buffer: Buffer): void {
|
||||
let written = 0;
|
||||
while (written < buffer.length) {
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { normalizePlainSubtitleText } from './core/services/ass-text';
|
||||
|
||||
interface TimingEntry {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -191,23 +193,14 @@ export class SubtitleTimingTracker {
|
||||
return costs[shorter.length] || 0;
|
||||
}
|
||||
|
||||
// Both sides take text mpv has already decoded from ASS; only whitespace differs
|
||||
// between the lookup key (single line) and the display form (line breaks kept).
|
||||
private normalizeText(text: string): string {
|
||||
return text
|
||||
.replace(/\\N/g, ' ')
|
||||
.replace(/\\n/g, ' ')
|
||||
.replace(/\n/g, ' ')
|
||||
.replace(/{[^}]*}/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return normalizePlainSubtitleText(text, { collapseLineBreaks: true });
|
||||
}
|
||||
|
||||
private prepareDisplayText(text: string): string {
|
||||
// Convert ASS/SSA newlines to real newlines, strip tags
|
||||
return text
|
||||
.replace(/\\N/g, '\n')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/{[^}]*}/g, '')
|
||||
.trim();
|
||||
return normalizePlainSubtitleText(text);
|
||||
}
|
||||
|
||||
private startCleanup(): void {
|
||||
|
||||
@@ -100,39 +100,6 @@ export interface StatsAnkiNotesInfoRequest {
|
||||
noteIds: number[];
|
||||
}
|
||||
|
||||
export interface StatsMergeAnimeRequest {
|
||||
sourceAnimeIds: number[];
|
||||
}
|
||||
|
||||
export interface StatsMoveVideoRequest {
|
||||
animeId: number;
|
||||
}
|
||||
|
||||
export interface StatsAnimeMergeRecommendation {
|
||||
recommendationId: number;
|
||||
animeIds: [number, number];
|
||||
}
|
||||
|
||||
export interface StatsAnimeMergeRecommendationsResponse {
|
||||
recommendations: StatsAnimeMergeRecommendation[];
|
||||
}
|
||||
|
||||
export interface StatsMergeAnimeResponse {
|
||||
ok: true;
|
||||
/** Library entry that owns every merged episode afterwards. */
|
||||
animeId: number;
|
||||
mergedAnimeIds: number[];
|
||||
movedVideos: number;
|
||||
}
|
||||
|
||||
export interface StatsMoveVideoResponse {
|
||||
ok: true;
|
||||
animeId: number;
|
||||
previousAnimeId: number | null;
|
||||
/** True when the previous entry was emptied by the move and removed. */
|
||||
removedPreviousAnime: boolean;
|
||||
}
|
||||
|
||||
export interface StatsOkResponse {
|
||||
ok: true;
|
||||
}
|
||||
@@ -167,7 +134,6 @@ export interface StatsJsonResponseMap {
|
||||
mediaLibrary: MediaLibraryItem[];
|
||||
mediaDetail: MediaDetailData;
|
||||
animeLibrary: AnimeLibraryItem[];
|
||||
animeMergeRecommendations: StatsAnimeMergeRecommendationsResponse;
|
||||
animeDetail: AnimeDetailData;
|
||||
animeWords: AnimeWord[];
|
||||
animeRollups: DailyRollup[];
|
||||
@@ -176,9 +142,6 @@ export interface StatsJsonResponseMap {
|
||||
deleteSession: StatsOkResponse;
|
||||
deleteVideo: StatsOkResponse;
|
||||
deleteAnime: StatsOkResponse;
|
||||
mergeAnime: StatsMergeAnimeResponse;
|
||||
moveVideoToAnime: StatsMoveVideoResponse;
|
||||
dismissAnimeMergeRecommendation: StatsOkResponse;
|
||||
anilistSearch: StatsAnilistSearchResult[];
|
||||
knownWords: string[];
|
||||
knownWordsSummary: StatsKnownWordsSummary;
|
||||
@@ -236,7 +199,6 @@ export interface StatsHttpClient {
|
||||
getMediaLibrary: () => Promise<MediaLibraryItem[]>;
|
||||
getMediaDetail: (videoId: number) => Promise<MediaDetailData>;
|
||||
getAnimeLibrary: () => Promise<AnimeLibraryItem[]>;
|
||||
getAnimeMergeRecommendations: () => Promise<StatsAnimeMergeRecommendationsResponse>;
|
||||
getAnimeDetail: (animeId: number) => Promise<AnimeDetailData>;
|
||||
getAnimeWords: (animeId: number, limit?: number) => Promise<AnimeWord[]>;
|
||||
getAnimeRollups: (animeId: number, limit?: number) => Promise<DailyRollup[]>;
|
||||
@@ -259,9 +221,6 @@ export interface StatsHttpClient {
|
||||
deleteSessions: (sessionIds: number[]) => Promise<void>;
|
||||
deleteVideo: (videoId: number) => Promise<void>;
|
||||
deleteAnime: (animeId: number) => Promise<void>;
|
||||
mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise<StatsMergeAnimeResponse>;
|
||||
moveVideoToAnime: (videoId: number, animeId: number) => Promise<StatsMoveVideoResponse>;
|
||||
dismissAnimeMergeRecommendation: (recommendationId: number) => Promise<void>;
|
||||
getKnownWords: () => Promise<string[]>;
|
||||
getKnownWordsSummary: () => Promise<StatsKnownWordsSummary>;
|
||||
getAnimeKnownWordsSummary: (animeId: number) => Promise<StatsKnownWordsSummary>;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { BaseWindowTracker } from './base-tracker';
|
||||
import { HyprlandWindowTracker } from './hyprland-tracker';
|
||||
import { SwayWindowTracker } from './sway-tracker';
|
||||
import { X11WindowTracker } from './x11-tracker';
|
||||
import type { ScreenToDipPoint } from './x11-tracker';
|
||||
import { MacOSWindowTracker } from './macos-tracker';
|
||||
import { WindowsWindowTracker } from './windows-tracker';
|
||||
import { createLogger } from '../logger';
|
||||
@@ -51,6 +52,7 @@ function normalizeCompositor(value: string): Compositor | null {
|
||||
export function createWindowTracker(
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
screenToDipPoint?: ScreenToDipPoint,
|
||||
): BaseWindowTracker | null {
|
||||
let compositor = detectCompositor();
|
||||
|
||||
@@ -70,7 +72,11 @@ export function createWindowTracker(
|
||||
case 'sway':
|
||||
return new SwayWindowTracker(targetMpvSocketPath?.trim() || undefined);
|
||||
case 'x11':
|
||||
return new X11WindowTracker(targetMpvSocketPath?.trim() || undefined);
|
||||
return new X11WindowTracker(
|
||||
targetMpvSocketPath?.trim() || undefined,
|
||||
undefined,
|
||||
screenToDipPoint,
|
||||
);
|
||||
case 'macos':
|
||||
return new MacOSWindowTracker(targetMpvSocketPath?.trim() || undefined);
|
||||
case 'windows':
|
||||
|
||||
@@ -82,6 +82,47 @@ Height: 360`;
|
||||
});
|
||||
});
|
||||
|
||||
test('X11WindowTracker converts both physical rectangle corners to Electron DIP', async () => {
|
||||
const convertedPoints: Array<{ x: number; y: number }> = [];
|
||||
const tracker = new X11WindowTracker(
|
||||
undefined,
|
||||
async (command, args) => {
|
||||
if (command === 'xdotool' && args[0] === 'search') {
|
||||
return '123';
|
||||
}
|
||||
if (command === 'xdotool' && args[0] === 'getactivewindow') {
|
||||
return '123';
|
||||
}
|
||||
if (command === 'xwininfo') {
|
||||
return `Absolute upper-left X: 2000
|
||||
Absolute upper-left Y: 125
|
||||
Width: 1000
|
||||
Height: 750`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
(point) => {
|
||||
convertedPoints.push(point);
|
||||
if (point.x === 2000) return { x: 1600, y: 100 };
|
||||
return { x: 2400, y: 700 };
|
||||
},
|
||||
);
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(convertedPoints, [
|
||||
{ x: 2000, y: 125 },
|
||||
{ x: 3000, y: 875 },
|
||||
]);
|
||||
assert.deepEqual(tracker.getGeometry(), {
|
||||
x: 1600,
|
||||
y: 100,
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
});
|
||||
|
||||
test('X11WindowTracker updates target focus from active X11 window', async () => {
|
||||
let activeWindowId = '999';
|
||||
const tracker = new X11WindowTracker(undefined, async (command, args) => {
|
||||
|
||||
@@ -20,6 +20,9 @@ import { execFile } from 'child_process';
|
||||
import { BaseWindowTracker } from './base-tracker';
|
||||
|
||||
type CommandRunner = (command: string, args: string[]) => Promise<string>;
|
||||
export type ScreenToDipPoint = (point: { x: number; y: number }) => { x: number; y: number };
|
||||
|
||||
const preservePoint: ScreenToDipPoint = (point) => point;
|
||||
|
||||
function execFileUtf8(command: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -87,16 +90,22 @@ export class X11WindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly targetMpvSocketPath: string | null;
|
||||
private readonly runCommand: CommandRunner;
|
||||
private readonly screenToDipPoint: ScreenToDipPoint;
|
||||
private targetWindowId: string | null = null;
|
||||
private targetWindowPid: number | null = null;
|
||||
private pollInFlight = false;
|
||||
private currentPollIntervalMs = 750;
|
||||
private readonly stablePollIntervalMs = 250;
|
||||
|
||||
constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileUtf8) {
|
||||
constructor(
|
||||
targetMpvSocketPath?: string,
|
||||
runCommand: CommandRunner = execFileUtf8,
|
||||
screenToDipPoint: ScreenToDipPoint = preservePoint,
|
||||
) {
|
||||
super();
|
||||
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
||||
this.runCommand = runCommand;
|
||||
this.screenToDipPoint = screenToDipPoint;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -196,11 +205,25 @@ export class X11WindowTracker extends BaseWindowTracker {
|
||||
this.targetWindowPid = targetPid;
|
||||
|
||||
const winInfo = await this.runCommand('xwininfo', ['-id', windowId]);
|
||||
const geometry = parseX11WindowGeometry(winInfo);
|
||||
if (!geometry) {
|
||||
const physicalGeometry = parseX11WindowGeometry(winInfo);
|
||||
if (!physicalGeometry) {
|
||||
this.updateGeometry(null);
|
||||
return;
|
||||
}
|
||||
const topLeft = this.screenToDipPoint({
|
||||
x: physicalGeometry.x,
|
||||
y: physicalGeometry.y,
|
||||
});
|
||||
const bottomRight = this.screenToDipPoint({
|
||||
x: physicalGeometry.x + physicalGeometry.width,
|
||||
y: physicalGeometry.y + physicalGeometry.height,
|
||||
});
|
||||
const geometry = {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
width: bottomRight.x - topLeft.x,
|
||||
height: bottomRight.y - topLeft.y,
|
||||
};
|
||||
|
||||
const focused = await this.isWindowActive(windowId, targetPid);
|
||||
this.updateGeometry(geometry, focused);
|
||||
|
||||
@@ -5,45 +5,22 @@ import type { AnimeLibraryItem } from '../../types/stats';
|
||||
interface AnimeCardProps {
|
||||
anime: AnimeLibraryItem;
|
||||
onClick: () => void;
|
||||
/** While selecting, clicking the card toggles it instead of opening it. */
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export function AnimeCard({
|
||||
anime,
|
||||
onClick,
|
||||
selectable = false,
|
||||
selected = false,
|
||||
}: AnimeCardProps) {
|
||||
export function AnimeCard({ anime, onClick }: AnimeCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selectable ? selected : undefined}
|
||||
className={`group bg-ctp-surface0 border rounded-lg overflow-hidden hover:shadow-lg hover:shadow-ctp-blue/10 transition-all duration-200 hover:-translate-y-1 text-left w-full ${
|
||||
selected ? 'border-ctp-blue' : 'border-ctp-surface1 hover:border-ctp-blue/50'
|
||||
}`}
|
||||
className="group bg-ctp-surface0 border border-ctp-surface1 rounded-lg overflow-hidden hover:border-ctp-blue/50 hover:shadow-lg hover:shadow-ctp-blue/10 transition-all duration-200 hover:-translate-y-1 text-left w-full"
|
||||
>
|
||||
<div className="overflow-hidden relative">
|
||||
<div className="overflow-hidden">
|
||||
<AnimeCoverImage
|
||||
animeId={anime.animeId}
|
||||
title={anime.canonicalTitle}
|
||||
coverRetryToken={anime.anilistId ?? 0}
|
||||
className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
{selectable && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute top-2 left-2 w-5 h-5 rounded border flex items-center justify-center text-xs ${
|
||||
selected
|
||||
? 'bg-ctp-blue border-ctp-blue text-ctp-base'
|
||||
: 'bg-ctp-crust/70 border-ctp-surface2 text-transparent'
|
||||
}`}
|
||||
>
|
||||
{'✓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{anime.canonicalTitle}</div>
|
||||
|
||||
@@ -25,8 +25,6 @@ interface AnimeDetailViewProps {
|
||||
* keeps showing the previous title's art.
|
||||
*/
|
||||
onAnilistRelinked?: () => void;
|
||||
/** Called after an episode is reassigned to another entry. */
|
||||
onEpisodeMoved?: () => void;
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
@@ -152,7 +150,6 @@ export function AnimeDetailView({
|
||||
onOpenEpisodeDetail,
|
||||
onAnimeDeleted,
|
||||
onAnilistRelinked,
|
||||
onEpisodeMoved,
|
||||
}: AnimeDetailViewProps) {
|
||||
const { data, loading, error, reload } = useAnimeDetail(animeId);
|
||||
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
|
||||
@@ -226,13 +223,6 @@ export function AnimeDetailView({
|
||||
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
|
||||
<EpisodeList
|
||||
episodes={episodes}
|
||||
animeId={animeId}
|
||||
onEpisodeMoved={(removedPreviousAnime) => {
|
||||
onEpisodeMoved?.();
|
||||
// The last episode taking the entry with it leaves nothing to show.
|
||||
if (removedPreviousAnime) onBack();
|
||||
else reload();
|
||||
}}
|
||||
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
|
||||
/>
|
||||
<AnimeWatchChart animeId={animeId} />
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import type { AnimeLibraryItem } from '../../types/stats';
|
||||
import { AnimeMergeDialog } from './AnimeMergeDialog';
|
||||
import { LibraryEntryPicker } from './LibraryEntryPicker';
|
||||
|
||||
interface TestWindow extends Window {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
|
||||
function installDom(): () => void {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
const previousIsReactActEnvironment = (
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT;
|
||||
const window = new Window() as TestWindow;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: window.HTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: previousHTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
|
||||
};
|
||||
}
|
||||
|
||||
function libraryItem(animeId: number, title: string): AnimeLibraryItem {
|
||||
return {
|
||||
animeId,
|
||||
canonicalTitle: title,
|
||||
anilistId: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 0,
|
||||
episodeCount: 1,
|
||||
episodesTotal: null,
|
||||
lastWatchedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
test('AnimeMergeDialog focuses its close control, closes on Escape, and restores focus', async () => {
|
||||
const uninstallDom = installDom();
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Review merge
|
||||
</button>
|
||||
{open ? (
|
||||
<AnimeMergeDialog
|
||||
entries={[libraryItem(1, 'Show'), libraryItem(2, 'Show Season 1')]}
|
||||
onClose={() => setOpen(false)}
|
||||
onMerged={() => undefined}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => root.render(<Harness />));
|
||||
const trigger = container.querySelector('button') as HTMLButtonElement;
|
||||
trigger.focus();
|
||||
await act(async () => trigger.click());
|
||||
|
||||
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Close');
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
});
|
||||
assert.equal(container.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeMergeDialog keeps keyboard focus inside the modal', async () => {
|
||||
const uninstallDom = installDom();
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AnimeMergeDialog
|
||||
entries={[libraryItem(1, 'Show'), libraryItem(2, 'Show Season 1')]}
|
||||
onClose={() => undefined}
|
||||
onMerged={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = container.querySelector('[role="dialog"]') as HTMLElement;
|
||||
const focusable = [...dialog.querySelectorAll('button:not([disabled])')] as HTMLButtonElement[];
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
assert.ok(first);
|
||||
assert.ok(last);
|
||||
|
||||
last.focus();
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Tab' }));
|
||||
});
|
||||
assert.equal(document.activeElement, first);
|
||||
|
||||
first.focus();
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }));
|
||||
});
|
||||
assert.equal(document.activeElement, last);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('LibraryEntryPicker focuses search, closes on Escape, and restores focus', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show'),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Move
|
||||
</button>
|
||||
{open ? (
|
||||
<LibraryEntryPicker
|
||||
heading="Move episode"
|
||||
onSelect={() => undefined}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => root.render(<Harness />));
|
||||
const trigger = container.querySelector('button') as HTMLButtonElement;
|
||||
trigger.focus();
|
||||
await act(async () => trigger.click());
|
||||
|
||||
assert.equal(document.activeElement?.getAttribute('placeholder'), 'Search library...');
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
});
|
||||
assert.equal(container.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
apiClient.getAnimeLibrary = original;
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('LibraryEntryPicker cannot be dismissed while a move is in flight', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show'),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
let closeCalls = 0;
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LibraryEntryPicker
|
||||
heading="Move episode"
|
||||
busyAnimeId={1}
|
||||
onSelect={() => undefined}
|
||||
onClose={() => {
|
||||
closeCalls += 1;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const closeButton = container.querySelector('button[aria-label="Close"]') as HTMLButtonElement;
|
||||
assert.equal(closeButton.disabled, true);
|
||||
await act(async () => {
|
||||
closeButton.click();
|
||||
container.firstElementChild?.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
|
||||
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
});
|
||||
assert.equal(closeCalls, 0);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
apiClient.getAnimeLibrary = original;
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import { useModalFocus } from '../../hooks/useModalFocus';
|
||||
import { AnimeCoverImage } from './AnimeCoverImage';
|
||||
import type { AnimeLibraryItem } from '../../types/stats';
|
||||
|
||||
interface AnimeMergeDialogProps {
|
||||
entries: AnimeLibraryItem[];
|
||||
onClose: () => void;
|
||||
onMerged: (survivingAnimeId: number) => void;
|
||||
}
|
||||
|
||||
/** Biggest entry first: the one most likely to carry the right title and art. */
|
||||
function pickDefaultKeeper(entries: AnimeLibraryItem[]): number {
|
||||
const best = [...entries].sort(
|
||||
(a, b) => b.episodeCount - a.episodeCount || b.totalActiveMs - a.totalActiveMs,
|
||||
)[0];
|
||||
return best?.animeId ?? 0;
|
||||
}
|
||||
|
||||
export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialogProps) {
|
||||
const headingId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [keeperId, setKeeperId] = useState(() => pickDefaultKeeper(entries));
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const totalEpisodes = entries.reduce((sum, entry) => sum + entry.episodeCount, 0);
|
||||
const totalCards = entries.reduce((sum, entry) => sum + entry.totalCards, 0);
|
||||
const totalActiveMs = entries.reduce((sum, entry) => sum + entry.totalActiveMs, 0);
|
||||
|
||||
useModalFocus({
|
||||
dialogRef,
|
||||
initialFocusRef: closeButtonRef,
|
||||
dismissDisabled: merging,
|
||||
onDismiss: onClose,
|
||||
});
|
||||
|
||||
const handleMerge = async () => {
|
||||
const sourceAnimeIds = entries
|
||||
.map((entry) => entry.animeId)
|
||||
.filter((animeId) => animeId !== keeperId);
|
||||
if (sourceAnimeIds.length === 0) return;
|
||||
setMerging(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await apiClient.mergeAnime(keeperId, sourceAnimeIds);
|
||||
onMerged(result.animeId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to merge these entries.');
|
||||
setMerging(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Dismissing mid-request would leave the caller unaware of a merge that is
|
||||
// still going to land, so the backdrop and close button are inert until it
|
||||
// resolves.
|
||||
const handleDismiss = () => {
|
||||
if (!merging) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
|
||||
<div
|
||||
ref={dialogRef}
|
||||
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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4 border-b border-ctp-surface1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 id={headingId} className="text-sm font-semibold text-ctp-text">
|
||||
Merge {entries.length} Library Entries
|
||||
</h3>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
disabled={merging}
|
||||
aria-label="Close"
|
||||
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
|
||||
>
|
||||
{'✕'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-ctp-overlay2 mt-2">
|
||||
Pick the entry to keep. Every episode moves onto it and the others are removed; no
|
||||
sessions or mined cards are deleted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.animeId}
|
||||
type="button"
|
||||
disabled={merging}
|
||||
aria-pressed={keeperId === 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 ${
|
||||
keeperId === entry.animeId ? 'bg-ctp-surface1' : 'hover:bg-ctp-surface0'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`w-4 h-4 rounded-full border shrink-0 ${
|
||||
keeperId === entry.animeId
|
||||
? 'border-ctp-blue bg-ctp-blue'
|
||||
: 'border-ctp-surface2 bg-transparent'
|
||||
}`}
|
||||
/>
|
||||
<AnimeCoverImage
|
||||
animeId={entry.animeId}
|
||||
title={entry.canonicalTitle}
|
||||
coverRetryToken={entry.anilistId ?? 0}
|
||||
className="w-10 h-14 rounded shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-ctp-text truncate">{entry.canonicalTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2 mt-0.5">
|
||||
{entry.episodeCount} episode{entry.episodeCount !== 1 ? 's' : ''} ·{' '}
|
||||
{formatDuration(entry.totalActiveMs)} · {formatNumber(entry.totalCards)} cards
|
||||
</div>
|
||||
</div>
|
||||
{keeperId === entry.animeId ? (
|
||||
<span className="text-xs text-ctp-blue shrink-0">Keep</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-ctp-surface1 space-y-2">
|
||||
{error ? (
|
||||
<div role="alert" className="text-xs text-ctp-red">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
Result: {totalEpisodes} episode{totalEpisodes !== 1 ? 's' : ''} ·{' '}
|
||||
{formatDuration(totalActiveMs)} · {formatNumber(totalCards)} cards
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={merging}
|
||||
onClick={() => void handleMerge()}
|
||||
className="px-3 py-1.5 rounded-lg bg-ctp-blue/15 border border-ctp-blue/40 text-xs text-ctp-blue hover:bg-ctp-blue/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{merging ? 'Merging…' : 'Merge Entries'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import type { AnimeLibraryItem, StatsMergeAnimeResponse } from '../../types/stats';
|
||||
import { AnimeTab } from './AnimeTab';
|
||||
|
||||
interface TestWindow extends Window {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
|
||||
function installDom(): () => void {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
const previousIsReactActEnvironment = (
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT;
|
||||
const window = new Window() as TestWindow;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: window.HTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: previousHTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
|
||||
};
|
||||
}
|
||||
|
||||
function libraryItem(animeId: number, title: string, episodeCount: number): AnimeLibraryItem {
|
||||
return {
|
||||
animeId,
|
||||
canonicalTitle: title,
|
||||
anilistId: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 1,
|
||||
totalTokensSeen: 0,
|
||||
episodeCount,
|
||||
episodesTotal: null,
|
||||
lastWatchedMs: animeId,
|
||||
};
|
||||
}
|
||||
|
||||
function findButton(container: Element, label: string): HTMLElement {
|
||||
const match = [...container.querySelectorAll('button')].find((button) =>
|
||||
(button.textContent ?? '').includes(label),
|
||||
);
|
||||
assert.ok(match, `expected a "${label}" button`);
|
||||
return match as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
/** Library cards only expose aria-pressed while selection mode is on. */
|
||||
function cardButtons(container: Element): HTMLButtonElement[] {
|
||||
return [...container.querySelectorAll('button[aria-pressed]')] as unknown as HTMLButtonElement[];
|
||||
}
|
||||
|
||||
function mergeButton(container: Element): HTMLButtonElement {
|
||||
const match = [...container.querySelectorAll('button')].find(
|
||||
(button) => (button.textContent ?? '').trim() === 'Merge Selected',
|
||||
);
|
||||
assert.ok(match, 'expected a "Merge Selected" button');
|
||||
return match as unknown as HTMLButtonElement;
|
||||
}
|
||||
|
||||
test('AnimeTab merges the selected duplicate entries into the chosen keeper', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
mergeAnime: apiClient.mergeAnime,
|
||||
};
|
||||
|
||||
// Two cards for one show, the split this feature exists to undo.
|
||||
let entries = [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
|
||||
let libraryFetches = 0;
|
||||
let mergeCall: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
|
||||
|
||||
apiClient.getAnimeLibrary = (async () => {
|
||||
libraryFetches += 1;
|
||||
return entries;
|
||||
}) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.mergeAnime = (async (targetAnimeId: number, sourceAnimeIds: number[]) => {
|
||||
mergeCall = { targetAnimeId, sourceAnimeIds };
|
||||
entries = [libraryItem(1, 'Show', 3)];
|
||||
return {
|
||||
ok: true,
|
||||
animeId: targetAnimeId,
|
||||
mergedAnimeIds: sourceAnimeIds,
|
||||
movedVideos: 1,
|
||||
} satisfies StatsMergeAnimeResponse;
|
||||
}) as typeof apiClient.mergeAnime;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<AnimeTab />);
|
||||
});
|
||||
assert.equal(libraryFetches, 1);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Select').click();
|
||||
});
|
||||
// Nothing to merge until at least two entries are picked.
|
||||
assert.equal(mergeButton(container).disabled, true);
|
||||
|
||||
// Sorted by last watched, so the season-tagged duplicate comes first.
|
||||
const cards = cardButtons(container);
|
||||
assert.equal(cards.length, 2);
|
||||
assert.match(cards[0]?.textContent ?? '', /Show Season 1/);
|
||||
|
||||
await act(async () => {
|
||||
cards[0]?.click();
|
||||
});
|
||||
assert.equal(mergeButton(container).disabled, true);
|
||||
|
||||
await act(async () => {
|
||||
cardButtons(container)[1]?.click();
|
||||
});
|
||||
assert.equal(mergeButton(container).disabled, false);
|
||||
|
||||
await act(async () => {
|
||||
mergeButton(container).click();
|
||||
});
|
||||
// The dialog defaults to the entry with the most episodes.
|
||||
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Merge Entries').click();
|
||||
});
|
||||
|
||||
assert.deepEqual(mergeCall, { targetAnimeId: 1, sourceAnimeIds: [2] });
|
||||
assert.equal(libraryFetches, 2);
|
||||
// Selection mode closes and the grid is back to a single card.
|
||||
assert.doesNotMatch(container.textContent ?? '', /Merge 2 Library Entries/);
|
||||
assert.doesNotMatch(container.textContent ?? '', /Show Season 1/);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab keeps a suggested duplicate visible until it is reviewed and merged', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
|
||||
mergeAnime: apiClient.mergeAnime,
|
||||
};
|
||||
|
||||
let entries = [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
|
||||
let recommendations = [{ recommendationId: 41, animeIds: [1, 2] }];
|
||||
let mergeCall: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
|
||||
|
||||
apiClient.getAnimeLibrary = (async () => entries) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => ({
|
||||
recommendations,
|
||||
})) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
apiClient.dismissAnimeMergeRecommendation = (async () =>
|
||||
undefined) as typeof apiClient.dismissAnimeMergeRecommendation;
|
||||
apiClient.mergeAnime = (async (targetAnimeId: number, sourceAnimeIds: number[]) => {
|
||||
mergeCall = { targetAnimeId, sourceAnimeIds };
|
||||
entries = [libraryItem(targetAnimeId, 'Show', 3)];
|
||||
recommendations = [];
|
||||
return {
|
||||
ok: true,
|
||||
animeId: targetAnimeId,
|
||||
mergedAnimeIds: sourceAnimeIds,
|
||||
movedVideos: 1,
|
||||
} satisfies StatsMergeAnimeResponse;
|
||||
}) as typeof apiClient.mergeAnime;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<AnimeTab />);
|
||||
});
|
||||
|
||||
assert.match(container.textContent ?? '', /Possible duplicate/);
|
||||
assert.match(container.textContent ?? '', /Show/);
|
||||
assert.match(container.textContent ?? '', /Show Season 1/);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Review merge').click();
|
||||
});
|
||||
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
|
||||
|
||||
const keeper = [...container.querySelectorAll('button[aria-pressed]')].find((button) =>
|
||||
(button.textContent ?? '').includes('Show Season 1'),
|
||||
) as HTMLButtonElement | undefined;
|
||||
assert.ok(keeper);
|
||||
await act(async () => {
|
||||
keeper.click();
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Merge Entries').click();
|
||||
});
|
||||
|
||||
assert.deepEqual(mergeCall, { targetAnimeId: 2, sourceAnimeIds: [1] });
|
||||
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
|
||||
assert.doesNotMatch(container.textContent ?? '', /Show Season 1/);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab dismisses a false-positive duplicate recommendation', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
|
||||
};
|
||||
let dismissedId: number | null = null;
|
||||
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show', 2),
|
||||
libraryItem(2, 'Different Show', 1),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => ({
|
||||
recommendations: [{ recommendationId: 73, animeIds: [1, 2] }],
|
||||
})) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
apiClient.dismissAnimeMergeRecommendation = (async (recommendationId: number) => {
|
||||
dismissedId = recommendationId;
|
||||
}) as typeof apiClient.dismissAnimeMergeRecommendation;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Not duplicates').click();
|
||||
});
|
||||
|
||||
assert.equal(dismissedId, 73);
|
||||
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab refreshes the library and recommendations when the window regains focus', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
};
|
||||
let entries = [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
|
||||
let libraryFetches = 0;
|
||||
let recommendationFetches = 0;
|
||||
apiClient.getAnimeLibrary = (async () => {
|
||||
libraryFetches += 1;
|
||||
return entries;
|
||||
}) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => {
|
||||
recommendationFetches += 1;
|
||||
return { recommendations: [] };
|
||||
}) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
|
||||
entries = [libraryItem(1, 'Show', 3)];
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new window.Event('focus'));
|
||||
});
|
||||
|
||||
assert.equal(libraryFetches, 2);
|
||||
assert.equal(recommendationFetches, 2);
|
||||
assert.doesNotMatch(container.textContent ?? '', /Show Season 1/);
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab keeps a recommendation visible through a transient refresh failure', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
};
|
||||
let failRecommendations = false;
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show', 2),
|
||||
libraryItem(2, 'Show Season 1', 1),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => {
|
||||
if (failRecommendations) throw new Error('temporary failure');
|
||||
return { recommendations: [{ recommendationId: 41, animeIds: [1, 2] }] };
|
||||
}) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
assert.match(container.textContent ?? '', /Possible duplicate/);
|
||||
|
||||
failRecommendations = true;
|
||||
await act(async () => window.dispatchEvent(new window.Event('focus')));
|
||||
|
||||
assert.match(container.textContent ?? '', /Possible duplicate/);
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab retains a recommendation and reports a failed dismissal', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
|
||||
};
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show', 2),
|
||||
libraryItem(2, 'Show Season 1', 1),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => ({
|
||||
recommendations: [{ recommendationId: 41, animeIds: [1, 2] }],
|
||||
})) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
apiClient.dismissAnimeMergeRecommendation = (async () => {
|
||||
throw new Error('offline');
|
||||
}) as typeof apiClient.dismissAnimeMergeRecommendation;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
|
||||
await act(async () => findButton(container, 'Not duplicates').click());
|
||||
|
||||
assert.match(container.textContent ?? '', /Possible duplicate/);
|
||||
assert.match(container.textContent ?? '', /Could not dismiss this suggestion/);
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab keeps an open recommendation review stable during background refresh', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
};
|
||||
let recommendations = [{ recommendationId: 41, animeIds: [1, 2] as [number, number] }];
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show', 2),
|
||||
libraryItem(2, 'Show Season 1', 1),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => ({
|
||||
recommendations,
|
||||
})) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
await act(async () => findButton(container, 'Review merge').click());
|
||||
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
|
||||
|
||||
recommendations = [];
|
||||
await act(async () => window.dispatchEvent(new window.Event('focus')));
|
||||
|
||||
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
|
||||
assert.match(container.textContent ?? '', /Show Season 1/);
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
} from '../../lib/library-card-size';
|
||||
import { AnimeCard } from './AnimeCard';
|
||||
import { AnimeDetailView } from './AnimeDetailView';
|
||||
import { AnimeMergeDialog } from './AnimeMergeDialog';
|
||||
import { DuplicateReviewStrip } from './DuplicateReviewStrip';
|
||||
|
||||
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
|
||||
|
||||
@@ -55,17 +53,7 @@ export function AnimeTab({
|
||||
onNavigateToWord,
|
||||
onOpenEpisodeDetail,
|
||||
}: AnimeTabProps) {
|
||||
const {
|
||||
anime,
|
||||
loading,
|
||||
error,
|
||||
reload,
|
||||
recommendations,
|
||||
dismissRecommendation,
|
||||
dismissingRecommendationId,
|
||||
recommendationActionError,
|
||||
clearRecommendation,
|
||||
} = useAnimeLibrary();
|
||||
const { anime, loading, error, reload } = useAnimeLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
||||
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
|
||||
@@ -74,23 +62,6 @@ export function AnimeTab({
|
||||
),
|
||||
);
|
||||
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [checkedAnimeIds, setCheckedAnimeIds] = useState<number[]>([]);
|
||||
const [showMergeDialog, setShowMergeDialog] = useState(false);
|
||||
const [reviewRecommendationId, setReviewRecommendationId] = useState<number | null>(null);
|
||||
const [reviewAnimeIds, setReviewAnimeIds] = useState<[number, number] | null>(null);
|
||||
|
||||
function toggleChecked(animeId: number): void {
|
||||
setCheckedAnimeIds((ids) =>
|
||||
ids.includes(animeId) ? ids.filter((id) => id !== animeId) : [...ids, animeId],
|
||||
);
|
||||
}
|
||||
|
||||
function exitSelectionMode(): void {
|
||||
setSelectionMode(false);
|
||||
setCheckedAnimeIds([]);
|
||||
setShowMergeDialog(false);
|
||||
}
|
||||
|
||||
function handleCardSizeChange(size: LibraryCardSize): void {
|
||||
setCardSize(size);
|
||||
@@ -115,22 +86,6 @@ export function AnimeTab({
|
||||
}, [anime, search, sortKey]);
|
||||
|
||||
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
|
||||
const checkedEntries = checkedAnimeIds
|
||||
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
|
||||
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
|
||||
const hydratedRecommendations = recommendations
|
||||
.map((recommendation) => ({
|
||||
...recommendation,
|
||||
entries: recommendation.animeIds
|
||||
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
|
||||
.filter((entry): entry is (typeof anime)[number] => entry !== undefined),
|
||||
}))
|
||||
.filter((recommendation) => recommendation.entries.length >= 2);
|
||||
const activeRecommendation = hydratedRecommendations[0] ?? null;
|
||||
const reviewEntries = (reviewAnimeIds ?? [])
|
||||
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
|
||||
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
|
||||
const mergeEntries = reviewRecommendationId !== null ? reviewEntries : checkedEntries;
|
||||
|
||||
if (selectedAnimeId !== null) {
|
||||
return (
|
||||
@@ -145,7 +100,6 @@ export function AnimeTab({
|
||||
}
|
||||
onAnimeDeleted={reload}
|
||||
onAnilistRelinked={reload}
|
||||
onEpisodeMoved={reload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -189,57 +143,11 @@ export function AnimeTab({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (selectionMode ? exitSelectionMode() : setSelectionMode(true))}
|
||||
title="Select several entries to merge them into one"
|
||||
className={`px-2 py-2 rounded-lg border text-xs shrink-0 transition-colors ${
|
||||
selectionMode
|
||||
? 'bg-ctp-blue/15 border-ctp-blue/40 text-ctp-blue'
|
||||
: 'bg-ctp-surface0 border-ctp-surface1 text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{selectionMode ? 'Cancel' : 'Select'}
|
||||
</button>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{filtered.length} titles · {formatDuration(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeRecommendation ? (
|
||||
<DuplicateReviewStrip
|
||||
entries={activeRecommendation.entries}
|
||||
current={1}
|
||||
total={hydratedRecommendations.length}
|
||||
dismissing={dismissingRecommendationId === activeRecommendation.recommendationId}
|
||||
error={recommendationActionError}
|
||||
onReview={() => {
|
||||
setReviewRecommendationId(activeRecommendation.recommendationId);
|
||||
setReviewAnimeIds(activeRecommendation.animeIds);
|
||||
setShowMergeDialog(true);
|
||||
}}
|
||||
onDismiss={() => void dismissRecommendation(activeRecommendation.recommendationId)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectionMode && (
|
||||
<div className="flex items-center justify-between gap-3 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2">
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{checkedEntries.length === 0
|
||||
? 'Pick the duplicate entries to combine'
|
||||
: `${checkedEntries.length} selected`}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={checkedEntries.length < 2}
|
||||
onClick={() => setShowMergeDialog(true)}
|
||||
className="px-3 py-1.5 rounded-lg bg-ctp-blue/15 border border-ctp-blue/40 text-xs text-ctp-blue hover:bg-ctp-blue/25 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Merge Selected
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No titles found</div>
|
||||
) : (
|
||||
@@ -248,35 +156,11 @@ export function AnimeTab({
|
||||
<AnimeCard
|
||||
key={item.animeId}
|
||||
anime={item}
|
||||
selectable={selectionMode}
|
||||
selected={checkedAnimeIds.includes(item.animeId)}
|
||||
onClick={() =>
|
||||
selectionMode ? toggleChecked(item.animeId) : setSelectedAnimeId(item.animeId)
|
||||
}
|
||||
onClick={() => setSelectedAnimeId(item.animeId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMergeDialog && mergeEntries.length >= 2 && (
|
||||
<AnimeMergeDialog
|
||||
entries={mergeEntries}
|
||||
onClose={() => {
|
||||
setShowMergeDialog(false);
|
||||
setReviewRecommendationId(null);
|
||||
setReviewAnimeIds(null);
|
||||
}}
|
||||
onMerged={() => {
|
||||
if (reviewRecommendationId !== null) {
|
||||
clearRecommendation(reviewRecommendationId);
|
||||
}
|
||||
exitSelectionMode();
|
||||
setReviewRecommendationId(null);
|
||||
setReviewAnimeIds(null);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { AnimeLibraryItem } from '../../types/stats';
|
||||
|
||||
interface DuplicateReviewStripProps {
|
||||
entries: AnimeLibraryItem[];
|
||||
current: number;
|
||||
total: number;
|
||||
dismissing: boolean;
|
||||
error?: string | null;
|
||||
onReview: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function DuplicateReviewStrip({
|
||||
entries,
|
||||
current,
|
||||
total,
|
||||
dismissing,
|
||||
error = null,
|
||||
onReview,
|
||||
onDismiss,
|
||||
}: DuplicateReviewStripProps) {
|
||||
return (
|
||||
<aside
|
||||
aria-label="Possible duplicate library entries"
|
||||
className="relative overflow-hidden rounded-lg border border-ctp-yellow/25 bg-ctp-yellow/[0.06] px-3 py-2.5"
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 w-0.5 bg-ctp-yellow/70" aria-hidden="true" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-ctp-yellow">Possible duplicate</span>
|
||||
{total > 1 ? (
|
||||
<span className="text-[10px] tabular-nums text-ctp-overlay1">
|
||||
{current} of {total}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-ctp-subtext0">
|
||||
{entries.map((entry) => entry.canonicalTitle).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dismissing}
|
||||
onClick={onDismiss}
|
||||
className="shrink-0 rounded-md px-2.5 py-1.5 text-xs text-ctp-overlay2 transition-colors hover:bg-ctp-surface0 hover:text-ctp-text disabled:opacity-50"
|
||||
>
|
||||
{dismissing ? 'Dismissing…' : 'Not duplicates'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dismissing}
|
||||
onClick={onReview}
|
||||
className="shrink-0 rounded-md border border-ctp-yellow/35 bg-ctp-yellow/10 px-2.5 py-1.5 text-xs font-medium text-ctp-yellow transition-colors hover:bg-ctp-yellow/20 disabled:opacity-50"
|
||||
>
|
||||
Review merge
|
||||
</button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="mt-1.5 text-xs text-ctp-red">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -4,39 +4,21 @@ import { apiClient } from '../../lib/api-client';
|
||||
import { confirmEpisodeDelete } from '../../lib/delete-confirm';
|
||||
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
||||
import { EpisodeDetail } from './EpisodeDetail';
|
||||
import { LibraryEntryPicker } from './LibraryEntryPicker';
|
||||
import type { AnimeEpisode } from '../../types/stats';
|
||||
|
||||
/**
|
||||
* Row actions that only appear on hover. Keyboard focus and pointers with no
|
||||
* hover (touch) reveal them too, otherwise those users cannot reach the button
|
||||
* at all.
|
||||
*/
|
||||
const HOVER_REVEALED =
|
||||
'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100';
|
||||
|
||||
interface EpisodeListProps {
|
||||
episodes: AnimeEpisode[];
|
||||
/** Entry these episodes currently belong to; excluded from the move picker. */
|
||||
animeId?: number;
|
||||
onEpisodeDeleted?: () => void;
|
||||
/** Fires after an episode is reassigned, so the caller can refetch. */
|
||||
onEpisodeMoved?: (removedPreviousAnime: boolean) => void;
|
||||
onOpenDetail?: (videoId: number) => void;
|
||||
}
|
||||
|
||||
export function EpisodeList({
|
||||
episodes: initialEpisodes,
|
||||
animeId,
|
||||
onEpisodeDeleted,
|
||||
onEpisodeMoved,
|
||||
onOpenDetail,
|
||||
}: EpisodeListProps) {
|
||||
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
|
||||
const [episodes, setEpisodes] = useState(initialEpisodes);
|
||||
const [movingEpisode, setMovingEpisode] = useState<AnimeEpisode | null>(null);
|
||||
const [moveTargetId, setMoveTargetId] = useState<number | null>(null);
|
||||
const [moveError, setMoveError] = useState<string | null>(null);
|
||||
|
||||
if (episodes.length === 0) return null;
|
||||
|
||||
@@ -69,22 +51,6 @@ export function EpisodeList({
|
||||
onEpisodeDeleted?.();
|
||||
};
|
||||
|
||||
const handleMoveEpisode = async (videoId: number, targetAnimeId: number) => {
|
||||
setMoveTargetId(targetAnimeId);
|
||||
setMoveError(null);
|
||||
try {
|
||||
const result = await apiClient.moveVideoToAnime(videoId, targetAnimeId);
|
||||
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
|
||||
if (expandedVideoId === videoId) setExpandedVideoId(null);
|
||||
setMovingEpisode(null);
|
||||
onEpisodeMoved?.(result.removedPreviousAnime);
|
||||
} catch (err) {
|
||||
setMoveError(err instanceof Error ? err.message : 'Failed to move this episode.');
|
||||
} finally {
|
||||
setMoveTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const watchedCount = episodes.filter((ep) => ep.watched).length;
|
||||
|
||||
return (
|
||||
@@ -198,28 +164,14 @@ export function EpisodeList({
|
||||
>
|
||||
{'\u2713'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMoveError(null);
|
||||
setMovingEpisode(ep);
|
||||
}}
|
||||
className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue focus-visible:text-ctp-blue hover:bg-ctp-blue/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`}
|
||||
title="Move to another library entry"
|
||||
aria-label="Move to another library entry"
|
||||
>
|
||||
{'\u2192'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
|
||||
}}
|
||||
className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red focus-visible:text-ctp-red hover:bg-ctp-red/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`}
|
||||
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
|
||||
title="Delete episode"
|
||||
aria-label="Delete episode"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
@@ -239,19 +191,6 @@ export function EpisodeList({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{movingEpisode && (
|
||||
<LibraryEntryPicker
|
||||
heading={`Move "${movingEpisode.canonicalTitle}" To`}
|
||||
excludeAnimeIds={animeId != null ? [animeId] : []}
|
||||
busyAnimeId={moveTargetId}
|
||||
error={moveError}
|
||||
onSelect={(entry) => void handleMoveEpisode(movingEpisode.videoId, entry.animeId)}
|
||||
onClose={() => {
|
||||
setMovingEpisode(null);
|
||||
setMoveError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import type { AnimeEpisode, AnimeLibraryItem, StatsMoveVideoResponse } from '../../types/stats';
|
||||
import { EpisodeList } from './EpisodeList';
|
||||
|
||||
interface TestWindow extends Window {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
|
||||
function installDom(): () => void {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
const previousIsReactActEnvironment = (
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT;
|
||||
const window = new Window() as TestWindow;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: window.HTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: previousHTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
|
||||
};
|
||||
}
|
||||
|
||||
function episode(videoId: number, title: string): AnimeEpisode {
|
||||
return {
|
||||
videoId,
|
||||
episode: videoId,
|
||||
season: null,
|
||||
durationMs: 1_440_000,
|
||||
endedMediaMs: null,
|
||||
watched: 0,
|
||||
canonicalTitle: title,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
lastWatchedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function libraryItem(animeId: number, title: string): AnimeLibraryItem {
|
||||
return {
|
||||
animeId,
|
||||
canonicalTitle: title,
|
||||
anilistId: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 0,
|
||||
episodeCount: 1,
|
||||
episodesTotal: null,
|
||||
lastWatchedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function findButtonByTitle(container: Element, title: string): HTMLElement {
|
||||
const match = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.getAttribute('title') === title,
|
||||
);
|
||||
assert.ok(match, `expected a button titled "${title}"`);
|
||||
return match as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
function findButtonByText(container: Element, text: string): HTMLElement {
|
||||
const match = [...container.querySelectorAll('button')].find((button) =>
|
||||
(button.textContent ?? '').includes(text),
|
||||
);
|
||||
assert.ok(match, `expected a "${text}" button`);
|
||||
return match as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
test('EpisodeList moves an episode to the library entry picked in the dialog', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
moveVideoToAnime: apiClient.moveVideoToAnime,
|
||||
};
|
||||
|
||||
let moveCall: { videoId: number; animeId: number } | null = null;
|
||||
let movedResult: boolean | null = null;
|
||||
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Current Entry'),
|
||||
libraryItem(2, 'Real Series'),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.moveVideoToAnime = (async (videoId: number, animeId: number) => {
|
||||
moveCall = { videoId, animeId };
|
||||
return {
|
||||
ok: true,
|
||||
animeId,
|
||||
previousAnimeId: 1,
|
||||
removedPreviousAnime: true,
|
||||
} satisfies StatsMoveVideoResponse;
|
||||
}) as typeof apiClient.moveVideoToAnime;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EpisodeList
|
||||
episodes={[episode(5, 'Stray Episode')]}
|
||||
animeId={1}
|
||||
onEpisodeMoved={(removedPreviousAnime) => {
|
||||
movedResult = removedPreviousAnime;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButtonByTitle(container, 'Move to another library entry').click();
|
||||
});
|
||||
assert.match(container.textContent ?? '', /Move "Stray Episode" To/);
|
||||
// The entry the episode already belongs to is not offered as a target.
|
||||
assert.doesNotMatch(container.textContent ?? '', /Current Entry/);
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(container, 'Real Series').click();
|
||||
});
|
||||
|
||||
assert.deepEqual(moveCall, { videoId: 5, animeId: 2 });
|
||||
assert.equal(movedResult, true);
|
||||
// The row leaves this entry's list and the picker closes.
|
||||
assert.doesNotMatch(container.textContent ?? '', /Stray Episode/);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { formatDuration } from '../../lib/formatters';
|
||||
import { useModalFocus } from '../../hooks/useModalFocus';
|
||||
import { AnimeCoverImage } from './AnimeCoverImage';
|
||||
import type { AnimeLibraryItem } from '../../types/stats';
|
||||
|
||||
interface LibraryEntryPickerProps {
|
||||
heading: string;
|
||||
/** Entries that cannot be picked, typically the one being moved away from. */
|
||||
excludeAnimeIds?: number[];
|
||||
initialQuery?: string;
|
||||
busyAnimeId?: number | null;
|
||||
error?: string | null;
|
||||
onSelect: (entry: AnimeLibraryItem) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LibraryEntryPicker({
|
||||
heading,
|
||||
excludeAnimeIds = [],
|
||||
initialQuery = '',
|
||||
busyAnimeId = null,
|
||||
error = null,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: LibraryEntryPickerProps) {
|
||||
const [entries, setEntries] = useState<AnimeLibraryItem[] | null>(null);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const headingId = useId();
|
||||
const searchId = useId();
|
||||
const busy = busyAnimeId !== null;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
apiClient
|
||||
.getAnimeLibrary()
|
||||
.then((data) => {
|
||||
if (!cancelled) setEntries(data);
|
||||
})
|
||||
.catch(() => {
|
||||
// Distinct from an empty library: telling the user "no other titles"
|
||||
// when the request failed hides a retryable error.
|
||||
if (cancelled) return;
|
||||
setEntries([]);
|
||||
setLoadFailed(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useModalFocus({
|
||||
dialogRef,
|
||||
initialFocusRef: inputRef,
|
||||
dismissDisabled: busy,
|
||||
onDismiss: onClose,
|
||||
});
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (!busy) onClose();
|
||||
};
|
||||
|
||||
const excluded = useMemo(() => new Set(excludeAnimeIds), [excludeAnimeIds]);
|
||||
const visible = useMemo(() => {
|
||||
const term = query.trim().toLowerCase();
|
||||
return (entries ?? [])
|
||||
.filter((entry) => !excluded.has(entry.animeId))
|
||||
.filter((entry) => !term || entry.canonicalTitle.toLowerCase().includes(term))
|
||||
.sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
|
||||
}, [entries, excluded, query]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
|
||||
<div
|
||||
ref={dialogRef}
|
||||
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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4 border-b border-ctp-surface1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 id={headingId} className="text-sm font-semibold text-ctp-text">
|
||||
{heading}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
disabled={busy}
|
||||
aria-label="Close"
|
||||
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
|
||||
>
|
||||
{'✕'}
|
||||
</button>
|
||||
</div>
|
||||
<label htmlFor={searchId} className="sr-only">
|
||||
Search library
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={searchId}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
{error ? (
|
||||
<div role="alert" className="text-xs text-ctp-red mt-2">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{entries === null && <div className="text-xs text-ctp-overlay2 p-3">Loading...</div>}
|
||||
{loadFailed && (
|
||||
<div role="alert" className="text-xs text-ctp-red p-3">
|
||||
Could not load the library. Close this dialog and try again.
|
||||
</div>
|
||||
)}
|
||||
{!loadFailed && entries !== null && visible.length === 0 && (
|
||||
<div className="text-xs text-ctp-overlay2 p-3">
|
||||
{query.trim() ? 'No matches' : 'No other titles'}
|
||||
</div>
|
||||
)}
|
||||
{visible.map((entry) => (
|
||||
<button
|
||||
key={entry.animeId}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onSelect(entry)}
|
||||
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-ctp-surface0 transition-colors text-left disabled:opacity-50"
|
||||
>
|
||||
<AnimeCoverImage
|
||||
animeId={entry.animeId}
|
||||
title={entry.canonicalTitle}
|
||||
coverRetryToken={entry.anilistId ?? 0}
|
||||
className="w-10 h-14 rounded shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-ctp-text truncate">{entry.canonicalTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2 mt-0.5">
|
||||
{entry.episodeCount} episode{entry.episodeCount !== 1 ? 's' : ''} ·{' '}
|
||||
{formatDuration(entry.totalActiveMs)}
|
||||
</div>
|
||||
</div>
|
||||
{busyAnimeId === entry.animeId ? (
|
||||
<span className="text-xs text-ctp-blue shrink-0">Moving...</span>
|
||||
) : (
|
||||
<span className="text-xs text-ctp-overlay2 shrink-0">Select</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { AnimeLibraryItem, StatsAnimeMergeRecommendation } from '../types/stats';
|
||||
|
||||
const BACKGROUND_REFRESH_MS = 30_000;
|
||||
import type { AnimeLibraryItem } from '../types/stats';
|
||||
|
||||
export function useAnimeLibrary() {
|
||||
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [recommendations, setRecommendations] = useState<StatsAnimeMergeRecommendation[]>([]);
|
||||
const [dismissingRecommendationId, setDismissingRecommendationId] = useState<number | null>(null);
|
||||
const [recommendationActionError, setRecommendationActionError] = useState<string | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
@@ -19,14 +14,10 @@ export function useAnimeLibrary() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const client = getStatsClient();
|
||||
client
|
||||
getStatsClient()
|
||||
.getAnimeLibrary()
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setAnime(data);
|
||||
setError(null);
|
||||
}
|
||||
if (!cancelled) setAnime(data);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
@@ -34,68 +25,10 @@ export function useAnimeLibrary() {
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
// Recommendation support is deliberately non-blocking. An older backend
|
||||
// should still be able to display its library even when this endpoint is
|
||||
// unavailable.
|
||||
client
|
||||
.getAnimeMergeRecommendations()
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setRecommendations(data.recommendations);
|
||||
setRecommendationActionError(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Preserve the last confirmed set. A transient polling failure should
|
||||
// not make a pending review silently disappear.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reloadToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshOnFocus = () => reload();
|
||||
const interval = window.setInterval(reload, BACKGROUND_REFRESH_MS);
|
||||
window.addEventListener('focus', refreshOnFocus);
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener('focus', refreshOnFocus);
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
const dismissRecommendation = useCallback(async (recommendationId: number) => {
|
||||
setDismissingRecommendationId(recommendationId);
|
||||
setRecommendationActionError(null);
|
||||
try {
|
||||
await getStatsClient().dismissAnimeMergeRecommendation(recommendationId);
|
||||
setRecommendations((current) =>
|
||||
current.filter((item) => item.recommendationId !== recommendationId),
|
||||
);
|
||||
} catch {
|
||||
setRecommendationActionError('Could not dismiss this suggestion. Try again.');
|
||||
} finally {
|
||||
setDismissingRecommendationId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearRecommendation = useCallback((recommendationId: number) => {
|
||||
setRecommendations((current) =>
|
||||
current.filter((item) => item.recommendationId !== recommendationId),
|
||||
);
|
||||
setRecommendationActionError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
anime,
|
||||
loading,
|
||||
error,
|
||||
reload,
|
||||
recommendations,
|
||||
dismissRecommendation,
|
||||
dismissingRecommendationId,
|
||||
recommendationActionError,
|
||||
clearRecommendation,
|
||||
};
|
||||
return { anime, loading, error, reload };
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react';
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'a[href]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
interface UseModalFocusOptions {
|
||||
dialogRef: RefObject<HTMLElement | null>;
|
||||
initialFocusRef: RefObject<HTMLElement | null>;
|
||||
dismissDisabled?: boolean;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function useModalFocus({
|
||||
dialogRef,
|
||||
initialFocusRef,
|
||||
dismissDisabled = false,
|
||||
onDismiss,
|
||||
}: UseModalFocusOptions): void {
|
||||
const dismissDisabledRef = useRef(dismissDisabled);
|
||||
const onDismissRef = useRef(onDismiss);
|
||||
dismissDisabledRef.current = dismissDisabled;
|
||||
onDismissRef.current = onDismiss;
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
initialFocusRef.current?.focus();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (!dismissDisabledRef.current) {
|
||||
event.preventDefault();
|
||||
onDismissRef.current();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const focusable = [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)];
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (!first || !last) return;
|
||||
|
||||
if (
|
||||
event.shiftKey &&
|
||||
(document.activeElement === first || !dialog.contains(document.activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (
|
||||
!event.shiftKey &&
|
||||
(document.activeElement === last || !dialog.contains(document.activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [dialogRef, initialFocusRef]);
|
||||
}
|
||||
@@ -44,45 +44,6 @@ 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 = '';
|
||||
|
||||
@@ -9,10 +9,6 @@ import type {
|
||||
StatsExcludedWordsRequest,
|
||||
StatsHttpClient,
|
||||
StatsJsonResponseMap,
|
||||
StatsMergeAnimeRequest,
|
||||
StatsMergeAnimeResponse,
|
||||
StatsMoveVideoRequest,
|
||||
StatsMoveVideoResponse,
|
||||
StatsTrendGroupBy,
|
||||
StatsTrendRange,
|
||||
StatsVideoWatchedRequest,
|
||||
@@ -129,8 +125,6 @@ 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}`),
|
||||
@@ -200,30 +194,6 @@ 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) =>
|
||||
|
||||
Reference in New Issue
Block a user