pstream is dead; long live pstream
taciturnaxolotl.github.io/pstream-ng/
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}