mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
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
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Document YouTube channel filtering and video statistics in the Library.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
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.
|
||||||
@@ -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. 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:
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ 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 TABLE imm_videos(
|
CREATE TABLE imm_videos(
|
||||||
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -600,6 +602,7 @@ 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 = COALESCE(?, anilist_id),
|
||||||
title_romaji = COALESCE(?, title_romaji),
|
title_romaji = COALESCE(?, title_romaji),
|
||||||
@@ -610,6 +613,7 @@ 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,
|
input.anilistId,
|
||||||
input.titleRomaji,
|
input.titleRomaji,
|
||||||
@@ -627,6 +631,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,10 +641,11 @@ 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,
|
input.anilistId,
|
||||||
@@ -803,6 +809,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 +882,22 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
WHERE 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
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
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 +920,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);
|
||||||
@@ -921,6 +945,13 @@ 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'))",
|
||||||
|
);
|
||||||
|
classifyYoutubeChannels(db);
|
||||||
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,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export const SCHEMA_VERSION = 23;
|
import type { MediaKind } from '../../../shared/media-kind';
|
||||||
|
|
||||||
|
export const SCHEMA_VERSION = 24;
|
||||||
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,188 @@
|
|||||||
|
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(
|
||||||
|
'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');
|
||||||
|
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(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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,3 +98,37 @@ 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const MEDIA_KINDS = ['anime', 'youtube'] as const;
|
||||||
|
|
||||||
|
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,10 +62,13 @@ 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 &&
|
||||||
|
(anilistEntries.length > 0 ? (
|
||||||
hasMultipleEntries ? (
|
hasMultipleEntries ? (
|
||||||
anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
|
anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
|
||||||
) : (
|
) : (
|
||||||
@@ -86,8 +90,8 @@ export function AnimeHeader({
|
|||||||
>
|
>
|
||||||
View on AniList <span className="text-[10px]">{'\u2197'}</span>
|
View on AniList <span className="text-[10px]">{'\u2197'}</span>
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null)}
|
||||||
{onChangeAnilist && (
|
{!isYoutube && 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,7 @@ 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user