mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 12:08:37 +01:00
feat(casting): add unified loadCastMedia with downgrade-on-failure
This commit is contained in:
20
utils/casting/castErrors.test.ts
Normal file
20
utils/casting/castErrors.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isLoadFailedError } from "./castErrors";
|
||||
|
||||
describe("isLoadFailedError", () => {
|
||||
test("recognises a status 2100 error message", () => {
|
||||
const error = new Error(
|
||||
"java.lang.Exception: Media control channel status code 2100",
|
||||
);
|
||||
expect(isLoadFailedError(error)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for unrelated errors", () => {
|
||||
expect(isLoadFailedError(new Error("network timeout"))).toBe(false);
|
||||
});
|
||||
|
||||
test("handles non-Error values without throwing", () => {
|
||||
expect(isLoadFailedError("status code 2100")).toBe(true);
|
||||
expect(isLoadFailedError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
11
utils/casting/castErrors.ts
Normal file
11
utils/casting/castErrors.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Cast load error classification. Kept dependency-free so it is unit-testable
|
||||
* without pulling React Native modules into the test runtime.
|
||||
*/
|
||||
|
||||
/** True when an error is a Cast "LOAD_FAILED" (status 2100) rejection. */
|
||||
export const isLoadFailedError = (error: unknown): boolean => {
|
||||
if (error == null) return false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes("2100");
|
||||
};
|
||||
137
utils/casting/castLoad.ts
Normal file
137
utils/casting/castLoad.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Unified Chromecast media loading.
|
||||
*
|
||||
* Owns the getStreamUrl → buildCastMediaInfo → loadMedia sequence that was
|
||||
* previously duplicated across PlayButton and the casting player. Builds the
|
||||
* device profile from detected capabilities and retries once with a forced
|
||||
* conservative profile when the receiver rejects the initial load (status 2100).
|
||||
*/
|
||||
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
||||
import { buildChromecastProfile } from "@/utils/casting/buildProfile";
|
||||
import {
|
||||
type ChromecastProfileMode,
|
||||
detectCapabilities,
|
||||
} from "@/utils/casting/capabilities";
|
||||
import { isLoadFailedError } from "@/utils/casting/castErrors";
|
||||
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
|
||||
export interface CastLoadOptions {
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
maxBitrate?: number;
|
||||
mediaSourceId?: string;
|
||||
startPositionMs?: number;
|
||||
}
|
||||
|
||||
export interface CastLoadParams {
|
||||
client: RemoteMediaClient;
|
||||
/** Cast device — only `modelName` is read, for capability detection. */
|
||||
device: { modelName?: string } | null;
|
||||
api: Api;
|
||||
item: BaseItemDto;
|
||||
userId: string;
|
||||
profileMode: ChromecastProfileMode;
|
||||
/** Manual bitrate cap from settings, in bits per second. */
|
||||
maxBitrateSetting?: number;
|
||||
options?: CastLoadOptions;
|
||||
}
|
||||
|
||||
export type CastLoadResult = { ok: true } | { ok: false; error: unknown };
|
||||
|
||||
/**
|
||||
* Resolve the default audio stream index for an item.
|
||||
* Fixes the previous `audioStreamIndex = 0` default, which selected the video stream.
|
||||
*/
|
||||
export const resolveDefaultAudioIndex = (
|
||||
item: BaseItemDto,
|
||||
mediaSourceId?: string,
|
||||
): number | undefined => {
|
||||
const source = mediaSourceId
|
||||
? item.MediaSources?.find((s) => s.Id === mediaSourceId)
|
||||
: item.MediaSources?.[0];
|
||||
if (source?.DefaultAudioStreamIndex != null) {
|
||||
return source.DefaultAudioStreamIndex;
|
||||
}
|
||||
const audio =
|
||||
item.MediaStreams?.find((s) => s.Type === "Audio" && s.IsDefault) ??
|
||||
item.MediaStreams?.find((s) => s.Type === "Audio");
|
||||
return audio?.Index ?? undefined;
|
||||
};
|
||||
|
||||
const attemptLoad = async (
|
||||
params: CastLoadParams,
|
||||
caps: Parameters<typeof buildChromecastProfile>[0],
|
||||
): Promise<void> => {
|
||||
const { api, item, userId, client, options } = params;
|
||||
const profile = buildChromecastProfile(caps);
|
||||
const audioStreamIndex =
|
||||
options?.audioStreamIndex ??
|
||||
resolveDefaultAudioIndex(item, options?.mediaSourceId);
|
||||
const startPositionMs = options?.startPositionMs ?? 0;
|
||||
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
userId,
|
||||
startTimeTicks: Math.floor(startPositionMs * 10000),
|
||||
deviceProfile: profile,
|
||||
audioStreamIndex,
|
||||
subtitleStreamIndex: options?.subtitleStreamIndex,
|
||||
maxStreamingBitrate: options?.maxBitrate,
|
||||
mediaSourceId: options?.mediaSourceId,
|
||||
});
|
||||
|
||||
if (!data?.url) {
|
||||
throw new Error("getStreamUrl returned no URL");
|
||||
}
|
||||
|
||||
await client.loadMedia({
|
||||
mediaInfo: buildCastMediaInfo({
|
||||
item,
|
||||
streamUrl: data.url,
|
||||
api,
|
||||
playSessionId: data.sessionId ?? undefined,
|
||||
}),
|
||||
startTime: startPositionMs / 1000,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Load media onto the connected Chromecast.
|
||||
* On a status-2100 rejection, retries once with a forced conservative profile.
|
||||
*/
|
||||
export const loadCastMedia = async (
|
||||
params: CastLoadParams,
|
||||
): Promise<CastLoadResult> => {
|
||||
const caps = detectCapabilities(params.device, {
|
||||
profileMode: params.profileMode,
|
||||
maxBitrate: params.maxBitrateSetting,
|
||||
});
|
||||
|
||||
try {
|
||||
await attemptLoad(params, caps);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (!isLoadFailedError(error)) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
// Downgrade-on-failure: one retry with the safest possible profile.
|
||||
try {
|
||||
const fallback = detectCapabilities(params.device, {
|
||||
profileMode: "force-h264",
|
||||
});
|
||||
await attemptLoad(params, {
|
||||
...fallback,
|
||||
maxVideoBitrate: 4_000_000,
|
||||
maxAudioChannels: 2,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (retryError) {
|
||||
return { ok: false, error: retryError };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export const buildCastMediaInfo = ({
|
||||
api,
|
||||
contentType,
|
||||
isLive = false,
|
||||
playSessionId,
|
||||
}: {
|
||||
item: BaseItemDto;
|
||||
streamUrl: string;
|
||||
@@ -31,6 +32,8 @@ export const buildCastMediaInfo = ({
|
||||
contentType?: string;
|
||||
/** Set true for live TV streams to use MediaStreamType.LIVE. */
|
||||
isLive?: boolean;
|
||||
/** Jellyfin PlaySessionId, embedded in customData for progress reporting. */
|
||||
playSessionId?: string;
|
||||
}) => {
|
||||
if (!item.Id) {
|
||||
throw new Error("Missing item.Id for media load — cannot build contentId");
|
||||
@@ -84,7 +87,8 @@ export const buildCastMediaInfo = ({
|
||||
// Build a slim customData payload with only the fields the casting-player needs.
|
||||
// Sending the full BaseItemDto can exceed the Cast protocol's ~64KB message limit,
|
||||
// especially for movies with many chapters, media sources, and people.
|
||||
const slimCustomData: Partial<BaseItemDto> = {
|
||||
const slimCustomData: Partial<BaseItemDto> & { playSessionId?: string } = {
|
||||
playSessionId,
|
||||
Id: item.Id,
|
||||
Name: item.Name,
|
||||
Type: item.Type,
|
||||
|
||||
Reference in New Issue
Block a user