From e3f105691bfd5faaf73460d9ba5bb71f853539b0 Mon Sep 17 00:00:00 2001 From: Uruk Date: Thu, 21 May 2026 02:16:29 +0200 Subject: [PATCH] feat(casting): add Chromecast device profile builder --- utils/casting/buildProfile.test.ts | 55 +++++++++++++++ utils/casting/buildProfile.ts | 106 +++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 utils/casting/buildProfile.test.ts create mode 100644 utils/casting/buildProfile.ts diff --git a/utils/casting/buildProfile.test.ts b/utils/casting/buildProfile.test.ts new file mode 100644 index 000000000..90c4acb50 --- /dev/null +++ b/utils/casting/buildProfile.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { buildChromecastProfile } from "./buildProfile"; +import { CONSERVATIVE_CAPABILITIES } from "./capabilities"; + +describe("buildChromecastProfile", () => { + test("conservative caps produce an H.264-only video codec list", () => { + const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES); + const videoCodecProfile = profile.CodecProfiles?.find( + (c) => c.Type === "Video", + ); + expect(videoCodecProfile?.Codec).toBe("h264"); + }); + + test("HEVC-capable caps include hevc in the video codec list", () => { + const profile = buildChromecastProfile({ + ...CONSERVATIVE_CAPABILITIES, + hevc: true, + }); + const videoCodecProfile = profile.CodecProfiles?.find( + (c) => c.Type === "Video", + ); + expect(videoCodecProfile?.Codec).toContain("hevc"); + }); + + test("maxVideoBitrate drives MaxStreamingBitrate", () => { + const profile = buildChromecastProfile({ + ...CONSERVATIVE_CAPABILITIES, + maxVideoBitrate: 5_000_000, + }); + expect(profile.MaxStreamingBitrate).toBe(5_000_000); + }); + + test("maxAudioChannels constrains transcoding profiles", () => { + const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES); + const videoTranscode = profile.TranscodingProfiles?.find( + (p) => p.Type === "Video", + ); + expect(videoTranscode?.MaxAudioChannels).toBe("2"); + }); + + test("non-10bit HEVC caps add a video bit-depth condition", () => { + const profile = buildChromecastProfile({ + ...CONSERVATIVE_CAPABILITIES, + hevc: true, + hevc10bit: false, + }); + const videoCodecProfile = profile.CodecProfiles?.find( + (c) => c.Type === "Video", + ); + const bitDepthCondition = videoCodecProfile?.Conditions?.find( + (cond) => cond.Property === "VideoBitDepth", + ); + expect(bitDepthCondition).toBeDefined(); + }); +}); diff --git a/utils/casting/buildProfile.ts b/utils/casting/buildProfile.ts new file mode 100644 index 000000000..36c78d026 --- /dev/null +++ b/utils/casting/buildProfile.ts @@ -0,0 +1,106 @@ +import type { + DeviceProfile, + ProfileCondition, +} from "@jellyfin/sdk/lib/generated-client/models"; +import type { ChromecastCapabilities } from "./capabilities"; + +/** + * Build a Jellyfin `DeviceProfile` for a Chromecast from its detected capabilities. + * Replaces the former static `chromecast.ts` / `chromecasth265.ts` profiles. + */ +export const buildChromecastProfile = ( + caps: ChromecastCapabilities, +): DeviceProfile => { + const videoCodecs = caps.hevc ? "hevc,h264" : "h264"; + const maxHeight = caps.maxResolution === 2160 ? "2160" : "1080"; + const maxChannels = String(caps.maxAudioChannels); + + const videoConditions: ProfileCondition[] = [ + { + Condition: "LessThanEqual", + Property: "Height", + Value: maxHeight, + IsRequired: false, + }, + ]; + // When HEVC is allowed but 10-bit is not, force the server to transcode + // 10-bit sources down to 8-bit. + if (caps.hevc && !caps.hevc10bit) { + videoConditions.push({ + Condition: "LessThanEqual", + Property: "VideoBitDepth", + Value: "8", + IsRequired: false, + }); + } + + return { + Name: "Chromecast Video Profile", + MaxStreamingBitrate: caps.maxVideoBitrate, + MaxStaticBitrate: caps.maxVideoBitrate, + MusicStreamingTranscodingBitrate: 384000, + CodecProfiles: [ + { + Type: "Video", + Codec: videoCodecs, + Conditions: videoConditions, + }, + { + Type: "Audio", + Codec: "aac,mp3,flac,opus,vorbis", + // Force transcode of multichannel audio the receiver cannot output. + Conditions: [ + { + Condition: "LessThanEqual", + Property: "AudioChannels", + Value: maxChannels, + IsRequired: false, + }, + ], + }, + ], + ContainerProfiles: [], + DirectPlayProfiles: [ + { + Container: caps.hevc ? "mp4,mkv" : "mp4", + Type: "Video", + VideoCodec: videoCodecs, + AudioCodec: "aac,mp3,opus,vorbis", + }, + { Container: "mp3", Type: "Audio" }, + { Container: "aac", Type: "Audio" }, + { Container: "flac", Type: "Audio" }, + { Container: "wav", Type: "Audio" }, + ], + TranscodingProfiles: [ + { + Container: "ts", + Type: "Video", + VideoCodec: videoCodecs, + AudioCodec: "aac,mp3", + Protocol: "hls", + Context: "Streaming", + MaxAudioChannels: maxChannels, + MinSegments: 2, + BreakOnNonKeyFrames: true, + }, + { + Container: "mp3", + Type: "Audio", + AudioCodec: "mp3", + Protocol: "http", + Context: "Streaming", + MaxAudioChannels: maxChannels, + }, + { + Container: "aac", + Type: "Audio", + AudioCodec: "aac", + Protocol: "http", + Context: "Streaming", + MaxAudioChannels: maxChannels, + }, + ], + SubtitleProfiles: [{ Format: "vtt", Method: "Encode" }], + }; +};