diff --git a/.gitignore b/.gitignore index 92594324..7f1a6f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ package-lock.json # config .env +local-libs/ diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 16aebae3..6142b4cb 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -852,6 +852,7 @@ "title": "Failed to play video!" }, "scraping": { + "skip": "Skip source", "items": { "failure": "Error occurred", "notFound": "Doesn't have the video (╥﹏╥)", diff --git a/src/backend/helpers/report.ts b/src/backend/helpers/report.ts index b4bdb5bf..65cc88f8 100644 --- a/src/backend/helpers/report.ts +++ b/src/backend/helpers/report.ts @@ -65,6 +65,7 @@ const segmentStatusMap: Record< failure: "failed", pending: null, waiting: null, + skipped: "notfound", }; export function scrapeSourceOutputToProviderMetric( diff --git a/src/backend/providers/fetchers.ts b/src/backend/providers/fetchers.ts index cd345577..d9ac59dc 100644 --- a/src/backend/providers/fetchers.ts +++ b/src/backend/providers/fetchers.ts @@ -1,6 +1,7 @@ import { Fetcher, makeSimpleProxyFetcher, + makeStandardFetcher, setM3U8ProxyUrl, } from "@p-stream/providers"; @@ -82,8 +83,19 @@ export function setupM3U8Proxy() { export function makeLoadBalancedSimpleProxyFetcher() { const fetcher: Fetcher = async (a, b) => { + const proxyUrl = getLoadbalancedProxyUrl(); + + // If no proxy URL is available, fall back to direct fetch + if (!proxyUrl) { + console.warn( + "[makeLoadBalancedSimpleProxyFetcher] No proxy URL available, using direct fetch", + ); + const directFetcher = makeStandardFetcher(fetchButWithApiTokens); + return directFetcher(a, b); + } + const currentFetcher = makeSimpleProxyFetcher( - getLoadbalancedProxyUrl(), + proxyUrl, fetchButWithApiTokens, ); return currentFetcher(a, b); diff --git a/src/components/player/internals/ScrapeCard.tsx b/src/components/player/internals/ScrapeCard.tsx index dc057901..55263a9f 100644 --- a/src/components/player/internals/ScrapeCard.tsx +++ b/src/components/player/internals/ScrapeCard.tsx @@ -9,7 +9,13 @@ import { import { Transition } from "@/components/utils/Transition"; export interface ScrapeItemProps { - status: "failure" | "pending" | "notfound" | "success" | "waiting"; + status: + | "failure" + | "pending" + | "notfound" + | "success" + | "waiting" + | "skipped"; name: string; id?: string; percentage?: number; @@ -24,6 +30,7 @@ const statusTextMap: Partial> = { notfound: "player.scraping.items.notFound", failure: "player.scraping.items.failure", pending: "player.scraping.items.pending", + skipped: "player.scraping.items.notFound", }; const statusMap: Record = @@ -33,6 +40,7 @@ const statusMap: Record = pending: "loading", success: "success", waiting: "waiting", + skipped: "noresult", }; export function ScrapeItem(props: ScrapeItemProps) { diff --git a/src/hooks/useProviderScrape.tsx b/src/hooks/useProviderScrape.tsx index 2492f6b9..5c3580de 100644 --- a/src/hooks/useProviderScrape.tsx +++ b/src/hooks/useProviderScrape.tsx @@ -22,7 +22,13 @@ export interface ScrapingSegment { name: string; id: string; embedId?: string; - status: "failure" | "pending" | "notfound" | "success" | "waiting"; + status: + | "failure" + | "pending" + | "notfound" + | "success" + | "waiting" + | "skipped"; reason?: string; error?: any; percentage: number; @@ -36,6 +42,7 @@ function useBaseScrape() { const [sources, setSources] = useState>({}); const [sourceOrder, setSourceOrder] = useState([]); const [currentSource, setCurrentSource] = useState(); + const abortControllerRef = useRef(null); const lastId = useRef(null); const initEvent = useCallback((evt: ScraperEvent<"init">) => { @@ -64,12 +71,16 @@ function useBaseScrape() { const lastIdTmp = lastId.current; setSources((s) => { if (s[id]) s[id].status = "pending"; - if (lastIdTmp && s[lastIdTmp] && s[lastIdTmp].status === "pending") + // Only mark as success if it's pending - don't overwrite skipped status + if (lastIdTmp && s[lastIdTmp] && s[lastIdTmp].status === "pending") { s[lastIdTmp].status = "success"; + } return { ...s }; }); setCurrentSource(id); lastId.current = id; + // Create new AbortController for this source + abortControllerRef.current = new AbortController(); }, []); const updateEvent = useCallback((evt: ScraperEvent<"update">) => { @@ -128,6 +139,35 @@ function useBaseScrape() { return output; }, []); + const skipCurrentSource = useCallback(() => { + if (currentSource) { + // Get the parent source ID (remove embed suffix like "-0", "-1", etc.) + const parentSourceId = currentSource.split("-")[0]; + + // Abort the current operation FIRST - abort all pending requests immediately + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Mark the parent source and all its embeds as skipped AFTER aborting + // This ensures the abort happens immediately and can interrupt ongoing operations + setSources((s) => { + Object.keys(s).forEach((key) => { + // Check if this is the parent source or one of its embeds + if (key === parentSourceId || key.startsWith(`${parentSourceId}-`)) { + if (s[key]) { + // Mark as skipped regardless of current status (even if it succeeded) + s[key].status = "skipped"; + s[key].reason = "Skipped by user"; + s[key].percentage = 100; + } + } + }); + return { ...s }; + }); + } + }, [currentSource]); + return { initEvent, startEvent, @@ -138,6 +178,8 @@ function useBaseScrape() { sources, sourceOrder, currentSource, + skipCurrentSource, + abortControllerRef, }; } @@ -152,6 +194,8 @@ export function useScrape() { getResult, startEvent, startScrape, + skipCurrentSource, + abortControllerRef, } = useBaseScrape(); const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder); @@ -171,6 +215,7 @@ export function useScrape() { async (media: ScrapeMedia, startFromSourceId?: string) => { const providerInstance = getProviders(); const allSources = providerInstance.listSources(); + const playerState = usePlayerStore.getState(); const failedSources = playerState.failedSources; const failedEmbeds = playerState.failedEmbeds; @@ -234,6 +279,7 @@ export function useScrape() { : undefined; const providerApiUrl = getLoadbalancedProviderApiUrl(); + if (providerApiUrl && !isExtensionActiveCached()) { startScrape(); const baseUrlMaker = makeProviderUrl(providerApiUrl); @@ -258,10 +304,25 @@ export function useScrape() { startScrape(); const providers = getProviders(); + + // Create initial abort controller if it doesn't exist + if (!abortControllerRef.current) { + abortControllerRef.current = new AbortController(); + } + + // Create a wrapper that always gets the current abort controller + const getCurrentAbortController = () => abortControllerRef.current; + const output = await providers.runAll({ media, sourceOrder: filteredSourceOrder, embedOrder: filteredEmbedOrder, + abortController: { + get signal() { + const controller = getCurrentAbortController(); + return controller ? controller.signal : undefined; + }, + } as AbortController, events: { init: initEvent, start: startEvent, @@ -288,6 +349,7 @@ export function useScrape() { preferredEmbedOrder, enableEmbedOrder, disabledEmbeds, + abortControllerRef, ], ); @@ -304,6 +366,7 @@ export function useScrape() { sourceOrder, sources, currentSource, + skipCurrentSource, }; } diff --git a/src/pages/PlayerView.tsx b/src/pages/PlayerView.tsx index 9ceabcc9..d28ad646 100644 --- a/src/pages/PlayerView.tsx +++ b/src/pages/PlayerView.tsx @@ -38,6 +38,7 @@ export function RealPlayerView() { episode?: string; season?: string; }>(); + const [skipSourceFn, setSkipSourceFn] = useState<(() => void) | null>(null); const [errorData, setErrorData] = useState<{ sources: Record; sourceOrder: ScrapingItems[]; @@ -204,7 +205,11 @@ export function RealPlayerView() { ); return ( - + {status === playerStatus.IDLE ? ( ) : null} @@ -223,6 +228,7 @@ export function RealPlayerView() { key={`scraping-${resumeFromSourceId || "default"}`} media={scrapeMedia} startFromSourceId={resumeFromSourceId || undefined} + onSkipSourceReady={(fn) => setSkipSourceFn(() => fn)} onResult={(sources, sourceOrder) => { setErrorData({ sourceOrder, @@ -232,7 +238,29 @@ export function RealPlayerView() { // Clear resume state after scraping setResumeFromSourceId(null); }} - onGetStream={playAfterScrape} + onGetStream={(out, sources) => { + // Check if the source was skipped by user + if (out) { + const outSourceId = out.sourceId; + const parentSourceId = outSourceId.split("-")[0]; + + // Check both the parent and the specific embed + const parentData = sources[parentSourceId]; + const embedData = sources[outSourceId]; + + // If the source or embed was skipped by user, don't play it + // Just ignore the result and let scraping continue to next source + if ( + parentData?.status === "skipped" || + embedData?.status === "skipped" || + parentData?.reason === "Skipped by user" || + embedData?.reason === "Skipped by user" + ) { + return; + } + } + playAfterScrape(out); + }} /> ) ) : null} diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 66c7234f..bb41f432 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -20,6 +20,7 @@ export interface PlayerPartProps { backUrl: string; onLoad?: () => void; onMetaChange?: (meta: PlayerMeta) => void; + skipSourceFn?: (() => void) | null; } export function PlayerPart(props: PlayerPartProps) { @@ -139,11 +140,15 @@ export function PlayerPart(props: PlayerPartProps) { - + {status !== playerStatus.PLAYING && !manualSourceSelection && }
{status === playerStatus.SCRAPING ? ( - + ) : null} {status === playerStatus.PLAYING ? ( <> diff --git a/src/pages/parts/player/ScrapingPart.tsx b/src/pages/parts/player/ScrapingPart.tsx index 68f572b5..6b8bc6df 100644 --- a/src/pages/parts/player/ScrapingPart.tsx +++ b/src/pages/parts/player/ScrapingPart.tsx @@ -26,18 +26,28 @@ import { WarningPart } from "../util/WarningPart"; export interface ScrapingProps { media: ScrapeMedia; - onGetStream?: (stream: AsyncReturnType) => void; + onGetStream?: ( + stream: AsyncReturnType, + sources: Record, + ) => void; onResult?: ( sources: Record, sourceOrder: ScrapingItems[], ) => void; startFromSourceId?: string; + onSkipSourceReady?: (skipFn: () => void) => void; } export function ScrapingPart(props: ScrapingProps) { const { report } = useReportProviders(); - const { startScraping, resumeScraping, sourceOrder, sources, currentSource } = - useScrape(); + const { + startScraping, + resumeScraping, + sourceOrder, + sources, + currentSource, + skipCurrentSource, + } = useScrape(); const isMounted = useMountedState(); const { t } = useTranslation(); @@ -63,30 +73,44 @@ export function ScrapingPart(props: ScrapingProps) { }, [sourceOrder, sources]); const started = useRef(null); + + // Pass skip function to parent + useEffect(() => { + props.onSkipSourceReady?.(skipCurrentSource); + }, [skipCurrentSource, props]); + useEffect(() => { // Only start scraping if we haven't started with this startFromSourceId before const currentKey = props.startFromSourceId || "default"; - if (started.current === currentKey) return; + if (started.current === currentKey) { + return; + } started.current = currentKey; (async () => { - const output = props.startFromSourceId - ? await resumeScraping(props.media, props.startFromSourceId) - : await startScraping(props.media); - if (!isMounted()) return; - props.onResult?.( - resultRef.current.sources, - resultRef.current.sourceOrder, - ); - report( - scrapePartsToProviderMetric( - props.media, - resultRef.current.sourceOrder, + try { + const output = props.startFromSourceId + ? await resumeScraping(props.media, props.startFromSourceId) + : await startScraping(props.media); + if (!isMounted()) { + return; + } + props.onResult?.( resultRef.current.sources, - ), - ); - props.onGetStream?.(output); - })().catch(() => setFailedStartScrape(true)); + resultRef.current.sourceOrder, + ); + report( + scrapePartsToProviderMetric( + props.media, + resultRef.current.sourceOrder, + resultRef.current.sources, + ), + ); + props.onGetStream?.(output, resultRef.current.sources); + } catch (error) { + setFailedStartScrape(true); + } + })(); }, [startScraping, resumeScraping, props, report, isMounted]); let currentProviderIndex = sourceOrder.findIndex( @@ -162,7 +186,9 @@ export function ScrapingPart(props: ScrapingProps) { ); } -export function ScrapingPartInterruptButton() { +export function ScrapingPartInterruptButton(props: { + skipCurrentSource?: () => void; +}) { const { t } = useTranslation(); return ( @@ -183,6 +209,16 @@ export function ScrapingPartInterruptButton() { > {t("notFound.reloadButton")} + {props.skipCurrentSource && ( + + )}
); } diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 41de37e6..63270e24 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -1,4 +1,5 @@ /* eslint-disable no-console */ + import { ScrapeMedia } from "@p-stream/providers"; import { MakeSlice } from "@/stores/player/slices/types"; diff --git a/vite.config.mts b/vite.config.mts index 66013c2f..6546a652 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -25,6 +25,17 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()); return { base: env.VITE_BASE_URL || "/", + assetsInclude: ['**/*.wasm'], + server: { + fs: { + allow: [ + // Default: allow serving files from project root + path.resolve(__dirname), + // Allow serving from the linked providers directory + path.resolve(__dirname, '../providers/@p-stream/providers'), + ], + }, + }, plugins: [ million.vite({ auto: true, mute: true }), handlebars({ @@ -123,6 +134,14 @@ export default defineConfig(({ mode }) => { build: { sourcemap: mode !== "production", + assetsInlineLimit: (filePath: string) => { + // Never inline WASM files + if (filePath.endsWith('.wasm')) { + return false; + } + // Use default 4KB limit for other assets + return undefined; + }, rollupOptions: { output: { manualChunks(id: string) {