Skip to content

Commit c34f0aa

Browse files
committed
fix: stop extract retry storms and preserve track metadata
Cap playback re-extracts, close the player on stream failure, raise YouTube rate limits, and keep session/search titles across idle restore instead of falling back to Unknown.
1 parent e6d37b7 commit c34f0aa

6 files changed

Lines changed: 168 additions & 94 deletions

File tree

src/app/api/youtube/status/route.ts

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,7 @@ import {
1313
assertYoutubeCircuitClosed,
1414
withYoutubeCircuit,
1515
} from "@/lib/youtubeCircuit";
16-
import {
17-
extractStreamUrl,
18-
getYoutubeCookieSource,
19-
searchMusic,
20-
searchYouTube,
21-
} from "@/lib/youtubei";
16+
import { getYoutubeCookieSource, searchMusic, searchYouTube } from "@/lib/youtubei";
2217

2318
const DATA_DIR = path.join(process.cwd(), "data");
2419
const STATUS_PATH = path.join(DATA_DIR, "status.json");
@@ -129,15 +124,14 @@ async function probeSearch(cookies?: string): Promise<string | null> {
129124
return videos[0]?.url ?? null;
130125
}
131126

132-
async function runProbe(
133-
cookies: string | undefined,
134-
signal?: AbortSignal,
135-
): Promise<{
127+
async function runProbe(cookies: string | undefined): Promise<{
136128
searchOk: boolean;
137129
extractOk: boolean;
138130
errorMessage?: string;
139131
code?: string;
140132
}> {
133+
// Search-only health check. A full extract here races real plays, burns the
134+
// shared YouTube circuit, and is redundant with /api/stream/extract.
141135
const songUrl = await probeSearch(cookies);
142136
const searchOk = Boolean(songUrl);
143137

@@ -150,20 +144,7 @@ async function runProbe(
150144
};
151145
}
152146

153-
const stream = await withYoutubeCircuit(() =>
154-
extractStreamUrl(songUrl, { cookies, signal }),
155-
);
156-
const extractOk = Boolean(stream.url);
157-
if (!extractOk) {
158-
return {
159-
searchOk,
160-
extractOk,
161-
errorMessage: "Stream extract returned no URL.",
162-
code: "STREAM_UNAVAILABLE",
163-
};
164-
}
165-
166-
return { searchOk, extractOk };
147+
return { searchOk, extractOk: searchOk };
167148
}
168149

169150
async function revalidateMoonlitStatus(): Promise<void> {
@@ -286,7 +267,7 @@ export async function GET(request: Request) {
286267
assertYoutubeCircuitClosed();
287268
// Static validation already required LOGIN_INFO / __Secure-*PSID.
288269
// account.getInfo() is too flaky with cookies and caused false "invalid".
289-
const result = await runProbe(userCookies, request.signal);
270+
const result = await runProbe(userCookies);
290271
const online = result.searchOk && result.extractOk;
291272
return respondUser({
292273
online,
@@ -400,7 +381,7 @@ export async function GET(request: Request) {
400381
const cookieSource = await getYoutubeCookieSource(undefined);
401382
try {
402383
assertYoutubeCircuitClosed();
403-
const result = await runProbe(undefined, request.signal);
384+
const result = await runProbe(undefined);
404385
const online = result.searchOk && result.extractOk;
405386
const payload: StatusPayload = {
406387
online,

src/components/Player.tsx

Lines changed: 123 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ import {
6666
savePlaybackPrefs,
6767
setShowVideo,
6868
} from "@/lib/playerPrefs";
69-
import { consumeSearchMeta, mergeTrackMetadata, peekSearchMeta } from "@/lib/searchMeta";
69+
import { mergeTrackMetadata, peekSearchMeta } from "@/lib/searchMeta";
7070
import { appTheme } from "@/lib/theme";
7171
import { getModeFromRate, getVideoState, saveVideoState } from "@/lib/videoState";
7272
import { getFormattedTime, getYouTubeId, isSupportedURL } from "@/utils";
@@ -107,7 +107,8 @@ function getPrepopulatedMetadata(
107107
/^.*(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/|shorts\/|watch\?v=|\&v=)([^#\&\?]*).*/,
108108
)?.[1];
109109
if (!id) return undefined;
110-
const meta = consumeSearchMeta(id);
110+
// peek (not consume) so long-lived tabs / re-extracts keep titles after leave.
111+
const meta = peekSearchMeta(id);
111112
return meta as Record<string, string | number> | undefined;
112113
} catch {
113114
return undefined;
@@ -155,7 +156,10 @@ export function Player({
155156
const [streamState, setStreamState] = useState<StreamState>({ status: "idle" });
156157
const [playbackError, setPlaybackError] = useState<string | null>(null);
157158
const streamStarted = useRef(false);
159+
/** Per-sourceUrl playback failures. Must NOT reset on extract success or cursed
160+
* streams (extract OK, play fail) re-extract forever and burn the rate limit. */
158161
const audioErrorCount = useRef(0);
162+
const extractAbortRef = useRef<AbortController | null>(null);
159163
const autoPlayedRef = useRef(false);
160164
const bottomBarRef = useRef<HTMLDivElement>(null);
161165
const [barHeight, setBarHeight] = useState(148);
@@ -215,12 +219,35 @@ export function Player({
215219
}, [url, initialMeta]);
216220

217221
const media = useMemo(() => {
218-
// Ignore stale extracted media from the previous track while a new URL loads
219-
const extracted =
220-
extractedMedia && (!url || extractedMedia.sourceUrl === url)
221-
? extractedMedia
222-
: null;
223-
return propMedia || extracted || provisionalMedia || (url ? null : contextMedia);
222+
// Ignore stale media from a previous track while a new URL loads
223+
const sameSource = (m: Media | null | undefined) =>
224+
m && (!url || m.sourceUrl === url) ? m : null;
225+
const extracted = sameSource(extractedMedia);
226+
const context = sameSource(contextMedia);
227+
const prop = sameSource(propMedia);
228+
229+
// Prefer a playable fileUrl; keep context/provisional as metadata shell.
230+
// Previously `url` made us drop contextMedia, so re-extract after a long idle
231+
// showed "Unknown" even when last-session/context still had real titles.
232+
const playable =
233+
(prop?.fileUrl ? prop : null) ||
234+
(extracted?.fileUrl ? extracted : null) ||
235+
(context?.fileUrl ? context : null);
236+
const shell = playable || prop || extracted || provisionalMedia || context;
237+
if (!shell) return null;
238+
239+
const ytId = getYouTubeId(shell.sourceUrl || url || "");
240+
return {
241+
...shell,
242+
metadata: mergeTrackMetadata(
243+
shell.metadata,
244+
playable?.metadata,
245+
extracted?.metadata,
246+
context?.metadata,
247+
provisionalMedia?.metadata,
248+
ytId ? peekSearchMeta(ytId) : undefined,
249+
),
250+
};
224251
}, [propMedia, extractedMedia, provisionalMedia, url, contextMedia]);
225252
// Show extracting UI only when resolving URL and not in error state
226253
const isExtracting = !media && !!url && streamState.status !== "error";
@@ -230,16 +257,19 @@ export function Player({
230257
const startStream = useCallback(() => {
231258
if (!url || !isSupportedURL(url)) {
232259
notifications.show({ title: "Error", message: "Invalid URL provided." });
233-
return () => {};
260+
return;
234261
}
235-
setStreamState({ status: "idle" });
262+
extractAbortRef.current?.abort();
236263
const abortController = new AbortController();
264+
extractAbortRef.current = abortController;
265+
setStreamState({ status: "idle" });
237266
const updateStreamState = (next: StreamState) => {
238267
setStreamState((prev) => ({ ...prev, ...next }));
239268
};
240269
streamWithProgress(url, updateStreamState, abortController.signal)
241270
.then((streamedMedia: Media) => {
242-
audioErrorCount.current = 0;
271+
if (abortController.signal.aborted) return;
272+
// Do not reset audioErrorCount here — extract OK + play fail must not loop.
243273
setPlaybackError(null);
244274
const ytId = getYouTubeId(url);
245275
setExtractedMedia({
@@ -252,9 +282,12 @@ export function Player({
252282
})
253283
.catch((e) => {
254284
if (e instanceof DOMException && e.name === "AbortError") return;
285+
if (abortController.signal.aborted) return;
255286
console.error("Stream error:", e);
256287
const code = e instanceof StreamError ? e.code : undefined;
257288
const message = e instanceof Error ? e.message : "Could not process the media.";
289+
setExtractedMedia(null);
290+
setPlaybackError(null);
258291
setStreamState({ status: "error", message });
259292
const hint =
260293
code === "RATE_LIMITED"
@@ -272,11 +305,14 @@ export function Player({
272305
autoClose:
273306
code === "RATE_LIMITED" || code === "YOUTUBE_UNAVAILABLE" ? 20000 : 10000,
274307
});
308+
// Provisional media keeps the player shell mounted; leave it so we don't
309+
// stick on "Processing the audio…" after extract failures (e.g. TVOD blocks).
310+
onRequestClose?.();
275311
});
276-
return () => abortController.abort();
277-
}, [url]);
312+
}, [url, onRequestClose]);
278313

279314
const retryExtract = useCallback(() => {
315+
audioErrorCount.current = 0;
280316
streamStarted.current = true;
281317
setStreamState({ status: "idle" });
282318
startStream();
@@ -288,17 +324,19 @@ export function Player({
288324
setExtractedMedia(null);
289325
streamStarted.current = false;
290326
setStreamState({ status: "idle" });
291-
setTimeout(() => {
327+
queueMicrotask(() => {
292328
streamStarted.current = true;
293329
startStream();
294-
}, 0);
330+
});
295331
}, [startStream]);
296332

297333
// Reset extraction when the source URL changes (new track while shell stays mounted)
298334
const prevUrlRef = useRef(url);
299335
useEffect(() => {
300336
if (prevUrlRef.current === url) return;
301337
prevUrlRef.current = url;
338+
extractAbortRef.current?.abort();
339+
extractAbortRef.current = null;
302340
streamStarted.current = false;
303341
audioErrorCount.current = 0;
304342
autoPlayedRef.current = false;
@@ -319,7 +357,14 @@ export function Player({
319357
if (extractedMedia || streamStarted.current) return;
320358
if (!url) return;
321359
streamStarted.current = true;
322-
setTimeout(() => startStream(), 0);
360+
// Defer so we don't sync-setState inside the effect body (React Compiler lint).
361+
const timer = window.setTimeout(() => startStream(), 0);
362+
return () => {
363+
window.clearTimeout(timer);
364+
extractAbortRef.current?.abort();
365+
extractAbortRef.current = null;
366+
streamStarted.current = false;
367+
};
323368
}, [url, extractedMedia, startStream]);
324369

325370
// Measure bottom chrome for mini-player clip height
@@ -347,10 +392,25 @@ export function Player({
347392
};
348393
}, [isMini, barHeight]);
349394

350-
// Sync playable extracted media into app context (last-session + shared state)
395+
// Sync playable extracted media into app context (last-session + shared state).
396+
// Keep a ref of context so we can merge prior titles without depending on it
397+
// (depending would re-fire this effect after every setMedia).
398+
const contextMediaRef = useRef(contextMedia);
399+
useEffect(() => {
400+
contextMediaRef.current = contextMedia;
401+
}, [contextMedia]);
351402
useEffect(() => {
352403
if (!extractedMedia?.fileUrl) return;
353-
setMedia(extractedMedia);
404+
const prior = contextMediaRef.current;
405+
const ytId = getYouTubeId(extractedMedia.sourceUrl);
406+
setMedia({
407+
...extractedMedia,
408+
metadata: mergeTrackMetadata(
409+
extractedMedia.metadata,
410+
prior?.sourceUrl === extractedMedia.sourceUrl ? prior.metadata : undefined,
411+
ytId ? peekSearchMeta(ytId) : undefined,
412+
),
413+
});
354414
}, [extractedMedia, setMedia]);
355415

356416
// Persist history once media is playable; refresh cover/title when extraction completes
@@ -484,34 +544,45 @@ export function Player({
484544
return () => cancelAnimationFrame(id);
485545
}, []);
486546

487-
// Re-extract when audio fails to load (stream URL may have expired)
547+
// One re-extract on load failure (expired token). Cap at 1 retry per sourceUrl —
548+
// some blocked/TVOD tracks extract a URL that always fails to fetch.
488549
const handleAudioError = useCallback(
489550
(e?: unknown) => {
551+
const message =
552+
e instanceof Error
553+
? e.message
554+
: "Couldn't load audio. Try again or check cookies in settings.";
555+
490556
if (audioErrorCount.current >= 1) {
491-
const message =
492-
e instanceof Error
493-
? e.message
494-
: "Couldn't load audio. Try again or check cookies in settings.";
495557
setPlaybackError(message);
558+
setStreamState({ status: "error", message });
496559
notifications.show({
497560
title: "Playback failed",
498561
message: `${message} Try configuring cookies from a logged-in account in the app settings if the problem persists.`,
499562
color: "red",
500563
autoClose: 10000,
501564
});
565+
onRequestClose?.();
502566
return;
503567
}
504568
audioErrorCount.current++;
569+
// Drop expired fileUrl but keep titles/cover so the shell doesn't flash Unknown.
570+
if (contextMedia && (!url || contextMedia.sourceUrl === url)) {
571+
setMedia({
572+
fileUrl: "",
573+
sourceUrl: contextMedia.sourceUrl,
574+
metadata: contextMedia.metadata,
575+
});
576+
}
505577
setExtractedMedia(null);
506-
streamStarted.current = false;
578+
streamStarted.current = true;
507579
setStreamState({ status: "idle" });
508580
setPlaybackError(null);
509-
setTimeout(() => {
510-
streamStarted.current = true;
581+
window.setTimeout(() => {
511582
startStream();
512583
}, 500);
513584
},
514-
[startStream],
585+
[startStream, onRequestClose, contextMedia, url, setMedia],
515586
);
516587

517588
// Unified player (audio + DSP processing)
@@ -1298,29 +1369,31 @@ export function Player({
12981369
[handleChromeClick],
12991370
);
13001371

1301-
// === Extraction UI (shown before player mounts) ===
1302-
if (streamState.status === "error" && !media) {
1303-
return (
1304-
<Box
1305-
style={{
1306-
position: "fixed",
1307-
inset: 0,
1308-
zIndex: 200,
1309-
}}
1310-
>
1311-
<ErrorScreen
1312-
title="Stream failed"
1313-
message={
1314-
streamState.message ||
1315-
"Could not process the media. Try configuring cookies from a logged-in account in settings."
1316-
}
1317-
primaryLabel="Retry"
1318-
onPrimary={retryExtract}
1319-
secondaryLabel="Go home"
1320-
onSecondary={() => onRequestClose?.()}
1321-
/>
1322-
</Box>
1323-
);
1372+
// Extract failed — prefer home via onRequestClose; avoid provisional-media
1373+
// "Processing the audio…" flash while the player unmounts.
1374+
if (streamState.status === "error") {
1375+
if (!onRequestClose) {
1376+
return (
1377+
<Box
1378+
style={{
1379+
position: "fixed",
1380+
inset: 0,
1381+
zIndex: 200,
1382+
}}
1383+
>
1384+
<ErrorScreen
1385+
title="Stream failed"
1386+
message={
1387+
streamState.message ||
1388+
"Could not process the media. Try configuring cookies from a logged-in account in settings."
1389+
}
1390+
primaryLabel="Retry"
1391+
onPrimary={retryExtract}
1392+
/>
1393+
</Box>
1394+
);
1395+
}
1396+
return null;
13241397
}
13251398

13261399
if (isExtracting) {

0 commit comments

Comments
 (0)