Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/player/AudioEngine.check.ts
Original file line number Diff line number Diff line change
@@ -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");
11 changes: 11 additions & 0 deletions src/player/AudioEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 33 additions & 7 deletions src/player/PlayerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading