mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
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
This commit is contained in:
@@ -3,3 +3,4 @@ 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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. 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.
|
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:
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -25,6 +25,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
|||||||
LAST_UPDATE_DATE TEXT,
|
LAST_UPDATE_DATE TEXT,
|
||||||
media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))
|
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,
|
||||||
|
|||||||
@@ -571,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 =
|
||||||
@@ -582,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
|
||||||
@@ -604,7 +612,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
|
|||||||
SET
|
SET
|
||||||
media_kind = COALESCE(?, media_kind),
|
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),
|
||||||
@@ -615,7 +623,8 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
|
|||||||
).run(
|
).run(
|
||||||
byAnilistId || byNormalizedTitle ? (input.mediaKind ?? null) : null,
|
byAnilistId || byNormalizedTitle ? (input.mediaKind ?? null) : null,
|
||||||
canonicalTitleUpdate,
|
canonicalTitleUpdate,
|
||||||
input.anilistId,
|
mediaKind,
|
||||||
|
anilistId,
|
||||||
input.titleRomaji,
|
input.titleRomaji,
|
||||||
input.titleEnglish,
|
input.titleEnglish,
|
||||||
input.titleNative,
|
input.titleNative,
|
||||||
@@ -648,7 +657,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
|
|||||||
input.mediaKind ?? 'anime',
|
input.mediaKind ?? 'anime',
|
||||||
normalizedTitleKey,
|
normalizedTitleKey,
|
||||||
canonicalTitle,
|
canonicalTitle,
|
||||||
input.anilistId,
|
anilistId,
|
||||||
input.titleRomaji,
|
input.titleRomaji,
|
||||||
input.titleEnglish,
|
input.titleEnglish,
|
||||||
input.titleNative,
|
input.titleNative,
|
||||||
@@ -882,13 +891,61 @@ 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
|
// Older builds can create channel rows with the default anime kind even after
|
||||||
// the schema upgrade. Repair classification on every startup without moving videos.
|
// the schema upgrade. Repair classification on every startup without moving videos.
|
||||||
function classifyYoutubeChannels(db: DatabaseSync): void {
|
function classifyYoutubeChannels(db: DatabaseSync): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
UPDATE imm_anime
|
UPDATE imm_anime
|
||||||
SET media_kind = 'youtube'
|
SET media_kind = 'youtube', anilist_id = NULL
|
||||||
WHERE media_kind = 'anime'
|
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 (
|
AND (
|
||||||
normalized_title_key LIKE 'youtube channel %'
|
normalized_title_key LIKE 'youtube channel %'
|
||||||
OR CASE WHEN json_valid(metadata_json)
|
OR CASE WHEN json_valid(metadata_json)
|
||||||
@@ -932,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,
|
||||||
@@ -951,7 +1008,6 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
'media_kind',
|
'media_kind',
|
||||||
"TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))",
|
"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,
|
||||||
@@ -1495,6 +1551,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrateAnimeTitleUniqueness(db);
|
||||||
|
classifyYoutubeChannels(db);
|
||||||
migrateSessionEventTimestampsToText(db);
|
migrateSessionEventTimestampsToText(db);
|
||||||
|
|
||||||
ensureLexicalDailyRollupTables(db);
|
ensureLexicalDailyRollupTables(db);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { MediaKind } from '../../../shared/media-kind';
|
import type { MediaKind } from '../../../shared/media-kind';
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 24;
|
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;
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ test('schema 23 channel migration preserves history and manual assignments and i
|
|||||||
const history = getAnimeLibrary(db);
|
const history = getAnimeLibrary(db);
|
||||||
// Reproduce the previous schema, including its lack of a media kind column.
|
// Reproduce the previous schema, including its lack of a media kind column.
|
||||||
db.exec(
|
db.exec(
|
||||||
'ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)',
|
'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);
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -163,6 +163,7 @@ test('startup reclassifies channels created by an older build after the schema u
|
|||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
// An old build omits media_kind when creating a channel in the upgraded DB.
|
// An old build omits media_kind when creating a channel in the upgraded DB.
|
||||||
const channelId = createAnime(db, 'youtube-channel:UCnew');
|
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 animeId = createAnime(db, 'Regular anime');
|
||||||
const videoId = createVideo(db, 'older-build');
|
const videoId = createVideo(db, 'older-build');
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -176,6 +177,14 @@ test('startup reclassifies channels created by an older build after the schema u
|
|||||||
const channel = getAnimeLibrary(db)[0];
|
const channel = getAnimeLibrary(db)[0];
|
||||||
assert.equal(channel?.animeId, channelId);
|
assert.equal(channel?.animeId, channelId);
|
||||||
assert.equal(channel?.mediaKind, 'youtube');
|
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?.totalActiveMs, 120000);
|
||||||
assert.equal(channel?.totalCards, 5);
|
assert.equal(channel?.totalCards, 5);
|
||||||
assert.equal(linkYoutubeVideoToAnimeRecord(db, videoId, metadata), channelId);
|
assert.equal(linkYoutubeVideoToAnimeRecord(db, videoId, metadata), channelId);
|
||||||
@@ -186,3 +195,116 @@ test('startup reclassifies channels created by an older build after the schema u
|
|||||||
db.close();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -99,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, ?),
|
||||||
@@ -117,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,
|
||||||
@@ -134,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,3 +132,88 @@ test('sync preserves the YouTube media kind when adding a channel', () => {
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -69,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('.grid 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user