diff --git a/src/hooks/useProviderScrape.tsx b/src/hooks/useProviderScrape.tsx index d208b82c..99e2e4bd 100644 --- a/src/hooks/useProviderScrape.tsx +++ b/src/hooks/useProviderScrape.tsx @@ -10,6 +10,7 @@ import { } from "@/backend/helpers/providerApi"; import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers"; import { getProviders } from "@/backend/providers/providers"; +import { getMediaKey } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; @@ -172,8 +173,26 @@ export function useScrape() { const providerInstance = getProviders(); const allSources = providerInstance.listSources(); const playerState = usePlayerStore.getState(); - const failedSources = playerState.failedSources; - const failedEmbeds = playerState.failedEmbeds; + + // Get media-specific failed sources/embeds + // Try to get media key from player state first, fallback to deriving from ScrapeMedia + let mediaKey = getMediaKey(playerState.meta); + if (!mediaKey) { + // Derive media key from ScrapeMedia if meta is not set yet + if (media.type === "movie") { + mediaKey = `movie-${media.tmdbId}`; + } else if (media.type === "show" && media.season && media.episode) { + mediaKey = `show-${media.tmdbId}-${media.season.tmdbId}-${media.episode.tmdbId}`; + } else if (media.type === "show") { + mediaKey = `show-${media.tmdbId}`; + } + } + const failedSources = mediaKey + ? playerState.failedSourcesPerMedia[mediaKey] || [] + : []; + const failedEmbeds = mediaKey + ? playerState.failedEmbedsPerMedia[mediaKey] || {} + : {}; // Start with all available sources (filtered by disabled and failed ones) let baseSourceOrder = allSources @@ -222,7 +241,7 @@ export function useScrape() { } } - // Collect all failed embed IDs across all sources + // Collect all failed embed IDs across all sources for current media const allFailedEmbedIds = Object.values(failedEmbeds).flat(); // Filter out disabled and failed embeds from the embed order diff --git a/src/pages/parts/player/PlaybackErrorPart.tsx b/src/pages/parts/player/PlaybackErrorPart.tsx index dfc90c55..cc855586 100644 --- a/src/pages/parts/player/PlaybackErrorPart.tsx +++ b/src/pages/parts/player/PlaybackErrorPart.tsx @@ -9,6 +9,7 @@ import { Paragraph } from "@/components/text/Paragraph"; import { Title } from "@/components/text/Title"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout"; +import { getMediaKey } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; @@ -24,9 +25,10 @@ export function PlaybackErrorPart(props: PlaybackErrorPartProps) { const playbackError = usePlayerStore((s) => s.interface.error); const currentSourceId = usePlayerStore((s) => s.sourceId); const currentEmbedId = usePlayerStore((s) => s.embedId); + const meta = usePlayerStore((s) => s.meta); + const failedEmbedsPerMedia = usePlayerStore((s) => s.failedEmbedsPerMedia); const addFailedSource = usePlayerStore((s) => s.addFailedSource); const addFailedEmbed = usePlayerStore((s) => s.addFailedEmbed); - const failedEmbeds = usePlayerStore((s) => s.failedEmbeds); const modal = useModal("error"); const settingsRouter = useOverlayRouter("settings"); const hasOpenedSettings = useRef(false); @@ -54,6 +56,11 @@ export function PlaybackErrorPart(props: PlaybackErrorPartProps) { // Check if all embeds for this source have now failed // If so, disable the entire source + const mediaKey = getMediaKey(meta); + const failedEmbeds = + mediaKey && failedEmbedsPerMedia[mediaKey] + ? failedEmbedsPerMedia[mediaKey] + : {}; const failedEmbedsForSource = failedEmbeds[currentSourceId] || []; // For now, we'll assume if we have 2+ failed embeds for a source, disable it // This is a simple heuristic - we could make it more sophisticated @@ -78,7 +85,8 @@ export function PlaybackErrorPart(props: PlaybackErrorPartProps) { playbackError, currentSourceId, currentEmbedId, - failedEmbeds, + meta, + failedEmbedsPerMedia, addFailedSource, addFailedEmbed, settingsRouter, diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 41de37e6..69732bf2 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -89,8 +89,8 @@ export interface SourceSlice { asTrack: boolean; }; meta: PlayerMeta | null; - failedSources: string[]; - failedEmbeds: Record; // sourceId -> array of failed embedIds + failedSourcesPerMedia: Record; // mediaKey -> array of failed sourceIds + failedEmbedsPerMedia: Record>; // mediaKey -> sourceId -> array of failed embedIds setStatus(status: PlayerStatus): void; setSource( stream: SourceSliceSource, @@ -108,11 +108,32 @@ export interface SourceSlice { addExternalSubtitles(): Promise; addFailedSource(sourceId: string): void; addFailedEmbed(sourceId: string, embedId: string): void; - clearFailedSources(): void; - clearFailedEmbeds(): void; + clearFailedSources(mediaKey?: string): void; + clearFailedEmbeds(mediaKey?: string): void; reset(): void; } +/** + * Generates a unique media key for tracking failed sources per media. + * For movies: `${type}-${tmdbId}` + * For shows: `${type}-${tmdbId}-${season.tmdbId}-${episode.tmdbId}` + */ +export function getMediaKey(meta: PlayerMeta | null): string | null { + if (!meta) return null; + + if (meta.type === "movie") { + return `${meta.type}-${meta.tmdbId}`; + } + + // For shows, include season and episode IDs for per-episode tracking + if (meta.type === "show" && meta.season && meta.episode) { + return `${meta.type}-${meta.tmdbId}-${meta.season.tmdbId}-${meta.episode.tmdbId}`; + } + + // Fallback if show data is incomplete + return `${meta.type}-${meta.tmdbId}`; +} + export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia { if (meta.type === "show") { if (!meta.episode || !meta.season) throw new Error("missing show data"); @@ -148,8 +169,8 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ currentAudioTrack: null, status: playerStatus.IDLE, meta: null, - failedSources: [], - failedEmbeds: {}, + failedSourcesPerMedia: {}, + failedEmbedsPerMedia: {}, caption: { selected: null, asTrack: false, @@ -172,12 +193,26 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ }); }, setMeta(meta, newStatus) { + const store = get(); + const oldMediaKey = getMediaKey(store.meta); + const newMediaKey = getMediaKey(meta); + set((s) => { s.meta = meta; s.embedId = null; s.sourceId = null; s.interface.hideNextEpisodeBtn = false; if (newStatus) s.status = newStatus; + + // Clear failed sources/embeds for the new media when media changes + // Since we're doing per-episode tracking, we clear whenever media key changes + // Only clear if we're actually switching to different media (not just setting meta for the first time) + if (newMediaKey && oldMediaKey && oldMediaKey !== newMediaKey) { + // Clear failed sources/embeds for the new media (if any exist from previous session) + // This ensures a fresh start for each media/episode + delete s.failedSourcesPerMedia[newMediaKey]; + delete s.failedEmbedsPerMedia[newMediaKey]; + } }); }, setCaption(caption) { @@ -267,30 +302,62 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ }); }, addFailedSource(sourceId: string) { + const store = get(); + const mediaKey = getMediaKey(store.meta); + if (!mediaKey) return; // Skip tracking if no media is set + set((s) => { - if (!s.failedSources.includes(sourceId)) { - s.failedSources = [...s.failedSources, sourceId]; + if (!s.failedSourcesPerMedia[mediaKey]) { + s.failedSourcesPerMedia[mediaKey] = []; + } + if (!s.failedSourcesPerMedia[mediaKey].includes(sourceId)) { + s.failedSourcesPerMedia[mediaKey] = [ + ...s.failedSourcesPerMedia[mediaKey], + sourceId, + ]; } }); }, addFailedEmbed(sourceId: string, embedId: string) { + const store = get(); + const mediaKey = getMediaKey(store.meta); + if (!mediaKey) return; // Skip tracking if no media is set + set((s) => { - if (!s.failedEmbeds[sourceId]) { - s.failedEmbeds[sourceId] = []; + if (!s.failedEmbedsPerMedia[mediaKey]) { + s.failedEmbedsPerMedia[mediaKey] = {}; } - if (!s.failedEmbeds[sourceId].includes(embedId)) { - s.failedEmbeds[sourceId] = [...s.failedEmbeds[sourceId], embedId]; + if (!s.failedEmbedsPerMedia[mediaKey][sourceId]) { + s.failedEmbedsPerMedia[mediaKey][sourceId] = []; + } + if (!s.failedEmbedsPerMedia[mediaKey][sourceId].includes(embedId)) { + s.failedEmbedsPerMedia[mediaKey][sourceId] = [ + ...s.failedEmbedsPerMedia[mediaKey][sourceId], + embedId, + ]; } }); }, - clearFailedSources() { + clearFailedSources(mediaKey?: string) { set((s) => { - s.failedSources = []; + if (mediaKey) { + // Clear for specific media + delete s.failedSourcesPerMedia[mediaKey]; + } else { + // Clear all + s.failedSourcesPerMedia = {}; + } }); }, - clearFailedEmbeds() { + clearFailedEmbeds(mediaKey?: string) { set((s) => { - s.failedEmbeds = {}; + if (mediaKey) { + // Clear for specific media + delete s.failedEmbedsPerMedia[mediaKey]; + } else { + // Clear all + s.failedEmbedsPerMedia = {}; + } }); }, reset() { @@ -306,8 +373,8 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ s.currentAudioTrack = null; s.status = playerStatus.IDLE; s.meta = null; - s.failedSources = []; - s.failedEmbeds = {}; + s.failedSourcesPerMedia = {}; + s.failedEmbedsPerMedia = {}; s.caption = { selected: null, asTrack: false,