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