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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user