Files
jellyfin/sidecar/profile.ts
T
officer a7f1a71785 jellyfin, extracted from the platform as a plugin
Eighth plugin out, and the second resurrection: like soulseek, hono.ts carried
the router import and its mount commented out since 2026-08-13, with the
permission reduced to api: [], so the server booted clean while every panel
request 404'd. jellyfin_servers was also absent from the drizzle barrel, so
bun db:push would have dropped it.

Easier than soulseek in one specific way: mountPrefix derives /jellyfin from
the appName, which is exactly what the router already hardcoded, so no frontend
call site needed repointing. Soulseek needed 47 because its appName and its old
prefix differed. The frontend also already routed through one helper, so there
were five call sites rather than forty-seven.

Self-contained, unlike transmission and soulseek: jellyfin_servers is a
registry this plugin owns outright — label, URL, encrypted token, one active —
because more than one Jellyfin is a thing someone has, and more than one
Transmission is not.

Carries its own .prettierignore for the vendored hls.mjs, rather than leaving a
stale rule behind in the platform naming a path that no longer exists.
2026-08-19 03:17:32 +00:00

86 lines
4.0 KiB
TypeScript

// The device profile sent with every PlaybackInfo request.
//
// This is the single most consequential object in the whole Jellyfin integration and the least obvious: it is
// how the SERVER decides whether to hand back the original file or spin up an ffmpeg transcode. Describe the
// browser too generously and playback dies silently on an unsupported codec; too conservatively and every
// file is transcoded, which on this machine means CPU-only ffmpeg (no /dev/dri is passed into the container).
//
// It deliberately describes what a bare Chromium/WebKit `<video>` can play with no media library loaded, which
// is narrower than the file formats a personal library contains — Matroska in particular is NOT playable in any
// browser, so mkv is the common case that has to be remuxed even when its streams are already h264/aac.
//
// Both transcoding profiles are declared, and the ORDER is load-bearing. HLS is first, so `TranscodingUrl` in
// the PlaybackInfo response stays an m3u8 the player carries but does not use (see routes.ts → `hlsUrl`). The
// progressive http profile below is what our player actually requests, and it exists so the server accepts
// that request; without it, `/Videos/{id}/stream.mp4?static=false` is refused as outside the profile.
//
// Kept as a plain literal rather than probed from the client: it is server-side knowledge about a fixed
// target (our own web player), and a profile assembled from feature detection in the browser is the classic
// way to end up with a per-machine playback bug nobody can reproduce.
const VIDEO_CONTAINERS = 'mp4,m4v,webm';
const VIDEO_CODECS = 'h264,vp8,vp9,av1';
const AUDIO_CODECS = 'aac,mp3,opus,flac,vorbis';
export const DEVICE_PROFILE = {
MaxStreamingBitrate: 120_000_000,
MaxStaticBitrate: 100_000_000,
MusicStreamingTranscodingBitrate: 384_000,
DirectPlayProfiles: [
{ Container: VIDEO_CONTAINERS, Type: 'Video', VideoCodec: VIDEO_CODECS, AudioCodec: AUDIO_CODECS },
// Present so a trailer or a stray extra plays; the library itself is video and audio lives in /music.
{ Container: 'mp3,aac,flac,opus,webm', Type: 'Audio' },
],
TranscodingProfiles: [
{
// fMP4 segments rather than MPEG-TS: h264 in fMP4 is what jellyfin-web itself defaults to on 10.11,
// and it is the only path that can stream-copy an h264 track into HLS instead of re-encoding it.
Container: 'mp4',
Type: 'Video',
AudioCodec: 'aac,mp3,opus,flac',
VideoCodec: 'h264',
Context: 'Streaming',
Protocol: 'hls',
MaxAudioChannels: '2',
MinSegments: 1,
BreakOnNonKeyFrames: true,
},
{
// Progressive mp4 over plain http — what our player requests. `Streaming`, not `Static`: `Static` means
// "the whole file, transcoded up front", which for a live seek-by-restart player is the wrong contract
// and is refused for a stream opened at an offset.
Container: 'mp4',
Type: 'Video',
AudioCodec: 'aac',
VideoCodec: 'h264',
Context: 'Streaming',
Protocol: 'http',
MaxAudioChannels: '2',
},
],
CodecProfiles: [
{
Type: 'Video',
Codec: 'h264',
Conditions: [
// High 10 (10-bit h264) decodes in almost no browser, and hitting it is a black screen with audio
// rather than an error, so it is excluded explicitly instead of being left to chance.
{ Condition: 'NotEquals', Property: 'VideoProfile', Value: 'high 10', IsRequired: false },
{ Condition: 'LessThanEqual', Property: 'VideoLevel', Value: '52', IsRequired: false },
],
},
],
SubtitleProfiles: [
// Text subtitles are fetched separately and rendered by the player. Image-based ones (PGS, VOBSUB) have
// no such path in a browser and are left out, which makes Jellyfin burn them into the video instead.
{ Format: 'vtt', Method: 'External' },
{ Format: 'srt', Method: 'External' },
{ Format: 'ass', Method: 'External' },
{ Format: 'ssa', Method: 'External' },
],
} as const;