pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
at main 361 lines 11 kB view raw
1import { FullScraperEvents, RunOutput, ScrapeMedia } from "@p-stream/providers"; 2import { RefObject, useCallback, useEffect, useRef, useState } from "react"; 3 4import { isExtensionActiveCached } from "@/backend/extension/messaging"; 5import { prepareStream } from "@/backend/extension/streams"; 6import { getCachedMetadata } from "@/backend/helpers/providerApi"; 7import { getProviders } from "@/backend/providers/providers"; 8import { getMediaKey } from "@/stores/player/slices/source"; 9import { usePlayerStore } from "@/stores/player/store"; 10import { usePreferencesStore } from "@/stores/preferences"; 11 12export interface ScrapingItems { 13 id: string; 14 children: string[]; 15} 16 17export interface ScrapingSegment { 18 name: string; 19 id: string; 20 embedId?: string; 21 status: "failure" | "pending" | "notfound" | "success" | "waiting"; 22 reason?: string; 23 error?: any; 24 percentage: number; 25} 26 27type ScraperEvent<Event extends keyof FullScraperEvents> = Parameters< 28 NonNullable<FullScraperEvents[Event]> 29>[0]; 30 31function useBaseScrape() { 32 const [sources, setSources] = useState<Record<string, ScrapingSegment>>({}); 33 const [sourceOrder, setSourceOrder] = useState<ScrapingItems[]>([]); 34 const [currentSource, setCurrentSource] = useState<string>(); 35 const lastId = useRef<string | null>(null); 36 37 const initEvent = useCallback((evt: ScraperEvent<"init">) => { 38 setSources( 39 evt.sourceIds 40 .map((v) => { 41 const source = getCachedMetadata().find((s) => s.id === v); 42 if (!source) throw new Error("invalid source id"); 43 const out: ScrapingSegment = { 44 name: source.name, 45 id: source.id, 46 status: "waiting", 47 percentage: 0, 48 }; 49 return out; 50 }) 51 .reduce<Record<string, ScrapingSegment>>((a, v) => { 52 a[v.id] = v; 53 return a; 54 }, {}), 55 ); 56 setSourceOrder(evt.sourceIds.map((v) => ({ id: v, children: [] }))); 57 }, []); 58 59 const startEvent = useCallback((id: ScraperEvent<"start">) => { 60 const lastIdTmp = lastId.current; 61 setSources((s) => { 62 if (s[id]) s[id].status = "pending"; 63 if (lastIdTmp && s[lastIdTmp] && s[lastIdTmp].status === "pending") 64 s[lastIdTmp].status = "success"; 65 return { ...s }; 66 }); 67 setCurrentSource(id); 68 lastId.current = id; 69 }, []); 70 71 const updateEvent = useCallback((evt: ScraperEvent<"update">) => { 72 setSources((s) => { 73 if (s[evt.id]) { 74 s[evt.id].status = evt.status; 75 s[evt.id].reason = evt.reason; 76 s[evt.id].error = evt.error; 77 s[evt.id].percentage = evt.percentage; 78 } 79 return { ...s }; 80 }); 81 }, []); 82 83 const discoverEmbedsEvent = useCallback( 84 (evt: ScraperEvent<"discoverEmbeds">) => { 85 setSources((s) => { 86 evt.embeds.forEach((v) => { 87 const source = getCachedMetadata().find( 88 (src) => src.id === v.embedScraperId, 89 ); 90 if (!source) throw new Error("invalid source id"); 91 const out: ScrapingSegment = { 92 embedId: v.embedScraperId, 93 name: source.name, 94 id: v.id, 95 status: "waiting", 96 percentage: 0, 97 }; 98 s[v.id] = out; 99 }); 100 return { ...s }; 101 }); 102 setSourceOrder((s) => { 103 const source = s.find((v) => v.id === evt.sourceId); 104 if (!source) throw new Error("invalid source id"); 105 source.children = evt.embeds.map((v) => v.id); 106 return [...s]; 107 }); 108 }, 109 [], 110 ); 111 112 const startScrape = useCallback(() => { 113 lastId.current = null; 114 }, []); 115 116 const getResult = useCallback((output: RunOutput | null) => { 117 if (output && lastId.current) { 118 setSources((s) => { 119 if (!lastId.current) return s; 120 if (s[lastId.current]) s[lastId.current].status = "success"; 121 return { ...s }; 122 }); 123 } 124 return output; 125 }, []); 126 127 return { 128 initEvent, 129 startEvent, 130 updateEvent, 131 discoverEmbedsEvent, 132 startScrape, 133 getResult, 134 sources, 135 sourceOrder, 136 currentSource, 137 }; 138} 139 140export function useScrape() { 141 const { 142 sources, 143 sourceOrder, 144 currentSource, 145 updateEvent, 146 discoverEmbedsEvent, 147 initEvent, 148 getResult, 149 startEvent, 150 startScrape, 151 } = useBaseScrape(); 152 153 const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder); 154 const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder); 155 const lastSuccessfulSource = usePreferencesStore( 156 (s) => s.lastSuccessfulSource, 157 ); 158 const enableLastSuccessfulSource = usePreferencesStore( 159 (s) => s.enableLastSuccessfulSource, 160 ); 161 const preferredEmbedOrder = usePreferencesStore((s) => s.embedOrder); 162 const enableEmbedOrder = usePreferencesStore((s) => s.enableEmbedOrder); 163 164 const startScraping = useCallback( 165 async (media: ScrapeMedia, startFromSourceId?: string) => { 166 const providerInstance = getProviders(); 167 const allSources = providerInstance.listSources(); 168 const playerState = usePlayerStore.getState(); 169 170 // Get media-specific failed sources/embeds 171 // Try to get media key from player state first, fallback to deriving from ScrapeMedia 172 let mediaKey = getMediaKey(playerState.meta); 173 if (!mediaKey) { 174 // Derive media key from ScrapeMedia if meta is not set yet 175 if (media.type === "movie") { 176 mediaKey = `movie-${media.tmdbId}`; 177 } else if (media.type === "show" && media.season && media.episode) { 178 mediaKey = `show-${media.tmdbId}-${media.season.tmdbId}-${media.episode.tmdbId}`; 179 } else if (media.type === "show") { 180 mediaKey = `show-${media.tmdbId}`; 181 } 182 } 183 const failedSources = mediaKey 184 ? playerState.failedSourcesPerMedia[mediaKey] || [] 185 : []; 186 const failedEmbeds = mediaKey 187 ? playerState.failedEmbedsPerMedia[mediaKey] || {} 188 : {}; 189 190 // Start with all available sources (filtered by failed ones only) 191 let baseSourceOrder = allSources 192 .filter((source) => !failedSources.includes(source.id)) 193 .map((source) => source.id); 194 195 // Apply custom source ordering if enabled 196 if (enableSourceOrder && (preferredSourceOrder || []).length > 0) { 197 const orderedSources: string[] = []; 198 const remainingSources = [...baseSourceOrder]; 199 200 // Add sources in preferred order 201 for (const sourceId of preferredSourceOrder) { 202 const sourceIndex = remainingSources.indexOf(sourceId); 203 if (sourceIndex !== -1) { 204 orderedSources.push(sourceId); 205 remainingSources.splice(sourceIndex, 1); 206 } 207 } 208 209 // Add remaining sources 210 baseSourceOrder = [...orderedSources, ...remainingSources]; 211 } 212 213 // If we have a last successful source and the feature is enabled, prioritize it 214 // BUT only if we're not resuming from a specific source (to preserve custom order) 215 if ( 216 enableLastSuccessfulSource && 217 lastSuccessfulSource && 218 !startFromSourceId 219 ) { 220 const lastSourceIndex = baseSourceOrder.indexOf(lastSuccessfulSource); 221 if (lastSourceIndex !== -1) { 222 baseSourceOrder = [ 223 lastSuccessfulSource, 224 ...baseSourceOrder.filter((id) => id !== lastSuccessfulSource), 225 ]; 226 } 227 } 228 229 // If starting from a specific source ID, filter the order to start AFTER that source 230 // This preserves the custom order while starting from the next source 231 let filteredSourceOrder = baseSourceOrder; 232 if (startFromSourceId) { 233 const startIndex = filteredSourceOrder.indexOf(startFromSourceId); 234 if (startIndex !== -1) { 235 filteredSourceOrder = filteredSourceOrder.slice(startIndex + 1); 236 } 237 } 238 239 // Collect all failed embed IDs across all sources for current media 240 const allFailedEmbedIds = Object.values(failedEmbeds).flat(); 241 242 // Filter out failed embeds from the embed order 243 const filteredEmbedOrder = enableEmbedOrder 244 ? (preferredEmbedOrder || []).filter( 245 (id) => !allFailedEmbedIds.includes(id), 246 ) 247 : undefined; 248 249 startScrape(); 250 const providers = getProviders(); 251 const output = await providers.runAll({ 252 media, 253 sourceOrder: filteredSourceOrder, 254 embedOrder: filteredEmbedOrder, 255 events: { 256 init: initEvent, 257 start: startEvent, 258 update: updateEvent, 259 discoverEmbeds: discoverEmbedsEvent, 260 }, 261 }); 262 if (output && isExtensionActiveCached()) 263 await prepareStream(output.stream); 264 return getResult(output); 265 }, 266 [ 267 initEvent, 268 startEvent, 269 updateEvent, 270 discoverEmbedsEvent, 271 getResult, 272 startScrape, 273 preferredSourceOrder, 274 enableSourceOrder, 275 lastSuccessfulSource, 276 enableLastSuccessfulSource, 277 preferredEmbedOrder, 278 enableEmbedOrder, 279 ], 280 ); 281 282 const resumeScraping = useCallback( 283 async (media: ScrapeMedia, startFromSourceId: string) => { 284 return startScraping(media, startFromSourceId); 285 }, 286 [startScraping], 287 ); 288 289 return { 290 startScraping, 291 resumeScraping, 292 sourceOrder, 293 sources, 294 currentSource, 295 }; 296} 297 298export function useListCenter( 299 containerRef: RefObject<HTMLDivElement | null>, 300 listRef: RefObject<HTMLDivElement | null>, 301 sourceOrder: ScrapingItems[], 302 currentSource: string | undefined, 303) { 304 const [renderedOnce, setRenderedOnce] = useState(false); 305 306 const updatePosition = useCallback(() => { 307 if (!containerRef.current) return; 308 if (!listRef.current) return; 309 310 const elements = [ 311 ...listRef.current.querySelectorAll("div[data-source-id]"), 312 ] as HTMLDivElement[]; 313 314 const currentIndex = elements.findIndex( 315 (e) => e.getAttribute("data-source-id") === currentSource, 316 ); 317 318 const currentElement = elements[currentIndex]; 319 320 if (!currentElement) return; 321 322 const containerWidth = containerRef.current.getBoundingClientRect().width; 323 const listWidth = listRef.current.getBoundingClientRect().width; 324 325 const containerHeight = containerRef.current.getBoundingClientRect().height; 326 327 const listTop = listRef.current.getBoundingClientRect().top; 328 329 const currentTop = currentElement.getBoundingClientRect().top; 330 const currentHeight = currentElement.getBoundingClientRect().height; 331 332 const topDifference = currentTop - listTop; 333 334 const listNewLeft = containerWidth / 2 - listWidth / 2; 335 const listNewTop = containerHeight / 2 - topDifference - currentHeight / 2; 336 337 listRef.current.style.transform = `translateY(${listNewTop}px) translateX(${listNewLeft}px)`; 338 setTimeout(() => { 339 setRenderedOnce(true); 340 }, 150); 341 }, [currentSource, containerRef, listRef, setRenderedOnce]); 342 343 const updatePositionRef = useRef(updatePosition); 344 345 useEffect(() => { 346 updatePosition(); 347 updatePositionRef.current = updatePosition; 348 }, [updatePosition, sourceOrder]); 349 350 useEffect(() => { 351 function resize() { 352 updatePositionRef.current(); 353 } 354 window.addEventListener("resize", resize); 355 return () => { 356 window.removeEventListener("resize", resize); 357 }; 358 }, []); 359 360 return renderedOnce; 361}