Compare commits

...
Author SHA1 Message Date
sudacode c961a84dce fix(stats): keep same-title anime and YouTube records separate
- Scope title identity, aliases, AniList matching, and sync by media kind
- Migrate legacy schemas while preserving IDs, history, and assignments
2026-09-19 00:40:54 -07:00
sudacode e79db73cd9 docs(stats): document separate YouTube media kind
- Mark the YouTube library separation as an added feature
- Document the v24 schema and channel metadata
2026-09-19 00:06:25 -07:00
sudacode d71aab37dc feat(stats): separate YouTube channels in the Library
- Store YouTube channels as a distinct media kind and migrate legacy entries safely
- Add All Titles, Anime, and YouTube filters with channel-specific stats views
2026-09-18 23:53:26 -07:00
sudacode 026d495fac feat(jimaku): add live-action subtitle search (#251) 2026-09-18 22:50:13 -07:00
42 changed files with 1365 additions and 93 deletions
+4
View File
@@ -0,0 +1,4 @@
type: added
area: jimaku
- Jimaku modal gains Anime / Live action tabs so searches can pull from Jimaku's live action catalogue instead of only anime entries (Arrow Left / Right switch tabs).
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: stats
- Document YouTube channel filtering and video statistics in the Library.
+6
View File
@@ -0,0 +1,6 @@
type: added
area: stats
- Store YouTube channels as a separate media kind and migrate existing channel entries without changing viewing history or manual video assignments, including channels created after temporarily returning to an older build.
- Add All Titles, Anime, and YouTube Library filters, identify channel pages, and keep channels out of AniList matching, season repair, and duplicate recommendations.
- Keep same-title anime and YouTube records separate in storage and sync while repairing legacy channel classification.
+3 -3
View File
@@ -55,7 +55,7 @@ When older stats already grouped multiple seasons under one series entry, SubMin
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard. Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
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. When YouTube channel metadata is available, the Library tab groups videos by creator/channel. Use **All Titles**, **Anime**, or **YouTube** above the grid to filter the library. Channel pages show tracked videos and their stats without AniList controls. Existing channel entries are classified as YouTube automatically on startup, preserving viewing history and manual video assignments. Anime and YouTube entries with the same normalized title remain separate, including during stats sync. Channels are excluded from anime metadata matching, season repair, and duplicate recommendations.
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: 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:
@@ -327,14 +327,14 @@ LIMIT ?;
- Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups. - Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups.
- Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`. - Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`.
### Schema (v23) ### Schema (v24)
The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table. The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table.
Core tables: Core tables:
- `imm_videos` - video key/title/source metadata - `imm_videos` - video key/title/source metadata
- `imm_anime` - anime/series metadata referenced by videos and lifetime tables - `imm_anime` - anime/series or YouTube channel metadata (`media_kind`) referenced by videos and lifetime tables
- `imm_anime_title_aliases` - alternate titles that resolve to the same anime row - `imm_anime_title_aliases` - alternate titles that resolve to the same anime row
- `imm_anime_merge_recommendations` - candidate duplicate-series merges surfaced in the dashboard - `imm_anime_merge_recommendations` - candidate duplicate-series merges surfaced in the dashboard
- `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals - `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals
+24 -22
View File
@@ -1,6 +1,6 @@
# Jimaku integration # Jimaku integration
[Jimaku](https://jimaku.cc) is a community subtitle repository for anime, built from files other learners uploaded. SubMiner talks to the Jimaku API, so you search, browse, and download Japanese subtitle files from inside the overlay. No alt-tabbing, no moving files around. A downloaded track loads into mpv right away. [Jimaku](https://jimaku.cc) is a community subtitle repository for anime and Japanese live action, built from files other learners uploaded. SubMiner talks to the Jimaku API, so you search, browse, and download Japanese subtitle files from inside the overlay. No alt-tabbing, no moving files around. A downloaded track loads into mpv right away.
::: tip Prerequisite: a free API key ::: tip Prerequisite: a free API key
You need a Jimaku account and an API key (a personal access string) before this feature works. Create an account at [jimaku.cc](https://jimaku.cc), copy your key, and add it to your config as shown under [Configuration](#configuration) below. Without a key, the search modal will report "Jimaku API key not set." You need a Jimaku account and an API key (a personal access string) before this feature works. Create an account at [jimaku.cc](https://jimaku.cc), copy your key, and add it to your config as shown under [Configuration](#configuration) below. Without a key, the search modal will report "Jimaku API key not set."
@@ -14,21 +14,23 @@ When you open the modal, SubMiner parses the current video filename to extract a
From there: From there:
1. **Search** - SubMiner queries the Jimaku API with the parsed title. Results appear as a list of anime entries (Japanese and English names). 1. **Pick a catalogue** - The **Anime** and **Live action** tabs at the top of the modal choose which Jimaku catalogue to search. Switching tabs re-runs the current search. The choice persists until SubMiner restarts.
2. **Browse entries** - Select an entry to load its available subtitle files, filtered by episode if one was detected. 2. **Search** - SubMiner queries the Jimaku API with the parsed title. Results appear as a list of entries (Japanese and English names).
3. **Browse files** - Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first). 3. **Browse entries** - Select an entry to load its available subtitle files, filtered by episode if one was detected.
4. **Download** - Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track. 4. **Browse files** - Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first).
5. **Download** - Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track.
If no files match the current episode filter, a "Show all files" button lets you broaden the search to all episodes for that entry. If no files match the current episode filter, a "Show all files" button lets you broaden the search to all episodes for that entry.
### Modal keyboard shortcuts ### Modal keyboard shortcuts
| Key | Action | | Key | Action |
| --- | --- | | ---------------------------- | --------------------------------------------- |
| `Enter` (in text field) | Search | | `Enter` (in text field) | Search |
| `Enter` (in list) | Select entry / download file | | `Enter` (in list) | Select entry / download file |
| `Arrow Up` / `Arrow Down` | Navigate entries or files | | `Arrow Up` / `Arrow Down` | Navigate entries or files |
| `Escape` | Close modal | | `Arrow Left` / `Arrow Right` | Switch between the Anime and Live action tabs |
| `Escape` | Close modal |
## Configuration ## Configuration
@@ -41,26 +43,26 @@ Add a `jimaku` section to your `config.jsonc`:
"apiKeyCommand": "cat ~/.jimaku_key", "apiKeyCommand": "cat ~/.jimaku_key",
"apiBaseUrl": "https://jimaku.cc", "apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja", "languagePreference": "ja",
"maxEntryResults": 10 "maxEntryResults": 10,
} },
} }
``` ```
| Option | Type | Default | Description | | Option | Type | Default | Description |
| --- | --- | --- | --- | | --------------------------- | ---------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. | | `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). | | `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. | | `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. | | `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. | | `jimaku.maxEntryResults` | `number` | `10` | Maximum number of entries returned per search. |
The keyboard shortcut is configured separately under `shortcuts`: The keyboard shortcut is configured separately under `shortcuts`:
```jsonc ```jsonc
{ {
"shortcuts": { "shortcuts": {
"openJimaku": "Ctrl+Shift+J" "openJimaku": "Ctrl+Shift+J",
} },
} }
``` ```
+4
View File
@@ -149,6 +149,10 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI
- **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently. - **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
- **Native mpv secondary rendering**: Stays hidden during YouTube flows so the SubMiner overlay remains the visible secondary subtitle surface. - **Native mpv secondary rendering**: Stays hidden during YouTube flows so the SubMiner overlay remains the visible secondary subtitle surface.
## Viewing stats
The stats Library groups tracked YouTube videos by channel when channel metadata is available. Select **YouTube** in the Library filter to see those channels separately from anime. Each channel page lists its videos, watch time, vocabulary, and mined cards. See [Immersion Tracking](/immersion-tracking#library).
## Related pages ## Related pages
- [Usage - YouTube Playback](/usage#youtube-playback) - [Usage - YouTube Playback](/usage#youtube-playback)
@@ -31,6 +31,7 @@ const SYNC_SCHEMA_OBJECTS = [
'imm_lifetime_applied_sessions', 'imm_lifetime_applied_sessions',
'imm_stats_excluded_words', 'imm_stats_excluded_words',
'idx_anime_normalized_title', 'idx_anime_normalized_title',
'idx_anime_kind_title',
'idx_anime_anilist_id', 'idx_anime_anilist_id',
'idx_videos_anime_id', 'idx_videos_anime_id',
'idx_sessions_video_started', 'idx_sessions_video_started',
+4 -2
View File
@@ -12,7 +12,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
); );
CREATE TABLE imm_anime( CREATE TABLE imm_anime(
anime_id INTEGER PRIMARY KEY AUTOINCREMENT, anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
normalized_title_key TEXT NOT NULL UNIQUE, normalized_title_key TEXT NOT NULL,
canonical_title TEXT NOT NULL, canonical_title TEXT NOT NULL,
anilist_id INTEGER UNIQUE, anilist_id INTEGER UNIQUE,
title_romaji TEXT, title_romaji TEXT,
@@ -22,8 +22,10 @@ export const IMMERSION_DB_FIXTURE_DDL = `
description TEXT, description TEXT,
metadata_json TEXT, metadata_json TEXT,
CREATED_DATE TEXT, CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT LAST_UPDATE_DATE TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))
); );
CREATE UNIQUE INDEX idx_anime_kind_title ON imm_anime(media_kind, normalized_title_key);
CREATE TABLE imm_videos( CREATE TABLE imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT, video_id INTEGER PRIMARY KEY AUTOINCREMENT,
video_key TEXT NOT NULL UNIQUE, video_key TEXT NOT NULL UNIQUE,
@@ -192,6 +192,16 @@ export function createCoverArtFetcher(
return { return {
async fetchIfMissing(db, videoId, canonicalTitle): Promise<boolean> { async fetchIfMissing(db, videoId, canonicalTitle): Promise<boolean> {
const channel = db
.prepare(
`
SELECT 1 FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ? AND a.media_kind != 'anime'
`,
)
.get(videoId);
if (channel) return false;
const existing = getCoverArt(db, videoId); const existing = getCoverArt(db, videoId);
if (existing?.coverBlob) { if (existing?.coverBlob) {
return true; return true;
+3 -2
View File
@@ -214,9 +214,10 @@ export function registerAnkiJimakuIpcRuntime(
}, },
getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()), getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()),
searchJimakuEntries: async (query) => { searchJimakuEntries: async (query) => {
logger.info(`[jimaku] search-entries query: "${query.query}"`); const category = query.category ?? 'anime';
logger.info(`[jimaku] search-entries query: "${query.query}" category=${category}`);
const response = await options.jimakuFetchJson<JimakuEntry[]>('/api/entries/search', { const response = await options.jimakuFetchJson<JimakuEntry[]>('/api/entries/search', {
anime: true, anime: category === 'anime',
query: query.query, query: query.query,
}); });
if (!response.ok) return response; if (!response.ok) return response;
@@ -27,7 +27,7 @@ function getAnimeTitles(db: DatabaseSync, animeId: number): AnimeTitleRow | null
.prepare( .prepare(
`SELECT canonical_title, title_romaji, title_english, title_native `SELECT canonical_title, title_romaji, title_english, title_native
FROM imm_anime FROM imm_anime
WHERE anime_id = ?`, WHERE anime_id = ? AND media_kind = 'anime'`,
) )
.get(animeId) as AnimeTitleRow | null; .get(animeId) as AnimeTitleRow | null;
} }
@@ -77,6 +77,7 @@ export function shouldRecommendAnilistConflict(
conflictAnimeId: number, conflictAnimeId: number,
options: AnimeConflictRecommendationOptions, options: AnimeConflictRecommendationOptions,
): boolean { ): boolean {
if (!getAnimeTitles(db, targetAnimeId) || !getAnimeTitles(db, conflictAnimeId)) return false;
if (options.survivor === 'target' || options.matchConfidence === 'manual') return false; if (options.survivor === 'target' || options.matchConfidence === 'manual') return false;
if ( if (
!animeSeasonsAreMergeCompatible( !animeSeasonsAreMergeCompatible(
@@ -99,6 +100,8 @@ export function recordAnimeMergeRecommendation(
secondCandidateAnimeId: number, secondCandidateAnimeId: number,
anilistId: number, anilistId: number,
): void { ): void {
if (!getAnimeTitles(db, firstCandidateAnimeId) || !getAnimeTitles(db, secondCandidateAnimeId))
return;
const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId); const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId);
const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId); const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId);
const timestamp = toDbTimestamp(nowMs()); const timestamp = toDbTimestamp(nowMs());
@@ -141,6 +144,8 @@ export function getAnimeMergeRecommendations(db: DatabaseSync): AnimeMergeRecomm
second_anime_id AS secondAnimeId second_anime_id AS secondAnimeId
FROM imm_anime_merge_recommendations FROM imm_anime_merge_recommendations
WHERE status = 'pending' WHERE status = 'pending'
AND first_anime_id IN (SELECT anime_id FROM imm_anime WHERE media_kind = 'anime')
AND second_anime_id IN (SELECT anime_id FROM imm_anime WHERE media_kind = 'anime')
ORDER BY recommendation_id ASC`, ORDER BY recommendation_id ASC`,
) )
.all() as Array<{ .all() as Array<{
@@ -134,7 +134,7 @@ function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null {
episodes_total, episodes_total,
description description
FROM imm_anime FROM imm_anime
WHERE anime_id = ? WHERE anime_id = ? AND media_kind = 'anime'
`, `,
) )
.get(animeId) as AnimeRow | null; .get(animeId) as AnimeRow | null;
@@ -335,7 +335,8 @@ export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRe
SELECT a.anime_id AS animeId SELECT a.anime_id AS animeId
FROM imm_anime a FROM imm_anime a
JOIN imm_videos v ON v.anime_id = a.anime_id JOIN imm_videos v ON v.anime_id = a.anime_id
WHERE v.parsed_title IS NOT NULL WHERE a.media_kind = 'anime'
AND v.parsed_title IS NOT NULL
AND TRIM(v.parsed_title) != '' AND TRIM(v.parsed_title) != ''
AND v.parsed_season IS NOT NULL AND v.parsed_season IS NOT NULL
AND v.parsed_season > 0 AND v.parsed_season > 0
@@ -372,6 +373,11 @@ export function resolveAnimeAnilistConflict(
anilistId: number, anilistId: number,
options: AnimeAnilistConflictOptions = {}, options: AnimeAnilistConflictOptions = {},
): AnimeSeasonRepairSummary { ): AnimeSeasonRepairSummary {
if (!getAnimeRow(db, targetAnimeId)) {
const summary = emptySummary();
summary.anilistAssignmentBlocked = true;
return summary;
}
const conflict = db const conflict = db
.prepare( .prepare(
` `
@@ -387,6 +393,11 @@ export function resolveAnimeAnilistConflict(
return emptySummary(); return emptySummary();
} }
if (!getAnimeRow(db, conflict.animeId)) {
const summary = emptySummary();
summary.anilistAssignmentBlocked = true;
return summary;
}
return runInTransaction(db, () => { return runInTransaction(db, () => {
const targetRow = getAnimeRow(db, targetAnimeId); const targetRow = getAnimeRow(db, targetAnimeId);
if ( if (
@@ -33,6 +33,7 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
SELECT SELECT
a.anime_id AS animeId, a.anime_id AS animeId,
a.canonical_title AS canonicalTitle, a.canonical_title AS canonicalTitle,
a.media_kind AS mediaKind,
a.anilist_id AS anilistId, a.anilist_id AS anilistId,
COALESCE(lm.total_sessions, 0) AS totalSessions, COALESCE(lm.total_sessions, 0) AS totalSessions,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs, COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
@@ -63,6 +64,7 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
SELECT SELECT
a.anime_id AS animeId, a.anime_id AS animeId,
a.canonical_title AS canonicalTitle, a.canonical_title AS canonicalTitle,
a.media_kind AS mediaKind,
a.anilist_id AS anilistId, a.anilist_id AS anilistId,
a.title_romaji AS titleRomaji, a.title_romaji AS titleRomaji,
a.title_english AS titleEnglish, a.title_english AS titleEnglish,
+100 -11
View File
@@ -1,3 +1,4 @@
import type { MediaKind } from '../../../shared/media-kind';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import path from 'node:path'; import path from 'node:path';
import { parseMediaInfo } from '../../../jimaku/utils'; import { parseMediaInfo } from '../../../jimaku/utils';
@@ -24,6 +25,7 @@ export interface TrackerPreparedStatements {
} }
export interface AnimeRecordInput { export interface AnimeRecordInput {
mediaKind?: MediaKind;
parsedTitle: string; parsedTitle: string;
canonicalTitle: string; canonicalTitle: string;
seasonScope?: number | null; seasonScope?: number | null;
@@ -569,6 +571,8 @@ function ensureSubtitleLineEventIndex(db: DatabaseSync): void {
} }
export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number { export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number {
const mediaKind = input.mediaKind ?? 'anime';
const anilistId = mediaKind === 'anime' ? input.anilistId : null;
const seasonScope = normalizeSeasonScope(input.seasonScope); const seasonScope = normalizeSeasonScope(input.seasonScope);
const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope); const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope);
const canonicalTitle = const canonicalTitle =
@@ -580,17 +584,23 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
} }
const byAnilistId = const byAnilistId =
input.anilistId !== null anilistId !== null
? (db.prepare('SELECT anime_id FROM imm_anime WHERE anilist_id = ?').get(input.anilistId) as { ? (db
.prepare("SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'")
.get(anilistId) as {
anime_id: number; anime_id: number;
} | null) } | null)
: null; : null;
const byNormalizedTitle = db const byNormalizedTitle = db
.prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?') .prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?')
.get(normalizedTitleKey) as { anime_id: number } | null; .get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
const byTitleAlias = db const byTitleAlias = db
.prepare('SELECT anime_id FROM imm_anime_title_aliases WHERE normalized_title_key = ?') .prepare(
.get(normalizedTitleKey) as { anime_id: number } | null; `SELECT a.anime_id FROM imm_anime_title_aliases AS alias
JOIN imm_anime AS a ON a.anime_id = alias.anime_id
WHERE alias.normalized_title_key = ? AND a.media_kind = ?`,
)
.get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias; const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias;
if (existing?.anime_id) { if (existing?.anime_id) {
// An alias remembers an intentionally merged-away spelling. Reusing it // An alias remembers an intentionally merged-away spelling. Reusing it
@@ -600,8 +610,9 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
` `
UPDATE imm_anime UPDATE imm_anime
SET SET
media_kind = COALESCE(?, media_kind),
canonical_title = COALESCE(NULLIF(?, ''), canonical_title), canonical_title = COALESCE(NULLIF(?, ''), canonical_title),
anilist_id = COALESCE(?, anilist_id), anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE COALESCE(?, anilist_id) END,
title_romaji = COALESCE(?, title_romaji), title_romaji = COALESCE(?, title_romaji),
title_english = COALESCE(?, title_english), title_english = COALESCE(?, title_english),
title_native = COALESCE(?, title_native), title_native = COALESCE(?, title_native),
@@ -610,8 +621,10 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
WHERE anime_id = ? WHERE anime_id = ?
`, `,
).run( ).run(
byAnilistId || byNormalizedTitle ? (input.mediaKind ?? null) : null,
canonicalTitleUpdate, canonicalTitleUpdate,
input.anilistId, mediaKind,
anilistId,
input.titleRomaji, input.titleRomaji,
input.titleEnglish, input.titleEnglish,
input.titleNative, input.titleNative,
@@ -627,6 +640,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
.prepare( .prepare(
` `
INSERT INTO imm_anime( INSERT INTO imm_anime(
media_kind,
normalized_title_key, normalized_title_key,
canonical_title, canonical_title,
anilist_id, anilist_id,
@@ -636,13 +650,14 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
metadata_json, metadata_json,
CREATED_DATE, CREATED_DATE,
LAST_UPDATE_DATE LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
) )
.run( .run(
input.mediaKind ?? 'anime',
normalizedTitleKey, normalizedTitleKey,
canonicalTitle, canonicalTitle,
input.anilistId, anilistId,
input.titleRomaji, input.titleRomaji,
input.titleEnglish, input.titleEnglish,
input.titleNative, input.titleNative,
@@ -803,6 +818,7 @@ export function linkYoutubeVideoToAnimeRecord(
} }
const animeId = getOrCreateAnimeRecord(db, { const animeId = getOrCreateAnimeRecord(db, {
mediaKind: 'youtube',
parsedTitle: identity.parsedTitle, parsedTitle: identity.parsedTitle,
canonicalTitle: identity.canonicalTitle, canonicalTitle: identity.canonicalTitle,
anilistId: null, anilistId: null,
@@ -875,6 +891,70 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
} }
} }
// SQLite cannot drop a table-level UNIQUE constraint. Rebuild with IDs intact
// and foreign keys disabled so dependent history and manual assignments survive.
function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'imm_anime'").get() as {
sql: string;
};
if (/normalized_title_key TEXT NOT NULL UNIQUE/i.test(schema.sql)) {
const foreignKeys = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number };
const sequence = db
.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'imm_anime'")
.get() as { seq: number } | null;
db.exec('PRAGMA foreign_keys = OFF');
try {
db.exec('BEGIN IMMEDIATE');
db.exec(
schema.sql
.replace(
/CREATE TABLE (?:IF NOT EXISTS )?["`]?imm_anime["`]?/i,
'CREATE TABLE imm_anime_new',
)
.replace(
/normalized_title_key TEXT NOT NULL UNIQUE/i,
'normalized_title_key TEXT NOT NULL',
),
);
db.exec(`INSERT INTO imm_anime_new SELECT * FROM imm_anime;
DROP TABLE imm_anime;
ALTER TABLE imm_anime_new RENAME TO imm_anime;`);
if (sequence) {
db.prepare("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = 'imm_anime'").run(
sequence.seq,
);
}
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
} finally {
db.exec(`PRAGMA foreign_keys = ${foreignKeys.foreign_keys}`);
}
}
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_kind_title
ON imm_anime(media_kind, normalized_title_key)`);
}
// Older builds can create channel rows with the default anime kind even after
// the schema upgrade. Repair classification on every startup without moving videos.
function classifyYoutubeChannels(db: DatabaseSync): void {
db.exec(`
UPDATE imm_anime
SET media_kind = 'youtube', anilist_id = NULL
WHERE media_kind = 'anime'
AND NOT EXISTS (SELECT 1 FROM imm_anime AS channel
WHERE channel.media_kind = 'youtube'
AND channel.normalized_title_key = imm_anime.normalized_title_key)
AND (
normalized_title_key LIKE 'youtube channel %'
OR CASE WHEN json_valid(metadata_json)
THEN json_extract(metadata_json, '$.source') = 'youtube-channel'
ELSE 0 END
)
`);
}
export function ensureSchema(db: DatabaseSync): void { export function ensureSchema(db: DatabaseSync): void {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS imm_schema_version ( CREATE TABLE IF NOT EXISTS imm_schema_version (
@@ -897,6 +977,7 @@ export function ensureSchema(db: DatabaseSync): void {
.prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1') .prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1')
.get() as { schema_version: number } | null; .get() as { schema_version: number } | null;
if (currentVersion?.schema_version === SCHEMA_VERSION) { if (currentVersion?.schema_version === SCHEMA_VERSION) {
classifyYoutubeChannels(db);
ensureLexicalDailyRollupTables(db); ensureLexicalDailyRollupTables(db);
ensureLifetimeSummaryTables(db); ensureLifetimeSummaryTables(db);
ensureStatsExcludedWordsTable(db); ensureStatsExcludedWordsTable(db);
@@ -908,7 +989,7 @@ export function ensureSchema(db: DatabaseSync): void {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS imm_anime( CREATE TABLE IF NOT EXISTS imm_anime(
anime_id INTEGER PRIMARY KEY AUTOINCREMENT, anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
normalized_title_key TEXT NOT NULL UNIQUE, normalized_title_key TEXT NOT NULL,
canonical_title TEXT NOT NULL, canonical_title TEXT NOT NULL,
anilist_id INTEGER UNIQUE, anilist_id INTEGER UNIQUE,
title_romaji TEXT, title_romaji TEXT,
@@ -921,6 +1002,12 @@ export function ensureSchema(db: DatabaseSync): void {
LAST_UPDATE_DATE TEXT LAST_UPDATE_DATE TEXT
); );
`); `);
addColumnIfMissing(
db,
'imm_anime',
'media_kind',
"TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))",
);
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS imm_videos( CREATE TABLE IF NOT EXISTS imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT, video_id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1464,6 +1551,8 @@ export function ensureSchema(db: DatabaseSync): void {
); );
} }
migrateAnimeTitleUniqueness(db);
classifyYoutubeChannels(db);
migrateSessionEventTimestampsToText(db); migrateSessionEventTimestampsToText(db);
ensureLexicalDailyRollupTables(db); ensureLexicalDailyRollupTables(db);
+5 -1
View File
@@ -1,4 +1,6 @@
export const SCHEMA_VERSION = 23; import type { MediaKind } from '../../../shared/media-kind';
export const SCHEMA_VERSION = 25;
export const DEFAULT_QUEUE_CAP = 1_000; export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25; export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500; export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -518,6 +520,7 @@ export interface YoutubeVideoMetadata {
} }
export interface AnimeLibraryRow { export interface AnimeLibraryRow {
mediaKind: MediaKind;
animeId: number; animeId: number;
canonicalTitle: string; canonicalTitle: string;
anilistId: number | null; anilistId: number | null;
@@ -531,6 +534,7 @@ export interface AnimeLibraryRow {
} }
export interface AnimeDetailRow { export interface AnimeDetailRow {
mediaKind: MediaKind;
animeId: number; animeId: number;
canonicalTitle: string; canonicalTitle: string;
anilistId: number | null; anilistId: number | null;
@@ -0,0 +1,310 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Database, type DatabaseSync } from './sqlite';
import {
ensureSchema,
getOrCreateAnimeRecord,
getOrCreateVideoRecord,
linkYoutubeVideoToAnimeRecord,
} from './storage';
import { getAnimeDetail, getAnimeLibrary } from './query-library';
import { updateAnimeAnilistInfo } from './query-maintenance';
import {
repairLegacySeasonlessAnimeRows,
resolveAnimeAnilistConflict,
} from './anime-season-repair';
import {
getAnimeMergeRecommendations,
recordAnimeMergeRecommendation,
} from './anime-merge-recommendations';
import { createCoverArtFetcher } from '../anilist/cover-art-fetcher';
import { SCHEMA_VERSION, SOURCE_TYPE_REMOTE, type YoutubeVideoMetadata } from './types';
const metadata: YoutubeVideoMetadata = {
youtubeVideoId: 'video1',
videoUrl: 'https://www.youtube.com/watch?v=video1',
videoTitle: 'Video title',
videoThumbnailUrl: null,
channelId: 'UC123',
channelName: 'Channel name',
channelUrl: 'https://www.youtube.com/channel/UC123',
channelThumbnailUrl: null,
uploaderId: null,
uploaderUrl: null,
description: null,
metadataJson: null,
};
function createVideo(db: DatabaseSync, key: string): number {
return getOrCreateVideoRecord(db, key, {
canonicalTitle: 'Video title',
sourcePath: null,
sourceUrl: metadata.videoUrl,
sourceType: SOURCE_TYPE_REMOTE,
});
}
function createAnime(
db: DatabaseSync,
parsedTitle: string,
metadataJson: string | null = null,
): number {
return getOrCreateAnimeRecord(db, {
parsedTitle,
canonicalTitle: parsedTitle,
metadataJson,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
});
}
test('schema 23 channel migration preserves history and manual assignments and is idempotent', () => {
const db = new Database(':memory:');
try {
ensureSchema(db);
const ids = [
createAnime(db, 'youtube-channel:UC123'),
createAnime(db, 'youtube-channel-url:https://www.youtube.com/@creator'),
createAnime(db, 'youtube-channel-name:Creator'),
createAnime(db, 'Renamed channel', '{ "source": "youtube-channel" }'),
];
const animeId = createAnime(db, 'Anime title', 'legacy non-JSON metadata');
const videoId = createVideo(db, 'manual');
db.prepare(
'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?',
).run(animeId, videoId);
db.prepare(
'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123456, 7)',
).run(animeId);
const history = getAnimeLibrary(db);
// Reproduce the previous schema, including its lack of a media kind column.
db.exec(
'DROP INDEX idx_anime_kind_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)',
);
ensureSchema(db);
ensureSchema(db);
for (const id of ids) {
const row = db.prepare('SELECT media_kind FROM imm_anime WHERE anime_id = ?').get(id);
assert.ok(row && typeof row === 'object' && 'media_kind' in row);
assert.equal(row.media_kind, 'youtube');
}
assert.deepEqual(getAnimeLibrary(db), history);
assert.equal(linkYoutubeVideoToAnimeRecord(db, videoId, metadata), animeId);
const version = db
.prepare('SELECT MAX(schema_version) AS version FROM imm_schema_version')
.get();
assert.ok(version && typeof version === 'object' && 'version' in version);
assert.equal(version.version, SCHEMA_VERSION);
} finally {
db.close();
}
});
test('channel creation and repeated linking expose youtube in library and detail without losing totals', async () => {
const db = new Database(':memory:');
try {
ensureSchema(db);
const videoId = createVideo(db, 'first');
const channelId = linkYoutubeVideoToAnimeRecord(db, videoId, metadata);
assert.ok(channelId);
const secondVideoId = createVideo(db, 'second');
assert.equal(linkYoutubeVideoToAnimeRecord(db, secondVideoId, metadata), channelId);
db.prepare(
'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123456, 7)',
).run(channelId);
assert.equal(getAnimeLibrary(db)[0]?.mediaKind, 'youtube');
const detail = getAnimeDetail(db, channelId);
assert.equal(detail?.mediaKind, 'youtube');
assert.equal(detail?.episodeCount, 2);
assert.equal(detail?.totalActiveMs, 123456);
assert.equal(detail?.totalCards, 7);
// Even parsed season numbers and a matching anime name must not trigger repairs.
db.prepare('UPDATE imm_videos SET parsed_season = video_id').run();
assert.equal(repairLegacySeasonlessAnimeRows(db).repaired, 0);
const animeId = createAnime(db, 'Channel name');
for (const matchConfidence of ['exact', 'weak', 'manual'] as const) {
assert.equal(
resolveAnimeAnilistConflict(db, channelId, 123, { matchConfidence })
.anilistAssignmentBlocked,
true,
);
}
updateAnimeAnilistInfo(db, videoId, {
anilistId: 123,
titleRomaji: 'Wrong title',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
});
recordAnimeMergeRecommendation(db, channelId, animeId, 123);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
assert.equal(getAnimeDetail(db, channelId)?.anilistId, null);
const fetcher = createCoverArtFetcher(
{
acquire: async () => {
assert.fail('YouTube must not query AniList');
},
recordResponse: () => {},
},
console,
);
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Channel name'), false);
} finally {
db.close();
}
});
test('startup reclassifies channels created by an older build after the schema upgrade', () => {
const db = new Database(':memory:');
try {
ensureSchema(db);
// An old build omits media_kind when creating a channel in the upgraded DB.
const channelId = createAnime(db, 'youtube-channel:UCnew');
db.prepare('UPDATE imm_anime SET anilist_id = 321 WHERE anime_id = ?').run(channelId);
const animeId = createAnime(db, 'Regular anime');
const videoId = createVideo(db, 'older-build');
db.prepare(
'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?',
).run(channelId, videoId);
db.prepare(
'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 120000, 5)',
).run(channelId);
assert.equal(getAnimeLibrary(db)[0]?.mediaKind, 'anime');
ensureSchema(db);
const channel = getAnimeLibrary(db)[0];
assert.equal(channel?.animeId, channelId);
assert.equal(channel?.mediaKind, 'youtube');
assert.equal(
(
db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(channelId) as {
anilist_id: number | null;
}
).anilist_id,
null,
);
assert.equal(channel?.totalActiveMs, 120000);
assert.equal(channel?.totalCards, 5);
assert.equal(linkYoutubeVideoToAnimeRecord(db, videoId, metadata), channelId);
const anime = db.prepare('SELECT media_kind FROM imm_anime WHERE anime_id = ?').get(animeId);
assert.ok(anime && typeof anime === 'object' && 'media_kind' in anime);
assert.equal(anime.media_kind, 'anime');
} finally {
db.close();
}
});
test('title identity and aliases never cross media kinds', () => {
const db = new Database(':memory:');
try {
ensureSchema(db);
const animeId = createAnime(db, 'Shared title');
const input = {
mediaKind: 'youtube' as const,
parsedTitle: 'Shared title',
canonicalTitle: 'Shared title',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
};
const channelId = getOrCreateAnimeRecord(db, input);
assert.notEqual(channelId, animeId);
db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = ?').run(channelId);
assert.equal(getOrCreateAnimeRecord(db, input), channelId);
assert.equal(
(
db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(channelId) as {
anilist_id: number | null;
}
).anilist_id,
null,
);
assert.equal(createAnime(db, 'Shared title'), animeId);
db.prepare(
'INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id) VALUES (?, ?)',
).run('alias title', animeId);
assert.notEqual(getOrCreateAnimeRecord(db, { ...input, parsedTitle: 'Alias title' }), animeId);
assert.throws(() =>
db
.prepare(
"INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind) VALUES ('shared title', 'duplicate', 'youtube')",
)
.run(),
);
} finally {
db.close();
}
});
test('schema 24 title constraint migration preserves referenced data', () => {
const db = new Database(':memory:');
try {
// Reproduce the original table-level uniqueness constraint.
db.exec(`CREATE TABLE imm_anime(
anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
normalized_title_key TEXT NOT NULL UNIQUE,
canonical_title TEXT NOT NULL,
anilist_id INTEGER UNIQUE,
title_romaji TEXT, title_english TEXT, title_native TEXT,
episodes_total INTEGER, description TEXT, metadata_json TEXT,
CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))
)`);
ensureSchema(db);
const animeId = createAnime(db, 'Shared title');
const videoId = createVideo(db, 'migration-video');
db.prepare(
'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?',
).run(animeId, videoId);
db.prepare(
'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123, 4)',
).run(animeId);
// Restore the old constraint while leaving child rows populated.
db.exec(`PRAGMA foreign_keys = OFF;
CREATE TABLE old_anime AS SELECT * FROM imm_anime;
DROP TABLE imm_anime;
CREATE TABLE imm_anime(
anime_id INTEGER PRIMARY KEY AUTOINCREMENT, normalized_title_key TEXT NOT NULL UNIQUE,
canonical_title TEXT NOT NULL, anilist_id INTEGER UNIQUE,
title_romaji TEXT, title_english TEXT, title_native TEXT, episodes_total INTEGER,
description TEXT, metadata_json TEXT, CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube')));
INSERT INTO imm_anime SELECT * FROM old_anime;
DROP TABLE old_anime;
DELETE FROM imm_schema_version;
INSERT INTO imm_schema_version VALUES (24, 0);
PRAGMA foreign_keys = ON;`);
ensureSchema(db);
ensureSchema(db);
assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(), []);
assert.equal(
(db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }).foreign_keys,
1,
);
assert.equal(
(
db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?').get(videoId) as {
anime_id: number;
}
).anime_id,
animeId,
);
assert.equal(
(
db
.prepare('SELECT total_active_ms FROM imm_lifetime_anime WHERE anime_id = ?')
.get(animeId) as { total_active_ms: number }
).total_active_ms,
123,
);
db.prepare(
"INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind) VALUES ('shared title', 'Channel', 'youtube')",
).run();
} finally {
db.close();
}
});
+37 -5
View File
@@ -2,6 +2,7 @@ import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver'
import { insertRow, tableExists, type SyncMergeSummary } from './shared'; import { insertRow, tableExists, type SyncMergeSummary } from './shared';
const ANIME_COPY_COLUMNS = [ const ANIME_COPY_COLUMNS = [
'media_kind',
'normalized_title_key', 'normalized_title_key',
'canonical_title', 'canonical_title',
'anilist_id', 'anilist_id',
@@ -98,11 +99,28 @@ export function mergeAnime(
summary: SyncMergeSummary, summary: SyncMergeSummary,
): Map<number, number> { ): Map<number, number> {
const map = new Map<number, number>(); const map = new Map<number, number>();
const byAnilist = local.query('SELECT anime_id FROM imm_anime WHERE anilist_id = ?'); const byAnilist = local.query(
const byTitleKey = local.query('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?'); "SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'",
);
const byTitleKey = local.query(
'SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?',
);
// A pre-classification channel can be repaired, but a genuine anime sharing
// its title must remain a separate entry.
const legacyChannel = local.query(`SELECT anime_id FROM imm_anime
WHERE normalized_title_key = ? AND media_kind = 'anime' AND (
normalized_title_key LIKE 'youtube channel %'
OR CASE WHEN json_valid(metadata_json)
THEN json_extract(metadata_json, '$.source') = 'youtube-channel' ELSE 0 END
)`);
const releaseChannelAnilistId = local.query(
"UPDATE imm_anime SET anilist_id = NULL WHERE media_kind = 'youtube' AND anilist_id = ?",
);
const fillMissing = local.query( const fillMissing = local.query(
`UPDATE imm_anime `UPDATE imm_anime
SET SET
media_kind = ?,
anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE anilist_id END,
title_romaji = COALESCE(title_romaji, ?), title_romaji = COALESCE(title_romaji, ?),
title_english = COALESCE(title_english, ?), title_english = COALESCE(title_english, ?),
title_native = COALESCE(title_native, ?), title_native = COALESCE(title_native, ?),
@@ -116,12 +134,24 @@ export function mergeAnime(
`SELECT anime_id, ${ANIME_COPY_COLUMNS.join(', ')} FROM imm_anime`, `SELECT anime_id, ${ANIME_COPY_COLUMNS.join(', ')} FROM imm_anime`,
)) { )) {
const remoteId = Number(row.anime_id); const remoteId = Number(row.anime_id);
const existing = ((row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ?? if (row.media_kind === 'anime' && row.anilist_id !== null) {
byTitleKey.get(row.normalized_title_key)) as SqlRow | undefined; // AniList identifiers belong to anime, including when an older peer
// incorrectly attached one to a channel.
releaseChannelAnilistId.run(row.anilist_id);
}
const existing = ((row.media_kind === 'anime' && row.anilist_id !== null
? byAnilist.get(row.anilist_id)
: undefined) ??
byTitleKey.get(row.normalized_title_key, row.media_kind) ??
(row.media_kind === 'youtube' ? legacyChannel.get(row.normalized_title_key) : undefined)) as
| SqlRow
| undefined;
if (existing) { if (existing) {
const localId = Number(existing.anime_id); const localId = Number(existing.anime_id);
map.set(remoteId, localId); map.set(remoteId, localId);
fillMissing.run( fillMissing.run(
row.media_kind,
row.media_kind,
row.title_romaji, row.title_romaji,
row.title_english, row.title_english,
row.title_native, row.title_native,
@@ -133,7 +163,9 @@ export function mergeAnime(
} }
// No local row matched by anilist_id (checked first in `existing` above) // No local row matched by anilist_id (checked first in `existing` above)
// or title key, so the remote anilist_id — if any — is free to insert as-is. // or title key, so the remote anilist_id — if any — is free to insert as-is.
const values = ANIME_COPY_COLUMNS.map((column) => row[column]); const values = ANIME_COPY_COLUMNS.map((column) =>
column === 'anilist_id' && row.media_kind !== 'anime' ? null : row[column],
);
map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values)); map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values));
summary.animeAdded += 1; summary.animeAdded += 1;
} }
@@ -98,3 +98,122 @@ for (const legacyOccurrences of [false, true]) {
} }
}); });
} }
test('sync preserves the YouTube media kind when adding a channel', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-youtube-'));
try {
const localPath = buildDb(dir, 'local.sqlite', {
word: '猫',
seenMs: BASE_MS,
legacyOccurrences: false,
});
const remotePath = buildDb(dir, 'remote.sqlite', {
word: '犬',
seenMs: BASE_MS,
legacyOccurrences: false,
});
const remote = new Database(remotePath);
remote.exec("UPDATE imm_anime SET media_kind = 'youtube'");
remote.close();
mergeSnapshotIntoDb(localPath, remotePath);
const local = new Database(localPath);
try {
const row = local
.prepare(
"SELECT media_kind FROM imm_anime WHERE normalized_title_key = 'key-remote.sqlite'",
)
.get();
assert.ok(row && typeof row === 'object' && 'media_kind' in row);
assert.equal(row.media_kind, 'youtube');
} finally {
local.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('sync separates same-title kinds and repairs only identifiable legacy channels', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-kinds-'));
const localPath = buildDb(dir, 'local', {
word: 'local',
seenMs: BASE_MS,
legacyOccurrences: false,
});
const remotePath = buildDb(dir, 'remote', {
word: 'remote',
seenMs: BASE_MS,
legacyOccurrences: false,
});
try {
const local = new Database(localPath);
local.exec(`UPDATE imm_anime SET normalized_title_key = 'shared', anilist_id = 42;
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, metadata_json, title_english, LAST_UPDATE_DATE)
VALUES (2, 'legacy', 'Local channel', '{"source":"youtube-channel"}', 'Keep local', '9999999999999');
UPDATE imm_anime SET anilist_id = 99 WHERE anime_id = 2;
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, anilist_id)
VALUES (3, 'other shared', 'Channel with bad ID', 'youtube', 77);`);
local.close();
const remote = new Database(remotePath);
remote.exec(`UPDATE imm_anime SET normalized_title_key = 'shared', media_kind = 'youtube', anilist_id = 42;
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, title_english, description, LAST_UPDATE_DATE)
VALUES (2, 'legacy', 'Remote channel', 'youtube', 'Remote title', 'Fill missing', '1');
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, anilist_id)
VALUES (3, 'other shared', 'Real anime', 'anime', 77);`);
remote.close();
mergeSnapshotIntoDb(localPath, remotePath);
mergeSnapshotIntoDb(localPath, remotePath);
const db = new Database(localPath);
try {
const rows = db
.prepare(
'SELECT media_kind FROM imm_anime WHERE normalized_title_key = ? ORDER BY media_kind',
)
.all('shared');
assert.deepEqual(rows, [{ media_kind: 'anime' }, { media_kind: 'youtube' }]);
assert.deepEqual(
db
.prepare(
"SELECT media_kind FROM imm_anime WHERE normalized_title_key = 'other shared' ORDER BY media_kind",
)
.all(),
[{ media_kind: 'anime' }, { media_kind: 'youtube' }],
);
assert.equal(
(
db.prepare('SELECT media_kind FROM imm_anime WHERE anilist_id = 77').get() as {
media_kind: string;
}
).media_kind,
'anime',
);
assert.deepEqual(
db
.prepare(
'SELECT media_kind, anilist_id, title_english, description FROM imm_anime WHERE anime_id = 2',
)
.all()[0],
{
media_kind: 'youtube',
anilist_id: null,
title_english: 'Keep local',
description: 'Fill missing',
},
);
assert.equal(
(
db
.prepare(
"SELECT a.media_kind FROM imm_videos v JOIN imm_anime a ON a.anime_id = v.anime_id WHERE v.video_key = 'video-remote'",
)
.get() as { media_kind: string }
).media_kind,
'youtube',
);
} finally {
db.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+21 -1
View File
@@ -86,10 +86,30 @@
<button id="jimakuClose" class="modal-close" type="button">Close</button> <button id="jimakuClose" class="modal-close" type="button">Close</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="jimaku-tabs" role="tablist">
<button
id="jimakuTabAnime"
class="jimaku-tab active"
type="button"
role="tab"
aria-selected="true"
>
Anime
</button>
<button
id="jimakuTabLiveAction"
class="jimaku-tab"
type="button"
role="tab"
aria-selected="false"
>
Live action
</button>
</div>
<div class="jimaku-form"> <div class="jimaku-form">
<label class="jimaku-field"> <label class="jimaku-field">
<span>Title</span> <span>Title</span>
<input id="jimakuTitle" type="text" placeholder="Anime title" /> <input id="jimakuTitle" type="text" placeholder="Title" />
</label> </label>
<label class="jimaku-field"> <label class="jimaku-field">
<span>Season</span> <span>Season</span>
+426
View File
@@ -119,6 +119,8 @@ test('successful Jimaku subtitle selection closes modal', async () => {
classList: jimakuBroadenButtonClassList, classList: jimakuBroadenButtonClassList,
addEventListener: () => {}, addEventListener: () => {},
}, },
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
}, },
state, state,
}; };
@@ -147,3 +149,427 @@ test('successful Jimaku subtitle selection closes modal', async () => {
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
} }
}); });
test('switching to the Live action tab re-runs the search with the live action category', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const searchQueries: Array<{ query: string; category?: string }> = [];
const electronAPI = {
jimakuSearchEntries: async (query: { query: string; category?: string }) => {
searchQueries.push(query);
return { ok: true, data: [] };
},
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
try {
const state = createRendererState();
state.jimakuModalOpen = true;
const animeTabClassList = createClassList(['active']);
const liveActionTabClassList = createClassList();
const status = { textContent: '', style: { color: '' } };
const ctx = {
dom: {
overlay: { classList: createClassList(['interactive']) },
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
jimakuTitleInput: { value: 'Shinzanmono' },
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '3' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: status,
jimakuEntriesSection: { classList: createClassList(['hidden']) },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: createClassList(['hidden']) },
jimakuFilesList: createListStub(),
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
jimakuTabAnimeButton: { classList: animeTabClassList, setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: liveActionTabClassList, setAttribute: () => {} },
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
jimakuModal.handleJimakuKeydown({
key: 'ArrowRight',
preventDefault: () => {},
} as KeyboardEvent);
await flushAsyncWork();
assert.equal(state.jimakuActiveTab, 'liveAction');
assert.equal(liveActionTabClassList.contains('active'), true);
assert.equal(animeTabClassList.contains('active'), false);
assert.deepEqual(searchQueries, [{ query: 'Shinzanmono', category: 'liveAction' }]);
assert.equal(status.textContent, 'No live action entries found. Try the Anime tab.');
// Same tab again is a no-op: no duplicate request.
jimakuModal.handleJimakuKeydown({
key: 'ArrowRight',
preventDefault: () => {},
} as KeyboardEvent);
await flushAsyncWork();
assert.equal(searchQueries.length, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('a slow reply from a superseded search does not overwrite the newer results', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const pending: Array<(entries: unknown[]) => void> = [];
const electronAPI = {
jimakuSearchEntries: () =>
new Promise((resolve) => {
pending.push((entries) => resolve({ ok: true, data: entries }));
}),
jimakuListFiles: async () => ({ ok: true, data: [] }),
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
try {
const state = createRendererState();
state.jimakuModalOpen = true;
const ctx = {
dom: {
overlay: { classList: createClassList(['interactive']) },
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
jimakuTitleInput: { value: 'Shinzanmono' },
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: { textContent: '', style: { color: '' } },
jimakuEntriesSection: { classList: createClassList(['hidden']) },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: createClassList(['hidden']) },
jimakuFilesList: createListStub(),
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
// Anime -> Live action -> Anime, all before any reply arrives.
jimakuModal.handleJimakuKeydown({
key: 'ArrowRight',
preventDefault: () => {},
} as KeyboardEvent);
jimakuModal.handleJimakuKeydown({
key: 'ArrowLeft',
preventDefault: () => {},
} as KeyboardEvent);
await flushAsyncWork();
assert.equal(pending.length, 2);
// The stale live action reply lands after the newer anime search was issued.
pending[0]!([{ id: 1, name: 'Stale live action entry' }]);
await flushAsyncWork();
assert.equal(state.jimakuEntries.length, 0);
pending[1]!([
{ id: 2, name: 'Anime A' },
{ id: 3, name: 'Anime B' },
]);
await flushAsyncWork();
assert.deepEqual(
state.jimakuEntries.map((entry) => entry.id),
[2, 3],
);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('closing the modal discards an in-flight search reply', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
let resolveSearch!: (entries: unknown[]) => void;
let listFilesCalls = 0;
const electronAPI = {
jimakuSearchEntries: () =>
new Promise((resolve) => {
resolveSearch = (entries) => resolve({ ok: true, data: entries });
}),
jimakuListFiles: async () => {
listFilesCalls += 1;
return { ok: true, data: [] };
},
notifyOverlayModalClosed: () => {},
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
try {
const state = createRendererState();
state.jimakuModalOpen = true;
const ctx = {
dom: {
overlay: { classList: createClassList(['interactive']) },
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
jimakuTitleInput: { value: 'Shinzanmono' },
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: { textContent: '', style: { color: '' } },
jimakuEntriesSection: { classList: createClassList(['hidden']) },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: createClassList(['hidden']) },
jimakuFilesList: createListStub(),
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
await flushAsyncWork();
jimakuModal.closeJimakuModal();
// A single entry would normally auto-select and fetch its files.
resolveSearch([{ id: 7, name: 'Only entry' }]);
await flushAsyncWork();
assert.equal(state.jimakuEntries.length, 0);
assert.equal(state.currentEntryId, null);
assert.equal(listFilesCalls, 0);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('a slow files reply for a previously selected entry is ignored', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const pending = new Map<number, (files: unknown[]) => void>();
const electronAPI = {
jimakuListFiles: (query: { entryId: number }) =>
new Promise((resolve) => {
pending.set(query.entryId, (files) => resolve({ ok: true, data: files }));
}),
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
try {
const state = createRendererState();
state.jimakuModalOpen = true;
state.jimakuEntries = [
{ id: 1, name: 'Entry A' },
{ id: 2, name: 'Entry B' },
];
const ctx = {
dom: {
overlay: { classList: createClassList(['interactive']) },
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
jimakuTitleInput: { value: '' },
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: { textContent: '', style: { color: '' } },
jimakuEntriesSection: { classList: createClassList() },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: createClassList(['hidden']) },
jimakuFilesList: createListStub(),
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
// Select entry A, then move to entry B before A's files arrive.
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
jimakuModal.handleJimakuKeydown({
key: 'ArrowDown',
preventDefault: () => {},
} as KeyboardEvent);
jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent);
await flushAsyncWork();
assert.equal(state.currentEntryId, 2);
pending.get(1)!([
{ name: 'a.srt', url: 'https://jimaku.cc/a.srt', size: 1, last_modified: '' },
]);
await flushAsyncWork();
assert.equal(state.jimakuFiles.length, 0);
pending.get(2)!([
{ name: 'b1.srt', url: 'https://jimaku.cc/b1.srt', size: 1, last_modified: '' },
{ name: 'b2.srt', url: 'https://jimaku.cc/b2.srt', size: 1, last_modified: '' },
]);
await flushAsyncWork();
assert.deepEqual(
state.jimakuFiles.map((file) => file.name),
['b1.srt', 'b2.srt'],
);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('media info arriving after the modal closed does not fill inputs or search', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
let resolveMediaInfo!: (info: unknown) => void;
let searchCalls = 0;
const electronAPI = {
getJimakuMediaInfo: () =>
new Promise((resolve) => {
resolveMediaInfo = resolve;
}),
jimakuSearchEntries: async () => {
searchCalls += 1;
return { ok: true, data: [] };
},
notifyOverlayModalClosed: () => {},
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
try {
const state = createRendererState();
const titleInput = { value: '' };
const status = { textContent: '', style: { color: '' } };
const ctx = {
dom: {
overlay: { classList: createClassList() },
jimakuModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
jimakuTitleInput: titleInput,
jimakuSeasonInput: { value: '' },
jimakuEpisodeInput: { value: '' },
jimakuSearchButton: { addEventListener: () => {} },
jimakuCloseButton: { addEventListener: () => {} },
jimakuStatus: status,
jimakuEntriesSection: { classList: createClassList(['hidden']) },
jimakuEntriesList: createListStub(),
jimakuFilesSection: { classList: createClassList(['hidden']) },
jimakuFilesList: createListStub(),
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
},
state,
};
const jimakuModal = createJimakuModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
jimakuModal.openJimakuModal();
await flushAsyncWork();
jimakuModal.closeJimakuModal();
resolveMediaInfo({
title: 'Shinzanmono',
season: 1,
episode: 3,
confidence: 'high',
filename: 'Shinzanmono S01E03.mkv',
rawTitle: 'Shinzanmono S01E03',
});
await flushAsyncWork();
assert.equal(titleInput.value, '');
assert.equal(searchCalls, 0);
assert.equal(status.textContent, 'Loading media info...');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
+69 -2
View File
@@ -4,6 +4,7 @@ import type {
JimakuEntry, JimakuEntry,
JimakuFileEntry, JimakuFileEntry,
JimakuMediaInfo, JimakuMediaInfo,
JimakuSearchCategory,
} from '../../types'; } from '../../types';
import type { ModalStateReader, RendererContext } from '../context'; import type { ModalStateReader, RendererContext } from '../context';
@@ -21,7 +22,12 @@ export function createJimakuModal(
: 'rgba(255, 255, 255, 0.8)'; : 'rgba(255, 255, 255, 0.8)';
} }
// Bumped whenever the lists are reset (new search, tab switch, open, close)
// so any in-flight entries or files reply for the old state is discarded.
let searchGeneration = 0;
function resetJimakuLists(): void { function resetJimakuLists(): void {
searchGeneration += 1;
ctx.state.jimakuEntries = []; ctx.state.jimakuEntries = [];
ctx.state.jimakuFiles = []; ctx.state.jimakuFiles = [];
ctx.state.selectedEntryIndex = 0; ctx.state.selectedEntryIndex = 0;
@@ -35,6 +41,33 @@ export function createJimakuModal(
ctx.dom.jimakuBroadenButton.classList.add('hidden'); ctx.dom.jimakuBroadenButton.classList.add('hidden');
} }
function renderTabs(): void {
const liveActionActive = ctx.state.jimakuActiveTab === 'liveAction';
const active = liveActionActive
? ctx.dom.jimakuTabLiveActionButton
: ctx.dom.jimakuTabAnimeButton;
const inactive = liveActionActive
? ctx.dom.jimakuTabAnimeButton
: ctx.dom.jimakuTabLiveActionButton;
active.classList.add('active');
active.setAttribute('aria-selected', 'true');
inactive.classList.remove('active');
inactive.setAttribute('aria-selected', 'false');
}
// Tabs map to Jimaku's anime / live-action catalogues, so switching re-runs
// the search server-side instead of filtering a shared result list.
function setActiveTab(tab: JimakuSearchCategory): void {
if (ctx.state.jimakuActiveTab === tab) return;
ctx.state.jimakuActiveTab = tab;
renderTabs();
if (getSearchQuery().query) {
void performJimakuSearch();
} else {
resetJimakuLists();
}
}
function formatEntryLabel(entry: JimakuEntry): string { function formatEntryLabel(entry: JimakuEntry): string {
if (entry.english_name && entry.english_name !== entry.name) { if (entry.english_name && entry.english_name !== entry.name) {
return `${entry.name} / ${entry.english_name}`; return `${entry.name} / ${entry.english_name}`;
@@ -133,9 +166,12 @@ export function createJimakuModal(
setJimakuStatus('Searching Jimaku...'); setJimakuStatus('Searching Jimaku...');
ctx.state.currentEpisodeFilter = episode; ctx.state.currentEpisodeFilter = episode;
const category = ctx.state.jimakuActiveTab;
const generation = searchGeneration;
const response: JimakuApiResponse<JimakuEntry[]> = await window.electronAPI.jimakuSearchEntries( const response: JimakuApiResponse<JimakuEntry[]> = await window.electronAPI.jimakuSearchEntries(
{ query }, { query, category },
); );
if (generation !== searchGeneration) return;
if (!response.ok) { if (!response.ok) {
const retry = response.error.retryAfter const retry = response.error.retryAfter
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.` ? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
@@ -148,7 +184,11 @@ export function createJimakuModal(
ctx.state.selectedEntryIndex = 0; ctx.state.selectedEntryIndex = 0;
if (ctx.state.jimakuEntries.length === 0) { if (ctx.state.jimakuEntries.length === 0) {
setJimakuStatus('No entries found.'); setJimakuStatus(
category === 'anime'
? 'No anime entries found. Try the Live action tab.'
: 'No live action entries found. Try the Anime tab.',
);
return; return;
} }
@@ -167,12 +207,15 @@ export function createJimakuModal(
ctx.dom.jimakuFilesList.innerHTML = ''; ctx.dom.jimakuFilesList.innerHTML = '';
ctx.dom.jimakuFilesSection.classList.add('hidden'); ctx.dom.jimakuFilesSection.classList.add('hidden');
const generation = searchGeneration;
const response: JimakuApiResponse<JimakuFileEntry[]> = await window.electronAPI.jimakuListFiles( const response: JimakuApiResponse<JimakuFileEntry[]> = await window.electronAPI.jimakuListFiles(
{ {
entryId, entryId,
episode, episode,
}, },
); );
// The user may have picked another entry or reset the modal meanwhile.
if (generation !== searchGeneration || ctx.state.currentEntryId !== entryId) return;
if (!response.ok) { if (!response.ok) {
const retry = response.error.retryAfter const retry = response.error.retryAfter
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.` ? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
@@ -262,10 +305,15 @@ export function createJimakuModal(
setJimakuStatus('Loading media info...'); setJimakuStatus('Loading media info...');
resetJimakuLists(); resetJimakuLists();
renderTabs();
// Media info can resolve after the user already closed the modal or
// started their own search; a stale reply must not touch the inputs.
const generation = searchGeneration;
window.electronAPI window.electronAPI
.getJimakuMediaInfo() .getJimakuMediaInfo()
.then((info: JimakuMediaInfo) => { .then((info: JimakuMediaInfo) => {
if (generation !== searchGeneration) return;
ctx.dom.jimakuTitleInput.value = info.title || ''; ctx.dom.jimakuTitleInput.value = info.title || '';
ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : ''; ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : '';
ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : ''; ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : '';
@@ -280,6 +328,7 @@ export function createJimakuModal(
} }
}) })
.catch(() => { .catch(() => {
if (generation !== searchGeneration) return;
setJimakuStatus('Failed to load media info.', true); setJimakuStatus('Failed to load media info.', true);
}); });
} }
@@ -315,6 +364,18 @@ export function createJimakuModal(
return true; return true;
} }
if (e.key === 'ArrowLeft') {
e.preventDefault();
setActiveTab('anime');
return true;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
setActiveTab('liveAction');
return true;
}
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
if (ctx.state.jimakuFiles.length > 0) { if (ctx.state.jimakuFiles.length > 0) {
@@ -367,6 +428,12 @@ export function createJimakuModal(
ctx.dom.jimakuCloseButton.addEventListener('click', () => { ctx.dom.jimakuCloseButton.addEventListener('click', () => {
closeJimakuModal(); closeJimakuModal();
}); });
ctx.dom.jimakuTabAnimeButton.addEventListener('click', () => {
setActiveTab('anime');
});
ctx.dom.jimakuTabLiveActionButton.addEventListener('click', () => {
setActiveTab('liveAction');
});
ctx.dom.jimakuBroadenButton.addEventListener('click', () => { ctx.dom.jimakuBroadenButton.addEventListener('click', () => {
if (ctx.state.currentEntryId !== null) { if (ctx.state.currentEntryId !== null) {
ctx.dom.jimakuBroadenButton.classList.add('hidden'); ctx.dom.jimakuBroadenButton.classList.add('hidden');
+3
View File
@@ -7,6 +7,7 @@ import type {
TsukihimeEntry, TsukihimeEntry,
TsukihimeSubtitleFile, TsukihimeSubtitleFile,
JimakuEntry, JimakuEntry,
JimakuSearchCategory,
JimakuFileEntry, JimakuFileEntry,
KikuDuplicateCardInfo, KikuDuplicateCardInfo,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
@@ -43,6 +44,7 @@ export type RendererState = {
persistedSubtitlePosition: SubtitlePosition; persistedSubtitlePosition: SubtitlePosition;
jimakuModalOpen: boolean; jimakuModalOpen: boolean;
jimakuActiveTab: JimakuSearchCategory;
jimakuEntries: JimakuEntry[]; jimakuEntries: JimakuEntry[];
jimakuFiles: JimakuFileEntry[]; jimakuFiles: JimakuFileEntry[];
selectedEntryIndex: number; selectedEntryIndex: number;
@@ -176,6 +178,7 @@ export function createRendererState(): RendererState {
persistedSubtitlePosition: { yPercent: 10 }, persistedSubtitlePosition: { yPercent: 10 },
jimakuModalOpen: false, jimakuModalOpen: false,
jimakuActiveTab: 'anime',
jimakuEntries: [], jimakuEntries: [],
jimakuFiles: [], jimakuFiles: [],
selectedEntryIndex: 0, selectedEntryIndex: 0,
+5
View File
@@ -819,12 +819,14 @@ body:focus-visible,
grid-template-columns: 1fr 120px auto; grid-template-columns: 1fr 120px auto;
} }
.jimaku-tabs,
.tsukihime-tabs { .tsukihime-tabs {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px; gap: 6px;
} }
.jimaku-tab,
.tsukihime-tab { .tsukihime-tab {
min-width: 0; min-width: 0;
min-height: 34px; min-height: 34px;
@@ -842,6 +844,8 @@ body:focus-visible,
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.jimaku-tab:hover,
.jimaku-tab:focus-visible,
.tsukihime-tab:hover, .tsukihime-tab:hover,
.tsukihime-tab:focus-visible { .tsukihime-tab:focus-visible {
border-color: rgba(138, 173, 244, 0.48); border-color: rgba(138, 173, 244, 0.48);
@@ -849,6 +853,7 @@ body:focus-visible,
outline: none; outline: none;
} }
.jimaku-tab.active,
.tsukihime-tab.active { .tsukihime-tab.active {
border-color: rgba(238, 212, 159, 0.62); border-color: rgba(238, 212, 159, 0.62);
background: rgba(238, 212, 159, 0.16); background: rgba(238, 212, 159, 0.16);
+4
View File
@@ -21,6 +21,8 @@ export type RendererDom = {
jimakuFilesSection: HTMLDivElement; jimakuFilesSection: HTMLDivElement;
jimakuFilesList: HTMLUListElement; jimakuFilesList: HTMLUListElement;
jimakuBroadenButton: HTMLButtonElement; jimakuBroadenButton: HTMLButtonElement;
jimakuTabAnimeButton: HTMLButtonElement;
jimakuTabLiveActionButton: HTMLButtonElement;
tsukihimeModal: HTMLDivElement; tsukihimeModal: HTMLDivElement;
tsukihimeTitleInput: HTMLInputElement; tsukihimeTitleInput: HTMLInputElement;
@@ -218,6 +220,8 @@ export function resolveRendererDom(): RendererDom {
jimakuFilesSection: getRequiredElement<HTMLDivElement>('jimakuFilesSection'), jimakuFilesSection: getRequiredElement<HTMLDivElement>('jimakuFilesSection'),
jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'), jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'),
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'), jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'),
jimakuTabAnimeButton: getRequiredElement<HTMLButtonElement>('jimakuTabAnime'),
jimakuTabLiveActionButton: getRequiredElement<HTMLButtonElement>('jimakuTabLiveAction'),
tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'), tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'),
tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'), tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'),
+8 -1
View File
@@ -393,7 +393,14 @@ export function parseKikuMergePreviewRequest(value: unknown): KikuMergePreviewRe
export function parseJimakuSearchQuery(value: unknown): JimakuSearchQuery | null { export function parseJimakuSearchQuery(value: unknown): JimakuSearchQuery | null {
if (!isObject(value) || typeof value.query !== 'string') return null; if (!isObject(value) || typeof value.query !== 'string') return null;
return { query: value.query }; if (
value.category !== undefined &&
value.category !== 'anime' &&
value.category !== 'liveAction'
) {
return null;
}
return { query: value.query, category: value.category };
} }
export function parseJimakuFilesQuery(value: unknown): JimakuFilesQuery | null { export function parseJimakuFilesQuery(value: unknown): JimakuFilesQuery | null {
+3
View File
@@ -0,0 +1,3 @@
export const MEDIA_KINDS = ['anime', 'youtube'] as const;
export type MediaKind = (typeof MEDIA_KINDS)[number];
+4
View File
@@ -190,8 +190,12 @@ export interface JimakuMediaInfo {
rawTitle: string; rawTitle: string;
} }
export type JimakuSearchCategory = 'anime' | 'liveAction';
export interface JimakuSearchQuery { export interface JimakuSearchQuery {
query: string; query: string;
// Which Jimaku catalogue to search; defaults to anime when omitted.
category?: JimakuSearchCategory;
} }
export interface JimakuEntryFlags { export interface JimakuEntryFlags {
+3
View File
@@ -1,3 +1,4 @@
import type { MediaKind } from '../shared/media-kind';
export interface SessionSummary { export interface SessionSummary {
sessionId: number; sessionId: number;
canonicalTitle: string | null; canonicalTitle: string | null;
@@ -240,6 +241,7 @@ export const EventType = {
export type EventType = (typeof EventType)[keyof typeof EventType]; export type EventType = (typeof EventType)[keyof typeof EventType];
export interface AnimeLibraryItem { export interface AnimeLibraryItem {
mediaKind: MediaKind;
animeId: number; animeId: number;
canonicalTitle: string; canonicalTitle: string;
anilistId: number | null; anilistId: number | null;
@@ -261,6 +263,7 @@ export interface AnilistEntry {
export interface AnimeDetailData { export interface AnimeDetailData {
detail: { detail: {
mediaKind: MediaKind;
animeId: number; animeId: number;
canonicalTitle: string; canonicalTitle: string;
anilistId: number | null; anilistId: number | null;
@@ -7,6 +7,7 @@ test('AnimeCard includes linked AniList id in cover URLs to avoid stale library
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<AnimeCard <AnimeCard
anime={{ anime={{
mediaKind: 'anime',
animeId: 42, animeId: 42,
canonicalTitle: 'Test Anime', canonicalTitle: 'Test Anime',
anilistId: 21699, anilistId: 21699,
+2 -1
View File
@@ -48,7 +48,8 @@ export function AnimeCard({
<div className="p-3"> <div className="p-3">
<div className="text-sm font-medium text-ctp-text truncate">{anime.canonicalTitle}</div> <div className="text-sm font-medium text-ctp-text truncate">{anime.canonicalTitle}</div>
<div className="text-xs text-ctp-overlay2 mt-1"> <div className="text-xs text-ctp-overlay2 mt-1">
{anime.episodeCount} episode{anime.episodeCount !== 1 ? 's' : ''} {anime.episodeCount} {anime.mediaKind === 'youtube' ? 'video' : 'episode'}
{anime.episodeCount !== 1 ? 's' : ''}
</div> </div>
<div className="text-xs text-ctp-overlay2"> <div className="text-xs text-ctp-overlay2">
{formatDuration(anime.totalActiveMs)} · {formatNumber(anime.totalCards)} cards {formatDuration(anime.totalActiveMs)} · {formatNumber(anime.totalCards)} cards
@@ -16,6 +16,7 @@ test('AnimeHeader uses the linked AniList id to avoid stale cached cover art', (
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<AnimeHeader <AnimeHeader
detail={{ detail={{
mediaKind: 'anime',
animeId: 42, animeId: 42,
canonicalTitle: 'Test Anime', canonicalTitle: 'Test Anime',
anilistId: 21699, anilistId: 21699,
@@ -168,7 +168,7 @@ export function AnimeDetailView({
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>; if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Library entry not found</div>;
const { detail, episodes, anilistEntries } = data; const { detail, episodes, anilistEntries } = data;
@@ -226,6 +226,7 @@ export function AnimeDetailView({
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} /> <AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
<EpisodeList <EpisodeList
episodes={episodes} episodes={episodes}
isYoutube={detail.mediaKind === 'youtube'}
animeId={animeId} animeId={animeId}
onEpisodeMoved={(removedPreviousAnime) => { onEpisodeMoved={(removedPreviousAnime) => {
onEpisodeMoved?.(); onEpisodeMoved?.();
@@ -237,7 +238,7 @@ export function AnimeDetailView({
/> />
<AnimeWatchChart animeId={animeId} /> <AnimeWatchChart animeId={animeId} />
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} /> <AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
{showAnilistSelector && ( {detail.mediaKind === 'anime' && showAnilistSelector && (
<AnilistSelector <AnilistSelector
animeId={animeId} animeId={animeId}
initialQuery={detail.canonicalTitle} initialQuery={detail.canonicalTitle}
@@ -46,6 +46,7 @@ function installDom(): () => void {
function libraryItem(animeId: number, title: string): AnimeLibraryItem { function libraryItem(animeId: number, title: string): AnimeLibraryItem {
return { return {
mediaKind: 'anime',
animeId, animeId,
canonicalTitle: title, canonicalTitle: title,
anilistId: null, anilistId: null,
@@ -6,6 +6,7 @@ import { confirmAnimeDelete, setDeleteConfirmPresenter } from '../../lib/delete-
import type { AnimeDetailData } from '../../types/stats'; import type { AnimeDetailData } from '../../types/stats';
const DETAIL: AnimeDetailData['detail'] = { const DETAIL: AnimeDetailData['detail'] = {
mediaKind: 'anime',
animeId: 3, animeId: 3,
canonicalTitle: 'Project Radio Noise Season 2', canonicalTitle: 'Project Radio Noise Season 2',
anilistId: 20661, anilistId: 20661,
@@ -69,3 +70,16 @@ test('confirmAnimeDelete spells out how much data the entry deletion removes', a
assert.match(seen[1] ?? '', /3 episodes/); assert.match(seen[1] ?? '', /3 episodes/);
assert.match(seen[0] ?? '', /every session and stat/); assert.match(seen[0] ?? '', /every session and stat/);
}); });
test('YouTube channel headers show videos and omit all AniList controls', () => {
const markup = renderToStaticMarkup(
<AnimeHeader
detail={{ ...DETAIL, mediaKind: 'youtube' }}
anilistEntries={[{ anilistId: 20661, titleRomaji: null, titleEnglish: null, season: null }]}
onChangeAnilist={() => {}}
/>,
);
assert.match(markup, /YouTube channel/);
assert.match(markup, /video/);
assert.doesNotMatch(markup, /AniList|anilist\.co|episode/);
});
+22 -18
View File
@@ -37,6 +37,7 @@ export function AnimeHeader({
onDeleteAnime, onDeleteAnime,
isDeletingAnime = false, isDeletingAnime = false,
}: AnimeHeaderProps) { }: AnimeHeaderProps) {
const isYoutube = detail.mediaKind === 'youtube';
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter( const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
(t): t is string => t != null && t !== detail.canonicalTitle, (t): t is string => t != null && t !== detail.canonicalTitle,
); );
@@ -61,33 +62,36 @@ export function AnimeHeader({
</div> </div>
)} )}
<div className="text-sm text-ctp-subtext0 mt-2"> <div className="text-sm text-ctp-subtext0 mt-2">
{detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''} {isYoutube ? 'YouTube channel · ' : ''}
{detail.episodeCount} {isYoutube ? 'video' : 'episode'}
{detail.episodeCount !== 1 ? 's' : ''}
</div> </div>
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{anilistEntries.length > 0 ? ( {!isYoutube &&
hasMultipleEntries ? ( (anilistEntries.length > 0 ? (
anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />) hasMultipleEntries ? (
) : ( anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
) : (
<a
href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
>
View on AniList <span className="text-[10px]">{'\u2197'}</span>
</a>
)
) : detail.anilistId ? (
<a <a
href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`} href={`https://anilist.co/anime/${detail.anilistId}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors" className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
> >
View on AniList <span className="text-[10px]">{'\u2197'}</span> View on AniList <span className="text-[10px]">{'\u2197'}</span>
</a> </a>
) ) : null)}
) : detail.anilistId ? ( {!isYoutube && onChangeAnilist && (
<a
href={`https://anilist.co/anime/${detail.anilistId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
>
View on AniList <span className="text-[10px]">{'\u2197'}</span>
</a>
) : null}
{onChangeAnilist && (
<button <button
type="button" type="button"
onClick={onChangeAnilist} onClick={onChangeAnilist}
@@ -45,6 +45,7 @@ function installDom(): () => void {
function libraryItem(animeId: number, title: string, episodeCount: number): AnimeLibraryItem { function libraryItem(animeId: number, title: string, episodeCount: number): AnimeLibraryItem {
return { return {
mediaKind: 'anime',
animeId, animeId,
canonicalTitle: title, canonicalTitle: title,
anilistId: null, anilistId: null,
@@ -68,7 +69,9 @@ function findButton(container: Element, label: string): HTMLElement {
/** Library cards only expose aria-pressed while selection mode is on. */ /** Library cards only expose aria-pressed while selection mode is on. */
function cardButtons(container: Element): HTMLButtonElement[] { function cardButtons(container: Element): HTMLButtonElement[] {
return [...container.querySelectorAll('button[aria-pressed]')] as unknown as HTMLButtonElement[]; return [
...container.querySelectorAll('.grid button[aria-pressed]'),
] as unknown as HTMLButtonElement[];
} }
function mergeButton(container: Element): HTMLButtonElement { function mergeButton(container: Element): HTMLButtonElement {
@@ -53,19 +53,19 @@ export function AnimeOverviewStats({ detail, knownWordsSummary }: AnimeOverviewS
label="Watch Time" label="Watch Time"
value={formatDuration(detail.totalActiveMs)} value={formatDuration(detail.totalActiveMs)}
color="text-ctp-blue" color="text-ctp-blue"
tooltip="Total active watch time for this anime" tooltip="Total active watch time for this title"
/> />
<Metric <Metric
label="Sessions" label="Sessions"
value={String(detail.totalSessions)} value={String(detail.totalSessions)}
color="text-ctp-peach" color="text-ctp-peach"
tooltip="Number of immersion sessions on this anime" tooltip="Number of immersion sessions on this title"
/> />
<Metric <Metric
label="Episodes" label={detail.mediaKind === 'youtube' ? 'Videos' : 'Episodes'}
value={String(detail.episodeCount)} value={String(detail.episodeCount)}
color="text-ctp-yellow" color="text-ctp-yellow"
tooltip="Number of completed episodes for this anime" tooltip={`Number of tracked ${detail.mediaKind === 'youtube' ? 'videos' : 'episodes'} for this title`}
/> />
<Metric <Metric
label="Words Seen" label="Words Seen"
@@ -81,7 +81,7 @@ export function AnimeOverviewStats({ detail, knownWordsSummary }: AnimeOverviewS
label="Cards Mined" label="Cards Mined"
value={formatNumber(detail.totalCards)} value={formatNumber(detail.totalCards)}
color="text-ctp-cards-mined" color="text-ctp-cards-mined"
tooltip="Anki cards created from subtitle lines in this anime" tooltip="Anki cards created from subtitle lines in this title"
/> />
<Metric <Metric
label="Lookups" label="Lookups"
@@ -109,7 +109,7 @@ export function AnimeOverviewStats({ detail, knownWordsSummary }: AnimeOverviewS
label="Known Words" label="Known Words"
value={`${knownPct}%`} value={`${knownPct}%`}
color="text-ctp-green" color="text-ctp-green"
tooltip={`${formatNumber(knownWordsSummary!.knownWordCount)} known out of ${formatNumber(knownWordsSummary!.totalUniqueWords)} unique words in this anime`} tooltip={`${formatNumber(knownWordsSummary!.knownWordCount)} known out of ${formatNumber(knownWordsSummary!.totalUniqueWords)} unique words in this title`}
/> />
) : ( ) : (
<Metric <Metric
@@ -45,6 +45,7 @@ function installDom(): () => void {
function libraryItem(anilistId: number | null): AnimeLibraryItem { function libraryItem(anilistId: number | null): AnimeLibraryItem {
return { return {
mediaKind: 'anime',
animeId: 7, animeId: 7,
canonicalTitle: 'Test Anime Season 2', canonicalTitle: 'Test Anime Season 2',
anilistId, anilistId,
@@ -61,6 +62,7 @@ function libraryItem(anilistId: number | null): AnimeLibraryItem {
function detailData(anilistId: number | null): AnimeDetailData { function detailData(anilistId: number | null): AnimeDetailData {
return { return {
detail: { detail: {
mediaKind: 'anime',
animeId: 7, animeId: 7,
canonicalTitle: 'Test Anime Season 2', canonicalTitle: 'Test Anime Season 2',
anilistId, anilistId,
@@ -177,3 +179,55 @@ test('AnimeTab refetches the library after the AniList entry is relinked', async
uninstallDom(); uninstallDom();
} }
}); });
test('Library kind filter separates YouTube channels from anime and updates totals', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
};
apiClient.getAnimeLibrary = async () => [
{ ...libraryItem(null), totalActiveMs: 60_000 },
{
...libraryItem(null),
animeId: 8,
mediaKind: 'youtube',
canonicalTitle: 'Language Channel',
totalActiveMs: 120_000,
},
];
apiClient.getAnimeMergeRecommendations = async () => ({ recommendations: [] });
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => {
root.render(<AnimeTab />);
});
assert.match(container.textContent ?? '', /Test Anime Season 2/);
assert.match(container.textContent ?? '', /Language Channel/);
assert.match(container.textContent ?? '', /2 titles · 3m/);
await act(async () => {
findButton(container, 'YouTube').click();
});
assert.doesNotMatch(container.textContent ?? '', /Test Anime Season 2/);
assert.match(container.textContent ?? '', /Language Channel/);
assert.match(container.textContent ?? '', /1 channel · 2m/);
assert.equal(findButton(container, 'YouTube').getAttribute('aria-pressed'), 'true');
await act(async () => {
findButton(container, 'Anime').click();
});
assert.match(container.textContent ?? '', /Test Anime Season 2/);
assert.doesNotMatch(container.textContent ?? '', /Language Channel/);
await act(async () => {
findButton(container, 'All Titles').click();
});
assert.match(container.textContent ?? '', /Language Channel/);
} finally {
await act(async () => {
root.unmount();
});
Object.assign(apiClient, original);
uninstallDom();
}
});
+48 -8
View File
@@ -1,3 +1,4 @@
import { MEDIA_KINDS, type MediaKind } from '../../../../src/shared/media-kind';
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary'; import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
import { formatDuration } from '../../lib/formatters'; import { formatDuration } from '../../lib/formatters';
@@ -67,6 +68,7 @@ export function AnimeTab({
clearRecommendation, clearRecommendation,
} = useAnimeLibrary(); } = useAnimeLibrary();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [mediaKind, setMediaKind] = useState<MediaKind | 'all'>('all');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched'); const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [cardSize, setCardSize] = useState<LibraryCardSize>(() => const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
readLibraryCardSizePreference( readLibraryCardSizePreference(
@@ -108,13 +110,14 @@ export function AnimeTab({
}, [initialAnimeId, onClearInitialAnime]); }, [initialAnimeId, onClearInitialAnime]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const entries = anime.filter((entry) => mediaKind === 'all' || entry.mediaKind === mediaKind);
const base = search.trim() const base = search.trim()
? anime.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase())) ? entries.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase()))
: anime; : entries;
return sortAnime(base, sortKey); return sortAnime(base, sortKey);
}, [anime, search, sortKey]); }, [anime, search, sortKey, mediaKind]);
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0); const totalMs = filtered.reduce((sum, a) => sum + a.totalActiveMs, 0);
const checkedEntries = checkedAnimeIds const checkedEntries = checkedAnimeIds
.map((animeId) => anime.find((entry) => entry.animeId === animeId)) .map((animeId) => anime.find((entry) => entry.animeId === animeId))
.filter((entry): entry is (typeof anime)[number] => entry !== undefined); .filter((entry): entry is (typeof anime)[number] => entry !== undefined);
@@ -125,7 +128,12 @@ export function AnimeTab({
.map((animeId) => anime.find((entry) => entry.animeId === animeId)) .map((animeId) => anime.find((entry) => entry.animeId === animeId))
.filter((entry): entry is (typeof anime)[number] => entry !== undefined), .filter((entry): entry is (typeof anime)[number] => entry !== undefined),
})) }))
.filter((recommendation) => recommendation.entries.length >= 2); .filter(
(recommendation) =>
recommendation.entries.length >= 2 &&
(mediaKind === 'all' ||
recommendation.entries.every((entry) => entry.mediaKind === mediaKind)),
);
const activeRecommendation = hydratedRecommendations[0] ?? null; const activeRecommendation = hydratedRecommendations[0] ?? null;
const reviewEntries = (reviewAnimeIds ?? []) const reviewEntries = (reviewAnimeIds ?? [])
.map((animeId) => anime.find((entry) => entry.animeId === animeId)) .map((animeId) => anime.find((entry) => entry.animeId === animeId))
@@ -155,7 +163,31 @@ export function AnimeTab({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div
className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1"
aria-label="Media kind"
role="group"
>
{(['all', ...MEDIA_KINDS] as const).map((kind) => (
<button
key={kind}
type="button"
aria-pressed={mediaKind === kind}
onClick={() => {
setMediaKind(kind);
exitSelectionMode();
}}
className={`px-3 py-1.5 rounded-md text-xs transition-colors ${
mediaKind === kind
? 'bg-ctp-surface2 text-ctp-text'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{kind === 'all' ? 'All Titles' : kind === 'youtube' ? 'YouTube' : 'Anime'}
</button>
))}
</div>
<input <input
type="text" type="text"
placeholder="Search library..." placeholder="Search library..."
@@ -170,7 +202,7 @@ export function AnimeTab({
> >
{SORT_OPTIONS.map((opt) => ( {SORT_OPTIONS.map((opt) => (
<option key={opt.key} value={opt.key}> <option key={opt.key} value={opt.key}>
{opt.label} {opt.key === 'episodes' && mediaKind === 'youtube' ? 'Videos' : opt.label}
</option> </option>
))} ))}
</select> </select>
@@ -202,7 +234,15 @@ export function AnimeTab({
{selectionMode ? 'Cancel' : 'Select'} {selectionMode ? 'Cancel' : 'Select'}
</button> </button>
<div className="text-xs text-ctp-overlay2 shrink-0"> <div className="text-xs text-ctp-overlay2 shrink-0">
{filtered.length} titles · {formatDuration(totalMs)} {filtered.length}{' '}
{mediaKind === 'youtube'
? filtered.length === 1
? 'channel'
: 'channels'
: filtered.length === 1
? 'title'
: 'titles'}{' '}
· {formatDuration(totalMs)}
</div> </div>
</div> </div>
+6 -4
View File
@@ -17,6 +17,7 @@ const HOVER_REVEALED =
interface EpisodeListProps { interface EpisodeListProps {
episodes: AnimeEpisode[]; episodes: AnimeEpisode[];
isYoutube?: boolean;
/** Entry these episodes currently belong to; excluded from the move picker. */ /** Entry these episodes currently belong to; excluded from the move picker. */
animeId?: number; animeId?: number;
onEpisodeDeleted?: () => void; onEpisodeDeleted?: () => void;
@@ -27,6 +28,7 @@ interface EpisodeListProps {
export function EpisodeList({ export function EpisodeList({
episodes: initialEpisodes, episodes: initialEpisodes,
isYoutube = false,
animeId, animeId,
onEpisodeDeleted, onEpisodeDeleted,
onEpisodeMoved, onEpisodeMoved,
@@ -90,7 +92,7 @@ export function EpisodeList({
return ( return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">Episodes</h3> <h3 className="text-sm font-semibold text-ctp-text">{isYoutube ? 'Videos' : 'Episodes'}</h3>
<span className="text-xs text-ctp-overlay2"> <span className="text-xs text-ctp-overlay2">
{watchedCount}/{episodes.length} watched {watchedCount}/{episodes.length} watched
</span> </span>
@@ -178,7 +180,7 @@ export function EpisodeList({
onOpenDetail(ep.videoId); onOpenDetail(ep.videoId);
}} }}
className="px-2 py-1 rounded border border-ctp-surface2 text-[11px] text-ctp-blue hover:border-ctp-blue/50 hover:bg-ctp-blue/10 transition-colors" className="px-2 py-1 rounded border border-ctp-surface2 text-[11px] text-ctp-blue hover:border-ctp-blue/50 hover:bg-ctp-blue/10 transition-colors"
title="Open episode details" title={isYoutube ? 'Open video details' : 'Open episode details'}
> >
Details Details
</button> </button>
@@ -218,8 +220,8 @@ export function EpisodeList({
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle); 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 focus-visible:text-ctp-red hover:bg-ctp-red/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`}
title="Delete episode" title={isYoutube ? 'Delete video' : 'Delete episode'}
aria-label="Delete episode" aria-label={isYoutube ? 'Delete video' : 'Delete episode'}
> >
{'\u2715'} {'\u2715'}
</button> </button>
@@ -63,6 +63,7 @@ function episode(videoId: number, title: string): AnimeEpisode {
function libraryItem(animeId: number, title: string): AnimeLibraryItem { function libraryItem(animeId: number, title: string): AnimeLibraryItem {
return { return {
mediaKind: 'anime',
animeId, animeId,
canonicalTitle: title, canonicalTitle: title,
anilistId: null, anilistId: null,
+1
View File
@@ -116,6 +116,7 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
detail={{ detail={{
animeId: 1, animeId: 1,
canonicalTitle: 'Anime', canonicalTitle: 'Anime',
mediaKind: 'anime',
anilistId: null, anilistId: null,
titleRomaji: null, titleRomaji: null,
titleEnglish: null, titleEnglish: null,