From ea8ebc0e34af76572bddd5faec0a329dc7ca335d Mon Sep 17 00:00:00 2001 From: OwaisByte <269550201+OwaisByte@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:53:47 +1100 Subject: [PATCH 1/8] fix(player): resume the last session with saved track choices Keep observed subtitle and audio preferences with playback history, respect account and profile scope, and retain explicit picker choices. Co-authored-by: OpenAI Codex --- src-tauri/src/mpv.rs | 1 + src/hooks/use-last-playback-continuation.ts | 18 +- src/lib/i18n/locales/ar/context-actions.ts | 6 + .../i18n/locales/context-actions-fallback.ts | 7 + src/lib/playback-history.ts | 363 ++++++++++++++--- src/lib/player/actual-playback.ts | 38 ++ src/lib/player/bridge.ts | 2 + src/lib/player/continue-playback.ts | 39 ++ src/lib/player/mpv-properties.ts | 3 +- src/lib/player/mpv.ts | 3 + src/lib/player/subtitle-choice.ts | 149 +++++++ src/lib/subtitles/subtitle-memory.ts | 12 +- src/views/player/hooks/use-actual-playback.ts | 48 ++- .../player/hooks/use-playback-controls.ts | 31 +- src/views/player/hooks/use-player-bridge.ts | 1 + src/views/player/hooks/use-track-autoload.ts | 282 +++++++++++-- tests/actual-playback-recorder.test.ts | 155 ++++++- tests/actual-playback-snapshot.test.ts | 127 ++++++ tests/actual-playback.test.ts | 381 ++++++++++++++++++ tests/helpers/mpv-bridge-harness.ts | 10 +- tests/helpers/subtitle-memory-harness.ts | 27 ++ tests/last-playback-continuation-hook.test.ts | 65 ++- tests/playback-audio-autoload.test.ts | 64 ++- tests/playback-continuation.test.ts | 61 +++ tests/playback-session-fps.test.ts | 121 ++++++ tests/playback-session-restore.test.ts | 308 ++++++++++++++ tests/playback-subtitle-choice.test.ts | 76 ++++ tests/subtitle-last-choice.test.ts | 17 + tests/subtitle-native-mpv.test.ts | 24 ++ 29 files changed, 2300 insertions(+), 139 deletions(-) create mode 100644 src/lib/player/subtitle-choice.ts create mode 100644 tests/playback-session-fps.test.ts create mode 100644 tests/playback-session-restore.test.ts create mode 100644 tests/playback-subtitle-choice.test.ts diff --git a/src-tauri/src/mpv.rs b/src-tauri/src/mpv.rs index ed9614e62..b05737bfa 100644 --- a/src-tauri/src/mpv.rs +++ b/src-tauri/src/mpv.rs @@ -305,6 +305,7 @@ const OBSERVED_PROPS: &[(&str, u64, PropertyKind)] = &[ // so upscaler presets need these to know how much headroom the window has. ("width", 22, PropertyKind::Int64), ("height", 23, PropertyKind::Int64), + ("sub-fps", 24, PropertyKind::Double), ]; #[derive(Clone, Copy)] diff --git a/src/hooks/use-last-playback-continuation.ts b/src/hooks/use-last-playback-continuation.ts index 045f7323e..b1f0f98c9 100644 --- a/src/hooks/use-last-playback-continuation.ts +++ b/src/hooks/use-last-playback-continuation.ts @@ -4,6 +4,7 @@ import { useProfiles } from "@/lib/profiles"; import { useView, type PlayerSrc } from "@/lib/view"; import { capturePlaybackActor, + getPlaybackRevision, isPlaybackActorCurrent, readLastActualPlayback, subscribePlayback, @@ -12,12 +13,6 @@ import { import { currentPlayerActions } from "@/lib/player-actions"; import { continueActualPlayback } from "@/lib/player/continue-playback"; -let version = 0; -subscribePlayback(() => { - version += 1; -}); -const snapshotVersion = () => version; - async function prepareServer(src: PlayerSrc, positionMs: number): Promise { const context = src.homeServer; if (!context) throw new Error("This is not home-server playback."); @@ -50,10 +45,17 @@ export function useLastPlaybackContinuation() { const view = useView(); const { activeProfile } = useProfiles(); const { user } = useAuth(); - const revision = useSyncExternalStore(subscribePlayback, snapshotVersion, snapshotVersion); - const target = useMemo(() => readLastActualPlayback(), [revision, activeProfile?.id, user?._id]); + const revision = useSyncExternalStore( + subscribePlayback, + getPlaybackRevision, + getPlaybackRevision, + ); const currentActor = capturePlaybackActor(); const actorKey = JSON.stringify(currentActor); + const target = useMemo( + () => readLastActualPlayback(), + [revision, activeProfile?.id, user?._id, actorKey], + ); const operation = useRef<{ actorKey: string; token: object | null; mounted: boolean }>({ actorKey, token: null, diff --git a/src/lib/i18n/locales/ar/context-actions.ts b/src/lib/i18n/locales/ar/context-actions.ts index aa7be4688..3c9526f84 100644 --- a/src/lib/i18n/locales/ar/context-actions.ts +++ b/src/lib/i18n/locales/ar/context-actions.ts @@ -17,6 +17,12 @@ const contextActions: Record = { "Play this same video from the beginning?": "تشغيل الفيديو نفسه من البداية؟", "Opening playback…": "جارٍ فتح التشغيل…", "Continue last watched": "متابعة آخر مشاهدة", + "Resume last watched": "استكمال آخر مشاهدة", + "The saved audio track could not be restored. Choose an audio track.": + "تعذّر استعادة مسار الصوت المحفوظ. اختر مسار صوت.", + "The saved subtitle could not be restored. Choose a subtitle.": + "تعذّر استعادة الترجمة المحفوظة. اختر ترجمة.", + "The saved subtitle timing could not be restored.": "تعذّر استعادة توقيت الترجمة المحفوظ.", "Local video": "فيديو محلي", "Open my profile": "فتح ملفي الشخصي", "Add to page": "إضافة إلى صفحة", diff --git a/src/lib/i18n/locales/context-actions-fallback.ts b/src/lib/i18n/locales/context-actions-fallback.ts index 3c6100da2..d4b9ce0de 100644 --- a/src/lib/i18n/locales/context-actions-fallback.ts +++ b/src/lib/i18n/locales/context-actions-fallback.ts @@ -19,6 +19,13 @@ const contextActionsFallback: Record = { "Play this same video from the beginning?": "Play this same video from the beginning?", "Opening playback…": "Opening playback…", "Continue last watched": "Continue last watched", + "Resume last watched": "Resume last watched", + "The saved audio track could not be restored. Choose an audio track.": + "The saved audio track could not be restored. Choose an audio track.", + "The saved subtitle could not be restored. Choose a subtitle.": + "The saved subtitle could not be restored. Choose a subtitle.", + "The saved subtitle timing could not be restored.": + "The saved subtitle timing could not be restored.", "Local video": "Local video", "Open my profile": "Open my profile", "Add to page": "Add to page", diff --git a/src/lib/playback-history.ts b/src/lib/playback-history.ts index d843c2338..ae202ac5f 100644 --- a/src/lib/playback-history.ts +++ b/src/lib/playback-history.ts @@ -2,7 +2,17 @@ import { useSyncExternalStore } from "react"; import type { AddonProgress } from "./streams/addons"; import type { PlayerSrc } from "./view"; import type { PlaybackAudioChoice } from "./player/audio-choice"; +import type { PlaybackSubtitleChoice } from "./player/subtitle-choice"; import { noteNotify } from "./render-cause"; +import { subtitleMediaKey, subtitleStreamKey } from "./subtitles/subtitle-memory"; +import { + activeScopedProfileId, + isNuvioProviderActive, + stremioProfileId, +} from "./active-profile-id"; +import { cwProfileScope } from "./cw-profile"; + +type ActualPlaybackState = { actual?: ActualPlayback }; export type PlaybackEntry = { infoHash?: string | null; @@ -21,12 +31,16 @@ export type PlaybackEntry = { savedAt: number; /** Written by observed player activity, never by source selection or watched edits. */ actual?: ActualPlayback; + /** Same history, with observed ownership retained when private CW is enabled. */ + actualByProfile?: Record; }; export type PlaybackActor = { profileId: string; storageProfileId: string; accountId: string | null; + provider?: "nuvio"; + privateCw?: boolean; }; export type ActualPlayback = { @@ -39,6 +53,9 @@ export type ActualPlayback = { src: Omit; requiresSourceRefresh?: boolean; audioTrack?: PlaybackAudioChoice; + subtitle?: PlaybackSubtitleChoice; + subDelaySec?: number; + audioDelaySec?: number; }; export type PlaybackContinuation = { @@ -46,9 +63,13 @@ export type PlaybackContinuation = { positionMs: number; source: Omit; sourceKey: string; + target?: { metaId: string; season?: number; episode?: number }; actor: PlaybackActor; restart?: boolean; audioTrack?: PlaybackAudioChoice; + subtitle?: PlaybackSubtitleChoice; + subDelaySec?: number; + audioDelaySec?: number; }; const STORAGE_KEY_PREFIX = "harbor.playback-history.v1."; @@ -58,8 +79,39 @@ const TTL_MS = 30 * 24 * 60 * 60 * 1000; const MAX_ENTRIES = 200; const listeners = new Set<() => void>(); +let playbackRevision = 0; + +export function getPlaybackRevision(): number { + return playbackRevision; +} + +function notifyPlayback(): void { + playbackRevision += 1; + noteNotify("store:playback"); + listeners.forEach((listener) => listener()); +} + +function nuvioAccountId(): string | null { + try { + const raw = + localStorage.getItem("harbor.nuvio.state.v1") ?? localStorage.getItem("harbor.nuvio.state"); + const userId = JSON.parse(raw ?? "null")?.session?.user?.id; + return typeof userId === "string" && userId ? userId : null; + } catch { + return null; + } +} export function capturePlaybackActor(): PlaybackActor { + if (isNuvioProviderActive()) { + const profileId = activeScopedProfileId(); + return { + profileId, + storageProfileId: profileId, + accountId: nuvioAccountId(), + provider: "nuvio", + }; + } const storageProfileId = activeProfileId(); try { const state = JSON.parse(localStorage.getItem(PROFILES_KEY) ?? "null") as { @@ -74,6 +126,7 @@ export function capturePlaybackActor(): PlaybackActor { profileId: state?.activeId ?? storageProfileId, storageProfileId, accountId: session?.user?._id ?? null, + ...(cwProfileScope().private ? { privateCw: true } : {}), }; } catch { return { profileId: storageProfileId, storageProfileId, accountId: null }; @@ -85,30 +138,14 @@ export function isPlaybackActorCurrent(actor: PlaybackActor): boolean { return ( current.profileId === actor.profileId && current.storageProfileId === actor.storageProfileId && - current.accountId === actor.accountId + current.accountId === actor.accountId && + current.provider === actor.provider && + !!current.privateCw === !!actor.privateCw ); } function activeProfileId(): string { - try { - const raw = localStorage.getItem(PROFILES_KEY); - if (!raw) return ""; - const s = JSON.parse(raw) as { - activeId?: string; - profiles?: Array<{ id?: string; isPrimary?: boolean; shareStremioWith?: string | null }>; - }; - const profiles = Array.isArray(s.profiles) ? s.profiles : []; - const active = profiles.find((p) => p.id === s.activeId) ?? null; - const own = active?.id ?? profiles.find((p) => p?.isPrimary)?.id ?? ""; - if (!own) return ""; - if (active && typeof active.shareStremioWith === "string" && active.shareStremioWith) { - const shared = profiles.find((p) => p.id === active.shareStremioWith); - if (shared?.id) return shared.id; - } - return own; - } catch { - return ""; - } + return stremioProfileId(); } function primaryProfileId(): string { @@ -125,11 +162,15 @@ function primaryProfileId(): string { } function storeKey(): string { + if (isNuvioProviderActive()) { + return `${STORAGE_KEY_PREFIX}nuvio.${encodeURIComponent(nuvioAccountId() ?? "anon")}.${activeScopedProfileId()}`; + } const id = activeProfileId(); return id ? STORAGE_KEY_PREFIX + id : LEGACY_STORAGE_KEY; } function migrateLegacy(): void { + if (isNuvioProviderActive()) return; try { const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); if (!legacy) return; @@ -143,13 +184,109 @@ function migrateLegacy(): void { } } +const scopeEvents = [ + "storage", + "harbor:active-profile-changed", + "harbor:profiles-updated", + "harbor:settings-changed", + "harbor:nuvio-profile-changed", + "harbor:nuvio-state-changed", + "harbor:account-provider-changed", +]; +let stopScopeEvents: (() => void) | null = null; + export function subscribePlayback(fn: () => void): () => void { + if (!stopScopeEvents && typeof window !== "undefined") { + const target = window; + let actorKey = JSON.stringify(capturePlaybackActor()); + const onScopeChange = () => { + const next = JSON.stringify(capturePlaybackActor()); + if (next === actorKey) return; + actorKey = next; + notifyPlayback(); + }; + for (const event of scopeEvents) target.addEventListener(event, onScopeChange); + stopScopeEvents = () => { + for (const event of scopeEvents) target.removeEventListener(event, onScopeChange); + }; + } listeners.add(fn); return () => { listeners.delete(fn); + if (listeners.size === 0) { + stopScopeEvents?.(); + stopScopeEvents = null; + } }; } +function samePlaybackOwner(actual: ActualPlayback, actor: PlaybackActor): boolean { + return ( + validActualPlayback(actual) && + Date.now() - actual.playedAt <= TTL_MS && + actual.actor.provider === actor.provider && + actual.actor.storageProfileId === actor.storageProfileId && + actual.actor.accountId === actor.accountId + ); +} + +function retainedActualProfiles( + entry: PlaybackEntry | undefined, + actor: PlaybackActor, +): Record { + let profileIds = new Set([actor.profileId]); + if (!actor.provider) { + try { + const roster = JSON.parse(localStorage.getItem(PROFILES_KEY) ?? "null") as { + profiles?: Array<{ id: string }>; + } | null; + profileIds = new Set([ + actor.profileId, + ...(roster?.profiles ?? []).map((profile) => profile.id), + ]); + } catch { + // Keep existing slots if the roster is temporarily unavailable. + profileIds = new Set([actor.profileId, ...Object.keys(entry?.actualByProfile ?? {})]); + } + } + return Object.fromEntries( + Object.entries(entry?.actualByProfile ?? {}).filter( + ([profileId, state]) => + profileIds.has(profileId) && + !!state?.actual && + validActualPlayback(state.actual) && + state.actual.actor.profileId === profileId && + Date.now() - state.actual.playedAt <= TTL_MS, + ), + ); +} + +function ownActualState( + entry: PlaybackEntry | undefined, + actor: PlaybackActor, +): ActualPlaybackState { + const own = entry?.actualByProfile?.[actor.profileId]; + if ( + own?.actual && + own.actual.actor?.profileId === actor.profileId && + samePlaybackOwner(own.actual, actor) + ) + return { actual: own.actual }; + // Old shared entries establish private ownership only through the observed actor. + return entry?.actual?.actor?.profileId === actor.profileId && + samePlaybackOwner(entry.actual, actor) + ? { actual: entry.actual } + : {}; +} + +function selectedActualState( + entry: PlaybackEntry | undefined, + actor: PlaybackActor, +): ActualPlaybackState { + const state = actor.privateCw ? ownActualState(entry, actor) : entry; + return state?.actual && samePlaybackOwner(state.actual, actor) ? { actual: state.actual } : {}; +} + function entryKey(metaId: string, season?: number, episode?: number): string { if (typeof season === "number" && typeof episode === "number") { return `${metaId}|s${season}e${episode}`; @@ -188,8 +325,7 @@ function writeAll(map: Record): void { // A failed replacement must leave the last persisted source/session intact. return; } - noteNotify("store:playback"); - listeners.forEach((l) => l()); + notifyPlayback(); } export function playbackHistoryRawKeys(): string[] { @@ -222,7 +358,12 @@ export function savePlayback( parsedTitle: entry.parsedTitle ?? prev.parsedTitle, savedAt: Date.now(), } - : { ...entry, actual: prev?.actual, savedAt: Date.now() }; + : { + ...entry, + actual: prev?.actual, + actualByProfile: prev?.actualByProfile, + savedAt: Date.now(), + }; writeAll(all); } @@ -235,26 +376,46 @@ export function recordActualPlayback(playback: ActualPlayback): boolean { playback.src.episode?.episode, ); const previous = all[key]; - const previousActual = previous?.actual; + const previousOwn = ownActualState(previous, playback.actor); + const previousActual = previousOwn.actual; // mpv can clear its track list at natural EOF. That is not a new audio // choice; keep only the last observed choice from this exact session. - const actual = + const sameEndedSession = playback.completed && - !playback.audioTrack && - previousActual?.audioTrack && + previousActual && validActualPlayback(previousActual) && previousActual.id === playback.id && - isPlaybackActorCurrent(previousActual.actor) && - playbackSourceKey(previousActual.src) === playbackSourceKey(playback.src) - ? { ...playback, audioTrack: previousActual.audioTrack } - : playback; + playbackSourceKey(previousActual.src) === playbackSourceKey(playback.src); + const actual = sameEndedSession + ? { + ...playback, + audioTrack: playback.audioTrack ?? previousActual.audioTrack, + subtitle: playback.subtitle ?? previousActual.subtitle, + subDelaySec: playback.subDelaySec ?? previousActual.subDelaySec, + audioDelaySec: playback.audioDelaySec ?? previousActual.audioDelaySec, + } + : playback; + const shared = previous?.actual; + const updateShared = + !shared || !samePlaybackOwner(shared, playback.actor) || shared.playedAt <= actual.playedAt; + const updateOwn = !previousActual || previousActual.playedAt <= actual.playedAt; const next = { ...all, [key]: { ...previous, title: previous?.title ?? playback.src.meta.name, savedAt: Math.max(previous?.savedAt ?? 0, playback.playedAt), - actual, + actual: updateShared ? actual : shared, + actualByProfile: { + ...retainedActualProfiles(previous, playback.actor), + ...(updateOwn + ? { + [playback.actor.profileId]: { + actual, + }, + } + : {}), + }, }, }; const entries = Object.entries(next).sort((a, b) => b[1].savedAt - a[1].savedAt); @@ -266,8 +427,7 @@ export function recordActualPlayback(playback: ActualPlayback): boolean { } catch { return false; } - noteNotify("store:playback"); - listeners.forEach((listener) => listener()); + notifyPlayback(); return true; } @@ -293,19 +453,14 @@ function validActualPlayback(value: ActualPlayback): boolean { export function readLastActualPlayback(): ActualPlayback | null { const actor = capturePlaybackActor(); - let last: ActualPlayback | null = null; + let last: ActualPlaybackState | null = null; for (const entry of Object.values(readAll())) { - const actual = entry.actual; - if ( - !actual || - !validActualPlayback(actual) || - actual.actor.storageProfileId !== actor.storageProfileId || - actual.actor.accountId !== actor.accountId - ) - continue; - if (!last || actual.playedAt > last.playedAt) last = actual; + const state = selectedActualState(entry, actor); + const actual = state.actual; + if (!actual || Date.now() - actual.playedAt > TTL_MS) continue; + if (!last || actual.playedAt > last.actual!.playedAt) last = state; } - return last; + return last?.actual ?? null; } export function playbackSourceKey( @@ -319,15 +474,93 @@ export function playbackSourceKey( } export function readActualPlaybackFor(src: PlayerSrc): ActualPlayback | null { - const entry = readAll()[entryKey(src.meta.id, src.episode?.season, src.episode?.episode)]?.actual; const actor = capturePlaybackActor(); - return entry && - validActualPlayback(entry) && - entry.actor.storageProfileId === actor.storageProfileId && - entry.actor.accountId === actor.accountId && - playbackSourceKey(entry.src) === playbackSourceKey(src) - ? entry - : null; + const entry = selectedActualState( + readAll()[entryKey(src.meta.id, src.episode?.season, src.episode?.episode)], + actor, + ).actual; + return entry && playbackSourceKey(entry.src) === playbackSourceKey(src) ? entry : null; +} + +/** Capture the observed session before an asynchronous subtitle cache write starts. */ +export function readActualPlaybackForSubtitle( + mediaKey: string, + streamKey: string | undefined, + notBefore: number, +): ActualPlayback | null { + const actor = capturePlaybackActor(); + return ( + Object.values(readAll()) + .map((entry) => ownActualState(entry, actor).actual) + .find( + (actual) => + actual && + validActualPlayback(actual) && + actual.actor.profileId === actor.profileId && + actual.actor.storageProfileId === actor.storageProfileId && + actual.actor.accountId === actor.accountId && + actual.playedAt >= notBefore && + subtitleMediaKey( + actual.src.meta.id, + actual.src.episode?.season, + actual.src.episode?.episode, + ) === mediaKey && + subtitleStreamKey(actual.src.streamRef) === streamKey, + ) ?? null + ); +} + +/** Cache completion may improve a source pointer, never playback recency or a newer session. */ +export function cacheActualPlaybackSubtitle( + target: ActualPlayback, + original: PlaybackSubtitleChoice, + cached: PlaybackSubtitleChoice, +): boolean { + if ( + !isPlaybackActorCurrent(target.actor) || + !cached.imported || + !cached.source || + /^(https?|blob|data|memory):/i.test(cached.source) + ) + return false; + const all = readAll(); + const key = entryKey(target.src.meta.id, target.src.episode?.season, target.src.episode?.episode); + const entry = all[key]; + const own = ownActualState(entry, target.actor); + const actual = own.actual; + if ( + !entry || + !actual || + !validActualPlayback(actual) || + actual.id !== target.id || + playbackSourceKey(actual.src) !== playbackSourceKey(target.src) || + !actual.subtitle || + actual.subtitle.off || + actual.subtitle.source !== original.source || + actual.subtitle.streamKey !== original.streamKey || + actual.subtitle.subId !== original.subId || + actual.subtitle.provider !== original.provider + ) + return false; + const patched = { + ...actual, + subtitle: { ...actual.subtitle, ...cached, fpsOverride: actual.subtitle.fpsOverride }, + }; + all[key] = { + ...entry, + ...(entry.actual?.id === actual.id ? { actual: patched } : {}), + actualByProfile: { + ...entry.actualByProfile, + [target.actor.profileId]: { ...own, actual: patched }, + }, + }; + try { + localStorage.setItem(storeKey(), JSON.stringify(all)); + } catch { + return false; + } + notifyPlayback(); + return true; } export function readPlayback( @@ -343,7 +576,13 @@ export function clearPlayback(metaId: string, season?: number, episode?: number) const all = readAll(); const key = entryKey(metaId, season, episode); const actual = all[key]?.actual; - if (actual) all[key] = { savedAt: actual.playedAt, title: actual.src.meta.name, actual }; + if (actual || all[key]?.actualByProfile) + all[key] = { + savedAt: all[key].savedAt, + title: actual?.src.meta.name ?? all[key].title, + actual, + actualByProfile: all[key].actualByProfile, + }; else delete all[key]; writeAll(all); } @@ -368,13 +607,15 @@ export function streamOfferedByAddon( * addon. Debrid library copies and aggregator duplicates share the torrent hash, * so a plain hash match would otherwise select whichever row came first. */ -export function findPreferredSourceMatch; - resolution?: string | null; - releaseGroupNormalized?: string | null; - audioLanguages?: string[] | null; -}>( +export function findPreferredSourceMatch< + T extends { + addonId?: string | null; + contributors?: Array<{ id: string }>; + resolution?: string | null; + releaseGroupNormalized?: string | null; + audioLanguages?: string[] | null; + }, +>( streams: T[], entry: PlaybackEntry, match: (stream: T, entry: PlaybackEntry) => boolean, diff --git a/src/lib/player/actual-playback.ts b/src/lib/player/actual-playback.ts index b30366915..64ea12f2f 100644 --- a/src/lib/player/actual-playback.ts +++ b/src/lib/player/actual-playback.ts @@ -4,6 +4,12 @@ import type { PlayerSnapshot } from "./bridge"; import { isLivePlaybackSrc } from "./live-src"; import { isNaturalEnd } from "./playback-end"; import { playbackAudioChoice } from "./audio-choice"; +import { playbackSubtitleChoice, type PlaybackSubtitleChoice } from "./subtitle-choice"; +import { + subtitleMediaKey, + subtitleStreamKey, + type RememberedSub, +} from "../subtitles/subtitle-memory"; export function actualPlaybackSnapshot( src: PlayerSrc, @@ -11,6 +17,7 @@ export function actualPlaybackSnapshot( positionSec: number, session: { id: string; actor: PlaybackActor }, at = Date.now(), + rememberedSubtitle?: RememberedSub | null, ): ActualPlayback | null { if ( isLivePlaybackSrc(src) || @@ -33,10 +40,32 @@ export function actualPlaybackSnapshot( startPositionMs: _start, startPaused: _paused, startFromZero: _zero, + subtitlePreselect: _initialSubtitle, ...saved } = src; const { videos: _videos, description: _description, ...meta } = src.meta; const selectedAudio = snapshot.audioTracks.find((track) => track.selected); + const selectedSubtitle = snapshot.subtitleTracks.find((track) => track.selected); + const tracksClearedAtEnd = + snapshot.status === "ended" && + snapshot.audioTracks.length === 0 && + snapshot.subtitleTracks.length === 0; + const subtitle: PlaybackSubtitleChoice | undefined = selectedSubtitle + ? playbackSubtitleChoice(selectedSubtitle, rememberedSubtitle, { + mediaKey: subtitleMediaKey(src.meta.id, src.episode?.season, src.episode?.episode), + streamKey: subtitleStreamKey(src.streamRef), + }) + : snapshot.status === "ended" && snapshot.subtitleTracks.length === 0 + ? undefined + : { off: true }; + if (subtitle?.source) subtitle.streamKey = subtitleStreamKey(src.streamRef); + if ( + subtitle && + selectedSubtitle && + Number.isFinite(snapshot.subtitleFps) && + (snapshot.subtitleFps === 0 || (snapshot.subtitleFps! >= 1 && snapshot.subtitleFps! <= 240)) + ) + subtitle.fpsOverride = snapshot.subtitleFps; return { ...session, playedAt: at, @@ -48,5 +77,14 @@ export function actualPlaybackSnapshot( src: { ...saved, meta, url: src.historyUrl ?? src.url }, requiresSourceRefresh: !!proxySessionId || !!src.streamRef?.infoHash || !!src.homeServer, audioTrack: selectedAudio ? playbackAudioChoice(selectedAudio) : undefined, + subtitle, + subDelaySec: + !tracksClearedAtEnd && Number.isFinite(snapshot.subDelaySec) + ? snapshot.subDelaySec + : undefined, + audioDelaySec: + !tracksClearedAtEnd && Number.isFinite(snapshot.audioDelaySec) + ? snapshot.audioDelaySec + : undefined, }; } diff --git a/src/lib/player/bridge.ts b/src/lib/player/bridge.ts index 65f35da74..af7aa7289 100644 --- a/src/lib/player/bridge.ts +++ b/src/lib/player/bridge.ts @@ -74,6 +74,8 @@ export type PlayerSnapshot = { chapters: Chapter[]; subDelaySec: number; audioDelaySec: number; + /** Observed native correction; undefined when the engine does not expose it. Zero means no correction. */ + subtitleFps?: number; subText: string; subStartSec: number; secondarySubText: string; diff --git a/src/lib/player/continue-playback.ts b/src/lib/player/continue-playback.ts index 2158aa8dd..dc2c79644 100644 --- a/src/lib/player/continue-playback.ts +++ b/src/lib/player/continue-playback.ts @@ -37,6 +37,37 @@ export function continuationStream 0 && + source.size === saved.size + ); +} + export function continuationFor( target: ActualPlayback, actor: PlaybackActor, @@ -48,7 +79,15 @@ export function continuationFor( restart, positionMs: restart ? 0 : target.positionMs, sourceKey: playbackSourceKey(target.src), + target: { + metaId: target.src.meta.id, + season: target.src.episode?.season, + episode: target.src.episode?.episode, + }, audioTrack: target.audioTrack, + subtitle: target.subtitle, + subDelaySec: target.subDelaySec, + audioDelaySec: target.audioDelaySec, source: { ...target.src.streamRef, url: target.src.historyUrl ?? target.src.url, diff --git a/src/lib/player/mpv-properties.ts b/src/lib/player/mpv-properties.ts index 12c40ccaa..0dc50989c 100644 --- a/src/lib/player/mpv-properties.ts +++ b/src/lib/player/mpv-properties.ts @@ -81,8 +81,9 @@ async function waitForMpvSubtitleFpsTransitions(): Promise { export async function writeMpvSubtitleFps( choice: SubtitleFpsChoice, generation: number, + isCurrent: () => boolean = () => true, ): Promise { - await coordinator.apply(choice, () => generation === subtitleFpsGeneration); + await coordinator.apply(choice, () => generation === subtitleFpsGeneration && isCurrent()); } export async function resetMpvSubtitleFpsForTransition( diff --git a/src/lib/player/mpv.ts b/src/lib/player/mpv.ts index d7b16cb16..f70433074 100644 --- a/src/lib/player/mpv.ts +++ b/src/lib/player/mpv.ts @@ -970,6 +970,8 @@ export function createMpvBridge(mpvOptions?: MpvOptions): PlayerBridge { } if (name === "sub-delay" && typeof data === "number") snap.subDelaySec = data; if (name === "audio-delay" && typeof data === "number") snap.audioDelaySec = data; + if (name === "sub-fps" && typeof data === "number" && Number.isFinite(data)) + snap.subtitleFps = data; if (name === "sub-text") { snap.subText = !primarySubtitleOff && typeof data === "string" ? data : ""; } @@ -1172,6 +1174,7 @@ export function createMpvBridge(mpvOptions?: MpvOptions): PlayerBridge { snap.errorMessage = null; snap.audioTracks = []; snap.subtitleTracks = []; + snap.subtitleFps = undefined; primarySubtitleOff = false; snap.subText = ""; snap.subStartSec = 0; diff --git a/src/lib/player/subtitle-choice.ts b/src/lib/player/subtitle-choice.ts new file mode 100644 index 000000000..b3051fb46 --- /dev/null +++ b/src/lib/player/subtitle-choice.ts @@ -0,0 +1,149 @@ +import type { TrackInfo } from "./bridge"; +import { + lookupSubtitleOrigin, + rememberedFromChoice, + type RememberedSub, +} from "../subtitles/subtitle-memory"; +import { normalizeLang } from "../subtitles/language"; +import { selectedSubtitleCacheName } from "../subtitles/selected-subtitle-cache"; + +export type PlaybackSubtitleChoice = Omit & { + external?: boolean; + codec?: string; + forced?: boolean; + hearingImpaired?: boolean; + fpsOverride?: number; +}; + +function durableSource(source?: string): string | undefined { + const original = source && (lookupSubtitleOrigin(source) ?? source); + return original && !/^(blob|data|memory):/i.test(original) ? original : undefined; +} + +function sameSource(source: string | undefined, saved: string): boolean { + return durableSource(source)?.replace(/\\/g, "/") === durableSource(saved)?.replace(/\\/g, "/"); +} + +type SubtitleCacheContext = { mediaKey: string; streamKey?: string }; + +function matchesCachedSubtitleOrigin( + choice: Pick, + source: string | undefined, + remembered?: RememberedSub | null, + context?: SubtitleCacheContext, +): boolean { + const format = /\.(srt|vtt|ass|ssa)$/i.exec(remembered?.source ?? "")?.[1]?.toLowerCase() as + | "srt" + | "vtt" + | "ass" + | "ssa" + | undefined; + return ( + !!context && + !!remembered?.imported && + !remembered.off && + !!source && + !!format && + !/^(https?|blob|data|memory|asset):/i.test(remembered.source ?? "") && + remembered.streamKey === context.streamKey && + remembered.source?.split(/[\\/]/).pop() === + selectedSubtitleCacheName(context.mediaKey, context.streamKey, { ...choice, source }, format) + ); +} + +/** A late cache may finish after the actual session was flushed on exit. */ +export function restorablePlaybackSubtitleChoice( + saved: PlaybackSubtitleChoice, + remembered: RememberedSub | null, + context: SubtitleCacheContext, +): PlaybackSubtitleChoice { + if ( + !remembered || + saved.off || + !saved.external || + saved.streamKey !== context.streamKey || + !matchesCachedSubtitleOrigin(saved, durableSource(saved.source), remembered, context) + ) + return saved; + const { updatedAt: _cacheTime, ...cached } = remembered; + return { ...saved, ...cached, external: true, fpsOverride: saved.fpsOverride }; +} + +export function playbackSubtitleChoice( + track: TrackInfo, + remembered?: RememberedSub | null, + context?: SubtitleCacheContext, +): PlaybackSubtitleChoice { + const choice = rememberedFromChoice({ + ...track, + title: track.title ?? track.label, + source: + durableSource(track.originalUrl) ?? + durableSource(track.externalFilename) ?? + durableSource(track.url), + }); + // A prepared blob is never a persistent subtitle source, even when no origin is known. + choice.source = durableSource(choice.source); + const matchesCachedOrigin = matchesCachedSubtitleOrigin( + track, + choice.source, + remembered, + context, + ); + const sameRemembered = + remembered && + !remembered.off && + (matchesCachedOrigin || + (remembered.source && + [track.originalUrl, track.externalFilename, track.url].some((source) => + sameSource(source, remembered.source!), + )) || + (remembered.subId && + remembered.subId === track.subId && + remembered.provider === track.provider && + (!remembered.lang || normalizeLang(remembered.lang) === normalizeLang(track.lang ?? ""))) || + (!track.external && + remembered.trackId === track.id && + normalizeLang(remembered.lang ?? "") === normalizeLang(track.lang ?? "") && + remembered.title === (track.title ?? track.label))); + return { + ...(sameRemembered ? { ...remembered, updatedAt: undefined } : choice), + source: (sameRemembered ? durableSource(remembered?.source) : undefined) ?? choice.source, + external: track.external || undefined, + codec: track.codec, + forced: track.forced, + hearingImpaired: track.hearingImpaired, + }; +} + +export function selectPlaybackSubtitle( + tracks: TrackInfo[], + choice: PlaybackSubtitleChoice, +): TrackInfo | null { + if (choice.off) return null; + const norm = (value?: string) => (value ?? "").trim().toLowerCase(); + const matches = tracks.filter((track) => { + if (choice.external) { + return ( + !!track.external && + ((!!choice.source && + [track.originalUrl, track.url, track.externalFilename].some((source) => + sameSource(source, choice.source!), + )) || + (!!choice.subId && + choice.subId === track.subId && + choice.provider === track.provider && + (!choice.lang || normalizeLang(choice.lang) === normalizeLang(track.lang ?? "")))) + ); + } + return ( + !track.external && + normalizeLang(track.lang ?? "") === normalizeLang(choice.lang ?? "") && + norm(track.title ?? track.label) === norm(choice.title) && + (!choice.codec || norm(track.codec) === norm(choice.codec)) && + (choice.forced === undefined || !!track.forced === choice.forced) && + (choice.hearingImpaired === undefined || !!track.hearingImpaired === choice.hearingImpaired) + ); + }); + return matches.length === 1 ? matches[0] : null; +} diff --git a/src/lib/subtitles/subtitle-memory.ts b/src/lib/subtitles/subtitle-memory.ts index 49d89f777..14ec1c348 100644 --- a/src/lib/subtitles/subtitle-memory.ts +++ b/src/lib/subtitles/subtitle-memory.ts @@ -89,7 +89,7 @@ export function subtitleStreamKey( } export function rememberedSubAppliesToStream( - remembered: RememberedSub | null | undefined, + remembered: Omit | null | undefined, streamRef: PlayerStreamRef | null | undefined, ): boolean { if (!remembered) return false; @@ -202,8 +202,8 @@ export function rememberedChoiceFromLoad( } export function rememberedSubtitleIsLocal( - remembered: RememberedSub | null | undefined, -): remembered is RememberedSub & { source: string } { + remembered: Omit | null | undefined, +): remembered is Omit & { source: string } { if (remembered?.imported !== true || !remembered.source) return false; return !/^(https?|blob|data):/i.test(remembered.source); } @@ -213,7 +213,7 @@ export function subtitleSourceIsLocal(source: string | null | undefined): boolea } export function rememberedSubtitleLoadMetadata( - remembered: RememberedSub, + remembered: Omit, downloadAuth?: SubtitleLoadMetadata["downloadAuth"], ): SubtitleLoadMetadata { return { @@ -227,8 +227,8 @@ export function rememberedSubtitleLoadMetadata( providerDerived: remembered.localTrusted === true ? false - : remembered.providerDerived ?? - (rememberedSubtitleIsLocal(remembered) ? false : Boolean(remembered.provider)), + : (remembered.providerDerived ?? + (rememberedSubtitleIsLocal(remembered) ? false : Boolean(remembered.provider))), subId: remembered.subId, matchScore: remembered.matchScore, matchConfidence: remembered.matchConfidence, diff --git a/src/views/player/hooks/use-actual-playback.ts b/src/views/player/hooks/use-actual-playback.ts index 5070cd675..3d7e33aa3 100644 --- a/src/views/player/hooks/use-actual-playback.ts +++ b/src/views/player/hooks/use-actual-playback.ts @@ -9,6 +9,7 @@ import { actualPlaybackSnapshot } from "@/lib/player/actual-playback"; import { getPlaybackPosition, subscribePlaybackClock } from "@/lib/player/playback-clock"; import type { PlayerSnapshot } from "@/lib/player/bridge"; import type { PlayerSrc } from "@/lib/view"; +import { readRememberedSub, subtitleMediaKey } from "@/lib/subtitles/subtitle-memory"; /** The player, not a menu or a metadata write, owns actual playback recency. */ export function useActualPlayback(src: PlayerSrc, snap: PlayerSnapshot): void { @@ -22,9 +23,25 @@ export function useActualPlayback(src: PlayerSrc, snap: PlayerSnapshot): void { useEffect(() => { const session = { id: crypto.randomUUID(), actor: capturePlaybackActor() }; let last: ActualPlayback | null = null; + let observedPlaying = false; let lastSavedAt = 0; let previousPosition = getPlaybackPosition(); + const rememberedSubtitle = () => + readRememberedSub(subtitleMediaKey(src.meta.id, src.episode?.season, src.episode?.episode)); const save = () => { + const actor = capturePlaybackActor(); + if ( + last && + !!actor.privateCw !== !!session.actor.privateCw && + actor.profileId === session.actor.profileId && + actor.storageProfileId === session.actor.storageProfileId && + actor.accountId === session.actor.accountId && + actor.provider === session.actor.provider + ) { + // A privacy preference does not create another play or transfer its owner. + session.actor = actor; + last = { ...last, actor }; + } if (last && recordActualPlayback(last)) lastSavedAt = Date.now(); }; const saveCurrent = () => { @@ -37,6 +54,7 @@ export function useActualPlayback(src: PlayerSrc, snap: PlayerSnapshot): void { last.positionMs / 1000, session, last.playedAt, + rememberedSubtitle(), ) ?? last; } save(); @@ -45,31 +63,47 @@ export function useActualPlayback(src: PlayerSrc, snap: PlayerSnapshot): void { const observe = () => { const current = latest.current; if (current.key !== key || !loaded.current.armed) return; + if (current.snap.status === "playing" && current.snap.firstFrameReady) observedPlaying = true; + if (!observedPlaying) return; const position = getPlaybackPosition(); // Buffer/cache updates share the clock subscription. They are not plays. if (position === previousPosition) return; previousPosition = position; - const next = actualPlaybackSnapshot(current.src, current.snap, position, session); + const next = actualPlaybackSnapshot( + current.src, + current.snap, + position, + session, + Date.now(), + rememberedSubtitle(), + ); if (!next) return; last = next; if (!lastSavedAt || Date.now() - lastSavedAt >= 4000) save(); }; const unsubscribe = subscribePlaybackClock(observe); const onVisibility = () => { - if (document.visibilityState === "hidden") save(); + if (document.visibilityState === "hidden") saveCurrent(); }; - window.addEventListener("pagehide", save); - window.addEventListener("beforeunload", save); + window.addEventListener("pagehide", saveCurrent); + window.addEventListener("beforeunload", saveCurrent); document.addEventListener("visibilitychange", onVisibility); return () => { unsubscribe(); - window.removeEventListener("pagehide", save); - window.removeEventListener("beforeunload", save); + window.removeEventListener("pagehide", saveCurrent); + window.removeEventListener("beforeunload", saveCurrent); document.removeEventListener("visibilitychange", onVisibility); saveCurrent(); }; }, [key]); useEffect(() => { if (snap.status !== "playing") flush.current(); - }, [snap.status]); + }, [ + snap.status, + snap.subtitleTracks, + snap.audioTracks, + snap.subDelaySec, + snap.audioDelaySec, + snap.subtitleFps, + ]); } diff --git a/src/views/player/hooks/use-playback-controls.ts b/src/views/player/hooks/use-playback-controls.ts index 7d7dabdd8..259c595dc 100644 --- a/src/views/player/hooks/use-playback-controls.ts +++ b/src/views/player/hooks/use-playback-controls.ts @@ -14,6 +14,12 @@ import { hasImportedSubTitle } from "@/lib/player/imported-subs"; import type { RoomCommand } from "@/lib/together/protocol"; import { cacheSelectedSubtitle } from "@/lib/subtitles/selected-subtitle-cache"; import { notifyMediaSeeked } from "@/lib/media-session"; +import { + capturePlaybackActor, + isPlaybackActorCurrent, + readActualPlaybackForSubtitle, + cacheActualPlaybackSubtitle, +} from "@/lib/playback-history"; const SEEK_ACCUM_WINDOW_MS = 700; @@ -58,7 +64,12 @@ export function usePlaybackControls(params: { } = params; const pendingSubtitleCacheRef = useRef<(() => void) | null>(null); - const subtitleCacheContextRef = useRef({ mediaKey, subtitleStreamKey, revision: 0 }); + const subtitleCacheContextRef = useRef({ + mediaKey, + subtitleStreamKey, + revision: 0, + startedAt: Date.now(), + }); if ( subtitleCacheContextRef.current.mediaKey !== mediaKey || subtitleCacheContextRef.current.subtitleStreamKey !== subtitleStreamKey @@ -68,6 +79,7 @@ export function usePlaybackControls(params: { mediaKey, subtitleStreamKey, revision: subtitleCacheContextRef.current.revision + 1, + startedAt: Date.now(), }; } useEffect(() => () => pendingSubtitleCacheRef.current?.(), []); @@ -76,6 +88,7 @@ export function usePlaybackControls(params: { (choice: SubChoiceInput | null | undefined) => { pendingSubtitleCacheRef.current?.(); const revision = ++subtitleCacheContextRef.current.revision; + const actor = capturePlaybackActor(); if (choice) { writePlayerPrefs( metaId, @@ -127,6 +140,11 @@ export function usePlaybackControls(params: { // still be displaying the preceding selection when this arrives. const playableUrl = bridge.getSelectedTrackUrl(); const cues = bridge.getSelectedTrackCues(); + const actual = readActualPlaybackForSubtitle( + mediaKey, + subtitleStreamKey, + subtitleCacheContextRef.current.startedAt, + ); stopWaiting(); void cacheSelectedSubtitle({ mediaKey, @@ -137,11 +155,18 @@ export function usePlaybackControls(params: { }) .then((cached) => { // The copy may finish after the player was closed and reopened. - if (!cached || readRememberedSub(mediaKey) !== remembered) return; - writeRememberedSub( + if ( + !cached || + !isPlaybackActorCurrent(actor) || + readRememberedSub(mediaKey) !== remembered + ) + return; + const saved = writeRememberedSub( mediaKey, rememberedFromChoice({ ...cached, streamKey: subtitleStreamKey }), ); + if (actual && saved) + cacheActualPlaybackSubtitle(actual, remembered, { ...saved, external: true }); }) .catch(() => {}); }); diff --git a/src/views/player/hooks/use-player-bridge.ts b/src/views/player/hooks/use-player-bridge.ts index 9d1d60f45..ca2cfc735 100644 --- a/src/views/player/hooks/use-player-bridge.ts +++ b/src/views/player/hooks/use-player-bridge.ts @@ -36,6 +36,7 @@ function snapChangedIgnoringClock(a: PlayerSnapshot, b: PlayerSnapshot): boolean a.chapters !== b.chapters || a.subDelaySec !== b.subDelaySec || a.audioDelaySec !== b.audioDelaySec || + a.subtitleFps !== b.subtitleFps || a.subText !== b.subText || a.subStartSec !== b.subStartSec || a.secondarySubText !== b.secondarySubText || diff --git a/src/views/player/hooks/use-track-autoload.ts b/src/views/player/hooks/use-track-autoload.ts index ead3ba747..820d5e322 100644 --- a/src/views/player/hooks/use-track-autoload.ts +++ b/src/views/player/hooks/use-track-autoload.ts @@ -7,8 +7,21 @@ import { subtitleStreamDescriptor } from "@/lib/subtitles/provider-label"; import { publishSubtitleSearch } from "@/components/player/subtitle-menu/subtitle-search-store"; import { publishSubtitleContext } from "@/components/player/subtitle-menu/subtitle-context-store"; import { readPlayerPrefs, type PerShowPrefs } from "@/lib/player-prefs"; -import { playbackSourceKey, readActualPlaybackFor } from "@/lib/playback-history"; +import { + capturePlaybackActor, + isPlaybackActorCurrent, + playbackSourceKey, + readActualPlaybackFor, +} from "@/lib/playback-history"; import { playbackAudioChoice, selectPlaybackAudio } from "@/lib/player/audio-choice"; +import { continuationAppliesToSource } from "@/lib/player/continue-playback"; +import { + restorablePlaybackSubtitleChoice, + selectPlaybackSubtitle, +} from "@/lib/player/subtitle-choice"; +import { emitListToast } from "@/components/lists/list-toast"; +import { t } from "@/lib/i18n"; +import { getMpvSubtitleFpsGeneration, writeMpvSubtitleFps } from "@/lib/player/mpv-properties"; import { tmdbImdbId } from "@/lib/providers/tmdb"; import type { Addon } from "@/lib/addons"; import type { GeneratedSubtitleGroup } from "@/lib/subtitles/types"; @@ -38,6 +51,7 @@ import { rememberedSubtitleLoadMetadata, rememberedSubtitleIsLocal, subtitleMediaKey, + subtitleStreamKey, } from "@/lib/subtitles/subtitle-memory"; export function useTrackAutoload(params: { @@ -49,14 +63,28 @@ export function useTrackAutoload(params: { authKey: string | null; }) { const { bridgeRef, src, snap, engine, settings, authKey } = params; + const playbackActor = capturePlaybackActor(); + const actorKey = JSON.stringify(playbackActor); const sourceKey = playbackSourceKey(src); - const rememberedAudio = useMemo( - () => - src.continuation?.sourceKey === sourceKey - ? src.continuation.audioTrack - : readActualPlaybackFor(src)?.audioTrack, - [sourceKey, src.meta.id, src.episode?.season, src.episode?.episode, src.continuation], - ); + const rememberedSession = useMemo(() => { + const session = + src.continuation && continuationAppliesToSource(src, src.continuation) + ? isPlaybackActorCurrent(src.continuation.actor) + ? src.continuation + : null + : readActualPlaybackFor(src); + // A new picker choice replaces recalled subtitle intent, while audio and timing remain valid. + return src.subtitlePreselect && session ? { ...session, subtitle: undefined } : session; + }, [ + sourceKey, + src.meta.id, + src.episode?.season, + src.episode?.episode, + src.continuation, + src.subtitlePreselect, + actorKey, + ]); + const rememberedAudio = rememberedSession?.audioTrack; const snapRef = useRef(snap); snapRef.current = snap; const selectedSubtitleId = snap.subtitleTracks.find((track) => track.selected)?.id ?? null; @@ -262,7 +290,9 @@ export function useTrackAutoload(params: { autoSubStagesRef.current.add(stageKey); const runLease = autoSubRunRef.current.begin(key); const isActive = () => - autoSubLoadKeyRef.current === key && autoSubRunRef.current.isCurrent(runLease, key); + isPlaybackActorCurrent(playbackActor) && + autoSubLoadKeyRef.current === key && + autoSubRunRef.current.isCurrent(runLease, key); setInitialPreflight({ mediaUrl: src.url, settled: false }); void (async () => { const coords = await resolveAnimeSearchCoords({ @@ -369,6 +399,7 @@ export function useTrackAutoload(params: { isActive() && !keepEmbedded && !src.subtitlePreselect && + !rememberedSession?.subtitle && !subsOffFor(prefs, settings) && !rememberedSubAppliesToStream( readRememberedSub( @@ -499,7 +530,7 @@ export function useTrackAutoload(params: { const preselectAppliedRef = useRef(null); useEffect(() => { const choice = src.subtitlePreselect; - if (!choice) return; + if (!choice || rememberedSession?.subtitle) return; if (snap.audioTracks.length === 0 && snap.subtitleTracks.length === 0 && snap.durationSec === 0) return; if (preselectAppliedRef.current === src.url) return; @@ -523,6 +554,7 @@ export function useTrackAutoload(params: { snap.subtitleTracks, snap.durationSec, bridgeRef, + rememberedSession, ]); const subRestoreWaitRef = useRef<{ key: string; startedAt: number } | null>(null); const subRestoreLogRef = useRef(null); @@ -551,16 +583,26 @@ export function useTrackAutoload(params: { subRestoreTimerRef.current = null; } }; - }, [src.url]); + }, [src.url, actorKey]); useEffect(() => { - if (src.subtitlePreselect) return; - const remembered = readRememberedSub( - subtitleMediaKey(src.meta.id, src.episode?.season, src.episode?.episode), - ); + if (!isPlaybackActorCurrent(playbackActor)) return; + if (src.continuation && !isPlaybackActorCurrent(src.continuation.actor)) return; + if (src.subtitlePreselect && !rememberedSession?.subtitle) return; + const mediaKey = subtitleMediaKey(src.meta.id, src.episode?.season, src.episode?.episode); + const memory = readRememberedSub(mediaKey); + const remembered = rememberedSession?.subtitle + ? restorablePlaybackSubtitleChoice(rememberedSession.subtitle, memory, { + mediaKey, + streamKey: subtitleStreamKey(src.streamRef), + }) + : memory; if (!remembered) return; if (!rememberedSubAppliesToStream(remembered, src.streamRef)) return; const mediaReady = - snap.audioTracks.length > 0 || snap.subtitleTracks.length > 0 || snap.durationSec > 0; + snap.firstFrameReady || + snap.audioTracks.length > 0 || + snap.subtitleTracks.length > 0 || + snap.durationSec > 0; if (!mediaReady) return; const bridge = bridgeRef.current; if (!bridge) return; @@ -574,6 +616,18 @@ export function useTrackAutoload(params: { return; } + if (rememberedSession?.subtitle?.external && !remembered.source) { + const track = selectPlaybackSubtitle(snap.subtitleTracks, rememberedSession.subtitle); + if (track) { + if (!track.selected) bridge.setSubtitleTrack(track.id, "restore"); + autoSubIdRef.current = track.id; + } else if (snap.firstFrameReady && subRestoreLogRef.current !== "unavailable-subtitle") { + subRestoreLogRef.current = "unavailable-subtitle"; + emitListToast(t("The saved subtitle could not be restored. Choose a subtitle."), "error"); + } + return; + } + if (remembered.source) { const source = remembered.source; const restoreKey = [ @@ -705,7 +759,12 @@ export function useTrackAutoload(params: { remembered.downloadAuthKind, rememberedApiKey, ); - if (subRestoreAddRef.current?.key !== restoreKey) return false; + if ( + !isPlaybackActorCurrent(playbackActor) || + bridgeRef.current !== bridge || + subRestoreAddRef.current?.key !== restoreKey + ) + return false; return bridge.addSubtitle( source, remembered.lang, @@ -714,37 +773,74 @@ export function useTrackAutoload(params: { rememberedSubtitleLoadMetadata(remembered, downloadAuth), "restore", ); - })().then((ok) => { - if (subRestoreAddRef.current?.key !== restoreKey) return; - subRestoreAddRef.current = { key: restoreKey, pending: false }; - if (!ok) return; - if (remembered.imported && remembered.title) markImportedSub(remembered.title); - else markAddedSub(source); - setSubRestoreTick((tick) => tick + 1); - }); + })() + .then((ok) => { + if ( + !isPlaybackActorCurrent(playbackActor) || + bridgeRef.current !== bridge || + subRestoreAddRef.current?.key !== restoreKey + ) + return; + subRestoreAddRef.current = { key: restoreKey, pending: false }; + if (!ok) { + if (rememberedSession?.subtitle) + emitListToast( + t("The saved subtitle could not be restored. Choose a subtitle."), + "error", + ); + return; + } + if (remembered.imported && remembered.title) markImportedSub(remembered.title); + else markAddedSub(source); + setSubRestoreTick((tick) => tick + 1); + }) + .catch(() => { + if ( + !isPlaybackActorCurrent(playbackActor) || + bridgeRef.current !== bridge || + subRestoreAddRef.current?.key !== restoreKey + ) + return; + subRestoreAddRef.current = { key: restoreKey, pending: false }; + if (rememberedSession?.subtitle) + emitListToast( + t("The saved subtitle could not be restored. Choose a subtitle."), + "error", + ); + }); return; } - if (snap.subtitleTracks.length === 0) return; + if (snap.subtitleTracks.length === 0 && !snap.firstFrameReady) return; if (embeddedRestoreRef.current === src.url) return; - embeddedRestoreRef.current = src.url; const byTrackId = remembered.trackId ? snap.subtitleTracks.find( (t) => !t.external && t.id === remembered.trackId && sameLang(t.lang, remembered.lang), ) : undefined; - const want = - byTrackId ?? - snap.subtitleTracks.find( - (t) => - !t.external && - sameLang(t.lang, remembered.lang) && - (!remembered.title || t.title === remembered.title), - ) ?? - snap.subtitleTracks.find((t) => !t.external && sameLang(t.lang, remembered.lang)); + const want = rememberedSession?.subtitle + ? selectPlaybackSubtitle(snap.subtitleTracks, rememberedSession.subtitle) + : (byTrackId ?? + snap.subtitleTracks.find( + (t) => + !t.external && + sameLang(t.lang, remembered.lang) && + (!remembered.title || t.title === remembered.title), + ) ?? + snap.subtitleTracks.find((t) => !t.external && sameLang(t.lang, remembered.lang))); if (want) { + embeddedRestoreRef.current = src.url; if (!want.selected) bridge.setSubtitleTrack(want.id, "restore"); autoSubIdRef.current = want.id; + } else if ( + rememberedSession?.subtitle && + snap.firstFrameReady && + subRestoreLogRef.current !== "unavailable-embedded-subtitle" + ) { + subRestoreLogRef.current = "unavailable-embedded-subtitle"; + if (snap.subtitleTracks.some((track) => track.selected)) + bridge.setSubtitleTrack(null, "restore"); + emitListToast(t("The saved subtitle could not be restored. Choose a subtitle."), "error"); } }, [ src.url, @@ -756,8 +852,11 @@ export function useTrackAutoload(params: { snap.durationSec, bridgeRef, subRestoreTick, + snap.firstFrameReady, settings.subsourceApiKey, settings.subdlApiKey, + rememberedSession, + actorKey, ]); useEffect(() => { if (engine !== "mpv") return; @@ -835,7 +934,9 @@ export function useTrackAutoload(params: { } } } - const subsOff = subsOffFor(prefs, settings); + const subsOff = rememberedSession?.subtitle + ? rememberedSession.subtitle.off === true + : subsOffFor(prefs, settings); if (subsOff) { if (snap.subtitleTracks.some((t) => t.selected)) bridgeRef.current?.setSubtitleTrack(null, "automatic"); @@ -846,6 +947,7 @@ export function useTrackAutoload(params: { src.streamRef, ) && !src.subtitlePreselect && + !rememberedSession?.subtitle && snap.subtitleTracks.length > 0 && subLangs.length > 0 ) { @@ -894,17 +996,115 @@ export function useTrackAutoload(params: { snap.rate, settings, rememberedAudio, + rememberedSession, + ]); + + const audioRestoreWarningRef = useRef(null); + useEffect(() => { + if (!rememberedAudio || !snap.firstFrameReady) return; + const current = snap.audioTracks.find((track) => track.selected); + if (current && autoAudioIdRef.current && current.id !== autoAudioIdRef.current) return; + if (selectPlaybackAudio(snap.audioTracks, rememberedAudio)) return; + if (audioRestoreWarningRef.current === src.url) return; + audioRestoreWarningRef.current = src.url; + emitListToast( + t("The saved audio track could not be restored. Choose an audio track."), + "error", + ); + }, [src.url, snap.firstFrameReady, snap.audioTracks, rememberedAudio]); + + const delaysAppliedRef = useRef<{ url: string; bridge: PlayerBridge } | null>(null); + useEffect(() => { + if (snap.status === "idle" || snap.status === "loading") { + delaysAppliedRef.current = null; + return; + } + if (src.continuation && !isPlaybackActorCurrent(src.continuation.actor)) return; + if (snap.durationSec === 0 && snap.audioTracks.length === 0 && snap.subtitleTracks.length === 0) + return; + const bridge = bridgeRef.current; + if ( + !bridge || + (delaysAppliedRef.current?.url === src.url && delaysAppliedRef.current.bridge === bridge) + ) + return; + delaysAppliedRef.current = { url: src.url, bridge }; + bridge.setSubDelay( + rememberedSession?.subDelaySec ?? readPlayerPrefs(src.meta.id)?.subDelaySec ?? 0, + ); + if (rememberedSession?.audioDelaySec != null) + bridge.setAudioDelay(rememberedSession.audioDelaySec); + }, [ + src.url, + src.meta.id, + src.continuation, + snap.status, + snap.durationSec, + snap.audioTracks.length, + snap.subtitleTracks.length, + rememberedSession, + bridgeRef, ]); + const subtitleFpsAppliedRef = useRef(null); useEffect(() => { - bridgeRef.current?.setSubDelay(readPlayerPrefs(src.meta.id)?.subDelaySec ?? 0); - }, [src.url, src.meta.id]); + if (snap.status === "loading" || snap.status === "idle") { + subtitleFpsAppliedRef.current = null; + return; + } + const saved = rememberedSession?.subtitle; + const mediaKey = subtitleMediaKey(src.meta.id, src.episode?.season, src.episode?.episode); + const choice = + saved && + restorablePlaybackSubtitleChoice(saved, readRememberedSub(mediaKey), { + mediaKey, + streamKey: subtitleStreamKey(src.streamRef), + }); + if (engine !== "mpv" || choice?.fpsOverride == null || choice.off) return; + if ( + !Number.isFinite(choice.fpsOverride) || + (choice.fpsOverride !== 0 && (choice.fpsOverride < 1 || choice.fpsOverride > 240)) + ) + return; + const selected = snap.subtitleTracks.find((track) => track.selected); + if (!selected || selectPlaybackSubtitle(snap.subtitleTracks, choice)?.id !== selected.id) + return; + const bridge = bridgeRef.current; + if (!bridge || bridge.canAutoSelectSubtitle?.() === false) return; + const key = `${src.url}|${selected.id}|${choice.fpsOverride}`; + if (subtitleFpsAppliedRef.current === key) return; + subtitleFpsAppliedRef.current = key; + const isCurrent = () => { + const currentSelected = snapRef.current.subtitleTracks.find((track) => track.selected); + return ( + !!currentSelected && + isPlaybackActorCurrent(playbackActor) && + bridgeRef.current === bridge && + bridge.canAutoSelectSubtitle?.() !== false && + selectPlaybackSubtitle(snapRef.current.subtitleTracks, choice)?.id === currentSelected.id + ); + }; + if (!isCurrent()) return; + void writeMpvSubtitleFps( + choice.fpsOverride === 0 ? "default" : choice.fpsOverride, + getMpvSubtitleFpsGeneration(), + isCurrent, + ).catch(() => { + if (isCurrent()) + emitListToast(t("The saved subtitle timing could not be restored."), "error"); + }); + }, [engine, src.url, snap.status, snap.subtitleTracks, rememberedSession, actorKey, bridgeRef]); useEffect(() => { - if (!subsOffFor(readPlayerPrefs(src.meta.id), settings)) return; + if ( + !(rememberedSession?.subtitle + ? rememberedSession.subtitle.off === true + : subsOffFor(readPlayerPrefs(src.meta.id), settings)) + ) + return; const selected = snap.subtitleTracks.find((t) => t.selected); if (selected) bridgeRef.current?.setSubtitleTrack(null, "automatic"); - }, [src.meta.id, snap.subtitleTracks, settings]); + }, [src.meta.id, snap.subtitleTracks, settings, rememberedSession]); return { resolvedImdbId, diff --git a/tests/actual-playback-recorder.test.ts b/tests/actual-playback-recorder.test.ts index 6d303b4f7..5bdb8904b 100644 --- a/tests/actual-playback-recorder.test.ts +++ b/tests/actual-playback-recorder.test.ts @@ -7,6 +7,7 @@ import * as React from "react"; import { createRoot } from "react-dom/client"; import { actualPlaybackSnapshot } from "../src/lib/player/actual-playback"; import { initialPlayerSnapshot } from "../src/lib/player/bridge"; +import * as history from "../src/lib/playback-history"; test("actual recorder follows decoded clock activity, ignores buffer updates and stale source frames", async () => { const dom = new JSDOM("
"); @@ -29,6 +30,10 @@ test("actual recorder follows decoded clock activity, ignores buffer updates and }, }, "@/lib/player/actual-playback": { actualPlaybackSnapshot }, + "@/lib/subtitles/subtitle-memory": { + readRememberedSub: () => null, + subtitleMediaKey: () => "fixture", + }, "@/lib/player/playback-clock": { getPlaybackPosition: () => position, subscribePlaybackClock: (fn: () => void) => { @@ -78,11 +83,14 @@ test("actual recorder follows decoded clock activity, ignores buffer updates and await render(source("a"), initialPlayerSnapshot()); await tick(0); assert.equal(writes.length, 0); - await render(source("a"), ready); + await render(source("a"), { ...ready, status: "paused" }); await tick(2); + assert.equal(writes.length, 0, "opening and pre-seeking a paused source is not an actual play"); + await render(source("a"), ready); + await tick(3); assert.equal(writes.at(-1)?.src.title, "a"); const count = writes.length; - await tick(2); + await tick(3); assert.equal(writes.length, count); await render(source("b"), ready); // previous source's last rendered snapshot await tick(3); @@ -93,6 +101,149 @@ test("actual recorder follows decoded clock activity, ignores buffer updates and await tick(1); assert.equal(writes.at(-1)?.src.title, "b"); assert.equal(writes.at(-1)?.positionMs, 1000); + await render(source("b"), { ...ready, status: "paused", audioDelaySec: 0.1 }); + await render(source("b"), { + ...ready, + status: "paused", + audioDelaySec: 0.7, + subDelaySec: -1.5, + }); + await React.act(async () => window.dispatchEvent(new dom.window.Event("beforeunload"))); + assert.equal(writes.at(-1)?.audioDelaySec, 0.7, "refresh captures late paused settings"); + assert.equal(writes.at(-1)?.subDelaySec, -1.5); + } finally { + await React.act(async () => root.unmount()); + dom.window.close(); + } +}); + +test("a live recorder survives a same-owner CW privacy change and ignores obsolete hide flags", async () => { + const dom = new JSDOM("
"); + const values = new Map(); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, + }); + const profile = (activeId: string) => + values.set( + "harbor.profiles.v1", + JSON.stringify({ + activeId, + profiles: [ + { id: "one", isPrimary: true }, + { id: "two", shareStremioWith: "one" }, + ], + }), + ); + profile("one"); + values.set("harbor.auth.one", JSON.stringify({ user: { _id: "fixture-account" } })); + let position = 0; + const clock = new Set<() => void>(); + const dependencies: Record = { + react: React, + "@/lib/playback-history": history, + "@/lib/player/actual-playback": { actualPlaybackSnapshot }, + "@/lib/subtitles/subtitle-memory": { + readRememberedSub: () => null, + subtitleMediaKey: () => "fixture", + }, + "@/lib/player/playback-clock": { + getPlaybackPosition: () => position, + subscribePlaybackClock: (fn: () => void) => { + clock.add(fn); + return () => clock.delete(fn); + }, + }, + }; + const code = ts.transpileModule( + readFileSync( + new URL("../src/views/player/hooks/use-actual-playback.ts", import.meta.url), + "utf8", + ), + { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }, + ).outputText; + const exports: any = {}; + new Function("require", "exports", code)((id: string) => { + assert.ok(id in dependencies, id); + return dependencies[id]; + }, exports); + const src = { + meta: { id: "local:fixture", name: "Fixture", type: "movie" }, + url: "C:/fixture/movie.mp4", + }; + const ready = { + ...initialPlayerSnapshot(), + status: "playing", + firstFrameReady: true, + durationSec: 60, + }; + function Fixture({ snap }: { snap: unknown }) { + exports.useActualPlayback(src, snap); + return null; + } + const root = createRoot(document.querySelector("#app")!); + const render = (snap: unknown) => + React.act(async () => root.render(React.createElement(Fixture, { snap }))); + const tick = (value: number) => + React.act(async () => { + position = value; + clock.forEach((listener) => listener()); + }); + try { + await render(initialPlayerSnapshot()); + await render(ready); + await tick(3); + const first = history.readLastActualPlayback()!; + assert.equal(first.positionMs, 3000); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + await tick(5); + await render({ ...ready, status: "paused", audioDelaySec: 0.7 }); + const continued = history.readLastActualPlayback()!; + assert.equal(continued.id, first.id); + assert.equal(continued.positionMs, 5000); + assert.equal(continued.audioDelaySec, 0.7); + const key = "harbor.playback-history.v1.one"; + const stored = JSON.parse(values.get(key)!); + stored["local:fixture"].dismissedActualId = continued.id; + stored["local:fixture"].actualByProfile.one.dismissedActualId = continued.id; + values.set(key, JSON.stringify(stored)); + await render({ ...ready, status: "paused", audioDelaySec: 0.7, subDelaySec: 1.2 }); + assert.equal(history.readLastActualPlayback()?.id, continued.id); + assert.equal(history.readLastActualPlayback()?.positionMs, 5000); + assert.equal(history.readActualPlaybackFor(src)?.subDelaySec, 1.2); + profile("two"); + await render(ready); + await tick(8); + await render({ ...ready, status: "paused" }); + assert.equal( + history.readLastActualPlayback(), + null, + "the old recorder cannot claim the sibling profile", + ); + profile("one"); + assert.equal(history.readActualPlaybackFor(src)?.positionMs, 5000); + values.set("harbor.account-provider", "nuvio"); + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ activeProfileIndex: 1, session: { user: { id: "fixture-nuvio" } } }), + ); + await render({ ...ready, status: "paused", subDelaySec: 4 }); + assert.equal( + history.readLastActualPlayback(), + null, + "the old recorder cannot claim another provider", + ); } finally { await React.act(async () => root.unmount()); dom.window.close(); diff --git a/tests/actual-playback-snapshot.test.ts b/tests/actual-playback-snapshot.test.ts index 9e96a65fd..b90c33667 100644 --- a/tests/actual-playback-snapshot.test.ts +++ b/tests/actual-playback-snapshot.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { actualPlaybackSnapshot } from "../src/lib/player/actual-playback"; import { initialPlayerSnapshot } from "../src/lib/player/bridge"; import type { PlayerSrc } from "../src/lib/view"; +import { selectedSubtitleCacheName } from "../src/lib/subtitles/selected-subtitle-cache"; const src: PlayerSrc = { meta: { id: "local:short", type: "movie", name: "Short video" }, @@ -72,3 +73,129 @@ test("near-end progress is resumable until natural EOF, independent of watched t "a truncated stream EOF is not a completed playback", ); }); + +test("an observed session includes exact subtitle intent and both delays without temporary blob references", () => { + const playing = { + ...initialPlayerSnapshot(), + status: "playing" as const, + firstFrameReady: true, + durationSec: 400, + subDelaySec: 1.25, + audioDelaySec: -0.2, + subtitleTracks: [ + { + id: "3", + kind: "subtitle" as const, + label: "Arabic", + lang: "ara", + selected: true, + external: true, + url: "blob:temporary", + originalUrl: "C:/fixture/subtitle.srt", + localTrusted: true, + }, + ], + }; + const result = actualPlaybackSnapshot(src, playing, 30, session, 100); + assert.equal(result?.subtitle?.source, "C:/fixture/subtitle.srt"); + assert.equal(JSON.stringify(result?.subtitle).includes("blob:"), false); + assert.equal(result?.subDelaySec, 1.25); + assert.equal(result?.audioDelaySec, -0.2); + const off = actualPlaybackSnapshot(src, { ...playing, subtitleTracks: [] }, 30, session, 101); + assert.deepEqual(off?.subtitle, { off: true }); +}); + +test("an old initial subtitle override cannot replace the user's observed selection after restart", () => { + const result = actualPlaybackSnapshot( + { ...src, subtitlePreselect: { url: "blob:old-choice", lang: "eng" } }, + { ...initialPlayerSnapshot(), status: "playing", firstFrameReady: true, durationSec: 400 }, + 20, + session, + ); + assert.equal(result?.src.subtitlePreselect, undefined); + assert.deepEqual(result?.subtitle, { off: true }); +}); + +test("EOF teardown cannot overwrite the remembered track delays with cleared native defaults", () => { + const ended = actualPlaybackSnapshot( + src, + { ...initialPlayerSnapshot(), status: "ended", firstFrameReady: true, durationSec: 100 }, + 100, + session, + ); + assert.equal(ended?.subDelaySec, undefined); + assert.equal(ended?.audioDelaySec, undefined); +}); + +test("an observed external subtitle is bound to the actual release even when subtitle memory is absent", () => { + const result = actualPlaybackSnapshot( + { ...src, streamRef: { infoHash: "ABC", fileIdx: 4 } }, + { + ...initialPlayerSnapshot(), + status: "playing", + firstFrameReady: true, + durationSec: 100, + subtitleTracks: [ + { + id: "1", + kind: "subtitle", + selected: true, + external: true, + label: "Arabic", + url: "https://fixture.invalid/a.srt", + }, + ], + }, + 20, + session, + ); + assert.equal(result?.subtitle?.streamKey, "torrent:abc:4"); +}); + +test("a later seed-track snapshot without provider IDs retains the proven cached subtitle pointer", () => { + const source = "https://fixture.invalid/arabic.srt"; + const track = { + id: "404", + kind: "subtitle" as const, + external: true, + selected: true, + label: "Arabic", + lang: "ara", + url: source, + originalUrl: source, + }; + const mediaKey = "local:short||"; + const streamKey = "torrent:abc:4"; + const file = selectedSubtitleCacheName(mediaKey, streamKey, { ...track, source }, "srt"); + const cached = { + source: `C:/fixture/selected/${file}`, + format: "srt" as const, + streamKey, + imported: true, + lang: "ara", + updatedAt: Date.now(), + }; + const actualSrc = { ...src, streamRef: { infoHash: "ABC", fileIdx: 4 } }; + const snapshot = { + ...initialPlayerSnapshot(), + status: "playing" as const, + firstFrameReady: true, + durationSec: 100, + subtitleTracks: [track], + }; + assert.equal( + actualPlaybackSnapshot(actualSrc, snapshot, 20, session, Date.now(), cached)?.subtitle?.source, + cached.source, + ); + assert.equal( + actualPlaybackSnapshot(actualSrc, snapshot, 24, session, Date.now(), cached)?.subtitle?.source, + cached.source, + ); + assert.equal( + actualPlaybackSnapshot(actualSrc, snapshot, 24, session, Date.now(), { + ...cached, + source: "C:/fixture/unrelated.srt", + })?.subtitle?.source, + source, + ); +}); diff --git a/tests/actual-playback.test.ts b/tests/actual-playback.test.ts index 9fd75fe86..cbc96c3b8 100644 --- a/tests/actual-playback.test.ts +++ b/tests/actual-playback.test.ts @@ -51,6 +51,18 @@ function fixture(id: string, path: string, at: number, captured = actor()) { }; } +function markLegacyDismissals(key: string): string { + const stored = JSON.parse(values.get(key)!); + for (const entry of Object.values(stored) as any[]) { + if (entry.actual) entry.dismissedActualId = entry.actual.id; + for (const state of Object.values(entry.actualByProfile ?? {}) as any[]) + if (state.actual) state.dismissedActualId = state.actual.id; + } + const raw = JSON.stringify(stored); + values.set(key, raw); + return raw; +} + test("actual playback does not derive recency from source selections or manual history", () => { values.clear(); actor(); @@ -180,7 +192,376 @@ test("an invalid prior snapshot cannot supply EOF audio or prevent the new snaps const key = "harbor.playback-history.v1.one"; const stored = JSON.parse(values.get(key)!); delete stored["local:fixture"].actual.actor; + delete stored["local:fixture"].actualByProfile.one.actual.actor; values.set(key, JSON.stringify(stored)); assert.equal(history.recordActualPlayback({ ...first, completed: true }), true); assert.equal(history.readLastActualPlayback()?.audioTrack, undefined); }); + +test("legacy dismissal flags cannot hide the newest shared or private playback or replace it with an older item", () => { + for (const privateCw of [false, true]) { + values.clear(); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: privateCw })); + actor("one", "account-one", "one"); + const older = fixture("old", "C:/fixture/old.mp4", Date.now(), history.capturePlaybackActor()); + older.src.meta.id = "local:older"; + history.recordActualPlayback(older); + const latest = fixture( + "new", + "C:/fixture/new.mp4", + Date.now() + 1, + history.capturePlaybackActor(), + ); + history.recordActualPlayback(latest); + const key = "harbor.playback-history.v1.one"; + const persisted = markLegacyDismissals(key); + assert.equal(history.readLastActualPlayback()?.id, latest.id); + assert.equal(values.get(key), persisted, "reading an old flag never rewrites stored history"); + assert.equal(history.readActualPlaybackFor(latest.src)?.id, latest.id); + assert.equal(history.readPlayback("local:older")?.actual?.id, older.id); + values.set(key, JSON.stringify(JSON.parse(persisted))); + assert.equal(history.readLastActualPlayback()?.id, latest.id, "fresh reads ignore old flags"); + history.recordActualPlayback({ ...latest, playedAt: latest.playedAt + 1, positionMs: 13000 }); + assert.equal(history.readLastActualPlayback()?.positionMs, 13000); + history.savePlayback(latest.src.meta.id, { title: "A metadata edit" }); + assert.equal(history.readLastActualPlayback()?.id, latest.id); + history.recordActualPlayback({ + ...latest, + id: "genuine-new-session", + playedAt: Date.now() + 3, + }); + assert.equal(history.readLastActualPlayback()?.id, "genuine-new-session"); + } +}); + +test("ignoring legacy dismissal keeps account isolation and failed-write protection", () => { + values.clear(); + const captured = actor(); + const target = fixture("one", "C:/fixture/a.mp4", Date.now(), captured); + history.recordActualPlayback(target); + markLegacyDismissals("harbor.playback-history.v1.one"); + assert.equal(history.readLastActualPlayback()?.id, target.id); + const next = { ...target, id: "two", playedAt: target.playedAt + 1 }; + failWrite = true; + try { + assert.equal(history.recordActualPlayback(next), false); + } finally { + failWrite = false; + } + assert.equal(history.readLastActualPlayback()?.id, target.id); + actor("two", "other-account"); + assert.equal(history.readLastActualPlayback(), null); + assert.equal(history.recordActualPlayback(next), false); + actor(); + assert.equal(history.readLastActualPlayback()?.id, target.id); +}); + +test("existing history retention accepts a four-day-old session but not an expired actual session refreshed by metadata", () => { + values.clear(); + actor(); + const day = 24 * 60 * 60 * 1000; + history.recordActualPlayback(fixture("four-days", "C:/fixture/a.mp4", Date.now() - 4 * day)); + assert.equal(history.readLastActualPlayback()?.id, "four-days"); + values.clear(); + actor(); + const stale = fixture("expired", "C:/fixture/a.mp4", Date.now() - 31 * day); + history.recordActualPlayback(stale); + history.savePlayback(stale.src.meta.id, { title: "Unplayed source selection" }); + assert.equal(history.readLastActualPlayback(), null); +}); + +test("natural EOF preserves the last subtitle intent and delays from its exact session", () => { + values.clear(); + const first = { + ...fixture("same", "C:/fixture/a.mp4", Date.now()), + subtitle: { source: "C:/fixture/a.srt", external: true }, + subDelaySec: 2, + audioDelaySec: -0.3, + }; + history.recordActualPlayback(first); + history.recordActualPlayback({ + ...first, + completed: true, + playedAt: first.playedAt + 1, + subtitle: undefined, + subDelaySec: undefined, + audioDelaySec: undefined, + }); + const ended = history.readLastActualPlayback(); + assert.deepEqual(ended?.subtitle, first.subtitle); + assert.equal(ended?.subDelaySec, 2); + assert.equal(ended?.audioDelaySec, -0.3); +}); + +test("late subtitle caching upgrades only the captured actual session without changing recency or honoring old dismissal", () => { + values.clear(); + const now = Date.now(); + const original = { + source: "https://fixture.invalid/a.srt", + external: true, + streamKey: "torrent:abc:2", + subId: "a", + provider: "Fixture", + }; + const playback = { + ...fixture("played", "C:/fixture/a.mp4", now), + subtitle: original, + subDelaySec: 1.2, + }; + playback.src.streamRef = { infoHash: "abc", fileIdx: 2 }; + history.recordActualPlayback(playback); + const captured = history.readActualPlaybackForSubtitle( + "local:fixture||", + "torrent:abc:2", + now - 1, + ); + assert.equal(captured?.id, "played"); + assert.equal( + history.readActualPlaybackForSubtitle("local:fixture||", "torrent:abc:2", now + 1), + null, + ); + markLegacyDismissals("harbor.playback-history.v1.one"); + const cached = { ...original, source: "C:/fixture/cached.srt", imported: true }; + assert.equal(history.cacheActualPlaybackSubtitle(captured!, original, cached), true); + const saved = history.readActualPlaybackFor(playback.src)!; + assert.equal(saved.subtitle?.source, cached.source); + assert.equal(saved.playedAt, now); + assert.equal(saved.subDelaySec, 1.2); + assert.equal(history.readLastActualPlayback()?.id, playback.id); + history.recordActualPlayback({ ...playback, id: "new-session", playedAt: now + 1 }); + assert.equal(history.cacheActualPlaybackSubtitle(captured!, original, cached), false); + actor("two", "other-account"); + assert.equal(history.cacheActualPlaybackSubtitle(captured!, original, cached), false); +}); + +test("private CW keeps same-title actual playback independent and ignores old flags without changing shared source history", () => { + values.clear(); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + const one = actor("one", "account-one", "one"); + const first = fixture("private-one", "C:/fixture/one.mp4", Date.now(), one); + history.savePlayback(first.src.meta.id, { addonId: "shared-addon", title: "Fixture" }); + assert.equal(history.recordActualPlayback(first), true); + const two = actor("two", "account-one", "one"); + assert.equal(history.readLastActualPlayback(), null); + assert.equal(history.readActualPlaybackFor(first.src), null); + assert.equal(history.readPlayback(first.src.meta.id)?.addonId, "shared-addon"); + const second = fixture("private-two", "C:/fixture/two.mp4", first.playedAt + 1, two); + assert.equal(history.recordActualPlayback(second), true); + markLegacyDismissals("harbor.playback-history.v1.one"); + assert.equal(history.readLastActualPlayback()?.id, second.id); + actor("one", "account-one", "one"); + assert.equal(history.readLastActualPlayback()?.id, first.id); + history.savePlayback(first.src.meta.id, { title: "Metadata only" }); + history.clearPlayback(first.src.meta.id); + assert.equal(history.readLastActualPlayback()?.id, first.id); + actor("two", "account-one", "one"); + assert.equal(history.readLastActualPlayback()?.id, second.id); + assert.equal(history.readActualPlaybackFor(second.src)?.id, second.id); + actor("one", "account-one", "one"); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: false })); + assert.equal( + history.readLastActualPlayback()?.id, + second.id, + "shared suggestion keeps the latest shared actual session", + ); +}); + +test("private legacy adoption requires the actual playback owner's identity", () => { + values.clear(); + const one = actor("one", "account-one", "one"); + const first = fixture("legacy-one", "C:/fixture/one.mp4", Date.now(), one); + values.set( + "harbor.playback-history.v1.one", + JSON.stringify({ "local:fixture": { actual: first, savedAt: first.playedAt } }), + ); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + assert.equal(history.readLastActualPlayback()?.id, first.id); + actor("two", "account-one", "one"); + assert.equal(history.readLastActualPlayback(), null); + assert.equal(history.readActualPlaybackFor(first.src), null); + assert.equal(history.recordActualPlayback(first), false); +}); + +test("private EOF and late cache keep their own session without replacing a newer shared session", () => { + values.clear(); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + const one = actor("one", "account-one", "one"); + const original = { source: "https://fixture.invalid/one.srt", external: true }; + const first = { + ...fixture("private-one", "C:/fixture/one.mp4", Date.now(), one), + subtitle: original, + audioTrack: { id: "2", lang: "ara" }, + subDelaySec: 1.2, + }; + history.recordActualPlayback(first); + const two = actor("two", "account-one", "one"); + const second = fixture("private-two", "C:/fixture/two.mp4", first.playedAt + 2, two); + history.recordActualPlayback(second); + actor("one", "account-one", "one"); + const cached = { ...original, source: "C:/fixture/cached-one.srt", imported: true }; + assert.equal(history.cacheActualPlaybackSubtitle(first, original, cached), true); + assert.equal(history.readLastActualPlayback()?.subtitle?.source, cached.source); + assert.equal(history.readPlayback(first.src.meta.id)?.actual?.id, second.id); + history.recordActualPlayback({ + ...first, + completed: true, + playedAt: first.playedAt + 1, + audioTrack: undefined, + subtitle: undefined, + subDelaySec: undefined, + }); + assert.deepEqual(history.readLastActualPlayback()?.audioTrack, first.audioTrack); + assert.equal(history.readLastActualPlayback()?.subtitle?.source, cached.source); + assert.equal(history.readLastActualPlayback()?.subDelaySec, 1.2); + assert.equal(history.readPlayback(first.src.meta.id)?.actual?.id, second.id); +}); + +test("a fresh shared entry cannot extend an expired private actual session", () => { + values.clear(); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + const one = actor("one", "account-one", "one"); + const first = fixture("private-one", "C:/fixture/one.mp4", Date.now(), one); + history.recordActualPlayback(first); + const two = actor("two", "account-one", "one"); + history.recordActualPlayback(fixture("private-two", "C:/fixture/two.mp4", Date.now() + 1, two)); + const key = "harbor.playback-history.v1.one"; + const stored = JSON.parse(values.get(key)!); + stored["local:fixture"].actualByProfile.one.actual.playedAt = + Date.now() - 31 * 24 * 60 * 60 * 1000; + values.set(key, JSON.stringify(stored)); + actor("one", "account-one", "one"); + assert.equal(history.readLastActualPlayback(), null); + assert.equal(history.readActualPlaybackFor(first.src), null); +}); + +test("privacy policy changes invalidate captured writes without hiding the existing session", () => { + values.clear(); + const one = actor("one", "account-one", "one"); + const first = fixture("shared-one", "C:/fixture/one.mp4", Date.now(), one); + history.recordActualPlayback(first); + actor("two", "account-one", "one"); + assert.equal(history.readLastActualPlayback()?.id, first.id); + markLegacyDismissals("harbor.playback-history.v1.one"); + actor("one", "account-one", "one"); + assert.equal(history.readLastActualPlayback()?.id, first.id); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + assert.equal(history.isPlaybackActorCurrent(one), false); + assert.equal(history.recordActualPlayback({ ...first, id: "late" }), false); + assert.equal( + history.readLastActualPlayback()?.id, + first.id, + "private suggestion remains available from its own observed session", + ); +}); + +test("Nuvio account, provider, and slot changes isolate last actual playback and reject stale actors", () => { + values.clear(); + actor(); + history.recordActualPlayback(fixture("old-stremio", "C:/fixture/stremio.mp4", Date.now())); + values.set("harbor.account-provider", "nuvio"); + const nuvio = (accountId: string, slot: number) => { + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ activeProfileIndex: slot, session: { user: { id: accountId } } }), + ); + return history.capturePlaybackActor(); + }; + const one = nuvio("nuvio-account-a", 1); + assert.equal( + history.readLastActualPlayback(), + null, + "Nuvio cannot adopt an unattributed legacy Stremio session", + ); + const first = fixture("nuvio-one", "C:/fixture/one.mp4", Date.now(), one); + assert.equal(history.recordActualPlayback(first), true); + const two = nuvio("nuvio-account-a", 2); + assert.equal(history.isPlaybackActorCurrent(one), false); + assert.equal(history.readLastActualPlayback(), null); + const second = fixture("nuvio-two", "C:/fixture/two.mp4", first.playedAt + 1, two); + assert.equal(history.recordActualPlayback(second), true); + markLegacyDismissals("harbor.playback-history.v1.nuvio.nuvio-account-a.nuvio-2"); + nuvio("nuvio-account-a", 1); + assert.equal(history.readLastActualPlayback()?.id, first.id); + nuvio("nuvio-account-b", 1); + assert.equal(history.isPlaybackActorCurrent(one), false); + assert.equal(history.readLastActualPlayback(), null); + assert.equal(history.recordActualPlayback({ ...first, id: "late" }), false); + nuvio("nuvio-account-a", 2); + assert.equal(history.readLastActualPlayback()?.id, second.id); + values.set("harbor.account-provider", "stremio"); + assert.equal(history.isPlaybackActorCurrent(two), false); + assert.equal(history.readLastActualPlayback()?.id, "old-stremio"); + const stremio = fixture("stremio", "C:/fixture/stremio.mp4", Date.now()); + history.recordActualPlayback(stremio); + values.set("harbor.account-provider", "nuvio"); + nuvio("nuvio-account-a", 1); + assert.equal(history.readLastActualPlayback()?.id, first.id); +}); + +test("scope events notify only for account, provider, profile, slot, or privacy changes", () => { + values.clear(); + actor("one", "account-one", "one"); + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + const events = new EventTarget(); + Object.defineProperty(globalThis, "window", { configurable: true, value: events }); + let notifications = 0; + const revision = history.getPlaybackRevision(); + const unsubscribe = history.subscribePlayback(() => notifications++); + const unsubscribeOther = history.subscribePlayback(() => {}); + try { + events.dispatchEvent(new Event("harbor:settings-changed")); + assert.equal(notifications, 0); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + events.dispatchEvent(new Event("harbor:settings-changed")); + assert.equal(notifications, 1); + actor("two", "account-one", "one"); + events.dispatchEvent(new Event("harbor:active-profile-changed")); + assert.equal(notifications, 2); + values.set("harbor.account-provider", "nuvio"); + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ activeProfileIndex: 1, session: { user: { id: "account-a" } } }), + ); + events.dispatchEvent(new Event("harbor:account-provider-changed")); + assert.equal(notifications, 3); + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ + activeProfileIndex: 2, + session: { user: { id: "account-a" }, access_token: "fixture-refresh" }, + }), + ); + events.dispatchEvent(new Event("harbor:nuvio-profile-changed")); + assert.equal(notifications, 4); + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ + activeProfileIndex: 2, + session: { user: { id: "account-a" }, access_token: "fixture-next-refresh" }, + }), + ); + events.dispatchEvent(new Event("harbor:nuvio-state-changed")); + assert.equal(notifications, 4, "token refresh cannot invalidate a same-owner session"); + values.set( + "harbor.nuvio.state.v1", + JSON.stringify({ activeProfileIndex: 2, session: { user: { id: "account-b" } } }), + ); + events.dispatchEvent(new Event("harbor:nuvio-state-changed")); + assert.equal(notifications, 5); + assert.equal( + history.getPlaybackRevision(), + revision + 5, + "revision advances once per scope change, not per subscriber", + ); + unsubscribe(); + unsubscribeOther(); + values.set("harbor.account-provider", "stremio"); + events.dispatchEvent(new Event("harbor:account-provider-changed")); + assert.equal(notifications, 5, "scope listeners are cleaned up after the last subscriber"); + } finally { + unsubscribe(); + unsubscribeOther(); + if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); + else Reflect.deleteProperty(globalThis, "window"); + } +}); diff --git a/tests/helpers/mpv-bridge-harness.ts b/tests/helpers/mpv-bridge-harness.ts index d569df5e8..c5f4f8dd1 100644 --- a/tests/helpers/mpv-bridge-harness.ts +++ b/tests/helpers/mpv-bridge-harness.ts @@ -9,6 +9,7 @@ import * as failures from "../../src/lib/player/mpv-failure.ts"; import * as translationJobs from "../../src/lib/subtitles/translation-jobs.ts"; import * as addedSubs from "../../src/lib/subtitles/added-subs.ts"; import * as language from "../../src/lib/subtitles/language.ts"; +import * as pauseTrace from "../../src/lib/player/pause-trace.ts"; import { SubtitlePreparationError as RealSubtitlePreparationError } from "../../src/lib/subtitles/prepare.ts"; import type { PlayerBridge, PlayerSnapshot } from "../../src/lib/player/bridge.ts"; import type { MpvOptions } from "../../src/lib/player/mpv.ts"; @@ -102,6 +103,7 @@ export function mpvBridgeHarness(prefs = { volume: 0.35, muted: false }, options "@/lib/subtitles/added-subs": addedSubs, "@/lib/subtitles/language": language, "./mpv-failure": failures, + "./pause-trace": pauseTrace, "@/lib/platform": { isWindowsDesktop: () => true, isLinuxDesktop: () => false, @@ -159,7 +161,9 @@ export function mpvBridgeHarness(prefs = { volume: 0.35, muted: false }, options }, { warn() {}, info() {} }, { - get visibilityState() { return visibilityState; }, + get visibilityState() { + return visibilityState; + }, addEventListener: documentEvents.addEventListener.bind(documentEvents), removeEventListener: documentEvents.removeEventListener.bind(documentEvents), }, @@ -173,7 +177,9 @@ export function mpvBridgeHarness(prefs = { volume: 0.35, muted: false }, options bridge, commands, errors, - emitWindow(name: string) { windowEvents.dispatchEvent(new Event(name)); }, + emitWindow(name: string) { + windowEvents.dispatchEvent(new Event(name)); + }, emitVisibility(state: DocumentVisibilityState) { visibilityState = state; documentEvents.dispatchEvent(new Event("visibilitychange")); diff --git a/tests/helpers/subtitle-memory-harness.ts b/tests/helpers/subtitle-memory-harness.ts index ea28cd8bc..2c6f44b4a 100644 --- a/tests/helpers/subtitle-memory-harness.ts +++ b/tests/helpers/subtitle-memory-harness.ts @@ -4,6 +4,10 @@ import ts from "typescript"; import * as memory from "../../src/lib/subtitles/subtitle-memory.ts"; import { normalizeLang } from "../../src/lib/subtitles/language.ts"; import { emptySnapshot, type PlayerSnapshot, type TrackInfo } from "../../src/lib/player/bridge.ts"; +import { + restorablePlaybackSubtitleChoice, + selectPlaybackSubtitle, +} from "../../src/lib/player/subtitle-choice"; export function subtitleTrack(id: string): TrackInfo { return { @@ -47,6 +51,14 @@ export function restoreHarness(remembered: memory.RememberedSub, tracks: TrackIn const scope = { ...memory, normalizeLang, + selectPlaybackSubtitle, + restorablePlaybackSubtitleChoice, + rememberedSession: null, + playbackActor: {}, + isPlaybackActorCurrent: () => true, + embeddedRestoreRef: { current: null }, + emitListToast() {}, + t: (key: string) => key, src: { url: "fixture.mkv", meta: { id: "fixture" }, @@ -90,6 +102,8 @@ export function restoreHarness(remembered: memory.RememberedSub, tracks: TrackIn } export function memoryControlsHarness() { + let actual: unknown = null; + const actualCachePatches: unknown[][] = []; const source = readFileSync( new URL("../../src/views/player/hooks/use-playback-controls.ts", import.meta.url), "utf8", @@ -133,6 +147,15 @@ export function memoryControlsHarness() { }, "@/lib/player/playback-clock": {}, "@/lib/media-session": { notifyMediaSeeked() {} }, + "@/lib/playback-history": { + capturePlaybackActor: () => ({}), + isPlaybackActorCurrent: () => true, + readActualPlaybackForSubtitle: () => actual, + cacheActualPlaybackSubtitle(...args: unknown[]) { + actualCachePatches.push(args); + return true; + }, + }, "@/lib/player-prefs": { writePlayerPrefs() {} }, "@/lib/subtitles/subtitle-memory": { ...memory, @@ -208,6 +231,10 @@ export function memoryControlsHarness() { }; } return { + actualCachePatches, + actualPlayback(value: unknown) { + actual = value; + }, mount, saved, cacheRequests, diff --git a/tests/last-playback-continuation-hook.test.ts b/tests/last-playback-continuation-hook.test.ts index 2147bafb0..1f62233f7 100644 --- a/tests/last-playback-continuation-hook.test.ts +++ b/tests/last-playback-continuation-hook.test.ts @@ -17,6 +17,7 @@ test("continuation runner owns pending work across actor changes and rejects sta let result: any; const releases: Array<() => void> = []; const opened: string[] = []; + let subscriptions = 0; const capture = () => ({ profileId: actorId, storageProfileId: actorId, accountId: actorId }); const dependencies: Record = { react: React, @@ -26,8 +27,12 @@ test("continuation runner owns pending work across actor changes and rejects sta "@/lib/playback-history": { capturePlaybackActor: capture, isPlaybackActorCurrent: (actor: any) => actor.profileId === actorId, + getPlaybackRevision: () => 0, readLastActualPlayback: () => ({ id: actorId }), - subscribePlayback: () => () => {}, + subscribePlayback: () => { + subscriptions += 1; + return () => subscriptions--; + }, }, "@/lib/player-actions": { currentPlayerActions: () => null }, "@/lib/player/continue-playback": { @@ -51,6 +56,7 @@ test("continuation runner owns pending work across actor changes and rejects sta if (!(id in dependencies)) throw new Error(id); return dependencies[id]; }, exports); + assert.equal(subscriptions, 0, "loading the hook module must not subscribe to history"); function Fixture() { result = exports.useLastPlaybackContinuation(); return null; @@ -59,6 +65,7 @@ test("continuation runner owns pending work across actor changes and rejects sta let unmounted = false; try { await React.act(async () => root.render(React.createElement(Fixture))); + assert.equal(subscriptions, 1); let first!: Promise; await React.act(async () => { first = result.continuePlayback(result.target).catch((error: Error) => error.message); @@ -89,6 +96,7 @@ test("continuation runner owns pending work across actor changes and rejects sta }); await React.act(async () => root.unmount()); unmounted = true; + assert.equal(subscriptions, 0, "unmounting the hook releases its history subscription"); releases[2](); assert.equal(await third, "stale"); assert.deepEqual(opened, ["two"]); @@ -97,3 +105,58 @@ test("continuation runner owns pending work across actor changes and rejects sta dom.window.close(); } }); + +test("target refreshes for scope identity changes while Stremio React identities stay unchanged", async () => { + const dom = new JSDOM("
"); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + let actor = { profileId: "one", storageProfileId: "one", accountId: "one" }; + let result: any; + const dependencies: Record = { + react: React, + "@/lib/auth": { useAuth: () => ({ user: { _id: "unchanged-stremio" } }) }, + "@/lib/profiles": { useProfiles: () => ({ activeProfile: { id: "unchanged-profile" } }) }, + "@/lib/view": { useView: () => ({}) }, + "@/lib/playback-history": { + capturePlaybackActor: () => actor, + getPlaybackRevision: () => 0, + readLastActualPlayback: () => ({ id: JSON.stringify(actor) }), + subscribePlayback: () => () => {}, + }, + "@/lib/player-actions": {}, + "@/lib/player/continue-playback": {}, + }; + const source = readFileSync( + new URL("../src/hooks/use-last-playback-continuation.ts", import.meta.url), + "utf8", + ); + const code = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const exports: any = {}; + new Function("require", "exports", code)((id: string) => { + assert.ok(id in dependencies, id); + return dependencies[id]; + }, exports); + function Fixture() { + result = exports.useLastPlaybackContinuation(); + return null; + } + const root = createRoot(document.querySelector("#app")!); + try { + await React.act(async () => root.render(React.createElement(Fixture))); + assert.equal(result.target.id, JSON.stringify(actor)); + actor = { ...actor, accountId: "two" }; + await React.act(async () => root.render(React.createElement(Fixture))); + assert.equal(result.target.id, JSON.stringify(actor)); + actor = { ...actor, profileId: "nuvio-2", storageProfileId: "nuvio-2" }; + await React.act(async () => root.render(React.createElement(Fixture))); + assert.equal(result.target.id, JSON.stringify(actor)); + } finally { + await React.act(async () => root.unmount()); + dom.window.close(); + } +}); diff --git a/tests/playback-audio-autoload.test.ts b/tests/playback-audio-autoload.test.ts index b22277b2d..b44d3c2da 100644 --- a/tests/playback-audio-autoload.test.ts +++ b/tests/playback-audio-autoload.test.ts @@ -6,6 +6,9 @@ import { JSDOM } from "jsdom"; import * as React from "react"; import { createRoot } from "react-dom/client"; import * as audioChoice from "../src/lib/player/audio-choice"; +import * as playbackHistory from "../src/lib/playback-history"; +import * as continuation from "../src/lib/player/continue-playback"; +import * as subtitleChoice from "../src/lib/player/subtitle-choice"; import * as language from "../src/lib/subtitles/language"; import { initialPlayerSnapshot, type TrackInfo } from "../src/lib/player/bridge"; @@ -43,6 +46,7 @@ async function fixture(remembered = true) { IS_REACT_ACT_ENVIRONMENT: true, }); const calls: string[] = []; + const errors: string[] = []; const bridgeRef = { current: { setAudioTrack: (id: string) => { @@ -69,10 +73,17 @@ async function fixture(remembered = true) { "@/components/player/subtitle-menu/subtitle-context-store": { publishSubtitleContext() {} }, "@/lib/player-prefs": { readPlayerPrefs: () => ({ audioLang: "eng", subsOff: true }) }, "@/lib/playback-history": { - playbackSourceKey: (src: { url: string }) => src.url, + ...playbackHistory, readActualPlaybackFor: () => null, }, "@/lib/player/audio-choice": audioChoice, + "@/lib/player/continue-playback": continuation, + "@/lib/player/subtitle-choice": subtitleChoice, + "@/components/lists/list-toast": { + emitListToast: (message: string) => errors.push(message), + }, + "@/lib/i18n": { t: (message: string) => message }, + "@/lib/player/mpv-properties": {}, "@/lib/providers/tmdb": {}, "@/lib/subtitles/addon-source": { gatherSubtitleAddons: async () => [] }, "@/lib/streams/stream-ids": { buildStreamIds: () => [] }, @@ -112,7 +123,11 @@ async function fixture(remembered = true) { url: "C:/synthetic/audio.mkv", meta: { id: "local:fixture", type: "movie", name: "Fixture" }, continuation: remembered - ? { sourceKey: "C:/synthetic/audio.mkv", audioTrack: saved } + ? { + sourceKey: "C:/synthetic/audio.mkv", + actor: playbackHistory.capturePlaybackActor(), + audioTrack: saved, + } : undefined, }; const settings = { @@ -123,25 +138,33 @@ async function fixture(remembered = true) { audioNormalize: false, audioProfile: "off", }; - function Fixture({ audioTracks }: { audioTracks: TrackInfo[] }) { + function Fixture({ + audioTracks, + firstFrameReady, + }: { + audioTracks: TrackInfo[]; + firstFrameReady: boolean; + }) { exports.useTrackAutoload({ bridgeRef, src, settings, engine: "mpv", authKey: null, - snap: { ...initialPlayerSnapshot(), durationSec: 360, audioTracks }, + snap: { ...initialPlayerSnapshot(), durationSec: 360, audioTracks, firstFrameReady }, }); return null; } const root = createRoot(document.getElementById("root")!); - const render = async (audioTracks: TrackInfo[]) => - React.act(async () => root.render(React.createElement(Fixture, { audioTracks }))); + const render = async (audioTracks: TrackInfo[], firstFrameReady = false) => + React.act(async () => + root.render(React.createElement(Fixture, { audioTracks, firstFrameReady })), + ); const dispose = async () => { await React.act(async () => root.unmount()); dom.window.close(); }; - return { render, dispose, calls }; + return { render, dispose, calls, errors }; } test("an exact remembered Commentary choice overrides automatic block words", async () => { @@ -214,3 +237,30 @@ test("late metadata cannot overwrite a newer manual audio selection", async () = await f.dispose(); } }); + +test("reordered numeric IDs restore the remembered semantic audio track", async () => { + const f = await fixture(); + try { + await f.render(tracks.map((track) => ({ ...track, id: track.id === "1" ? "2" : "1" }))); + assert.deepEqual(f.calls, ["1"]); + assert.deepEqual(f.errors, []); + } finally { + await f.dispose(); + } +}); + +test("unavailable remembered audio reports once after decoding, without claiming restoration", async () => { + const f = await fixture(); + try { + await f.render([tracks[0]]); + assert.deepEqual(f.errors, []); + await f.render([tracks[0]], true); + await f.render([tracks[0]], true); + assert.deepEqual(f.calls, []); + assert.deepEqual(f.errors, [ + "The saved audio track could not be restored. Choose an audio track.", + ]); + } finally { + await f.dispose(); + } +}); diff --git a/tests/playback-continuation.test.ts b/tests/playback-continuation.test.ts index 2c7301da1..69d742cd6 100644 --- a/tests/playback-continuation.test.ts +++ b/tests/playback-continuation.test.ts @@ -4,6 +4,7 @@ import { continueActualPlayback, continuationFor, continuationStream, + continuationAppliesToSource, type ContinuationServices, } from "../src/lib/player/continue-playback"; import type { ActualPlayback } from "../src/lib/playback-history"; @@ -138,3 +139,63 @@ test("home-server preparation receives actual position and explicit restart zero await continueActualPlayback(target, { restart: true }, api); assert.deepEqual(positions, [45000, 0]); }); + +test("continuation carries scoped subtitle choice and delays through source preparation", async () => { + const target: ActualPlayback = { + ...fixture(), + subtitle: { off: true }, + subDelaySec: 1.5, + audioDelaySec: -0.25, + }; + const { api, calls } = services(target); + await continueActualPlayback(target, {}, api); + const continued = (calls[0] as [string, { continuation: ReturnType }])[1] + .continuation; + assert.deepEqual(continued.subtitle, { off: true }); + assert.equal(continued.subDelaySec, 1.5); + assert.equal(continued.audioDelaySec, -0.25); +}); + +test("refreshed URLs restore exact session state only for the same title, episode, and verified file metadata", () => { + const target = fixture(); + target.src.url = "https://fixture.invalid/old-token"; + target.src.meta.type = "series"; + target.src.episode = { season: 2, episode: 3 }; + target.src.streamRef = { + addonId: "fixture-addon", + parsedTitle: "Series.S02E03.1080p.RELEASE", + size: 1234567, + resolution: "1080p", + source: "WEB", + fileIdx: 2, + }; + const context = continuationFor(target, target.actor); + const renewed = { ...target.src, url: "https://fixture.invalid/new-token" }; + assert.equal(continuationAppliesToSource(renewed, context), true); + assert.equal( + continuationAppliesToSource({ ...renewed, meta: { ...renewed.meta, id: "other" } }, context), + false, + ); + assert.equal( + continuationAppliesToSource({ ...renewed, episode: { season: 2, episode: 4 } }, context), + false, + ); + assert.equal( + continuationAppliesToSource( + { ...renewed, streamRef: { ...renewed.streamRef, fileIdx: 4 } }, + context, + ), + false, + ); + assert.equal( + continuationAppliesToSource( + { ...renewed, streamRef: { ...renewed.streamRef, size: undefined } }, + context, + ), + false, + ); + assert.equal( + continuationAppliesToSource({ ...renewed, streamRef: { addonId: "fixture-addon" } }, context), + false, + ); +}); diff --git a/tests/playback-session-fps.test.ts b/tests/playback-session-fps.test.ts new file mode 100644 index 000000000..489181508 --- /dev/null +++ b/tests/playback-session-fps.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mpvBridgeHarness, playerSnapshotChanged } from "./helpers/mpv-bridge-harness"; +import { actualPlaybackSnapshot } from "../src/lib/player/actual-playback"; +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { + restorablePlaybackSubtitleChoice, + selectPlaybackSubtitle, +} from "../src/lib/player/subtitle-choice"; +import { subtitleMediaKey, subtitleStreamKey } from "../src/lib/subtitles/subtitle-memory"; + +test("native subtitle FPS observations reach the player snapshot and existing actual history payload", async () => { + const h = mpvBridgeHarness(); + await h.bridge.load({ url: "C:/fixture/video.mp4" }); + h.emit("sub-fps", 25); + assert.equal(h.snapshot().subtitleFps, 25); + const snapshot = { + ...h.snapshot(), + status: "paused" as const, + firstFrameReady: true, + subtitleTracks: [ + { id: "1", kind: "subtitle" as const, label: "Arabic", lang: "ara", selected: true }, + ], + }; + const result = actualPlaybackSnapshot( + { + url: "C:/fixture/video.mp4", + title: "Fixture", + meta: { id: "local:fixture", name: "Fixture", type: "movie" }, + }, + snapshot, + 30, + { id: "session", actor: { profileId: "one", storageProfileId: "one", accountId: null } }, + ); + assert.equal(result?.subtitle?.fpsOverride, 25); + h.emit("sub-fps", 0); + assert.equal(h.snapshot().subtitleFps, 0); + assert.equal(playerSnapshotChanged()(snapshot, { ...snapshot, subtitleFps: 0 }), true); + h.bridge.destroy(); +}); + +test("FPS restoration waits for the intended selected subtitle and is not replayed on ordinary rerenders", async () => { + const source = readFileSync( + new URL("../src/views/player/hooks/use-track-autoload.ts", import.meta.url), + "utf8", + ); + const ast = ts.createSourceFile("autoload.ts", source, ts.ScriptTarget.Latest, true); + let callback: ts.Node | undefined; + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node) && node.expression.getText(ast) === "useEffect") { + const candidate = node.arguments[0]; + if (candidate?.getText(ast).includes("writeMpvSubtitleFps(")) callback = candidate; + } + ts.forEachChild(node, visit); + }; + visit(ast); + assert.ok(callback, "the existing track-restoration path must restore saved FPS"); + const compiled = ts.transpileModule(`const effect = ${callback.getText(ast)};`, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + const correct = { + id: "7", + kind: "subtitle", + selected: true, + label: "Full dialogue", + title: "Full dialogue", + lang: "ara", + codec: "ass", + }; + const wrong = { ...correct, id: "1", title: "Signs", label: "Signs" }; + const writes: unknown[] = []; + let validity: (() => boolean) | undefined; + const scope = { + engine: "mpv", + rememberedSession: { + subtitle: { + trackId: "1", + title: "Full dialogue", + lang: "ara", + codec: "ass", + fpsOverride: 25, + }, + }, + src: { url: "fixture.mkv", meta: { id: "fixture" } }, + snap: { status: "paused", subtitleTracks: [wrong] }, + snapRef: { current: { subtitleTracks: [wrong] } }, + bridgeRef: { current: { canAutoSelectSubtitle: () => true } }, + playbackActor: {}, + isPlaybackActorCurrent: () => true, + selectPlaybackSubtitle, + restorablePlaybackSubtitleChoice, + subtitleMediaKey, + subtitleStreamKey, + readRememberedSub: () => null, + subtitleFpsAppliedRef: { current: null }, + getMpvSubtitleFpsGeneration: () => 1, + writeMpvSubtitleFps: async (...args: unknown[]) => { + writes.push(args[0]); + validity = args[2] as () => boolean; + }, + emitListToast() {}, + t: (key: string) => key, + }; + const run = () => + new Function(...Object.keys(scope), `${compiled}\nreturn effect();`)(...Object.values(scope)); + run(); + assert.deepEqual(writes, []); + scope.snap.subtitleTracks = [correct, { ...correct, id: "8", selected: false }]; + scope.snapRef.current = scope.snap; + run(); + assert.deepEqual(writes, [], "ambiguous semantic tracks cannot inherit an FPS correction"); + scope.snap.subtitleTracks = [correct]; + scope.snapRef.current = scope.snap; + run(); + await Promise.resolve(); + run(); + assert.deepEqual(writes, [25]); + scope.snapRef.current = { subtitleTracks: [] }; + assert.equal(validity?.(), false, "clearing native tracks invalidates a queued correction"); +}); diff --git a/tests/playback-session-restore.test.ts b/tests/playback-session-restore.test.ts new file mode 100644 index 000000000..e79df7e01 --- /dev/null +++ b/tests/playback-session-restore.test.ts @@ -0,0 +1,308 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { restoreHarness } from "./helpers/subtitle-memory-harness"; +import { playbackSubtitleChoice } from "../src/lib/player/subtitle-choice"; +import type { TrackInfo } from "../src/lib/player/bridge"; +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { selectPlaybackAudio } from "../src/lib/player/audio-choice"; +import { selectedSubtitleCacheName } from "../src/lib/subtitles/selected-subtitle-cache"; +import { rememberedFromChoice } from "../src/lib/subtitles/subtitle-memory"; + +const embedded = (id: string, title: string): TrackInfo => ({ + id, + kind: "subtitle", + label: title, + title, + lang: "ara", + selected: false, + codec: "ass", +}); + +test("a fresh subtitle preselection wins over recalled history or continuation subtitle intent", () => { + const source = readFileSync( + new URL("../src/views/player/hooks/use-track-autoload.ts", import.meta.url), + "utf8", + ); + const ast = ts.createSourceFile("autoload.ts", source, ts.ScriptTarget.Latest, true); + let recall: ts.Node | undefined; + let preselect: ts.Node | undefined; + const visit = (node: ts.Node) => { + if ( + ts.isVariableDeclaration(node) && + node.name.getText(ast) === "rememberedSession" && + node.initializer && + ts.isCallExpression(node.initializer) + ) + recall = node.initializer.arguments[0]; + if ( + ts.isCallExpression(node) && + node.expression.getText(ast) === "useEffect" && + node.arguments[0]?.getText(ast).includes("const choice = src.subtitlePreselect;") + ) + preselect = node.arguments[0]; + ts.forEachChild(node, visit); + }; + visit(ast); + assert.ok(recall); + assert.ok(preselect); + const compile = (callback: ts.Node) => + ts.transpileModule(`const effect = ${callback.getText(ast)};`, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + const recallCode = compile(recall); + const preselectCode = compile(preselect); + + for (const fromContinuation of [false, true]) { + for (const choice of [{ off: true }, { url: "https://fixture.invalid/new.srt" }]) { + const session = { + subtitle: playbackSubtitleChoice(embedded("1", "Old Arabic")), + audioTrack: { lang: "eng" }, + subDelaySec: 2, + audioDelaySec: 0.5, + }; + const h = restoreHarness({ updatedAt: 1 }, [embedded("9", "Old Arabic")]); + Object.assign(h.scope.src, { + subtitlePreselect: choice, + ...(fromContinuation ? { continuation: { ...session, actor: {} } } : {}), + }); + const rememberedSession = new Function( + "src", + "continuationAppliesToSource", + "isPlaybackActorCurrent", + "readActualPlaybackFor", + `${recallCode}\nreturn effect();`, + )( + h.scope.src, + () => true, + () => true, + () => session, + ); + Object.assign(h.scope, { + rememberedSession, + preselectAppliedRef: { current: null }, + markAddedSub() {}, + }); + h.run(); + assert.deepEqual(h.picks, [], "recalled subtitle must not replace the new selection"); + new Function(...Object.keys(h.scope), `${preselectCode}\nreturn effect();`)( + ...Object.values(h.scope), + ); + if ("off" in choice) assert.deepEqual(h.picks, [null]); + else assert.deepEqual(h.adds, [choice.url]); + assert.deepEqual(rememberedSession.audioTrack, session.audioTrack); + assert.equal(rememberedSession.subDelaySec, 2); + assert.equal(rememberedSession.audioDelaySec, 0.5); + } + } +}); + +test("the real restore effect uses scoped continuation subtitle intent ahead of unrelated subtitle memory", () => { + const selected = embedded("1", "Full dialogue"); + const h = restoreHarness({ off: true, updatedAt: 1 }, [ + embedded("1", "Signs"), + embedded("9", "Full dialogue"), + ]); + Object.assign(h.scope, { + rememberedSession: { subtitle: playbackSubtitleChoice(selected) }, + embeddedRestoreRef: { current: null }, + }); + h.run(); + assert.deepEqual(h.picks, ["9"]); +}); + +test("scoped off selection stays off even when a prior subtitle memory would select a subtitle", () => { + const h = restoreHarness({ source: "https://fixture.invalid/a.srt", updatedAt: 1 }, [ + { ...embedded("3", "Arabic"), selected: true }, + ]); + Object.assign(h.scope, { rememberedSession: { subtitle: { off: true } } }); + h.run(); + assert.deepEqual(h.picks, [null]); + assert.deepEqual(h.adds, []); +}); + +test("late subtitle preparation cannot mutate the player after its account changes", async () => { + const h = restoreHarness( + { source: "C:/fixture/a.srt", imported: true, streamKey: "torrent:fixture:0", updatedAt: 1 }, + [], + ); + let current = true; + let release!: () => void; + Object.assign(h.scope, { + isPlaybackActorCurrent: () => current, + playbackActor: {}, + bindSubtitleDownloadAuth: () => + new Promise((resolve) => { + release = resolve; + }), + }); + h.run(); + current = false; + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(h.adds, []); +}); + +test("failure to reopen the saved local subtitle reports incomplete restoration", async () => { + const h = restoreHarness( + { + source: "C:/fixture/missing.srt", + imported: true, + streamKey: "torrent:fixture:0", + updatedAt: 1, + }, + [], + ); + const errors: string[] = []; + Object.assign(h.scope, { + rememberedSession: { + subtitle: { + source: "C:/fixture/missing.srt", + imported: true, + streamKey: "torrent:fixture:0", + }, + }, + emitListToast: (message: string) => errors.push(message), + bindSubtitleDownloadAuth: async () => { + throw new Error("Unavailable subtitle"); + }, + }); + h.run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(errors, ["The saved subtitle could not be restored. Choose a subtitle."]); +}); + +test("a missing embedded subtitle is not silently replaced by a different default track", () => { + const h = restoreHarness({ updatedAt: 1 }, [ + { ...embedded("1", "Different translation"), selected: true }, + ]); + const errors: string[] = []; + Object.assign(h.scope, { + rememberedSession: { subtitle: playbackSubtitleChoice(embedded("1", "Intended translation")) }, + snap: { ...h.scope.snap, firstFrameReady: true }, + emitListToast: (message: string) => errors.push(message), + }); + h.run(); + assert.deepEqual(h.picks, [null]); + assert.deepEqual(errors, ["The saved subtitle could not be restored. Choose a subtitle."]); +}); + +test("an embedded subtitle missing from an empty ready track list reports once without a premature loading error", () => { + const h = restoreHarness({ updatedAt: 1 }, []); + const errors: string[] = []; + Object.assign(h.scope, { + rememberedSession: { subtitle: playbackSubtitleChoice(embedded("1", "Intended translation")) }, + emitListToast: (message: string) => errors.push(message), + }); + h.run(); + assert.deepEqual(errors, []); + h.scope.snap.firstFrameReady = true; + h.run(); + h.run(); + assert.deepEqual(errors, ["The saved subtitle could not be restored. Choose a subtitle."]); +}); + +function latePreplayCacheFixture() { + const track = { + ...embedded("9", "Arabic"), + external: true, + url: "https://fixture.invalid/chosen.srt", + subId: "chosen", + provider: "Fixture provider", + }; + const streamKey = "torrent:fixture:0"; + const saved = { ...rememberedFromChoice(track), external: true, streamKey, fpsOverride: 25 }; + const filename = selectedSubtitleCacheName( + "fixture||", + streamKey, + { ...track, source: track.url }, + "srt", + ); + const cached = { + ...saved, + source: `C:/fixture/selected/${filename}`, + format: "srt" as const, + imported: true, + updatedAt: Date.now(), + }; + return { saved, cached }; +} + +test("reopening uses a proven late pre-play cache even when the scoped actual snapshot still has its remote source", async () => { + const { saved, cached } = latePreplayCacheFixture(); + const h = restoreHarness(cached, []); + Object.assign(h.scope, { rememberedSession: { subtitle: saved } }); + h.run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(h.adds, [cached.source]); + assert.deepEqual(h.timers, []); +}); + +test("late cache preference does not cross a different stream, unrelated cached file, or stale actor", async () => { + const { saved, cached } = latePreplayCacheFixture(); + for (const change of ["stream", "file", "actor"] as const) { + const memory = + change === "stream" + ? { ...cached, streamKey: "torrent:other:0" } + : change === "file" + ? { ...cached, source: "C:/fixture/selected/unrelated.srt" } + : cached; + const h = restoreHarness(memory, []); + Object.assign(h.scope, { rememberedSession: { subtitle: saved } }); + if (change === "actor") { + const stale = {}; + Object.assign(h.scope.src, { continuation: { actor: stale } }); + Object.assign(h.scope, { isPlaybackActorCurrent: (actor: unknown) => actor !== stale }); + } + h.run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(h.adds.includes(memory.source), false, change); + } +}); + +test("unavailable remembered audio produces honest one-time feedback after decoding", () => { + const source = readFileSync( + new URL("../src/views/player/hooks/use-track-autoload.ts", import.meta.url), + "utf8", + ); + const ast = ts.createSourceFile("autoload.ts", source, ts.ScriptTarget.Latest, true); + let effect: ts.Node | undefined; + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + node.expression.getText(ast) === "useEffect" && + node.arguments[0]?.getText(ast).includes("The saved audio track could not be restored.") + ) + effect = node.arguments[0]; + ts.forEachChild(node, visit); + }; + visit(ast); + assert.ok(effect); + const compiled = ts.transpileModule(`const effect = ${effect.getText(ast)};`, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + const errors: string[] = []; + const scope = { + rememberedAudio: { id: "1", lang: "ara", title: "Arabic" }, + src: { url: "fixture.mkv" }, + snap: { + firstFrameReady: false, + audioTracks: [ + { id: "1", kind: "audio", selected: true, title: "English", label: "English", lang: "eng" }, + ], + }, + selectPlaybackAudio, + audioRestoreWarningRef: { current: null }, + autoAudioIdRef: { current: "1" }, + emitListToast: (message: string) => errors.push(message), + t: (key: string) => key, + }; + const run = () => + new Function(...Object.keys(scope), `${compiled}\nreturn effect();`)(...Object.values(scope)); + run(); + assert.deepEqual(errors, []); + scope.snap.firstFrameReady = true; + run(); + run(); + assert.deepEqual(errors, ["The saved audio track could not be restored. Choose an audio track."]); +}); diff --git a/tests/playback-subtitle-choice.test.ts b/tests/playback-subtitle-choice.test.ts new file mode 100644 index 000000000..bf585c706 --- /dev/null +++ b/tests/playback-subtitle-choice.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { playbackSubtitleChoice, selectPlaybackSubtitle } from "../src/lib/player/subtitle-choice"; +import type { TrackInfo } from "../src/lib/player/bridge"; + +const track = (id: string, title: string): TrackInfo => ({ + id, + title, + label: title, + lang: "ara", + codec: "ass", + kind: "subtitle", + selected: true, +}); + +test("embedded subtitle identity wins over a stale numeric id after track ordering changes", () => { + const selected = playbackSubtitleChoice(track("1", "Full dialogue")); + assert.equal( + selectPlaybackSubtitle([track("1", "Signs only"), track("7", "Full dialogue")], selected)?.id, + "7", + ); + assert.equal(selectPlaybackSubtitle([track("7", "Unrelated")], selected), null); +}); + +test("ephemeral subtitle URLs are not persisted as durable identities", () => { + assert.equal( + playbackSubtitleChoice({ ...track("1", "Arabic"), external: true, url: "blob:temp" }).source, + undefined, + ); + assert.equal( + playbackSubtitleChoice({ + ...track("1", "Arabic"), + external: true, + url: "blob:temp", + originalUrl: "C:/fixture/arabic.srt", + }).source, + "C:/fixture/arabic.srt", + ); + assert.equal( + playbackSubtitleChoice( + { + ...track("1", "Arabic"), + external: true, + url: "blob:temp", + subId: "id", + provider: "Provider", + }, + { source: "blob:old", subId: "id", provider: "Provider", updatedAt: 1 }, + ).source, + undefined, + ); +}); + +test("durable local subtitle identity accepts platform path separators without accepting another language", () => { + const local = { ...track("1", "Arabic"), external: true, originalUrl: "C:/fixture/arabic.srt" }; + assert.equal( + selectPlaybackSubtitle([local], { external: true, source: "C:\\fixture\\arabic.srt" })?.id, + "1", + ); + const choice = { external: true, subId: "provider:1", provider: "Provider", lang: "ara" }; + assert.equal( + selectPlaybackSubtitle( + [{ ...local, subId: "provider:1", provider: "Provider", lang: "eng" }], + choice, + ), + null, + ); +}); + +test("indistinguishable embedded subtitle tracks are not guessed using an old numeric id", () => { + const choice = playbackSubtitleChoice(track("1", "Full dialogue")); + assert.equal( + selectPlaybackSubtitle([track("1", "Full dialogue"), track("2", "Full dialogue")], choice), + null, + ); +}); diff --git a/tests/subtitle-last-choice.test.ts b/tests/subtitle-last-choice.test.ts index e4b077344..6fa2ed403 100644 --- a/tests/subtitle-last-choice.test.ts +++ b/tests/subtitle-last-choice.test.ts @@ -109,6 +109,23 @@ test("a confirmed choice can finish copying after exit without losing the local assert.equal(h.saved.get("fixture||")?.source, "cached-6.srt"); }); +test("late selected-subtitle cache attaches to the captured observed session, never a newly opened player", async () => { + const h = memoryControlsHarness(); + const copying = deferred(); + h.cacheWith(() => copying.promise); + const captured = { id: "old-observed-session" }; + h.actualPlayback(captured); + const player = h.mount(six); + player.rememberSubChoice(six); + player.unmount(); + h.actualPlayback({ id: "new-session" }); + copying.resolve({ ...six, source: "C:/fixture/cached-six.srt", imported: true }); + await flushBridge(); + assert.equal(h.actualCachePatches.length, 1); + assert.equal(h.actualCachePatches[0][0], captured); + assert.equal((h.actualCachePatches[0][2] as SubChoiceInput).source, "C:/fixture/cached-six.srt"); +}); + test("Off cancels a waiting cache and cannot be overwritten by an older file copy", async () => { const h = memoryControlsHarness(); const copying = deferred(); diff --git a/tests/subtitle-native-mpv.test.ts b/tests/subtitle-native-mpv.test.ts index 1136cc397..a222ed7f1 100644 --- a/tests/subtitle-native-mpv.test.ts +++ b/tests/subtitle-native-mpv.test.ts @@ -151,6 +151,9 @@ test( "sub-start", "time-pos", "pause", + "sub-fps", + "sub-delay", + "audio-delay", ].entries()) { await command(["observe_property", i + 1, property]); } @@ -172,6 +175,27 @@ test( assert.equal(String(await command(["get_property", "sid"])), english); assert.equal(await command(["get_property", "sub-visibility"]), false); + // Verify real property events reach the persisted-session snapshot. This + // exercises native mpv + the bridge, not a full Tauri restart. + await command(["set_property", "sub-fps", 25]); + h.bridge.setSubDelay(0.35); + h.bridge.setAudioDelay(-0.2); + await eventually( + () => + h.snapshot().subtitleFps === 25 && + h.snapshot().subDelaySec === 0.35 && + h.snapshot().audioDelaySec === -0.2, + ); + await command(["set_property", "sub-fps", 0]); + h.bridge.setSubDelay(0); + h.bridge.setAudioDelay(0); + await eventually( + () => + h.snapshot().subtitleFps === 0 && + h.snapshot().subDelaySec === 0 && + h.snapshot().audioDelaySec === 0, + ); + // Run the real style writer against this isolated native player's IPC. const styleSource = readFileSync( new URL("../src/lib/player/sub-style.ts", import.meta.url), From 29cdab4829971726107bf57bfc0109f3950cbacf Mon Sep 17 00:00:00 2001 From: OwaisByte <269550201+OwaisByte@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:54:18 +1100 Subject: [PATCH 2/8] feat(context-menu): simplify item and background commands Simplify item menus, retain contextual copy commands, and keep an unavailable Resume row visible. Remove unused toolbar copy metadata and update menu fixtures for Queue. Co-authored-by: OpenAI Codex --- src/assets/ui-icons/add-to.svg | 9 +- src/components/context-menu.tsx | 214 +++----- .../context-menu/action-command.tsx | 2 +- .../context-menu/content-actions.tsx | 85 +--- .../context-menu/last-playback-command.tsx | 27 +- .../context-menu/menu-brand-footer.css | 15 +- .../context-menu/menu-brand-footer.tsx | 28 +- .../context-menu/player-actions.tsx | 50 +- src/components/continue-card.tsx | 4 +- src/components/music/music-item-menu.tsx | 32 +- src/components/music/music-track-menu.tsx | 12 - src/lib/context-actions.ts | 1 - src/lib/context-menu.tsx | 1 - src/lib/context-page-background.ts | 31 ++ src/lib/context-quick-actions.ts | 24 +- src/lib/continue-card-actions.ts | 41 +- src/lib/membership-actions.ts | 2 +- src/views/group/post-item.tsx | 1 - src/views/manga/manga-reader/book-view.tsx | 38 +- .../manga/manga-reader/reader-context.ts | 13 - src/views/profile/comment-item.tsx | 1 - .../community-store/comments/comment-item.tsx | 1 - tests/context-content-actions.test.ts | 77 +-- tests/context-image-exclusion.test.tsx | 191 +++++++ tests/context-menu-actor-scope.test.ts | 147 ++++-- tests/context-menu-local-presence.test.ts | 2 + tests/context-menu-simplification.test.tsx | 471 ++++++++++++++++++ tests/context-menu-surface.test.ts | 37 +- tests/context-page-background.test.ts | 4 +- tests/context-quick-actions.test.ts | 158 +++--- tests/context-reader-pages.test.ts | 16 - tests/context-tooltip-intent.test.ts | 57 ++- tests/continue-card-actions.test.ts | 31 +- tests/continue-card-interaction.test.ts | 22 +- tests/last-playback-command.test.ts | 60 ++- tests/membership-actions.test.ts | 2 +- tests/membership-controls.test.ts | 1 + tests/menu-brand-footer.test.ts | 36 +- tests/player-menu-tools.test.ts | 119 ++--- 39 files changed, 1367 insertions(+), 696 deletions(-) delete mode 100644 src/views/manga/manga-reader/reader-context.ts create mode 100644 tests/context-image-exclusion.test.tsx create mode 100644 tests/context-menu-simplification.test.tsx diff --git a/src/assets/ui-icons/add-to.svg b/src/assets/ui-icons/add-to.svg index 6e2f4204d..3a1159dd3 100644 --- a/src/assets/ui-icons/add-to.svg +++ b/src/assets/ui-icons/add-to.svg @@ -1,6 +1,7 @@ - - - - + + + + + diff --git a/src/components/context-menu.tsx b/src/components/context-menu.tsx index 65cc333ac..38b6f1e43 100644 --- a/src/components/context-menu.tsx +++ b/src/components/context-menu.tsx @@ -19,7 +19,6 @@ import { RotateCcw, Share2, UserPlus, - Wallpaper, X, ListPlus, } from "lucide-react"; @@ -51,7 +50,6 @@ import { import { useContextWatchedState } from "@/lib/context-watched-state"; import { useTmdbImdbId } from "@/lib/providers/tmdb"; import { useIsFavorite } from "@/lib/media-favorites"; -import { clearTitleBackdrop, getTitleBackdrop, setTitleBackdrop } from "@/lib/title-backdrop"; import { MyListSubmenu } from "./context-menu/my-list-submenu"; import { useSettings } from "@/lib/settings"; import { @@ -86,18 +84,12 @@ import { anyProfileSharesStremioWith, useProfiles } from "@/lib/profiles"; import { currentAuthor, subscribeAuthor } from "@/lib/theme-auth"; import { MenuSurface, menuItemClass, useMenuExecution } from "./context-menu/menu-surface"; import { ActionItems, QuickActions, MenuIcon } from "./context-menu/action-items"; -import { findAction, type ActionSource } from "@/lib/context-actions"; -import { - contextNavigationFooter, - omitContextActions, - partitionContextActions, -} from "@/lib/context-quick-actions"; +import type { ActionSource } from "@/lib/context-actions"; +import { contextNavigationFooter, partitionContextActions } from "@/lib/context-quick-actions"; import { contentActions } from "./context-menu/content-actions"; -import { ContextImageViewer } from "./context-image-viewer"; -import type { ContextImage } from "@/lib/context-image"; import { parseExternalLink } from "@/lib/social/external-link-policy"; import { usePageContextTarget } from "@/chrome/context-page-navigation"; -import { isPageContextBackground } from "@/lib/context-page-background"; +import { isBackgroundMenuTarget, isPageContextBackground } from "@/lib/context-page-background"; import { useManualDownload } from "./context-menu/use-manual-download"; import { LastPlaybackCommand } from "./context-menu/last-playback-command"; import { MenuBrandFooter } from "./context-menu/menu-brand-footer"; @@ -155,7 +147,6 @@ export function ContextMenu() { openService, openAddonDetail, openSettings, - meta: currentMeta, topKind, topPath, player, @@ -173,6 +164,8 @@ export function ContextMenu() { () => currentAuthor()?.id ?? null, () => null, ); + // A docked live player can coexist with page space. The semantic target below + // still excludes the player surface itself from navigation and last playback. const pageContext = usePageContextTarget({ includePlayer: true }); const playerMenu = useRef({ session: state?.session, src: playerActions?.src }); if (playerMenu.current.session !== state?.session) @@ -187,15 +180,8 @@ export function ContextMenu() { const { beginManualDownload, manualDownloadDialog } = useManualDownload({ actorKey: `${activeProfile?.id ?? ""}:${authorId ?? ""}:${stremioUser?._id ?? ""}`, }); - const [viewedImage, setViewedImage] = useState<{ - image: ContextImage; - origin: HTMLElement | null; - } | null>(null); - const viewImage = (image: ContextImage) => - setViewedImage({ image, origin: state?.origin ?? null }); useEffect(() => { closeCurrent(false); - setViewedImage(null); }, [topPath, activeProfile?.id, authorId, stremioUser?._id, closeCurrent]); const { openAt } = useContextMenu(); useEffect(() => { @@ -389,15 +375,11 @@ export function ContextMenu() { open(e, pageContext); return; } - const backdropEl = - e.target instanceof HTMLElement ? e.target.closest("[data-title-backdrop]") : null; - if (backdropEl && currentMeta) { - const backdropUrl = backdropEl.getAttribute("data-title-backdrop"); - if (backdropUrl) { - e.preventDefault(); - open(e, { kind: "backdrop", metaId: currentMeta.id, url: backdropUrl }); - return; - } + if (el?.closest("[data-title-backdrop]")) { + // Artwork without context commands is still an occupied surface. + e.preventDefault(); + closeCurrent(false); + return; } if (el?.closest("[data-harbor-player]") && player?.meta) { const source = currentPlayerActions()?.src; @@ -429,22 +411,9 @@ export function ContextMenu() { }; document.addEventListener("contextmenu", handler); return () => document.removeEventListener("contextmenu", handler); - }, [open, closeCurrent, currentMeta, player?.meta, topKind, activeAddon, pageContext, inSession]); + }, [open, closeCurrent, player?.meta, topKind, activeAddon, pageContext, inSession]); - const viewer = viewedImage ? ( - setViewedImage(null)} - /> - ) : null; - if (!state) - return ( - <> - {viewer} - {manualDownloadDialog} - - ); + if (!state) return manualDownloadDialog; const subtitleDetails = state.target.kind === "subtitle" ? (state.target.details ?? null) : null; const target = state.target; @@ -454,19 +423,12 @@ export function ContextMenu() { ? { src: target.meta.poster, publicUrl: target.meta.poster, label: target.meta.name } : undefined); const isPlayer = target.kind === "meta" && !!target.player; - const allowQuick = - target.navigation !== false && - target.kind !== "edit" && - target.kind !== "subtitle" && - target.kind !== "backdrop" && - !(target.kind === "meta" && target.player); + const isBackground = isBackgroundMenuTarget(target); const navigation = () => - (allowQuick || isPlayer) && pageContext?.kind === "actions" && pageContext.isValid?.() !== false + isBackground && pageContext?.kind === "actions" && pageContext.isValid?.() !== false ? pageContext.actions() : []; - const playerSources = isPlayer - ? playerContextSources(readMenuPlayer, () => findAction(navigation(), "page:go:settings")) - : null; + const playerSources = isPlayer ? playerContextSources(readMenuPlayer) : null; const availableActions = (source?: ActionSource) => source?.isValid?.() === false ? [] : (source?.actions() ?? []); const makeParts = () => { @@ -476,43 +438,32 @@ export function ContextMenu() { : target.kind === "meta" ? [...availableActions(target.primary), ...availableActions(target.extra)] : []; - const content = contentActions( - { ...target, image: artwork }, - viewImage, - target.kind === "content", - ); - return allowQuick - ? partitionContextActions(entity, content, navigation()) - : { quick: [], entity, content }; + const content = contentActions({ ...target, image: artwork }); + return partitionContextActions(entity, content, navigation(), target); }; const parts = makeParts(); const filteredSource = (source: ActionSource): ActionSource => ({ ...source, - actions: () => - omitContextActions( - source.actions(), - new Set([ - ...makeParts().quick.map((action) => action.id), - ...contextNavigationFooter(navigation(), makeParts().quick).map((action) => action.id), - ]), - ), + // Page commands are composed once, only for a semantic background target. + // Item-specific copies stay in their original source as ordinary rows. + actions: () => source.actions().filter((action) => !action.id.startsWith("page:")), }); - const quickSource: ActionSource = playerSources?.quick ?? { + const quickSource: ActionSource = { actions: () => makeParts().quick, isValid: target.isValid, }; - const menuWidth = subtitleDetails - ? SUBTITLE_MENU_WIDTH - : allowQuick || isPlayer - ? 264 - : MENU_WIDTH; + const menuWidth = subtitleDetails ? SUBTITLE_MENU_WIDTH : isBackground ? 264 : MENU_WIDTH; const items: React.ReactNode[] = []; let membershipMenu: { index: number; props: React.ComponentProps; } | null = null; - if (state.target.kind === "meta" && state.target.primary) + if ( + state.target.kind === "meta" && + state.target.primary && + filteredSource(state.target.primary).actions().length + ) items.push( , , @@ -734,10 +685,9 @@ export function ContextMenu() { }, }; if (organizedMedia && !playerActions && (!episodeScope || targetEpisode)) { - items.push(); const summary = watchedState.summary; const status = watchedState.loading - ? t("Checking watched status…") + ? null : summary?.status === "unknown" ? t("Watched status unavailable") : summary?.status === "partial" @@ -746,6 +696,20 @@ export function ContextMenu() { total: summary.total, }) : null; + const choices = watchedState.loading + ? [] + : summary?.status === "watched" + ? [false] + : summary?.status === "unwatched" + ? [true] + : [true, false]; + if ( + status || + summary?.unavailable.length || + choices.length || + watchedError?.session === state.session + ) + items.push(); if (status) items.push(

@@ -760,13 +724,6 @@ export function ContextMenu() { })}

, ); - const choices = watchedState.loading - ? [] - : summary?.status === "watched" - ? [false] - : summary?.status === "unwatched" - ? [true] - : [true, false]; choices.forEach((on, index) => items.push( , ); } - items.push( - } - label={t("Share as link")} - onClick={() => shareLink(meta.type, meta.id)} - />, - ); + if (meta.type !== "movie" && meta.type !== "series") + items.push( + } + label={t("Share as link")} + onClick={() => shareLink(meta.type, meta.id)} + />, + ); if (organizedMedia && inSession && !playerActions) { items.push( , ); } - } else if (state.target.kind === "backdrop") { - const { metaId, url } = state.target; - const isCurrent = getTitleBackdrop(metaId) === url; - items.push( - } - label={t("Set as a backdrop")} - onClick={() => { - setTitleBackdrop(metaId, url); - close(); - }} - accent={isCurrent} - />, - ); - if (getTitleBackdrop(metaId)) { - items.push( - } - label={t("Reset to original")} - onClick={() => { - clearTitleBackdrop(metaId); - close(); - }} - />, - ); - } } else if (state.target.kind === "subtitle") { const { download, details } = state.target; if (details) { @@ -1313,15 +1243,16 @@ export function ContextMenu() { , ); if (parts.content.some((action) => action.id === "selection:copy")) { + const separator = items.length ? [] : []; items.unshift( contentActions({ selection: target.selection }, viewImage, true) }} + source={{ actions: () => contentActions({ selection: target.selection }) }} onClose={close} />, - , + ...separator, ); - if (membershipMenu) membershipMenu.index += 2; + if (membershipMenu) membershipMenu.index += 1 + separator.length; } const tools = parts.content.filter((action) => action.id !== "selection:copy"); if (tools.length) @@ -1334,24 +1265,22 @@ export function ContextMenu() { onClose={close} />, ); - // The player reuses navigation policy for Settings only, never browsing rows. const navigationFooter = () => - isPlayer ? [] : contextNavigationFooter(navigation(), makeParts().quick); + isBackground ? contextNavigationFooter(navigation(), makeParts().quick) : []; const footer = navigationFooter(); if (items.length === 0 && parts.quick.length === 0 && footer.length === 0) return ( <> - {viewer} + {manualDownloadDialog} ); return ( <> - {viewer} {manualDownloadDialog} - - {(last) => ( + + {(_last, lastRow) => ( - {last && ( - <> - [last] }} onClose={close} /> - {(items.length > 0 || footer.length > 0) && } - - )} + {lastRow} {membershipMenu ? ( <> {items.slice(0, membershipMenu.index)} @@ -1411,6 +1335,24 @@ export function ContextMenu() { ); } +/** A consumed image/empty entity must not keep hover previews or validation + * listeners suspended just because it has no menu surface to finish closing. */ +function EmptyMenu({ + session, + close, + completeClose, +}: { + session: number; + close: (restoreFocus: boolean, expectedSession: number) => void; + completeClose: (expectedSession: number) => void; +}) { + useEffect(() => { + close(false, session); + completeClose(session); + }, [session, close, completeClose]); + return null; +} + function SubtitleDetailsCard({ details, onBack, diff --git a/src/components/context-menu/action-command.tsx b/src/components/context-menu/action-command.tsx index 9e527494c..9b895bab5 100644 --- a/src/components/context-menu/action-command.tsx +++ b/src/components/context-menu/action-command.tsx @@ -97,7 +97,7 @@ export function ActionCommand({ > { if (!(await copyText(text))) throw new Error(t("Could not copy to the clipboard.")); } -export function contentActions( - content: ContentContext, - showImage: (image: ContextImage) => void, - standalone: boolean, -): ContextAction[] { +export function contentActions(content: ContentContext): ContextAction[] { const actions: ContextAction[] = []; if (content.selection) actions.push({ @@ -33,9 +22,15 @@ export function contentActions( group: "selection", }); const parsed = content.link && parseExternalLink(content.link); - const imageLink = content.image && publicContextImageUrl(content.image); const linkIsImage = - !!imageLink && parsed && parsed.ok && new URL(content.link!).href === imageLink; + parsed && + parsed.ok && + content.image && + [content.image.src, content.image.originalSrc, content.image.publicUrl].some((src) => { + if (!src) return false; + const imageLink = parseExternalLink(src); + return imageLink.ok && imageLink.link.href === parsed.link.href; + }); if (parsed && parsed.ok && !linkIsImage) { actions.push({ id: "link:open", @@ -55,63 +50,5 @@ export function contentActions( group: "link", }); } - if (content.image) { - const image = content.image; - const children: ContextAction[] = [ - { - id: "image:view", - label: t("View image"), - icon: , - run: () => showImage(image), - restoreFocus: false, - }, - { - id: "image:save", - label: t("Save image…"), - icon: , - run: async () => { - await saveContextImage(image); - }, - }, - ...(canCopyContextImage() - ? [ - { - id: "image:copy", - label: t("Copy image"), - icon: , - run: () => copyContextImage(image), - }, - ] - : []), - ]; - const url = publicContextImageUrl(image); - if (url) - children.push( - { - id: "image:copy-link", - label: t("Copy image link"), - group: "link", - icon: , - run: () => copyContextText(url), - }, - { - id: "image:open-link", - label: t("Open image link"), - group: "link", - icon: , - run: () => openLinkOut(url), - restoreFocus: false, - }, - ); - if (standalone) actions.push(...children); - else - actions.push({ - id: "image", - label: t("Image"), - group: "content", - icon: , - children, - }); - } return actions; } diff --git a/src/components/context-menu/last-playback-command.tsx b/src/components/context-menu/last-playback-command.tsx index 9a4ac027e..ac1087a72 100644 --- a/src/components/context-menu/last-playback-command.tsx +++ b/src/components/context-menu/last-playback-command.tsx @@ -32,11 +32,11 @@ function PlaybackArtwork({ src }: { src?: string }) { type LastPlaybackCommandProps = { onClose: (restoreFocus?: boolean) => void; enabled?: boolean; - children?: (action: ContextAction | null) => ReactNode; + children?: (action: ContextAction | null, row: ReactNode | null) => ReactNode; }; export function LastPlaybackCommand({ enabled = true, ...props }: LastPlaybackCommandProps) { - return enabled ? : props.children?.(null); + return enabled ? : props.children?.(null, null); } function ResolvedLastPlaybackCommand({ onClose, children }: LastPlaybackCommandProps) { @@ -54,14 +54,25 @@ function ResolvedLastPlaybackCommand({ onClose, children }: LastPlaybackCommandP } : null, ); - if (!target) return children?.(null) ?? null; + if (!target) { + const action: ContextAction = { + id: "playback:last:unavailable", + label: t("Resume last watched"), + icon: , + disabled: true, + }; + const row = ( + [action] }} onClose={onClose} /> + ); + return children ? children(action, row) : row; + } const presentation = lastPlaybackPresentation(target, settings, summary?.status === "watched"); const tooltip = [presentation.title ?? t("Local video"), presentation.episode] .filter(Boolean) .join(" · "); const action: ContextAction = { id: `playback:last:${target.id}`, - label: t("Continue last watched"), + label: t("Resume last watched"), icon: , reason: tooltip, tooltipIntent: "deliberate", @@ -83,10 +94,8 @@ function ResolvedLastPlaybackCommand({ onClose, children }: LastPlaybackCommandP await continuation.continuePlayback(target, { restart: target.completed }); }, }; - if (children) return children(action); - return ( - <> - [action] }} onClose={onClose} /> - + const row = ( + [action] }} onClose={onClose} /> ); + return children ? children(action, row) : row; } diff --git a/src/components/context-menu/menu-brand-footer.css b/src/components/context-menu/menu-brand-footer.css index b8742c521..98e028831 100644 --- a/src/components/context-menu/menu-brand-footer.css +++ b/src/components/context-menu/menu-brand-footer.css @@ -6,7 +6,7 @@ gap: 3px; min-height: 16px; padding: 3px 8px 1px; - color: var(--color-ink); + color: #eba018; opacity: 0.68; pointer-events: none; user-select: none; @@ -25,11 +25,14 @@ object-fit: contain; } -/* Follow the actual chrome's monochrome mark; img assets retain their colors. */ -html:is([data-theme-layout="dracula"], [data-theme-layout="rail"]) .context-menu-brand-footer { - color: var(--color-accent); +.context-menu-brand-filter { + position: absolute; + width: 0; + height: 0; + overflow: hidden; } -html[data-theme-layout="stremio"] .context-menu-brand-footer { - color: rgb(255 255 255 / 90%); +/* Keep the menu's mark monochrome even when the kid theme colors its sails. */ +.context-menu-brand-footer > svg.context-menu-brand-mark path { + fill: currentColor; } diff --git a/src/components/context-menu/menu-brand-footer.tsx b/src/components/context-menu/menu-brand-footer.tsx index ccef6e23e..c2cc31ee3 100644 --- a/src/components/context-menu/menu-brand-footer.tsx +++ b/src/components/context-menu/menu-brand-footer.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import { HarborMark } from "@/components/icons/harbor-mark"; import { useHarborLogo } from "@/lib/harbor-logo"; import "./menu-brand-footer.css"; @@ -6,18 +6,30 @@ import "./menu-brand-footer.css"; /** Decorative identity for the root context menu, never a menu item. */ export function MenuBrandFooter() { const { mark, wordmark } = useHarborLogo(); + const tintId = useId(); const [failedMark, setFailedMark] = useState(null); const [failedWordmark, setFailedWordmark] = useState(null); return (
{mark && mark !== failedMark ? ( - setFailedMark(mark)} - /> + <> + + + + + + + + + setFailedMark(mark)} + /> + ) : ( )} diff --git a/src/components/context-menu/player-actions.tsx b/src/components/context-menu/player-actions.tsx index 15d884c26..1a035f976 100644 --- a/src/components/context-menu/player-actions.tsx +++ b/src/components/context-menu/player-actions.tsx @@ -1,12 +1,4 @@ -import { - AudioLines, - Download, - ExternalLink, - Link2, - Magnet, - Maximize, - Settings, -} from "lucide-react"; +import { AudioLines, Download, ExternalLink, Link2, Magnet, Maximize } from "lucide-react"; import type { ActionSource, ContextAction } from "@/lib/context-actions"; import type { PlayerActions } from "@/lib/player-actions"; import { t } from "@/lib/i18n"; @@ -14,37 +6,11 @@ import { openLinkOut } from "@/lib/social/link-out"; import { copyContextText } from "./content-actions"; /** Reads the active session again at invocation; source changes never reuse an old URL. */ -export function playerContextSources( - readCurrent: () => PlayerActions | null, - readSettings: () => ContextAction | undefined, -): { quick: ActionSource; rows: ActionSource } { +export function playerContextSources(readCurrent: () => PlayerActions | null): { + rows: ActionSource; +} { const isValid = () => readCurrent() != null; return { - quick: { - isValid, - actions: () => { - const player = readCurrent(); - if (!player) return []; - const actions: ContextAction[] = []; - if (player.magnetUrl) - actions.push({ - id: "player:copy-magnet", - label: t("Copy magnet"), - icon: , - run: () => copyContextText(player.magnetUrl!), - }); - const settings = readSettings(); - if (settings) - actions.push({ - ...settings, - id: "player:settings", - label: t("Settings"), - icon: settings.icon ?? , - restoreFocus: false, - }); - return actions; - }, - }, rows: { isValid, actions: () => { @@ -106,6 +72,14 @@ export function playerContextSources( group: "links", }, ); + if (player.magnetUrl) + actions.push({ + id: "player:copy-magnet", + label: t("Copy magnet"), + icon: , + run: () => copyContextText(player.magnetUrl!), + group: "links", + }); return actions; }, }, diff --git a/src/components/continue-card.tsx b/src/components/continue-card.tsx index 3e1187166..6b976a854 100644 --- a/src/components/continue-card.tsx +++ b/src/components/continue-card.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { Check, X, Play as ContextPlay, ListVideo } from "lucide-react"; +import { Check, X, ListVideo } from "lucide-react"; import { Play } from "@/components/icons/play-filled"; import simklLogo from "@/assets/simkl.png"; import traktLogo from "@/assets/trakt.svg"; @@ -666,7 +666,6 @@ export const ContinueCard = memo(function ContinueCard({ }, onError: reportActionError, icons: { - play: , sources: , dismiss: , }, @@ -697,7 +696,6 @@ export const ContinueCard = memo(function ContinueCard({ } : (ep ?? undefined), primary: commands.primary, - extra: commands.extra, }); return ( diff --git a/src/components/music/music-item-menu.tsx b/src/components/music/music-item-menu.tsx index 3f0506590..9f41cda79 100644 --- a/src/components/music/music-item-menu.tsx +++ b/src/components/music/music-item-menu.tsx @@ -1,5 +1,5 @@ import { useRef, useState, type MouseEvent } from "react"; -import { ChevronRight, ImageDown, Play, UserRound } from "@/components/icons/music-icons"; +import { ChevronRight, Play, UserRound } from "@/components/icons/music-icons"; import { coverCardTitle } from "@/components/music/music-cover-card"; import { useMusicNavigate } from "@/components/music/music-navigate"; import { useMusicPlaylistPicker } from "@/components/music/music-playlist-picker"; @@ -10,7 +10,6 @@ import { } from "@/components/music/music-track-menu"; import { useT } from "@/lib/i18n"; import { enqueueMusic, toggleMusicLiked } from "@/lib/music/player"; -import { saveArtwork } from "@/lib/music/artwork-save"; import { pinMusicPlaylist, unpinMusicPlaylist, @@ -71,19 +70,6 @@ export function musicItemMenuItems({ icon: , run: onGoToArtist, }); - if (item.artwork) { - const art = Array.isArray(item.artwork) ? item.artwork[0] : item.artwork; - const name = coverCardTitle(item); - const by = "artist" in item ? (item.artist ?? "") : ""; - items.push({ - id: "artwork", - label: t("music.artwork.save"), - icon: , - run: async () => { - await saveArtwork(art, name, by); - }, - }); - } return items; } @@ -128,8 +114,7 @@ export function useMusicItemMenu(handlers: Handlers) { const track = item?.kind === "track" ? item : null; // Only Harbor's own playlists have a playlist page to pin; catalog playlists // live in whichever service they came from. - const localPlaylist = - item?.kind === "playlist" && item.connectorId === "local" ? item : null; + const localPlaylist = item?.kind === "playlist" && item.connectorId === "local" ? item : null; const pinned = useMusicPlaylistPinned(localPlaylist?.id ?? ""); const trackItems = useMusicTrackMenuItems(track, { onPlay: handlers.onPlay && track ? () => handlers.onPlay?.(track, index) : undefined, @@ -169,9 +154,10 @@ export function useMusicItemMenu(handlers: Handlers) { const open = (item: MusicCatalogItem, index: number, event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); - const target = event.target instanceof Element - ? event.target.closest("button, [href], [tabindex]:not([tabindex='-1'])") - : null; + const target = + event.target instanceof Element + ? event.target.closest("button, [href], [tabindex]:not([tabindex='-1'])") + : null; anchor.current = target && event.currentTarget.contains(target) ? target : event.currentTarget; setTarget({ item, @@ -187,8 +173,10 @@ export function useMusicItemMenu(handlers: Handlers) { return { open, close, - openFor: (item: MusicCatalogItem, index = 0) => (event: MouseEvent) => - open(item, index, event), + openFor: + (item: MusicCatalogItem, index = 0) => + (event: MouseEvent) => + open(item, index, event), isOpen: target !== null, menu: ( , - run: async () => { - await saveArtwork(track.artwork, track.album || track.title, track.artist); - }, - }); - } if (onAddToQueue) { items.push({ id: "queue", diff --git a/src/lib/context-actions.ts b/src/lib/context-actions.ts index d63dde782..6eada7c52 100644 --- a/src/lib/context-actions.ts +++ b/src/lib/context-actions.ts @@ -28,7 +28,6 @@ export type ContextAction = { checked?: boolean; dismiss?: "on-success" | "keep-open"; confirmation?: ActionConfirmation; - quickCopy?: boolean; searchable?: boolean; searchPlaceholder?: string; submenuVariant?: "navigation"; diff --git a/src/lib/context-menu.tsx b/src/lib/context-menu.tsx index f3fe4a8d9..d6ce6a815 100644 --- a/src/lib/context-menu.tsx +++ b/src/lib/context-menu.tsx @@ -75,7 +75,6 @@ export type ContextMenuTarget = ContentContext & { | { kind: "ebook"; id: string } | { kind: "nav"; itemId?: NavItemId; view?: View; label?: string; onOpen?: () => void } | { kind: "edit"; element: HTMLElement | null; selection: string } - | { kind: "backdrop"; metaId: string; url: string } | { kind: "subtitle"; label: string; diff --git a/src/lib/context-page-background.ts b/src/lib/context-page-background.ts index 8d6afd77d..b8c1fa88b 100644 --- a/src/lib/context-page-background.ts +++ b/src/lib/context-page-background.ts @@ -6,6 +6,9 @@ const OWNED_CONTENT = [ "select", "option", "iframe", + "img", + "picture", + "svg", "canvas", "video", "audio", @@ -16,6 +19,8 @@ const OWNED_CONTENT = [ "[role='textbox']", "[data-harbor-context-layer]", "[data-harbor-player]", + "[data-harbor-image-viewer]", + "[data-title-backdrop]", "[data-ebook-page]", "[data-bp-root]", "[data-bp-focusable]", @@ -33,3 +38,29 @@ export function isPageContextBackground(target: Element | null): boolean { ) ); } + +/** A generic action target is not page space. Only the trusted page binding can + * opt in, and content under the pointer always keeps its own context. */ +export function isBackgroundMenuTarget( + target: + | { + kind: string; + scope?: "page-background"; + navigation?: boolean; + image?: unknown; + link?: string; + selection?: string; + } + | null + | undefined, +): boolean { + return ( + !!target && + target.kind === "actions" && + target.scope === "page-background" && + target.navigation !== false && + !target.image && + !target.link && + !target.selection + ); +} diff --git a/src/lib/context-quick-actions.ts b/src/lib/context-quick-actions.ts index f1db7f031..dceeb8f41 100644 --- a/src/lib/context-quick-actions.ts +++ b/src/lib/context-quick-actions.ts @@ -1,4 +1,6 @@ import { findAction, type ContextAction } from "./context-actions"; +import type { ContextMenuTarget } from "./context-menu"; +import { isBackgroundMenuTarget } from "./context-page-background"; export function omitContextActions( actions: ContextAction[], @@ -16,22 +18,22 @@ export function partitionContextActions( entity: ContextAction[], content: ContextAction[], navigation: ContextAction[], + target?: ContextMenuTarget, ): { quick: ContextAction[]; entity: ContextAction[]; content: ContextAction[] } { - const find = findAction; - const copy = [ - entity.find((action) => action.quickCopy), - find(content, "image:copy"), - find(content, "link:copy"), - find(content, "selection:copy"), - ].find((action) => action && !action.disabled); - const quick = [find(navigation, "page:back"), copy, find(navigation, "page:go:settings")].filter( - (action): action is ContextAction => !!action, + const quick = ( + isBackgroundMenuTarget(target) + ? [findAction(navigation, "page:back"), findAction(navigation, "page:go:settings")] + : [] + ).filter((action): action is ContextAction => !!action); + // Page commands are exclusively rendered in the background strip/footer. + // Item copy commands remain in their original command list. + const moved = new Set( + entity.filter((action) => action.id.startsWith("page:")).map((action) => action.id), ); - const moved = new Set(quick.map((action) => action.id)); return { quick, entity: omitContextActions(entity, moved), - content: omitContextActions(content, moved), + content, }; } diff --git a/src/lib/continue-card-actions.ts b/src/lib/continue-card-actions.ts index 6f09e59cc..0e534c6a6 100644 --- a/src/lib/continue-card-actions.ts +++ b/src/lib/continue-card-actions.ts @@ -33,7 +33,7 @@ export function createContinueCardActions(options: { dismiss?: () => void | CwDismissResult | Promise; onDismissed?: (result: void | CwDismissResult) => void; onError?: (error: unknown) => void; - icons?: { play: ReactNode; sources: ReactNode; dismiss: ReactNode }; + icons?: { sources: ReactNode; dismiss: ReactNode }; }) { const identity = continueCardIdentity(options.item); const listeners = new Set<() => void>(); @@ -88,15 +88,6 @@ export function createContinueCardActions(options: { const disabled = pending || !isValid() || waitingForAir(); const reason = waitingForAir() ? options.t("This episode has not aired yet.") : undefined; return [ - { - id: `continue:play:${identity}`, - label: options.t("Continue watching"), - icon: options.icons?.play, - disabled, - reason, - restoreFocus: false, - run: () => run("play"), - }, { id: `continue:sources:${identity}`, label: options.t("Choose another source"), @@ -106,30 +97,24 @@ export function createContinueCardActions(options: { restoreFocus: false, run: () => run("sources"), }, + ...(options.dismiss + ? [ + { + id: `continue:dismiss:${identity}`, + label: options.t("Remove from Continue Watching"), + icon: options.icons?.dismiss, + disabled: pending || !isValid(), + restoreFocus: false, + run: () => run("dismiss"), + }, + ] + : []), ]; }, }; - const extra: ActionSource = { - isValid, - subscribe, - actions: () => - options.dismiss - ? [ - { - id: `continue:dismiss:${identity}`, - label: options.t("Remove from Continue Watching"), - icon: options.icons?.dismiss, - disabled: pending || !isValid(), - restoreFocus: false, - run: () => run("dismiss"), - }, - ] - : [], - }; return { isValid, primary, - extra, subscribe, isPending: () => pending, play: () => run("play"), diff --git a/src/lib/membership-actions.ts b/src/lib/membership-actions.ts index ad60d9cbb..c5c409cbd 100644 --- a/src/lib/membership-actions.ts +++ b/src/lib/membership-actions.ts @@ -221,7 +221,7 @@ export function buildMembershipActionSource(options: Options): ActionSource { const actions: ContextAction[] = [ { id: "membership:add", - label: t("Add to"), + label: t("Add to list"), icon: options.commandIcon?.("add"), group: "membership", disabled: !available, diff --git a/src/views/group/post-item.tsx b/src/views/group/post-item.tsx index ae21c3de3..29efa94e6 100644 --- a/src/views/group/post-item.tsx +++ b/src/views/group/post-item.tsx @@ -142,7 +142,6 @@ export function PostItem({ id: `post:copy:${post.id}`, label: t("Copy Text"), icon: , - quickCopy: true, run: () => copyContextText(bodyRef.current?.innerText ?? post.body), }, ...(currentAuthor() diff --git a/src/views/manga/manga-reader/book-view.tsx b/src/views/manga/manga-reader/book-view.tsx index f1aef2b42..3cb3ae4ce 100644 --- a/src/views/manga/manga-reader/book-view.tsx +++ b/src/views/manga/manga-reader/book-view.tsx @@ -1,10 +1,6 @@ -import { Image } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { useContextMenu, useContextTarget, type ContextMenuTarget } from "@/lib/context-menu"; -import { ContextImageViewer } from "@/components/context-image-viewer"; -import type { ContextImage } from "@/lib/context-image"; import { useT } from "@/lib/i18n"; -import { visibleReaderPages } from "./reader-context"; import { fitLevel, guardPageLoaded, @@ -183,30 +179,16 @@ export function BookFlip({ const wrapRef = useRef(null); const panRef = useRef({ x: 0, y: 0 }); const zoomRef = useRef(1); - const visiblePages = useRef>([]); - const [image, setImage] = useState(null); const t = useT(); const { open } = useContextMenu(); const contextTarget = (): ContextMenuTarget => ({ kind: "actions", id: `reader-pages:${instanceName}`, label: t("Visible pages"), - actions: () => - visiblePages.current.map((page) => ({ - id: `reader-page:${instanceName}:${page.number}`, - icon: , - label: t("View page {number}", { number: page.number }), - restoreFocus: false, - run: () => - setImage({ - src: page.src, - label: t("Page {number}", { number: page.number }), - filename: `page-${page.number}`, - }), - })), + // The reader remains a semantic boundary even without contextual commands. + actions: () => [], }); const contextRef = useContextTarget(contextTarget); - const contextOrigin = useRef(null); const applyTransform = (smooth: boolean) => { const el = wrapRef.current; @@ -266,7 +248,6 @@ export function BookFlip({ ensureStyle(); let cancelled = false; let inst: FlipInstance | null = null; - visiblePages.current = []; const origFetch = window.fetch; const origOnPopState = window.onpopstate; @@ -299,7 +280,6 @@ export function BookFlip({ const d = (e as CustomEvent).detail as { page?: string | number; name?: string } | undefined; if (!d || d.name !== instanceName) return; const spread = String(d.page); - visiblePages.current = visibleReaderPages(pages, spread); const first = Number(spread.split("-")[0]); if (!Number.isFinite(first) || first <= 0) return; report.current(first - 1, spread); @@ -484,10 +464,7 @@ export function BookFlip({ return (
{ - contextOrigin.current = node; - contextRef(node); - }} + ref={contextRef} tabIndex={0} aria-label={t("Visible pages")} className="relative h-full w-full overflow-hidden" @@ -503,13 +480,6 @@ export function BookFlip({ >
- {image && ( - setImage(null)} - /> - )}
); } diff --git a/src/views/manga/manga-reader/reader-context.ts b/src/views/manga/manga-reader/reader-context.ts deleted file mode 100644 index aa4a3e320..000000000 --- a/src/views/manga/manga-reader/reader-context.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function visibleReaderPages( - pages: string[], - spread: string, -): Array<{ number: number; src: string }> { - if (!/^\d+(?:-\d+)?$/.test(spread)) return []; - const numbers = [...new Set(spread.split("-").map(Number))]; - if ( - numbers.some((number) => number < 1 || number > pages.length) || - (numbers.length === 2 && Math.abs(numbers[0] - numbers[1]) !== 1) - ) - return []; - return numbers.map((number) => ({ number, src: pages[number - 1] })); -} diff --git a/src/views/profile/comment-item.tsx b/src/views/profile/comment-item.tsx index de5f1cfec..0000cc39c 100644 --- a/src/views/profile/comment-item.tsx +++ b/src/views/profile/comment-item.tsx @@ -148,7 +148,6 @@ export function CommentItem({ id: "comment:copy", label: t("Copy Text"), icon: , - quickCopy: true, run: () => copyContextText(text.trim()), }, ...(signedIn && onToggleLike diff --git a/src/views/settings/theme-panel/custom-themes-section/community-store/comments/comment-item.tsx b/src/views/settings/theme-panel/custom-themes-section/community-store/comments/comment-item.tsx index dec582d01..86b3a83a4 100644 --- a/src/views/settings/theme-panel/custom-themes-section/community-store/comments/comment-item.tsx +++ b/src/views/settings/theme-panel/custom-themes-section/community-store/comments/comment-item.tsx @@ -88,7 +88,6 @@ export function CommentItem({ id: `theme-comment:copy:${comment.id}`, label: t("Copy Text"), icon: , - quickCopy: true, run: async () => { if (!(await copyText(comment.body))) throw new Error(t("Could not copy comment text.")); }, diff --git a/tests/context-content-actions.test.ts b/tests/context-content-actions.test.ts index a5a865edf..a68f61ba6 100644 --- a/tests/context-content-actions.test.ts +++ b/tests/context-content-actions.test.ts @@ -4,39 +4,60 @@ import assert from "node:assert/strict"; import test from "node:test"; import { contentActions } from "../src/components/context-menu/content-actions.tsx"; -test("a linked poster keeps distinct title and image links but does not duplicate the same image URL", () => { - const image = { - src: "https://images.example.org/poster.png", - publicUrl: "https://images.example.org/poster.png", - }; - const independent = contentActions( - { image, link: "https://games.example.org/game" }, - () => {}, - false, - ); +test("a linked poster keeps its independent page link without any image commands", () => { + const actions = contentActions({ + image: { + src: "https://images.example.org/poster.png", + publicUrl: "https://images.example.org/poster.png", + }, + link: "https://games.example.org/game", + }); assert.deepEqual( - independent.map((a) => a.id), - ["link:open", "link:copy", "image"], + actions.map((action) => action.id), + ["link:open", "link:copy"], ); - assert.ok(independent[2].children?.some((a) => a.id === "image:copy-link")); - const same = contentActions({ image, link: image.src }, () => {}, true); - assert.equal( - same.some((a) => a.id === "link:copy"), - false, +}); + +test("an image alone or a link to that same image has no contextual commands", () => { + for (const image of [ + { src: "https://images.example.org/poster.png" }, + { + src: "https://images.example.org/poster.png", + publicUrl: "https://images.example.org/poster.png", + }, + { + src: "https://images.example.org/small.png", + originalSrc: "https://images.example.org/full.png", + }, + { src: "blob:https://example.org/fixture" }, + { src: "data:image/png;base64,iVBORw0KGgo=" }, + ]) { + assert.deepEqual(contentActions({ image }), []); + assert.deepEqual(contentActions({ image, link: image.src }), []); + if (image.originalSrc) assert.deepEqual(contentActions({ image, link: image.originalSrc }), []); + } +}); + +test("selected text keeps its own command even when an image has no commands", () => { + const actions = contentActions({ + selection: "selected words", + image: { src: "blob:https://example.org/fixture" }, + }); + assert.deepEqual( + actions.map((action) => action.id), + ["selection:copy"], ); - assert.equal(same.filter((a) => a.id === "image:copy-link").length, 1); }); -test("selected text remains distinct, and private image URLs do not become share links", () => { - const actions = contentActions( - { selection: "selected words", image: { src: "blob:https://example.org/fixture" } }, - () => {}, - true, +test("ordinary links preserve opening and safe copying without adding image tools", () => { + const actions = contentActions({ link: "https://site.example.org/page" }); + assert.deepEqual( + actions.map((action) => action.id), + ["link:open", "link:copy"], ); - assert.equal(actions[0].id, "selection:copy"); - assert.equal( - actions.some((a) => a.id === "image:copy-link"), - false, + const privateLink = contentActions({ link: "https://site.example.org/page?token=fixture" }); + assert.deepEqual( + privateLink.map((action) => action.id), + ["link:open"], ); - assert.equal(actions.find((a) => a.id === "image:view")?.restoreFocus, false); }); diff --git a/tests/context-image-exclusion.test.tsx b/tests/context-image-exclusion.test.tsx new file mode 100644 index 000000000..d5c421a8d --- /dev/null +++ b/tests/context-image-exclusion.test.tsx @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import ts from "typescript"; +import { JSDOM } from "jsdom"; +import * as React from "react"; +import * as jsx from "react/jsx-runtime"; +import { createRoot } from "react-dom/client"; + +// Exercise the production builders/components while isolating music providers, +// native saving, and the third-party WebGL reader from these menu contracts. +function load(relative: string, dependencies: Record) { + const output = ts.transpileModule(readFileSync(new URL(relative, import.meta.url), "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + const module = { exports: {} as any }; + new Function("require", "module", "exports", output)( + (id: string) => { + assert.ok(id in dependencies, `unmocked boundary ${id}`); + return dependencies[id]; + }, + module, + module.exports, + ); + return module.exports; +} + +const icons = new Proxy({}, { get: () => () => null }); +const nothing = () => {}; +const navigation = { goToArtist: nothing, goToAlbum: nothing }; +const translate = (key: string) => key; +const common = { + react: React, + "react/jsx-runtime": jsx, + "@/components/icons/music-icons": icons, + "lucide-react": icons, + "@/components/anchored-menu": { AnchoredMenu: () => null }, + "@/lib/i18n": { useT: () => translate }, + "@/lib/music/artwork-save": { + saveArtwork: () => assert.fail("no image saving from a context menu"), + }, +}; + +test("music track menus retain playback and text copying without a save-artwork command", async () => { + const { useMusicTrackMenuItems } = load("../src/components/music/music-track-menu.tsx", { + ...common, + "@/components/icons/more-like-this-icon": { MoreLikeThisIcon: () => null }, + "@/lib/music/navigation": {}, + "@/lib/music/playback-origin": { + getMusicPlaybackOrigin: () => null, + musicOriginName: () => "", + musicTitleTarget: () => ({ kind: "album" }), + }, + "@/lib/music/player": { getMusicState: () => ({ current: null }) }, + "@/lib/music/use-track-liked": { useMusicTrackLiked: () => false }, + "./use-artist-credits": { useArtistCredits: () => [] }, + "./music-navigate": { useMusicNavigate: () => navigation }, + }); + let played = 0; + const items = useMusicTrackMenuItems( + { + id: "fixture", + title: "Fixture", + artist: "Artist", + artwork: "https://images.example.org/art.jpg", + }, + { onPlay: () => played++ }, + ); + assert.deepEqual( + items.map((item: any) => item.id), + ["play", "similar", "copy"], + ); + await items[0].run(); + assert.equal(played, 1); +}); + +test("music album, artist and playlist menus omit image saving without losing their normal actions", async () => { + const { musicItemMenuItems } = load("../src/components/music/music-item-menu.tsx", { + ...common, + "@/components/music/music-cover-card": { + coverCardTitle: (item: any) => item.title || item.name, + }, + "@/components/music/music-navigate": { useMusicNavigate: () => navigation }, + "@/components/music/music-playlist-picker": {}, + "@/components/music/music-track-menu": {}, + "@/lib/music/player": {}, + "@/lib/music/pinned-playlists": {}, + }); + for (const kind of ["album", "artist", "playlist"]) { + let opened = 0; + const items = musicItemMenuItems({ + t: translate, + item: { + kind, + id: "fixture", + name: "Fixture", + title: "Fixture", + artwork: "https://images.example.org/art.jpg", + }, + onOpen: () => opened++, + }); + assert.deepEqual( + items.map((item: any) => item.id), + ["open"], + ); + await items[0].run(); + assert.equal(opened, 1); + } +}); + +test("book-view reader pages remain a trusted reader target without image actions or a viewer portal", async () => { + const dom = new JSDOM("
", { url: "http://localhost/" }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + Node: dom.window.Node, + Element: dom.window.Element, + IS_REACT_ACT_ENVIRONMENT: true, + }); + let targetFactory: any; + const opened: any[] = []; + let viewerMounts = 0; + (window as any).FlipBook = class { + toggleSound() {} + dispose() {} + destroy() {} + }; + const { BookFlip } = load("../src/views/manga/manga-reader/book-view.tsx", { + react: React, + "react/jsx-runtime": jsx, + "lucide-react": icons, + "@/lib/context-menu": { + useContextMenu: () => ({ open: (_event: any, target: any) => opened.push(target) }), + useContextTarget: (factory: any) => { + targetFactory = factory; + return nothing; + }, + }, + "@/components/context-image-viewer": { + ContextImageViewer: () => { + viewerMounts++; + return null; + }, + }, + "@/lib/i18n": { useT: () => translate }, + "./flipbook": { guardPageLoaded: nothing, hasNativeZoom: () => false }, + "./reader-utils": { measureAspect: async () => 1.4 }, + }); + const root = createRoot(document.querySelector("#root")!); + try { + await React.act(() => + root.render( + jsx.jsx(BookFlip, { + pages: ["https://images.example.org/page.png"], + rtl: false, + bg: "black", + resumePage: 0, + soundEnabled: false, + onProgress: nothing, + }), + ), + ); + await React.act(() => + window.dispatchEvent( + new dom.window.CustomEvent("r3d-pagechange", { + detail: { page: "1", name: "harborBook" }, + }), + ), + ); + assert.equal(targetFactory().kind, "actions"); + assert.deepEqual(targetFactory().actions(), []); + const reader = document.querySelector("[tabindex='0']")!; + await React.act(() => + reader.dispatchEvent( + new dom.window.MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }), + ), + ); + assert.deepEqual(opened.at(-1).actions(), []); + assert.equal(viewerMounts, 0); + } finally { + await React.act(() => root.unmount()); + dom.window.close(); + } +}); diff --git a/tests/context-menu-actor-scope.test.ts b/tests/context-menu-actor-scope.test.ts index 9660df3a0..57b5e0a0e 100644 --- a/tests/context-menu-actor-scope.test.ts +++ b/tests/context-menu-actor-scope.test.ts @@ -13,6 +13,7 @@ import * as actions from "../src/lib/context-actions.ts"; import * as content from "../src/lib/context-content.ts"; import * as background from "../src/lib/context-page-background.ts"; import * as navigationPolicy from "../src/chrome/navigation-policy.ts"; +import { contentActions } from "../src/components/context-menu/content-actions.tsx"; import * as back from "../src/lib/app-back.ts"; import * as backHandlers from "../src/lib/back-intercept.ts"; @@ -55,7 +56,7 @@ async function fixture({ toggleFullscreen() {}, liveSync() {}, }; - const last = { id: "playback:last:a", label: "Continue last watched", run() {} }; + const last = { id: "playback:last:a", label: "Resume last watched", run() {} }; const navigation = [ { id: "page:back", label: "Back", run() {} }, { id: "page:my-profile", label: "Open my profile", run() {} }, @@ -97,6 +98,11 @@ async function fixture({ "@/components/player/copy-link-button": { copyText: async () => true }, "@/components/lists/list-toast": { emitListToast: empty }, "@/lib/deep-link": { shareDeepLink: empty }, + "@/lib/queue": { queueAdd: empty, queueIndexOf: () => -1 }, + "@/lib/membership-operations": {}, + "@/lib/stremio": {}, + "@/lib/queue-resolve-episode": {}, + "@/hooks/use-queue-surface": { useQueueSurface: () => !pageShare && !ebookDetails }, "@/lib/settings": { useSettings: () => ({ settings: { navCustomization: {} }, update: empty }), }, @@ -177,18 +183,14 @@ async function fixture({ LastPlaybackCommand: ({ children, enabled, - variant, }: { - children: (action: actions.ContextAction | null) => React.ReactNode; + children: (action: actions.ContextAction | null, row: React.ReactNode) => React.ReactNode; enabled: boolean; - variant?: string; }) => children( + presentation && enabled ? last : null, presentation && enabled - ? { - ...last, - label: variant === "quick" ? "Resume last" : last.label, - } + ? React.createElement(rows, { source: { actions: () => [last] } }) : null, ), }, @@ -215,7 +217,8 @@ async function fixture({ React.createElement( "section", { "data-test-menu": true }, - React.createElement("header", { "data-test-quick": true }, quickActions), + quickActions && + React.createElement("header", { "data-test-quick": true }, quickActions), React.createElement("main", { "data-test-rows": true }, children), ) : childrenOnly, @@ -227,7 +230,7 @@ async function fixture({ QuickActions: presentation ? rows : empty, MenuIcon: empty, }, - "./context-menu/content-actions": { contentActions: () => [] }, + "./context-menu/content-actions": { contentActions }, "./context-image-viewer": { ContextImageViewer: empty }, "@/lib/social/external-link-policy": {}, "@/chrome/context-page-navigation": { @@ -466,9 +469,16 @@ test("changing the Stremio account invalidates a still-open menu in the same loc } }); -test("non-player root menus place global last playback once above contextual rows, outside the strip", async () => { +test("only the background root places last playback and global navigation above its footer", async () => { const h = await fixture({ presentation: true }); try { + const pageTarget: menu.ContextMenuTarget = { + kind: "actions", + id: "page:profile", + scope: "page-background", + label: "Profile background", + actions: () => h.navigation, + }; const targets: menu.ContextMenuTarget[] = [ { kind: "meta", meta: { id: "b", name: "Poster B", type: "movie" } }, { @@ -477,19 +487,13 @@ test("non-player root menus place global last playback once above contextual row label: "Comment B", actions: () => [{ id: "comment:reply", label: "Reply", run() {} }], }, - { - kind: "actions", - id: "page:profile", - scope: "page-background", - label: "Profile background", - actions: () => h.navigation, - }, + pageTarget, { kind: "meta", meta: { id: "b", name: "Continue B", type: "series" }, primary: { actions: () => [ - { id: "continue:b", label: "Continue watching", run() {} }, + { id: "sources:b", label: "Choose another source", run() {} }, { id: "remove:b", label: "Remove from Continue Watching", run() {} }, ], }, @@ -498,16 +502,22 @@ test("non-player root menus place global last playback once above contextual row for (const target of targets) { await h.openTarget(target); const last = document.querySelectorAll('[data-context-action="playback:last:a"]'); - assert.equal(last.length, 1); - assert.equal(last[0].textContent, "Continue last watched"); - assert.ok(last[0].closest("[data-test-rows]")); - assert.equal(last[0].closest("[data-test-quick]"), null); - assert.equal(document.querySelector("[data-test-rows] [data-context-action]"), last[0]); - assert.equal(document.querySelectorAll('[data-context-action="page:my-profile"]').length, 1); - assert.equal(document.querySelectorAll('[data-context-action="page:go-to"]').length, 1); - assert.equal(document.querySelectorAll('[data-context-action="page:refresh"]').length, 1); + const background = target === pageTarget; + assert.equal(last.length, background ? 1 : 0); + if (background) { + assert.equal(last[0].textContent, "Resume last watched"); + assert.ok(last[0].closest("[data-test-rows]")); + assert.equal(last[0].closest("[data-test-quick]"), null); + assert.equal(document.querySelector("[data-test-rows] [data-context-action]"), last[0]); + } + for (const id of ["page:my-profile", "page:go-to", "page:refresh"]) + assert.equal( + document.querySelectorAll(`[data-context-action='${id}']`).length, + background ? 1 : 0, + ); + assert.equal(!!document.querySelector("[data-test-quick]"), background); } - assert.ok(document.querySelector('[data-context-action="continue:b"]')); + assert.ok(document.querySelector('[data-context-action="sources:b"]')); assert.ok(document.querySelector('[data-context-action="remove:b"]')); } finally { await h.close(); @@ -526,16 +536,18 @@ test("player menu excludes browsing navigation and global last playback while re "page:go-to", "page:refresh", "page:back", + "player:settings", "playback:last:a", ]) assert.equal(!!document.querySelector(`[data-context-action='${id}']`), false, id); const quick = [...document.querySelectorAll("[data-test-quick] [data-context-action]")].map( (node) => node.getAttribute("data-context-action"), ); - assert.deepEqual( - quick, - magnet ? ["player:copy-magnet", "player:settings"] : ["player:settings"], - ); + assert.deepEqual(quick, []); + assert.equal(document.querySelector("[data-test-quick]"), null); + const magnetRow = document.querySelector('[data-context-action="player:copy-magnet"]'); + assert.equal(!!magnetRow, !!magnet); + if (magnetRow) assert.ok(magnetRow.closest("[data-test-rows]")); for (const id of [ "player:fullscreen", "player:download-video", @@ -550,7 +562,32 @@ test("player menu excludes browsing navigation and global last playback while re } }); -test("page sharing stays behind native editing, registered cards and clicked content", async () => { +test("non-film entity sharing remains separate from the exclusive background menu", async () => { + const h = await fixture({ presentation: true, pageShare: true }); + try { + for (const target of [ + { kind: "person", id: 12 }, + { kind: "ebook", id: "book-12" }, + { kind: "manga", id: "manga-12", title: "Manga" }, + { kind: "meta", meta: { id: "anime-12", name: "Anime", type: "anime" } }, + ] satisfies menu.ContextMenuTarget[]) { + await h.openTarget(target); + assert.equal( + [...document.querySelectorAll("button")].filter( + (button) => button.textContent === "Share as link", + ).length, + 1, + `${target.kind} retains its own sharing command`, + ); + assert.equal(document.querySelector("[data-test-quick]"), null); + assert.equal(document.querySelector('[data-context-action="page:go-to"]'), null); + } + } finally { + await h.close(); + } +}); + +test("person background stays exclusive while editing, registered cards and clicked content keep ownership", async () => { const h = await fixture({ presentation: true, pageShare: true }); const main = document.createElement("main"); main.innerHTML = ` @@ -571,17 +608,26 @@ test("page sharing stays behind native editing, registered cards and clicked con try { await summon(main); assert.equal(h.state?.target.kind, "actions"); - assert.ok(document.querySelector('[data-context-action="page:share:person:12"]')); + assert.equal(document.querySelector('[data-context-action="page:share:person:12"]'), null); + assert.ok(document.querySelector('[data-context-action="page:go-to"]')); + assert.ok(document.querySelector('[data-context-action="page:refresh"]')); for (const [selector, kind] of [ ["#card span", "actions"], - ["#image", "content"], + ["#image", null], ["#link", "content"], - ]) { - await summon(main.querySelector(selector)!); - assert.equal(h.state?.target.kind, kind); + ] as const) { + const event = await summon(main.querySelector(selector)!); + assert.equal(event.defaultPrevented, true, selector); + if (kind) assert.equal(h.state?.target.kind, kind); + else assert.equal(h.state, null, "an image without commands releases its menu state"); + if (selector === "#link") { + assert.ok(document.querySelector('[data-context-action="link:open"]')); + assert.ok(document.querySelector('[data-context-action="link:copy"]')); + } assert.equal(document.querySelector('[data-context-action="page:share:person:12"]'), null); - assert.ok(document.querySelector('[data-context-action="page:go-to"]')); - assert.ok(document.querySelector('[data-context-action="page:refresh"]')); + assert.equal(document.querySelector('[data-context-action="page:go-to"]'), null); + assert.equal(document.querySelector('[data-context-action="page:refresh"]'), null); + assert.equal(document.querySelector("[data-test-quick]"), null); } const fieldEvent = await summon(main.querySelector("#field")!); assert.equal(fieldEvent.defaultPrevented, false, "editing keeps its native context menu"); @@ -596,7 +642,7 @@ test("page sharing stays behind native editing, registered cards and clicked con } }); -test("the explicit eBook details background shares without changing reader or descendant menus", async () => { +test("the explicit eBook background stays exclusive without changing reader or descendant ownership", async () => { const h = await fixture({ presentation: true, ebookDetails: true }); const main = document.getElementById("ebook-details")!; main.innerHTML = ` @@ -618,16 +664,25 @@ test("the explicit eBook details background shares without changing reader or de try { await summon(main); assert.equal(h.state?.target.kind, "actions"); - assert.ok(document.querySelector(shareSelector)); + assert.equal(document.querySelector(shareSelector), null); assert.ok(document.querySelector('[data-context-action="page:go-to"]')); + assert.ok(document.querySelector('[data-context-action="page:refresh"]')); for (const [selector, kind] of [ ["#card span", "actions"], - ["#image", "content"], + ["#image", null], ["#link", "content"], - ]) { - await summon(main.querySelector(selector)!); - assert.equal(h.state?.target.kind, kind); + ] as const) { + const event = await summon(main.querySelector(selector)!); + assert.equal(event.defaultPrevented, true, selector); + if (kind) assert.equal(h.state?.target.kind, kind); + else assert.equal(h.state, null, "an image without commands releases its menu state"); + if (selector === "#link") { + assert.ok(document.querySelector('[data-context-action="link:open"]')); + assert.ok(document.querySelector('[data-context-action="link:copy"]')); + } assert.equal(document.querySelector(shareSelector), null); + assert.equal(document.querySelector('[data-context-action="page:go-to"]'), null); + assert.equal(document.querySelector('[data-context-action="page:refresh"]'), null); } const fieldEvent = await summon(main.querySelector("#field")!); assert.equal(fieldEvent.defaultPrevented, false); diff --git a/tests/context-menu-local-presence.test.ts b/tests/context-menu-local-presence.test.ts index ee96db666..d22fefa1a 100644 --- a/tests/context-menu-local-presence.test.ts +++ b/tests/context-menu-local-presence.test.ts @@ -32,6 +32,8 @@ function harness(presentation: "baseline" | "popout") { "react/jsx-runtime": jsx, "lucide-react": icons, "@/lib/i18n": { useT: () => (key: string) => key }, + "@/lib/queue": { queueAdd() {}, queueIndexOf: () => -1, useQueue: () => [] }, + "@/hooks/use-queue-surface": { useQueueSurface: () => true }, "@/lib/manual-watched": { manualWatchedState: () => false, subscribeManualWatched: () => () => {}, diff --git a/tests/context-menu-simplification.test.tsx b/tests/context-menu-simplification.test.tsx new file mode 100644 index 000000000..8d063357b --- /dev/null +++ b/tests/context-menu-simplification.test.tsx @@ -0,0 +1,471 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import ts from "typescript"; +import { JSDOM } from "jsdom"; +import * as React from "react"; +import * as jsx from "react/jsx-runtime"; +import { createRoot } from "react-dom/client"; +import * as icons from "lucide-react"; +import * as context from "../src/lib/context-menu.tsx"; +import * as content from "../src/lib/context-content.ts"; +import * as partition from "../src/lib/context-quick-actions.ts"; +import * as backgroundPolicy from "../src/lib/context-page-background.ts"; +import * as surface from "../src/components/context-menu/menu-surface.tsx"; +import * as actionItems from "../src/components/context-menu/action-items.tsx"; +import * as contentActions from "../src/components/context-menu/content-actions.tsx"; +import * as playerSources from "../src/components/context-menu/player-actions.tsx"; + +function harness() { + const dom = new JSDOM('
', { + url: "https://fixture.invalid", + pretendToBeVisual: true, + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + Element: dom.window.Element, + Node: dom.window.Node, + HTMLElement: dom.window.HTMLElement, + HTMLInputElement: dom.window.HTMLInputElement, + innerWidth: dom.window.innerWidth, + innerHeight: dom.window.innerHeight, + ResizeObserver: class { + observe() {} + disconnect() {} + }, + MutationObserver: dom.window.MutationObserver, + HTMLTextAreaElement: dom.window.HTMLTextAreaElement, + MouseEvent: dom.window.MouseEvent, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + IS_REACT_ACT_ENVIRONMENT: true, + }); + dom.window.matchMedia = (() => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + })) as any; + const empty = () => {}; + let api!: ReturnType; + let lastReads = 0; + let copied = 0; + let queueSurface = false; + const queued: string[] = []; + let player: any = null; + let watched: any = { loading: true, summary: null, refresh: empty }; + const nav = [ + { id: "page:back", label: "Back", run: empty }, + { id: "page:my-profile", label: "Open my profile", run: empty }, + { + id: "page:go-to", + label: "Go to", + children: [ + { id: "page:go:home", label: "Home", run: empty }, + { id: "page:go:settings", label: "Settings", run: empty }, + ], + }, + { id: "page:refresh", label: "Refresh", run: empty }, + ]; + const page: context.ContextMenuTarget = { + kind: "actions", + id: "page:home", + label: "Page actions", + scope: "page-background", + actions: () => nav, + }; + const view = { + topKind: "home", + topPath: "/home", + openMeta: empty, + openManga: empty, + setView: empty, + openQueue: empty, + openPicker: empty, + openPerson: empty, + openService: empty, + openAddonDetail: empty, + openSettings: empty, + }; + const deps: Record = { + react: React, + "react/jsx-runtime": jsx, + "lucide-react": icons, + "@/lib/context-menu": context, + "@/lib/context-content": content, + "@/lib/context-quick-actions": partition, + "@/lib/context-page-background": backgroundPolicy, + "@/lib/context-actions": {}, + "@/lib/i18n": { t: (s: string) => s, useT: () => (s: string) => s }, + "@/lib/active-addon": { useActiveAddon: () => null }, + "@/components/player/copy-link-button": { copyText: async () => true }, + "@/components/lists/list-toast": { emitListToast: empty }, + "@/lib/deep-link": { shareDeepLink: (type: string, id: string) => `${type}:${id}` }, + "@/lib/auth": { useAuth: () => ({ user: null }) }, + "@/lib/queue": { + queueAdd: (meta: { id: string }) => queued.push(meta.id), + queueIndexOf: (meta: { id: string }) => queued.indexOf(meta.id), + }, + "@/lib/membership-operations": {}, + "@/lib/stremio": {}, + "@/lib/queue-resolve-episode": {}, + "@/hooks/use-queue-surface": { useQueueSurface: () => queueSurface }, + "@/lib/player-actions": { usePlayerActions: () => player, currentPlayerActions: () => player }, + "@/lib/playback-history": { + capturePlaybackActor: () => "fixture", + isPlaybackActorCurrent: () => true, + }, + "@/lib/together/provider": { + useTogether: () => ({ snapshot: { state: "idle" }, sendSummon: empty }), + }, + "@/lib/view": { useView: () => ({ ...view, player }) }, + "@/lib/watchlist": { useInWatchlist: () => false }, + "@/lib/media-context-actions": { + setContextWatchlist: empty, + setContextFavorite: empty, + setContextWatched: empty, + requireMediaActionSuccess: empty, + }, + "@/lib/context-watched-state": { useContextWatchedState: () => watched }, + "@/lib/providers/tmdb": { useTmdbImdbId: () => undefined }, + "@/lib/media-favorites": { useIsFavorite: () => false }, + "@/lib/title-backdrop": { getTitleBackdrop: () => undefined }, + "./context-menu/my-list-submenu": { + MyListSubmenu: ({ children }: any) => ( + <> + + {children} + + ), + }, + "@/lib/settings": { + useSettings: () => ({ + settings: { navCustomization: { order: [], hidden: [] } }, + update: empty, + }), + }, + "@/chrome/nav-items": {}, + "@/chrome/nav-edit-mode": { useNavEditMode: () => false }, + "@/lib/manga-favorites": { + useMangaFavorites: () => ({ toggle: empty }), + useIsMangaFavorite: () => false, + }, + "@/lib/manga-progress": { + useMangaProgressEntry: () => null, + useReadMangaChapterIds: () => new Set(), + }, + "@/lib/manga-bookmarks": { useMangaBookmarks: () => [] }, + "@/lib/manga-downloads": {}, + "@/lib/manga/read-intent": {}, + "@/lib/manga-lists": {}, + "@/lib/manga/api": {}, + "@/lib/manga/chapter-identity": {}, + "@/lib/profiles": { + useProfiles: () => ({ activeProfile: { id: "fixture" }, activeId: "fixture" }), + }, + "@/lib/theme-auth": { currentAuthor: () => null, subscribeAuthor: () => empty }, + "./context-menu/menu-surface": surface, + "./context-menu/action-items": actionItems, + "./context-menu/content-actions": contentActions, + "@/components/context-image-viewer": { + ContextImageViewer: () =>
, + }, + "./context-image-viewer": { ContextImageViewer: () =>
}, + "@/lib/social/external-link-policy": {}, + "@/chrome/context-page-navigation": { + usePageContextTarget: ({ includePlayer = false } = {}) => + player && !includePlayer ? null : page, + }, + "./context-menu/use-manual-download": { + useManualDownload: () => ({ beginManualDownload: empty, manualDownloadDialog: null }), + }, + "./context-menu/last-playback-command": { + LastPlaybackCommand: ({ enabled, children }: any) => { + if (enabled) lastReads++; + const action = enabled + ? { id: "playback:last:fixture", label: "Resume last watched", run: empty } + : null; + return children( + action, + action ? ( + [action] }} onClose={empty} /> + ) : null, + ); + }, + }, + "./context-menu/menu-brand-footer": { + MenuBrandFooter: () =>
, + }, + "./context-menu/player-actions": playerSources, + }; + const output = ts.transpileModule( + readFileSync(new URL("../src/components/context-menu.tsx", import.meta.url), "utf8"), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + target: ts.ScriptTarget.ES2022, + }, + }, + ).outputText; + const module = { exports: {} as any }; + new Function("require", "module", "exports", output)( + (name: string) => { + assert.ok(name in deps, `unexpected dependency ${name}`); + return deps[name]; + }, + module, + module.exports, + ); + function App() { + api = context.useContextMenu(); + const emptyCard = context.useContextTarget(() => ({ + kind: "actions", + id: "empty-card", + label: "Card", + actions: () => [], + })); + return ( + <> +
+ +
+ Occupied card space +
+
+ + + + + ); + } + const root = createRoot(document.getElementById("root")!); + const render = () => + React.act(() => + root.render( + + + , + ), + ); + const open = async (target: context.ContextMenuTarget) => { + await React.act(() => api.openAt({ x: 20, y: 20 }, target)); + }; + const labels = () => + [...document.querySelectorAll('[role="menu"] > .context-menu-motion [role="menuitem"]')].map( + (e) => e.textContent?.trim(), + ); + return { + dom, + root, + render, + open, + page, + labels, + get reads() { + return lastReads; + }, + get state() { + return api.state; + }, + copy: () => copied++, + get copied() { + return copied; + }, + set watched(value: any) { + watched = value; + }, + set player(value: any) { + player = value; + }, + set queueSurface(value: boolean) { + queueSurface = value; + }, + get queued() { + return queued; + }, + dispose: async () => { + await React.act(() => root.unmount()); + dom.window.close(); + }, + }; +} + +test("actual menus reserve navigation/last playback for declared background and keep entity Copy as a row", async () => { + const h = harness(); + await h.render(); + try { + await h.open(h.page); + assert.deepEqual(h.labels(), [ + "Back", + "Settings", + "Resume last watched", + "Open my profile", + "Go to", + "Refresh", + ]); + assert.ok(document.querySelector("[data-context-quick]")); + const before = h.reads; + await h.open({ + kind: "actions", + id: "collection", + label: "Collection", + actions: () => [{ id: "collection:copy", label: "Copy share link", run: h.copy }], + }); + assert.deepEqual(h.labels(), ["Copy share link"]); + assert.equal(document.querySelector("[data-context-quick]") === null, true); + assert.equal(h.reads, before, "item menus never resolve last playback"); + await React.act(() => + ( + document.querySelector('[data-context-action="collection:copy"]') as HTMLButtonElement + ).click(), + ); + assert.equal(h.copied, 1); + await h.open({ kind: "content", image: { src: "https://fixture.invalid/image.png" } }); + assert.equal( + document.querySelector('[role="menu"]') === null, + true, + "empty image target does not become a page menu", + ); + assert.equal( + h.state === null, + true, + "an empty target releases the menu ownership, not only its surface", + ); + } finally { + await h.dispose(); + } +}); + +test("rendered movie menu has no toolbar, image/sharing/waiting tools, or orphan watched separator", async () => { + const h = harness(); + await h.render(); + try { + await h.open({ + kind: "meta", + meta: { id: "fixture", type: "movie", name: "Movie", poster: "fixture.png" }, + }); + assert.deepEqual(h.labels(), [ + "View details", + "Download", + "Add to watchlist", + "Add to favorites", + "Add to list", + ]); + assert.equal(document.querySelector('[role="separator"]') === null, true); + assert.equal(document.querySelector("[data-context-quick]") === null, true); + assert.equal(h.reads, 0); + const download = [...document.querySelectorAll("button")].find( + (e) => e.textContent === "Download", + )!; + download.focus(); + h.watched = { loading: false, summary: { status: "watched", unavailable: [] }, refresh() {} }; + await h.render(); + assert.ok(h.labels().includes("Mark as unwatched")); + assert.equal(document.activeElement, download, "late watched status preserves focused command"); + } finally { + await h.dispose(); + } +}); + +test("the official queue command remains available on queue-enabled surfaces", async () => { + const h = harness(); + h.queueSurface = true; + await h.render(); + const target: context.ContextMenuTarget = { + kind: "meta", + meta: { id: "queue-movie", type: "movie", name: "Queue movie" }, + }; + try { + await h.open(target); + assert.ok(h.labels().includes("Add to queue")); + const add = [...document.querySelectorAll("button")].find( + (button) => button.textContent === "Add to queue", + )!; + await React.act(async () => add.click()); + assert.deepEqual(h.queued, ["queue-movie"]); + await h.open(target); + const existing = [...document.querySelectorAll("button")].find( + (button) => button.textContent === "In your queue", + )!; + assert.ok(existing); + assert.equal(existing.disabled, true); + assert.equal(document.querySelector("[data-context-quick]") === null, true); + } finally { + await h.dispose(); + } +}); + +test("managed image context is consumed and native editing remains untouched", async () => { + const h = harness(); + await h.render(); + try { + const imageEvent = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + await React.act(() => document.getElementById("image")!.dispatchEvent(imageEvent)); + assert.equal(imageEvent.defaultPrevented, true); + assert.equal(document.querySelector('[role="menu"]') === null, true); + const editEvent = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + await React.act(() => document.getElementById("editor")!.dispatchEvent(editEvent)); + assert.equal(editEvent.defaultPrevented, false); + assert.equal(document.querySelector('[role="menu"]') === null, true); + } finally { + await h.dispose(); + } +}); + +test("empty cards and independent controls are not background, while true page space is", async () => { + const h = harness(); + await h.render(); + try { + for (const id of ["empty-card", "control"]) { + const event = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + await React.act(() => document.getElementById(id)!.dispatchEvent(event)); + assert.equal(event.defaultPrevented, true); + assert.equal(document.querySelector('[role="menu"]') === null, true, id); + } + const event = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + await React.act(() => document.getElementById("background")!.dispatchEvent(event)); + assert.equal(event.defaultPrevented, true); + assert.deepEqual(h.labels(), [ + "Back", + "Settings", + "Resume last watched", + "Open my profile", + "Go to", + "Refresh", + ]); + } finally { + await h.dispose(); + } +}); + +test("a docked live player keeps page-background navigation without adding it to the player menu", async () => { + const h = harness(); + const meta = { id: "playing", type: "movie", name: "Playing" }; + h.player = { meta, src: {}, toggleFullscreen() {} }; + await h.render(); + try { + await h.open(h.page); + assert.deepEqual(h.labels(), [ + "Back", + "Settings", + "Resume last watched", + "Open my profile", + "Go to", + "Refresh", + ]); + await h.open({ kind: "meta", player: true, meta }); + assert.equal(document.querySelector("[data-context-quick]") === null, true); + assert.equal( + h + .labels() + .some((label) => /Back|Settings|last watched|my profile|Go to|Refresh/.test(label ?? "")), + false, + ); + assert.ok(h.labels().includes("Full screen")); + } finally { + await h.dispose(); + } +}); diff --git a/tests/context-menu-surface.test.ts b/tests/context-menu-surface.test.ts index d0bcbc6cd..527c2c043 100644 --- a/tests/context-menu-surface.test.ts +++ b/tests/context-menu-surface.test.ts @@ -250,7 +250,24 @@ test("quick navigation, closing guards, and destination search preserve keyboard point: { x: 20, y: 20 }, onClose() {}, quickActions: createElement(QuickActions, { source: quickSource, onClose() {} }), - children: createElement(Commands), + children: createElement( + Fragment, + null, + createElement(Commands), + createElement(ActionItems, { + source: { + actions: () => [ + { + id: "described", + label: "Described command", + reason: "Extra details", + run() {}, + }, + ], + }, + onClose() {}, + }), + ), }), ), ); @@ -273,10 +290,20 @@ test("quick navigation, closing guards, and destination search preserve keyboard await act(async () => { await new Promise((resolve) => setTimeout(resolve, 280)); }); - const settingsTip = document.querySelector("[role='tooltip']")!; - assert.equal(settingsTip.textContent, "Settings"); - assert.equal(settings.getAttribute("aria-describedby"), settingsTip.id); - const tipLayer = settingsTip.closest("[data-harbor-context-layer]")!; + assert.equal(document.querySelector("[role='tooltip']")?.textContent ?? null, null); + assert.equal(settings.getAttribute("aria-describedby"), null); + const described = document.querySelector( + "[data-context-action='described']", + )!; + await act(() => described.focus()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 280)); + }); + const descriptionTip = document.querySelector("[role='tooltip']")!; + assert.ok(descriptionTip, "explicit row descriptions still appear"); + assert.equal(descriptionTip.textContent, "Extra details"); + assert.equal(described.getAttribute("aria-describedby"), descriptionTip.id); + const tipLayer = descriptionTip.closest("[data-harbor-context-layer]")!; assert.equal( tipLayer.parentElement, document.body, diff --git a/tests/context-page-background.test.ts b/tests/context-page-background.test.ts index 39ad175f1..5de0a651a 100644 --- a/tests/context-page-background.test.ts +++ b/tests/context-page-background.test.ts @@ -29,8 +29,10 @@ test("page markers cannot override editor, reader, player, or modal ownership",
+ +
`); - for (const id of ["dialog", "editor", "player", "reader", "custom", "frame"]) + for (const id of ["dialog", "editor", "player", "reader", "custom", "frame", "image", "backdrop"]) assert.equal(isPageContextBackground(dom.window.document.getElementById(id)), false, id); assert.equal(isPageContextBackground(null), false); dom.window.close(); diff --git a/tests/context-quick-actions.test.ts b/tests/context-quick-actions.test.ts index 12a7286e3..4670988ff 100644 --- a/tests/context-quick-actions.test.ts +++ b/tests/context-quick-actions.test.ts @@ -1,120 +1,75 @@ -// @ts-expect-error Node test types are outside browser config. import assert from "node:assert/strict"; -// @ts-expect-error Node test types are outside browser config. import test from "node:test"; import { contextNavigationFooter, partitionContextActions, } from "../src/lib/context-quick-actions.ts"; -test("unsupported image copy is omitted from the strip without inventing paste", () => { - const result = partitionContextActions( - [], - [{ id: "image:copy", label: "Copy Image", disabled: true }], - [], - ); - assert.deepEqual(result.quick, []); -}); +const back = { id: "page:back", label: "Back", disabled: true }; +const settings = { id: "page:go:settings", label: "Settings", run: () => {} }; +const nav = [ + back, + { id: "page:go-to", label: "Go to", children: [settings, { id: "page:go:home", label: "Home" }] }, + { id: "page:my-profile", label: "Open my profile" }, + { id: "page:refresh", label: "Refresh" }, +]; +const background = { + kind: "actions", + id: "page:home", + label: "Page actions", + scope: "page-background", + actions: () => nav, +}; +const partition = partitionContextActions as ( + ...args: any[] +) => ReturnType; -test("root navigation keeps own profile above Go to then Refresh without duplicating Settings", () => { - const settings = { id: "page:go:settings", label: "Settings" }; - const footer = contextNavigationFooter( - [ - { id: "page:back", label: "Back" }, - { id: "page:my-profile", label: "Open my profile", group: "pages" }, - { id: "page:refresh", label: "Refresh", shortcut: "Ctrl+R", group: "reload" }, - { - id: "page:go-to", - label: "Go to", - children: [settings, { id: "page:go:home", label: "Home" }], - }, - ], - [settings], - ); - assert.deepEqual( - footer.map((action) => action.id), - ["page:my-profile", "page:go-to", "page:refresh"], - ); - assert.deepEqual( - footer[1].children?.map((action) => action.id), - ["page:go:home"], - ); +test("only explicit unoccupied background gets Back and Settings; copy stays a row", () => { + const copy = { id: "collection:copy", label: "Copy share link" }; + for (const target of [ + undefined, + { kind: "actions" }, + { kind: "meta", player: true }, + { ...background, image: { src: "fixture.png" } }, + { ...background, link: "https://fixture.invalid" }, + { ...background, selection: "Selected" }, + { ...background, navigation: false }, + ]) { + const result = partition([copy], [], nav, target); + assert.deepEqual(result.quick, [], JSON.stringify(target)); + assert.deepEqual(result.entity, [copy], "copying is retained as a normal command"); + } + assert.deepEqual(partition(nav, [], nav, background).quick, [back, settings]); }); -test("the strip moves an explicit copy command without conflating image bytes and image link", () => { - const entity = [{ id: "comment:copy", label: "Copy comment text", quickCopy: true }]; - const content = [ - { - id: "image", - label: "Image", - children: [ - { id: "image:copy", label: "Copy image" }, - { id: "image:copy-link", label: "Copy image link" }, - ], - }, - ]; - const result = partitionContextActions(entity, content, []); +test("background navigation order is stable and Settings is removed from Go to", () => { + const result = partition(nav, [], nav, background); + const footer = contextNavigationFooter(nav, result.quick); assert.deepEqual( - result.quick.map((a) => a.id), - ["comment:copy"], - ); - assert.deepEqual(result.entity, []); - assert.equal(result.content[0].children?.length, 2); - const poster = partitionContextActions([], content, []); - assert.deepEqual( - poster.quick.map((a) => a.id), - ["image:copy"], + footer.map((a) => a.id), + ["page:my-profile", "page:go-to", "page:refresh"], ); assert.deepEqual( - poster.content[0].children?.map((a) => a.id), - ["image:copy-link"], - ); -}); - -test("navigation commands preserve their original capability and avoid duplicates", () => { - const back = { id: "page:back", label: "Back", disabled: true }; - const settings = { id: "page:go:settings", label: "Settings", run: () => {} }; - const nav = [ - back, - { - id: "page:go-to", - label: "Go to", - children: [settings, { id: "page:go:home", label: "Home" }], - }, - ]; - const result = partitionContextActions(nav, [], nav); - assert.deepEqual(result.quick, [back, settings]); - assert.deepEqual( - result.entity[0].children?.map((a) => a.id), + footer[1].children?.map((a) => a.id), ["page:go:home"], ); - assert.equal( - result.quick.some((a) => /paste/i.test(a.id)), - false, - ); + assert.deepEqual(result.entity, [], "background does not duplicate its own navigation rows"); }); -test("selection never silently replaces a poster copy and empty groups are removed", () => { - const result = partitionContextActions( - [], - [ - { id: "selection:copy", label: "Copy selected text" }, - { id: "image", label: "Image", children: [{ id: "image:copy", label: "Copy image" }] }, - ], - [], - ); - assert.deepEqual( - result.quick.map((a) => a.id), - ["image:copy"], - ); - assert.deepEqual( - result.content.map((a) => a.id), - ["selection:copy"], - ); +test("empty items never inherit global navigation and selected text remains a row", () => { + const selected = { id: "selection:copy", label: "Copy selected text" }; + const result = partition([], [selected], nav, { kind: "content", selection: "fixture" }); + assert.deepEqual(result.quick, []); + assert.deepEqual(result.content, [selected]); + assert.deepEqual(partition([], [], nav, { kind: "actions" }), { + quick: [], + entity: [], + content: [], + }); }); -test("moving a child command preserves its parent's disabled capability", () => { - const result = partitionContextActions( +test("extracting background Settings preserves its inherited disabled/PIN capability", () => { + const result = partition( [], [], [ @@ -122,11 +77,12 @@ test("moving a child command preserves its parent's disabled capability", () => id: "page:go-to", label: "Go to", disabled: true, - reason: "Navigation unavailable", - children: [{ id: "page:go:settings", label: "Settings", run: () => {} }], + reason: "PIN required", + children: [settings], }, ], + background, ); assert.equal(result.quick[0].disabled, true); - assert.equal(result.quick[0].reason, "Navigation unavailable"); + assert.equal(result.quick[0].reason, "PIN required"); }); diff --git a/tests/context-reader-pages.test.ts b/tests/context-reader-pages.test.ts index b92008a9b..ea82cec1d 100644 --- a/tests/context-reader-pages.test.ts +++ b/tests/context-reader-pages.test.ts @@ -2,24 +2,8 @@ import assert from "node:assert/strict"; // @ts-expect-error Node test types are intentionally outside the browser-only tsconfig. import test from "node:test"; -import { visibleReaderPages } from "../src/views/manga/manga-reader/reader-context.ts"; import { fetchImageObjectUrl } from "../src/views/manga/manga-reader/reader-utils.ts"; -test("reader image choices use the renderer's exact reported page numbers", () => { - const pages = ["blob:one", "blob:two", "blob:three", "blob:four"]; - assert.deepEqual(visibleReaderPages(pages, "2-3"), [ - { number: 2, src: "blob:two" }, - { number: 3, src: "blob:three" }, - ]); - assert.deepEqual(visibleReaderPages(pages, "3-2"), [ - { number: 3, src: "blob:three" }, - { number: 2, src: "blob:two" }, - ]); - assert.deepEqual(visibleReaderPages(pages, "1"), [{ number: 1, src: "blob:one" }]); - for (const spread of ["", "0", "5", "2-5", "1-4", "undefined", "1.5"]) - assert.deepEqual(visibleReaderPages(pages, spread), []); -}); - test("header-fetched manga image blobs preserve the actual format when the source omits content type", async () => { Object.defineProperty(globalThis, "window", { configurable: true, diff --git a/tests/context-tooltip-intent.test.ts b/tests/context-tooltip-intent.test.ts index d9446f7bb..e77336c97 100644 --- a/tests/context-tooltip-intent.test.ts +++ b/tests/context-tooltip-intent.test.ts @@ -4,7 +4,62 @@ import { JSDOM } from "jsdom"; import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; import { MenuSurface } from "../src/components/context-menu/menu-surface.tsx"; -import { ActionItems } from "../src/components/context-menu/action-items.tsx"; +import { ActionItems, QuickActions } from "../src/components/context-menu/action-items.tsx"; + +test("labeled navigation quick actions omit tooltips and remain actionable", async () => { + const dom = new JSDOM("
", { url: "https://fixture.invalid" }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + KeyboardEvent: dom.window.KeyboardEvent, + MutationObserver: dom.window.MutationObserver, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + IS_REACT_ACT_ENVIRONMENT: true, + }); + const calls: string[] = []; + const root = createRoot(document.querySelector("#root")!); + const wait = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 290)); + }); + try { + await act(() => + root.render( + createElement(QuickActions, { + source: { + actions: () => [ + { id: "page:back", label: "Back", run: () => void calls.push("back") }, + { id: "page:go:settings", label: "Settings", run: () => void calls.push("settings") }, + ], + }, + onClose() {}, + }), + ), + ); + for (const [id, label] of [ + ["page:back", "Back"], + ["page:go:settings", "Settings"], + ]) { + const button = document.querySelector(`[data-context-action='${id}']`)!; + assert.equal(button.getAttribute("aria-label"), label); + await act(() => + button.dispatchEvent(new dom.window.MouseEvent("mouseover", { bubbles: true })), + ); + await wait(); + assert.equal(document.querySelector("[role='tooltip']")?.textContent ?? null, null, label); + await act(() => button.focus()); + await wait(); + assert.equal(document.querySelector("[role='tooltip']")?.textContent ?? null, null, label); + await act(async () => button.click()); + } + assert.deepEqual(calls, ["back", "settings"]); + } finally { + await act(() => root.unmount()); + dom.window.close(); + } +}); test("initial focus and a stationary pointer do not reveal last-playback details; exploration does", async () => { const dom = new JSDOM("
", { url: "https://fixture.invalid" }); diff --git a/tests/continue-card-actions.test.ts b/tests/continue-card-actions.test.ts index cd4c2e191..5bc3d8645 100644 --- a/tests/continue-card-actions.test.ts +++ b/tests/continue-card-actions.test.ts @@ -28,7 +28,7 @@ const item = (id: string, upNext = false) => ({ }, }); -test("older and Up Next entries each expose contextual continuation and removal independently from latest playback", async () => { +test("older and Up Next menus expose sources then removal while original playback remains independent", async () => { const { createContinueCardActions } = api(); let globalLatest = "newest-a"; const continued: any[] = []; @@ -50,10 +50,12 @@ test("older and Up Next entries each expose contextual continuation and removal }, }); const actions = commands.primary.actions(); - assert.equal(actions[0].label, "Continue watching"); - assert.equal(actions.length, 2, "primary continuation and alternate sources remain distinct"); - await actions[0].run(); - const remove = commands.extra.actions()[0]; + assert.deepEqual( + actions.map((action: any) => action.label), + ["Choose another source", "Remove from Continue Watching"], + ); + await commands.play(); + const remove = actions[1]; assert.equal(remove.label, "Remove from Continue Watching"); await remove.run(); assert.equal(commands.isValid(), false); @@ -96,10 +98,10 @@ test("a pending primary operation blocks original controls and menu commands; ac starts++; }, }); - const pending = commands.primary.actions()[0].run(); - assert.equal(commands.primary.actions()[0].disabled, true); + const pending = commands.play(); + assert.ok(commands.primary.actions().every((action: any) => action.disabled)); await assert.rejects(commands.play(), /already running/); - await assert.rejects(commands.extra.actions()[0].run(), /already running/); + await assert.rejects(commands.primary.actions()[1].run(), /already running/); current = false; finish(); await assert.rejects(pending, /no longer available/); @@ -129,7 +131,11 @@ test("harmless metadata rerender preserves a menu but replacement entry invalida assert.equal(commands.isValid(), false); await assert.rejects(commands.play(), /no longer available/); assert.equal(starts, 1); - assert.deepEqual(commands.extra.actions(), [], "no unsupported removal is invented"); + assert.deepEqual( + commands.primary.actions().map((action: any) => action.label), + ["Choose another source"], + "no unsupported removal or contextual play command is invented", + ); }); test("removal failure remains retryable while unavailable cards cannot continue or use another source", async () => { @@ -148,7 +154,12 @@ test("removal failure remains retryable while unavailable cards cannot continue if (fails) throw new Error("Storage unavailable"); }, }); - assert.ok(commands.primary.actions().every((action: any) => action.disabled)); + assert.equal(commands.primary.actions()[0].disabled, true); + assert.equal( + commands.primary.actions()[1].disabled, + false, + "future entries can still be removed", + ); await assert.rejects(commands.play()); await assert.rejects(commands.dismiss(), /Storage/); assert.equal(commands.isValid(), true); diff --git a/tests/continue-card-interaction.test.ts b/tests/continue-card-interaction.test.ts index 12f58ffdd..63a43a97d 100644 --- a/tests/continue-card-interaction.test.ts +++ b/tests/continue-card-interaction.test.ts @@ -107,6 +107,10 @@ function harness() { resolveAnimeIdentity: async () => assert.fail("ordinary series must not resolve anime identity"), }, + "@/lib/anime-tracker-entry": { + resolveTrackerAnimeEntry: async () => + assert.fail("ordinary series must not resolve anime tracker identity"), + }, "@/lib/cw-anime-episode": { aniZipLookupKey: () => null, applyAniZipEpisode: empty, @@ -207,7 +211,7 @@ function harness() { }; } -test("real ContinueCard poster, title and play controls resolve the older entry and reuse primary continuation instead of the body source picker", async () => { +test("real ContinueCard keeps original playback while its menu groups sources and removal for the clicked entry", async () => { const h = harness(); try { await h.render(); @@ -220,16 +224,20 @@ test("real ContinueCard poster, title and play controls resolve the older entry ]) { await h.context(selector); assert.equal(h.target.meta.id, "older-b"); - assert.equal(h.target.primary.actions()[0].label, "Continue watching"); + assert.deepEqual( + h.target.primary.actions().map((action: any) => action.label), + ["Choose another source", "Remove from Continue Watching"], + ); + assert.equal(h.target.extra, undefined, "removal is not duplicated below the title actions"); await React.act(() => h.target.primary.actions()[0].run()); const request = h.starts.at(-1); assert.equal(request.meta.id, "older-b"); assert.deepEqual(request.episode, { season: 2, episode: 3 }); - assert.deepEqual(request.mode, { autoPlay: true, resume: true }); + assert.deepEqual(request.mode, { autoPlay: false, resume: false }); } await h.click("button[title='Play']"); assert.deepEqual(h.starts.at(-1).mode, { autoPlay: true, resume: true }); - await React.act(() => h.target.extra.actions()[0].run()); + await React.act(() => h.target.primary.actions()[1].run()); assert.equal(h.removed[0], h.item); assert.equal(h.target.isValid(), false); } finally { @@ -247,7 +255,7 @@ test("an open older-card menu cannot execute after a profile change", async () = await h.render(); assert.equal(target.isValid(), false); await assert.rejects(target.primary.actions()[0].run(), /no longer available/); - await assert.rejects(target.extra.actions()[0].run(), /no longer available/); + await assert.rejects(target.primary.actions()[1].run(), /no longer available/); assert.equal(h.starts.length, 0); assert.equal(h.removed.length, 0); } finally { @@ -266,7 +274,7 @@ test("original removal invalidates an already-open card menu and matches removal directItem = original.removed[0]; assert.equal(openMenu.isValid(), false); await assert.rejects(openMenu.primary.actions()[0].run(), /no longer available/); - await assert.rejects(openMenu.extra.actions()[0].run(), /no longer available/); + await assert.rejects(openMenu.primary.actions()[1].run(), /no longer available/); assert.equal(original.removed.length, 1); assert.equal(original.starts.length, 0); } finally { @@ -276,7 +284,7 @@ test("original removal invalidates an already-open card menu and matches removal try { await contextual.render(); await contextual.context("button[title='Play']"); - await React.act(() => contextual.target.extra.actions()[0].run()); + await React.act(() => contextual.target.primary.actions()[1].run()); assert.deepEqual(contextual.removed, [directItem]); } finally { await contextual.dispose(); diff --git a/tests/last-playback-command.test.ts b/tests/last-playback-command.test.ts index b52b5e4fb..5fdf29792 100644 --- a/tests/last-playback-command.test.ts +++ b/tests/last-playback-command.test.ts @@ -158,11 +158,27 @@ async function fixture(target: ActualPlayback | null = last) { return { dom, state, calls, render, action, click, dispose }; } -test("empty actual history renders no last-playback command or separator", async () => { +test("empty actual history keeps a disabled last-playback command without a hide control", async () => { const f = await fixture(null); try { - assert.equal(document.querySelector("[data-context-action]"), null); + assert.equal(f.action()?.textContent?.trim(), "Resume last watched"); + assert.equal(f.action().disabled, true); + assert.equal(f.action().getAttribute("aria-disabled"), "true"); + assert.ok(f.action().querySelector("svg")); + assert.equal(f.action().querySelector("img"), null); + assert.equal(document.querySelector("[data-context-hide-playback]") === null, true); assert.equal(document.querySelector("[role='separator']"), null); + await f.click(f.action()); + assert.deepEqual(f.calls, []); + assert.deepEqual(f.state.closes, []); + f.state.target = last; + await f.render(); + assert.equal(f.action().disabled, false); + assert.equal(document.querySelector("[data-context-hide-playback]") === null, true); + f.state.target = null; + f.state.enabled = false; + await f.render(); + assert.equal(document.querySelector("[data-context-action]"), null); } finally { await f.dispose(); } @@ -173,7 +189,7 @@ test("a menu on B continues the actual A target and preserves its payload across try { f.state.clicked = "Different B"; await f.render(); - assert.equal(f.action().textContent?.trim(), "Continue last watched"); + assert.equal(f.action().textContent?.trim(), "Resume last watched"); await f.click(f.action()); assert.equal(f.calls.length, 1); assert.equal(f.calls[0].target, last); @@ -234,6 +250,24 @@ test("pending state disables activation and the shared runner rejects duplicate } }); +test("losing the target clears restart confirmation and leaves only the disabled command", async () => { + const f = await fixture({ ...last, completed: true }); + try { + await f.click(f.action()); + assert.ok(document.querySelector("[data-context-confirm]")); + f.state.target = null; + await f.render(); + assert.equal(f.action()?.textContent?.trim(), "Resume last watched"); + assert.equal(f.action().disabled, true); + assert.equal(document.querySelectorAll("[data-context-action]").length, 1); + assert.equal(document.querySelector("[data-context-confirm]"), null); + assert.equal(document.querySelector("[data-context-hide-playback]") === null, true); + assert.deepEqual(f.calls, []); + } finally { + await f.dispose(); + } +}); + test("missing, loading and broken artwork retain the real SVG fallback without changing the command", async () => { const f = await fixture({ ...last, @@ -254,7 +288,7 @@ test("missing, loading and broken artwork retain the real SVG fallback without c await React.act(async () => image.dispatchEvent(new f.dom.window.Event("error"))); assert.equal(f.action().querySelector("img"), null); assert.ok(f.action().querySelector(".context-menu-icon svg")); - assert.equal(f.action().textContent?.trim(), "Continue last watched"); + assert.equal(f.action().textContent?.trim(), "Resume last watched"); } finally { await f.dispose(); } @@ -296,7 +330,7 @@ test("standalone last-playback row preserves A and explicit EOF restart without const f = await fixture({ ...last, completed: true }); try { await f.render(); - assert.equal(f.action().textContent?.trim(), "Continue last watched"); + assert.equal(f.action().textContent?.trim(), "Resume last watched"); assert.equal(document.querySelectorAll("[data-context-action]").length, 1); assert.equal(document.querySelectorAll("[role=separator]").length, 0); assert.equal(f.action().closest("[data-context-quick]"), null); @@ -315,3 +349,19 @@ test("standalone last-playback row preserves A and explicit EOF restart without await f.dispose(); } }); + +test("a valid last-playback target has no dismissal command", async () => { + const f = await fixture(); + try { + assert.equal(document.querySelector("[data-context-hide-playback]") === null, true); + assert.equal(document.querySelectorAll("[role='menuitem']").length, 1); + assert.equal(f.action().textContent?.trim(), "Resume last watched"); + assert.equal(f.action().disabled, false); + await f.click(f.action()); + assert.equal(f.calls.length, 1); + assert.equal(f.calls[0].target, last); + assert.deepEqual(f.state.closes, [false]); + } finally { + await f.dispose(); + } +}); diff --git a/tests/membership-actions.test.ts b/tests/membership-actions.test.ts index ae8c77921..a34f2d005 100644 --- a/tests/membership-actions.test.ts +++ b/tests/membership-actions.test.ts @@ -160,7 +160,7 @@ test("creating a list belongs to Add and opening creation does not change existi false, ); const add = findAction(actions, "membership:add"); - assert.equal(add.label, "Add to"); + assert.equal(add.label, "Add to list"); const createIndex = add.children.findIndex((action) => action.id === "membership:create-list"); assert.equal(add.children[createIndex].sectionLabel, "Lists"); assert.equal(add.children[createIndex].group, "create"); diff --git a/tests/membership-controls.test.ts b/tests/membership-controls.test.ts index a96046e41..ca3346a17 100644 --- a/tests/membership-controls.test.ts +++ b/tests/membership-controls.test.ts @@ -135,6 +135,7 @@ test("membership creation cancels and saves back into the same stable destinatio }); try { await React.act(() => root.render(render())); + assert.equal(action("membership:add").textContent?.trim(), "Add to list"); await click(action("membership:add")); const chooser = document.querySelector("[data-context-submenu]"); const initial = localStorage.getItem(key); diff --git a/tests/menu-brand-footer.test.ts b/tests/menu-brand-footer.test.ts index d85a19fb7..8681b5510 100644 --- a/tests/menu-brand-footer.test.ts +++ b/tests/menu-brand-footer.test.ts @@ -8,7 +8,13 @@ import ts from "typescript"; import { HarborMark } from "../src/components/icons/harbor-mark.tsx"; test("root branding uses the real mark, preserves custom assets, and adds no command", async () => { - const dom = new JSDOM("
"); + const dom = new JSDOM("
"); + const style = dom.window.document.createElement("style"); + style.textContent = readFileSync( + new URL("../src/components/context-menu/menu-brand-footer.css", import.meta.url), + "utf8", + ); + dom.window.document.head.append(style); Object.assign(globalThis, { window: dom.window, document: dom.window.document, @@ -47,11 +53,26 @@ test("root branding uses the real mark, preserves custom assets, and adds no com ); const { createRoot } = await import("react-dom/client"); const root = createRoot(document.getElementById("root")!); + const otherRoot = createRoot(document.getElementById("other")!); const render = () => React.act(() => root.render(React.createElement(module.exports.MenuBrandFooter))); try { await render(); const footer = document.querySelector("[data-context-brand-footer]")!; + await React.act(() => otherRoot.render(React.createElement(HarborMark))); + for (const layout of ["classic", "dracula", "rail", "stremio"]) { + document.documentElement.setAttribute("data-theme-layout", layout); + assert.equal( + dom.window.getComputedStyle(footer).color, + "rgb(235, 160, 24)", + `menu branding keeps its approved color in ${layout}`, + ); + } + assert.notEqual( + dom.window.getComputedStyle(document.querySelector("#other svg")!).color, + "rgb(235, 160, 24)", + "the shared Harbor mark outside a menu is not recolored", + ); assert.equal(footer.getAttribute("aria-hidden"), "true"); assert.equal(footer.querySelector("svg")?.getAttribute("viewBox"), "0 0 700 642.88"); assert.equal(footer.textContent, ""); @@ -68,18 +89,25 @@ test("root branding uses the real mark, preserves custom assets, and adds no com for (const img of imgs) { assert.equal(img.alt, ""); assert.equal(img.draggable, false); - assert.equal(img.style.filter, ""); assert.equal(img.style.maskImage, ""); } + const filterId = /^url\(["']?#([^"')]+)["']?\)$/.exec(imgs[0].style.filter)?.[1]; + assert.ok(filterId, "the custom mark uses the footer's alpha-preserving color filter"); + const tint = document.getElementById(filterId)!; + assert.equal(tint.querySelector("feFlood")?.getAttribute("flood-color"), "#EBA018"); + assert.equal(tint.querySelector("feComposite")?.getAttribute("in2"), "SourceAlpha"); + assert.equal(tint.querySelector("feComposite")?.getAttribute("operator"), "in"); + assert.equal(imgs[1].style.filter, "", "the wordmark preserves its own colors"); await React.act(() => imgs[0].dispatchEvent(new dom.window.Event("error"))); - assert.ok(footer.querySelector("svg")); + assert.ok(footer.querySelector("svg.context-menu-brand-mark")); assert.equal(footer.querySelectorAll("img").length, 1); logo = { mark: "data:image/svg+xml,next-fixture-mark", wordmark: null }; await render(); - assert.equal(footer.querySelector("svg"), null); + assert.equal(footer.querySelector("svg.context-menu-brand-mark"), null); assert.equal(footer.querySelector("img")?.getAttribute("src"), logo.mark); } finally { await React.act(() => root.unmount()); + await React.act(() => otherRoot.unmount()); dom.window.close(); } }); diff --git a/tests/player-menu-tools.test.ts b/tests/player-menu-tools.test.ts index 2d263224b..dec774fcb 100644 --- a/tests/player-menu-tools.test.ts +++ b/tests/player-menu-tools.test.ts @@ -71,10 +71,9 @@ test("published player tools refresh after source and subtitle capability change assert.equal(actions.currentPlayerActions(), null); }); -test("player menu has a source-scoped magnet strip and separate subtitle and stream tools", async () => { +test("player menu keeps magnet as a normal row with subtitle and stream tools and no quick strip", async () => { const copied: string[] = []; const opened: string[] = []; - let settings = 0; let synced = 0; let current: any = { streamUrl: "https://media.invalid/a", @@ -90,9 +89,10 @@ test("player menu has a source-scoped magnet strip and separate subtitle and str }, }; const icons = Object.fromEntries( - ["AudioLines", "Download", "ExternalLink", "Link2", "Magnet", "Maximize", "Settings"].map( - (key) => [key, () => null], - ), + ["AudioLines", "Download", "ExternalLink", "Link2", "Magnet", "Maximize"].map((key) => [ + key, + () => null, + ]), ); const { playerContextSources } = load("src/components/context-menu/player-actions.tsx", { "react/jsx-runtime": jsxRuntime, @@ -109,25 +109,27 @@ test("player menu has a source-scoped magnet strip and separate subtitle and str }, }, }); - const source = playerContextSources( - () => current, - () => ({ - id: "page:go:settings", - label: "Settings", - run: () => { - settings++; - }, - }), - ); + const source = playerContextSources(() => current); + assert.equal(source.quick, undefined, "item menus do not provide an empty or populated strip"); assert.deepEqual( - source.quick.actions().map((a: any) => a.label), - ["Copy magnet", "Settings"], + source.rows.actions().map((a: any) => a.label), + [ + "Full screen", + "Download Video", + "Download Subtitle", + "Live sync", + "Copy stream link", + "Open in browser", + "Copy magnet", + ], ); assert.equal( - source.rows.actions().some((a: any) => /magnet|Back|Resume/.test(a.label)), + source.rows.actions().some((a: any) => /Settings|Back|Resume/.test(a.label)), false, ); - await source.quick.actions()[0].run(); + const magnet = source.rows.actions().find((a: any) => a.label === "Copy magnet"); + assert.equal(magnet.group, "links", "magnet shares the ordinary link command group"); + await magnet.run(); assert.deepEqual(copied, ["magnet:a"]); current = { ...current, @@ -135,9 +137,9 @@ test("player menu has a source-scoped magnet strip and separate subtitle and str magnetUrl: null, canLiveSync: false, }; - assert.deepEqual( - source.quick.actions().map((a: any) => a.label), - ["Settings"], + assert.equal( + source.rows.actions().some((a: any) => a.label === "Copy magnet"), + false, ); const live = source.rows.actions().find((a: any) => a.label === "Live sync"); assert.equal(live.disabled, true); @@ -153,21 +155,28 @@ test("player menu has a source-scoped magnet strip and separate subtitle and str .run(); assert.equal(copied[1], "https://media.invalid/b"); assert.deepEqual(opened, ["https://media.invalid/b"]); - await source.quick.actions()[0].run(); - assert.equal(settings, 1); current = null; - assert.equal(source.quick.isValid(), false); + assert.equal(source.rows.isValid(), false); assert.deepEqual(source.rows.actions(), []); }); -test("player Settings preserves the current navigation command's permission, PIN and availability", async () => { - let pinRequests = 0; - let navigations = 0; - let available: any = undefined; +test("player rows preserve available commands without adding application navigation", async () => { + const calls: string[] = []; + let current: any = { + toggleFullscreen: () => calls.push("fullscreen"), + download: () => calls.push("video"), + downloadSubtitle: () => calls.push("subtitle"), + liveSync: () => calls.push("sync"), + canDownload: true, + canDownloadSubtitle: true, + canLiveSync: true, + streamUrl: "file:///disposable-fixture/video.mp4", + }; const icons = Object.fromEntries( - ["AudioLines", "Download", "ExternalLink", "Link2", "Magnet", "Maximize", "Settings"].map( - (key) => [key, () => null], - ), + ["AudioLines", "Download", "ExternalLink", "Link2", "Magnet", "Maximize"].map((key) => [ + key, + () => null, + ]), ); const { playerContextSources } = load("src/components/context-menu/player-actions.tsx", { "react/jsx-runtime": jsxRuntime, @@ -176,39 +185,17 @@ test("player Settings preserves the current navigation command's permission, PIN "./content-actions": { copyContextText: async () => {} }, "@/lib/social/link-out": { openLinkOut: async () => {} }, }); - const source = playerContextSources( - () => ({}), - () => available, + const source = playerContextSources(() => current); + assert.equal(source.quick, undefined); + assert.deepEqual( + source.rows.actions().map((action: any) => action.label), + ["Full screen", "Download Video", "Download Subtitle", "Live sync"], + ); + for (const action of source.rows.actions()) await action.run(); + assert.deepEqual(calls, ["fullscreen", "video", "subtitle", "sync"]); + current = { ...current, canDownload: false, canDownloadSubtitle: false, liveSync: undefined }; + assert.deepEqual( + source.rows.actions().map((action: any) => action.label), + ["Full screen"], ); - assert.deepEqual(source.quick.actions(), [], "hidden/kid navigation must not gain Settings"); - const lockIcon = React.createElement("span", null, "locked"); - available = { - id: "page:go:settings", - label: "Settings", - icon: lockIcon, - reason: "PIN required", - run: () => { - pinRequests++; - }, - }; - const gated = source.quick.actions()[0]; - assert.equal(gated.id, "player:settings"); - assert.equal(gated.icon, lockIcon); - assert.equal(gated.reason, "PIN required"); - await gated.run(); - assert.equal(pinRequests, 1); - assert.equal(navigations, 0, "the player menu cannot bypass the supplied PIN operation"); - available = { ...available, disabled: true }; - assert.equal(source.quick.actions()[0].disabled, true); - available = { - ...available, - disabled: false, - run: () => { - navigations++; - }, - }; - await source.quick.actions()[0].run(); - assert.equal(navigations, 1, "unlocked navigation retains the shared departure dispatcher"); - available = undefined; - assert.deepEqual(source.quick.actions(), [], "policy changes are read while the menu is open"); }); From 44b264299decd983809186d5130b011c2aef195f Mon Sep 17 00:00:00 2001 From: OwaisByte <269550201+OwaisByte@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:42:41 +1100 Subject: [PATCH 3/8] fix(player): keep credentials out of playback history Persist restoration identity instead of authenticated transports, sanitize existing scoped history, and rebind server subtitles after source preparation. Co-authored-by: OpenAI Codex --- src/lib/addon-configuration-key.ts | 32 ++ src/lib/cinemeta.ts | 33 +- src/lib/playback-history.ts | 132 ++++- src/lib/player/actual-playback.ts | 33 +- src/lib/player/continue-playback.ts | 39 +- src/lib/player/playback-history-source.ts | 226 +++++++++ src/lib/player/subtitle-choice.ts | 1 + tests/continue-card-dismissal.test.ts | 2 + tests/playback-continuation.test.ts | 54 ++ tests/playback-history-credentials.test.ts | 553 +++++++++++++++++++++ 10 files changed, 1032 insertions(+), 73 deletions(-) create mode 100644 src/lib/player/playback-history-source.ts create mode 100644 tests/playback-history-credentials.test.ts diff --git a/src/lib/addon-configuration-key.ts b/src/lib/addon-configuration-key.ts index 27d825677..423ad8407 100644 --- a/src/lib/addon-configuration-key.ts +++ b/src/lib/addon-configuration-key.ts @@ -1,8 +1,40 @@ import { sha256 } from "@noble/hashes/sha256"; import { bytesToHex } from "@noble/hashes/utils"; +import type { AddonOrigin } from "./cinemeta"; export function addonConfigurationKey(base: string): string | undefined { if (!/^https?:\/\//i.test(base)) return undefined; const normalized = base.replace(/\/manifest\.json$/, ""); return bytesToHex(sha256(new TextEncoder().encode(normalized))); } + +export function persistableAddonOrigin(origin: unknown): AddonOrigin | undefined { + if (!origin || typeof origin !== "object") return undefined; + const value = origin as { + id?: unknown; + name?: unknown; + logo?: unknown; + base?: unknown; + configurationKey?: unknown; + }; + if (typeof value.id !== "string" || !value.id) return undefined; + // Plugin catalogue identifiers pin saved rows to their provider. Network transport URLs can + // contain credentials; resolve those from the installed addon configuration instead. + const base = + typeof value.base === "string" && + /^harbor-plugin:\/\/[A-Za-z0-9._:-]+(?:\/[A-Za-z0-9._:-]+)*$/.test(value.base) + ? value.base + : undefined; + const configurationKey = + (typeof value.base === "string" ? addonConfigurationKey(value.base) : undefined) ?? + (typeof value.configurationKey === "string" && /^[a-f0-9]{64}$/.test(value.configurationKey) + ? value.configurationKey + : undefined); + return { + id: value.id, + name: typeof value.name === "string" && value.name ? value.name : value.id, + logo: typeof value.logo === "string" ? value.logo : undefined, + ...(base ? { base } : {}), + ...(configurationKey ? { configurationKey } : {}), + }; +} diff --git a/src/lib/cinemeta.ts b/src/lib/cinemeta.ts index 07dd4b25f..190b4bf94 100644 --- a/src/lib/cinemeta.ts +++ b/src/lib/cinemeta.ts @@ -1,6 +1,6 @@ import { withMetaCache } from "@/lib/cinemeta-cache"; import { safeFetch as fetch } from "@/lib/safe-fetch"; -import { addonConfigurationKey } from "@/lib/addon-configuration-key"; +export { persistableAddonOrigin } from "./addon-configuration-key"; const CINEMETA = "https://v3-cinemeta.strem.io"; @@ -87,37 +87,6 @@ export type Meta = { }>; }; -export function persistableAddonOrigin(origin: unknown): AddonOrigin | undefined { - if (!origin || typeof origin !== "object") return undefined; - const value = origin as { - id?: unknown; - name?: unknown; - logo?: unknown; - base?: unknown; - configurationKey?: unknown; - }; - if (typeof value.id !== "string" || !value.id) return undefined; - // Plugin catalogue identifiers pin saved rows to their provider. Network transport URLs can - // contain credentials; resolve those from the installed addon configuration instead. - const base = - typeof value.base === "string" && - /^harbor-plugin:\/\/[A-Za-z0-9._:-]+(?:\/[A-Za-z0-9._:-]+)*$/.test(value.base) - ? value.base - : undefined; - const configurationKey = - (typeof value.base === "string" ? addonConfigurationKey(value.base) : undefined) ?? - (typeof value.configurationKey === "string" && /^[a-f0-9]{64}$/.test(value.configurationKey) - ? value.configurationKey - : undefined); - return { - id: value.id, - name: typeof value.name === "string" && value.name ? value.name : value.id, - logo: typeof value.logo === "string" ? value.logo : undefined, - ...(base ? { base } : {}), - ...(configurationKey ? { configurationKey } : {}), - }; -} - export function hasEmbeddedStreams(videos: Meta["videos"]): boolean { return videos?.some((v) => Array.isArray(v.streams) && v.streams.length > 0) === true; } diff --git a/src/lib/playback-history.ts b/src/lib/playback-history.ts index ae202ac5f..02c2c9674 100644 --- a/src/lib/playback-history.ts +++ b/src/lib/playback-history.ts @@ -3,6 +3,12 @@ import type { AddonProgress } from "./streams/addons"; import type { PlayerSrc } from "./view"; import type { PlaybackAudioChoice } from "./player/audio-choice"; import type { PlaybackSubtitleChoice } from "./player/subtitle-choice"; +import { + persistablePlaybackSource, + persistablePlaybackSubtitle, + persistablePlaybackUrl, + playbackUrlFingerprint, +} from "./player/playback-history-source"; import { noteNotify } from "./render-cause"; import { subtitleMediaKey, subtitleStreamKey } from "./subtitles/subtitle-memory"; import { @@ -51,6 +57,7 @@ export type ActualPlayback = { durationMs: number; completed: boolean; src: Omit; + sourceFingerprint?: string; requiresSourceRefresh?: boolean; audioTrack?: PlaybackAudioChoice; subtitle?: PlaybackSubtitleChoice; @@ -302,10 +309,20 @@ function readAll(): Record { const parsed = JSON.parse(raw) as Record; const now = Date.now(); const fresh: Record = {}; + let scrubbed = false; for (const [k, v] of Object.entries(parsed)) { - if (!v?.savedAt) continue; - if (now - v.savedAt > TTL_MS) continue; - fresh[k] = v; + if (!v) continue; + const safe = persistableEntry(v); + scrubbed ||= JSON.stringify(safe) !== JSON.stringify(v); + if (!v.savedAt || now - v.savedAt > TTL_MS) continue; + fresh[k] = safe; + } + if (scrubbed) { + try { + localStorage.setItem(storeKey(), JSON.stringify(fresh)); + } catch { + // Reads remain safe when storage is unavailable; a later read can retry. + } } return fresh; } catch { @@ -320,7 +337,7 @@ function writeAll(map: Record): void { const sorted = Object.entries(map).sort((a, b) => b[1].savedAt - a[1].savedAt); map = Object.fromEntries(sorted.slice(0, MAX_ENTRIES)); } - localStorage.setItem(storeKey(), JSON.stringify(map)); + localStorage.setItem(storeKey(), JSON.stringify(persistableEntries(map))); } catch { // A failed replacement must leave the last persisted source/session intact. return; @@ -368,7 +385,10 @@ export function savePlayback( } export function recordActualPlayback(playback: ActualPlayback): boolean { - if (!isPlaybackActorCurrent(playback.actor) || !validActualPlayback(playback)) return false; + if (!isPlaybackActorCurrent(playback.actor)) return false; + const safe = persistableActualPlayback(playback); + if (!safe || !validActualPlayback(safe)) return false; + playback = safe; const all = readAll(); const key = entryKey( playback.src.meta.id, @@ -385,7 +405,7 @@ export function recordActualPlayback(playback: ActualPlayback): boolean { previousActual && validActualPlayback(previousActual) && previousActual.id === playback.id && - playbackSourceKey(previousActual.src) === playbackSourceKey(playback.src); + actualPlaybackSourceKey(previousActual) === actualPlaybackSourceKey(playback); const actual = sameEndedSession ? { ...playback, @@ -422,7 +442,7 @@ export function recordActualPlayback(playback: ActualPlayback): boolean { try { localStorage.setItem( storeKey(), - JSON.stringify(Object.fromEntries(entries.slice(0, MAX_ENTRIES))), + JSON.stringify(persistableEntries(Object.fromEntries(entries.slice(0, MAX_ENTRIES)))), ); } catch { return false; @@ -432,6 +452,21 @@ export function recordActualPlayback(playback: ActualPlayback): boolean { } function validActualPlayback(value: ActualPlayback): boolean { + const server = value?.src?.homeServer; + const stream = value?.src?.streamRef; + const refreshable = + !!value?.requiresSourceRefresh && + (!!value.sourceFingerprint || + !!stream?.infoHash || + (!!stream?.addonId && !!stream.parsedTitle && !!stream.size && stream.size > 0)); + const serverIdentity = + !!server && + [server.connectionId, server.itemId, server.versionId].every( + (id) => typeof id === "string" && !!id, + ) && + ["original", "4k-40", "1080p-20", "1080p-12", "720p-4", "480p-2", "360p-0.7"].includes( + server.quality, + ); return ( !!value && typeof value.id === "string" && @@ -444,13 +479,71 @@ function validActualPlayback(value: ActualPlayback): boolean { !!value.actor && typeof value.actor.storageProfileId === "string" && typeof value.src?.url === "string" && - !!value.src.url && + (server ? serverIdentity : !!value.src.url || refreshable) && typeof value.src?.meta?.id === "string" && !!value.src.meta.id && typeof value.src.meta.name === "string" ); } +function persistableActualPlayback(value: ActualPlayback): ActualPlayback | undefined { + if (!value?.src?.meta) return undefined; + const src = persistablePlaybackSource(value.src); + return { + id: value.id, + actor: value.actor, + playedAt: value.playedAt, + positionMs: value.positionMs, + durationMs: value.durationMs, + completed: value.completed, + src, + sourceFingerprint: + playbackUrlFingerprint(value.src) ?? + (typeof value.sourceFingerprint === "string" && /^[a-f0-9]{64}$/.test(value.sourceFingerprint) + ? value.sourceFingerprint + : undefined), + requiresSourceRefresh: + value.requiresSourceRefresh || + !!value.src.proxySessionId || + !!Object.keys(value.src.headers ?? {}).length || + !!src.streamRef?.infoHash || + !!src.homeServer || + !src.url || + undefined, + audioTrack: value.audioTrack, + subtitle: persistablePlaybackSubtitle(value.subtitle, value.src), + subDelaySec: value.subDelaySec, + audioDelaySec: value.audioDelaySec, + }; +} + +function persistableEntry(entry: PlaybackEntry): PlaybackEntry { + return { + ...entry, + ...(typeof entry.url === "string" ? { url: persistablePlaybackUrl(entry.url) } : {}), + ...(entry.actual ? { actual: persistableActualPlayback(entry.actual) } : {}), + ...(entry.actualByProfile + ? { + actualByProfile: Object.fromEntries( + Object.entries(entry.actualByProfile).map(([id, state]) => [ + id, + { + ...state, + ...(state?.actual ? { actual: persistableActualPlayback(state.actual) } : {}), + }, + ]), + ), + } + : {}), + }; +} + +function persistableEntries(entries: Record): Record { + return Object.fromEntries( + Object.entries(entries).map(([key, entry]) => [key, persistableEntry(entry)]), + ); +} + export function readLastActualPlayback(): ActualPlayback | null { const actor = capturePlaybackActor(); let last: ActualPlaybackState | null = null; @@ -470,7 +563,21 @@ export function playbackSourceKey( return `server:${src.homeServer.connectionId}:${src.homeServer.itemId}:${src.homeServer.versionId}`; if (src.streamRef?.infoHash) return `torrent:${src.streamRef.infoHash.toLowerCase()}:${src.streamRef.fileIdx ?? ""}`; - return src.historyUrl ?? src.url; + const url = persistablePlaybackUrl(src.historyUrl ?? src.url); + if (url) return url; + const fingerprint = playbackUrlFingerprint(src); + if (fingerprint) return `private:${fingerprint}`; + const stream = src.streamRef; + // Older redacted sources may only have their verified release fields. + return stream?.addonId && stream.parsedTitle && stream.size && stream.size > 0 + ? `release:${JSON.stringify([stream.addonId, stream.parsedTitle, stream.size, stream.fileIdx ?? null])}` + : ""; +} + +export function actualPlaybackSourceKey(playback: ActualPlayback): string { + return playback.sourceFingerprint + ? `private:${playback.sourceFingerprint}` + : playbackSourceKey(playback.src); } export function readActualPlaybackFor(src: PlayerSrc): ActualPlayback | null { @@ -479,7 +586,8 @@ export function readActualPlaybackFor(src: PlayerSrc): ActualPlayback | null { readAll()[entryKey(src.meta.id, src.episode?.season, src.episode?.episode)], actor, ).actual; - return entry && playbackSourceKey(entry.src) === playbackSourceKey(src) ? entry : null; + const sourceKey = playbackSourceKey(src); + return entry && sourceKey && actualPlaybackSourceKey(entry) === sourceKey ? entry : null; } /** Capture the observed session before an asynchronous subtitle cache write starts. */ @@ -533,7 +641,7 @@ export function cacheActualPlaybackSubtitle( !actual || !validActualPlayback(actual) || actual.id !== target.id || - playbackSourceKey(actual.src) !== playbackSourceKey(target.src) || + actualPlaybackSourceKey(actual) !== actualPlaybackSourceKey(target) || !actual.subtitle || actual.subtitle.off || actual.subtitle.source !== original.source || @@ -555,7 +663,7 @@ export function cacheActualPlaybackSubtitle( }, }; try { - localStorage.setItem(storeKey(), JSON.stringify(all)); + localStorage.setItem(storeKey(), JSON.stringify(persistableEntries(all))); } catch { return false; } diff --git a/src/lib/player/actual-playback.ts b/src/lib/player/actual-playback.ts index 64ea12f2f..20a9dd7e8 100644 --- a/src/lib/player/actual-playback.ts +++ b/src/lib/player/actual-playback.ts @@ -5,6 +5,11 @@ import { isLivePlaybackSrc } from "./live-src"; import { isNaturalEnd } from "./playback-end"; import { playbackAudioChoice } from "./audio-choice"; import { playbackSubtitleChoice, type PlaybackSubtitleChoice } from "./subtitle-choice"; +import { + persistablePlaybackSource, + persistablePlaybackSubtitle, + playbackUrlFingerprint, +} from "./playback-history-source"; import { subtitleMediaKey, subtitleStreamKey, @@ -29,21 +34,7 @@ export function actualPlaybackSnapshot( !src.url ) return null; - // Runtime-only handles cannot survive restarting Harbor. Keep the original - // source identity and send proxy/torrent sources through normal preparation. - const { - continuation: _continuation, - playbackTraceId: _trace, - proxySessionId, - attempt: _attempt, - autoFired: _auto, - startPositionMs: _start, - startPaused: _paused, - startFromZero: _zero, - subtitlePreselect: _initialSubtitle, - ...saved - } = src; - const { videos: _videos, description: _description, ...meta } = src.meta; + const saved = persistablePlaybackSource(src); const selectedAudio = snapshot.audioTracks.find((track) => track.selected); const selectedSubtitle = snapshot.subtitleTracks.find((track) => track.selected); const tracksClearedAtEnd = @@ -74,10 +65,16 @@ export function actualPlaybackSnapshot( // Watched/progress thresholds do not mean the player finished. A paused // near-end session must still continue from its actual position. completed: isNaturalEnd(snapshot, positionSec), - src: { ...saved, meta, url: src.historyUrl ?? src.url }, - requiresSourceRefresh: !!proxySessionId || !!src.streamRef?.infoHash || !!src.homeServer, + src: saved, + sourceFingerprint: playbackUrlFingerprint(src), + requiresSourceRefresh: + !!src.proxySessionId || + !!Object.keys(src.headers ?? {}).length || + !!src.streamRef?.infoHash || + !!src.homeServer || + !saved.url, audioTrack: selectedAudio ? playbackAudioChoice(selectedAudio) : undefined, - subtitle, + subtitle: persistablePlaybackSubtitle(subtitle, src), subDelaySec: !tracksClearedAtEnd && Number.isFinite(snapshot.subDelaySec) ? snapshot.subDelaySec diff --git a/src/lib/player/continue-playback.ts b/src/lib/player/continue-playback.ts index dc2c79644..a0c69b451 100644 --- a/src/lib/player/continue-playback.ts +++ b/src/lib/player/continue-playback.ts @@ -1,5 +1,6 @@ import { playbackSourceKey, + actualPlaybackSourceKey, streamMatchesEntry, type ActualPlayback, type PlaybackActor, @@ -50,7 +51,7 @@ export function continuationAppliesToSource( target.episode !== src.episode?.episode) ) return false; - if (context.sourceKey === playbackSourceKey(src)) return true; + if (context.sourceKey && context.sourceKey === playbackSourceKey(src)) return true; if (!target || !src.streamRef) return false; const source = { ...src.streamRef, url: src.historyUrl ?? src.url }; const saved = context.source; @@ -78,7 +79,7 @@ export function continuationFor( actor, restart, positionMs: restart ? 0 : target.positionMs, - sourceKey: playbackSourceKey(target.src), + sourceKey: actualPlaybackSourceKey(target), target: { metaId: target.src.meta.id, season: target.src.episode?.season, @@ -115,7 +116,8 @@ export async function continueActualPlayback( const active = services.active(); if (active) { if ( - playbackSourceKey(active.src) !== playbackSourceKey(target.src) || + !playbackSourceKey(active.src) || + playbackSourceKey(active.src) !== actualPlaybackSourceKey(target) || active.src.meta.id !== target.src.meta.id || active.src.episode?.season !== target.src.episode?.season || active.src.episode?.episode !== target.src.episode?.episode @@ -126,14 +128,29 @@ export async function continueActualPlayback( return "returned"; } const continuation = continuationFor(target, actor, options.restart); - const prepared = (src: PlayerSrc): PlayerSrc => ({ - ...src, - continuation, - resume: true, - startPositionMs: continuation.positionMs, - startFromZero: options.restart === true, - startPaused: false, - }); + const prepared = (src: PlayerSrc): PlayerSrc => { + const subtitle = continuation.subtitle; + const restored = + src.homeServer && subtitle?.homeServerSubtitleId + ? { + ...continuation, + subtitle: { + ...subtitle, + // Server subtitle URLs are authenticated transport, not saved identity. + source: src.subtitles?.find((track) => track.id === subtitle.homeServerSubtitleId) + ?.url, + }, + } + : continuation; + return { + ...src, + continuation: restored, + resume: true, + startPositionMs: continuation.positionMs, + startFromZero: options.restart === true, + startPaused: false, + }; + }; const pick = () => { valid(); services.openPicker(target.src.meta, target.src.episode, { diff --git a/src/lib/player/playback-history-source.ts b/src/lib/player/playback-history-source.ts new file mode 100644 index 000000000..8c97b78fa --- /dev/null +++ b/src/lib/player/playback-history-source.ts @@ -0,0 +1,226 @@ +import { persistableAddonOrigin } from "../addon-configuration-key"; +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; +import type { PlayerSrc } from "../view"; +import type { PlaybackSubtitleChoice } from "./subtitle-choice"; + +// Keep a credentialed transport out of every retained field, including artwork +// and provider labels. The installed connection/addon owns its credentials. +function safeString(value: string): string | undefined { + for (const match of value.matchAll(/https?:\/\/[^\s<>"']+/gi)) { + try { + const url = new URL(match[0]); + if (url.username || url.password) return undefined; + for (const key of url.searchParams.keys()) { + const normalized = key.toLowerCase().replace(/[-_]/g, ""); + if ( + /token|password|secret|signature|authorization/.test(normalized) || + ["apikey", "key", "auth", "sig", "jwt", "credential", "policy", "keypairid"].includes( + normalized, + ) || + normalized.startsWith("xamz") || + normalized.startsWith("xgoog") + ) + return undefined; + } + } catch { + return undefined; + } + } + return value; +} + +/** Select only scalar fields; nested transport objects never enter history. */ +function fields(value: T, keys: readonly K[]): Pick { + const entries: Array<[K, unknown]> = []; + for (const key of keys) { + const item = value[key]; + if (typeof item === "string") { + const safe = safeString(item); + if (safe !== undefined) entries.push([key, safe]); + } else if ( + typeof item === "boolean" || + item === null || + (typeof item === "number" && Number.isFinite(item)) + ) { + entries.push([key, item]); + } else if (Array.isArray(item) && item.every((part) => typeof part === "string")) { + entries.push([key, item.filter((part) => safeString(part) !== undefined)]); + } + } + return Object.fromEntries(entries) as Pick; +} + +export function persistablePlaybackUrl(value: string | undefined): string | undefined { + return typeof value === "string" && !/^(blob|data|memory):/i.test(value) + ? safeString(value) + : undefined; +} + +/** Compare a redacted source with a live session without retaining its URL. */ +export function playbackUrlFingerprint( + src: Pick, +): string | undefined { + const url = src.historyUrl ?? src.url; + return !src.homeServer && !src.streamRef?.infoHash && url && !persistablePlaybackUrl(url) + ? bytesToHex(sha256(new TextEncoder().encode(url))) + : undefined; +} + +/** Persist source identity, never a complete runtime PlayerSrc. */ +export function persistablePlaybackSource(src: PlayerSrc): Omit { + const origin = persistableAddonOrigin(src.meta.addonOrigin); + return { + ...fields(src, [ + "title", + "subtitle", + "imdbId", + "imdbIdVerified", + "notWebReady", + "isAnime", + "suppressCloudSync", + "episodeEnd", + ]), + meta: { + ...fields(src.meta, [ + "id", + "type", + "name", + "poster", + "background", + "logo", + "originalLanguage", + "country", + "malId", + "animeFormat", + "releaseInfo", + "releaseDate", + "inTheaters", + "imdbRating", + "adult", + "sourceRank", + "tmdbScore", + "pluginQuality", + "listingYear", + "runtime", + "genres", + "isCollection", + ]), + ...(origin + ? { addonOrigin: fields(origin, ["id", "name", "logo", "base", "configurationKey"]) } + : {}), + ...(src.meta.providerBadge + ? { providerBadge: fields(src.meta.providerBadge, ["name", "logo", "tint"]) } + : {}), + ...(src.meta.listingExtras + ? { + listingExtras: fields(src.meta.listingExtras, [ + "rest", + "languages", + "quality", + "resolutions", + "hdr", + ]), + } + : {}), + ...(src.meta.behaviorHints + ? { behaviorHints: fields(src.meta.behaviorHints, ["defaultVideoId"]) } + : {}), + }, + url: src.homeServer ? "" : (persistablePlaybackUrl(src.historyUrl ?? src.url) ?? ""), + ...(src.episode + ? { + episode: fields(src.episode, [ + "season", + "episode", + "name", + "imdbId", + "imdbSeason", + "imdbEpisode", + "absoluteNumber", + "tvdbEpisodeId", + "kitsuStreamId", + "sourceMetaId", + "videoId", + "still", + "overview", + "rating", + "airDate", + "runtime", + ]), + } + : {}), + ...(src.episodeSpan + ? { episodeSpan: fields(src.episodeSpan, ["season", "episode", "episodeEnd"]) } + : {}), + ...(src.streamRef + ? { + streamRef: fields(src.streamRef, [ + "resolvedFilename", + "infoHash", + "fileIdx", + "addonId", + "name", + "addonName", + "title", + "parsedTitle", + "resolution", + "quality", + "releaseGroup", + "source", + "audioLanguages", + "size", + "bingeGroup", + "cachedSlugs", + ]), + } + : {}), + ...(src.homeServer + ? { homeServer: fields(src.homeServer, ["connectionId", "itemId", "versionId", "quality"]) } + : {}), + }; +} + +export function persistablePlaybackSubtitle( + choice: PlaybackSubtitleChoice | undefined, + src: PlayerSrc, +): PlaybackSubtitleChoice | undefined { + if (!choice) return undefined; + if (choice.off) return { off: true }; + const saved = fields(choice, [ + "lang", + "title", + "trackId", + "subId", + "provider", + "release", + "format", + "encoding", + "downloadAuthKind", + "providerDerived", + "localTrusted", + "matchScore", + "matchConfidence", + "streamKey", + "imported", + "external", + "codec", + "forced", + "hearingImpaired", + "fpsOverride", + "homeServerSubtitleId", + ]); + const serverSubtitle = + src.homeServer && choice.source + ? src.subtitles?.find((track) => track.url === choice.source) + : undefined; + const homeServerSubtitleId = saved.homeServerSubtitleId ?? serverSubtitle?.id; + if (src.homeServer) { + delete saved.trackId; + // A server track ID is stable only when supplied by its source catalogue. + if (homeServerSubtitleId) return { ...saved, homeServerSubtitleId }; + if (serverSubtitle) return saved; + } + const source = persistablePlaybackUrl(choice.source); + return source ? { ...saved, source } : saved; +} diff --git a/src/lib/player/subtitle-choice.ts b/src/lib/player/subtitle-choice.ts index b3051fb46..dd2e11427 100644 --- a/src/lib/player/subtitle-choice.ts +++ b/src/lib/player/subtitle-choice.ts @@ -13,6 +13,7 @@ export type PlaybackSubtitleChoice = Omit & { forced?: boolean; hearingImpaired?: boolean; fpsOverride?: number; + homeServerSubtitleId?: string; }; function durableSource(source?: string): string | undefined { diff --git a/tests/continue-card-dismissal.test.ts b/tests/continue-card-dismissal.test.ts index bd9f70b9e..6c61da58e 100644 --- a/tests/continue-card-dismissal.test.ts +++ b/tests/continue-card-dismissal.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { readFileSync } from "node:fs"; import ts from "typescript"; +import * as playbackHistorySource from "../src/lib/player/playback-history-source"; function harness() { const data = new Map(); @@ -23,6 +24,7 @@ function harness() { const file = name.replace(/^@\/lib\//, "").replace(/^\.\//, ""); if (cache.has(file)) return cache.get(file); if (file === "react") return { useSyncExternalStore() {} }; + if (file === "player/playback-history-source") return playbackHistorySource; if (file === "stremio") return { episodeFromVideoId: (id?: string) => { diff --git a/tests/playback-continuation.test.ts b/tests/playback-continuation.test.ts index 69d742cd6..67d17214f 100644 --- a/tests/playback-continuation.test.ts +++ b/tests/playback-continuation.test.ts @@ -140,6 +140,60 @@ test("home-server preparation receives actual position and explicit restart zero assert.deepEqual(positions, [45000, 0]); }); +test("home-server continuation rebinds a saved subtitle ID to fresh authenticated transport", async () => { + const target = fixture(); + target.src.url = ""; + target.src.homeServer = { + connectionId: "server", + itemId: "item", + versionId: "version", + quality: "original", + }; + target.subtitle = { external: true, homeServerSubtitleId: "arabic", lang: "ara" }; + target.audioTrack = { lang: "eng", title: "English" }; + target.subDelaySec = 1.25; + target.audioDelaySec = -0.2; + const { api, calls } = services(target); + const freshSubtitle = "https://server.test/subtitle.srt?api_key=fixture-fresh-token"; + api.prepareServer = async (src) => ({ + ...src, + url: "https://server.test/fresh-video", + headers: { Authorization: "fixture-fresh-authorization" }, + subtitles: [{ id: "arabic", lang: "ara", url: freshSubtitle }], + }); + await continueActualPlayback(target, {}, api); + const restored = (calls[0] as [string, any])[1]; + assert.equal(restored.continuation.subtitle.source, freshSubtitle); + assert.deepEqual(restored.headers, { Authorization: "fixture-fresh-authorization" }); + assert.equal(restored.startPositionMs, target.positionMs); + assert.deepEqual(restored.continuation.audioTrack, target.audioTrack); + assert.equal(restored.continuation.subDelaySec, 1.25); + assert.equal(restored.continuation.audioDelaySec, -0.2); + assert.equal(target.subtitle.source, undefined, "transport renewal never mutates saved history"); +}); + +test("a missing saved server subtitle is not replaced with another subtitle URL", async () => { + const target = fixture(); + target.src.url = ""; + target.src.homeServer = { + connectionId: "server", + itemId: "item", + versionId: "version", + quality: "original", + }; + target.subtitle = { external: true, homeServerSubtitleId: "missing", lang: "ara" }; + const { api, calls } = services(target); + api.prepareServer = async (src) => ({ + ...src, + url: "https://server.test/fresh-video", + subtitles: [{ id: "different", lang: "ara", url: "https://server.test/another.srt" }], + }); + await continueActualPlayback(target, {}, api); + const restored = (calls[0] as [string, any])[1]; + assert.equal(restored.continuation.subtitle.homeServerSubtitleId, "missing"); + assert.equal(restored.continuation.subtitle.source, undefined); +}); + test("continuation carries scoped subtitle choice and delays through source preparation", async () => { const target: ActualPlayback = { ...fixture(), diff --git a/tests/playback-history-credentials.test.ts b/tests/playback-history-credentials.test.ts new file mode 100644 index 000000000..725f60721 --- /dev/null +++ b/tests/playback-history-credentials.test.ts @@ -0,0 +1,553 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { actualPlaybackSnapshot } from "../src/lib/player/actual-playback"; +import { initialPlayerSnapshot } from "../src/lib/player/bridge"; +import * as history from "../src/lib/playback-history"; +import type { PlayerSrc } from "../src/lib/view"; +import { + continueActualPlayback, + continuationAppliesToSource, + continuationFor, + type ContinuationServices, +} from "../src/lib/player/continue-playback"; + +const values = new Map(); +let failWrite = false; +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + if (failWrite) throw new Error("synthetic storage failure"); + values.set(key, value); + }, + removeItem: (key: string) => values.delete(key), + }, +}); + +const storageKey = "harbor.playback-history.v1.one"; +const actor = { profileId: "one", storageProfileId: "one", accountId: null }; +const snapshot = { + ...initialPlayerSnapshot(), + status: "playing" as const, + firstFrameReady: true, + durationSec: 400, + audioTracks: [{ id: "2", kind: "audio" as const, label: "Arabic", lang: "ara", selected: true }], + subDelaySec: 1.25, + audioDelaySec: -0.2, + subtitleFps: 23.976, +}; + +function configure(privateCw = false): void { + values.clear(); + values.set( + "harbor.profiles.v1", + JSON.stringify({ + activeId: "one", + profiles: [ + { id: "one", isPrimary: true }, + { id: "two", shareStremioWith: "one" }, + ], + }), + ); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: privateCw })); +} + +function serverSource(provider: string, query: string): PlayerSrc { + const credential = `synthetic-${provider}-credential`; + const url = `https://${provider}.invalid/video?${query}=${credential}`; + return { + meta: { + id: "tt-synthetic", + type: "series", + name: "Synthetic series", + poster: `https://${provider}.invalid/poster?${query}=${credential}`, + background: `https://synthetic-user:${credential}@${provider}.invalid/background`, + logo: "https://public.invalid/logo.png", + adult: true, + addonOrigin: { id: "synthetic-addon", name: "Synthetic addon", base: url, logo: url }, + videos: [{ season: 2, episode: 3, thumbnail: url, streams: [{ url }] }], + }, + episode: { + season: 2, + episode: 3, + name: "Synthetic episode", + still: url, + sourceMetaId: "tt-source", + }, + url, + historyUrl: url, + title: "Synthetic video", + headers: { Authorization: credential, "X-Emby-Token": credential, "X-Plex-Token": credential }, + homeServer: { + connectionId: `synthetic-${provider}-connection`, + itemId: "synthetic-item", + versionId: "synthetic-version", + quality: "original", + playbackSessionId: `synthetic-${provider}-temporary-session`, + }, + subtitles: [{ id: "stable-subtitle-9", url: `${url}&subtitle=9`, lang: "ara" }], + subtitlePreselect: { off: false, url: `${url}&subtitle=old` }, + playbackTraceId: "synthetic-temporary-trace", + proxySessionId: "synthetic-temporary-proxy", + streamRef: { infoHash: "abc", fileIdx: 4, resolvedFilename: "Synthetic.S02E03.mkv" }, + }; +} + +function observed(src: PlayerSrc, at = Date.now()): history.ActualPlayback { + return actualPlaybackSnapshot( + src, + { + ...snapshot, + subtitleTracks: [ + { + id: "temporary-native-7", + kind: "subtitle", + label: "Arabic", + lang: "ara", + selected: true, + external: true, + url: src.subtitles?.[0]?.url, + }, + ], + }, + 30, + { id: "synthetic-observed-session", actor: history.capturePlaybackActor() }, + at, + )!; +} + +for (const [provider, query] of [ + ["jellyfin", "api_key"], + ["emby", "X-Emby-Token"], + ["plex", "X-Plex-Token"], +]) { + test(`${provider} observed playback persists identity and choices without authenticated transport`, () => { + configure(); + const src = serverSource(provider, query); + const before = JSON.stringify(src); + const playback = observed(src); + assert.equal(playback.src.url, ""); + assert.deepEqual(playback.src.homeServer, { + connectionId: `synthetic-${provider}-connection`, + itemId: "synthetic-item", + versionId: "synthetic-version", + quality: "original", + }); + assert.equal(playback.subtitle?.homeServerSubtitleId, "stable-subtitle-9"); + assert.equal(playback.subtitle?.source, undefined); + assert.equal(playback.subtitle?.trackId, undefined); + assert.equal(playback.subtitle?.fpsOverride, 23.976); + assert.equal(playback.subDelaySec, 1.25); + assert.equal(playback.audioDelaySec, -0.2); + assert.equal(playback.audioTrack?.lang, "ara"); + assert.equal(playback.src.episode?.sourceMetaId, "tt-source"); + assert.equal(playback.src.meta.logo, "https://public.invalid/logo.png"); + assert.equal(playback.src.meta.adult, true); + assert.equal(playback.src.meta.addonOrigin?.id, "synthetic-addon"); + assert.match(playback.src.meta.addonOrigin?.configurationKey ?? "", /^[a-f0-9]{64}$/); + assert.equal(history.recordActualPlayback(playback), true); + const serialized = values.get(storageKey)!; + assert.doesNotMatch( + serialized, + /credential|temporary-|Authorization|X-Emby-Token|X-Plex-Token|playbackSessionId|subtitlePreselect/, + ); + assert.equal(history.readLastActualPlayback()?.id, playback.id); + assert.equal(history.readActualPlaybackFor(src)?.id, playback.id); + assert.equal(JSON.stringify(src), before, "snapshot must not mutate runtime transport"); + }); +} + +test("read boundaries scrub legacy shared and private actual slots without changing progress or ownership", () => { + for (const legacyKey of ["harbor.playback-history.v1", storageKey]) { + configure(true); + const src = serverSource("legacy", "access_token"); + const at = Date.now() - 1000; + const base = { + id: "legacy-one", + actor: { ...actor, privateCw: true }, + playedAt: at, + positionMs: 31000, + durationMs: 400000, + completed: false, + src, + requiresSourceRefresh: true, + subtitle: { + source: src.subtitles![0].url, + external: true, + trackId: "synthetic-temporary-native", + fpsOverride: 25, + }, + subDelaySec: 2, + }; + const other = { + ...base, + id: "legacy-two", + actor: { ...base.actor, profileId: "two" }, + playedAt: at + 1, + }; + values.set( + legacyKey, + JSON.stringify({ + "tt-synthetic|s2e3": { + savedAt: at + 2, + title: "Existing title", + actual: other, + actualByProfile: { one: { actual: base }, two: { actual: other } }, + }, + unrelated: { savedAt: at, title: "Keep unrelated history" }, + }), + ); + const saved = history.readLastActualPlayback(); + assert.equal(saved?.id, "legacy-one"); + assert.equal(saved?.positionMs, 31000); + assert.equal(saved?.playedAt, at); + assert.equal(saved?.subtitle?.homeServerSubtitleId, "stable-subtitle-9"); + const serialized = values.get(storageKey)!; + assert.doesNotMatch( + serialized, + /credential|temporary-|playbackSessionId|Authorization|subtitlePreselect/, + ); + const entries = JSON.parse(serialized); + assert.equal(entries["tt-synthetic|s2e3"].actual.id, "legacy-two"); + assert.equal(entries["tt-synthetic|s2e3"].actualByProfile.two.actual.playedAt, at + 1); + assert.equal(entries["tt-synthetic|s2e3"].savedAt, at + 2); + assert.equal(entries.unrelated.title, "Keep unrelated history"); + } +}); + +test("credentialed generic sources refresh through a known release instead of persisting credentials", () => { + configure(); + const src: PlayerSrc = { + meta: { + id: "tt-public", + type: "movie", + name: "Public", + poster: "https://art.invalid/image?token=synthetic-generic-credential", + }, + title: "Public", + url: "https://synthetic-user:synthetic-generic-credential@video.invalid/movie", + streamRef: { addonId: "addon", parsedTitle: "Public.2026", size: 42 }, + }; + const playback = actualPlaybackSnapshot(src, snapshot, 20, { id: "generic", actor })!; + assert.equal(history.recordActualPlayback(playback), true); + assert.equal(history.readLastActualPlayback()?.src.url, ""); + assert.equal(history.readLastActualPlayback()?.requiresSourceRefresh, true); + assert.equal(history.readActualPlaybackFor(src)?.id, "generic"); + assert.equal(history.actualPlaybackSourceKey(playback), history.playbackSourceKey(src)); + const context = continuationFor(playback, actor); + assert.equal( + continuationAppliesToSource({ ...src, url: "https://public.invalid/renewed" }, context), + true, + ); + assert.equal( + continuationAppliesToSource( + { ...src, url: "https://public.invalid/renewed", streamRef: { ...src.streamRef, size: 43 } }, + context, + ), + false, + ); + assert.doesNotMatch(values.get(storageKey)!, /synthetic-generic-credential|synthetic-user/); + assert.equal( + history.recordActualPlayback({ + ...playback, + sourceFingerprint: undefined, + src: { ...playback.src, streamRef: undefined }, + }), + false, + "empty transport without recoverable identity is invalid", + ); +}); + +test("public direct and local source identities and durable subtitle choices survive, including off", () => { + for (const url of ["https://public.invalid/movie.mp4?quality=1080", "C:/synthetic/movie.mp4"]) { + configure(); + const src: PlayerSrc = { + meta: { id: "local:synthetic", type: "movie", name: "Synthetic" }, + title: "Synthetic", + url, + }; + const playback = actualPlaybackSnapshot(src, snapshot, 20, { id: url, actor })!; + assert.equal(history.recordActualPlayback({ ...playback, subtitle: { off: true } }), true); + assert.equal(history.readLastActualPlayback()?.src.url, url); + assert.deepEqual(history.readLastActualPlayback()?.subtitle, { off: true }); + for (const source of ["C:/synthetic/sub.srt", "https://public.invalid/sub.vtt?lang=en"]) { + assert.equal( + history.recordActualPlayback({ + ...playback, + subtitle: { external: true, source, fpsOverride: 25 }, + }), + true, + ); + assert.equal(history.readLastActualPlayback()?.subtitle?.source, source); + } + } +}); + +test("history write boundary sanitizes raw snapshots and a failed security rewrite still returns safe data", () => { + configure(); + const src = serverSource("write", "token"); + const raw = { + id: "raw", + actor, + playedAt: Date.now(), + positionMs: 30000, + durationMs: 400000, + completed: false, + src, + subtitle: { external: true, source: src.subtitles![0].url }, + }; + assert.equal(history.recordActualPlayback(raw), true); + assert.doesNotMatch( + values.get(storageKey)!, + /credential|temporary-|playbackSessionId|Authorization/, + ); + values.set( + storageKey, + JSON.stringify({ "tt-synthetic|s2e3": { savedAt: raw.playedAt, actual: raw } }), + ); + failWrite = true; + try { + const safe = history.readLastActualPlayback(); + assert.equal(safe?.id, "raw"); + assert.doesNotMatch( + JSON.stringify(safe), + /credential|temporary-|playbackSessionId|Authorization/, + ); + assert.equal(history.recordActualPlayback({ ...raw, id: "failed" }), false); + } finally { + failWrite = false; + } + assert.equal(history.readLastActualPlayback()?.id, "raw"); + assert.doesNotMatch( + values.get(storageKey)!, + /credential|temporary-|playbackSessionId|Authorization/, + ); +}); + +test("expired actual records cannot leave old credentials behind when history is read", () => { + configure(); + const expired = { + id: "expired", + actor, + playedAt: Date.now() - 31 * 24 * 60 * 60 * 1000, + positionMs: 30000, + durationMs: 400000, + completed: false, + src: serverSource("expired", "api_key"), + }; + values.set( + storageKey, + JSON.stringify({ "tt-synthetic|s2e3": { savedAt: expired.playedAt, actual: expired } }), + ); + assert.equal(history.readLastActualPlayback(), null); + assert.doesNotMatch( + values.get(storageKey)!, + /credential|temporary-|playbackSessionId|Authorization/, + ); +}); + +test("generic credential query spellings cannot survive in artwork, episode fields, or subtitle choices", () => { + for (const query of [ + "Api_Key", + "access_token", + "auth", + "X-Plex-Token", + "X-Amz-Credential", + "signature", + ]) { + configure(); + const secret = `https://synthetic.invalid/resource?${query}=synthetic-query-credential`; + const src: PlayerSrc = { + meta: { + id: "tt-public", + type: "movie", + name: "Public", + poster: secret, + providerBadge: { name: "Public", logo: secret, tint: "blue" }, + }, + title: "Public", + url: "https://public.invalid/movie.mp4", + episode: { season: 1, episode: 2, still: secret }, + }; + const playback = actualPlaybackSnapshot( + src, + { + ...snapshot, + subtitleTracks: [ + { + id: "1", + kind: "subtitle", + label: "English", + selected: true, + external: true, + originalUrl: secret, + }, + ], + }, + 20, + { id: "public", actor }, + )!; + assert.equal(history.recordActualPlayback(playback), true); + assert.doesNotMatch(values.get(storageKey)!, /synthetic-query-credential/); + assert.equal(history.readLastActualPlayback()?.subtitle?.source, undefined); + assert.equal(history.readLastActualPlayback()?.src.url, "https://public.invalid/movie.mp4"); + } +}); + +test("known credentialed source preference URLs are scrubbed on both saves and existing history reads", () => { + configure(); + history.savePlayback("tt-secret", { + addonId: "synthetic-addon", + url: "https://synthetic.invalid/video?api_key=synthetic-preference-credential", + title: "Keep source identity", + }); + assert.doesNotMatch(values.get(storageKey)!, /synthetic-preference-credential/); + assert.equal(history.readPlayback("tt-secret")?.addonId, "synthetic-addon"); + values.set( + storageKey, + JSON.stringify({ + "tt-secret": { + addonId: "synthetic-addon", + url: "https://synthetic-user:synthetic-legacy-preference-credential@synthetic.invalid/video", + savedAt: Date.now(), + }, + "tt-public": { url: "https://public.invalid/video", savedAt: Date.now() }, + }), + ); + assert.equal(history.readPlayback("tt-secret")?.url, undefined); + assert.doesNotMatch( + values.get(storageKey)!, + /synthetic-legacy-preference-credential|synthetic-user/, + ); + assert.equal(history.readPlayback("tt-public")?.url, "https://public.invalid/video"); +}); + +test("a normal URL requiring runtime headers is refreshed instead of replayed without authentication", () => { + configure(); + const src: PlayerSrc = { + meta: { id: "tt-header", type: "movie", name: "Header source" }, + title: "Header source", + url: "https://synthetic.invalid/video", + headers: { Authorization: "synthetic-header-credential" }, + streamRef: { addonId: "synthetic-addon", parsedTitle: "Synthetic.2026", size: 42 }, + }; + const playback = actualPlaybackSnapshot(src, snapshot, 20, { id: "headers", actor })!; + assert.equal(playback.requiresSourceRefresh, true); + assert.equal(history.recordActualPlayback(playback), true); + assert.equal(history.readLastActualPlayback()?.requiresSourceRefresh, true); + assert.doesNotMatch(values.get(storageKey)!, /synthetic-header-credential|Authorization/); + assert.equal( + history.recordActualPlayback({ ...playback, requiresSourceRefresh: false, src }), + true, + "write boundary must also recognize raw headers", + ); + assert.equal(history.readLastActualPlayback()?.requiresSourceRefresh, true); +}); + +test("an opaque signed source remains the latest session without retaining its credentialed URL", () => { + configure(); + const src: PlayerSrc = { + meta: { id: "tt-opaque", type: "movie", name: "Opaque source" }, + title: "Opaque source", + url: "https://synthetic.invalid/video?token=synthetic-opaque-credential", + }; + const at = Date.now(); + const previous = actualPlaybackSnapshot( + { ...src, meta: { ...src.meta, id: "tt-older" }, url: "https://public.invalid/older" }, + snapshot, + 10, + { id: "older", actor }, + at - 1000, + )!; + assert.equal(history.recordActualPlayback(previous), true); + const playback = actualPlaybackSnapshot(src, snapshot, 45, { id: "opaque", actor }, at)!; + assert.equal(history.recordActualPlayback(playback), true); + const saved = history.readLastActualPlayback()!; + assert.equal(saved.id, "opaque"); + assert.equal(saved.src.url, ""); + assert.equal(saved.requiresSourceRefresh, true); + assert.equal(saved.positionMs, 45000); + assert.equal(history.readActualPlaybackFor(src)?.id, "opaque"); + const context = continuationFor(saved, actor); + assert.equal(continuationAppliesToSource(src, context), true); + assert.equal( + continuationAppliesToSource({ ...src, url: src.url + "-different" }, context), + false, + ); + assert.doesNotMatch(values.get(storageKey)!, /synthetic-opaque-credential|https:\/\/synthetic/); + assert.equal(history.readLastActualPlayback()?.id, "opaque", "sanitized rereads keep identity"); +}); + +test("empty source keys never establish that an unknown source matches a continuation", () => { + const src: PlayerSrc = { + meta: { id: "tt-empty", type: "movie", name: "Empty" }, + title: "Empty", + url: "", + }; + assert.equal( + continuationAppliesToSource(src, { + id: "empty", + actor, + positionMs: 45000, + sourceKey: "", + source: { savedAt: Date.now() }, + target: { metaId: src.meta.id }, + }), + false, + ); +}); + +test("a redacted source returns to its live session or opens the picker with its saved choices", async () => { + configure(); + const src: PlayerSrc = { + meta: { id: "tt-return", type: "movie", name: "Return" }, + title: "Return", + url: "https://synthetic.invalid/video?token=synthetic-return-credential", + }; + assert.equal( + history.recordActualPlayback( + actualPlaybackSnapshot(src, snapshot, 45, { id: "return", actor })!, + ), + true, + ); + const target = history.readLastActualPlayback()!; + let returned = 0; + let picked = 0; + const services: ContinuationServices = { + isCurrent: history.isPlaybackActorCurrent, + latest: history.readLastActualPlayback, + active: () => ({ + src, + resume: async () => { + returned++; + }, + }), + localFileExists: async () => { + throw new Error("not a local source"); + }, + prepareServer: async () => { + throw new Error("not a server source"); + }, + openPlayer: () => { + throw new Error("redacted URL must not reopen"); + }, + openPicker: (_meta, _episode, options) => { + picked++; + assert.equal(options.autoPlay, false); + assert.equal(options.continuation.positionMs, 45000); + assert.deepEqual(options.continuation.audioTrack, target.audioTrack); + assert.deepEqual(options.continuation.subtitle, { off: true }); + assert.equal(options.continuation.subDelaySec, 1.25); + assert.equal(options.continuation.audioDelaySec, -0.2); + assert.doesNotMatch(JSON.stringify(options), /synthetic-return-credential/); + }, + }; + assert.equal(await continueActualPlayback(target, {}, services), "returned"); + assert.equal(returned, 1); + assert.equal( + await continueActualPlayback(target, {}, { ...services, active: () => null }), + "picker-opened", + ); + assert.equal(picked, 1); +}); From 5b6dc305c5be6a740e99ec15c4175cc1b527e207 Mon Sep 17 00:00:00 2001 From: OwaisByte <269550201+OwaisByte@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:42:53 +1100 Subject: [PATCH 4/8] fix(player): recover failed direct playback continuation Return failed direct resumes to source selection with their saved choices. Scope failures and timers to the current opening so stale callbacks cannot replace a newer source. Co-authored-by: OpenAI Codex --- src/views/player/hooks/use-auto-retry.ts | 133 +++++++++++++-- tests/player-recovery-ownership.test.ts | 208 ++++++++++++++++++++++- 2 files changed, 318 insertions(+), 23 deletions(-) diff --git a/src/views/player/hooks/use-auto-retry.ts b/src/views/player/hooks/use-auto-retry.ts index 6b66d2f0a..7ef862095 100644 --- a/src/views/player/hooks/use-auto-retry.ts +++ b/src/views/player/hooks/use-auto-retry.ts @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type RefObject, + type SetStateAction, +} from "react"; import type { PlayerBridge, PlayerSnapshot } from "@/lib/player/bridge"; import { getPlaybackBuffered, @@ -13,7 +20,10 @@ import { registerStreamProxy } from "@/lib/stream-proxy"; import { buildTranscodedUrl, probeStremioServer } from "@/lib/stremio-server"; import type { DebridStore } from "@/lib/debrid/types"; import type { Meta } from "@/lib/cinemeta"; -import type { PlayerSrc, PlayEpisode } from "@/lib/view"; +import type { PlayerSrc, PlayEpisode, PickerOptions } from "@/lib/view"; +import { isPlaybackActorCurrent } from "@/lib/playback-history"; +import { continuationAppliesToSource } from "@/lib/player/continue-playback"; +import { playerLoadIdentity } from "@/lib/player/load-identity"; import { BLACK_SCREEN_GRACE_MS, MAX_AUTORETRY_ATTEMPTS, @@ -24,11 +34,7 @@ import { } from "../player-utils"; import { GENUINE_FAILURE_WINDOW_MS, type EngineStats } from "@/lib/torrent/engine-stats"; -type OpenPicker = ( - meta: Meta, - episode?: PlayEpisode, - opts?: { autoPlay?: boolean; attempt?: number }, -) => void; +type OpenPicker = (meta: Meta, episode?: PlayEpisode, opts?: PickerOptions) => void; export type SourceError = { status: number; host: string; exhausted?: boolean }; @@ -70,14 +76,18 @@ export function useAutoRetry(params: { } = params; const isLocal = isLocalUrl(src.url); const isLive = src.meta.id.startsWith("iptv:"); + const openingKey = JSON.stringify([ + playerLoadIdentity(src, src.url, src.episode?.season, src.episode?.episode), + src.continuation?.id, + ]); const ENGINE_FIRST_FRAME_GRACE_MS = 20_000; const ENGINE_HARD_CEILING_MS = 75_000; const ENGINE_COMPLETE_RELOAD_MS = 8_000; const ENGINE_COMPLETE_FAILURE_MS = 20_000; const urlAtRef = useRef(0); - const urlSeenRef = useRef(null); - if (urlSeenRef.current !== src.url) { - urlSeenRef.current = src.url; + const currentOpeningRef = useRef(null); + if (currentOpeningRef.current !== openingKey) { + currentOpeningRef.current = openingKey; urlAtRef.current = Date.now(); } const snapRef = useRef(snap); @@ -113,7 +123,40 @@ export function useAutoRetry(params: { const truncatedReloadedRef = useRef(false); const resumedAfterTruncatedRef = useRef(false); const [transcodedUrl, setTranscodedUrl] = useState(null); - const [sourceError, setSourceError] = useState(null); + const [sourceFailure, setSourceFailure] = useState<{ + openingKey: string; + error: SourceError; + } | null>(null); + const sourceError = sourceFailure?.openingKey === openingKey ? sourceFailure.error : null; + const setSourceError = useCallback( + (update: SetStateAction) => { + if (currentOpeningRef.current !== openingKey) return; + setSourceFailure((previous) => { + if (currentOpeningRef.current !== openingKey) return previous; + const current = previous?.openingKey === openingKey ? previous.error : null; + const error = typeof update === "function" ? update(current) : update; + return error ? { openingKey, error } : null; + }); + }, + [openingKey], + ); + const directContinuation = + !!src.continuation && + !src.homeServer && + !src.proxySessionId && + !src.streamRef?.infoHash && + !isLocal && + !isLive && + !isP2pEngine && + !inRoom && + /^https?:\/\//i.test(src.url); + const continuationDecodedRef = useRef(false); + useEffect(() => { + continuationDecodedRef.current = false; + }, [openingKey]); + useEffect(() => { + if (snap.firstFrameReady) continuationDecodedRef.current = true; + }, [snap.firstFrameReady, snap.status]); // Latch "this source actually played" instead of reading the live clock in // the error handler: mpv resets time-pos while tearing a failed file down, so // the clock can already read zero when the error arrives and the old guard @@ -121,7 +164,7 @@ export function useAutoRetry(params: { const playedPastStartRef = useRef(false); useEffect(() => { playedPastStartRef.current = false; - }, [src.url]); + }, [openingKey]); useEffect(() => { if (snap.positionSec > 5 || getPlaybackPosition() > 5) playedPastStartRef.current = true; }, [snap.positionSec, snap.status]); @@ -141,7 +184,7 @@ export function useAutoRetry(params: { resumedAfterTruncatedRef.current = false; dlRef.current = { bytes: 0, at: Date.now() }; setTranscodedUrl(null); - }, [src.url]); + }, [openingKey, setSourceError]); useEffect(() => { if (isLive && hasProgress) livePlayedRef.current = true; @@ -173,8 +216,33 @@ export function useAutoRetry(params: { }, [isLive, snap.errorCode, src.url, src.subtitles, src.notWebReady, bridgeRef]); const triggerAutoRetry = useCallback( - (reason: string) => { + (reason: string, terminalFailure = false) => { if (autoRetriedRef.current) return; + const continuation = src.continuation; + if ( + continuation && + (!isPlaybackActorCurrent(continuation.actor) || + !continuationAppliesToSource(src, continuation)) + ) + return; + if ( + directContinuation && + continuation && + !continuationDecodedRef.current && + (snap.errorCode != null || terminalFailure) + ) { + autoRetriedRef.current = true; + clearOnePickerCache(src.meta, src.episode); + bridgeRef.current?.destroy(); + bridgeRef.current = null; + openPicker(src.meta, src.episode, { + autoPlay: false, + resume: true, + continuation, + queueItemId: src.queueItemId, + }); + return; + } if (isLocal) { console.warn(`[player] local file: skipping auto-retry (${reason})`); return; @@ -230,10 +298,20 @@ export function useAutoRetry(params: { src.url, src.subtitles, src.notWebReady, + src.continuation, + src.queueItemId, + directContinuation, + snap.errorCode, + setSourceError, bridgeRef, ], ); + useEffect(() => { + if (sourceError && directContinuation && !continuationDecodedRef.current) + triggerAutoRetry("saved source could not reopen", true); + }, [sourceError, directContinuation, triggerAutoRetry]); + useEffect(() => { if (snap.errorCode == null) return; if (snap.status === "ended") return; @@ -241,6 +319,16 @@ export function useAutoRetry(params: { console.warn(`[player] live channel: ignoring "${snap.errorCode}", mpv handles reconnection`); return; } + if ( + src.continuation && + (!isPlaybackActorCurrent(src.continuation.actor) || + !continuationAppliesToSource(src, src.continuation)) + ) + return; + if (directContinuation && !continuationDecodedRef.current) { + triggerAutoRetry("saved source failed before playback"); + return; + } if (playedPastStartRef.current || getPlaybackPosition() > 5) return; if ( isP2pEngine && @@ -252,7 +340,10 @@ export function useAutoRetry(params: { if (!recoveryBridge) return; const ac = new AbortController(); // A recovery belongs to the failed load, never to a later source or bridge. - const current = () => !ac.signal.aborted && bridgeRef.current === recoveryBridge; + const current = () => + !ac.signal.aborted && + bridgeRef.current === recoveryBridge && + (!src.continuation || isPlaybackActorCurrent(src.continuation.actor)); const retry = () => { if (current()) triggerAutoRetry(`playback error "${snap.errorCode}"`); }; @@ -391,6 +482,8 @@ export function useAutoRetry(params: { src.url, src.subtitles, src.notWebReady, + src.continuation, + directContinuation, bridgeRef, isP2pEngine, engineFailure, @@ -555,15 +648,19 @@ export function useAutoRetry(params: { const t = window.setTimeout( () => { const s = snapRef.current; - if (s.status === "playing" || s.status === "ended") return; - if (s.durationSec > 0 || getPlaybackPosition() > 0) return; + if (s.status === "ended") return; + if (directContinuation) { + if (continuationDecodedRef.current || s.firstFrameReady) return; + } else if (s.status === "playing" || s.durationSec > 0 || getPlaybackPosition() > 0) { + return; + } console.warn(`[player] source never started within ${NEVER_STARTED_CEILING_MS}ms`); setSourceError((prev) => prev ?? { status: 0, host: hostOf(src.url) }); }, Math.max(0, remaining), ); return () => window.clearTimeout(t); - }, [src.url, isLive, isLocal, inRoom, isP2pEngine]); + }, [openingKey, directContinuation, isLive, isLocal, inRoom, isP2pEngine, setSourceError]); useEffect(() => { if (startedPlaying) setSourceError(null); diff --git a/tests/player-recovery-ownership.test.ts b/tests/player-recovery-ownership.test.ts index cf5f846a0..e40c80337 100644 --- a/tests/player-recovery-ownership.test.ts +++ b/tests/player-recovery-ownership.test.ts @@ -3,6 +3,9 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import ts from "typescript"; import { emptySnapshot } from "../src/lib/player/bridge.ts"; +import { continuationAppliesToSource } from "../src/lib/player/continue-playback"; +import { playbackSourceKey } from "../src/lib/playback-history"; +import { playerLoadIdentity } from "../src/lib/player/load-identity"; function deferred() { let resolve!: (value: T) => void, reject!: (error: Error) => void; @@ -23,8 +26,10 @@ function recoveryHarness() { mi = 0, ei = 0, position = 0, - timer = 0; + timer = 0, + actorCurrent = true; let pending: Array<{ i: number; deps?: unknown[]; run: () => any }> = []; + const timeouts = new Map void; delay: number }>(); const same = (a?: unknown[], b?: unknown[]) => !!a && !!b && a.length === b.length && a.every((v, i) => Object.is(v, b[i])); const react = { @@ -65,14 +70,18 @@ function recoveryHarness() { const proxyRequests: Array<{ args: any[]; job: ReturnType }> = []; const debridRequests: Array<{ args: any[]; job: ReturnType }> = []; const probes: Array> = [], - pickers: any[][] = []; + pickers: any[][] = [], + cleared: any[][] = []; const dependencies = { getPlaybackPosition: () => position, getPlaybackBuffered: () => 0, usePlaybackFlag: (read: () => boolean) => read(), isLocalUrl: () => false, isTruncatedEnd: () => false, - clearOnePickerCache() {}, + clearOnePickerCache: (...args: any[]) => cleared.push(args), + isPlaybackActorCurrent: () => actorCurrent, + continuationAppliesToSource, + playerLoadIdentity, registerStreamProxy: (...args: any[]) => { const job = deferred(); proxyRequests.push({ args, job }); @@ -109,8 +118,11 @@ function recoveryHarness() { (id: string) => (id === "react" ? react : dependencies), module, { - setTimeout: () => ++timer, - clearTimeout() {}, + setTimeout: (run: () => void, delay: number) => { + timeouts.set(++timer, { run, delay }); + return timer; + }, + clearTimeout: (id: number) => timeouts.delete(id), setInterval: () => ++timer, clearInterval() {}, }, @@ -157,6 +169,18 @@ function recoveryHarness() { debridRequests, probes, pickers, + cleared, + actorCurrent: (current: boolean) => { + actorCurrent = current; + }, + runTimeouts: (minimumDelay: number) => { + for (const [id, timeout] of timeouts) { + if (timeout.delay < minimumDelay) continue; + timeouts.delete(id); + timeout.run(); + } + }, + pendingTimeouts: () => [...timeouts.values()], get params() { return params; }, @@ -174,6 +198,168 @@ function recoveryHarness() { }; } +function resumedSource(h: ReturnType) { + const src = h.params.src; + return { + ...src, + startPositionMs: 45000, + continuation: { + id: "saved-session", + actor: { profileId: "one", storageProfileId: "one", accountId: "fixture-account" }, + positionMs: 45000, + sourceKey: src.url, + target: { metaId: src.meta.id }, + source: { url: src.url, savedAt: 1 }, + audioTrack: { lang: "ara", title: "Arabic" }, + subtitle: { off: true }, + subDelaySec: 1.25, + audioDelaySec: -0.2, + }, + }; +} + +test("a valid resumed direct stream starts without a probe or source picker", () => { + const h = recoveryHarness(); + h.render({ src: resumedSource(h), instantPlay: false }); + h.render({ snap: { ...emptySnapshot, status: "playing", firstFrameReady: true } }); + assert.equal(h.pickers.length, 0); + assert.equal(h.proxyRequests.length, 0); + assert.equal(h.probes.length, 0); + assert.equal(h.cleared.length, 0); +}); + +test("failed direct continuation refreshes its picker once with the complete saved session", () => { + const h = recoveryHarness(); + const src = resumedSource(h); + h.render({ src, instantPlay: false }); + h.clock(45); + h.fail(); + assert.equal(h.pickers.length, 1, "a seek position does not prove a decoded frame"); + assert.deepEqual(h.pickers[0], [ + src.meta, + src.episode, + { + autoPlay: false, + resume: true, + continuation: src.continuation, + queueItemId: undefined, + }, + ]); + assert.deepEqual(h.cleared, [[src.meta, src.episode]]); + assert.equal(h.bridge.destroyed, 1); + assert.equal(h.proxyRequests.length, 0); + h.cycle(); + assert.equal(h.pickers.length, 1, "the same failed opening never repeats recovery"); +}); + +test("a stale actor or mismatched continuation cannot reopen its picker", () => { + for (const changed of ["actor", "target"] as const) { + const h = recoveryHarness(); + const src = resumedSource(h); + if (changed === "actor") h.actorCurrent(false); + else src.continuation.target.metaId = "another-title"; + h.render({ src, instantPlay: false }); + h.fail(); + assert.equal(h.pickers.length, 0); + assert.equal(h.bridge.loads.length, 0); + assert.equal(h.proxyRequests.length, 0); + } +}); + +test("a direct continuation that decoded a frame keeps ordinary mid-playback failure handling", () => { + const h = recoveryHarness(); + h.render({ src: resumedSource(h), instantPlay: false }); + h.render({ snap: { ...emptySnapshot, status: "playing", firstFrameReady: true } }); + h.clock(45); + h.fail(); + assert.equal(h.pickers.length, 0); +}); + +test("a terminal startup timeout recovers a direct continuation with its saved state", () => { + const h = recoveryHarness(); + const src = resumedSource(h); + h.render({ src, instantPlay: false }); + h.clock(45); + h.runTimeouts(70000); + h.render(); + assert.equal(h.pickers.length, 1); + assert.equal(h.pickers[0][2].continuation, src.continuation); + assert.equal(h.pickers[0][2].autoPlay, false); +}); + +test("a previous source's terminal error cannot dismiss a fresh direct continuation", () => { + const h = recoveryHarness(); + h.render({ instantPlay: false }); + h.runTimeouts(70000); + assert.ok(h.render().sourceError); + const src = resumedSource(h); + h.render({ src, snap: { ...emptySnapshot, status: "loading" } }); + assert.equal(h.pickers.length, 0); + assert.equal(h.bridge.destroyed, 0); +}); + +test("a replaced continuation ignores the prior opening's queued startup deadline", () => { + const h = recoveryHarness(); + const first = resumedSource(h); + h.render({ src: first, instantPlay: false }); + const oldDeadline = h.pendingTimeouts().find((timer) => timer.delay > 70000)!; + const next = { ...first, continuation: { ...first.continuation, id: "new-session" } }; + h.render({ src: next, snap: { ...emptySnapshot, status: "loading" } }); + oldDeadline.run(); + assert.equal(h.render().sourceError, null); + assert.equal(h.pickers.length, 0); +}); + +test("a new continuation on the same URL receives its own recovery attempt", () => { + const h = recoveryHarness(); + const first = resumedSource(h); + h.render({ src: first, instantPlay: false }); + h.fail(); + h.bridgeRef.current = h.makeBridge(); + const next = { ...first, continuation: { ...first.continuation, id: "new-session" } }; + h.render({ src: next, snap: { ...emptySnapshot, status: "loading" } }); + h.fail(); + assert.equal(h.pickers.length, 2); + assert.equal(h.pickers[1][2].continuation, next.continuation); +}); + +test("server, local, proxy, torrent, live and room sources keep their existing recovery paths", () => { + for (const mode of ["server", "local", "proxy", "torrent", "live", "room"] as const) { + const h = recoveryHarness(); + const src: any = resumedSource(h); + if (mode === "server") + src.homeServer = { connectionId: "one", itemId: "two", versionId: "three" }; + if (mode === "local") src.url = "C:/fixture/video.mkv"; + if (mode === "proxy") src.proxySessionId = "fixture-proxy"; + if (mode === "torrent") src.streamRef = { infoHash: "fixture-hash", fileIdx: 1 }; + if (mode === "live") src.meta = { ...src.meta, id: "iptv:fixture" }; + src.continuation = { + ...src.continuation, + target: { metaId: src.meta.id }, + sourceKey: playbackSourceKey(src), + }; + h.render({ src, instantPlay: false, inRoom: mode === "room" }); + h.fail(); + assert.equal(h.pickers.length, 0, mode); + assert.equal(h.cleared.length, 0, mode); + } +}); + +test("an old proxy failure cannot act after a direct continuation opens its picker", async () => { + const h = recoveryHarness(); + h.fail(); + h.cycle(); + const old = h.proxyRequests[0].job; + const src = resumedSource(h); + h.render({ src, instantPlay: false, snap: { ...emptySnapshot, status: "loading" } }); + h.fail(); + assert.equal(h.pickers.length, 1); + old.reject(new Error("fixture old proxy failed")); + await settle(); + assert.equal(h.pickers.length, 1); + assert.equal(h.bridge.destroyed, 1); +}); + async function settle() { for (let i = 0; i < 8; i++) await Promise.resolve(); } @@ -348,6 +534,18 @@ test("manual source recovery failure does not silently choose another title or s assert.equal(h.render().sourceError?.host, "media.test"); }); +test("showing an ordinary manual source error does not restart its recovery effect", async () => { + const h = recoveryHarness(); + h.render({ instantPlay: false }); + h.fail(); + h.cycle(); + h.proxyRequests[0].job.reject(new Error("fixture proxy unavailable")); + await settle(); + assert.ok(h.render().sourceError); + assert.equal(h.proxyRequests.length, 1, "a visible error does not start another remux request"); + assert.equal(h.pickers.length, 0); +}); + test("a failure after playback started is not auto-retried", () => { const h = recoveryHarness(); h.render({ snap: { ...emptySnapshot, status: "playing", positionSec: 10 } }); From ea26f4d4fddc92fe79b688515f1b31ef41ce0a4d Mon Sep 17 00:00:00 2001 From: OwaisByte <269550201+OwaisByte@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:44:30 +1100 Subject: [PATCH 5/8] test: keep history fixture compatible with beta Keep the real history-source helper mapping separate from upstream's provider mocks. Co-authored-by: OpenAI Codex --- tests/continue-card-dismissal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/continue-card-dismissal.test.ts b/tests/continue-card-dismissal.test.ts index 6c61da58e..1ff0d5224 100644 --- a/tests/continue-card-dismissal.test.ts +++ b/tests/continue-card-dismissal.test.ts @@ -24,7 +24,6 @@ function harness() { const file = name.replace(/^@\/lib\//, "").replace(/^\.\//, ""); if (cache.has(file)) return cache.get(file); if (file === "react") return { useSyncExternalStore() {} }; - if (file === "player/playback-history-source") return playbackHistorySource; if (file === "stremio") return { episodeFromVideoId: (id?: string) => { @@ -51,6 +50,7 @@ function harness() { }, persistCritical() {}, }; + if (file === "player/playback-history-source") return playbackHistorySource; const mod = { exports: {} }; cache.set(file, mod.exports); const output = ts.transpileModule( From 67e4377db235979d0604b2be5fee269de870ab42 Mon Sep 17 00:00:00 2001 From: OwaisByte <269550201+OwaisByte@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:43:50 +1100 Subject: [PATCH 6/8] fix(player): keep unclassified network URLs out of history Persist source identities and fingerprints rather than unknown network transports. Project legacy history and source preferences, retain exact live-session matching, and report nonrenewable sources explicitly. Co-authored-by: OpenAI Codex --- .../context-menu/last-playback-command.tsx | 7 +- src/lib/i18n/locales/ar/context-actions.ts | 2 + .../i18n/locales/context-actions-fallback.ts | 2 + src/lib/playback-history.ts | 186 +++++-- src/lib/player/continue-playback.ts | 9 +- src/lib/player/playback-history-source.ts | 254 ++++++--- src/lib/player/subtitle-choice.ts | 8 + src/lib/season-lock.ts | 43 +- tests/actual-playback-snapshot.test.ts | 5 +- tests/playback-history-credentials.test.ts | 22 +- tests/playback-history-provenance.test.ts | 514 ++++++++++++++++++ tests/player-recovery-ownership.test.ts | 2 +- 12 files changed, 929 insertions(+), 125 deletions(-) create mode 100644 tests/playback-history-provenance.test.ts diff --git a/src/components/context-menu/last-playback-command.tsx b/src/components/context-menu/last-playback-command.tsx index ac1087a72..0d4bd32b6 100644 --- a/src/components/context-menu/last-playback-command.tsx +++ b/src/components/context-menu/last-playback-command.tsx @@ -67,7 +67,12 @@ function ResolvedLastPlaybackCommand({ onClose, children }: LastPlaybackCommandP return children ? children(action, row) : row; } const presentation = lastPlaybackPresentation(target, settings, summary?.status === "watched"); - const tooltip = [presentation.title ?? t("Local video"), presentation.episode] + const tooltip = [ + target.src.meta.id.startsWith("playback-private:") + ? t("Direct stream") + : (presentation.title ?? t("Local video")), + presentation.episode, + ] .filter(Boolean) .join(" · "); const action: ContextAction = { diff --git a/src/lib/i18n/locales/ar/context-actions.ts b/src/lib/i18n/locales/ar/context-actions.ts index 3c9526f84..109bea07b 100644 --- a/src/lib/i18n/locales/ar/context-actions.ts +++ b/src/lib/i18n/locales/ar/context-actions.ts @@ -518,6 +518,8 @@ const contextActions: Record = { "هناك فيديو آخر قيد التشغيل. عُد إلى المشغّل أولًا.", "The last video file is missing or unavailable.": "ملف الفيديو الأخير مفقود أو غير متاح.", "The last playback source is no longer available.": "لم يعد مصدر آخر مشاهدة متاحًا.", + "Open this video from its source or enter the link again. Its network address was not saved.": + "افتح الفيديو من مصدره أو أدخل الرابط مجددًا. لم يُحفظ عنوانه الشبكي.", "Playback changed. Open the menu again.": "تغيّر التشغيل. افتح القائمة مجددًا.", "Playback controls are not available in this session.": "أدوات التحكم بالتشغيل غير متاحة في هذه الجلسة.", diff --git a/src/lib/i18n/locales/context-actions-fallback.ts b/src/lib/i18n/locales/context-actions-fallback.ts index d4b9ce0de..80d6855fe 100644 --- a/src/lib/i18n/locales/context-actions-fallback.ts +++ b/src/lib/i18n/locales/context-actions-fallback.ts @@ -548,6 +548,8 @@ const contextActionsFallback: Record = { "The last video file is missing or unavailable.", "The last playback source is no longer available.": "The last playback source is no longer available.", + "Open this video from its source or enter the link again. Its network address was not saved.": + "Open this video from its source or enter the link again. Its network address was not saved.", "Playback changed. Open the menu again.": "Playback changed. Open the menu again.", "Playback controls are not available in this session.": "Playback controls are not available in this session.", diff --git a/src/lib/playback-history.ts b/src/lib/playback-history.ts index 02c2c9674..267622524 100644 --- a/src/lib/playback-history.ts +++ b/src/lib/playback-history.ts @@ -8,6 +8,9 @@ import { persistablePlaybackSubtitle, persistablePlaybackUrl, playbackUrlFingerprint, + persistablePlaybackFields, + persistablePlaybackIdentity, + persistablePlaybackPreference, } from "./player/playback-history-source"; import { noteNotify } from "./render-cause"; import { subtitleMediaKey, subtitleStreamKey } from "./subtitles/subtitle-memory"; @@ -25,6 +28,7 @@ export type PlaybackEntry = { fileIdx?: number | null; addonId?: string | null; url?: string | null; + urlFingerprint?: string; title?: string | null; parsedTitle?: string | null; resolution?: string | null; @@ -295,27 +299,52 @@ function selectedActualState( } function entryKey(metaId: string, season?: number, episode?: number): string { + metaId = persistablePlaybackIdentity(metaId); if (typeof season === "number" && typeof episode === "number") { return `${metaId}|s${season}e${episode}`; } return metaId; } -function readAll(): Record { +function legacyEntryKey(key: string, entry: PlaybackEntry): string { + const observations = [ + entry.actual, + ...Object.values(entry.actualByProfile ?? {}).map((state) => state?.actual), + ]; + for (const actual of observations) { + const id = actual?.src?.meta?.id; + if (typeof id !== "string") continue; + const episode = actual?.src?.episode; + const suffix = + typeof episode?.season === "number" && typeof episode?.episode === "number" + ? `|s${episode.season}e${episode.episode}` + : ""; + if (key === `${id}${suffix}`) return entryKey(id, episode?.season, episode?.episode); + } + if (typeof entry.url === "string" && key === `url:${entry.url}`) return entryKey(key); + const suffix = /^(.*)(\|s\d+e\d+)$/.exec(key); + return suffix + ? `${persistablePlaybackIdentity(suffix[1])}${suffix[2]}` + : persistablePlaybackIdentity(key); +} + +function readAll(includeExpired = false): Record { migrateLegacy(); try { const raw = localStorage.getItem(storeKey()); if (!raw) return {}; const parsed = JSON.parse(raw) as Record; const now = Date.now(); - const fresh: Record = {}; + const fresh: Record = Object.create(null); let scrubbed = false; for (const [k, v] of Object.entries(parsed)) { if (!v) continue; - const safe = persistableEntry(v); - scrubbed ||= JSON.stringify(safe) !== JSON.stringify(v); - if (!v.savedAt || now - v.savedAt > TTL_MS) continue; - fresh[k] = safe; + const key = legacyEntryKey(k, v); + const safe = persistableEntry(v, key); + scrubbed ||= key !== k || JSON.stringify(safe) !== JSON.stringify(v); + if (!includeExpired && (!v.savedAt || now - v.savedAt > TTL_MS)) continue; + const previous = fresh[key]; + fresh[key] = previous ? mergeHistoryEntries(previous, safe) : safe; } if (scrubbed) { try { @@ -330,6 +359,33 @@ function readAll(): Record { } } +function newerActual( + a: ActualPlayback | undefined, + b: ActualPlayback | undefined, +): ActualPlayback | undefined { + if (!a || !validActualPlayback(a)) return b; + if (!b || !validActualPlayback(b)) return a; + return a.playedAt >= b.playedAt ? a : b; +} + +// A partially migrated key can coexist with its old URL-derived spelling. +// Preserve each profile's observed session when those keys converge. +function mergeHistoryEntries(a: PlaybackEntry, b: PlaybackEntry): PlaybackEntry { + const profiles: Record = Object.assign( + Object.create(null), + a.actualByProfile, + ); + for (const [id, state] of Object.entries(b.actualByProfile ?? {})) { + const actual = newerActual(profiles[id]?.actual, state.actual); + profiles[id] = { ...state, ...(actual ? { actual } : {}) }; + } + return { + ...(a.savedAt >= b.savedAt ? a : b), + actual: newerActual(a.actual, b.actual), + ...(Object.keys(profiles).length ? { actualByProfile: profiles } : {}), + }; +} + function writeAll(map: Record): void { try { const keys = Object.keys(map); @@ -346,15 +402,8 @@ function writeAll(map: Record): void { } export function playbackHistoryRawKeys(): string[] { - migrateLegacy(); - try { - const raw = localStorage.getItem(storeKey()); - if (!raw) return []; - const parsed = JSON.parse(raw) as Record; - return Object.keys(parsed); - } catch { - return []; - } + // Statistics inspect every retained key; only regular history reads apply TTL. + return Object.keys(readAll(true)); } export function savePlayback( @@ -471,6 +520,7 @@ function validActualPlayback(value: ActualPlayback): boolean { !!value && typeof value.id === "string" && !!value.id && + typeof value.completed === "boolean" && Number.isFinite(value.playedAt) && Number.isFinite(value.positionMs) && value.positionMs >= 0 && @@ -487,11 +537,42 @@ function validActualPlayback(value: ActualPlayback): boolean { } function persistableActualPlayback(value: ActualPlayback): ActualPlayback | undefined { - if (!value?.src?.meta) return undefined; + if (!validActualPlayback(value)) return undefined; const src = persistablePlaybackSource(value.src); + const actor = persistablePlaybackFields(value.actor, [ + "profileId", + "storageProfileId", + "accountId", + "provider", + "privateCw", + ]); + if ( + typeof actor.profileId !== "string" || + typeof actor.storageProfileId !== "string" || + !(actor.accountId === null || typeof actor.accountId === "string") || + (actor.provider !== undefined && actor.provider !== "nuvio") + ) + return undefined; + const audio = value.audioTrack + ? persistablePlaybackFields(value.audioTrack, [ + "id", + "lang", + "title", + "codec", + "channels", + "channelCount", + ]) + : undefined; + const audioValid = + audio && + typeof audio.id === "string" && + (["lang", "title", "codec", "channels"] as const).every( + (key) => audio[key] === undefined || typeof audio[key] === "string", + ) && + (audio.channelCount === undefined || typeof audio.channelCount === "number"); return { - id: value.id, - actor: value.actor, + id: persistablePlaybackIdentity(value.id), + actor, playedAt: value.playedAt, positionMs: value.positionMs, durationMs: value.durationMs, @@ -503,44 +584,58 @@ function persistableActualPlayback(value: ActualPlayback): ActualPlayback | unde ? value.sourceFingerprint : undefined), requiresSourceRefresh: - value.requiresSourceRefresh || + value.requiresSourceRefresh === true || !!value.src.proxySessionId || !!Object.keys(value.src.headers ?? {}).length || !!src.streamRef?.infoHash || !!src.homeServer || !src.url || undefined, - audioTrack: value.audioTrack, + audioTrack: audioValid ? audio : undefined, subtitle: persistablePlaybackSubtitle(value.subtitle, value.src), - subDelaySec: value.subDelaySec, - audioDelaySec: value.audioDelaySec, + subDelaySec: Number.isFinite(value.subDelaySec) ? value.subDelaySec : undefined, + audioDelaySec: Number.isFinite(value.audioDelaySec) ? value.audioDelaySec : undefined, }; } -function persistableEntry(entry: PlaybackEntry): PlaybackEntry { +function persistableEntry(entry: PlaybackEntry, key?: string): PlaybackEntry { + const preference = persistablePlaybackPreference(entry); + const metaId = entry.actual?.src?.meta?.id; + const opaque = + key?.startsWith("playback-private:") || + (!!metaId && persistablePlaybackIdentity(metaId).startsWith("playback-private:")); return { - ...entry, - ...(typeof entry.url === "string" ? { url: persistablePlaybackUrl(entry.url) } : {}), + ...preference, + ...(opaque ? { title: "Direct stream", parsedTitle: undefined } : {}), ...(entry.actual ? { actual: persistableActualPlayback(entry.actual) } : {}), ...(entry.actualByProfile ? { actualByProfile: Object.fromEntries( - Object.entries(entry.actualByProfile).map(([id, state]) => [ - id, - { - ...state, - ...(state?.actual ? { actual: persistableActualPlayback(state.actual) } : {}), - }, - ]), + Object.entries(entry.actualByProfile) + .filter(([, state]) => !!state?.actual) + .map(([id, state]) => [ + persistablePlaybackIdentity(id), + { + ...(state?.actual ? { actual: persistableActualPlayback(state.actual) } : {}), + ...persistablePlaybackFields( + state as ActualPlaybackState & { dismissedActualId?: string }, + ["dismissedActualId"], + ), + }, + ]), ), } : {}), + // Old versions wrote this inert field. It never controls visibility here. + ...persistablePlaybackFields(entry as PlaybackEntry & { dismissedActualId?: string }, [ + "dismissedActualId", + ]), }; } function persistableEntries(entries: Record): Record { return Object.fromEntries( - Object.entries(entries).map(([key, entry]) => [key, persistableEntry(entry)]), + Object.entries(entries).map(([key, entry]) => [key, persistableEntry(entry, key)]), ); } @@ -597,6 +692,9 @@ export function readActualPlaybackForSubtitle( notBefore: number, ): ActualPlayback | null { const actor = capturePlaybackActor(); + const parts = /^(.*)\|([^|]*)\|([^|]*)$/.exec(mediaKey); + if (parts) mediaKey = `${persistablePlaybackIdentity(parts[1])}|${parts[2]}|${parts[3]}`; + if (streamKey) streamKey = persistablePlaybackIdentity(streamKey); return ( Object.values(readAll()) .map((entry) => ownActualState(entry, actor).actual) @@ -613,7 +711,7 @@ export function readActualPlaybackForSubtitle( actual.src.episode?.season, actual.src.episode?.episode, ) === mediaKey && - subtitleStreamKey(actual.src.streamRef) === streamKey, + (actual.subtitle?.streamKey ?? subtitleStreamKey(actual.src.streamRef)) === streamKey, ) ?? null ); } @@ -636,6 +734,7 @@ export function cacheActualPlaybackSubtitle( const entry = all[key]; const own = ownActualState(entry, target.actor); const actual = own.actual; + const savedOriginal = persistablePlaybackSubtitle(original, target.src); if ( !entry || !actual || @@ -644,10 +743,14 @@ export function cacheActualPlaybackSubtitle( actualPlaybackSourceKey(actual) !== actualPlaybackSourceKey(target) || !actual.subtitle || actual.subtitle.off || - actual.subtitle.source !== original.source || - actual.subtitle.streamKey !== original.streamKey || - actual.subtitle.subId !== original.subId || - actual.subtitle.provider !== original.provider + !( + (actual.subtitle.source && actual.subtitle.source === savedOriginal?.source) || + (actual.subtitle.sourceFingerprint && + actual.subtitle.sourceFingerprint === savedOriginal?.sourceFingerprint) + ) || + actual.subtitle.streamKey !== savedOriginal?.streamKey || + actual.subtitle.subId !== savedOriginal?.subId || + actual.subtitle.provider !== savedOriginal?.provider ) return false; const patched = { @@ -699,7 +802,10 @@ function offeredBy( s: { addonId?: string | null; contributors?: Array<{ id: string }> }, addonId: string, ): boolean { - return s.addonId === addonId || s.contributors?.some((c) => c.id === addonId) === true; + return ( + (s.addonId && persistablePlaybackIdentity(s.addonId) === addonId) || + s.contributors?.some((c) => persistablePlaybackIdentity(c.id) === addonId) === true + ); } /** True when the stream came from the given addon or one of its contributors. */ @@ -771,6 +877,7 @@ export function streamMatchesEntry( return true; } if (e.url && s.url) return s.url === e.url; + if (e.urlFingerprint && s.url) return playbackUrlFingerprint({ url: s.url }) === e.urlFingerprint; return false; } @@ -827,6 +934,7 @@ export function playbackEntries(): Array<{ } export function readLastSeriesPlayback(metaId: string): PlaybackEntry | null { + metaId = persistablePlaybackIdentity(metaId); const all = readAll(); const prefix = `${metaId}|`; let best: PlaybackEntry | null = null; diff --git a/src/lib/player/continue-playback.ts b/src/lib/player/continue-playback.ts index a0c69b451..c9e095a61 100644 --- a/src/lib/player/continue-playback.ts +++ b/src/lib/player/continue-playback.ts @@ -9,6 +9,7 @@ import { import type { Meta } from "../cinemeta"; import type { PlayEpisode, PlayerSrc } from "../view"; import { isLocalUrl } from "./local-url"; +import { persistablePlaybackIdentity } from "./playback-history-source"; export type ContinuationServices = { isCurrent: (actor: PlaybackActor) => boolean; @@ -46,7 +47,7 @@ export function continuationAppliesToSource( const target = context.target; if ( target && - (target.metaId !== src.meta.id || + (target.metaId !== persistablePlaybackIdentity(src.meta.id) || target.season !== src.episode?.season || target.episode !== src.episode?.episode) ) @@ -118,7 +119,7 @@ export async function continueActualPlayback( if ( !playbackSourceKey(active.src) || playbackSourceKey(active.src) !== actualPlaybackSourceKey(target) || - active.src.meta.id !== target.src.meta.id || + persistablePlaybackIdentity(active.src.meta.id) !== target.src.meta.id || active.src.episode?.season !== target.src.episode?.season || active.src.episode?.episode !== target.src.episode?.episode ) { @@ -176,6 +177,10 @@ export async function continueActualPlayback( services.openPlayer(prepared(src)); return "player-opened"; } + if (target.src.meta.id.startsWith("playback-private:")) + throw new Error( + "Open this video from its source or enter the link again. Its network address was not saved.", + ); if (target.requiresSourceRefresh || target.src.streamRef?.infoHash) return pick(); if (!/^https?:\/\//i.test(target.src.url)) throw new Error("The last playback source is no longer available."); diff --git a/src/lib/player/playback-history-source.ts b/src/lib/player/playback-history-source.ts index 8c97b78fa..52e1d9c33 100644 --- a/src/lib/player/playback-history-source.ts +++ b/src/lib/player/playback-history-source.ts @@ -3,31 +3,27 @@ import { sha256 } from "@noble/hashes/sha256"; import { bytesToHex } from "@noble/hashes/utils"; import type { PlayerSrc } from "../view"; import type { PlaybackSubtitleChoice } from "./subtitle-choice"; +import type { PlaybackEntry } from "../playback-history"; +import { isLocalUrl } from "./local-url"; -// Keep a credentialed transport out of every retained field, including artwork -// and provider labels. The installed connection/addon owns its credentials. +const MAX_URL_ENCODING_DEPTH = 8; + +// Names and labels are not an alternative place to retain a transport URL. function safeString(value: string): string | undefined { - for (const match of value.matchAll(/https?:\/\/[^\s<>"']+/gi)) { - try { - const url = new URL(match[0]); - if (url.username || url.password) return undefined; - for (const key of url.searchParams.keys()) { - const normalized = key.toLowerCase().replace(/[-_]/g, ""); - if ( - /token|password|secret|signature|authorization/.test(normalized) || - ["apikey", "key", "auth", "sig", "jwt", "credential", "policy", "keypairid"].includes( - normalized, - ) || - normalized.startsWith("xamz") || - normalized.startsWith("xgoog") - ) - return undefined; - } - } catch { - return undefined; - } + if (/^file:\/\//i.test(value) && persistablePlaybackUrl(value)) return value; + if (/^local[:-]/i.test(value) && persistablePlaybackUrl(value.slice(6))) return value; + if (/^harbor-plugin:\/\/[A-Za-z0-9._:-]+(?:\/[A-Za-z0-9._:-]+)*$/.test(value)) return value; + let decoded = value; + for (let depth = 0; depth < MAX_URL_ENCODING_DEPTH; depth++) { + if (/(?:https?|rtmps?|rtsp|mms|ftp|sftp|wss?):\s*[/\\]|\/\//i.test(decoded)) return undefined; + const next = decoded.replace(/%([a-f0-9]{2})/gi, (_, hex) => + String.fromCharCode(parseInt(hex, 16)), + ); + if (next === decoded) return value; + decoded = next; } - return value; + // Do not let deeper or deliberately layered encoding bypass the bounded scan. + return undefined; } /** Select only scalar fields; nested transport objects never enter history. */ @@ -51,10 +47,97 @@ function fields(value: T, keys: readonly K[ return Object.fromEntries(entries) as Pick; } +export { fields as persistablePlaybackFields }; + +export function playbackTransportFingerprint(value: string | undefined): string | undefined { + return value ? bytesToHex(sha256(new TextEncoder().encode(value))) : undefined; +} + +/** URL-derived item IDs cannot double as a second copy of the transport. */ +export function persistablePlaybackIdentity(value: string): string { + if (typeof value !== "string") return ""; + return safeString(value) === undefined + ? `playback-private:${playbackTransportFingerprint(value)}` + : value; +} + +export function persistablePlaybackKey(value: string): string { + const match = /^(.*)(\|(?:s\d+(?:e\d+)?|all))$/.exec(value); + return match + ? `${persistablePlaybackIdentity(match[1])}${match[2]}` + : persistablePlaybackIdentity(value); +} + export function persistablePlaybackUrl(value: string | undefined): string | undefined { - return typeof value === "string" && !/^(blob|data|memory):/i.test(value) - ? safeString(value) - : undefined; + if (typeof value !== "string" || !isLocalUrl(value) || value.startsWith("//")) return undefined; + if (/^file:/i.test(value)) { + try { + const url = new URL(value); + if (url.username || url.password || url.search || url.hash) return undefined; + } catch { + return undefined; + } + } + return value; +} + +function artworkUrl(value: string | undefined): string | undefined { + if ( + value && + /^https:\/\/image\.tmdb\.org\/t\/p\/(?:original|[wh]\d+)\/[A-Za-z0-9_-]+\.(?:jpg|png|webp|svg)$/.test( + value, + ) + ) + return value; + return persistablePlaybackUrl(value); +} + +/** The source preference has the same URL policy as the observed session. */ +export function persistablePlaybackPreference(entry: PlaybackEntry): PlaybackEntry { + const textKeys = [ + "infoHash", + "addonId", + "title", + "parsedTitle", + "resolution", + "releaseGroup", + "source", + "bingeGroup", + ] as const; + const text = fields(entry, textKeys); + for (const key of textKeys) + if (text[key] != null && typeof text[key] !== "string") delete text[key]; + const numbers = fields(entry, ["fileIdx", "size", "savedAt"]); + for (const key of ["fileIdx", "size", "savedAt"] as const) + if (numbers[key] != null && typeof numbers[key] !== "number") delete numbers[key]; + return { + ...text, + ...numbers, + ...(Array.isArray(entry.audioLanguages) + ? { + audioLanguages: entry.audioLanguages.filter( + (value) => typeof value === "string" && safeString(value) !== undefined, + ), + } + : {}), + ...(Array.isArray(entry.cachedSlugs) + ? { + cachedSlugs: entry.cachedSlugs.filter( + (value) => typeof value === "string" && safeString(value) !== undefined, + ), + } + : {}), + ...(typeof entry.addonId === "string" + ? { addonId: persistablePlaybackIdentity(entry.addonId) } + : {}), + url: persistablePlaybackUrl(entry.url ?? undefined), + urlFingerprint: + typeof entry.url === "string" && entry.url && !persistablePlaybackUrl(entry.url) + ? playbackTransportFingerprint(entry.url) + : typeof entry.urlFingerprint === "string" && /^[a-f0-9]{64}$/.test(entry.urlFingerprint) + ? entry.urlFingerprint + : undefined, + }; } /** Compare a redacted source with a live session without retaining its URL. */ @@ -63,13 +146,19 @@ export function playbackUrlFingerprint( ): string | undefined { const url = src.historyUrl ?? src.url; return !src.homeServer && !src.streamRef?.infoHash && url && !persistablePlaybackUrl(url) - ? bytesToHex(sha256(new TextEncoder().encode(url))) + ? playbackTransportFingerprint(url) : undefined; } /** Persist source identity, never a complete runtime PlayerSrc. */ export function persistablePlaybackSource(src: PlayerSrc): Omit { const origin = persistableAddonOrigin(src.meta.addonOrigin); + const privateIdentity = persistablePlaybackIdentity(src.meta.id); + const badge = src.meta.providerBadge + ? fields(src.meta.providerBadge, ["name", "tint"]) + : undefined; + const badgeLogo = artworkUrl(src.meta.providerBadge?.logo); + const opaque = privateIdentity !== src.meta.id || privateIdentity.startsWith("playback-private:"); return { ...fields(src, [ "title", @@ -106,11 +195,21 @@ export function persistablePlaybackSource(src: PlayerSrc): Omit track.url === choice.source) : undefined; const homeServerSubtitleId = saved.homeServerSubtitleId ?? serverSubtitle?.id; if (src.homeServer) { - delete saved.trackId; + delete retained.trackId; // A server track ID is stable only when supplied by its source catalogue. - if (homeServerSubtitleId) return { ...saved, homeServerSubtitleId }; - if (serverSubtitle) return saved; + if (homeServerSubtitleId) return { ...retained, homeServerSubtitleId }; + if (serverSubtitle) return retained; } const source = persistablePlaybackUrl(choice.source); - return source ? { ...saved, source } : saved; + return source ? { ...retained, source } : retained; } diff --git a/src/lib/player/subtitle-choice.ts b/src/lib/player/subtitle-choice.ts index dd2e11427..014def8c7 100644 --- a/src/lib/player/subtitle-choice.ts +++ b/src/lib/player/subtitle-choice.ts @@ -6,6 +6,7 @@ import { } from "../subtitles/subtitle-memory"; import { normalizeLang } from "../subtitles/language"; import { selectedSubtitleCacheName } from "../subtitles/selected-subtitle-cache"; +import { playbackTransportFingerprint } from "./playback-history-source"; export type PlaybackSubtitleChoice = Omit & { external?: boolean; @@ -14,6 +15,7 @@ export type PlaybackSubtitleChoice = Omit & { hearingImpaired?: boolean; fpsOverride?: number; homeServerSubtitleId?: string; + sourceFingerprint?: string; }; function durableSource(source?: string): string | undefined { @@ -131,6 +133,12 @@ export function selectPlaybackSubtitle( [track.originalUrl, track.url, track.externalFilename].some((source) => sameSource(source, choice.source!), )) || + (!choice.source && + !!choice.sourceFingerprint && + [track.originalUrl, track.url, track.externalFilename].some( + (source) => + playbackTransportFingerprint(durableSource(source)) === choice.sourceFingerprint, + )) || (!!choice.subId && choice.subId === track.subId && choice.provider === track.provider && diff --git a/src/lib/season-lock.ts b/src/lib/season-lock.ts index 175d54a56..585d8d20d 100644 --- a/src/lib/season-lock.ts +++ b/src/lib/season-lock.ts @@ -1,4 +1,9 @@ import type { PlaybackEntry } from "./playback-history"; +import { + persistablePlaybackIdentity, + persistablePlaybackKey, + persistablePlaybackPreference, +} from "./player/playback-history-source"; export type SeasonLockEntry = PlaybackEntry & { seriesWide?: boolean }; @@ -6,7 +11,18 @@ const STORAGE_KEY = "harbor.season-lock.v1"; const TTL_MS = 120 * 24 * 60 * 60 * 1000; const MAX_ENTRIES = 300; +function persistableLock(key: string, value: SeasonLockEntry): SeasonLockEntry { + return { + ...persistablePlaybackPreference(value), + ...(key.startsWith("playback-private:") + ? { title: "Direct stream", parsedTitle: undefined } + : {}), + ...(typeof value.seriesWide === "boolean" ? { seriesWide: value.seriesWide } : {}), + }; +} + function seasonKey(metaId: string, season?: number | null): string { + metaId = persistablePlaybackIdentity(metaId); return season != null ? `${metaId}|s${season}` : `${metaId}|all`; } @@ -17,11 +33,16 @@ function readAll(): Record { const parsed = JSON.parse(raw) as Record; const now = Date.now(); const fresh: Record = {}; + let scrubbed = false; for (const [k, v] of Object.entries(parsed)) { - if (!v?.savedAt) continue; - if (now - v.savedAt > TTL_MS) continue; - fresh[k] = v; + if (!v) continue; + const key = persistablePlaybackKey(k); + const safe = persistableLock(key, v); + scrubbed ||= key !== k || JSON.stringify(safe) !== JSON.stringify(v); + if (!v.savedAt || now - v.savedAt > TTL_MS) continue; + if (!fresh[key] || safe.savedAt >= fresh[key].savedAt) fresh[key] = safe; } + if (scrubbed) writeAll(fresh); return fresh; } catch { return {}; @@ -34,7 +55,17 @@ function writeAll(map: Record): void { const sorted = Object.entries(map).sort((a, b) => b[1].savedAt - a[1].savedAt); map = Object.fromEntries(sorted.slice(0, MAX_ENTRIES)); } - localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify( + Object.fromEntries( + Object.entries(map).map(([key, value]) => [ + persistablePlaybackKey(key), + persistableLock(persistablePlaybackKey(key), value), + ]), + ), + ), + ); } catch {} } @@ -45,7 +76,7 @@ export function saveSeasonLock( seriesWide?: boolean, ): void { const all = readAll(); - all[seriesWide ? `${metaId}|all` : seasonKey(metaId, season)] = { + all[seasonKey(metaId, seriesWide ? null : season)] = { ...entry, seriesWide, savedAt: Date.now(), @@ -54,12 +85,14 @@ export function saveSeasonLock( } export function readSeasonLock(metaId: string, season?: number | null): SeasonLockEntry | null { + metaId = persistablePlaybackIdentity(metaId); const all = readAll(); const perSeason = season != null ? all[`${metaId}|s${season}`] : null; return perSeason ?? all[`${metaId}|all`] ?? null; } export function clearSeasonLock(metaId: string, season?: number | null): void { + metaId = persistablePlaybackIdentity(metaId); const all = readAll(); if (season != null) delete all[`${metaId}|s${season}`]; delete all[`${metaId}|all`]; diff --git a/tests/actual-playback-snapshot.test.ts b/tests/actual-playback-snapshot.test.ts index b90c33667..6818537b2 100644 --- a/tests/actual-playback-snapshot.test.ts +++ b/tests/actual-playback-snapshot.test.ts @@ -41,7 +41,8 @@ test("a proxied source retains original identity but cannot replay a stale proxy session, 100, ); - assert.equal(result?.src.url, "https://synthetic.invalid/video"); + assert.equal(result?.src.url, ""); + assert.ok(result?.sourceFingerprint, "retain exact identity without the network transport"); assert.equal(result?.requiresSourceRefresh, true); assert.equal(result?.src.proxySessionId, undefined); assert.equal(result?.src.playbackTraceId, undefined); @@ -196,6 +197,6 @@ test("a later seed-track snapshot without provider IDs retains the proven cached ...cached, source: "C:/fixture/unrelated.srt", })?.subtitle?.source, - source, + undefined, ); }); diff --git a/tests/playback-history-credentials.test.ts b/tests/playback-history-credentials.test.ts index 725f60721..c95e6e09d 100644 --- a/tests/playback-history-credentials.test.ts +++ b/tests/playback-history-credentials.test.ts @@ -142,7 +142,7 @@ for (const [provider, query] of [ assert.equal(playback.audioDelaySec, -0.2); assert.equal(playback.audioTrack?.lang, "ara"); assert.equal(playback.src.episode?.sourceMetaId, "tt-source"); - assert.equal(playback.src.meta.logo, "https://public.invalid/logo.png"); + assert.equal(playback.src.meta.logo, undefined, "an arbitrary host is not public provenance"); assert.equal(playback.src.meta.adult, true); assert.equal(playback.src.meta.addonOrigin?.id, "synthetic-addon"); assert.match(playback.src.meta.addonOrigin?.configurationKey ?? "", /^[a-f0-9]{64}$/); @@ -259,7 +259,7 @@ test("credentialed generic sources refresh through a known release instead of pe ); }); -test("public direct and local source identities and durable subtitle choices survive, including off", () => { +test("unclassified direct sources retain fingerprints while local sources and subtitle choices remain durable", () => { for (const url of ["https://public.invalid/movie.mp4?quality=1080", "C:/synthetic/movie.mp4"]) { configure(); const src: PlayerSrc = { @@ -267,9 +267,10 @@ test("public direct and local source identities and durable subtitle choices sur title: "Synthetic", url, }; - const playback = actualPlaybackSnapshot(src, snapshot, 20, { id: url, actor })!; + const playback = actualPlaybackSnapshot(src, snapshot, 20, { id: "session", actor })!; assert.equal(history.recordActualPlayback({ ...playback, subtitle: { off: true } }), true); - assert.equal(history.readLastActualPlayback()?.src.url, url); + assert.equal(history.readLastActualPlayback()?.src.url, url.startsWith("https:") ? "" : url); + assert.equal(history.readActualPlaybackFor(src)?.id, "session"); assert.deepEqual(history.readLastActualPlayback()?.subtitle, { off: true }); for (const source of ["C:/synthetic/sub.srt", "https://public.invalid/sub.vtt?lang=en"]) { assert.equal( @@ -279,7 +280,12 @@ test("public direct and local source identities and durable subtitle choices sur }), true, ); - assert.equal(history.readLastActualPlayback()?.subtitle?.source, source); + assert.equal( + history.readLastActualPlayback()?.subtitle?.source, + source.startsWith("https:") ? undefined : source, + ); + if (source.startsWith("https:")) + assert.ok(history.readLastActualPlayback()?.subtitle?.sourceFingerprint); } } }); @@ -391,7 +397,8 @@ test("generic credential query spellings cannot survive in artwork, episode fiel assert.equal(history.recordActualPlayback(playback), true); assert.doesNotMatch(values.get(storageKey)!, /synthetic-query-credential/); assert.equal(history.readLastActualPlayback()?.subtitle?.source, undefined); - assert.equal(history.readLastActualPlayback()?.src.url, "https://public.invalid/movie.mp4"); + assert.equal(history.readLastActualPlayback()?.src.url, ""); + assert.equal(history.readLastActualPlayback()?.requiresSourceRefresh, true); } }); @@ -420,7 +427,8 @@ test("known credentialed source preference URLs are scrubbed on both saves and e values.get(storageKey)!, /synthetic-legacy-preference-credential|synthetic-user/, ); - assert.equal(history.readPlayback("tt-public")?.url, "https://public.invalid/video"); + assert.equal(history.readPlayback("tt-public")?.url, undefined); + assert.ok(history.readPlayback("tt-public")?.urlFingerprint); }); test("a normal URL requiring runtime headers is refreshed instead of replayed without authentication", () => { diff --git a/tests/playback-history-provenance.test.ts b/tests/playback-history-provenance.test.ts new file mode 100644 index 000000000..a28fd7ce1 --- /dev/null +++ b/tests/playback-history-provenance.test.ts @@ -0,0 +1,514 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as history from "../src/lib/playback-history"; +import { actualPlaybackSnapshot } from "../src/lib/player/actual-playback"; +import { initialPlayerSnapshot } from "../src/lib/player/bridge"; +import { persistablePlaybackUrl } from "../src/lib/player/playback-history-source"; +import { + continueActualPlayback, + continuationAppliesToSource, + continuationFor, + type ContinuationServices, +} from "../src/lib/player/continue-playback"; +import { subtitleMediaKey, subtitleStreamKey } from "../src/lib/subtitles/subtitle-memory"; +import type { PlayerSrc } from "../src/lib/view"; +import { readSeasonLock, saveSeasonLock } from "../src/lib/season-lock"; +import { selectPlaybackSubtitle } from "../src/lib/player/subtitle-choice"; + +const values = new Map(); +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, +}); +const storageKey = "harbor.playback-history.v1.one"; +const actor = { profileId: "one", storageProfileId: "one", accountId: null }; +const snap = { + ...initialPlayerSnapshot(), + status: "playing" as const, + firstFrameReady: true, + durationSec: 400, +}; +const urls = [ + "https://media.example/v1/key/PATHSECRET123/play.m3u8", + "https://media.example/watch?accessCode=QUERYSECRET123", + "https://proxy.example/watch?url=https%3A%2F%2Fcdn.example%2Fvideo%3Ftoken%3DINNERSECRET", +]; +function configure() { + values.clear(); + values.set( + "harbor.profiles.v1", + JSON.stringify({ + activeId: "one", + profiles: [ + { id: "one", isPrimary: true }, + { id: "two", shareStremioWith: "one" }, + ], + }), + ); +} +function source(url = urls[0]): PlayerSrc { + return { + meta: { id: "tt-provenance", type: "movie", name: "A real title" }, + title: "A real title", + url, + streamRef: { addonId: "synthetic-addon", parsedTitle: "A.Real.Title.2026", size: 42 }, + }; +} +function recorded(src = source(), id = "observed") { + return actualPlaybackSnapshot(src, snap, 45, { id, actor })!; +} + +for (const [index, url] of urls.entries()) { + test(`unclassified transport case ${index + 1} cannot enter history through URL or text fields`, () => { + configure(); + assert.equal(persistablePlaybackUrl(url), undefined); + const src = source(url); + src.meta.poster = url; + src.meta.providerBadge = { name: "Provider", logo: url }; + src.episode = { season: 1, episode: 2, name: url, still: url }; + assert.equal( + history.recordActualPlayback({ + ...recorded(src), + subtitle: { external: true, source: url, title: url }, + }), + true, + ); + history.savePlayback(src.meta.id, { + url, + title: url, + parsedTitle: encodeURIComponent(url), + unexpectedTransport: url, + } as any); + const actual = history.readLastActualPlayback()!; + assert.equal(actual.positionMs, 45000); + assert.equal(actual.src.url, ""); + assert.equal(actual.requiresSourceRefresh, true); + assert.equal(history.readActualPlaybackFor(src)?.id, "observed"); + assert.doesNotMatch( + values.get(storageKey)!, + /PATHSECRET123|QUERYSECRET123|INNERSECRET|unexpectedTransport/, + ); + }); +} + +test("an ordinary-looking HTTP URL is not evidence of public provenance", () => { + configure(); + const src = source("https://media.example/movies/a-film.mp4"); + assert.equal(history.recordActualPlayback(recorded(src)), true); + assert.equal(history.readLastActualPlayback()?.src.url, ""); + assert.equal(history.readLastActualPlayback()?.requiresSourceRefresh, true); + assert.equal(history.readActualPlaybackFor(src)?.id, "observed"); +}); + +test("scoped legacy history scrubs transport copies, unknown properties and URL-bearing map keys", () => { + configure(); + const url = urls[1]; + const id = `url:${url}`; + const src = source(url); + src.meta.id = id; + src.meta.name = "QUERYSECRET123"; + src.title = "QUERYSECRET123"; + values.set( + storageKey, + JSON.stringify({ + [id]: { + savedAt: Date.now(), + url, + title: url, + extra: { url }, + actual: { ...recorded(src), src, unexpectedTransport: url }, + actualByProfile: { one: { actual: { ...recorded(src), src }, unexpectedTransport: url } }, + }, + }), + ); + const saved = history.readLastActualPlayback()!; + assert.ok(saved); + assert.doesNotMatch(values.get(storageKey)!, /QUERYSECRET123|https:|unexpectedTransport/); + assert.equal(history.readActualPlaybackFor(src)?.id, saved.id); + assert.equal(continuationAppliesToSource(src, continuationFor(saved, actor)), true); + assert.equal(history.readPlayback(id)?.actual?.id, saved.id); + assert.equal( + history.readLastActualPlayback()?.src.meta.id, + saved.src.meta.id, + "ID normalization is idempotent", + ); +}); + +test("encoded provider-page IDs do not retain a hidden network URL", () => { + configure(); + const src = source(urls[2]); + src.meta.id = `capstan:synthetic:${encodeURIComponent(urls[2])}`; + assert.equal(history.recordActualPlayback(recorded(src)), true); + const target = history.readLastActualPlayback()!; + assert.doesNotMatch(values.get(storageKey)!, /INNERSECRET|https%3A/i); + assert.equal(continuationAppliesToSource(src, continuationFor(target, actor)), true); +}); + +test("explicit public image routes retain artwork without allowing arbitrary network images", () => { + configure(); + const src = source(); + src.meta.poster = "https://image.tmdb.org/t/p/w500/1E5baAaEse26fej7uHcjOgEE2t2.jpg"; + src.meta.background = urls[0]; + assert.equal(history.recordActualPlayback(recorded(src)), true); + assert.equal(history.readLastActualPlayback()?.src.meta.poster, src.meta.poster); + assert.equal(history.readLastActualPlayback()?.src.meta.background, undefined); +}); + +test("unknown history fields cannot bypass the retained entry, actor, track, or profile schemas", () => { + configure(); + const raw: any = recorded(); + raw.actor = { ...actor, extra: urls[0] }; + raw.audioTrack = { id: "1", lang: "ara", title: urls[1], extra: urls[2] }; + raw.extra = urls[0]; + assert.equal(history.recordActualPlayback(raw), true); + const entry: any = history.readPlayback(raw.src.meta.id); + values.set( + storageKey, + JSON.stringify({ + [raw.src.meta.id]: { + ...entry, + extra: urls[0], + actualByProfile: { one: { ...entry.actualByProfile.one, extra: urls[0] } }, + }, + }), + ); + history.readLastActualPlayback(); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123|QUERYSECRET123|INNERSECRET|"extra"/); +}); + +test("a URL-only source preference and its season lock retain exact matching without storing transport", () => { + configure(); + const url = urls[0]; + const entry = { + addonId: "synthetic-addon", + url, + title: url, + parsedTitle: encodeURIComponent(url), + extra: url, + }; + history.savePlayback("tt-preference", entry); + saveSeasonLock("tt-preference", entry, 2); + const saved = history.readPlayback("tt-preference")!; + assert.equal(history.streamMatchesEntry({ addonId: "synthetic-addon", url }, saved), true); + assert.equal( + history.streamMatchesEntry({ addonId: "synthetic-addon", url: urls[1] }, saved), + false, + ); + assert.equal(history.streamMatchesEntry({ url }, readSeasonLock("tt-preference", 2)!), true); + assert.doesNotMatch([...values.values()].join(""), /PATHSECRET123|"extra"|https%3A/i); +}); + +test("legacy key migration keeps both private profile sessions when a canonical key already exists", () => { + configure(); + const src = source(urls[1]); + src.meta.id = `url:${urls[1]}`; + history.recordActualPlayback(recorded(src, "one")); + const canonical = Object.keys(JSON.parse(values.get(storageKey)!))[0]; + const one = { ...recorded(src, "one"), actor: { ...actor, privateCw: true } }; + const two = { + ...recorded(src, "two"), + actor: { ...actor, profileId: "two", privateCw: true }, + playedAt: one.playedAt + 1, + }; + values.set( + storageKey, + JSON.stringify({ + [src.meta.id]: { + savedAt: one.playedAt, + actual: one, + actualByProfile: { one: { actual: one } }, + }, + [canonical]: { + savedAt: two.playedAt, + actual: two, + actualByProfile: { two: { actual: two } }, + }, + }), + ); + values.set("harbor.settings.shared", JSON.stringify({ cwPerProfile: true })); + assert.equal(history.readLastActualPlayback()?.id, "one"); + const migrated = JSON.parse(values.get(storageKey)!); + assert.deepEqual(Object.keys(migrated), [canonical]); + assert.equal(migrated[canonical].actualByProfile.two.actual.id, "two"); + assert.doesNotMatch(values.get(storageKey)!, /QUERYSECRET123/); +}); + +test("a late subtitle cache update identifies the exact redacted source and rejects a newer selection", () => { + configure(); + const src = source(); + src.meta.id = `url:${urls[0]}`; + const first = { + source: urls[0], + external: true, + lang: "ara", + streamKey: subtitleStreamKey(src.streamRef), + }; + assert.equal(history.recordActualPlayback({ ...recorded(src), subtitle: first }), true); + const target = history.readActualPlaybackForSubtitle( + subtitleMediaKey(src.meta.id), + first.streamKey, + 0, + )!; + assert.ok(target); + const cached = { imported: true, external: true, lang: "ara", source: "C:/synthetic/cached.srt" }; + assert.equal(history.cacheActualPlaybackSubtitle(target, first, cached), true); + assert.equal(history.readLastActualPlayback()?.subtitle?.source, cached.source); + assert.equal( + history.recordActualPlayback({ ...recorded(src), subtitle: { ...first, source: urls[1] } }), + true, + ); + assert.equal(history.cacheActualPlaybackSubtitle(target, first, cached), false); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123|QUERYSECRET123/); +}); + +test("a manual URL returns to its live session but asks for re-entry after restart", async () => { + configure(); + const src = source(urls[0]); + src.meta.id = `url:${urls[0]}`; + assert.equal(history.recordActualPlayback(recorded(src)), true); + const target = history.readLastActualPlayback()!; + let returned = false; + const services: ContinuationServices = { + isCurrent: history.isPlaybackActorCurrent, + latest: history.readLastActualPlayback, + active: () => ({ + src, + resume: async () => { + returned = true; + }, + }), + localFileExists: async () => { + throw new Error("not local"); + }, + prepareServer: async () => { + throw new Error("not a server"); + }, + openPlayer: () => { + throw new Error("must not reopen a retained network URL"); + }, + openPicker: () => { + throw new Error("no resolver for a manual URL"); + }, + }; + assert.equal(await continueActualPlayback(target, {}, services), "returned"); + assert.equal(returned, true); + await assert.rejects( + continueActualPlayback(target, {}, { ...services, active: () => null }), + /open.*source|enter.*link/i, + ); +}); + +test("protocol-relative URLs in ordinary text and IDs are not retained", () => { + configure(); + const src = source(); + const label = "Provider //media.example/private/PATHSECRET"; + src.meta.name = label; + src.title = label; + history.recordActualPlayback(recorded(src)); + history.savePlayback("addon " + label, { title: label }); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET/); +}); + +test("invalid required snapshot scalars are rejected and optional scalars cannot store objects", () => { + configure(); + const actual = recorded(source("C:/synthetic/movie.mp4")); + const nested = { url: urls[0] }; + assert.equal(history.recordActualPlayback({ ...actual, completed: nested } as any), false); + assert.equal( + history.recordActualPlayback({ + ...actual, + subDelaySec: nested, + audioDelaySec: nested, + requiresSourceRefresh: nested, + } as any), + true, + ); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123/); + assert.equal(history.readLastActualPlayback()?.subDelaySec, undefined); +}); + +test("a malformed legacy snapshot cannot prevent cleaning other history entries", () => { + configure(); + const valid = recorded(source("C:/synthetic/movie.mp4")); + values.set( + storageKey, + JSON.stringify({ + broken: { + savedAt: Date.now(), + actual: { ...valid, src: { ...valid.src, meta: { ...valid.src.meta, id: 42 } } }, + }, + valid: { savedAt: Date.now(), url: urls[0], actual: valid }, + }), + ); + assert.equal(history.readLastActualPlayback()?.id, valid.id); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123/); +}); + +test("legacy URL IDs ending in key-like text retain their exact source identity", () => { + for (const suffix of ["|all", "|s2e3"]) { + configure(); + const src = source(urls[1] + suffix); + src.meta.id = `url:${src.url}`; + values.set( + storageKey, + JSON.stringify({ [src.meta.id]: { savedAt: Date.now(), actual: { ...recorded(src), src } } }), + ); + assert.equal(history.readActualPlaybackFor(src)?.id, "observed"); + assert.equal(history.readPlayback(src.meta.id)?.actual?.id, "observed"); + assert.doesNotMatch(values.get(storageKey)!, /QUERYSECRET123/); + } +}); + +test("raw-key statistics scrub old transport without imposing an earlier history expiry", () => { + configure(); + const id = `url:${urls[0]}`; + values.set( + storageKey, + JSON.stringify({ [id]: { savedAt: Date.now() - 31 * 86400000, url: urls[0] } }), + ); + const keys = history.playbackHistoryRawKeys(); + assert.equal(keys.length, 1); + assert.doesNotMatch(keys[0], /PATHSECRET123/); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123/); + assert.equal(Object.keys(JSON.parse(values.get(storageKey)!)).length, 1); +}); + +test("expired season locks cannot leave their old transport in storage", () => { + configure(); + values.set( + "harbor.season-lock.v1", + JSON.stringify({ "tt-old|s2": { savedAt: Date.now() - 121 * 86400000, url: urls[1] } }), + ); + assert.equal(readSeasonLock("tt-old", 2), null); + assert.doesNotMatch(values.get("harbor.season-lock.v1")!, /QUERYSECRET123/); +}); + +test("late subtitle caching still matches a provider whose opaque ID contains a URL", () => { + configure(); + const src = source(); + src.streamRef!.addonId = urls[1]; + const original = { + source: urls[0], + external: true, + lang: "ara", + streamKey: subtitleStreamKey(src.streamRef), + }; + history.recordActualPlayback({ ...recorded(src), subtitle: original }); + const target = history.readActualPlaybackForSubtitle( + subtitleMediaKey(src.meta.id), + original.streamKey, + 0, + )!; + assert.ok(target); + assert.equal( + history.cacheActualPlaybackSubtitle(target, original, { + source: "C:/synthetic/cached.srt", + imported: true, + external: true, + }), + true, + ); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123|QUERYSECRET123/); +}); + +test("manual URL preferences do not retain a path credential as a bare title", () => { + configure(); + const id = "url:https://media.example/play/BARESECRET123"; + const entry = { + url: "https://media.example/play/BARESECRET123", + title: "BARESECRET123", + parsedTitle: "BARESECRET123", + }; + history.savePlayback(id, entry); + saveSeasonLock(id, entry, 1); + assert.doesNotMatch([...values.values()].join(""), /BARESECRET123/); +}); + +test("a redacted external subtitle restores only an exact fresh transport or its provider identity", () => { + configure(); + const original = { external: true, lang: "ara", source: urls[0] }; + history.recordActualPlayback({ ...recorded(), subtitle: original }); + const choice = history.readLastActualPlayback()!.subtitle!; + const track = { + id: "new-id", + kind: "subtitle" as const, + external: true, + lang: "ara", + label: "Arabic", + originalUrl: urls[0], + }; + assert.equal(selectPlaybackSubtitle([track], choice)?.id, "new-id"); + assert.equal(selectPlaybackSubtitle([{ ...track, originalUrl: urls[1] }], choice), null); + assert.equal( + selectPlaybackSubtitle([track, { ...track, id: "duplicate" }], choice), + null, + "ambiguous matches remain unavailable", + ); + const cached = { ...choice, source: "C:/synthetic/chosen.srt", imported: true }; + const local = { ...track, id: "cached", originalUrl: undefined, externalFilename: cached.source }; + assert.equal( + selectPlaybackSubtitle([track, local], cached)?.id, + "cached", + "the exact cached file takes precedence over its old remote fingerprint", + ); + assert.doesNotMatch(values.get(storageKey)!, /PATHSECRET123/); +}); + +test("the existing empty guest scope remains a valid playback owner", () => { + values.clear(); + const guest = history.capturePlaybackActor(); + assert.equal(guest.profileId, ""); + assert.equal( + history.recordActualPlayback({ ...recorded(source("C:/synthetic/guest.mp4")), actor: guest }), + true, + ); + assert.equal(history.readLastActualPlayback()?.id, "observed"); +}); + +test("free text cannot hide a relative transport behind punctuation or URI nesting", () => { + configure(); + for (const prefix of [ + "Provider{", + "Provider:", + "Provider_", + "Provider/", + "Provider|", + "Provider[", + "Provider(", + ]) { + const text = `${prefix}//media.example/private/PUNCTSECRET`; + for (const label of [ + text, + encodeURIComponent(text), + encodeURIComponent(encodeURIComponent(text)), + ]) { + const src = source("C:/synthetic/movie.mp4"); + src.title = label; + src.meta.name = label; + history.recordActualPlayback(recorded(src)); + history.savePlayback("tt-provenance", { title: label, parsedTitle: label }); + assert.doesNotMatch(values.get(storageKey)!, /PUNCTSECRET/); + } + } +}); + +test("validated filesystem and installed-plugin identities remain usable", () => { + configure(); + const src = source("file:///C:/synthetic/movie.mp4"); + src.meta.id = `local:${src.url}`; + src.meta.addonOrigin = { + id: "synthetic", + name: "Installed plugin", + base: "harbor-plugin://synthetic/id", + }; + history.recordActualPlayback(recorded(src)); + const saved = history.readLastActualPlayback()!; + assert.equal(saved.src.url, src.url); + assert.equal(saved.src.meta.id, src.meta.id); + assert.equal(saved.src.meta.name, src.meta.name); + assert.equal(saved.src.meta.addonOrigin?.base, src.meta.addonOrigin.base); +}); diff --git a/tests/player-recovery-ownership.test.ts b/tests/player-recovery-ownership.test.ts index e40c80337..102ed7b4a 100644 --- a/tests/player-recovery-ownership.test.ts +++ b/tests/player-recovery-ownership.test.ts @@ -207,7 +207,7 @@ function resumedSource(h: ReturnType) { id: "saved-session", actor: { profileId: "one", storageProfileId: "one", accountId: "fixture-account" }, positionMs: 45000, - sourceKey: src.url, + sourceKey: playbackSourceKey(src), target: { metaId: src.meta.id }, source: { url: src.url, savedAt: 1 }, audioTrack: { lang: "ara", title: "Arabic" }, From 789d1382ce060d26400ab8291c9ced3df5a0e3fa Mon Sep 17 00:00:00 2001 From: Thunderhawkk Date: Fri, 6 Nov 2026 21:05:06 +0530 Subject: [PATCH 7/8] fix(player): restore cached subtitles after history URL redaction Record an exact SHA-256 origin fingerprint when caching a remote subtitle, retain it through subtitle memory, and use it to verify the saved cached file after replay history sanitization. Reject unrelated files and provider identities. --- src/lib/player/subtitle-choice.ts | 37 ++++++++++----- src/lib/subtitles/selected-subtitle-cache.ts | 15 ++++++ src/lib/subtitles/subtitle-memory.ts | 8 ++++ tests/playback-session-restore.test.ts | 48 ++++++++++++++++++-- 4 files changed, 92 insertions(+), 16 deletions(-) diff --git a/src/lib/player/subtitle-choice.ts b/src/lib/player/subtitle-choice.ts index 014def8c7..7905ade9e 100644 --- a/src/lib/player/subtitle-choice.ts +++ b/src/lib/player/subtitle-choice.ts @@ -30,27 +30,42 @@ function sameSource(source: string | undefined, saved: string): boolean { type SubtitleCacheContext = { mediaKey: string; streamKey?: string }; function matchesCachedSubtitleOrigin( - choice: Pick, + choice: Pick, source: string | undefined, remembered?: RememberedSub | null, context?: SubtitleCacheContext, ): boolean { - const format = /\.(srt|vtt|ass|ssa)$/i.exec(remembered?.source ?? "")?.[1]?.toLowerCase() as + const file = remembered?.source?.split(/[\\/]/).pop() ?? ""; + const format = /^selected-[a-f0-9]{8}\.(srt|vtt|ass|ssa)$/i.exec(file)?.[1]?.toLowerCase() as | "srt" | "vtt" | "ass" | "ssa" | undefined; - return ( - !!context && - !!remembered?.imported && - !remembered.off && - !!source && - !!format && - !/^(https?|blob|data|memory|asset):/i.test(remembered.source ?? "") && - remembered.streamKey === context.streamKey && - remembered.source?.split(/[\\/]/).pop() === + if ( + !context || + !remembered?.imported || + remembered.off || + !format || + !/^(?:[a-z]:[\\/]|[\\/]|file:\/\/)/i.test(remembered.source ?? "") || + remembered.streamKey !== context.streamKey || + remembered.subId !== choice.subId || + remembered.provider !== choice.provider + ) + return false; + + // An exact origin URL can verify the managed cache filename. Redacted + // histories instead prove the same origin using its SHA-256 fingerprint, + // captured when the remote subtitle was copied into the local cache. + if (source) + return ( + file === selectedSubtitleCacheName(context.mediaKey, context.streamKey, { ...choice, source }, format) + ); + return ( + !!choice.sourceFingerprint && + /^[a-f0-9]{64}$/.test(choice.sourceFingerprint) && + remembered.sourceFingerprint === choice.sourceFingerprint ); } diff --git a/src/lib/subtitles/selected-subtitle-cache.ts b/src/lib/subtitles/selected-subtitle-cache.ts index 7f8b24e12..4a35ad511 100644 --- a/src/lib/subtitles/selected-subtitle-cache.ts +++ b/src/lib/subtitles/selected-subtitle-cache.ts @@ -1,6 +1,7 @@ import type { SubChoiceInput } from "./subtitle-memory"; import type { SubCue } from "./parser"; import { toSrt, toVtt } from "./serialize"; +import { playbackTransportFingerprint } from "../player/playback-history-source"; const MAX_SELECTED_SUBTITLE_CHARS = 8 * 1024 * 1024; @@ -60,6 +61,17 @@ export function selectedSubtitleCacheName( return `selected-${stableHash(identity)}.${format}`; } +/** An exact, non-reversible identity links a redacted history selection to its cached bytes. */ +export function subtitleCacheOriginFingerprint(choice: SubChoiceInput): string | undefined { + if ( + typeof choice.sourceFingerprint === "string" && + /^[a-f0-9]{64}$/.test(choice.sourceFingerprint) + ) + return choice.sourceFingerprint; + const origin = choice.source ?? choice.url ?? choice.originalUrl; + return origin && /^https?:\/\//i.test(origin) ? playbackTransportFingerprint(origin) : undefined; +} + export async function cacheSelectedSubtitle(input: CacheInput): Promise { if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) return null; const readablePath = localPath(input.playableUrl) ?? localPath(input.choice.source); @@ -102,6 +114,9 @@ export async function cacheSelectedSubtitle(input: CacheInput): Promise; @@ -172,6 +175,11 @@ export function rememberedFromChoice(choice: SubChoiceInput): Omit ({ @@ -219,10 +224,13 @@ function latePreplayCacheFixture() { "srt", ); const cached = { - ...saved, - source: `C:/fixture/selected/${filename}`, - format: "srt" as const, - imported: true, + ...rememberedFromChoice({ + ...saved, + source: `C:/fixture/selected/${filename}`, + format: "srt", + imported: true, + sourceFingerprint: subtitleCacheOriginFingerprint({ source: saved.source }), + }), updatedAt: Date.now(), }; return { saved, cached }; @@ -238,6 +246,36 @@ test("reopening uses a proven late pre-play cache even when the scoped actual sn assert.deepEqual(h.timers, []); }); +test("redacted playback history restores its exact cached remote subtitle", async () => { + const { saved, cached } = latePreplayCacheFixture(); + const sanitized = persistablePlaybackSubtitle(saved, { + url: "https://fixture.invalid/video.mp4", + meta: { id: "fixture", name: "Fixture", type: "movie" }, + } as PlayerSrc)!; + assert.equal(sanitized.source, undefined, "the remote subtitle URL must not be persisted"); + assert.ok(sanitized.sourceFingerprint); + assert.equal(sanitized.sourceFingerprint, cached.sourceFingerprint); + + const h = restoreHarness(cached, []); + Object.assign(h.scope, { rememberedSession: { subtitle: sanitized } }); + h.run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(h.adds, [cached.source]); + + for (const bad of [ + { ...cached, sourceFingerprint: "a".repeat(64) }, + { ...cached, subId: "another-subtitle" }, + { ...cached, provider: "another-provider" }, + { ...cached, source: "C:/fixture/unrelated.srt" }, + ]) { + const wrong = restoreHarness(bad, []); + Object.assign(wrong.scope, { rememberedSession: { subtitle: sanitized } }); + wrong.run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(wrong.adds, [], "an unverified subtitle must not be restored"); + } +}); + test("late cache preference does not cross a different stream, unrelated cached file, or stale actor", async () => { const { saved, cached } = latePreplayCacheFixture(); for (const change of ["stream", "file", "actor"] as const) { From d4f203a07519dbf177747a3f52df35c3dfe6a2ff Mon Sep 17 00:00:00 2001 From: Thunderhawkk Date: Fri, 6 Nov 2026 21:17:06 +0530 Subject: [PATCH 8/8] test(context): remove duplicate mock keys after beta integration --- tests/context-menu-actor-scope.test.ts | 5 ----- tests/context-menu-local-presence.test.ts | 2 -- tests/continue-card-interaction.test.ts | 1 - tests/helpers/mpv-bridge-harness.ts | 1 - 4 files changed, 9 deletions(-) diff --git a/tests/context-menu-actor-scope.test.ts b/tests/context-menu-actor-scope.test.ts index 1093da9c2..e16d3e219 100644 --- a/tests/context-menu-actor-scope.test.ts +++ b/tests/context-menu-actor-scope.test.ts @@ -98,11 +98,6 @@ async function fixture({ "@/components/player/copy-link-button": { copyText: async () => true }, "@/components/lists/list-toast": { emitListToast: empty }, "@/lib/deep-link": { shareDeepLink: empty }, - "@/lib/queue": { queueAdd: empty, queueIndexOf: () => -1 }, - "@/lib/membership-operations": {}, - "@/lib/stremio": {}, - "@/lib/queue-resolve-episode": {}, - "@/hooks/use-queue-surface": { useQueueSurface: () => !pageShare && !ebookDetails }, "@/lib/settings": { useSettings: () => ({ settings: { navCustomization: {} }, update: empty }), }, diff --git a/tests/context-menu-local-presence.test.ts b/tests/context-menu-local-presence.test.ts index 03cc34dad..b66b58ca0 100644 --- a/tests/context-menu-local-presence.test.ts +++ b/tests/context-menu-local-presence.test.ts @@ -32,8 +32,6 @@ function harness(presentation: "baseline" | "popout") { "react/jsx-runtime": jsx, "lucide-react": icons, "@/lib/i18n": { useT: () => (key: string) => key }, - "@/lib/queue": { queueAdd() {}, queueIndexOf: () => -1, useQueue: () => [] }, - "@/hooks/use-queue-surface": { useQueueSurface: () => true }, "@/lib/manual-watched": { manualWatchedState: () => false, subscribeManualWatched: () => () => {}, diff --git a/tests/continue-card-interaction.test.ts b/tests/continue-card-interaction.test.ts index d57c1db56..92699a4ac 100644 --- a/tests/continue-card-interaction.test.ts +++ b/tests/continue-card-interaction.test.ts @@ -107,7 +107,6 @@ function harness() { animeCwIdsVersion: () => 0, subscribeAnimeCwIds: () => () => {}, }, - "@/lib/anime-tracker-entry": { resolveTrackerAnimeEntry: async () => null }, "@/lib/anime-detect": { isDetectedAnime: () => false }, "@/lib/streams/anime-identity": { resolveAnimeIdentity: async () => diff --git a/tests/helpers/mpv-bridge-harness.ts b/tests/helpers/mpv-bridge-harness.ts index 1cef7ae32..6fcf3c10f 100644 --- a/tests/helpers/mpv-bridge-harness.ts +++ b/tests/helpers/mpv-bridge-harness.ts @@ -104,7 +104,6 @@ export function mpvBridgeHarness(prefs = { volume: 0.35, muted: false }, options isBlockedDiskCacheOptionLine: () => false, }, "@/lib/native-memory": { getTotalPhysBytes: () => totalPhysBytes }, - "./pause-trace": { traceFrameDrops() {}, tracePlayback() {} }, "@/lib/subtitles/prepare": { prepareSubtitle: (input: { url: string }) => prepare(input), SubtitlePreparationError: RealSubtitlePreparationError,