jellyfin panel app: browse, detail and player

adds the /jellyfin screen and its two panels (nav + view) on top of the
jellyfin sidecar, following the photos/invoiceshelf shape.

the transcode path is a progressive mp4 rather than hls, so no hls.js
dependency is added under the frozen-install rule. that stream has no
length and no byte ranges, so the player owns its own scrubber and seeks
by re-negotiating at a new startTimeTicks, tracking offset + currentTime;
a direct file keeps native controls. the hls url is still returned, so
switching later is a player change only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:16:45 +00:00
co-authored by Claude Opus 5
parent 904edefd62
commit a894d64cb3
27 changed files with 2206 additions and 16 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ import { getConfig, probe } from './upstream';
// GET /_officer/items?parentId=… browse grid (allow-listed filters, poster fields always on)
// GET /_officer/items/:id full detail, incl. MediaSources
// GET /_officer/items/:id/similar
// POST /_officer/items/:id/playback negotiate → { playMethod, url, isHls, playSessionId }
// POST /_officer/items/:id/playback negotiate → { playMethod, url, seekable, hlsUrl, playSessionId }
// POST|DEL /_officer/items/:id/played watched mark
// POST|DEL /_officer/items/:id/favorite
// GET /_officer/shows/:id/seasons
+13 -4
View File
@@ -5,9 +5,14 @@
// 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 modern Chromium/WebKit `<video>` plus hls.js can actually play, 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 go out as HLS even when its streams are already h264/aac.
// 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
@@ -43,12 +48,16 @@ export const DEVICE_PROFILE = {
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: 'Static',
Context: 'Streaming',
Protocol: 'http',
MaxAudioChannels: '2',
},
],
+63 -11
View File
@@ -190,8 +190,11 @@ async function item(cfg: UpstreamConfig, id: string): Promise<Response> {
type MediaSource = {
Id?: string;
Container?: string;
RunTimeTicks?: number;
SupportsDirectPlay?: boolean;
SupportsDirectStream?: boolean;
DirectStreamUrl?: string;
TranscodingUrl?: string;
};
type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null };
@@ -213,13 +216,51 @@ function sanitizeUpstreamPath(raw: string): string {
return `${path}${qs ? `?${qs}` : ''}`;
}
/**
* Build the PROGRESSIVE transcode URL — one continuous mp4 the `<video>` element can play with no library.
*
* This is the deliberate fallback instead of HLS, and the reason is a dependency this repo does not have:
* Chromium cannot play an HLS playlist natively, so the m3u8 path needs hls.js, and `bunfig.toml` freezes
* installs precisely so a new package is a considered act rather than a side effect of a feature. A
* progressive stream needs nothing.
*
* What it costs is honest and worth stating: a live transcode has no length and no byte ranges, so the
* browser cannot seek it. The player seeks by asking for a NEW stream at an offset — which is what
* `startTimeTicks` is for here, and why the UI re-requests playback on every scrub.
*
* `allowVideoStreamCopy` is what keeps this cheap for the common case. An mkv whose video is already h264
* gets REMUXED, not re-encoded: the container changes, the video bytes are copied. That matters on this
* machine, where the Jellyfin container has no /dev/dri and every real encode is on the CPU.
*/
function progressivePath(id: string, source: MediaSource, playSessionId: string | null, startSeconds: number): string {
return `/Videos/${encodeURIComponent(id)}/stream.mp4${buildQuery({
static: 'false',
container: 'mp4',
mediaSourceId: source.Id,
playSessionId: playSessionId ?? undefined,
videoCodec: 'h264',
audioCodec: 'aac',
audioBitrate: '192000',
maxAudioChannels: '2',
allowVideoStreamCopy: 'true',
allowAudioStreamCopy: 'true',
// No `subtitleMethod=Encode` here on purpose: burning subtitles in forces a full video re-encode, which
// throws away the stream copy above and pins the CPU on a file that needed nothing but a remux. Subtitles
// are a deliberate later feature, chosen per-item, not a default that quietly costs that much.
startTimeTicks: startSeconds > 0 ? String(Math.round(startSeconds * TICKS_PER_SECOND)) : undefined,
})}`;
}
/**
* Negotiate playback: ask the server what it can do with this file for this profile, and turn the answer into
* one URL the player can use.
*
* The three outcomes are direct play (the original container is browser-playable), direct stream (remuxed on
* the fly, still `/Videos/{id}/stream`) and transcode (HLS). They are reported explicitly rather than
* inferred, because "why is my CPU pinned" is a question the UI should be able to answer.
* the fly, still `/Videos/{id}/stream`) and transcode. They are reported explicitly rather than inferred,
* because "why is my CPU pinned" is a question the UI should be able to answer.
*
* `hlsUrl` is returned alongside whenever Jellyfin offered one. Nothing consumes it yet — it is what the
* player switches to the day hls.js is added on purpose, and it costs nothing to carry until then.
*/
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0');
@@ -260,16 +301,22 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
if (!source) return bad('the server returned no playable source for this item', 502);
const playSessionId = info.PlaySessionId ?? null;
const direct = source.SupportsDirectPlay || source.SupportsDirectStream;
const direct = !!(source.SupportsDirectPlay || source.SupportsDirectStream);
const staticPath = `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
static: 'true',
mediaSourceId: source.Id,
playSessionId: playSessionId ?? undefined,
})}`;
const path = direct
? `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
static: 'true',
mediaSourceId: source.Id,
playSessionId: playSessionId ?? undefined,
})}`
: source.TranscodingUrl
? sanitizeUpstreamPath(source.TranscodingUrl)
? source.SupportsDirectPlay
? staticPath
: (source.DirectStreamUrl && sanitizeUpstreamPath(source.DirectStreamUrl)) || staticPath
: // A `TranscodingUrl` is Jellyfin agreeing to transcode. We ask for the same thing progressively
// instead of taking its HLS URL, but its absence still means "refused", so it stays the signal.
source.TranscodingUrl
? progressivePath(id, source, playSessionId, startSeconds)
: null;
if (!path) {
@@ -280,11 +327,16 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
playMethod: direct ? (source.SupportsDirectPlay ? 'DirectPlay' : 'DirectStream') : 'Transcode',
// Relative to the panel's mount, so the browser prefixes /api/jellyfin and the pass-through does the rest.
url: `/_jf${path}`,
isHls: !direct,
// A direct file is byte-range seekable; a live transcode is not, and the player has to restart the stream
// at an offset instead. This flag is the difference, and it is the one thing the player cannot guess.
seekable: direct,
// Carried, not used. The day hls.js is a deliberate dependency, the player switches to this.
hlsUrl: source.TranscodingUrl ? `/_jf${sanitizeUpstreamPath(source.TranscodingUrl)}` : null,
playSessionId,
mediaSourceId: source.Id ?? null,
mediaSource: source,
startSeconds,
runtimeSeconds: source.RunTimeTicks ? source.RunTimeTicks / TICKS_PER_SECOND : null,
});
}