pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #71 from afyef/feat/skip-source-button

feat: add skip source button during scraping

authored by

Pas and committed by
GitHub
d1356405 85421a88

+204 -29
+1
.gitignore
··· 28 28 29 29 # config 30 30 .env 31 + local-libs/
+1
src/assets/locales/en.json
··· 852 852 "title": "Failed to play video!" 853 853 }, 854 854 "scraping": { 855 + "skip": "Skip source", 855 856 "items": { 856 857 "failure": "Error occurred", 857 858 "notFound": "Doesn't have the video (╥﹏╥)",
+1
src/backend/helpers/report.ts
··· 65 65 failure: "failed", 66 66 pending: null, 67 67 waiting: null, 68 + skipped: "notfound", 68 69 }; 69 70 70 71 export function scrapeSourceOutputToProviderMetric(
+13 -1
src/backend/providers/fetchers.ts
··· 1 1 import { 2 2 Fetcher, 3 3 makeSimpleProxyFetcher, 4 + makeStandardFetcher, 4 5 setM3U8ProxyUrl, 5 6 } from "@p-stream/providers"; 6 7 ··· 82 83 83 84 export function makeLoadBalancedSimpleProxyFetcher() { 84 85 const fetcher: Fetcher = async (a, b) => { 86 + const proxyUrl = getLoadbalancedProxyUrl(); 87 + 88 + // If no proxy URL is available, fall back to direct fetch 89 + if (!proxyUrl) { 90 + console.warn( 91 + "[makeLoadBalancedSimpleProxyFetcher] No proxy URL available, using direct fetch", 92 + ); 93 + const directFetcher = makeStandardFetcher(fetchButWithApiTokens); 94 + return directFetcher(a, b); 95 + } 96 + 85 97 const currentFetcher = makeSimpleProxyFetcher( 86 - getLoadbalancedProxyUrl(), 98 + proxyUrl, 87 99 fetchButWithApiTokens, 88 100 ); 89 101 return currentFetcher(a, b);
+9 -1
src/components/player/internals/ScrapeCard.tsx
··· 9 9 import { Transition } from "@/components/utils/Transition"; 10 10 11 11 export interface ScrapeItemProps { 12 - status: "failure" | "pending" | "notfound" | "success" | "waiting"; 12 + status: 13 + | "failure" 14 + | "pending" 15 + | "notfound" 16 + | "success" 17 + | "waiting" 18 + | "skipped"; 13 19 name: string; 14 20 id?: string; 15 21 percentage?: number; ··· 24 30 notfound: "player.scraping.items.notFound", 25 31 failure: "player.scraping.items.failure", 26 32 pending: "player.scraping.items.pending", 33 + skipped: "player.scraping.items.notFound", 27 34 }; 28 35 29 36 const statusMap: Record<ScrapeCardProps["status"], StatusCircleProps["type"]> = ··· 33 40 pending: "loading", 34 41 success: "success", 35 42 waiting: "waiting", 43 + skipped: "noresult", 36 44 }; 37 45 38 46 export function ScrapeItem(props: ScrapeItemProps) {
+65 -2
src/hooks/useProviderScrape.tsx
··· 22 22 name: string; 23 23 id: string; 24 24 embedId?: string; 25 - status: "failure" | "pending" | "notfound" | "success" | "waiting"; 25 + status: 26 + | "failure" 27 + | "pending" 28 + | "notfound" 29 + | "success" 30 + | "waiting" 31 + | "skipped"; 26 32 reason?: string; 27 33 error?: any; 28 34 percentage: number; ··· 36 42 const [sources, setSources] = useState<Record<string, ScrapingSegment>>({}); 37 43 const [sourceOrder, setSourceOrder] = useState<ScrapingItems[]>([]); 38 44 const [currentSource, setCurrentSource] = useState<string>(); 45 + const abortControllerRef = useRef<AbortController | null>(null); 39 46 const lastId = useRef<string | null>(null); 40 47 41 48 const initEvent = useCallback((evt: ScraperEvent<"init">) => { ··· 64 71 const lastIdTmp = lastId.current; 65 72 setSources((s) => { 66 73 if (s[id]) s[id].status = "pending"; 67 - if (lastIdTmp && s[lastIdTmp] && s[lastIdTmp].status === "pending") 74 + // Only mark as success if it's pending - don't overwrite skipped status 75 + if (lastIdTmp && s[lastIdTmp] && s[lastIdTmp].status === "pending") { 68 76 s[lastIdTmp].status = "success"; 77 + } 69 78 return { ...s }; 70 79 }); 71 80 setCurrentSource(id); 72 81 lastId.current = id; 82 + // Create new AbortController for this source 83 + abortControllerRef.current = new AbortController(); 73 84 }, []); 74 85 75 86 const updateEvent = useCallback((evt: ScraperEvent<"update">) => { ··· 128 139 return output; 129 140 }, []); 130 141 142 + const skipCurrentSource = useCallback(() => { 143 + if (currentSource) { 144 + // Get the parent source ID (remove embed suffix like "-0", "-1", etc.) 145 + const parentSourceId = currentSource.split("-")[0]; 146 + 147 + // Abort the current operation FIRST - abort all pending requests immediately 148 + if (abortControllerRef.current) { 149 + abortControllerRef.current.abort(); 150 + } 151 + 152 + // Mark the parent source and all its embeds as skipped AFTER aborting 153 + // This ensures the abort happens immediately and can interrupt ongoing operations 154 + setSources((s) => { 155 + Object.keys(s).forEach((key) => { 156 + // Check if this is the parent source or one of its embeds 157 + if (key === parentSourceId || key.startsWith(`${parentSourceId}-`)) { 158 + if (s[key]) { 159 + // Mark as skipped regardless of current status (even if it succeeded) 160 + s[key].status = "skipped"; 161 + s[key].reason = "Skipped by user"; 162 + s[key].percentage = 100; 163 + } 164 + } 165 + }); 166 + return { ...s }; 167 + }); 168 + } 169 + }, [currentSource]); 170 + 131 171 return { 132 172 initEvent, 133 173 startEvent, ··· 138 178 sources, 139 179 sourceOrder, 140 180 currentSource, 181 + skipCurrentSource, 182 + abortControllerRef, 141 183 }; 142 184 } 143 185 ··· 152 194 getResult, 153 195 startEvent, 154 196 startScrape, 197 + skipCurrentSource, 198 + abortControllerRef, 155 199 } = useBaseScrape(); 156 200 157 201 const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder); ··· 171 215 async (media: ScrapeMedia, startFromSourceId?: string) => { 172 216 const providerInstance = getProviders(); 173 217 const allSources = providerInstance.listSources(); 218 + 174 219 const playerState = usePlayerStore.getState(); 175 220 const failedSources = playerState.failedSources; 176 221 const failedEmbeds = playerState.failedEmbeds; ··· 234 279 : undefined; 235 280 236 281 const providerApiUrl = getLoadbalancedProviderApiUrl(); 282 + 237 283 if (providerApiUrl && !isExtensionActiveCached()) { 238 284 startScrape(); 239 285 const baseUrlMaker = makeProviderUrl(providerApiUrl); ··· 258 304 259 305 startScrape(); 260 306 const providers = getProviders(); 307 + 308 + // Create initial abort controller if it doesn't exist 309 + if (!abortControllerRef.current) { 310 + abortControllerRef.current = new AbortController(); 311 + } 312 + 313 + // Create a wrapper that always gets the current abort controller 314 + const getCurrentAbortController = () => abortControllerRef.current; 315 + 261 316 const output = await providers.runAll({ 262 317 media, 263 318 sourceOrder: filteredSourceOrder, 264 319 embedOrder: filteredEmbedOrder, 320 + abortController: { 321 + get signal() { 322 + const controller = getCurrentAbortController(); 323 + return controller ? controller.signal : undefined; 324 + }, 325 + } as AbortController, 265 326 events: { 266 327 init: initEvent, 267 328 start: startEvent, ··· 288 349 preferredEmbedOrder, 289 350 enableEmbedOrder, 290 351 disabledEmbeds, 352 + abortControllerRef, 291 353 ], 292 354 ); 293 355 ··· 304 366 sourceOrder, 305 367 sources, 306 368 currentSource, 369 + skipCurrentSource, 307 370 }; 308 371 } 309 372
+30 -2
src/pages/PlayerView.tsx
··· 38 38 episode?: string; 39 39 season?: string; 40 40 }>(); 41 + const [skipSourceFn, setSkipSourceFn] = useState<(() => void) | null>(null); 41 42 const [errorData, setErrorData] = useState<{ 42 43 sources: Record<string, ScrapingSegment>; 43 44 sourceOrder: ScrapingItems[]; ··· 204 205 ); 205 206 206 207 return ( 207 - <PlayerPart backUrl={backUrl} onMetaChange={metaChange}> 208 + <PlayerPart 209 + backUrl={backUrl} 210 + onMetaChange={metaChange} 211 + skipSourceFn={skipSourceFn} 212 + > 208 213 {status === playerStatus.IDLE ? ( 209 214 <MetaPart onGetMeta={handleMetaReceived} /> 210 215 ) : null} ··· 223 228 key={`scraping-${resumeFromSourceId || "default"}`} 224 229 media={scrapeMedia} 225 230 startFromSourceId={resumeFromSourceId || undefined} 231 + onSkipSourceReady={(fn) => setSkipSourceFn(() => fn)} 226 232 onResult={(sources, sourceOrder) => { 227 233 setErrorData({ 228 234 sourceOrder, ··· 232 238 // Clear resume state after scraping 233 239 setResumeFromSourceId(null); 234 240 }} 235 - onGetStream={playAfterScrape} 241 + onGetStream={(out, sources) => { 242 + // Check if the source was skipped by user 243 + if (out) { 244 + const outSourceId = out.sourceId; 245 + const parentSourceId = outSourceId.split("-")[0]; 246 + 247 + // Check both the parent and the specific embed 248 + const parentData = sources[parentSourceId]; 249 + const embedData = sources[outSourceId]; 250 + 251 + // If the source or embed was skipped by user, don't play it 252 + // Just ignore the result and let scraping continue to next source 253 + if ( 254 + parentData?.status === "skipped" || 255 + embedData?.status === "skipped" || 256 + parentData?.reason === "Skipped by user" || 257 + embedData?.reason === "Skipped by user" 258 + ) { 259 + return; 260 + } 261 + } 262 + playAfterScrape(out); 263 + }} 236 264 /> 237 265 ) 238 266 ) : null}
+7 -2
src/pages/parts/player/PlayerPart.tsx
··· 20 20 backUrl: string; 21 21 onLoad?: () => void; 22 22 onMetaChange?: (meta: PlayerMeta) => void; 23 + skipSourceFn?: (() => void) | null; 23 24 } 24 25 25 26 export function PlayerPart(props: PlayerPartProps) { ··· 139 140 </div> 140 141 </Player.TopControls> 141 142 142 - <Player.BottomControls show={showTargets}> 143 + <Player.BottomControls 144 + show={showTargets || status === playerStatus.SCRAPING} 145 + > 143 146 {status !== playerStatus.PLAYING && !manualSourceSelection && <Tips />} 144 147 <div className="flex items-center justify-center space-x-3 h-full"> 145 148 {status === playerStatus.SCRAPING ? ( 146 - <ScrapingPartInterruptButton /> 149 + <ScrapingPartInterruptButton 150 + skipCurrentSource={props.skipSourceFn || undefined} 151 + /> 147 152 ) : null} 148 153 {status === playerStatus.PLAYING ? ( 149 154 <>
+57 -21
src/pages/parts/player/ScrapingPart.tsx
··· 26 26 27 27 export interface ScrapingProps { 28 28 media: ScrapeMedia; 29 - onGetStream?: (stream: AsyncReturnType<ProviderControls["runAll"]>) => void; 29 + onGetStream?: ( 30 + stream: AsyncReturnType<ProviderControls["runAll"]>, 31 + sources: Record<string, ScrapingSegment>, 32 + ) => void; 30 33 onResult?: ( 31 34 sources: Record<string, ScrapingSegment>, 32 35 sourceOrder: ScrapingItems[], 33 36 ) => void; 34 37 startFromSourceId?: string; 38 + onSkipSourceReady?: (skipFn: () => void) => void; 35 39 } 36 40 37 41 export function ScrapingPart(props: ScrapingProps) { 38 42 const { report } = useReportProviders(); 39 - const { startScraping, resumeScraping, sourceOrder, sources, currentSource } = 40 - useScrape(); 43 + const { 44 + startScraping, 45 + resumeScraping, 46 + sourceOrder, 47 + sources, 48 + currentSource, 49 + skipCurrentSource, 50 + } = useScrape(); 41 51 const isMounted = useMountedState(); 42 52 const { t } = useTranslation(); 43 53 ··· 63 73 }, [sourceOrder, sources]); 64 74 65 75 const started = useRef<string | null>(null); 76 + 77 + // Pass skip function to parent 78 + useEffect(() => { 79 + props.onSkipSourceReady?.(skipCurrentSource); 80 + }, [skipCurrentSource, props]); 81 + 66 82 useEffect(() => { 67 83 // Only start scraping if we haven't started with this startFromSourceId before 68 84 const currentKey = props.startFromSourceId || "default"; 69 - if (started.current === currentKey) return; 85 + if (started.current === currentKey) { 86 + return; 87 + } 70 88 started.current = currentKey; 71 89 72 90 (async () => { 73 - const output = props.startFromSourceId 74 - ? await resumeScraping(props.media, props.startFromSourceId) 75 - : await startScraping(props.media); 76 - if (!isMounted()) return; 77 - props.onResult?.( 78 - resultRef.current.sources, 79 - resultRef.current.sourceOrder, 80 - ); 81 - report( 82 - scrapePartsToProviderMetric( 83 - props.media, 84 - resultRef.current.sourceOrder, 91 + try { 92 + const output = props.startFromSourceId 93 + ? await resumeScraping(props.media, props.startFromSourceId) 94 + : await startScraping(props.media); 95 + if (!isMounted()) { 96 + return; 97 + } 98 + props.onResult?.( 85 99 resultRef.current.sources, 86 - ), 87 - ); 88 - props.onGetStream?.(output); 89 - })().catch(() => setFailedStartScrape(true)); 100 + resultRef.current.sourceOrder, 101 + ); 102 + report( 103 + scrapePartsToProviderMetric( 104 + props.media, 105 + resultRef.current.sourceOrder, 106 + resultRef.current.sources, 107 + ), 108 + ); 109 + props.onGetStream?.(output, resultRef.current.sources); 110 + } catch (error) { 111 + setFailedStartScrape(true); 112 + } 113 + })(); 90 114 }, [startScraping, resumeScraping, props, report, isMounted]); 91 115 92 116 let currentProviderIndex = sourceOrder.findIndex( ··· 162 186 ); 163 187 } 164 188 165 - export function ScrapingPartInterruptButton() { 189 + export function ScrapingPartInterruptButton(props: { 190 + skipCurrentSource?: () => void; 191 + }) { 166 192 const { t } = useTranslation(); 167 193 168 194 return ( ··· 183 209 > 184 210 {t("notFound.reloadButton")} 185 211 </Button> 212 + {props.skipCurrentSource && ( 213 + <Button 214 + onClick={props.skipCurrentSource} 215 + theme="purple" 216 + padding="md:px-17 p-3" 217 + className="mt-6" 218 + > 219 + {t("player.scraping.skip")} 220 + </Button> 221 + )} 186 222 </div> 187 223 ); 188 224 }
+1
src/stores/player/slices/source.ts
··· 1 1 /* eslint-disable no-console */ 2 + 2 3 import { ScrapeMedia } from "@p-stream/providers"; 3 4 4 5 import { MakeSlice } from "@/stores/player/slices/types";
+19
vite.config.mts
··· 25 25 const env = loadEnv(mode, process.cwd()); 26 26 return { 27 27 base: env.VITE_BASE_URL || "/", 28 + assetsInclude: ['**/*.wasm'], 29 + server: { 30 + fs: { 31 + allow: [ 32 + // Default: allow serving files from project root 33 + path.resolve(__dirname), 34 + // Allow serving from the linked providers directory 35 + path.resolve(__dirname, '../providers/@p-stream/providers'), 36 + ], 37 + }, 38 + }, 28 39 plugins: [ 29 40 million.vite({ auto: true, mute: true }), 30 41 handlebars({ ··· 123 134 124 135 build: { 125 136 sourcemap: mode !== "production", 137 + assetsInlineLimit: (filePath: string) => { 138 + // Never inline WASM files 139 + if (filePath.endsWith('.wasm')) { 140 + return false; 141 + } 142 + // Use default 4KB limit for other assets 143 + return undefined; 144 + }, 126 145 rollupOptions: { 127 146 output: { 128 147 manualChunks(id: string) {