fix(immersion): special-case youtube media paths in runtime and tracking

This commit is contained in:
2026-03-23 00:36:19 -07:00
parent 3e7615b3bd
commit 2e43d95396
20 changed files with 1481 additions and 56 deletions

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
};
}

View File

@@ -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, []);
});

View File

@@ -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);
}

View File

@@ -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' };

View File

@@ -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');

View 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);
});

View 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);
}