Track and skip failed sources during playback

Introduces a mechanism to track failed sources in the player store. When a playback error occurs, the current source is marked as failed and subsequent attempts will skip these sources. Failed sources are cleared when a working source is found. UI text is updated to reflect the new behavior.
This commit is contained in:
Pas 2025-11-30 17:50:28 -07:00
parent 4ced25623f
commit c460c15966
5 changed files with 67 additions and 15 deletions

View file

@ -841,11 +841,11 @@
"errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.", "errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.",
"errorNotSupported": "The media or media provider object is not supported." "errorNotSupported": "The media or media provider object is not supported."
}, },
"autoResumeText": "There was an error trying to play the media 😖. Automatically trying the next source...", "autoResumeText": "There was an error trying to play the media 😖. Automatically trying the other sources...",
"copyDebugInfo": "Copy debug info", "copyDebugInfo": "Copy debug info",
"debugInfo": "Check console for more details.", "debugInfo": "Check console for more details.",
"homeButton": "Go home", "homeButton": "Go home",
"resumeButton": "Try next source", "resumeButton": "Try next sources",
"text": "There was an error trying to play the media 😖. Please try again or try a different source!", "text": "There was an error trying to play the media 😖. Please try again or try a different source!",
"title": "Failed to play video!" "title": "Failed to play video!"
}, },

View file

@ -10,6 +10,7 @@ import {
} from "@/backend/helpers/providerApi"; } from "@/backend/helpers/providerApi";
import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers"; import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers";
import { getProviders } from "@/backend/providers/providers"; import { getProviders } from "@/backend/providers/providers";
import { usePlayerStore } from "@/stores/player/store";
import { usePreferencesStore } from "@/stores/preferences"; import { usePreferencesStore } from "@/stores/preferences";
export interface ScrapingItems { export interface ScrapingItems {
@ -170,10 +171,15 @@ export function useScrape() {
async (media: ScrapeMedia, startFromSourceId?: string) => { async (media: ScrapeMedia, startFromSourceId?: string) => {
const providerInstance = getProviders(); const providerInstance = getProviders();
const allSources = providerInstance.listSources(); const allSources = providerInstance.listSources();
const failedSources = usePlayerStore.getState().failedSources;
// Start with all available sources (filtered by disabled ones) // Start with all available sources (filtered by disabled and failed ones)
let baseSourceOrder = allSources let baseSourceOrder = allSources
.filter((source) => !disabledSources.includes(source.id)) .filter(
(source) =>
!disabledSources.includes(source.id) &&
!failedSources.includes(source.id),
)
.map((source) => source.id); .map((source) => source.id);
// Apply custom source ordering if enabled // Apply custom source ordering if enabled

View file

@ -182,6 +182,10 @@ export function RealPlayerView() {
let startAt: number | undefined; let startAt: number | undefined;
if (startAtParam) startAt = parseTimestamp(startAtParam) ?? undefined; if (startAtParam) startAt = parseTimestamp(startAtParam) ?? undefined;
// Clear failed sources when we successfully find a working source
const playerStore = usePlayerStore.getState();
playerStore.clearFailedSources();
playMedia( playMedia(
convertRunoutputToSource(out), convertRunoutputToSource(out),
convertProviderCaption(out.stream.captions), convertProviderCaption(out.stream.captions),

View file

@ -22,6 +22,8 @@ export interface PlaybackErrorPartProps {
export function PlaybackErrorPart(props: PlaybackErrorPartProps) { export function PlaybackErrorPart(props: PlaybackErrorPartProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const playbackError = usePlayerStore((s) => s.interface.error); const playbackError = usePlayerStore((s) => s.interface.error);
const currentSourceId = usePlayerStore((s) => s.sourceId);
const addFailedSource = usePlayerStore((s) => s.addFailedSource);
const modal = useModal("error"); const modal = useModal("error");
const settingsRouter = useOverlayRouter("settings"); const settingsRouter = useOverlayRouter("settings");
const hasOpenedSettings = useRef(false); const hasOpenedSettings = useRef(false);
@ -33,21 +35,24 @@ export function PlaybackErrorPart(props: PlaybackErrorPartProps) {
(s) => s.enableAutoResumeOnPlaybackError, (s) => s.enableAutoResumeOnPlaybackError,
); );
// Automatically open the settings overlay when a playback error occurs (unless auto-resume is enabled) // Mark the failed source and handle UI when a playback error occurs
useEffect(() => { useEffect(() => {
if ( if (playbackError && currentSourceId) {
playbackError && // Mark this source as failed
!hasOpenedSettings.current && addFailedSource(currentSourceId);
!enableAutoResumeOnPlaybackError
) { if (!hasOpenedSettings.current && !enableAutoResumeOnPlaybackError) {
hasOpenedSettings.current = true; hasOpenedSettings.current = true;
// Reset the last successful source when a playback error occurs // Reset the last successful source when a playback error occurs
setLastSuccessfulSource(null); setLastSuccessfulSource(null);
settingsRouter.open(); settingsRouter.open();
settingsRouter.navigate("/source"); settingsRouter.navigate("/source");
}
} }
}, [ }, [
playbackError, playbackError,
currentSourceId,
addFailedSource,
settingsRouter, settingsRouter,
setLastSuccessfulSource, setLastSuccessfulSource,
enableAutoResumeOnPlaybackError, enableAutoResumeOnPlaybackError,

View file

@ -89,6 +89,7 @@ export interface SourceSlice {
asTrack: boolean; asTrack: boolean;
}; };
meta: PlayerMeta | null; meta: PlayerMeta | null;
failedSources: string[];
setStatus(status: PlayerStatus): void; setStatus(status: PlayerStatus): void;
setSource( setSource(
stream: SourceSliceSource, stream: SourceSliceSource,
@ -104,6 +105,9 @@ export interface SourceSlice {
redisplaySource(startAt: number): void; redisplaySource(startAt: number): void;
setCaptionAsTrack(asTrack: boolean): void; setCaptionAsTrack(asTrack: boolean): void;
addExternalSubtitles(): Promise<void>; addExternalSubtitles(): Promise<void>;
addFailedSource(sourceId: string): void;
clearFailedSources(): void;
reset(): void;
} }
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia { export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
@ -141,6 +145,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
currentAudioTrack: null, currentAudioTrack: null,
status: playerStatus.IDLE, status: playerStatus.IDLE,
meta: null, meta: null,
failedSources: [],
caption: { caption: {
selected: null, selected: null,
asTrack: false, asTrack: false,
@ -256,6 +261,38 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.caption.asTrack = asTrack; s.caption.asTrack = asTrack;
}); });
}, },
addFailedSource(sourceId: string) {
set((s) => {
if (!s.failedSources.includes(sourceId)) {
s.failedSources = [...s.failedSources, sourceId];
}
});
},
clearFailedSources() {
set((s) => {
s.failedSources = [];
});
},
reset() {
set((s) => {
s.source = null;
s.sourceId = null;
s.embedId = null;
s.qualities = [];
s.audioTracks = [];
s.captionList = [];
s.isLoadingExternalSubtitles = false;
s.currentQuality = null;
s.currentAudioTrack = null;
s.status = playerStatus.IDLE;
s.meta = null;
s.failedSources = [];
s.caption = {
selected: null,
asTrack: false,
};
});
},
async addExternalSubtitles() { async addExternalSubtitles() {
const store = get(); const store = get();
if (!store.meta) return; if (!store.meta) return;