diff --git a/src/player/AudioEngine.check.ts b/src/player/AudioEngine.check.ts new file mode 100644 index 00000000..5800afd7 --- /dev/null +++ b/src/player/AudioEngine.check.ts @@ -0,0 +1,39 @@ +/** + * Self-check for `isEmbedRestrictedPlaybackError`, the switch that decides whether an IFrame + * playback failure is the one kind worth retrying on native audio (owner disabled embedding) + * versus every other kind (network hiccup, removed video, ...) where retrying elsewhere would + * just fail the same way. Run via `npm run check`. + */ +export {}; + +import { isEmbedRestrictedPlaybackError } from "./AudioEngine"; + +function check(condition: boolean, message: string): void { + if (!condition) throw new Error(`FAILED: ${message}`); +} + +check( + isEmbedRestrictedPlaybackError(new Error("YouTube player error 150")), + "150 is the owner-disabled-embedding code", +); +check( + isEmbedRestrictedPlaybackError(new Error("YouTube player error 101")), + "101 is 150's alias", +); +check( + !isEmbedRestrictedPlaybackError(new Error("YouTube player error 100")), + "100 (video removed/private) is not embed-restriction and should not be retried", +); +check( + !isEmbedRestrictedPlaybackError(new Error("YouTube player error 2")), + "2 (invalid parameter) is not embed-restriction", +); +check( + !isEmbedRestrictedPlaybackError(new Error("Some other failure")), + "unrelated error messages don't match", +); +check(!isEmbedRestrictedPlaybackError("YouTube player error 150"), "non-Error values don't match"); +check(!isEmbedRestrictedPlaybackError(null), "null doesn't throw or match"); + +// eslint-disable-next-line no-console +console.log("AudioEngine.check.ts passed"); diff --git a/src/player/AudioEngine.ts b/src/player/AudioEngine.ts index a62867e5..e3bd32eb 100644 --- a/src/player/AudioEngine.ts +++ b/src/player/AudioEngine.ts @@ -160,6 +160,17 @@ function isPlayerStateTimeout(error: unknown): boolean { && /^Timed out waiting for YouTube player state: /.test(error.message); } +/** + * True for IFrame error 101/150 — YouTube's own "the owner of this video does not allow it to + * be played in embedded players" (150 is 101 in disguise, same restriction, same fix). + * + * A property of the *embed*, not of the video: a directly resolved stream URL is not an embed + * and is not subject to it, which is what makes falling back to one worth trying. + */ +export function isEmbedRestrictedPlaybackError(error: unknown): boolean { + return error instanceof Error && /^YouTube player error (101|150)$/.test(error.message); +} + function detectAudioMimeType(bytes: Uint8Array): string { if ( bytes.length >= 4 diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 31480ef3..3001da0d 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -2,7 +2,7 @@ import type { DataSource, StreamData } from "../datasource/DataSource"; import type { Lyrics, Track } from "../datasource/types"; import { logInternalDebug, logInternalError, logInternalInfo, logInternalWarn } from "../internal/logging"; import { isPrematureEnd } from "./prematureEnd"; -import { AudioEngine } from "./AudioEngine"; +import { AudioEngine, isEmbedRestrictedPlaybackError } from "./AudioEngine"; import { Queue } from "./Queue"; import { NavigationCoalescer } from "./navigationCoalescer"; import { recordPlay } from "./playHistory"; @@ -1310,6 +1310,14 @@ export class PlayerController { * saved on purpose. */ const canFallBackToIframe = this.audioEngine.usesRustAudio() && !isDownloaded; + /* + * The mirror case: the embed is refused not because Google won't resolve the track but + * because its owner disallows embedded playback at all (error 101/150) — a restriction + * on the embed, not on the video. A directly resolved stream is not an embed and + * sidesteps it, so a track that would otherwise be permanently unplayable on this engine + * gets one try at native audio before giving up. + */ + const canFallBackToNative = !useNativeAudio && !isDownloaded; try { const audioData = useNativeAudio @@ -1339,12 +1347,30 @@ export class PlayerController { ); } } catch (error) { - if (!canFallBackToIframe) throw error; - logInternalWarn("PlayerController.ensureTrackLoaded falling back to the YouTube player", { - trackId: track.id, - error: getErrorMessage(error), - }); - await this.audioEngine.loadIframeFallback(track.id); + if (canFallBackToIframe) { + logInternalWarn("PlayerController.ensureTrackLoaded falling back to the YouTube player", { + trackId: track.id, + error: getErrorMessage(error), + }); + await this.audioEngine.loadIframeFallback(track.id); + } else if (canFallBackToNative && isEmbedRestrictedPlaybackError(error)) { + const nativeAudioData = await this.dataSource.getStreamData?.(track); + if (!nativeAudioData) throw error; + logInternalWarn("PlayerController.ensureTrackLoaded falling back to native audio", { + trackId: track.id, + error: getErrorMessage(error), + }); + await this.audioEngine.loadNativeFallback( + track.id, + nativeAudioData.bytes, + nativeAudioData.mimeType, + nativeAudioData.sourceUrl, + nativeAudioData.rustSource, + track.durationSec, + ); + } else { + throw error; + } } this.loadedTrackId = track.id;