mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-25 00:11:26 -07:00
fix(immersion): special-case youtube media paths in runtime and tracking
This commit is contained in:
@@ -31,11 +31,12 @@ function checkDependencies(args: Args): void {
|
||||
|
||||
if (!commandExists('mpv')) missing.push('mpv');
|
||||
|
||||
if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('yt-dlp')) {
|
||||
const isYoutubeUrl = args.targetKind === 'url' && isYoutubeTarget(args.target);
|
||||
if (args.targetKind === 'url' && !isYoutubeUrl && !commandExists('yt-dlp')) {
|
||||
missing.push('yt-dlp');
|
||||
}
|
||||
|
||||
if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('ffmpeg')) {
|
||||
if (args.targetKind === 'url' && !isYoutubeUrl && !commandExists('ffmpeg')) {
|
||||
missing.push('ffmpeg');
|
||||
}
|
||||
|
||||
|
||||
@@ -1284,6 +1284,40 @@ test('flushTelemetry checkpoints latest playback position on the active session
|
||||
}
|
||||
});
|
||||
|
||||
test('recordSubtitleLine advances session checkpoint progress when playback position is unavailable', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
|
||||
tracker.handleMediaChange('https://stream.example.com/subtitle-progress.m3u8', 'Subtitle Progress');
|
||||
tracker.recordSubtitleLine('line one', 170, 185, [], null);
|
||||
|
||||
const privateApi = tracker as unknown as {
|
||||
db: DatabaseSync;
|
||||
sessionState: { sessionId: number } | null;
|
||||
flushTelemetry: (force?: boolean) => void;
|
||||
flushNow: () => void;
|
||||
};
|
||||
const sessionId = privateApi.sessionState?.sessionId;
|
||||
assert.ok(sessionId);
|
||||
|
||||
privateApi.flushTelemetry(true);
|
||||
privateApi.flushNow();
|
||||
|
||||
const row = privateApi.db
|
||||
.prepare('SELECT ended_media_ms FROM imm_sessions WHERE session_id = ?')
|
||||
.get(sessionId) as { ended_media_ms: number | null } | null;
|
||||
|
||||
assert.equal(row?.ended_media_ms, 185_000);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteSession ignores the currently active session and keeps new writes flushable', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
@@ -2412,6 +2446,23 @@ printf '%s\n' '${ytDlpOutput}'
|
||||
`,
|
||||
)
|
||||
.get() as { canonicalTitle: string } | null;
|
||||
const animeRow = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
a.canonical_title AS canonicalTitle,
|
||||
v.parsed_title AS parsedTitle,
|
||||
v.parser_source AS parserSource
|
||||
FROM imm_videos v
|
||||
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
WHERE v.video_id = 1
|
||||
`,
|
||||
)
|
||||
.get() as {
|
||||
canonicalTitle: string;
|
||||
parsedTitle: string | null;
|
||||
parserSource: string | null;
|
||||
} | null;
|
||||
|
||||
assert.ok(row);
|
||||
assert.ok(videoRow);
|
||||
@@ -2427,6 +2478,9 @@ printf '%s\n' '${ytDlpOutput}'
|
||||
assert.equal(row.uploaderUrl, 'https://www.youtube.com/@creator');
|
||||
assert.equal(row.description, 'Video description');
|
||||
assert.equal(videoRow.canonicalTitle, 'Video Name');
|
||||
assert.equal(animeRow?.canonicalTitle, 'Creator Name');
|
||||
assert.equal(animeRow?.parsedTitle, 'Creator Name');
|
||||
assert.equal(animeRow?.parserSource, 'youtube');
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
globalThis.fetch = originalFetch;
|
||||
@@ -2438,6 +2492,419 @@ printf '%s\n' '${ytDlpOutput}'
|
||||
}
|
||||
});
|
||||
|
||||
test('getMediaLibrary lazily backfills missing youtube metadata for existing rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
const originalPath = process.env.PATH;
|
||||
let fakeBinDir: string | null = null;
|
||||
|
||||
try {
|
||||
fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yt-dlp-bin-'));
|
||||
const ytDlpOutput =
|
||||
'{"id":"backfill123","title":"Backfilled Video Title","webpage_url":"https://www.youtube.com/watch?v=backfill123","thumbnail":"https://i.ytimg.com/vi/backfill123/hqdefault.jpg","channel_id":"UCbackfill123","channel":"Backfill Creator","channel_url":"https://www.youtube.com/channel/UCbackfill123","uploader_id":"@backfill","uploader_url":"https://www.youtube.com/@backfill","description":"Backfilled description","thumbnails":[{"url":"https://i.ytimg.com/vi/backfill123/hqdefault.jpg"},{"url":"https://yt3.googleusercontent.com/backfill-avatar=s88"}]}';
|
||||
if (process.platform === 'win32') {
|
||||
const outputPath = path.join(fakeBinDir, 'output.json');
|
||||
fs.writeFileSync(outputPath, ytDlpOutput, 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(fakeBinDir, 'yt-dlp.cmd'),
|
||||
'@echo off\r\ntype "%~dp0output.json"\r\n',
|
||||
'utf8',
|
||||
);
|
||||
} else {
|
||||
const scriptPath = path.join(fakeBinDir, 'yt-dlp');
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
`#!/bin/sh
|
||||
printf '%s\n' '${ytDlpOutput}'
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const nowMs = Date.now();
|
||||
|
||||
privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO imm_videos (
|
||||
video_key,
|
||||
canonical_title,
|
||||
source_type,
|
||||
source_path,
|
||||
source_url,
|
||||
duration_ms,
|
||||
file_size_bytes,
|
||||
codec_id,
|
||||
container_id,
|
||||
width_px,
|
||||
height_px,
|
||||
fps_x100,
|
||||
bitrate_kbps,
|
||||
audio_codec_id,
|
||||
hash_sha256,
|
||||
screenshot_path,
|
||||
metadata_json,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'remote:https://www.youtube.com/watch?v=backfill123',
|
||||
'watch?v=backfill123',
|
||||
2,
|
||||
null,
|
||||
'https://www.youtube.com/watch?v=backfill123',
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
nowMs,
|
||||
nowMs,
|
||||
);
|
||||
privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_media (
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(1, 1, 5_000, 0, 0, 50, 0, nowMs, nowMs, nowMs, nowMs);
|
||||
|
||||
const before = await tracker.getMediaLibrary();
|
||||
assert.equal(before[0]?.channelName ?? null, null);
|
||||
|
||||
await waitForCondition(() => {
|
||||
const row = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
video_title AS videoTitle,
|
||||
channel_name AS channelName,
|
||||
channel_thumbnail_url AS channelThumbnailUrl
|
||||
FROM imm_youtube_videos
|
||||
WHERE video_id = 1
|
||||
`,
|
||||
)
|
||||
.get() as {
|
||||
videoTitle: string | null;
|
||||
channelName: string | null;
|
||||
channelThumbnailUrl: string | null;
|
||||
} | null;
|
||||
return (
|
||||
row?.videoTitle === 'Backfilled Video Title' &&
|
||||
row.channelName === 'Backfill Creator' &&
|
||||
row.channelThumbnailUrl === 'https://yt3.googleusercontent.com/backfill-avatar=s88'
|
||||
);
|
||||
}, 5_000);
|
||||
|
||||
const after = await tracker.getMediaLibrary();
|
||||
assert.equal(after[0]?.videoTitle, 'Backfilled Video Title');
|
||||
assert.equal(after[0]?.channelName, 'Backfill Creator');
|
||||
assert.equal(
|
||||
after[0]?.channelThumbnailUrl,
|
||||
'https://yt3.googleusercontent.com/backfill-avatar=s88',
|
||||
);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
if (fakeBinDir) {
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const nowMs = Date.now();
|
||||
|
||||
privateApi.db.exec(`
|
||||
INSERT INTO imm_anime (
|
||||
anime_id,
|
||||
normalized_title_key,
|
||||
canonical_title,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, 'watch v first', 'watch?v first', ${nowMs}, ${nowMs}),
|
||||
(2, 'watch v second', 'watch?v second', ${nowMs}, ${nowMs});
|
||||
|
||||
INSERT INTO imm_videos (
|
||||
video_id,
|
||||
anime_id,
|
||||
video_key,
|
||||
canonical_title,
|
||||
parsed_title,
|
||||
parser_source,
|
||||
source_type,
|
||||
source_path,
|
||||
source_url,
|
||||
duration_ms,
|
||||
file_size_bytes,
|
||||
codec_id,
|
||||
container_id,
|
||||
width_px,
|
||||
height_px,
|
||||
fps_x100,
|
||||
bitrate_kbps,
|
||||
audio_codec_id,
|
||||
hash_sha256,
|
||||
screenshot_path,
|
||||
metadata_json,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(
|
||||
1,
|
||||
1,
|
||||
'remote:https://www.youtube.com/watch?v=first',
|
||||
'watch?v first',
|
||||
'watch?v first',
|
||||
'fallback',
|
||||
2,
|
||||
NULL,
|
||||
'https://www.youtube.com/watch?v=first',
|
||||
0,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
),
|
||||
(
|
||||
2,
|
||||
2,
|
||||
'remote:https://www.youtube.com/watch?v=second',
|
||||
'watch?v second',
|
||||
'watch?v second',
|
||||
'fallback',
|
||||
2,
|
||||
NULL,
|
||||
'https://www.youtube.com/watch?v=second',
|
||||
0,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
);
|
||||
|
||||
INSERT INTO imm_youtube_videos (
|
||||
video_id,
|
||||
youtube_video_id,
|
||||
video_url,
|
||||
video_title,
|
||||
video_thumbnail_url,
|
||||
channel_id,
|
||||
channel_name,
|
||||
channel_url,
|
||||
channel_thumbnail_url,
|
||||
uploader_id,
|
||||
uploader_url,
|
||||
description,
|
||||
metadata_json,
|
||||
fetched_at_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(
|
||||
1,
|
||||
'first',
|
||||
'https://www.youtube.com/watch?v=first',
|
||||
'First Video',
|
||||
'https://i.ytimg.com/vi/first/hqdefault.jpg',
|
||||
'UCchannel1',
|
||||
'Shared Channel',
|
||||
'https://www.youtube.com/channel/UCchannel1',
|
||||
'https://yt3.googleusercontent.com/shared=s88',
|
||||
'@shared',
|
||||
'https://www.youtube.com/@shared',
|
||||
NULL,
|
||||
'{}',
|
||||
${nowMs},
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
),
|
||||
(
|
||||
2,
|
||||
'second',
|
||||
'https://www.youtube.com/watch?v=second',
|
||||
'Second Video',
|
||||
'https://i.ytimg.com/vi/second/hqdefault.jpg',
|
||||
'UCchannel1',
|
||||
'Shared Channel',
|
||||
'https://www.youtube.com/channel/UCchannel1',
|
||||
'https://yt3.googleusercontent.com/shared=s88',
|
||||
'@shared',
|
||||
'https://www.youtube.com/@shared',
|
||||
NULL,
|
||||
'{}',
|
||||
${nowMs},
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
);
|
||||
|
||||
INSERT INTO imm_sessions (
|
||||
session_id,
|
||||
session_uuid,
|
||||
video_id,
|
||||
started_at_ms,
|
||||
ended_at_ms,
|
||||
status,
|
||||
total_watched_ms,
|
||||
active_watched_ms,
|
||||
lines_seen,
|
||||
tokens_seen,
|
||||
cards_mined,
|
||||
lookup_count,
|
||||
lookup_hits,
|
||||
yomitan_lookup_count,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(
|
||||
1,
|
||||
'session-youtube-1',
|
||||
1,
|
||||
${nowMs - 70000},
|
||||
${nowMs - 10000},
|
||||
2,
|
||||
65000,
|
||||
60000,
|
||||
0,
|
||||
100,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
),
|
||||
(
|
||||
2,
|
||||
'session-youtube-2',
|
||||
2,
|
||||
${nowMs - 50000},
|
||||
${nowMs - 5000},
|
||||
2,
|
||||
35000,
|
||||
30000,
|
||||
0,
|
||||
50,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
);
|
||||
|
||||
INSERT INTO imm_lifetime_anime (
|
||||
anime_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
episodes_started,
|
||||
episodes_completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, 1, 60000, 0, 0, 100, 1, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}),
|
||||
(2, 1, 30000, 0, 0, 50, 1, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs});
|
||||
|
||||
INSERT INTO imm_lifetime_media (
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, 1, 60000, 0, 0, 100, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}),
|
||||
(2, 1, 30000, 0, 0, 50, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs});
|
||||
`);
|
||||
|
||||
const rows = await tracker.getAnimeLibrary();
|
||||
const sharedRows = rows.filter((row) => row.canonicalTitle === 'Shared Channel');
|
||||
|
||||
assert.equal(sharedRows.length, 1);
|
||||
assert.equal(sharedRows[0]?.episodeCount, 2);
|
||||
|
||||
const relinked = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT a.canonical_title AS canonicalTitle, COUNT(*) AS total
|
||||
FROM imm_videos v
|
||||
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
GROUP BY a.anime_id, a.canonical_title
|
||||
ORDER BY total DESC, a.anime_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as Array<{ canonicalTitle: string; total: number }>;
|
||||
|
||||
assert.equal(relinked[0]?.canonicalTitle, 'Shared Channel');
|
||||
assert.equal(relinked[0]?.total, 2);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('reassignAnimeAnilist clears description when description is explicitly null', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getOrCreateAnimeRecord,
|
||||
getOrCreateVideoRecord,
|
||||
linkVideoToAnimeRecord,
|
||||
linkYoutubeVideoToAnimeRecord,
|
||||
type TrackerPreparedStatements,
|
||||
updateVideoMetadataRecord,
|
||||
updateVideoTitleRecord,
|
||||
@@ -161,6 +162,7 @@ const YOUTUBE_COVER_RETRY_MS = 5 * 60 * 1000;
|
||||
const YOUTUBE_SCREENSHOT_MAX_SECONDS = 120;
|
||||
const YOUTUBE_OEMBED_ENDPOINT = 'https://www.youtube.com/oembed';
|
||||
const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{6,}$/;
|
||||
const YOUTUBE_METADATA_REFRESH_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function isValidYouTubeVideoId(value: string | null): boolean {
|
||||
return Boolean(value && YOUTUBE_ID_PATTERN.test(value));
|
||||
@@ -535,11 +537,15 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
|
||||
async getMediaLibrary(): Promise<MediaLibraryRow[]> {
|
||||
return getMediaLibrary(this.db);
|
||||
const rows = getMediaLibrary(this.db);
|
||||
this.backfillYoutubeMetadataForLibrary();
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getMediaDetail(videoId: number): Promise<MediaDetailRow | null> {
|
||||
return getMediaDetail(this.db, videoId);
|
||||
const detail = getMediaDetail(this.db, videoId);
|
||||
this.backfillYoutubeMetadataForVideo(videoId);
|
||||
return detail;
|
||||
}
|
||||
|
||||
async getMediaSessions(videoId: number, limit = 100): Promise<SessionSummaryQueryRow[]> {
|
||||
@@ -555,10 +561,12 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
|
||||
async getAnimeLibrary(): Promise<AnimeLibraryRow[]> {
|
||||
this.relinkYoutubeAnimeLibrary();
|
||||
return getAnimeLibrary(this.db);
|
||||
}
|
||||
|
||||
async getAnimeDetail(animeId: number): Promise<AnimeDetailRow | null> {
|
||||
this.relinkYoutubeAnimeLibrary();
|
||||
return getAnimeDetail(this.db, animeId);
|
||||
}
|
||||
|
||||
@@ -909,6 +917,7 @@ export class ImmersionTrackerService {
|
||||
return;
|
||||
}
|
||||
upsertYoutubeVideoMetadata(this.db, videoId, metadata);
|
||||
linkYoutubeVideoToAnimeRecord(this.db, videoId, metadata);
|
||||
if (metadata.videoTitle?.trim()) {
|
||||
updateVideoTitleRecord(this.db, videoId, metadata.videoTitle.trim());
|
||||
}
|
||||
@@ -927,6 +936,174 @@ export class ImmersionTrackerService {
|
||||
});
|
||||
}
|
||||
|
||||
private backfillYoutubeMetadataForLibrary(): void {
|
||||
const candidate = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.video_id AS videoId,
|
||||
v.source_url AS sourceUrl
|
||||
FROM imm_videos v
|
||||
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||
WHERE
|
||||
v.source_type = ?
|
||||
AND v.source_url IS NOT NULL
|
||||
AND (
|
||||
LOWER(v.source_url) LIKE 'https://www.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtu.be/%'
|
||||
)
|
||||
AND (
|
||||
yv.video_id IS NULL
|
||||
OR yv.video_title IS NULL
|
||||
OR yv.channel_name IS NULL
|
||||
OR yv.channel_thumbnail_url IS NULL
|
||||
)
|
||||
AND (
|
||||
yv.fetched_at_ms IS NULL
|
||||
OR yv.fetched_at_ms <= ?
|
||||
)
|
||||
ORDER BY lm.last_watched_ms DESC, v.video_id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(
|
||||
SOURCE_TYPE_REMOTE,
|
||||
Date.now() - YOUTUBE_METADATA_REFRESH_MS,
|
||||
) as { videoId: number; sourceUrl: string | null } | null;
|
||||
if (!candidate?.sourceUrl) {
|
||||
return;
|
||||
}
|
||||
this.captureYoutubeMetadataAsync(candidate.videoId, candidate.sourceUrl);
|
||||
}
|
||||
|
||||
private backfillYoutubeMetadataForVideo(videoId: number): void {
|
||||
const candidate = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.source_url AS sourceUrl
|
||||
FROM imm_videos v
|
||||
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||
WHERE
|
||||
v.video_id = ?
|
||||
AND v.source_type = ?
|
||||
AND v.source_url IS NOT NULL
|
||||
AND (
|
||||
LOWER(v.source_url) LIKE 'https://www.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtu.be/%'
|
||||
)
|
||||
AND (
|
||||
yv.video_id IS NULL
|
||||
OR yv.video_title IS NULL
|
||||
OR yv.channel_name IS NULL
|
||||
OR yv.channel_thumbnail_url IS NULL
|
||||
)
|
||||
AND (
|
||||
yv.fetched_at_ms IS NULL
|
||||
OR yv.fetched_at_ms <= ?
|
||||
)
|
||||
`,
|
||||
)
|
||||
.get(
|
||||
videoId,
|
||||
SOURCE_TYPE_REMOTE,
|
||||
Date.now() - YOUTUBE_METADATA_REFRESH_MS,
|
||||
) as { sourceUrl: string | null } | null;
|
||||
if (!candidate?.sourceUrl) {
|
||||
return;
|
||||
}
|
||||
this.captureYoutubeMetadataAsync(videoId, candidate.sourceUrl);
|
||||
}
|
||||
|
||||
private relinkYoutubeAnimeLibrary(): void {
|
||||
const candidates = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.video_id AS videoId,
|
||||
yv.youtube_video_id AS youtubeVideoId,
|
||||
yv.video_url AS videoUrl,
|
||||
yv.video_title AS videoTitle,
|
||||
yv.video_thumbnail_url AS videoThumbnailUrl,
|
||||
yv.channel_id AS channelId,
|
||||
yv.channel_name AS channelName,
|
||||
yv.channel_url AS channelUrl,
|
||||
yv.channel_thumbnail_url AS channelThumbnailUrl,
|
||||
yv.uploader_id AS uploaderId,
|
||||
yv.uploader_url AS uploaderUrl,
|
||||
yv.description AS description,
|
||||
yv.metadata_json AS metadataJson
|
||||
FROM imm_videos v
|
||||
JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
LEFT JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||
WHERE
|
||||
v.source_type = ?
|
||||
AND v.source_url IS NOT NULL
|
||||
AND (
|
||||
LOWER(v.source_url) LIKE 'https://www.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtu.be/%'
|
||||
)
|
||||
AND yv.channel_name IS NOT NULL
|
||||
AND (
|
||||
v.anime_id IS NULL
|
||||
OR a.metadata_json IS NULL
|
||||
OR a.metadata_json NOT LIKE '%"source":"youtube-channel"%'
|
||||
OR a.canonical_title IS NULL
|
||||
OR TRIM(a.canonical_title) != TRIM(yv.channel_name)
|
||||
)
|
||||
ORDER BY lm.last_watched_ms DESC, v.video_id DESC
|
||||
`,
|
||||
)
|
||||
.all(SOURCE_TYPE_REMOTE) as Array<{
|
||||
videoId: number;
|
||||
youtubeVideoId: string | null;
|
||||
videoUrl: string | null;
|
||||
videoTitle: string | null;
|
||||
videoThumbnailUrl: string | null;
|
||||
channelId: string | null;
|
||||
channelName: string | null;
|
||||
channelUrl: string | null;
|
||||
channelThumbnailUrl: string | null;
|
||||
uploaderId: string | null;
|
||||
uploaderUrl: string | null;
|
||||
description: string | null;
|
||||
metadataJson: string | null;
|
||||
}>;
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.youtubeVideoId || !candidate.videoUrl) {
|
||||
continue;
|
||||
}
|
||||
linkYoutubeVideoToAnimeRecord(this.db, candidate.videoId, {
|
||||
youtubeVideoId: candidate.youtubeVideoId,
|
||||
videoUrl: candidate.videoUrl,
|
||||
videoTitle: candidate.videoTitle,
|
||||
videoThumbnailUrl: candidate.videoThumbnailUrl,
|
||||
channelId: candidate.channelId,
|
||||
channelName: candidate.channelName,
|
||||
channelUrl: candidate.channelUrl,
|
||||
channelThumbnailUrl: candidate.channelThumbnailUrl,
|
||||
uploaderId: candidate.uploaderId,
|
||||
uploaderUrl: candidate.uploaderUrl,
|
||||
description: candidate.description,
|
||||
metadataJson: candidate.metadataJson,
|
||||
});
|
||||
}
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
}
|
||||
|
||||
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
const normalizedTitle = normalizeText(mediaTitle);
|
||||
@@ -971,14 +1148,14 @@ export class ImmersionTrackerService {
|
||||
`Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`,
|
||||
);
|
||||
this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs);
|
||||
if (sourceType === SOURCE_TYPE_REMOTE) {
|
||||
const youtubeVideoId = extractYouTubeVideoId(normalizedPath);
|
||||
const youtubeVideoId =
|
||||
sourceType === SOURCE_TYPE_REMOTE ? extractYouTubeVideoId(normalizedPath) : null;
|
||||
if (youtubeVideoId) {
|
||||
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
||||
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||
}
|
||||
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
||||
}
|
||||
|
||||
@@ -1006,6 +1183,7 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
|
||||
const startMs = secToMs(startSec);
|
||||
const endMs = secToMs(endSec);
|
||||
const subtitleKey = `${startMs}:${cleaned}`;
|
||||
if (this.recordedSubtitleKeys.has(subtitleKey)) {
|
||||
return;
|
||||
@@ -1019,6 +1197,9 @@ export class ImmersionTrackerService {
|
||||
this.sessionState.currentLineIndex += 1;
|
||||
this.sessionState.linesSeen += 1;
|
||||
this.sessionState.tokensSeen += tokenCount;
|
||||
if (this.sessionState.lastMediaMs === null || endMs > this.sessionState.lastMediaMs) {
|
||||
this.sessionState.lastMediaMs = endMs;
|
||||
}
|
||||
this.sessionState.pendingTelemetry = true;
|
||||
|
||||
const wordOccurrences = new Map<string, CountedWordOccurrence>();
|
||||
@@ -1068,8 +1249,8 @@ export class ImmersionTrackerService {
|
||||
sessionId: this.sessionState.sessionId,
|
||||
videoId: this.sessionState.videoId,
|
||||
lineIndex: this.sessionState.currentLineIndex,
|
||||
segmentStartMs: secToMs(startSec),
|
||||
segmentEndMs: secToMs(endSec),
|
||||
segmentStartMs: startMs,
|
||||
segmentEndMs: endMs,
|
||||
text: cleaned,
|
||||
secondaryText: secondaryText ?? null,
|
||||
wordOccurrences: Array.from(wordOccurrences.values()),
|
||||
|
||||
@@ -280,6 +280,78 @@ test('getAnimeEpisodes falls back to the latest subtitle segment end when sessio
|
||||
}
|
||||
});
|
||||
|
||||
test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'remote:https://www.youtube.com/watch?v=zero123', {
|
||||
canonicalTitle: 'Zero Checkpoint Stream',
|
||||
sourcePath: null,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=zero123',
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Zero Checkpoint Anime',
|
||||
canonicalTitle: 'Zero Checkpoint Anime',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: 'watch?v=zero123',
|
||||
parsedTitle: 'Zero Checkpoint Anime',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: '{"episode":1}',
|
||||
});
|
||||
db.prepare('UPDATE imm_videos SET duration_ms = ? WHERE video_id = ?').run(600_000, videoId);
|
||||
|
||||
const startedAtMs = 1_200_000;
|
||||
const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId;
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_sessions
|
||||
SET
|
||||
ended_at_ms = ?,
|
||||
status = 2,
|
||||
ended_media_ms = 0,
|
||||
active_watched_ms = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(startedAtMs + 30_000, 180_000, startedAtMs + 30_000, sessionId);
|
||||
stmts.eventInsertStmt.run(
|
||||
sessionId,
|
||||
startedAtMs + 29_000,
|
||||
EVENT_SUBTITLE_LINE,
|
||||
1,
|
||||
170_000,
|
||||
185_000,
|
||||
4,
|
||||
0,
|
||||
'{"line":"stream progress"}',
|
||||
startedAtMs + 29_000,
|
||||
startedAtMs + 29_000,
|
||||
);
|
||||
|
||||
const [episode] = getAnimeEpisodes(db, animeId);
|
||||
assert.ok(episode);
|
||||
assert.equal(episode?.endedMediaMs, 185_000);
|
||||
assert.equal(episode?.durationMs, 600_000);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionTimeline returns the full session when no limit is provided', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -2774,3 +2846,200 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteSession removes zero-session media from library and trends', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Delete Me Anime',
|
||||
canonicalTitle: 'Delete Me Anime',
|
||||
anilistId: 404_404,
|
||||
titleRomaji: 'Delete Me Anime',
|
||||
titleEnglish: 'Delete Me Anime',
|
||||
titleNative: 'Delete Me Anime',
|
||||
metadataJson: null,
|
||||
});
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/delete-last-session.mkv', {
|
||||
canonicalTitle: 'Delete Last Session',
|
||||
sourcePath: '/tmp/delete-last-session.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: 'Delete Last Session',
|
||||
parsedTitle: 'Delete Me Anime',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: '{"episode":1}',
|
||||
});
|
||||
|
||||
const startedAtMs = 9_000_000;
|
||||
const endedAtMs = startedAtMs + 120_000;
|
||||
const rollupDay = Math.floor(startedAtMs / 86_400_000);
|
||||
const rollupMonth = 197001;
|
||||
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_sessions
|
||||
SET
|
||||
ended_at_ms = ?,
|
||||
ended_media_ms = ?,
|
||||
total_watched_ms = ?,
|
||||
active_watched_ms = ?,
|
||||
lines_seen = ?,
|
||||
tokens_seen = ?,
|
||||
cards_mined = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(endedAtMs, 120000, 120000, 120000, 12, 120, 3, endedAtMs, sessionId);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_applied_sessions (
|
||||
session_id,
|
||||
applied_at_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?)
|
||||
`,
|
||||
).run(sessionId, endedAtMs, endedAtMs, endedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_media (
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(videoId, 1, 120_000, 3, 12, 120, 0, startedAtMs, endedAtMs, endedAtMs, endedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_anime (
|
||||
anime_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
episodes_started,
|
||||
episodes_completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(animeId, 1, 120000, 3, 12, 120, 1, 0, startedAtMs, endedAtMs, endedAtMs, endedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_lifetime_global
|
||||
SET
|
||||
total_sessions = 1,
|
||||
total_active_ms = 120000,
|
||||
total_cards = 3,
|
||||
active_days = 1,
|
||||
episodes_started = 1,
|
||||
episodes_completed = 0,
|
||||
anime_completed = 0,
|
||||
last_rebuilt_ms = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE global_id = 1
|
||||
`,
|
||||
).run(endedAtMs, endedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day,
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_min,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
total_cards,
|
||||
cards_per_hour,
|
||||
tokens_per_min,
|
||||
lookup_hit_rate,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(rollupDay, videoId, 1, 2, 12, 120, 3, 90, 60, null, endedAtMs, endedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_monthly_rollups (
|
||||
rollup_month,
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_min,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
total_cards,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(rollupMonth, videoId, 1, 2, 12, 120, 3, endedAtMs, endedAtMs);
|
||||
|
||||
deleteSession(db, sessionId);
|
||||
|
||||
assert.deepEqual(getMediaLibrary(db), []);
|
||||
assert.equal(getMediaDetail(db, videoId) ?? null, null);
|
||||
assert.deepEqual(getAnimeLibrary(db), []);
|
||||
assert.equal(getAnimeDetail(db, animeId) ?? null, null);
|
||||
|
||||
const trends = getTrendsDashboard(db, 'all', 'day');
|
||||
assert.deepEqual(trends.activity.watchTime, []);
|
||||
assert.deepEqual(trends.activity.sessions, []);
|
||||
|
||||
const dailyRollups = getDailyRollups(db, 30);
|
||||
const monthlyRollups = getMonthlyRollups(db, 30);
|
||||
assert.deepEqual(dailyRollups, []);
|
||||
assert.deepEqual(monthlyRollups, []);
|
||||
|
||||
const lifetimeMediaCount = Number(
|
||||
(
|
||||
db.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media WHERE video_id = ?').get(
|
||||
videoId,
|
||||
) as { total: number }
|
||||
).total,
|
||||
);
|
||||
const lifetimeAnimeCount = Number(
|
||||
(
|
||||
db.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime WHERE anime_id = ?').get(
|
||||
animeId,
|
||||
) as { total: number }
|
||||
).total,
|
||||
);
|
||||
const appliedSessionCount = Number(
|
||||
(
|
||||
db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions WHERE session_id = ?')
|
||||
.get(sessionId) as { total: number }
|
||||
).total,
|
||||
);
|
||||
|
||||
assert.equal(lifetimeMediaCount, 0);
|
||||
assert.equal(lifetimeAnimeCount, 0);
|
||||
assert.equal(appliedSessionCount, 0);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -134,6 +134,49 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
|
||||
).run(nowMs, nowMs);
|
||||
}
|
||||
|
||||
function rebuildLifetimeSummariesInternal(
|
||||
db: DatabaseSync,
|
||||
rebuiltAtMs: number,
|
||||
): LifetimeRebuildSummary {
|
||||
const sessions = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
session_id AS sessionId,
|
||||
video_id AS videoId,
|
||||
started_at_ms AS startedAtMs,
|
||||
ended_at_ms AS endedAtMs,
|
||||
total_watched_ms AS totalWatchedMs,
|
||||
active_watched_ms AS activeWatchedMs,
|
||||
lines_seen AS linesSeen,
|
||||
tokens_seen AS tokensSeen,
|
||||
cards_mined AS cardsMined,
|
||||
lookup_count AS lookupCount,
|
||||
lookup_hits AS lookupHits,
|
||||
yomitan_lookup_count AS yomitanLookupCount,
|
||||
pause_count AS pauseCount,
|
||||
pause_ms AS pauseMs,
|
||||
seek_forward_count AS seekForwardCount,
|
||||
seek_backward_count AS seekBackwardCount,
|
||||
media_buffer_events AS mediaBufferEvents
|
||||
FROM imm_sessions
|
||||
WHERE ended_at_ms IS NOT NULL
|
||||
ORDER BY started_at_ms ASC, session_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as RetainedSessionRow[];
|
||||
|
||||
resetLifetimeSummaries(db, rebuiltAtMs);
|
||||
for (const session of sessions) {
|
||||
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs);
|
||||
}
|
||||
|
||||
return {
|
||||
appliedSessions: sessions.length,
|
||||
rebuiltAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function toRebuildSessionState(row: RetainedSessionRow): SessionState {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
@@ -482,50 +525,22 @@ export function applySessionLifetimeSummary(
|
||||
|
||||
export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSummary {
|
||||
const rebuiltAtMs = Date.now();
|
||||
const sessions = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
session_id AS sessionId,
|
||||
video_id AS videoId,
|
||||
started_at_ms AS startedAtMs,
|
||||
ended_at_ms AS endedAtMs,
|
||||
total_watched_ms AS totalWatchedMs,
|
||||
active_watched_ms AS activeWatchedMs,
|
||||
lines_seen AS linesSeen,
|
||||
tokens_seen AS tokensSeen,
|
||||
cards_mined AS cardsMined,
|
||||
lookup_count AS lookupCount,
|
||||
lookup_hits AS lookupHits,
|
||||
yomitan_lookup_count AS yomitanLookupCount,
|
||||
pause_count AS pauseCount,
|
||||
pause_ms AS pauseMs,
|
||||
seek_forward_count AS seekForwardCount,
|
||||
seek_backward_count AS seekBackwardCount,
|
||||
media_buffer_events AS mediaBufferEvents
|
||||
FROM imm_sessions
|
||||
WHERE ended_at_ms IS NOT NULL
|
||||
ORDER BY started_at_ms ASC, session_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as RetainedSessionRow[];
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
resetLifetimeSummaries(db, rebuiltAtMs);
|
||||
for (const session of sessions) {
|
||||
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs);
|
||||
}
|
||||
const summary = rebuildLifetimeSummariesInTransaction(db, rebuiltAtMs);
|
||||
db.exec('COMMIT');
|
||||
return summary;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appliedSessions: sessions.length,
|
||||
rebuiltAtMs,
|
||||
};
|
||||
export function rebuildLifetimeSummariesInTransaction(
|
||||
db: DatabaseSync,
|
||||
rebuiltAtMs = Date.now(),
|
||||
): LifetimeRebuildSummary {
|
||||
return rebuildLifetimeSummariesInternal(db, rebuiltAtMs);
|
||||
}
|
||||
|
||||
export function reconcileStaleActiveSessions(db: DatabaseSync): number {
|
||||
|
||||
@@ -113,6 +113,14 @@ function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number): void {
|
||||
).run(ROLLUP_STATE_KEY, sampleMs);
|
||||
}
|
||||
|
||||
function resetRollups(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
DELETE FROM imm_daily_rollups;
|
||||
DELETE FROM imm_monthly_rollups;
|
||||
`);
|
||||
setLastRollupSampleMs(db, ZERO_ID);
|
||||
}
|
||||
|
||||
function upsertDailyRollupsForGroups(
|
||||
db: DatabaseSync,
|
||||
groups: Array<{ rollupDay: number; videoId: number }>,
|
||||
@@ -281,8 +289,20 @@ function dedupeGroups<T extends { rollupDay?: number; rollupMonth?: number; vide
|
||||
}
|
||||
|
||||
export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): void {
|
||||
if (forceRebuild) {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
rebuildRollupsInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rollupNowMs = Date.now();
|
||||
const lastRollupSampleMs = forceRebuild ? ZERO_ID : getLastRollupSampleMs(db);
|
||||
const lastRollupSampleMs = getLastRollupSampleMs(db);
|
||||
|
||||
const maxSampleRow = db
|
||||
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
|
||||
@@ -324,6 +344,41 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
|
||||
}
|
||||
}
|
||||
|
||||
export function rebuildRollupsInTransaction(db: DatabaseSync): void {
|
||||
const rollupNowMs = Date.now();
|
||||
const maxSampleRow = db
|
||||
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
|
||||
.get() as unknown as RollupTelemetryResult | null;
|
||||
|
||||
resetRollups(db);
|
||||
if (!maxSampleRow?.maxSampleMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const affectedGroups = getAffectedRollupGroups(db, ZERO_ID);
|
||||
if (affectedGroups.length === 0) {
|
||||
setLastRollupSampleMs(db, Number(maxSampleRow.maxSampleMs));
|
||||
return;
|
||||
}
|
||||
|
||||
const dailyGroups = dedupeGroups(
|
||||
affectedGroups.map((group) => ({
|
||||
rollupDay: group.rollupDay,
|
||||
videoId: group.videoId,
|
||||
})),
|
||||
);
|
||||
const monthlyGroups = dedupeGroups(
|
||||
affectedGroups.map((group) => ({
|
||||
rollupMonth: group.rollupMonth,
|
||||
videoId: group.videoId,
|
||||
})),
|
||||
);
|
||||
|
||||
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
|
||||
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
|
||||
setLastRollupSampleMs(db, Number(maxSampleRow.maxSampleMs));
|
||||
}
|
||||
|
||||
export function runOptimizeMaintenance(db: DatabaseSync): void {
|
||||
db.exec('PRAGMA optimize');
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import type {
|
||||
VocabularyStatsRow,
|
||||
} from './types';
|
||||
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
||||
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||
import { rebuildRollupsInTransaction } from './maintenance';
|
||||
import { PartOfSpeech, type MergedToken } from '../../../types';
|
||||
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
||||
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
|
||||
@@ -1746,7 +1748,7 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
|
||||
v.duration_ms AS durationMs,
|
||||
(
|
||||
SELECT COALESCE(
|
||||
s_recent.ended_media_ms,
|
||||
NULLIF(s_recent.ended_media_ms, 0),
|
||||
(
|
||||
SELECT MAX(line.segment_end_ms)
|
||||
FROM imm_subtitle_lines line
|
||||
@@ -2467,6 +2469,8 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
|
||||
try {
|
||||
deleteSessionsByIds(db, sessionIds);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
@@ -2483,6 +2487,8 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
|
||||
try {
|
||||
deleteSessionsByIds(db, sessionIds);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
@@ -2519,6 +2525,8 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
|
||||
cleanupUnusedCoverArtBlobHash(db, artRow?.coverBlobHash ?? null);
|
||||
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
|
||||
@@ -15,8 +15,14 @@ import {
|
||||
getOrCreateAnimeRecord,
|
||||
getOrCreateVideoRecord,
|
||||
linkVideoToAnimeRecord,
|
||||
linkYoutubeVideoToAnimeRecord,
|
||||
} from './storage';
|
||||
import { EVENT_SUBTITLE_LINE, SESSION_STATUS_ENDED, SOURCE_TYPE_LOCAL } from './types';
|
||||
import {
|
||||
EVENT_SUBTITLE_LINE,
|
||||
SESSION_STATUS_ENDED,
|
||||
SOURCE_TYPE_LOCAL,
|
||||
SOURCE_TYPE_REMOTE,
|
||||
} from './types';
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-storage-session-'));
|
||||
@@ -817,6 +823,123 @@ test('anime rows are reused by normalized parsed title and upgraded with AniList
|
||||
}
|
||||
});
|
||||
|
||||
test('youtube videos can be regrouped under a shared channel anime identity', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const firstVideoId = getOrCreateVideoRecord(
|
||||
db,
|
||||
'remote:https://www.youtube.com/watch?v=video-1',
|
||||
{
|
||||
canonicalTitle: 'watch?v video-1',
|
||||
sourcePath: null,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=video-1',
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
},
|
||||
);
|
||||
const secondVideoId = getOrCreateVideoRecord(
|
||||
db,
|
||||
'remote:https://www.youtube.com/watch?v=video-2',
|
||||
{
|
||||
canonicalTitle: 'watch?v video-2',
|
||||
sourcePath: null,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=video-2',
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
},
|
||||
);
|
||||
|
||||
const firstAnimeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'watch?v video-1',
|
||||
canonicalTitle: 'watch?v video-1',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, firstVideoId, {
|
||||
animeId: firstAnimeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: 'watch?v video-1',
|
||||
parsedSeason: null,
|
||||
parsedEpisode: null,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 0.2,
|
||||
parseMetadataJson: '{"source":"fallback"}',
|
||||
});
|
||||
|
||||
const secondAnimeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'watch?v video-2',
|
||||
canonicalTitle: 'watch?v video-2',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, secondVideoId, {
|
||||
animeId: secondAnimeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: 'watch?v video-2',
|
||||
parsedSeason: null,
|
||||
parsedEpisode: null,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 0.2,
|
||||
parseMetadataJson: '{"source":"fallback"}',
|
||||
});
|
||||
|
||||
linkYoutubeVideoToAnimeRecord(db, firstVideoId, {
|
||||
youtubeVideoId: 'video-1',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=video-1',
|
||||
videoTitle: 'Video One',
|
||||
videoThumbnailUrl: 'https://i.ytimg.com/vi/video-1/hqdefault.jpg',
|
||||
channelId: 'UC123',
|
||||
channelName: 'Channel Name',
|
||||
channelUrl: 'https://www.youtube.com/channel/UC123',
|
||||
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj',
|
||||
uploaderId: '@channelname',
|
||||
uploaderUrl: 'https://www.youtube.com/@channelname',
|
||||
description: null,
|
||||
metadataJson: '{"id":"video-1"}',
|
||||
});
|
||||
linkYoutubeVideoToAnimeRecord(db, secondVideoId, {
|
||||
youtubeVideoId: 'video-2',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=video-2',
|
||||
videoTitle: 'Video Two',
|
||||
videoThumbnailUrl: 'https://i.ytimg.com/vi/video-2/hqdefault.jpg',
|
||||
channelId: 'UC123',
|
||||
channelName: 'Channel Name',
|
||||
channelUrl: 'https://www.youtube.com/channel/UC123',
|
||||
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj',
|
||||
uploaderId: '@channelname',
|
||||
uploaderUrl: 'https://www.youtube.com/@channelname',
|
||||
description: null,
|
||||
metadataJson: '{"id":"video-2"}',
|
||||
});
|
||||
|
||||
const animeRows = db.prepare('SELECT anime_id, canonical_title FROM imm_anime').all() as Array<{
|
||||
anime_id: number;
|
||||
canonical_title: string;
|
||||
}>;
|
||||
const videoRows = db
|
||||
.prepare('SELECT video_id, anime_id, parsed_title FROM imm_videos ORDER BY video_id ASC')
|
||||
.all() as Array<{ video_id: number; anime_id: number | null; parsed_title: string | null }>;
|
||||
|
||||
const channelAnimeRows = animeRows.filter((row) => row.canonical_title === 'Channel Name');
|
||||
assert.equal(channelAnimeRows.length, 1);
|
||||
assert.equal(videoRows[0]?.anime_id, channelAnimeRows[0]?.anime_id);
|
||||
assert.equal(videoRows[1]?.anime_id, channelAnimeRows[0]?.anime_id);
|
||||
assert.equal(videoRows[0]?.parsed_title, 'Channel Name');
|
||||
assert.equal(videoRows[1]?.parsed_title, 'Channel Name');
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('start/finalize session updates ended_at and status', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
@@ -39,6 +39,41 @@ export interface VideoAnimeLinkInput {
|
||||
parseMetadataJson: string | null;
|
||||
}
|
||||
|
||||
function buildYoutubeChannelAnimeIdentity(metadata: YoutubeVideoMetadata): {
|
||||
parsedTitle: string;
|
||||
canonicalTitle: string;
|
||||
metadataJson: string;
|
||||
} | null {
|
||||
const channelId = metadata.channelId?.trim() || null;
|
||||
const channelUrl = metadata.channelUrl?.trim() || null;
|
||||
const channelName = metadata.channelName?.trim() || null;
|
||||
const uploaderId = metadata.uploaderId?.trim() || null;
|
||||
const videoTitle = metadata.videoTitle?.trim() || null;
|
||||
|
||||
const parsedTitle = channelId
|
||||
? `youtube-channel:${channelId}`
|
||||
: channelUrl
|
||||
? `youtube-channel-url:${channelUrl}`
|
||||
: channelName
|
||||
? `youtube-channel-name:${channelName}`
|
||||
: null;
|
||||
if (!parsedTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
parsedTitle,
|
||||
canonicalTitle: channelName || uploaderId || videoTitle || parsedTitle,
|
||||
metadataJson: JSON.stringify({
|
||||
source: 'youtube-channel',
|
||||
channelId,
|
||||
channelUrl,
|
||||
channelName,
|
||||
uploaderId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const COVER_BLOB_REFERENCE_PREFIX = '__subminer_cover_blob_ref__:';
|
||||
const WAL_JOURNAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
@@ -439,6 +474,38 @@ export function linkVideoToAnimeRecord(
|
||||
);
|
||||
}
|
||||
|
||||
export function linkYoutubeVideoToAnimeRecord(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
metadata: YoutubeVideoMetadata,
|
||||
): number | null {
|
||||
const identity = buildYoutubeChannelAnimeIdentity(metadata);
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: identity.parsedTitle,
|
||||
canonicalTitle: identity.canonicalTitle,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: identity.metadataJson,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: identity.canonicalTitle,
|
||||
parsedSeason: null,
|
||||
parsedEpisode: null,
|
||||
parserSource: 'youtube',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: identity.metadataJson,
|
||||
});
|
||||
return animeId;
|
||||
}
|
||||
|
||||
function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
|
||||
addColumnIfMissing(db, 'imm_videos', 'anime_id', 'INTEGER REFERENCES imm_anime(anime_id)');
|
||||
addColumnIfMissing(db, 'imm_videos', 'parsed_basename', 'TEXT');
|
||||
|
||||
18
src/main.ts
18
src/main.ts
@@ -412,6 +412,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||
import {
|
||||
@@ -1231,6 +1232,13 @@ const startupOsdSequencer = createStartupOsdSequencer({
|
||||
showOsd: (message) => showMpvOsd(message),
|
||||
});
|
||||
|
||||
function isYoutubePlaybackActiveNow(): boolean {
|
||||
return isYoutubePlaybackActive(
|
||||
appState.currentMediaPath,
|
||||
appState.mpvClient?.currentVideoPath ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
function maybeSignalPluginAutoplayReady(
|
||||
payload: SubtitleData,
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
@@ -1741,7 +1749,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
getConfig: () => {
|
||||
const config = getResolvedConfig().anilist.characterDictionary;
|
||||
return {
|
||||
enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(),
|
||||
enabled:
|
||||
config.enabled &&
|
||||
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
|
||||
!isYoutubePlaybackActiveNow(),
|
||||
maxLoaded: config.maxLoaded,
|
||||
profileScope: config.profileScope,
|
||||
};
|
||||
@@ -3518,7 +3529,7 @@ const {
|
||||
);
|
||||
},
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) {
|
||||
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
|
||||
return;
|
||||
}
|
||||
characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||
@@ -3613,7 +3624,8 @@ const {
|
||||
),
|
||||
getCharacterDictionaryEnabled: () =>
|
||||
getResolvedConfig().anilist.characterDictionary.enabled &&
|
||||
yomitanProfilePolicy.isCharacterDictionaryEnabled(),
|
||||
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
|
||||
!isYoutubePlaybackActiveNow(),
|
||||
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||
getFrequencyDictionaryEnabled: () =>
|
||||
getRuntimeBooleanOption(
|
||||
|
||||
@@ -68,3 +68,32 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
|
||||
});
|
||||
assert.equal(state.mediaGuessPromise, null);
|
||||
});
|
||||
|
||||
test('ensureAnilistMediaGuess skips youtube playback urls', async () => {
|
||||
let state: AnilistMediaGuessRuntimeState = {
|
||||
mediaKey: 'https://www.youtube.com/watch?v=abc123',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
};
|
||||
let calls = 0;
|
||||
const ensureGuess = createEnsureAnilistMediaGuessHandler({
|
||||
getState: () => state,
|
||||
setState: (next) => {
|
||||
state = next;
|
||||
},
|
||||
resolveMediaPathForJimaku: (value) => value,
|
||||
getCurrentMediaPath: () => 'https://www.youtube.com/watch?v=abc123',
|
||||
getCurrentMediaTitle: () => 'Video',
|
||||
guessAnilistMediaInfo: async () => {
|
||||
calls += 1;
|
||||
return { title: 'Show', season: null, episode: 1, source: 'guessit' };
|
||||
},
|
||||
});
|
||||
|
||||
const guess = await ensureGuess('https://www.youtube.com/watch?v=abc123');
|
||||
assert.equal(guess, null);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(state.mediaGuess, null);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
||||
import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
export type AnilistMediaGuessRuntimeState = {
|
||||
mediaKey: string | null;
|
||||
@@ -26,6 +27,9 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
|
||||
if (state.mediaKey !== mediaKey) {
|
||||
return null;
|
||||
}
|
||||
if (isYoutubeMediaPath(mediaKey)) {
|
||||
return null;
|
||||
}
|
||||
if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) {
|
||||
return state.mediaDurationSec;
|
||||
}
|
||||
@@ -73,6 +77,9 @@ export function createEnsureAnilistMediaGuessHandler(deps: {
|
||||
if (state.mediaKey !== mediaKey) {
|
||||
return null;
|
||||
}
|
||||
if (isYoutubeMediaPath(mediaKey)) {
|
||||
return null;
|
||||
}
|
||||
if (state.mediaGuess) {
|
||||
return state.mediaGuess;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,18 @@ test('get current anilist media key trims and normalizes empty path', () => {
|
||||
assert.equal(getEmptyKey(), null);
|
||||
});
|
||||
|
||||
test('get current anilist media key skips youtube playback urls', () => {
|
||||
const getYoutubeKey = createGetCurrentAnilistMediaKeyHandler({
|
||||
getCurrentMediaPath: () => ' https://www.youtube.com/watch?v=abc123 ',
|
||||
});
|
||||
const getShortYoutubeKey = createGetCurrentAnilistMediaKeyHandler({
|
||||
getCurrentMediaPath: () => 'https://youtu.be/abc123',
|
||||
});
|
||||
|
||||
assert.equal(getYoutubeKey(), null);
|
||||
assert.equal(getShortYoutubeKey(), null);
|
||||
});
|
||||
|
||||
test('reset anilist media tracking clears duration/guess/probe state', () => {
|
||||
let mediaKey: string | null = 'old';
|
||||
let mediaDurationSec: number | null = 123;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess';
|
||||
import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
export function createGetCurrentAnilistMediaKeyHandler(deps: {
|
||||
getCurrentMediaPath: () => string | null;
|
||||
}) {
|
||||
return (): string | null => {
|
||||
const mediaPath = deps.getCurrentMediaPath()?.trim();
|
||||
return mediaPath && mediaPath.length > 0 ? mediaPath : null;
|
||||
if (!mediaPath || mediaPath.length === 0 || isYoutubeMediaPath(mediaPath)) {
|
||||
return null;
|
||||
}
|
||||
return mediaPath;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -76,3 +76,52 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as
|
||||
assert.ok(calls.includes('inflight:true'));
|
||||
assert.ok(calls.includes('inflight:false'));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
|
||||
resetTrackedMedia: () => calls.push('reset'),
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => {
|
||||
calls.push('probe');
|
||||
return 1000;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => {
|
||||
calls.push('guess');
|
||||
return { title: 'Show', season: null, episode: 1 };
|
||||
},
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => {
|
||||
calls.push('process-retry');
|
||||
return { ok: true, message: 'noop' };
|
||||
},
|
||||
refreshAnilistClientSecretState: async () => {
|
||||
calls.push('refresh-token');
|
||||
return 'token';
|
||||
},
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
calls.push('update');
|
||||
return { status: 'updated', message: 'ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
type AnilistGuess = {
|
||||
title: string;
|
||||
episode: number | null;
|
||||
@@ -130,6 +132,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
if (!mediaKey || !deps.hasMpvClient()) {
|
||||
return;
|
||||
}
|
||||
if (isYoutubeMediaPath(mediaKey)) {
|
||||
return;
|
||||
}
|
||||
if (deps.getTrackedMediaKey() !== mediaKey) {
|
||||
deps.resetTrackedMedia(mediaKey);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,57 @@ test('createImmersionTrackerStartupHandler skips when disabled', () => {
|
||||
assert.equal(tracker, 'unchanged');
|
||||
});
|
||||
|
||||
test('createImmersionTrackerStartupHandler skips when env disables session tracking', () => {
|
||||
const calls: string[] = [];
|
||||
const originalEnv = process.env.SUBMINER_DISABLE_IMMERSION_TRACKING;
|
||||
process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = '1';
|
||||
|
||||
try {
|
||||
let tracker: unknown = 'unchanged';
|
||||
const handler = createImmersionTrackerStartupHandler({
|
||||
getResolvedConfig: () => {
|
||||
calls.push('getResolvedConfig');
|
||||
return makeConfig();
|
||||
},
|
||||
getConfiguredDbPath: () => {
|
||||
calls.push('getConfiguredDbPath');
|
||||
return '/tmp/subminer.db';
|
||||
},
|
||||
createTrackerService: () => {
|
||||
calls.push('createTrackerService');
|
||||
return {};
|
||||
},
|
||||
setTracker: (nextTracker) => {
|
||||
tracker = nextTracker;
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
});
|
||||
|
||||
handler();
|
||||
|
||||
assert.equal(calls.includes('getResolvedConfig'), false);
|
||||
assert.equal(calls.includes('getConfiguredDbPath'), false);
|
||||
assert.equal(calls.includes('createTrackerService'), false);
|
||||
assert.equal(calls.includes('seedTracker'), false);
|
||||
assert.equal(tracker, 'unchanged');
|
||||
assert.ok(
|
||||
calls.includes(
|
||||
'info:Immersion tracking disabled for this session by SUBMINER_DISABLE_IMMERSION_TRACKING=1.',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.SUBMINER_DISABLE_IMMERSION_TRACKING;
|
||||
} else {
|
||||
process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv', () => {
|
||||
const calls: string[] = [];
|
||||
const trackerInstance = { kind: 'tracker' };
|
||||
|
||||
@@ -23,6 +23,8 @@ type ImmersionTrackingConfig = {
|
||||
|
||||
type ImmersionTrackerPolicy = Omit<ImmersionTrackingPolicy, 'enabled'>;
|
||||
|
||||
const DISABLE_IMMERSION_TRACKING_SESSION_ENV = 'SUBMINER_DISABLE_IMMERSION_TRACKING';
|
||||
|
||||
type ImmersionTrackerServiceParams = {
|
||||
dbPath: string;
|
||||
policy: ImmersionTrackerPolicy;
|
||||
@@ -49,7 +51,16 @@ export type ImmersionTrackerStartupDeps = {
|
||||
export function createImmersionTrackerStartupHandler(
|
||||
deps: ImmersionTrackerStartupDeps,
|
||||
): () => void {
|
||||
const isSessionTrackingDisabled = process.env[DISABLE_IMMERSION_TRACKING_SESSION_ENV] === '1';
|
||||
|
||||
return () => {
|
||||
if (isSessionTrackingDisabled) {
|
||||
deps.logInfo(
|
||||
`Immersion tracking disabled for this session by ${DISABLE_IMMERSION_TRACKING_SESSION_ENV}=1.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = deps.getResolvedConfig();
|
||||
if (config.immersionTracking?.enabled === false) {
|
||||
deps.logInfo('Immersion tracking disabled in config');
|
||||
|
||||
23
src/main/runtime/youtube-playback.test.ts
Normal file
23
src/main/runtime/youtube-playback.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isYoutubeMediaPath, isYoutubePlaybackActive } from './youtube-playback';
|
||||
|
||||
test('isYoutubeMediaPath detects youtube watch and short urls', () => {
|
||||
assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true);
|
||||
assert.equal(isYoutubeMediaPath('https://m.youtube.com/watch?v=abc123'), true);
|
||||
assert.equal(isYoutubeMediaPath('https://youtu.be/abc123'), true);
|
||||
assert.equal(isYoutubeMediaPath('https://www.youtube-nocookie.com/embed/abc123'), true);
|
||||
});
|
||||
|
||||
test('isYoutubeMediaPath ignores local files and non-youtube urls', () => {
|
||||
assert.equal(isYoutubeMediaPath('/tmp/video.mkv'), false);
|
||||
assert.equal(isYoutubeMediaPath('https://example.com/watch?v=abc123'), false);
|
||||
assert.equal(isYoutubeMediaPath(' '), false);
|
||||
assert.equal(isYoutubeMediaPath(null), false);
|
||||
});
|
||||
|
||||
test('isYoutubePlaybackActive checks both current media and mpv video paths', () => {
|
||||
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', 'https://youtu.be/abc123'), true);
|
||||
assert.equal(isYoutubePlaybackActive('https://www.youtube.com/watch?v=abc123', null), true);
|
||||
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false);
|
||||
});
|
||||
36
src/main/runtime/youtube-playback.ts
Normal file
36
src/main/runtime/youtube-playback.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
function trimToNull(value: string | null | undefined): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
|
||||
const normalized = trimToNull(mediaPath);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return (
|
||||
host === 'youtu.be' ||
|
||||
host.endsWith('.youtu.be') ||
|
||||
host.endsWith('youtube.com') ||
|
||||
host.endsWith('youtube-nocookie.com')
|
||||
);
|
||||
}
|
||||
|
||||
export function isYoutubePlaybackActive(
|
||||
currentMediaPath: string | null | undefined,
|
||||
currentVideoPath: string | null | undefined,
|
||||
): boolean {
|
||||
return isYoutubeMediaPath(currentMediaPath) || isYoutubeMediaPath(currentVideoPath);
|
||||
}
|
||||
Reference in New Issue
Block a user