jellyfin, extracted into plugins/jellyfin — the platform half

Eighth extraction, second resurrection. Same shape as soulseek: hono.ts had the
router import and its PROTECTED_MOUNTS entry commented out since 2026-08-13 and
the permission reduced to api: [], so assertPermissionTotality was satisfied,
the server booted clean, and every panel request 404'd. Also closes the same
data hazard — jellyfin_servers was absent from the drizzle barrel, so bun
db:push would have dropped it.

No frontend call site needed repointing: mountPrefix derives /jellyfin from the
appName, which is what the router already hardcoded.

Test fixtures fixed properly rather than re-anchored again. Extracting jellyfin
broke 18 tests, the FOURTH extraction in a row to break the same files — photos
and transmission yesterday, slskd an hour ago, jellyfin now. installer.test.ts
now builds literal CatalogueEntry fixtures and asks the catalogue nothing: it
pins planSteps and runInstall, which are installer logic, and a service leaving
for plugins/ says nothing about whether the installer still plans the right
steps. catalogue.test.ts stays the place real entries are checked well-formed.
members/catalogue/preflight re-anchored to memos, which has jellyfin's exact
shape.

The .prettierignore rule for the vendored hls.mjs moved into the plugin rather
than being left behind naming a deleted path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 03:17:43 +00:00
co-authored by Claude Opus 5
parent b196c9cb55
commit aebd6ff0c0
49 changed files with 52 additions and 41512 deletions
-3
View File
@@ -3,6 +3,3 @@ dist
runtime-scripts
*.min.js
# Vendored third-party sources — reformatting them would destroy the diff against upstream,
# which is the only thing that makes a checked-in library reviewable. See the README beside it.
src/workspaces/officerdev/src/apps/Jellyfin/vendor/hls.mjs
-1
View File
@@ -10,7 +10,6 @@
"src/workspaces/officerdev/src/apps/FileBrowser": 15,
"src/workspaces/officerdev/src/apps/FileViewer": 38,
"src/workspaces/officerdev/src/apps/Invoices": 95,
"src/workspaces/officerdev/src/apps/Jellyfin": 48,
"src/workspaces/officerdev/src/apps/Preview": 4,
"src/workspaces/officerdev/src/apps/QrTransfer": 5,
"src/workspaces/officerdev/src/apps/SystemMonitor": 1,
-2
View File
@@ -102,8 +102,6 @@ export function App() {
element={<Dashboard.PluginScreen {...plugin} />}
/>,
])}
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
<Route path="/gitea" element={<Dashboard.GiteaScreen />} />
<Route path="/gitea/:section" element={<Dashboard.GiteaScreen />} />
<Route path="/gitea/repo/:owner/:name" element={<Dashboard.GiteaScreen />} />
@@ -1,37 +0,0 @@
import { Navigate, useParams } from 'react-router';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView, DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /jellyfin uses the Workspace/Panel system: the section and library nav (jellyfin-nav) on the left, the
// section view (jellyfin-view) on the right. Both talk to the officer-jellyfin sidecar through the
// /api/jellyfin auth proxy; the Jellyfin URL and its access token live in the sidecar and the browser never
// sees either.
//
// Video only — the owner's audio is Officer's own /music player, and the sidecar already filters music
// libraries out of everything this screen can ask for.
//
// The open section is :section in the URL; which library, which item and what is playing are in the query
// string. Nothing about "what is open" lives in a panel channel.
export const JellyfinScreen = () => {
const { section } = useParams();
const workspace = useDashboardState<LayoutNode>('screens/jellyfin', defaultLayout);
// Bare /jellyfin, or a section that doesn't exist, canonicalises rather than rendering a default behind a
// URL that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing.
if (!isJellyfinSection(section)) {
return <Navigate to={jellyfinSectionPath(DEFAULT_JELLYFIN_SECTION)} replace />;
}
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['jellyfin-nav', 'jellyfin-view'], fallback: 'jellyfin-view' }}
/>
</div>
);
};
@@ -1,11 +0,0 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'jellyfin-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'jellyfin-nav', appType: 'jellyfin-nav' }, size: 18 },
{ node: { type: 'panel', id: 'jellyfin-view', appType: 'jellyfin-view' }, size: 82 },
],
};
@@ -1 +0,0 @@
export * from './JellyfinScreen';
@@ -14,7 +14,6 @@ export * from './Files';
export * from './Calendar';
export * from './Contacts';
export * from './CommandHQ';
export * from './Jellyfin';
export * from './Gitea';
export * from './Invoices';
export * from './Wallet';
@@ -23,7 +23,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
{ match: (p) => p.startsWith('/marketplace'), title: 'Marketplace' },
{ match: (p) => p.startsWith('/plugins'), title: 'Marketplace' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
-1
View File
@@ -47,7 +47,6 @@ export * from './user-data';
export * from './dav';
export * from './invoiceshelf';
export * from './jellyfin';
export * from './notify';
export * from './wallet';
@@ -1,12 +0,0 @@
export {
listJellyfinServers,
getActiveJellyfinCredentials,
getJellyfinCredentials,
createJellyfinServer,
updateJellyfinServer,
setActiveJellyfinServer,
deleteJellyfinServer,
recordJellyfinProbe,
} from './queries';
export type { JellyfinServer, JellyfinCredentials } from './queries';
@@ -1,204 +0,0 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { jellyfinServers } from './schema';
import { encryptSecret, decryptSecret } from '../crypto';
// Jellyfin server registry for the officer-jellyfin sidecar. Callers deal in PLAINTEXT — encryption to and
// from at-rest ciphertext happens here. See ../crypto.ts and ../schema/jellyfin.ts.
//
// Two return types, and the split is the safety property:
// JellyfinServer — safe to serialize to the browser. Has NO token field at all, not even masked.
// JellyfinCredentials — url + decrypted token + the pinned Jellyfin user, for the sidecar's own calls.
// `serverCols` is what enforces it: a bare `select()` would put the ciphertext column into every list
// response the moment someone forgot to strip it.
export type JellyfinServer = {
id: number;
label: string;
url: string;
jellyfinUserId: string;
jellyfinUsername: string | null;
serverName: string | null;
version: string | null;
isActive: boolean;
lastSeenAt: Date | null;
createdAt: Date;
};
export type JellyfinCredentials = {
id: number;
label: string;
url: string;
accessToken: string;
jellyfinUserId: string;
deviceId: string;
};
const serverCols = {
id: jellyfinServers.id,
label: jellyfinServers.label,
url: jellyfinServers.url,
jellyfinUserId: jellyfinServers.jellyfinUserId,
jellyfinUsername: jellyfinServers.jellyfinUsername,
serverName: jellyfinServers.serverName,
version: jellyfinServers.version,
isActive: jellyfinServers.isActive,
lastSeenAt: jellyfinServers.lastSeenAt,
createdAt: jellyfinServers.createdAt,
};
const toCredentials = (row: typeof jellyfinServers.$inferSelect): JellyfinCredentials => ({
id: row.id,
label: row.label,
url: row.url,
accessToken: decryptSecret('jellyfin', row.accessToken),
jellyfinUserId: row.jellyfinUserId,
deviceId: row.deviceId,
});
/** Every server the owner has added, active first then newest. Never includes the token. */
export async function listJellyfinServers(userId: number): Promise<JellyfinServer[]> {
return db
.select(serverCols)
.from(jellyfinServers)
.where(eq(jellyfinServers.userId, userId))
.orderBy(desc(jellyfinServers.isActive), desc(jellyfinServers.createdAt));
}
/** The selected server with its token decrypted, or null when none is added. */
export async function getActiveJellyfinCredentials(userId: number): Promise<JellyfinCredentials | null> {
const [row] = await db
.select()
.from(jellyfinServers)
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true)));
return row ? toCredentials(row) : null;
}
/** One server's credentials by id — for probing a specific server rather than the active one. */
export async function getJellyfinCredentials(userId: number, id: number): Promise<JellyfinCredentials | null> {
const [row] = await db
.select()
.from(jellyfinServers)
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)));
return row ? toCredentials(row) : null;
}
type CreateJellyfinServerParams = {
userId: number;
label: string;
url: string;
accessToken: string;
jellyfinUserId: string;
jellyfinUsername: string | null;
deviceId: string;
serverName: string | null;
version: string | null;
/** Select it. True for the first server, so the UI is never left with servers added but none chosen. */
activate: boolean;
};
/** Add a server. The token is encrypted before write; the returned row carries no token. */
export async function createJellyfinServer(params: CreateJellyfinServerParams): Promise<JellyfinServer> {
const { userId, activate, accessToken, ...rest } = params;
return db.transaction(async (tx) => {
if (activate) {
await tx
.update(jellyfinServers)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true)));
}
const [row] = await tx
.insert(jellyfinServers)
.values({
userId,
...rest,
accessToken: encryptSecret('jellyfin', accessToken),
isActive: activate,
lastSeenAt: rest.version ? new Date() : null,
})
.returning(serverCols);
return row!;
});
}
type UpdateJellyfinServerParams = {
label?: string;
url?: string;
accessToken?: string;
jellyfinUserId?: string;
jellyfinUsername?: string | null;
serverName?: string | null;
version?: string | null;
};
/** Edit a server in place. Omitting `accessToken` keeps the stored one — re-authenticating replaces it. */
export async function updateJellyfinServer(
userId: number,
id: number,
params: UpdateJellyfinServerParams,
): Promise<JellyfinServer | null> {
const { accessToken, ...rest } = params;
const [row] = await db
.update(jellyfinServers)
.set({
...rest,
...(accessToken ? { accessToken: encryptSecret('jellyfin', accessToken) } : {}),
updatedAt: new Date(),
})
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
.returning(serverCols);
return row ?? null;
}
/** Switch the selected server. Clearing the others and setting this one is one transaction, never two. */
export async function setActiveJellyfinServer(userId: number, id: number): Promise<JellyfinServer | null> {
return db.transaction(async (tx) => {
await tx
.update(jellyfinServers)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true)));
const [row] = await tx
.update(jellyfinServers)
.set({ isActive: true, updatedAt: new Date() })
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
.returning(serverCols);
return row ?? null;
});
}
/**
* Remove a server. If it was the selected one, the newest remaining server takes its place — leaving the
* owner with servers registered but none active would show the setup form over a working registry.
*/
export async function deleteJellyfinServer(userId: number, id: number): Promise<boolean> {
return db.transaction(async (tx) => {
const [gone] = await tx
.delete(jellyfinServers)
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
.returning({ id: jellyfinServers.id, wasActive: jellyfinServers.isActive });
if (!gone) return false;
if (gone.wasActive) {
const [next] = await tx
.select({ id: jellyfinServers.id })
.from(jellyfinServers)
.where(eq(jellyfinServers.userId, userId))
.orderBy(desc(jellyfinServers.createdAt))
.limit(1);
if (next) {
await tx
.update(jellyfinServers)
.set({ isActive: true, updatedAt: new Date() })
.where(eq(jellyfinServers.id, next.id));
}
}
return true;
});
}
/** Stamp a successful probe: what version answered, and when. Failures deliberately leave the row alone. */
export async function recordJellyfinProbe(userId: number, id: number, version: string | null): Promise<void> {
await db
.update(jellyfinServers)
.set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)));
}
@@ -1,64 +0,0 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from '../auth/schema';
// The Jellyfin servers behind /jellyfin, for the officer-jellyfin sidecar.
//
// A REGISTRY rather than a single row, and here that is not speculative: this machine runs four Jellyfin
// instances on separate ports (video, albums, DJ sets, and a second person's), and the owner switches
// between them. Same shape as invoiceshelf_accounts and offscale_servers.
//
// `accessToken` is encrypted at rest via ../crypto.ts. A Jellyfin access token can read every item, every
// watch history and every stream on the instance, so a DB dump must not hand it over. Encryption is confined
// to queries/jellyfin.ts; nothing outside that file sees ciphertext, and no route ever returns the token.
//
// The PASSWORD is never stored. It is used once, by the sidecar, to trade for an access token through
// `POST /Users/AuthenticateByName` — the same shape as InvoiceShelf's mintToken.
export const jellyfinServers = pgTable(
'jellyfin_servers',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls this server. The switcher shows nothing else, so it has to be theirs to set. */
label: text('label').notNull(),
// Normalized without a trailing slash before write, so `${url}/Items/...` never doubles the separator.
url: text('url').notNull(),
accessToken: text('access_token').notNull(), // encrypted
/**
* The Jellyfin user the token belongs to, pinned on the row.
*
* Jellyfin's per-user routes (`/UserItems/Resume`, played state, favourites) need this id explicitly, and
* it is NOT derivable from the token without a round trip. Authenticating returns it, so it is stored
* once rather than looked up on every request.
*/
jellyfinUserId: text('jellyfin_user_id').notNull(),
/** The account name that was authenticated — shown in the UI so two logins on one server are told apart. */
jellyfinUsername: text('jellyfin_username'),
/**
* The DeviceId sent on every call, generated once when the server is added.
*
* Jellyfin keys sessions and "remembered devices" by this. A fresh one per request would litter the
* dashboard with hundreds of devices and break playback reporting, which correlates by session.
*/
deviceId: text('device_id').notNull(),
/** The instance's own name (`ServerName` from /System/Info/Public), for the switcher. */
serverName: text('server_name'),
/** Jellyfin version seen at the last successful probe — shown in the UI, never used for behaviour. */
version: text('version'),
isActive: boolean('is_active').notNull().default(false),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('uq_jellyfin_servers_user_label').on(t.userId, t.label),
// At most one active server per owner, enforced by the DB rather than by convention: a partial unique
// index over the active rows only. setActiveJellyfinServer still clears the others in a transaction, but
// a bug there fails loudly here instead of silently leaving two active and the UI picking one.
uniqueIndex('uq_jellyfin_servers_one_active')
.on(t.userId)
.where(sql`${t.isActive}`),
],
);
-1
View File
@@ -48,7 +48,6 @@ export * from './service-connections/schema'; // service_connections
// export * from './notify/schema'; // push_devices officer-notify
// export * from './dav/schema'; // dav_app_passwords officer-caldav
// export * from './jellyfin/schema'; // jellyfin_servers officer-jellyfin
// export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
-21
View File
@@ -1,21 +0,0 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
// /api/jellyfin/* — auth, then forward to officer-jellyfin. No routes of its own and no Jellyfin knowledge:
// this file must never grow app logic.
//
// The sidecar owns the Jellyfin contract and holds its credentials.
//
// `timeoutSeconds` is generous because this proxy carries VIDEO. A direct-play stream holds one connection
// open for the length of the film, and the first request against a fresh transcode waits on ffmpeg starting
// up. The default 60s idle drop would cut both.
const proxy = createSidecarProxy({
name: 'jellyfin',
prefix: '/api/jellyfin',
timeoutSeconds: 3600,
});
export const jellyfinRouter = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getJellyfinServerUrl = proxy.getHttpUrl;
+1 -1
View File
@@ -94,7 +94,7 @@ describe('permissions it claims to back', () => {
describe('byId', () => {
it('finds a known entry and returns undefined for anything else', () => {
expect(byId('jellyfin')?.process).toBe('officer-jellyfin');
expect(byId('memos')?.process).toBe('officer-memos');
expect(byId('not-a-sidecar')).toBeUndefined();
});
});
-15
View File
@@ -169,21 +169,6 @@ export const CATALOGUE: CatalogueEntry[] = [
// bitten twice. `permissionAvailability()` derives from `sidecar_installs`, which a plugin never gets a
// row in, so a leftover entry would make the permission permanently `unavailable`, put `/photos` into
// `deniedRoutes`, and withhold the dock tile — on a server where the plugin is installed and working.
{
id: 'jellyfin',
ui: { name: 'Video', icon: 'Clapperboard', color: '#a855f7', rootRoute: '/jellyfin', routes: ['/jellyfin'] },
process: 'officer-jellyfin',
label: 'Jellyfin',
summary: 'Films and shows, with a player that handles direct, HLS and progressive',
members: 'accounts',
modes: ['existing', 'provisioned'],
permission: 'jellyfin',
composeTemplate: 'jellyfin',
existingFields: [
{ key: 'url', label: 'Jellyfin URL', type: 'url', required: true, placeholder: 'https://jellyfin.example.com' },
{ key: 'secret', label: 'Access token', type: 'secret', required: true },
],
},
{
id: 'memos',
ui: { name: 'Memos', icon: 'NotebookPen', color: '#eab308', rootRoute: '/memos', routes: ['/memos'] },
+24 -9
View File
@@ -1,19 +1,34 @@
import { describe, expect, it } from 'bun:test';
import { planSteps, runInstall, type InstallEffects, type StepName } from './installer';
import { byId } from './catalogue';
import { byId, type CatalogueEntry } from './catalogue';
// The whole install machine, tested without Docker, Postgres, PM2 or an Immich to talk to — which is
// the reason the effects are injected. What is being pinned here is the behaviour that only shows up
// when something goes wrong: resume, blocking, and not doing work twice.
const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts'
// A LITERAL, not `byId(...)`. This was `byId('slskd')` until slskd became `plugins/soulseek/`, and
// before that the same assertion had to be re-pointed when photos and transmission left. No catalogue
// entry today is both `provisioned` and `members: 'none'`, so there is nothing left to borrow — and
// borrowing was the mistake anyway: this pins `planSteps`, which is installer logic, and it should not
// break because an unrelated service was extracted. Shaped from jellyfin so the rest of the entry stays
// realistic.
const noMemberConcept = { ...byId('jellyfin')!, id: 'test-single-tenant', members: 'none' as const };
// FIXTURES ARE LITERALS, not catalogue lookups.
//
// These pin `planSteps` and `runInstall` — installer LOGIC. Binding them to catalogue CONTENTS meant
// every extraction broke them: photos and transmission on 2026-08-18, slskd on 2026-08-19, and jellyfin
// in the same hour. Four times is enough. A service leaving for `plugins/` says nothing about whether
// the installer still plans the right steps, so these no longer ask the catalogue anything.
//
// `catalogue.test.ts` is where real entries are asserted to be well-formed; that is the right place for
// it, and it fails loudly if the shape below ever stops resembling one.
const fullInstall: CatalogueEntry = {
id: 'test-full',
ui: { name: 'Test', icon: 'Box', color: '#000', rootRoute: '/test', routes: ['/test'] },
process: 'officer-test',
label: 'Test',
summary: 'A service with accounts and both install modes',
members: 'accounts',
modes: ['existing', 'provisioned'],
permission: 'test',
composeTemplate: 'test',
existingFields: [{ key: 'url', label: 'URL', type: 'url', required: true }],
};
const jellyfin = fullInstall; // was byId('jellyfin') until it became `plugins/jellyfin/`
const noMemberConcept: CatalogueEntry = { ...fullInstall, id: 'test-single-tenant', members: 'none' };
const configOnly = byId('notify')!; // config only
/** Records what was actually called, so a test can assert on absence as well as presence. */
+4 -4
View File
@@ -10,7 +10,7 @@ const inviteOnly = { members: 'invite' } as CatalogueEntry;
describe('what adding a member would do', () => {
it('predicts the outcome per service without contacting anything', () => {
expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned');
expect(plannedOutcome(byId('memos')!)).toBe('provisioned');
expect(plannedOutcome(inviteOnly)).toBe('invited');
expect(plannedOutcome(byId('vnc')!)).toBe('not-applicable');
});
@@ -18,13 +18,13 @@ describe('what adding a member would do', () => {
describe('which installed services have work to do', () => {
it('skips single-tenant daemons entirely', () => {
const ids = servicesNeedingMemberProvision(['jellyfin', 'vnc', 'memos']).map((e) => e.id);
expect(ids.sort()).toEqual(['jellyfin', 'memos']);
const ids = servicesNeedingMemberProvision(['invoiceshelf', 'vnc', 'memos']).map((e) => e.id);
expect(ids.sort()).toEqual(['invoiceshelf', 'memos']);
});
it('ignores services that are not installed', () => {
// The catalogue is what COULD be installed; only what IS installed has a member to provision.
expect(servicesNeedingMemberProvision([])).toEqual([]);
expect(servicesNeedingMemberProvision(['jellyfin']).map((e) => e.id)).toEqual(['jellyfin']);
expect(servicesNeedingMemberProvision(['memos']).map((e) => e.id)).toEqual(['memos']);
});
});
+2 -2
View File
@@ -12,8 +12,8 @@ describe('preflight is per mode, not per entry', () => {
it('allows pointing at an existing instance without Docker', async () => {
// The whole point: a host with no Docker can still use Photos against an Immich elsewhere.
// Refusing the entry outright is the over-strict check that makes people bypass the installer.
const jellyfin = byId('jellyfin')!;
expect(await preflight(jellyfin, 'existing')).toEqual({ ok: true });
const existingCapable = byId('memos')!;
expect(await preflight(existingCapable, 'existing')).toEqual({ ok: true });
});
it('allows a config-only sidecar regardless', async () => {
-2
View File
@@ -25,7 +25,6 @@ import { pluginsRouter } from './api/plugins/router';
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
import { agentHandoffRouter } from './api/agent-handoff/router';
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
// import { jellyfinRouter } from './api/jellyfin/router';
// import { walletRouter } from './api/wallet/router';
import { terminalRouter } from './api/terminal/sidecar-server';
// import { caldavRouter } from './api/dav/sidecar-server';
@@ -149,7 +148,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
// ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13
// ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
// ['/wallet', walletRouter], // plugin — switched off 2026-08-13
['/system-monitor', systemMonitorRouter],
-9
View File
@@ -161,15 +161,6 @@ const CORE_REGISTRY: Permission[] = [
// commenting on their own issues — security theatre with a real cost and no benefit.
personal: ['/'],
},
{
key: 'jellyfin',
label: 'Video',
description: 'Browse and play the Jellyfin library',
kind: 'app',
// api: ['/jellyfin'], // plugin — switched off 2026-08-13
api: [],
routes: ['/jellyfin'],
},
{
key: 'memos',
label: 'Memos',
+21
View File
@@ -170,6 +170,27 @@ const BUNDLED: CatalogueEntry[] = [
// favourites and the cached browse tree are this plugin's own three tables.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'jellyfin',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Video',
summary: 'Films and shows, with a player that handles direct, HLS and progressive',
color: '#a855f7',
icon: 'Clapperboard',
source: 'https://gitea.officer.dev/plugins/jellyfin.git',
permissions: [
{
key: 'jellyfin',
label: 'Video',
description: 'Browse and play the Jellyfin library',
},
],
// Fully self-contained: `jellyfin_servers` is a registry this plugin owns outright, because more
// than one Jellyfin is a thing someone has.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'vault',
publisher: 'officerdev',
-249
View File
@@ -1,249 +0,0 @@
import type { JellyfinServer } from 'officerdb';
import {
createJellyfinServer,
deleteJellyfinServer,
getJellyfinCredentials,
listJellyfinServers,
recordJellyfinProbe,
setActiveJellyfinServer,
updateJellyfinServer,
} from 'officerdb';
import { UpstreamError, authenticate, invalidateConfig, normalizeBase, probe, publicInfo } from './upstream';
// `/_config` — the Jellyfin server registry, driven from the app.
//
// The access token is WRITE-ONLY across this boundary, and it is never even written directly: the owner
// supplies a URL, a username and a password, and the sidecar trades them for a token through
// `POST /Users/AuthenticateByName`. The password lives for the duration of that one call. The list route
// reports label, URL, account name, server name and version — it has no field that could carry a token,
// masked or otherwise.
//
// A save is validated against the live instance before it is stored, in two steps that answer two different
// questions: `/System/Info/Public` says "this URL is a Jellyfin, and it is called X", and the sign-in says
// "these credentials work on it". Reporting them separately is what makes a typo'd port distinguishable from
// a wrong password in the setup form.
const bad = (error: string, status = 400) => Response.json({ error }, { status });
/** What the browser is allowed to know about the registry. Never includes a token. */
async function serverList(userId: number): Promise<Response> {
const servers = await listJellyfinServers(userId);
const active = servers.find((server) => server.isActive) ?? null;
return Response.json({ configured: !!active, activeId: active?.id ?? null, servers });
}
/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */
export async function noteProbe(userId: number, id: number, version: string | null): Promise<void> {
await recordJellyfinProbe(userId, id, version).catch(() => {
/* a stale lastSeenAt is not worth failing a request over */
});
}
type ServerBody = { label?: unknown; url?: unknown; username?: unknown; password?: unknown };
const readBody = async (req: Request): Promise<ServerBody> =>
((await req.json().catch(() => null)) as ServerBody | null) ?? {};
const readLabel = (body: ServerBody): string => (typeof body.label === 'string' ? body.label.trim() : '');
const readUrl = (body: ServerBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : '');
const readUsername = (body: ServerBody): string => (typeof body.username === 'string' ? body.username.trim() : '');
const readPassword = (body: ServerBody): string => (typeof body.password === 'string' ? body.password : '');
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
/** Sign-in and reachability failures, in the words of what the owner actually did. */
function upstreamError(err: unknown): string {
if (err instanceof UpstreamError) return err.message;
return `could not reach that URL (${err instanceof Error ? err.message : String(err)})`;
}
const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_jellyfin_servers_user_label');
/**
* Add a server: the URL is identified, the credentials are traded for a token, then the token is stored
* encrypted and the password is dropped.
*
* A fresh `DeviceId` is minted here and only here. It is what Jellyfin keys sessions and remembered devices
* by, so it belongs to the ROW, not to a request — see schema/jellyfin.ts.
*/
async function addServer(req: Request, userId: number): Promise<Response> {
const body = await readBody(req);
const url = readUrl(body);
const username = readUsername(body);
const password = readPassword(body);
let label = readLabel(body);
if (!url) return bad('url is required');
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
if (!username) return bad('username is required');
if (!password) return bad('password is required');
let info;
try {
info = await publicInfo(url);
} catch (err) {
return bad(upstreamError(err));
}
const deviceId = crypto.randomUUID();
let auth;
try {
auth = await authenticate(url, deviceId, username, password);
} catch (err) {
return bad(upstreamError(err));
}
// An unlabelled server takes the instance's own name, which is nearly always what the owner would type.
// Falling back to the host keeps the switcher readable when the server is unnamed.
if (!label) label = info.serverName ?? new URL(url).host;
// The first server wins the selection: a registry with rows but nothing selected reads as "not connected".
const existing = await listJellyfinServers(userId);
const activate = existing.length === 0;
try {
const server = await createJellyfinServer({
userId,
label,
url,
accessToken: auth.token,
jellyfinUserId: auth.userId,
jellyfinUsername: auth.username ?? username,
deviceId,
serverName: info.serverName,
version: info.version,
activate,
});
invalidateConfig(userId);
return Response.json({ server });
} catch (err) {
if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`);
throw err;
}
}
/**
* Edit one server. No password means "keep the stored token", so a rename never needs one re-typed.
*
* Re-authenticating deliberately keeps the row's existing `deviceId`: the point of signing in again is
* usually that the token was revoked, and reusing the device id keeps the new session attached to the same
* entry in Jellyfin's device list instead of adding a second ghost.
*/
async function editServer(req: Request, userId: number, id: number): Promise<Response> {
const body = await readBody(req);
const label = readLabel(body);
const url = readUrl(body);
const username = readUsername(body);
const password = readPassword(body);
const current = await getJellyfinCredentials(userId, id);
if (!current) return bad('no such server', 404);
if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');
const base = url || normalizeBase(current.url);
let accessToken: string | undefined;
let jellyfinUserId: string | undefined;
let jellyfinUsername: string | null | undefined;
let serverName: string | null | undefined;
let version: string | null | undefined;
if (password) {
if (!username) return bad('username is required to sign in again');
try {
const info = await publicInfo(base);
const auth = await authenticate(base, current.deviceId, username, password);
accessToken = auth.token;
jellyfinUserId = auth.userId;
jellyfinUsername = auth.username ?? username;
serverName = info.serverName;
version = info.version;
} catch (err) {
return bad(upstreamError(err));
}
} else if (url && base !== normalizeBase(current.url)) {
// Moving a server to a new URL without re-authenticating is allowed — a reverse proxy in front of the
// same instance keeps the token valid — but it has to be checked, because pointing at a DIFFERENT
// Jellyfin would leave a row whose token belongs to another server.
const result = await probe({ ...current, base, token: current.accessToken });
if (!result.ok) return bad(result.error ?? 'that URL did not accept the stored token');
serverName = result.serverName;
version = result.version;
}
try {
const server = await updateJellyfinServer(userId, id, {
label: label || undefined,
url: url || undefined,
accessToken,
jellyfinUserId,
jellyfinUsername,
serverName,
version,
});
if (!server) return bad('no such server', 404);
invalidateConfig(userId);
return Response.json({ server });
} catch (err) {
if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`);
throw err;
}
}
/** Check one stored server without switching to it — what the "test" button on each row calls. */
async function testServer(userId: number, id: number): Promise<Response> {
const creds = await getJellyfinCredentials(userId, id);
if (!creds) return bad('no such server', 404);
const started = Date.now();
const result = await probe({ ...creds, base: normalizeBase(creds.url), token: creds.accessToken });
const ms = Date.now() - started;
if (!result.ok) return Response.json({ ok: false, error: result.error, ms }, { status: 502 });
await noteProbe(userId, id, result.version);
return Response.json({ ok: true, version: result.version, serverName: result.serverName, ms });
}
export type { JellyfinServer };
/** `subpath` is '' for /_config, or '/<id>', '/<id>/activate', '/<id>/test'. */
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
const [, rawId, action] = subpath.split('/');
if (!rawId) {
if (req.method === 'GET') return serverList(userId);
if (req.method === 'POST' || req.method === 'PUT') return addServer(req, userId);
return bad('method not allowed', 405);
}
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) return bad('invalid server id', 404);
if (action === 'activate') {
if (req.method !== 'POST') return bad('method not allowed', 405);
const server = await setActiveJellyfinServer(userId, id);
if (!server) return bad('no such server', 404);
invalidateConfig(userId);
return serverList(userId);
}
// GET only. It probes one server and reports whether it answered — nothing is written, and nothing is
// switched (that is `activate`, below). It accepted POST as well until 2026-08-06, which made the
// method meaningless for deciding whether this is a read; the permission model needs that answer.
if (action === 'test') {
if (req.method !== 'GET') return bad('method not allowed', 405);
return testServer(userId, id);
}
if (action) return bad('not found', 404);
if (req.method === 'PATCH' || req.method === 'PUT') return editServer(req, userId, id);
if (req.method === 'DELETE') {
const removed = await deleteJellyfinServer(userId, id);
if (!removed) return bad('no such server', 404);
invalidateConfig(userId);
return serverList(userId);
}
return bad('method not allowed', 405);
}
-174
View File
@@ -1,174 +0,0 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleConfigRoute, noteProbe } from './config';
import { handleBytesRoute, handleOfficerRoute } from './routes';
import { getConfig, probe } from './upstream';
import { API_URL } from '../../officer-url.mjs';
// The officer-jellyfin sidecar. Owns the whole Jellyfin contract for Officer: the instance URL, the access
// token, the Jellyfin user it belongs to and the DeviceId its sessions are keyed by. The platform API is a
// thin auth-gated forwarder (src/servers/api/jellyfin/router.ts) holding no Jellyfin credentials.
//
// VIDEO ONLY, deliberately. This machine runs four Jellyfin instances — video, albums, DJ sets, and a second
// person's — and Officer already has a music player of its own. The library list filters music collections
// out (routes.ts, VIDEO_COLLECTION_TYPES) rather than showing two competing answers to "where is my music".
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/jellyfin mount prefix before forwarding.
//
// GET /_health ours. Confirms the stored token is still accepted.
// GET /_config the server registry MINUS every token
// POST /_config { label?, url, username, password } — the password is traded once
// for an access token and never stored
// PATCH /_config/:id same fields, all optional; no password keeps the stored token
// POST /_config/:id/activate switch to that server
// GET /_config/:id/test probe one server without switching to it
// DEL /_config/:id remove it; the newest survivor is promoted if it was active
//
// GET /_officer/home views + resume + next-up + latest-per-view, one round trip
// GET /_officer/views the video libraries
// 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, seekable, hlsUrl, playSessionId }
// POST|DEL /_officer/items/:id/played watched mark
// POST|DEL /_officer/items/:id/favorite
// GET /_officer/shows/:id/seasons
// GET /_officer/shows/:id/episodes?seasonId=
// GET /_officer/resume | /_officer/nextup | /_officer/genres
// GET /_officer/search?q=
// POST /_officer/sessions/playing|progress|stopped playback reporting, bodies forwarded as sent
//
// GET /_jf/* authenticated byte pass-through: images, video, HLS, subtitles.
// Allow-listed prefixes only — see routes.ts for why it is not a
// general proxy, and why HLS forces it to keep Jellyfin's own paths.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Playback reporting posts small JSON; nothing is uploaded to Jellyfin from here. The default cap would do,
// but a modest explicit one documents that this sidecar is a reader.
maxRequestBodySize: 4 * 1024 * 1024,
// A transcode start can take a while to answer its first playlist request while ffmpeg spins up, and the
// segment requests that follow are long-lived byte streams.
idleTimeout: 255,
async fetch(req) {
const url = new URL(req.url);
const officerUser = req.headers.get('X-Officer-User');
const userId = Number(officerUser);
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
}
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
try {
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
} catch (err) {
console.error(`[jellyfin] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
const cfg = await getConfig(userId);
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
// configured-but-broken instance is the whole reason the flag is on the response.
if (url.pathname === '/_health') {
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
const started = Date.now();
const result = await probe(cfg);
const ms = Date.now() - started;
if (!result.ok) {
return Response.json(
{ ok: false, configured: true, server: cfg.label, error: result.error, ms },
{ status: 502 },
);
}
await noteProbe(userId, cfg.id, result.version);
return Response.json({
ok: true,
configured: true,
server: cfg.label,
serverName: result.serverName,
version: result.version,
ms,
});
}
const isOfficer = url.pathname.startsWith('/_officer/');
const isBytes = url.pathname.startsWith('/_jf/');
if (isOfficer || isBytes) {
if (!cfg) return Response.json({ error: 'jellyfin not connected', configured: false }, { status: 503 });
try {
const res = isOfficer ? await handleOfficerRoute(cfg, req, url) : await handleBytesRoute(cfg, req, url);
if (res) return res;
return Response.json({ error: 'not found' }, { status: 404 });
} catch (err) {
console.error(`[jellyfin] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
console.log(`[jellyfin] listening on 127.0.0.1:${port} (server configured from the UI, stored in jellyfin_servers)`);
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'jellyfin',
handles: ['jellyfin'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'jellyfin:server', port });
console.log(`[jellyfin] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[jellyfin] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
-85
View File
@@ -1,85 +0,0 @@
// 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;
-753
View File
@@ -1,753 +0,0 @@
import type { UpstreamConfig } from './upstream';
import { DEVICE_PROFILE } from './profile';
import { callUpstream } from './upstream';
// The two halves of the Jellyfin surface Officer exposes.
//
// `/_officer/*` — a small hand-written façade. Every route here exists because the raw Jellyfin call needs
// something the browser should not have to know: the user id in the path, a Fields list that decides whether
// a grid has posters, or a PlaybackInfo negotiation. It is JSON in, JSON out.
//
// `/_jf/*` — a transparent, authenticated, GET-only pass-through for BYTES: images, video, HLS playlists and
// segments, subtitles. It exists because HLS cannot be faked. A master playlist references its variant
// playlists and every segment RELATIVELY, so the URLs a browser derives from it must resolve back onto the
// same prefix; any façade that renamed those paths would have to rewrite the playlists on the way out, and
// rewriting m3u8 bodies to keep a prettier URL scheme is a trade nobody should take.
//
// The pass-through is deny-by-default over a prefix allow-list. Jellyfin's API is the whole server —
// `/System/*`, `/ScheduledTasks/*`, `/Plugins/*`, user administration, the setup wizard — and the token this
// sidecar holds belongs to a real account, so an "everything under /_jf" proxy would put library scanning and
// user creation one URL away from the browser.
//
// One thing here is a security fix rather than plumbing: `TranscodingUrl` comes back from Jellyfin with
// `api_key=<the access token>` embedded in its query string. That URL is handed to the video element, so it
// would put the token in the DOM, in history and in any log that records URLs. It is stripped in
// `sanitizeUpstreamPath` before anything is returned, and the pass-through re-adds the credential as a header
// where it belongs.
/** Ticks are 100-nanosecond units. Jellyfin speaks these everywhere; the UI speaks seconds. */
const TICKS_PER_SECOND = 10_000_000;
const json = (data: unknown, status = 200) => Response.json(data, { status });
const bad = (error: string, status = 400) => json({ error }, status);
/** Pass a Jellyfin JSON response through, keeping its status so a 404 upstream stays a 404 here. */
async function relayJson(res: Response): Promise<Response> {
const text = await res.text();
return new Response(text, {
status: res.status,
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
});
}
/** GET a Jellyfin path as parsed JSON, or throw with the upstream status attached. */
async function fetchJson<T>(cfg: UpstreamConfig, path: string, params: Record<string, string | undefined>): Promise<T> {
const res = await callUpstream(cfg, { path, query: buildQuery(params) });
if (!res.ok) throw new Error(`${path} → ${res.status}`);
return (await res.json()) as T;
}
function buildQuery(params: Record<string, string | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value != null && value !== '') search.set(key, value);
}
const qs = search.toString();
return qs ? `?${qs}` : '';
}
// The Fields lists. A grid without ImageTags renders grey rectangles, which is the single most common way a
// Jellyfin client looks broken, so the poster-bearing fields are not optional anywhere.
const GRID_FIELDS = 'PrimaryImageAspectRatio,BasicSyncInfo,ProductionYear,Status,EndDate';
const DETAIL_FIELDS =
'Overview,Genres,Studios,People,Taglines,ProductionYear,OfficialRating,CommunityRating,MediaSources,MediaStreams,Chapters,ExternalUrls,RemoteTrailers,PrimaryImageAspectRatio,ParentId,SeriesStudio';
/**
* The libraries worth showing.
*
* Music is filtered OUT on purpose and not as a stylistic choice: the owner's audio lives in Officer's own
* /music player, and this machine runs separate Jellyfin instances for albums and DJ sets. A Jellyfin panel
* that also listed those would be two music libraries competing for the same job.
*/
const VIDEO_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'homevideos', 'musicvideos', 'boxsets', 'playlists']);
type JellyfinItem = { Id?: string; Name?: string; CollectionType?: string; Type?: string };
type ItemsResponse = { Items?: JellyfinItem[]; TotalRecordCount?: number };
async function videoViews(cfg: UpstreamConfig): Promise<JellyfinItem[]> {
const data = await fetchJson<ItemsResponse>(cfg, `/Users/${cfg.jellyfinUserId}/Views`, {});
return (data.Items ?? []).filter((view) => !view.CollectionType || VIDEO_COLLECTION_TYPES.has(view.CollectionType));
}
/**
* The home screen in ONE round trip: libraries, what is half-watched, what is next, and the newest thing in
* each library. Jellyfin needs four different calls for that, and issuing them from the browser would mean
* four proxy hops and a screen that assembles itself in visible stages.
*
* Latest-per-library is fetched concurrently and failures are swallowed per library: one library that is
* mid-scan should cost its own shelf, not the whole page.
*/
async function home(cfg: UpstreamConfig): Promise<Response> {
const views = await videoViews(cfg);
const [resume, nextUp, latest] = await Promise.all([
fetchJson<ItemsResponse>(cfg, '/UserItems/Resume', {
userId: cfg.jellyfinUserId,
limit: '12',
mediaTypes: 'Video',
fields: GRID_FIELDS,
enableTotalRecordCount: 'false',
}).catch(() => ({ Items: [] })),
fetchJson<ItemsResponse>(cfg, '/Shows/NextUp', {
userId: cfg.jellyfinUserId,
limit: '12',
fields: GRID_FIELDS,
enableTotalRecordCount: 'false',
}).catch(() => ({ Items: [] })),
Promise.all(
views.map(async (view) => ({
viewId: view.Id ?? '',
viewName: view.Name ?? '',
collectionType: view.CollectionType ?? null,
items: await fetchJson<JellyfinItem[]>(cfg, `/Users/${cfg.jellyfinUserId}/Items/Latest`, {
parentId: view.Id,
limit: '12',
fields: GRID_FIELDS,
}).catch(() => []),
})),
),
]);
return json({ views, resume: resume.Items ?? [], nextUp: nextUp.Items ?? [], latest });
}
// What a browse grid is allowed to ask for. Deny-by-default again — `/Items` accepts filters that reach
// outside the library (`path`, `userId`) and this list is what keeps the query the UI's business only.
const ITEM_QUERY_PARAMS = [
'parentId',
'includeItemTypes',
'excludeItemTypes',
'recursive',
'sortBy',
'sortOrder',
'startIndex',
'limit',
'searchTerm',
'filters',
'genres',
'genreIds',
'years',
'officialRatings',
'tags',
'studioIds',
'personIds',
'isPlayed',
'isFavorite',
'nameStartsWith',
'imageTypeLimit',
'enableImageTypes',
'collapseBoxSetItems',
] as const;
function passthroughItemQuery(url: URL): Record<string, string | undefined> {
const params: Record<string, string | undefined> = {};
for (const key of ITEM_QUERY_PARAMS) {
const value = url.searchParams.get(key);
if (value != null) params[key] = value;
}
return params;
}
/** A browse grid. Defaults to recursive video items so a library id is enough to get something sensible. */
async function items(cfg: UpstreamConfig, url: URL): Promise<Response> {
const params = passthroughItemQuery(url);
const res = await callUpstream(cfg, {
path: '/Items',
query: buildQuery({
userId: cfg.jellyfinUserId,
recursive: params.recursive ?? 'true',
includeItemTypes: params.includeItemTypes ?? 'Movie,Series,Video',
sortBy: params.sortBy ?? 'SortName',
sortOrder: params.sortOrder ?? 'Ascending',
limit: params.limit ?? '100',
fields: GRID_FIELDS,
imageTypeLimit: '1',
enableImageTypes: 'Primary,Backdrop,Thumb,Logo',
...params,
}),
});
return relayJson(res);
}
/** One item, with everything the detail page draws — including MediaSources, which drive the codec line. */
async function item(cfg: UpstreamConfig, id: string): Promise<Response> {
const res = await callUpstream(cfg, {
path: `/Items/${encodeURIComponent(id)}`,
query: buildQuery({ userId: cfg.jellyfinUserId, fields: DETAIL_FIELDS }),
});
return relayJson(res);
}
type SourceStream = {
Type?: string;
Index?: number;
Codec?: string;
Profile?: string;
Level?: number;
Width?: number;
Height?: number;
BitRate?: number;
Channels?: number;
Language?: string | null;
DisplayTitle?: string | null;
Title?: string | null;
IsDefault?: boolean;
IsForced?: boolean;
IsExternal?: boolean;
IsTextSubtitleStream?: boolean;
/** Jellyfin's own verdict for THIS profile: External (a file we can fetch), Embed, Encode, or None. */
DeliveryMethod?: string | null;
DeliveryUrl?: string | null;
};
type MediaSource = {
Id?: string;
Container?: string;
RunTimeTicks?: number;
SupportsDirectPlay?: boolean;
SupportsDirectStream?: boolean;
DirectStreamUrl?: string;
TranscodingUrl?: string;
MediaStreams?: SourceStream[];
};
type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null };
/**
* Strip the credential Jellyfin embeds in the URLs it hands back.
*
* `TranscodingUrl` arrives as `/videos/…/main.m3u8?…&api_key=<access token>&…`. Returning it untouched would
* publish the token to the browser. The pass-through supplies the token as a header on every hop, so the
* parameter is not merely unsafe, it is redundant.
*/
function sanitizeUpstreamPath(raw: string): string {
const [path, search = ''] = raw.split('?');
const params = new URLSearchParams(search);
params.delete('api_key');
params.delete('ApiKey');
params.delete('X-Emby-Token');
const qs = params.toString();
return `${path}${qs ? `?${qs}` : ''}`;
}
/**
* A ceiling high enough to never be the binding constraint on a stream copy. It matches the profile's
* `MaxStreamingBitrate`: the request has to name a number, and for a copy the right number is "more than
* whatever this file is", since Jellyfin refuses to copy a stream that exceeds the requested bitrate.
*/
const COPY_CEILING_BITRATE = 120_000_000;
/** What a real CPU re-encode targets. 1080p at 12 Mbit is visually generous for h264 at the crf Jellyfin uses. */
const ENCODE_BITRATE = 12_000_000;
const ENCODE_MAX_WIDTH = 1920;
/**
* The rungs the quality picker offers, widest first. A bitrate alone is not a quality: Jellyfin's own
* `ResolutionNormalizer` would pair a low one with a resolution of its choosing, and the whole 416-pixel
* incident was that mapping being applied to a number nobody meant. Naming both here is what makes
* "720p" mean 720p.
*/
export const QUALITY_RUNGS = [
{ label: '1080p', videoBitrate: 12_000_000, maxWidth: 1920 },
{ label: '720p', videoBitrate: 6_000_000, maxWidth: 1280 },
{ label: '480p', videoBitrate: 2_500_000, maxWidth: 854 },
{ label: '360p', videoBitrate: 1_000_000, maxWidth: 640 },
] as const;
/**
* Decide the bitrate and resolution cap to ask for, from what is actually in the file.
*
* With no `requested` ceiling — the "Auto" the player sends by default — the two cases pull in opposite
* directions, which is why this is not one constant:
*
* video can be copied → any cap is a reason for Jellyfin to REFUSE the copy (it will not copy a stream
* above the requested bitrate, or wider than a requested `maxWidth`), so ask for a
* ceiling nothing hits and no width at all. The bytes are passed through untouched.
* video is re-encoded → a cap is the only thing keeping ffmpeg tractable. There is no /dev/dri in the
* Jellyfin container, so 4K HDR HEVC is being decoded, tonemapped and encoded on the
* CPU; downscaling to 1080p is what makes that keep up with playback.
*
* The copy test mirrors the profile's own conditions rather than a new opinion: h264, not 10-bit, level ≤ 5.2.
*
* A `requested` ceiling overrides both, and deliberately defeats the copy: picking 480p on a file that would
* have passed through untouched is asking for a re-encode, and that is the entire point of picking it.
*/
export type TranscodeQuality = { videoBitrate: number; maxWidth?: number };
function transcodeQuality(source: MediaSource, requested?: number): TranscodeQuality {
if (requested && requested < COPY_CEILING_BITRATE) {
const rung = QUALITY_RUNGS.find((r) => r.videoBitrate <= requested) ?? QUALITY_RUNGS[QUALITY_RUNGS.length - 1]!;
return { videoBitrate: rung.videoBitrate, maxWidth: rung.maxWidth };
}
const video = source.MediaStreams?.find((stream) => stream.Type === 'Video');
const copyable =
video?.Codec?.toLowerCase() === 'h264' && video.Profile?.toLowerCase() !== 'high 10' && (video.Level ?? 0) <= 52;
if (copyable) return { videoBitrate: COPY_CEILING_BITRATE };
return { videoBitrate: ENCODE_BITRATE, maxWidth: ENCODE_MAX_WIDTH };
}
/** `DisplayTitle` is what Jellyfin shows in its own picker; the rest is a legible fallback when it is absent. */
function trackLabel(stream: SourceStream, fallback: string): string {
const parts = [stream.DisplayTitle || stream.Title, stream.Language, stream.Codec?.toUpperCase()].filter(Boolean);
return (parts[0] as string) || parts.join(' · ') || fallback;
}
export type AudioTrack = {
index: number;
label: string;
language: string | null;
codec: string | null;
channels: number | null;
isDefault: boolean;
};
export type SubtitleTrack = {
index: number;
label: string;
language: string | null;
codec: string | null;
isDefault: boolean;
isForced: boolean;
/** A `/_jf` path to a WebVTT file, or null when this track cannot be delivered as one. */
url: string | null;
/** Why `url` is null, in words the picker can show. Null when the track is fine. */
unavailable: string | null;
};
/**
* Turn the source's streams into the two pickers the player offers.
*
* Subtitles are the interesting half. Jellyfin has already decided, for OUR profile, how each one could be
* delivered, and `DeliveryMethod: 'External'` with a `DeliveryUrl` means "fetch this as a file" — which for
* the profile's declared formats is WebVTT the `<track>` element reads directly. That is the only method
* taken. `Encode` means burning the subtitle into the picture, which forces a full video re-encode of a file
* that may have needed nothing but a remux, so an image-based track (PGS, VOBSUB) is reported as unavailable
* WITH ITS REASON rather than quietly dropped — "why is this subtitle missing" should be answerable from the
* picker instead of from the server logs.
*/
function describeTracks(source: MediaSource): { audio: AudioTrack[]; subtitles: SubtitleTrack[] } {
const streams = source.MediaStreams ?? [];
const audio = streams
.filter((stream) => stream.Type === 'Audio' && stream.Index !== undefined)
.map((stream) => ({
index: stream.Index!,
label: trackLabel(stream, `Track ${stream.Index}`),
language: stream.Language ?? null,
codec: stream.Codec ?? null,
channels: stream.Channels ?? null,
isDefault: stream.IsDefault === true,
}));
const subtitles = streams
.filter((stream) => stream.Type === 'Subtitle' && stream.Index !== undefined)
.map((stream) => {
const external = stream.DeliveryMethod === 'External' && !!stream.DeliveryUrl;
const graphical = stream.IsTextSubtitleStream === false;
return {
index: stream.Index!,
label: trackLabel(stream, `Subtitle ${stream.Index}`),
language: stream.Language ?? null,
codec: stream.Codec ?? null,
isDefault: stream.IsDefault === true,
isForced: stream.IsForced === true,
url: external ? `/_jf${sanitizeUpstreamPath(stream.DeliveryUrl!)}` : null,
unavailable: external
? null
: graphical
? 'image-based subtitles can only be burned into the video, which forces a full re-encode'
: `the server offered no external file for this track (delivery: ${stream.DeliveryMethod ?? 'none'})`,
};
});
return { audio: disambiguate(audio), subtitles: disambiguate(subtitles) };
}
/**
* Make every label in a list distinguishable.
*
* Two subtitle tracks on the same file genuinely do come back as "English - ASS" and "English - ASS" — one is
* usually dialogue and the other signs, and Jellyfin's own `DisplayTitle` does not say which. A picker with
* two identical rows is a coin flip, so colliding labels get their stream index, which at least makes the
* choice repeatable.
*/
function disambiguate<T extends { index: number; label: string }>(tracks: T[]): T[] {
const counts = new Map<string, number>();
for (const track of tracks) counts.set(track.label, (counts.get(track.label) ?? 0) + 1);
return tracks.map((track) =>
counts.get(track.label)! > 1 ? { ...track, label: `${track.label} #${track.index}` } : track,
);
}
/**
* Fix up the HLS URL Jellyfin handed back so it carries the same quality decision as the progressive one.
*
* `TranscodingUrl` is built by the server from our `DeviceProfile`, which is why it is taken rather than
* reinvented: it already carries the segment container, the audio codec list, the play session and the
* transcode reasons, and every one of those is the server's business rather than ours. What it gets wrong for
* this machine is exactly two values — it resolves `VideoBitrate` from `MaxStreamingBitrate` (~119 Mbit, the
* ceiling that exists to let a stream COPY through) and never sets a width. For a file that can be copied that
* is right. For a file that must be re-encoded it asks a CPU-only container to encode 4K, which cannot keep up
* with playback. So those two are overwritten from `transcodeQuality`, and nothing else is touched.
*
* Verified against a real answer from this server: the URL comes back with `VideoBitrate=119616000` and no
* width, on an item whose transcode reason was `ContainerNotSupported` alone.
*/
function hlsPath(source: MediaSource, quality: TranscodeQuality): string {
const sanitized = sanitizeUpstreamPath(source.TranscodingUrl!);
const [path, search = ''] = sanitized.split('?');
const params = new URLSearchParams(search);
// Jellyfin's own casing here is PascalCase, and its query parsing is case-insensitive — but a duplicate key
// under a different case would still be two values, so delete before setting rather than trusting that.
for (const key of [...params.keys()]) {
const lower = key.toLowerCase();
if (lower === 'videobitrate' || lower === 'maxwidth' || lower === 'maxheight') params.delete(key);
}
params.set('VideoBitrate', String(quality.videoBitrate));
if (quality.maxWidth) params.set('MaxWidth', String(quality.maxWidth));
return `${path}?${params.toString()}`;
}
/**
* Build the PROGRESSIVE transcode URL — one continuous mp4 the `<video>` element can play with no library.
*
* This is the FALLBACK now, not the primary: the player prefers `hlsUrl`, whose playlist covers the whole
* runtime and therefore seeks properly. This path stays for the browser that cannot do MSE at all (iOS
* Safari's native HLS covers itself, but a stripped-down webview may have neither), and it is why the plan
* still carries a plain `url`.
*
* 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 when it falls back here.
*
* `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.
*
* `videoBitrate` IS NOT OPTIONAL, and omitting it is not "let the server choose". Jellyfin resolves a missing
* bitrate to 0 and then runs `ResolutionNormalizer` over it, which maps a bitrate to a resolution — 0 lands on
* the bottom rung. Verified on this machine: without it, a 3840x1600 source transcoded through
* `scale=…min(max(iw,ih*a),416)…` with `-maxrate 0`, i.e. 416 pixels wide. That is the whole of the "why does
* it look so bad" question, and it is a silent, plausible-looking picture rather than an error.
*/
type ProgressiveParams = {
id: string;
source: MediaSource;
playSessionId: string | null;
startSeconds: number;
quality: TranscodeQuality;
};
function progressivePath({ id, source, playSessionId, startSeconds, quality }: ProgressiveParams): 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',
videoBitrate: String(quality.videoBitrate),
maxWidth: quality.maxWidth ? String(quality.maxWidth) : undefined,
// 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. 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, and is what the player actually uses for a
* transcode — hls.js is vendored for it (`apps/Jellyfin/vendor/`). `url` remains the progressive fallback for
* a browser with no MSE, so both are carried and the player chooses.
*/
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0');
const body = (await req.json().catch(() => ({}))) as {
mediaSourceId?: string;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
maxStreamingBitrate?: number;
};
const res = await callUpstream(cfg, {
path: `/Items/${encodeURIComponent(id)}/PlaybackInfo`,
method: 'POST',
query: buildQuery({ userId: cfg.jellyfinUserId }),
contentType: 'application/json',
body: JSON.stringify({
DeviceProfile: DEVICE_PROFILE,
UserId: cfg.jellyfinUserId,
MaxStreamingBitrate: body.maxStreamingBitrate ?? DEVICE_PROFILE.MaxStreamingBitrate,
StartTimeTicks: Math.max(0, Math.round(startSeconds * TICKS_PER_SECOND)),
MediaSourceId: body.mediaSourceId,
AudioStreamIndex: body.audioStreamIndex,
SubtitleStreamIndex: body.subtitleStreamIndex,
EnableDirectPlay: true,
EnableDirectStream: true,
EnableTranscoding: true,
AllowVideoStreamCopy: true,
AllowAudioStreamCopy: true,
// Without this the transcode is only prepared, not started, and the first segment request 404s.
AutoOpenLiveStream: true,
}),
});
if (!res.ok) return relayJson(res);
const info = (await res.json()) as PlaybackInfoResponse;
const source = info.MediaSources?.find((s) => s.Id === body.mediaSourceId) ?? info.MediaSources?.[0];
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 quality = transcodeQuality(source, body.maxStreamingBitrate);
const staticPath = `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
static: 'true',
mediaSourceId: source.Id,
playSessionId: playSessionId ?? undefined,
})}`;
const path = direct
? 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, quality })
: null;
if (!path) {
return bad(info.ErrorCode ? `playback refused: ${info.ErrorCode}` : 'the server offered no way to play this', 502);
}
return json({
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}`,
// 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,
// The seekable form of a transcode: the playlist spans the whole runtime, so a scrub is a segment request
// rather than a new ffmpeg. Preferred by the player whenever it is present and MSE is available.
hlsUrl: source.TranscodingUrl ? `/_jf${hlsPath(source, quality)}` : null,
playSessionId,
mediaSourceId: source.Id ?? null,
mediaSource: source,
startSeconds,
runtimeSeconds: source.RunTimeTicks ? source.RunTimeTicks / TICKS_PER_SECOND : null,
// The pickers. Audio needs a re-negotiation to change (it is muxed into the stream); subtitles do not,
// because an external VTT is a separate file the <track> element fetches on its own.
...describeTracks(source),
audioStreamIndex: body.audioStreamIndex ?? null,
/** Null means Auto — the copy-or-encode decision above, rather than a rung the owner picked. */
maxStreamingBitrate: body.maxStreamingBitrate ?? null,
qualityRungs: QUALITY_RUNGS,
});
}
/** Playback reporting — start, heartbeat, stop. Bodies are the upstream's own shapes, forwarded as sent. */
async function reportPlaystate(cfg: UpstreamConfig, action: string, req: Request): Promise<Response> {
const path =
action === 'playing'
? '/Sessions/Playing'
: action === 'progress'
? '/Sessions/Playing/Progress'
: action === 'stopped'
? '/Sessions/Playing/Stopped'
: null;
if (!path) return bad('not found', 404);
const body = await req.text();
const res = await callUpstream(cfg, { path, method: 'POST', contentType: 'application/json', body });
// 204 with no body is the normal answer; relaying it as JSON would invent content that is not there.
return new Response(null, { status: res.status });
}
/** Watched and favourite marks. POST sets, DELETE clears — the same shape Jellyfin's own routes use. */
async function userItemFlag(cfg: UpstreamConfig, kind: 'played' | 'favorite', id: string, method: string) {
const base = kind === 'played' ? '/UserPlayedItems' : '/UserFavoriteItems';
const res = await callUpstream(cfg, {
path: `${base}/${encodeURIComponent(id)}`,
method: method === 'DELETE' ? 'DELETE' : 'POST',
query: buildQuery({ userId: cfg.jellyfinUserId }),
});
return relayJson(res);
}
/** The `/_officer/*` façade. Returns null when nothing matched, so index.ts can answer a single 404. */
export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
const [head, a, b] = url.pathname.slice('/_officer/'.length).split('/');
const method = req.method;
if (head === 'home' && method === 'GET') return home(cfg);
if (head === 'views' && method === 'GET') return json({ views: await videoViews(cfg) });
if (head === 'items' && !a && method === 'GET') return items(cfg, url);
if (head === 'items' && a) {
if (!b && method === 'GET') return item(cfg, a);
if (b === 'similar' && method === 'GET') {
const res = await callUpstream(cfg, {
path: `/Items/${encodeURIComponent(a)}/Similar`,
query: buildQuery({ userId: cfg.jellyfinUserId, limit: '12', fields: GRID_FIELDS }),
});
return relayJson(res);
}
if (b === 'playback' && method === 'POST') return playbackInfo(cfg, a, req, url);
if ((b === 'played' || b === 'favorite') && (method === 'POST' || method === 'DELETE')) {
return userItemFlag(cfg, b, a, method);
}
}
if (head === 'shows' && a && method === 'GET') {
if (b === 'seasons') {
const res = await callUpstream(cfg, {
path: `/Shows/${encodeURIComponent(a)}/Seasons`,
query: buildQuery({ userId: cfg.jellyfinUserId, fields: GRID_FIELDS }),
});
return relayJson(res);
}
if (b === 'episodes') {
const res = await callUpstream(cfg, {
path: `/Shows/${encodeURIComponent(a)}/Episodes`,
query: buildQuery({
userId: cfg.jellyfinUserId,
seasonId: url.searchParams.get('seasonId') ?? undefined,
fields: `${GRID_FIELDS},Overview,MediaSources`,
}),
});
return relayJson(res);
}
}
if (head === 'resume' && method === 'GET') {
const res = await callUpstream(cfg, {
path: '/UserItems/Resume',
query: buildQuery({ userId: cfg.jellyfinUserId, limit: '24', mediaTypes: 'Video', fields: GRID_FIELDS }),
});
return relayJson(res);
}
if (head === 'nextup' && method === 'GET') {
const res = await callUpstream(cfg, {
path: '/Shows/NextUp',
query: buildQuery({ userId: cfg.jellyfinUserId, limit: '24', fields: GRID_FIELDS }),
});
return relayJson(res);
}
if (head === 'search' && method === 'GET') {
const term = url.searchParams.get('q')?.trim();
if (!term) return json({ Items: [], TotalRecordCount: 0 });
const res = await callUpstream(cfg, {
path: '/Items',
query: buildQuery({
userId: cfg.jellyfinUserId,
searchTerm: term,
recursive: 'true',
includeItemTypes: 'Movie,Series,Episode,Video,BoxSet,Person',
limit: '48',
fields: GRID_FIELDS,
}),
});
return relayJson(res);
}
if (head === 'genres' && method === 'GET') {
const res = await callUpstream(cfg, {
path: '/Genres',
query: buildQuery({ userId: cfg.jellyfinUserId, parentId: url.searchParams.get('parentId') ?? undefined }),
});
return relayJson(res);
}
if (head === 'sessions' && a && method === 'POST') return reportPlaystate(cfg, a, req);
return null;
}
// The pass-through allow-list. Anything not matching one of these is refused, including every administrative
// route. Matched case-insensitively because Jellyfin's own HLS playlists reference `/videos/…` in lower case
// while its OpenAPI document says `/Videos/…`, and a browser resolving a relative segment URL will send back
// whatever the playlist said.
const BYTES_PREFIXES = [
'items/', // artwork: /Items/{id}/Images/{type}
'videos/', // direct stream, HLS playlists and segments, subtitles
'users/', // /Users/{id}/Images/Primary — the account avatar
'persons/', // cast portraits
'studios/',
'genres/',
'musicgenres/',
];
const isAllowedBytesPath = (path: string): boolean => {
const lower = path.toLowerCase();
return BYTES_PREFIXES.some((prefix) => lower.startsWith(prefix));
};
/**
* `/_jf/*` — bytes only, GET only, allow-listed.
*
* `Accept: * / *` matters: the default JSON Accept makes Jellyfin answer some image routes with a JSON error
* instead of the picture. Range is forwarded so seeking works, and the response headers are relayed nearly
* whole — content-range, etag and cache-control are what make the `<video>` element and the browser cache
* behave, and dropping them turns seeking into a re-download.
*/
export async function handleBytesRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
if (req.method !== 'GET' && req.method !== 'HEAD') return null;
const subpath = url.pathname.slice('/_jf/'.length);
if (!subpath || subpath.includes('..')) return null;
if (!isAllowedBytesPath(subpath)) return null;
const upstream = await callUpstream(cfg, {
path: `/${subpath}`,
method: req.method,
query: url.search,
accept: '*/*',
range: req.headers.get('range'),
});
const headers = new Headers();
for (const header of [
'content-type',
'content-length',
'content-range',
'accept-ranges',
'etag',
'last-modified',
'cache-control',
] as const) {
const value = upstream.headers.get(header);
if (value) headers.set(header, value);
}
return new Response(upstream.body, { status: upstream.status, headers });
}
-232
View File
@@ -1,232 +0,0 @@
// Jellyfin upstream config + the one function that talks to it.
//
// All knowledge of the Jellyfin instance — its URL, its access token, the Jellyfin user the token belongs to
// and the DeviceId sessions are keyed by — lives here, mirroring officer-invoiceshelf: the platform API is a
// thin auth+forward proxy and holds NO Jellyfin credentials.
//
// The server is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `jellyfin_servers` (see
// databases/officer_db/src/jellyfin/queries.ts). Nothing in this file reads process.env — Bun auto-loads
// `.env` into every process started in the platform directory, so a token there would also be sitting in
// `officer`'s own environment: a credential held by the one process that has no code to use it.
//
// Three things about Jellyfin's API are load-bearing:
//
// 1. Auth is ONE header — `Authorization: MediaBrowser Token="…", Client=…, Device=…, DeviceId=…,
// Version=…`. The older `X-Emby-Token` still works in 10.11 but carries no client identity, and
// playback reporting correlates sessions by DeviceId, so the full header is what we send.
// 2. The DeviceId must be STABLE per server. A fresh one per request fills Jellyfin's dashboard with
// hundreds of "devices" and detaches each progress report from the session that started playback.
// 3. Almost every per-user route needs the user id in the PATH (`/Users/{id}/Views`), and it is not
// derivable from the token without a round trip — which is why it is pinned on the row at sign-in.
import { getActiveJellyfinCredentials } from 'officerdb';
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
export type UpstreamTarget = {
base: string;
token: string;
jellyfinUserId: string;
deviceId: string;
};
/**
* A stored server, which is where every real call goes.
*
* `id` and `label` ride along because the owner has several instances registered (this machine runs four)
* and only one selected: a probe has to be recorded against the row it actually reached, and a log line
* saying which instance answered is the difference between "Jellyfin is broken" and "you are looking at
* the albums server".
*/
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };
/** Trailing slashes off, so `${base}/Items/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
// A library grid is a burst of requests and each one needs the token, so the row is cached rather than
// re-read per request. Writes invalidate immediately; the TTL only covers someone editing the row in psql,
// which then takes effect within a minute instead of needing a restart.
const TTL_MS = 60_000;
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
/**
* The owner's SELECTED server, or null when /jellyfin has none yet — the sidecar then answers 503, and the
* UI turns that into the setup form rather than a screen of empty shelves.
*/
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
const hit = cache.get(userId);
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
const creds = await getActiveJellyfinCredentials(userId);
const cfg = creds
? {
id: creds.id,
label: creds.label,
base: normalizeBase(creds.url),
token: creds.accessToken,
jellyfinUserId: creds.jellyfinUserId,
deviceId: creds.deviceId,
}
: null;
cache.set(userId, { cfg, at: Date.now() });
return cfg;
}
/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */
export function invalidateConfig(userId: number): void {
cache.delete(userId);
}
/** Carries the upstream status so callers can tell a rejected token from an unreachable instance. */
export class UpstreamError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
const CLIENT = 'Officer';
const DEVICE = 'Officer Web';
const CLIENT_VERSION = '1.0.0';
/**
* The `Authorization` header Jellyfin expects, with or without a token.
*
* Signing in has to send the same header MINUS the token: Jellyfin refuses AuthenticateByName outright
* without a client identity, which is the single most common reason a hand-rolled login gets a 400.
*/
export function authHeader(deviceId: string, token?: string): string {
const parts = [`Client="${CLIENT}"`, `Device="${DEVICE}"`, `DeviceId="${deviceId}"`, `Version="${CLIENT_VERSION}"`];
if (token) parts.push(`Token="${token}"`);
return `MediaBrowser ${parts.join(', ')}`;
}
export type AuthResult = { token: string; userId: string; username: string | null; serverName: string | null };
/**
* Trade a username and password for an access token.
*
* The password is used for this one call and never stored — only the token it returns is persisted, the
* same trade officer-invoiceshelf makes. Jellyfin CAN issue API keys from its dashboard, but those are
* server-wide admin keys with no user identity, and every per-user route (resume points, watched state,
* favourites) needs a user. So we sign in as the owner rather than key the server.
*
* Each sign-in mints a NEW token; the previous one is deliberately left alone, because Officer cannot know
* whether the owner also uses it somewhere else. Old ones are revoked from Jellyfin's own Devices screen.
*/
export async function authenticate(
base: string,
deviceId: string,
username: string,
password: string,
): Promise<AuthResult> {
const res = await fetch(`${base}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
Authorization: authHeader(deviceId),
'Content-Type': 'application/json',
Accept: 'application/json',
},
// `Pw` is the plaintext field. `Password` is the legacy SHA1 one and is ignored by modern servers.
body: JSON.stringify({ Username: username, Pw: password }),
redirect: 'manual',
});
if (!res.ok) {
const detail = res.status === 401 ? 'wrong username or password' : `sign-in failed with ${res.status}`;
throw new UpstreamError(detail, res.status);
}
const payload = (await res.json()) as {
AccessToken?: unknown;
User?: { Id?: unknown; Name?: unknown };
ServerId?: unknown;
};
if (typeof payload.AccessToken !== 'string' || !payload.AccessToken) {
throw new UpstreamError('sign-in returned no access token', res.status);
}
if (typeof payload.User?.Id !== 'string' || !payload.User.Id) {
throw new UpstreamError('sign-in returned no user id', res.status);
}
return {
token: payload.AccessToken,
userId: payload.User.Id,
username: typeof payload.User.Name === 'string' ? payload.User.Name : null,
serverName: null,
};
}
export type PublicInfo = { serverName: string | null; version: string | null };
/**
* The unauthenticated identity endpoint, used before any credential exists so the setup form can confirm
* the URL points at a Jellyfin at all — and name it — before asking for a password.
*/
export async function publicInfo(base: string): Promise<PublicInfo> {
const res = await fetch(`${base}/System/Info/Public`, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new UpstreamError(`not a reachable Jellyfin (${res.status})`, res.status);
const payload = (await res.json()) as { ServerName?: unknown; Version?: unknown };
return {
serverName: typeof payload.ServerName === 'string' ? payload.ServerName : null,
version: typeof payload.Version === 'string' ? payload.Version : null,
};
}
type CallOptions = {
/** Absolute path on the Jellyfin host, e.g. `/Items`. */
path: string;
method?: string;
/** Raw search string including the leading `?`, or empty. */
query?: string;
body?: BodyInit | null;
contentType?: string | null;
/** Defaults to `application/json`; image and video routes ask for the bytes instead. */
accept?: string;
/** Forwarded verbatim for seeking — Jellyfin answers 206 for both direct play and HLS segments. */
range?: string | null;
};
/** The single door to Jellyfin. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
const headers: Record<string, string> = {
Authorization: authHeader(cfg.deviceId, cfg.token),
Accept: opts.accept ?? 'application/json',
};
if (opts.contentType) headers['Content-Type'] = opts.contentType;
if (opts.range) headers.Range = opts.range;
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
method: opts.method ?? 'GET',
headers,
body: opts.body ?? undefined,
redirect: 'manual',
});
if (res.status === 401) {
console.warn(`[jellyfin] ${opts.method ?? 'GET'} ${opts.path} → 401 (token rejected)`);
}
return res;
}
export type ProbeResult = { ok: boolean; version: string | null; serverName: string | null; error?: string };
/**
* Is the stored token still good?
*
* `/System/Info` (no `/Public`) is the right probe precisely because it is authenticated: the public one
* answers 200 to a revoked token, so a probe against it would call a dead connection healthy.
*/
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
try {
const res = await callUpstream(cfg, { path: '/System/Info' });
if (res.status === 401) return { ok: false, version: null, serverName: null, error: 'access token rejected' };
if (!res.ok) return { ok: false, version: null, serverName: null, error: `server answered ${res.status}` };
const payload = (await res.json()) as { Version?: unknown; ServerName?: unknown };
return {
ok: true,
version: typeof payload.Version === 'string' ? payload.Version : null,
serverName: typeof payload.ServerName === 'string' ? payload.ServerName : null,
};
} catch (err) {
return { ok: false, version: null, serverName: null, error: err instanceof Error ? err.message : 'unreachable' };
}
}
@@ -10,7 +10,6 @@ import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
@@ -32,7 +31,6 @@ export const apps = [
...chatHistoryMetas,
...widgetMetas,
...desktopMetas,
...jellyfinMetas,
...giteaMetas,
...invoicesMetas,
...walletMetas,
@@ -1,331 +0,0 @@
import type { JellyfinServerRow } from './useJellyfinData';
import { useState } from 'react';
import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { formatShortDate } from './shared';
import {
jellyfinErrorMessage,
useJellyfinHealth,
useJellyfinServerActions,
useJellyfinServers,
} from './useJellyfinData';
// Connecting Jellyfin servers to Officer, from the app.
//
// This screen is BOTH the setup wizard and the permanent settings page: JellyfinView renders it in place of
// whatever section the URL asks for while nothing is connected, and /jellyfin/settings renders it for good.
// One component, so a second server added later goes through exactly the code path that stored the first.
//
// There is no "paste an API key" alternative here, unlike the Immich screen. Jellyfin's API keys are
// SERVER-wide and identity-less — they authenticate as nobody, which breaks every per-user route this app
// depends on (resume points, next up, watched marks). Signing in is the only credential that has a user
// attached, so it is the only one offered. The password is used once and never stored.
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/** Jellyfin's own default port. The sidecar dials from this machine, so localhost is the right default. */
const DEFAULT_URL = 'http://localhost:8096';
const URL_HINT =
"The server's base URL, without /web. Officer reaches it from the server, not from this browser — so " +
'localhost here means the machine Officer runs on, and a local instance needs no TLS.';
const SIGN_IN_HINT =
'Your Jellyfin login. It is used once, by the sidecar, to exchange for an access token — the password is ' +
'never stored and never reaches Postgres. The session shows up in Jellyfin under Devices as "Officer"; ' +
'signing it out there is what revokes this connection.';
type FieldProps = {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
type?: string;
autoFocus?: boolean;
};
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">{label}</span>
<Input
value={value}
onChange={(ev) => onChange(ev.target.value)}
placeholder={placeholder}
type={type}
autoFocus={autoFocus}
autoComplete="off"
spellCheck={false}
/>
{hint && <span className={HINT}>{hint}</span>}
</label>
);
function useCredential() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const filled = !!username.trim() && !!password;
const reset = () => {
setUsername('');
setPassword('');
};
return { username, setUsername, password, setPassword, filled, reset };
}
type CredentialState = ReturnType<typeof useCredential>;
const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
<>
<Field
label="Username"
value={cred.username}
onChange={cred.setUsername}
placeholder="your Jellyfin user"
autoFocus={autoFocus}
/>
<Field
label="Password"
value={cred.password}
onChange={cred.setPassword}
placeholder="••••••••"
type="password"
hint={SIGN_IN_HINT}
/>
</>
);
/**
* One stored server. The active one carries the live health line, because health only ever describes the
* server actually in use — showing a status next to the others would be inventing one. "Test" on an inactive
* row probes it directly instead, which is the only way to check a server without switching to it first.
*/
const ServerRow = ({ server }: { server: JellyfinServerRow }) => {
const { data: health, refetch: recheck, isFetching: checking } = useJellyfinHealth();
const { edit, activate, remove, test } = useJellyfinServerActions();
const cred = useCredential();
const [error, setError] = useState<string | null>(null);
const [replacing, setReplacing] = useState(false);
// Removing a server throws away a token Officer can never show again, so the bin asks once.
const [confirming, setConfirming] = useState(false);
const run = async (action: Promise<unknown>) => {
setError(null);
try {
await action;
cred.reset();
setReplacing(false);
} catch (err) {
setError(jellyfinErrorMessage(err));
}
};
const busy = checking || test.isPending;
return (
<div className={`flex flex-col gap-2 rounded-lg border p-4 text-xs ${server.isActive ? 'border-primary/40' : ''}`}>
<div className="flex items-center gap-2">
{server.isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : <span className="h-4 w-4 shrink-0" />}
<span className="truncate font-medium">{server.label}</span>
{server.isActive && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">in use</span>
)}
<div className="ml-auto flex items-center gap-1">
{!server.isActive && (
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={activate.isPending}
onClick={() => void run(activate.mutateAsync(server.id))}
>
Use
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={busy}
onClick={() => void (server.isActive ? recheck() : run(test.mutateAsync(server.id)))}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
</Button>
<Button
variant="ghost"
size="sm"
className={`h-7 ${confirming ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}`}
disabled={remove.isPending}
onClick={() => {
if (!confirming) return setConfirming(true);
setConfirming(false);
void run(remove.mutateAsync(server.id));
}}
>
{confirming ? 'Remove?' : <Trash2 className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
<p className="truncate text-muted-foreground">
{server.url}
{server.jellyfinUsername ? ` · ${server.jellyfinUsername}` : ''}
</p>
{server.isActive ? (
<div className="flex items-center gap-1.5 text-muted-foreground">
{health?.ok ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
) : (
<TriangleAlert className="h-3.5 w-3.5 text-amber-500" />
)}
<span>
{health?.ok
? `Jellyfin ${health.version ?? '?'}${health.serverName ? ` · ${health.serverName}` : ''}`
: (health?.error ?? 'not checked yet')}
</span>
</div>
) : (
<p className="text-muted-foreground">
{server.version ? `Jellyfin ${server.version}` : 'version unknown'}
{server.lastSeenAt ? ` · last answered ${formatShortDate(server.lastSeenAt)}` : ''}
</p>
)}
{replacing ? (
<div className="flex flex-col gap-3 rounded-lg border p-3">
<CredentialFields cred={cred} autoFocus />
<div className="flex items-center gap-2">
<Button
size="sm"
className="h-8"
disabled={!cred.filled || edit.isPending}
onClick={() =>
void run(edit.mutateAsync({ id: server.id, username: cred.username.trim(), password: cred.password }))
}
>
{edit.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Save
</Button>
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
cred.reset();
setReplacing(false);
}}
>
Cancel
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setReplacing(true)}
className="self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Sign in again
</button>
)}
{error && <p className="text-destructive">{error}</p>}
</div>
);
};
export const ConnectionSection = () => {
const { data, isLoading } = useJellyfinServers();
const { add } = useJellyfinServerActions();
const cred = useCredential();
const [label, setLabel] = useState('');
const [url, setUrl] = useState(DEFAULT_URL);
const [error, setError] = useState<string | null>(null);
const servers = data?.servers ?? [];
const hasServers = servers.length > 0;
const submit = async () => {
setError(null);
if (!url.trim()) return setError('The Jellyfin URL is required');
if (!cred.filled) return setError('A username and password are required');
try {
await add.mutateAsync({
label: label.trim(),
url: url.trim(),
username: cred.username.trim(),
password: cred.password,
});
setLabel('');
setUrl(DEFAULT_URL);
cred.reset();
} catch (err) {
setError(jellyfinErrorMessage(err));
}
};
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-violet-500/15 text-violet-400">
<Plug className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">{hasServers ? 'Jellyfin servers' : 'Connect Jellyfin'}</h2>
<p className="text-xs text-muted-foreground">
{hasServers
? 'Officer stores each server and its access token encrypted, for this account only. One is in use at a time.'
: 'Video needs a Jellyfin server and a login before it can show anything.'}
</p>
</div>
</div>
{!isLoading && servers.map((server) => <ServerRow key={server.id} server={server} />)}
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 rounded-lg border p-4"
>
<p className="text-xs font-medium">{hasServers ? 'Add another server' : 'Add a server'}</p>
<Field
label="Label"
value={label}
onChange={setLabel}
placeholder="Optional — defaults to the server's own name"
hint="What the switcher calls this server. Two servers cannot share a label; two can share a URL."
autoFocus={hasServers}
/>
<Field
label="Jellyfin URL"
value={url}
onChange={setUrl}
placeholder={DEFAULT_URL}
hint={URL_HINT}
autoFocus={!hasServers}
/>
<CredentialFields cred={cred} />
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex items-center gap-2">
<Button type="submit" size="sm" disabled={add.isPending}>
{add.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{add.isPending ? 'Verifying…' : hasServers ? 'Add server' : 'Connect'}
</Button>
{add.isPending && <span className={HINT}>Checking the server and signing in…</span>}
</div>
</form>
</div>
</div>
);
};
@@ -1,57 +0,0 @@
import { Loader2 } from 'lucide-react';
import { MediaShelf } from './MediaCard';
import { useJellyfinHome } from './useJellyfinData';
// The landing screen: what you were watching, what comes next, and the newest thing in each library.
//
// One request. The sidecar fans out to Jellyfin's four different endpoints and swallows per-library failures,
// so a library mid-scan costs its own shelf rather than the whole page — which is why there is no per-shelf
// loading state here to write.
export const HomeSection = () => {
const { data, isLoading, error } = useJellyfinHome();
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center p-6 text-center text-xs text-muted-foreground">
The server did not answer.
</div>
);
}
const resume = data?.resume ?? [];
const nextUp = data?.nextUp ?? [];
const latest = data?.latest ?? [];
const empty = !resume.length && !nextUp.length && !latest.some((shelf) => shelf.items.length);
if (empty) {
return (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<p className="text-sm font-medium">Nothing here yet</p>
<p className="max-w-sm text-xs text-muted-foreground">
The server is connected but its video libraries are empty — or still scanning.
</p>
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-6 p-4">
<MediaShelf title="Continue watching" items={resume} wide />
<MediaShelf title="Next up" items={nextUp} wide />
{latest.map((shelf) => (
<MediaShelf key={shelf.viewId} title={`Latest in ${shelf.viewName}`} items={shelf.items} />
))}
</div>
</div>
);
};
@@ -1,252 +0,0 @@
import type { JellyItem } from './shared';
import { useEffect, useState } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router';
import { Check, Heart, Loader2, Play, X } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import {
backdropUrl,
closeHref,
episodeLabel,
formatBytes,
formatRuntime,
ITEM_PARAM,
itemHref,
posterUrl,
progressPercent,
ticksToSeconds,
} from './shared';
import { useEpisodes, useItemFlags, useJellyfinItem, useSeasons } from './useJellyfinData';
// The detail sheet — an overlay ON the grid, opened by `?item=<id>`.
//
// It is not a route because the grid behind it should stay mounted and scrolled: closing is dropping one
// query parameter, and the back button does it for free. The same component covers a film, a series, a season
// and an episode, because Jellyfin's item shape does — what changes is which extra list is worth showing
// underneath, and that is decided by `Type`.
const META = 'text-xs text-muted-foreground';
const Chip = ({ children }: { children: React.ReactNode }) => (
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{children}</span>
);
/** The technical line — what is actually in the file, which is what predicts whether it will transcode. */
const StreamSummary = ({ item }: { item: JellyItem }) => {
const source = item.MediaSources?.[0];
if (!source) return null;
const video = source.MediaStreams?.find((s) => s.Type === 'Video');
const audio = source.MediaStreams?.find((s) => s.Type === 'Audio');
const parts = [
source.Container?.toUpperCase(),
video?.Height ? `${video.Height}p` : null,
video?.Codec?.toUpperCase(),
audio?.Codec?.toUpperCase(),
audio?.Channels ? `${audio.Channels}ch` : null,
formatBytes(source.Size),
].filter(Boolean);
return <p className={META}>{parts.join(' · ')}</p>;
};
const SeriesEpisodes = ({ series }: { series: JellyItem }) => {
const { data: seasonData } = useSeasons(series.Id);
const seasons = seasonData?.Items ?? [];
const [seasonId, setSeasonId] = useState<string | null>(null);
// Default to the first season only once the list arrives; picking one is then the owner's choice and is
// not overwritten by a refetch.
useEffect(() => {
if (!seasonId && seasons.length) setSeasonId(seasons[0]?.Id ?? null);
}, [seasonId, seasons]);
const { data: episodeData, isLoading } = useEpisodes(series.Id, seasonId);
const episodes = episodeData?.Items ?? [];
if (!seasons.length) return null;
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1">
{seasons.map((season) => (
<button
key={season.Id}
type="button"
onClick={() => setSeasonId(season.Id)}
className={`rounded-md px-2 py-1 text-[11px] transition-colors ${
seasonId === season.Id ? 'bg-primary/10 font-medium text-primary' : 'bg-muted text-muted-foreground'
}`}
>
{season.Name ?? `Season ${season.IndexNumber ?? '?'}`}
</button>
))}
</div>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<ul className="flex flex-col divide-y rounded-lg border">
{episodes.map((episode) => (
<EpisodeRow key={episode.Id} episode={episode} />
))}
</ul>
)}
</div>
);
};
const EpisodeRow = ({ episode }: { episode: JellyItem }) => {
const { pathname } = useLocation();
const [params] = useSearchParams();
const progress = progressPercent(episode);
return (
<li className="relative">
<Link to={itemHref(pathname, params, episode.Id, true)} className="flex gap-3 p-3 hover:bg-muted/50">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{episodeLabel(episode)}</p>
{episode.Overview && <p className="line-clamp-2 text-[11px] text-muted-foreground">{episode.Overview}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-[11px] text-muted-foreground">{formatRuntime(episode.RunTimeTicks)}</span>
{episode.UserData?.Played && <Check className="h-3.5 w-3.5 text-primary" />}
</div>
</Link>
{progress > 0 && progress < 100 && (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-primary" style={{ width: `${progress}%` }} />
)}
</li>
);
};
export const ItemDetail = ({ id }: { id: string }) => {
const { token } = useClient();
const { pathname } = useLocation();
const [params] = useSearchParams();
const { data: item, isLoading } = useJellyfinItem(id);
const { setPlayed, setFavorite } = useItemFlags();
const close = closeHref(pathname, params, ITEM_PARAM);
if (isLoading || !item) {
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/95">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
const backdrop = backdropUrl(item, token);
const poster = posterUrl(item, token, 300);
const resumeSeconds = ticksToSeconds(item.UserData?.PlaybackPositionTicks);
const played = item.UserData?.Played === true;
const favorite = item.UserData?.IsFavorite === true;
// A series is not playable itself; its Next Up episode is. Sending the series id to the player would ask
// Jellyfin to negotiate playback for a folder, which fails in a way that reads as a broken video.
const playableId = item.Type === 'Series' ? null : item.Id;
const meta = [
item.ProductionYear ? String(item.ProductionYear) : null,
formatRuntime(item.RunTimeTicks),
item.OfficialRating,
item.CommunityRating ? `★ ${item.CommunityRating.toFixed(1)}` : null,
].filter(Boolean);
return (
<div className="absolute inset-0 z-20 overflow-y-auto bg-background">
<div className="relative">
{backdrop && (
<div className="relative h-48 w-full overflow-hidden sm:h-64">
<img src={backdrop} alt="" className="h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/40 to-transparent" />
</div>
)}
<Link
to={close}
aria-label="Close"
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70"
>
<X className="h-4 w-4" />
</Link>
</div>
<div className={`flex flex-col gap-4 p-4 ${backdrop ? '-mt-16' : ''}`}>
<div className="flex gap-4">
{poster && (
<img
src={poster}
alt=""
className="h-40 w-[107px] shrink-0 rounded-lg object-cover shadow-lg ring-1 ring-black/10"
/>
)}
<div className="flex min-w-0 flex-col gap-1.5">
<h2 className="text-lg font-semibold leading-tight">{item.Name}</h2>
{item.Type === 'Episode' && item.SeriesName && (
<p className={META}>
{item.SeriesName} · {episodeLabel(item)}
</p>
)}
{item.Taglines?.[0] && <p className="text-xs italic text-muted-foreground">{item.Taglines[0]}</p>}
<p className={META}>{meta.join(' · ')}</p>
<div className="flex flex-wrap gap-1">
{item.Genres?.slice(0, 5).map((g) => (
<Chip key={g}>{g}</Chip>
))}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{playableId && (
<Button asChild size="sm">
<Link to={itemHref(pathname, params, playableId, true)}>
<Play className="mr-1.5 h-3.5 w-3.5" fill="currentColor" />
{resumeSeconds > 0 ? 'Resume' : 'Play'}
</Link>
</Button>
)}
<Button
variant="outline"
size="sm"
disabled={setPlayed.isPending}
onClick={() => void setPlayed.mutateAsync({ id: item.Id, played: !played }).catch(() => undefined)}
>
<Check className={`mr-1.5 h-3.5 w-3.5 ${played ? 'text-primary' : ''}`} />
{played ? 'Watched' : 'Mark watched'}
</Button>
<Button
variant="outline"
size="sm"
disabled={setFavorite.isPending}
onClick={() => void setFavorite.mutateAsync({ id: item.Id, favorite: !favorite }).catch(() => undefined)}
>
<Heart className={`mr-1.5 h-3.5 w-3.5 ${favorite ? 'fill-current text-red-500' : ''}`} />
Favourite
</Button>
</div>
{item.Overview && <p className="max-w-3xl text-xs leading-relaxed text-muted-foreground">{item.Overview}</p>}
<StreamSummary item={item} />
{item.Type === 'Series' && <SeriesEpisodes series={item} />}
{!!item.People?.length && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold">Cast</h3>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{item.People.filter((person) => person.Type === 'Actor')
.slice(0, 12)
.map((person) => (
<p key={`${person.Id}-${person.Role}`} className="text-[11px]">
<span className="font-medium">{person.Name}</span>
{person.Role && <span className="text-muted-foreground"> as {person.Role}</span>}
</p>
))}
</div>
</div>
)}
</div>
</div>
);
};
@@ -1,103 +0,0 @@
import type { JellyItem } from './shared';
import { Link, NavLink, useSearchParams } from 'react-router';
import { Clapperboard, Film, FolderOpen, Home, Images, ListVideo, Plug, Search, Tv } from 'lucide-react';
import { jellyfinSectionPath, VIEW_PARAM } from './shared';
import { ServerSwitcher } from './ServerSwitcher';
import { useJellyfinViews } from './useJellyfinData';
// Left panel of the /jellyfin workspace: Home and Search, the libraries the server actually has, then the
// connection screen.
//
// The libraries are not a fixed vocabulary, so they cannot be sections in the path — they are
// /jellyfin/library?view=<id>. That keeps the section list closed (four entries, checked by a type guard)
// while the thing being browsed stays addressable, which is the same split the photos app uses for albums.
//
// Their active state is computed rather than taken from NavLink, because every library link shares the path
// /jellyfin/library and react-router's isActive does not look at the query string.
const ICON_FOR_COLLECTION: Record<string, typeof Film> = {
movies: Film,
tvshows: Tv,
homevideos: Images,
musicvideos: Clapperboard,
boxsets: FolderOpen,
playlists: ListVideo,
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
const ACTIVE = 'bg-primary/10 font-medium text-primary';
const IDLE = 'text-muted-foreground hover:bg-muted hover:text-foreground';
const Marker = () => <span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />;
export const JellyfinNav = () => {
const { data } = useJellyfinViews();
const [params] = useSearchParams();
const activeView = params.get(VIEW_PARAM);
const views = data?.views ?? [];
const sectionLink = (id: 'home' | 'search' | 'settings', label: string, Icon: typeof Film) => (
<NavLink key={id} to={jellyfinSectionPath(id)} className={({ isActive }) => `${ROW} ${isActive ? ACTIVE : IDLE}`}>
{({ isActive }) => (
<>
{isActive && <Marker />}
<Icon
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
{label}
</>
)}
</NavLink>
);
const libraryLink = (view: JellyItem) => {
const Icon = ICON_FOR_COLLECTION[view.CollectionType ?? ''] ?? FolderOpen;
const isActive = activeView === view.Id;
return (
<Link
key={view.Id}
to={`${jellyfinSectionPath('library')}?${VIEW_PARAM}=${encodeURIComponent(view.Id)}`}
className={`${ROW} ${isActive ? ACTIVE : IDLE}`}
>
{isActive && <Marker />}
<Icon
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className="truncate">{view.Name ?? 'Library'}</span>
</Link>
);
};
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-violet-500/15 text-violet-400 ring-1 ring-black/5">
<Clapperboard className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">Video</div>
<ServerSwitcher />
</div>
</div>
<nav className="flex flex-col gap-0.5 px-2">
{sectionLink('home', 'Home', Home)}
{sectionLink('search', 'Search', Search)}
</nav>
{views.length > 0 && (
<>
<div className="mx-4 my-2 border-t" />
<p className="px-4 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Libraries</p>
<nav className="flex flex-col gap-0.5 px-2">{views.map(libraryLink)}</nav>
</>
)}
<div className="flex-1" />
<div className="mx-4 my-2 border-t" />
<nav className="flex flex-col gap-0.5 px-2 pb-3">{sectionLink('settings', 'Connection', Plug)}</nav>
</div>
);
};
@@ -1,57 +0,0 @@
import { Link, useSearchParams } from 'react-router';
import { ConnectionSection } from './ConnectionSection';
import { HomeSection } from './HomeSection';
import { ItemDetail } from './ItemDetail';
import { LibrarySection } from './LibrarySection';
import { SearchSection } from './SearchSection';
import { VideoPlayer } from './VideoPlayer';
import { ITEM_PARAM, jellyfinSectionPath, PLAY_PARAM } from './shared';
import { useJellyfinHealth } from './useJellyfinData';
import { useJellyfinSection } from './useJellyfinSection';
// Right panel of the /jellyfin workspace — renders the section named by the URL, with the detail sheet and
// the player as overlays on top of it.
//
// Health gates all of them, and its two failure modes are answered differently: nothing configured yet is the
// setup form, whatever section was asked for, because there is nothing else useful to show; a stored server
// that is failing keeps its own message and a way back to the connection screen, since replacing a working
// sign-in by accident is worse than a wall of text.
//
// The player wins over the detail sheet when both parameters are set, and `itemHref` never sets both — but
// the precedence is written down rather than left to whichever renders last.
export const JellyfinView = () => {
const section = useJellyfinSection();
const [params] = useSearchParams();
const { data: health, isLoading } = useJellyfinHealth();
const playId = params.get(PLAY_PARAM);
const itemId = params.get(ITEM_PARAM);
if (section === 'settings') return <ConnectionSection />;
if (!isLoading && health && !health.ok) {
if (health.configured === false) return <ConnectionSection />;
return (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<p className="text-sm font-medium">Jellyfin is not answering</p>
<p className="max-w-sm text-xs text-muted-foreground">
{health.error ?? 'The jellyfin sidecar could not reach the configured server.'}
</p>
<Link to={jellyfinSectionPath('settings')} className="mt-2 text-xs font-medium text-primary hover:underline">
Check the connection
</Link>
</div>
);
}
const body = section === 'library' ? <LibrarySection /> : section === 'search' ? <SearchSection /> : <HomeSection />;
return (
<div className="relative h-full">
{body}
{playId ? <VideoPlayer id={playId} /> : itemId ? <ItemDetail id={itemId} /> : null}
</div>
);
};
@@ -1,17 +0,0 @@
import { Clapperboard } from 'lucide-react';
import { JELLYFIN_SECTIONS } from './shared';
import { useJellyfinSection } from './useJellyfinSection';
// Panel header for the right (jellyfin-view) panel.
export const JellyfinViewHeader = () => {
const section = useJellyfinSection();
const label = JELLYFIN_SECTIONS.find((s) => s.id === section)?.label ?? 'Video';
return (
<>
<Clapperboard className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-xs font-medium">{label}</span>
</>
);
};
@@ -1,114 +0,0 @@
import { useSearchParams } from 'react-router';
import { Loader2 } from 'lucide-react';
import { MediaGrid } from './MediaCard';
import { VIEW_PARAM } from './shared';
import { useJellyfinItems, useJellyfinViews } from './useJellyfinData';
// One library, as a grid.
//
// Which library is `?view=<id>`; the sort is `?sort=` and `?order=`. All three are in the URL rather than in
// component state so a particular view of a particular library is a link you can keep — and so the nav can
// tell which library is open without either panel talking to the other.
const SORTS = [
{ id: 'SortName', label: 'Name' },
{ id: 'DateCreated', label: 'Recently added' },
{ id: 'PremiereDate', label: 'Release date' },
{ id: 'CommunityRating', label: 'Rating' },
{ id: 'Random', label: 'Random' },
] as const;
const SORT_PARAM = 'sort';
const ORDER_PARAM = 'order';
// A show is a Series, not its episodes: a tvshows library browsed recursively for Episode would be thousands
// of tiles of the same six posters. BoxSets are collapsed for the same reason.
const TYPES_FOR_COLLECTION: Record<string, string> = {
movies: 'Movie',
tvshows: 'Series',
homevideos: 'Video,Photo',
musicvideos: 'MusicVideo',
boxsets: 'BoxSet',
playlists: 'Playlist',
};
const CHIP = 'rounded-md px-2 py-1 text-[11px] transition-colors';
export const LibrarySection = () => {
const [params, setParams] = useSearchParams();
const viewId = params.get(VIEW_PARAM);
const sortBy = params.get(SORT_PARAM) ?? 'SortName';
const sortOrder = params.get(ORDER_PARAM) === 'Descending' ? 'Descending' : 'Ascending';
const { data: viewData } = useJellyfinViews();
const view = viewData?.views.find((v) => v.Id === viewId) ?? null;
const { data, isLoading } = useJellyfinItems(
{
parentId: viewId ?? undefined,
includeItemTypes: TYPES_FOR_COLLECTION[view?.CollectionType ?? ''] ?? undefined,
sortBy,
sortOrder,
limit: 500,
collapseBoxSetItems: false,
},
!!viewId,
);
if (!viewId) {
return (
<div className="flex h-full items-center justify-center p-6 text-center text-xs text-muted-foreground">
Pick a library on the left.
</div>
);
}
const items = data?.Items ?? [];
const wide = view?.CollectionType === 'homevideos';
const setSort = (id: string) => {
const next = new URLSearchParams(params);
// Clicking the sort you are already on flips the direction, which is the behaviour every table has and
// saves a second control that would only ever be used with this one.
if (id === sortBy) next.set(ORDER_PARAM, sortOrder === 'Ascending' ? 'Descending' : 'Ascending');
else next.delete(ORDER_PARAM);
next.set(SORT_PARAM, id);
setParams(next, { replace: true });
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-4 py-2">
<h2 className="truncate text-sm font-semibold">{view?.Name ?? 'Library'}</h2>
<span className="text-[11px] text-muted-foreground">
{data?.TotalRecordCount != null ? `${data.TotalRecordCount.toLocaleString()} items` : ''}
</span>
<div className="ml-auto flex items-center gap-0.5 rounded-lg bg-muted p-0.5">
{SORTS.map((sort) => (
<button
key={sort.id}
type="button"
onClick={() => setSort(sort.id)}
className={`${CHIP} ${sortBy === sort.id ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
>
{sort.label}
{sortBy === sort.id && sort.id !== 'Random' ? (sortOrder === 'Ascending' ? ' ↑' : ' ↓') : ''}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : items.length ? (
<MediaGrid items={items} wide={wide} />
) : (
<p className="pt-10 text-center text-xs text-muted-foreground">This library is empty.</p>
)}
</div>
</div>
);
};
@@ -1,106 +0,0 @@
import type { JellyItem } from './shared';
import { Link, useLocation, useSearchParams } from 'react-router';
import { Check, Play } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { episodeLabel, itemHref, posterUrl, progressPercent, subtitleFor, thumbUrl } from './shared';
// One tile in a shelf or a grid.
//
// It is a real <Link> to `?item=<id>` — cmd-click opens the detail in a new tab, the URL says what is open,
// and the back button closes it. The play button is a SIBLING anchor rather than nested inside, because an
// anchor inside an anchor is not valid HTML and browsers resolve it by dropping one of them.
type MediaCardProps = { item: JellyItem; wide?: boolean };
export const MediaCard = ({ item, wide = false }: MediaCardProps) => {
const { token } = useClient();
const { pathname } = useLocation();
const [params] = useSearchParams();
const src = wide ? thumbUrl(item, token) : posterUrl(item, token);
const progress = progressPercent(item);
const watched = item.UserData?.Played === true;
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
const subtitle = subtitleFor(item);
return (
<div className="group/card flex flex-col gap-1.5">
<div className="relative">
<Link
to={itemHref(pathname, params, item.Id)}
className={`relative block overflow-hidden rounded-lg bg-muted ring-1 ring-black/5 transition-transform hover:scale-[1.02] ${
wide ? 'aspect-video' : 'aspect-[2/3]'
}`}
title={item.Name ?? undefined}
>
{src ? (
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center p-2 text-center text-[11px] text-muted-foreground">
{item.Name}
</div>
)}
{watched && (
<span className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</span>
)}
{!watched && unplayed > 0 && (
<span className="absolute right-1.5 top-1.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-medium text-primary-foreground">
{unplayed}
</span>
)}
{progress > 0 && progress < 100 && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
<span className="block h-full bg-primary" style={{ width: `${progress}%` }} />
</span>
)}
</Link>
<Link
to={itemHref(pathname, params, item.Id, true)}
aria-label={`Play ${item.Name ?? 'item'}`}
className="absolute left-1/2 top-1/2 flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition-opacity group-hover/card:opacity-100"
>
<Play className="h-5 w-5 translate-x-px" fill="currentColor" />
</Link>
</div>
<div className="min-w-0">
<p className="truncate text-xs font-medium leading-tight">{episodeLabel(item)}</p>
{subtitle && <p className="truncate text-[11px] text-muted-foreground">{subtitle}</p>}
</div>
</div>
);
};
/** A horizontal shelf. Overflow scrolls rather than wrapping — a shelf that wraps is a grid with a title. */
export const MediaShelf = ({ title, items, wide }: { title: string; items: JellyItem[]; wide?: boolean }) => {
if (!items.length) return null;
return (
<section className="flex flex-col gap-2">
<h3 className="px-1 text-sm font-semibold">{title}</h3>
<div className="flex gap-3 overflow-x-auto pb-2">
{items.map((item) => (
<div key={item.Id} className={wide ? 'w-56 shrink-0' : 'w-32 shrink-0'}>
<MediaCard item={item} wide={wide} />
</div>
))}
</div>
</section>
);
};
/** A wrapping grid. Column count comes from a min tile width, so it works in a panel of any size. */
export const MediaGrid = ({ items, wide }: { items: JellyItem[]; wide?: boolean }) => (
<div
className="grid gap-x-3 gap-y-4"
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${wide ? 200 : 130}px, 1fr))` }}
>
{items.map((item) => (
<MediaCard key={item.Id} item={item} wide={wide} />
))}
</div>
);
@@ -1,404 +0,0 @@
import type { AudioTrack, QualityRung, SubtitleTrack } from './shared';
import { useEffect, useRef, useState } from 'react';
import {
Check,
Gauge,
Languages,
Maximize2,
Minimize2,
Pause,
Play,
RotateCcw,
SkipForward,
Subtitles,
Volume1,
Volume2,
VolumeX,
} from 'lucide-react';
import { formatClock } from './shared';
// The control bar. Ours rather than the browser's for every transport, so that the progressive fallback — whose
// scrubber has to re-request the stream — is not a visibly different player from the seekable ones.
//
// THE MENUS ARE HAND-ROLLED, and that is not an oversight. A Radix/shadcn dropdown renders through a portal
// into document.body, and body is not inside the element that went fullscreen — so every one of these would be
// invisible exactly when the player is most likely to be used. These stay in the DOM subtree they belong to.
const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2];
const BUTTON = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white/90 hover:bg-white/15';
const ROW = 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-white/10';
type MenuProps = {
icon: React.ReactNode;
label: string;
active?: boolean;
children: (close: () => void) => React.ReactNode;
};
const Menu = ({ icon, label, active, children }: MenuProps) => {
const [open, setOpen] = useState(false);
const wrapper = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointer = (ev: PointerEvent) => {
if (!wrapper.current?.contains(ev.target as Node)) setOpen(false);
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key !== 'Escape') return;
// Escape closes the menu rather than leaving fullscreen, but only while one is open.
ev.stopPropagation();
ev.preventDefault();
setOpen(false);
};
document.addEventListener('pointerdown', onPointer, true);
document.addEventListener('keydown', onKey, true);
return () => {
document.removeEventListener('pointerdown', onPointer, true);
document.removeEventListener('keydown', onKey, true);
};
}, [open]);
return (
<div ref={wrapper} className="relative shrink-0">
<button
type="button"
aria-label={label}
aria-expanded={open}
onClick={() => setOpen((was) => !was)}
className={`${BUTTON} ${active ? 'text-primary' : ''}`}
>
{icon}
</button>
{open && (
<div className="absolute bottom-10 right-0 z-10 max-h-72 min-w-56 overflow-y-auto rounded-lg border border-white/10 bg-black/90 p-1 text-white shadow-xl backdrop-blur">
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-white/40">{label}</p>
{children(() => setOpen(false))}
</div>
)}
</div>
);
};
type OptionProps = { selected: boolean; onSelect: () => void; children: React.ReactNode; hint?: string | null };
const Option = ({ selected, onSelect, children, hint }: OptionProps) => (
<button type="button" onClick={onSelect} className={ROW}>
<Check className={`h-3.5 w-3.5 shrink-0 ${selected ? 'opacity-100 text-primary' : 'opacity-0'}`} />
<span className="min-w-0 flex-1">
<span className="block truncate">{children}</span>
{hint && <span className="block truncate text-[10px] text-white/40">{hint}</span>}
</span>
</button>
);
export type PlayerControlsProps = {
playing: boolean;
position: number;
duration: number;
/** Seconds buffered ahead of the current position, as one number — the element's own ranges, flattened. */
bufferedTo: number;
/** False in progressive mode, where a scrub costs a new transcode and only commits on release. */
liveScrub: boolean;
volume: number;
muted: boolean;
rate: number;
fullscreen: boolean;
audio: AudioTrack[];
audioIndex: number | null;
subtitles: SubtitleTrack[];
subtitleIndex: number | null;
qualityRungs: QualityRung[];
maxBitrate: number | null;
/** Set while a re-negotiation is in flight, so a picker click reads as busy rather than as nothing. */
busy: boolean;
onTogglePlay: () => void;
onScrub: (seconds: number) => void;
onScrubCommit: (seconds: number) => void;
onSkip: (seconds: number) => void;
onVolume: (volume: number) => void;
onToggleMute: () => void;
onRate: (rate: number) => void;
onAudio: (index: number) => void;
onSubtitle: (index: number | null) => void;
onQuality: (bitrate: number | null) => void;
onFullscreen: () => void;
};
export const PlayerControls = (props: PlayerControlsProps) => {
const { position, duration, bufferedTo, liveScrub } = props;
const barRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<number | null>(null);
const [hover, setHover] = useState<number | null>(null);
const shown = dragging ?? position;
const pct = (seconds: number) => (duration > 0 ? Math.min(100, Math.max(0, (seconds / duration) * 100)) : 0);
const secondsAt = (clientX: number): number => {
const box = barRef.current?.getBoundingClientRect();
if (!box || box.width === 0) return 0;
return Math.max(0, Math.min(duration, ((clientX - box.left) / box.width) * duration));
};
// Pointer capture rather than window listeners: the drag keeps tracking when the cursor leaves the bar, and
// it ends on the same element it started on even if that is over the video or outside the window.
const onPointerDown = (ev: React.PointerEvent<HTMLDivElement>) => {
ev.currentTarget.setPointerCapture(ev.pointerId);
const seconds = secondsAt(ev.clientX);
setDragging(seconds);
if (liveScrub) props.onScrub(seconds);
};
const onPointerMove = (ev: React.PointerEvent<HTMLDivElement>) => {
const seconds = secondsAt(ev.clientX);
setHover(seconds);
if (dragging === null) return;
setDragging(seconds);
// In progressive mode every intermediate value would start an ffmpeg, so only the release commits.
if (liveScrub) props.onScrub(seconds);
};
const onPointerUp = (ev: React.PointerEvent<HTMLDivElement>) => {
if (dragging === null) return;
ev.currentTarget.releasePointerCapture(ev.pointerId);
props.onScrubCommit(secondsAt(ev.clientX));
setDragging(null);
};
const currentSubtitle = props.subtitles.find((track) => track.index === props.subtitleIndex) ?? null;
const currentQuality = props.qualityRungs.find((rung) => rung.videoBitrate === props.maxBitrate) ?? null;
return (
<div className="bg-gradient-to-t from-black/90 to-transparent px-3 pb-2 pt-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="w-12 shrink-0 text-right text-[11px] tabular-nums text-white/70">{formatClock(shown)}</span>
<div
ref={barRef}
role="slider"
tabIndex={0}
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.max(1, Math.round(duration))}
aria-valuenow={Math.round(shown)}
aria-valuetext={formatClock(shown)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={() => setHover(null)}
className="group relative h-6 flex-1 cursor-pointer touch-none select-none"
>
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-white/20">
<div className="h-full rounded-full bg-white/30" style={{ width: `${pct(bufferedTo)}%` }} />
</div>
<div
className="absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-primary"
style={{ width: `${pct(shown)}%` }}
/>
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary opacity-0 transition-opacity group-hover:opacity-100"
style={{ left: `${pct(shown)}%`, opacity: dragging !== null ? 1 : undefined }}
/>
{hover !== null && duration > 0 && (
<span
className="pointer-events-none absolute -top-5 -translate-x-1/2 rounded bg-black/80 px-1 text-[10px] tabular-nums text-white"
style={{ left: `${pct(hover)}%` }}
>
{formatClock(hover)}
</span>
)}
</div>
<span className="w-12 shrink-0 text-[11px] tabular-nums text-white/70">{formatClock(duration)}</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={props.onTogglePlay}
aria-label={props.playing ? 'Pause' : 'Play'}
className={BUTTON}
>
{props.playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" fill="currentColor" />}
</button>
<button type="button" onClick={() => props.onSkip(-10)} aria-label="Back 10 seconds" className={BUTTON}>
<RotateCcw className="h-4 w-4" />
</button>
<button type="button" onClick={() => props.onSkip(30)} aria-label="Forward 30 seconds" className={BUTTON}>
<SkipForward className="h-4 w-4" />
</button>
{/* The slider stays out of the tab order on purpose — the volume button is the keyboard affordance,
and a range input here would swallow the arrow keys the player binds to seeking. */}
<div className="group/vol flex items-center">
<button
type="button"
onClick={props.onToggleMute}
aria-label={props.muted ? 'Unmute' : 'Mute'}
className={BUTTON}
>
{props.muted || props.volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : props.volume < 0.5 ? (
<Volume1 className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</button>
<input
type="range"
min={0}
max={1}
step={0.02}
tabIndex={-1}
value={props.muted ? 0 : props.volume}
onChange={(ev) => props.onVolume(Number(ev.target.value))}
aria-label="Volume"
className="h-1 w-0 cursor-pointer accent-primary opacity-0 transition-all group-hover/vol:w-20 group-hover/vol:opacity-100"
/>
</div>
<span className="flex-1" />
{props.busy && <span className="mr-1 text-[10px] text-white/50">switching…</span>}
<Menu icon={<Subtitles className="h-4 w-4" />} label="Subtitles" active={props.subtitleIndex !== null}>
{(close) => (
<>
<Option
selected={props.subtitleIndex === null}
onSelect={() => {
props.onSubtitle(null);
close();
}}
>
Off
</Option>
{props.subtitles.length === 0 && (
<p className="px-2 py-1.5 text-[11px] text-white/40">This file has no subtitle tracks.</p>
)}
{props.subtitles.map((track) =>
track.url ? (
<Option
key={track.index}
selected={props.subtitleIndex === track.index}
onSelect={() => {
props.onSubtitle(track.index);
close();
}}
hint={track.isForced ? 'forced' : null}
>
{track.label}
</Option>
) : (
<p
key={track.index}
className="px-2 py-1.5 text-[11px] text-white/30"
title={track.unavailable ?? ''}
>
<span className="block truncate line-through">{track.label}</span>
<span className="block text-[10px]">unavailable — {track.unavailable}</span>
</p>
),
)}
</>
)}
</Menu>
<Menu icon={<Languages className="h-4 w-4" />} label="Audio track">
{(close) => (
<>
{props.audio.map((track) => (
<Option
key={track.index}
selected={props.audioIndex === track.index || (props.audioIndex === null && track.isDefault)}
onSelect={() => {
props.onAudio(track.index);
close();
}}
hint={[track.codec?.toUpperCase(), track.channels ? `${track.channels}ch` : null]
.filter(Boolean)
.join(' · ')}
>
{track.label}
</Option>
))}
{props.audio.length <= 1 && (
<p className="px-2 py-1.5 text-[11px] text-white/40">Only one audio track.</p>
)}
</>
)}
</Menu>
<Menu icon={<Gauge className="h-4 w-4" />} label="Speed and quality">
{(close) => (
<>
<p className="px-2 pt-1 text-[10px] uppercase tracking-wide text-white/40">Speed</p>
{PLAYBACK_RATES.map((rate) => (
<Option
key={rate}
selected={props.rate === rate}
onSelect={() => {
props.onRate(rate);
close();
}}
>
{rate === 1 ? 'Normal' : `${rate}×`}
</Option>
))}
<p className="mt-1 border-t border-white/10 px-2 pt-2 text-[10px] uppercase tracking-wide text-white/40">
Quality
</p>
<Option
selected={props.maxBitrate === null}
onSelect={() => {
props.onQuality(null);
close();
}}
hint="direct play or remux where possible"
>
Auto
</Option>
{props.qualityRungs.map((rung) => (
<Option
key={rung.videoBitrate}
selected={props.maxBitrate === rung.videoBitrate}
onSelect={() => {
props.onQuality(rung.videoBitrate);
close();
}}
hint={`${Math.round(rung.videoBitrate / 1_000_000)} Mbit · forces a re-encode`}
>
{rung.label}
</Option>
))}
</>
)}
</Menu>
<button
type="button"
onClick={props.onFullscreen}
aria-label={props.fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
className={BUTTON}
>
{props.fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
{(currentSubtitle || currentQuality) && (
<p className="truncate px-1 text-[10px] text-white/35">
{[
currentSubtitle && `subtitles: ${currentSubtitle.label}`,
currentQuality && `quality: ${currentQuality.label}`,
]
.filter(Boolean)
.join(' · ')}
</p>
)}
</div>
</div>
);
};
@@ -1,54 +0,0 @@
import { useSearchParams } from 'react-router';
import { Loader2, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { MediaGrid } from './MediaCard';
import { QUERY_PARAM } from './shared';
import { useJellyfinSearch } from './useJellyfinData';
// Search across every video library.
//
// The term is `?q=` — in the URL, not in component state, so a result set is a link and the browser's own
// back button steps through what you looked for. `replace` on every keystroke keeps that history one entry
// per search rather than one per letter.
export const SearchSection = () => {
const [params, setParams] = useSearchParams();
const term = params.get(QUERY_PARAM) ?? '';
const { data, isFetching } = useJellyfinSearch(term);
const setTerm = (value: string) => {
const next = new URLSearchParams(params);
if (value) next.set(QUERY_PARAM, value);
else next.delete(QUERY_PARAM);
setParams(next, { replace: true });
};
const items = data?.Items ?? [];
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-4 py-2">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<Input
value={term}
onChange={(ev) => setTerm(ev.target.value)}
placeholder="Search films, shows and episodes"
className="h-8 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
autoFocus
spellCheck={false}
/>
{isFetching && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />}
</div>
<div className="flex-1 overflow-y-auto p-4">
{term.trim().length < 2 ? (
<p className="pt-10 text-center text-xs text-muted-foreground">Type at least two characters.</p>
) : items.length ? (
<MediaGrid items={items} />
) : (
!isFetching && <p className="pt-10 text-center text-xs text-muted-foreground">Nothing matched “{term}”.</p>
)}
</div>
</div>
);
};
@@ -1,81 +0,0 @@
import { Link } from 'react-router';
import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { jellyfinSectionPath } from './shared';
import { useJellyfinHealth, useJellyfinServerActions, useJellyfinServers } from './useJellyfinData';
// Which Jellyfin you are looking at, and how to change it.
//
// This machine really does run several instances, so the switcher is not hypothetical — but only the video
// one is expected here; the album and DJ-set instances are the music player's business, and the sidecar
// already drops music libraries from every listing.
//
// Switching invalidates the whole ['jellyfin'] key, because it changes the answer to every query in the
// workspace without changing any of their inputs. Anything less and the previous server's posters stay up.
export const ServerSwitcher = () => {
const { data } = useJellyfinServers();
const { data: health } = useJellyfinHealth();
const { activate } = useJellyfinServerActions();
const servers = data?.servers ?? [];
const active = servers.find((server) => server.isActive) ?? null;
// Before the registry answers, fall back to health — it is the query that was already driving this line.
const status = active?.label ?? (health?.ok ? (health.serverName ?? 'connected') : 'not connected');
if (servers.length < 2) {
return (
<Link
to={jellyfinSectionPath('settings')}
className="block truncate text-xs text-muted-foreground hover:text-foreground"
>
{status}
</Link>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
<span className="truncate">{status}</span>
{activate.isPending ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
) : (
<ChevronsUpDown className="h-3 w-3 shrink-0" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">Jellyfin servers</DropdownMenuLabel>
{servers.map((server) => (
<DropdownMenuItem
key={server.id}
disabled={server.isActive || activate.isPending}
onSelect={() => void activate.mutateAsync(server.id).catch(() => undefined)}
className="gap-2"
>
<Check className={`h-3.5 w-3.5 shrink-0 ${server.isActive ? 'opacity-100' : 'opacity-0'}`} />
<span className="min-w-0 flex-1">
<span className="block truncate">{server.label}</span>
<span className="block truncate text-[11px] text-muted-foreground">{server.url}</span>
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={jellyfinSectionPath('settings')} className="gap-2">
<Settings2 className="h-3.5 w-3.5" />
Manage servers
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -1,506 +0,0 @@
import type { PlaybackPlan } from './shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router';
import { Loader2, X } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { closeHref, episodeLabel, PLAY_PARAM, streamUrl, TICKS_PER_SECOND, ticksToSeconds } from './shared';
import { useJellyfinItem, usePlaybackPlan, usePlaystateReporter } from './useJellyfinData';
import { PlayerControls } from './PlayerControls';
import Hls from './vendor/hls.mjs';
// The player — a full-panel overlay opened by `?play=<id>`.
//
// THE ONE THING THAT MAKES THIS DIFFERENT FROM A NORMAL <video>: what the server hands back is not always a
// file. There are three transports, and `mode` names which one is in use rather than letting the difference
// leak out as mysterious behaviour:
//
// file — direct play or direct stream. A real byte-range resource; native seeking, `currentTime` is
// the position, and a scrub can follow the pointer.
// hls-mse — a transcode, driven by the vendored hls.js over Media Source Extensions. A real timeline
// too: Jellyfin's playlist is VOD and spans the whole runtime, so a scrub is a segment
// request and the server moves its ffmpeg to that point. Committed on release, because each
// intermediate position would be a real repositioning of that ffmpeg.
// progressive — the fallback for a browser without MSE. One endless mp4 with no length and no ranges, so
// the browser CANNOT seek it: seeking means asking the server for a NEW stream at an offset,
// and the position is `startSeconds + currentTime`.
//
// `hasTimeline` is the single flag every other decision here reads.
//
// Safari plays an m3u8 natively and is deliberately NOT given one. Segment URIs inside the playlist are
// relative, so they inherit the playlist's path but not its `?token=` — every segment would come back 401,
// and Safari cannot set an Authorization header the way hls.js can. Where hls.js runs (including iOS 17.1+,
// via Managed Media Source) it is used; where it does not, progressive is the honest fallback.
//
// The controls are ours in every mode. Not for looks: progressive has no timeline the browser can render, and
// an audio-track or quality change is a re-negotiation with the server rather than something a <video> knows
// how to do. One control bar for all three is what keeps those from being three different players.
//
// NO SCRUBBER THUMBNAILS. They come from Jellyfin's trickplay tiles, and this server generates none
// (`item.Trickplay` is `{}`) — a hover preview would be a permanently empty box.
const PROGRESS_INTERVAL_MS = 10_000;
const CHROME_HIDE_MS = 2_500;
/** Manhattan pixels a pointer must travel before it counts as the user, rather than as a resting mouse. */
const WAKE_THRESHOLD_PX = 8;
const VOLUME_KEY = 'jellyfin:volume';
type Mode = 'file' | 'hls-mse' | 'progressive';
function transportFor(plan: PlaybackPlan): Mode {
if (plan.seekable) return 'file';
if (plan.hlsUrl && Hls.isSupported()) return 'hls-mse';
return 'progressive';
}
function readStoredVolume(): { volume: number; muted: boolean } {
try {
const raw = localStorage.getItem(VOLUME_KEY);
if (!raw) return { volume: 1, muted: false };
const parsed = JSON.parse(raw) as { volume?: number; muted?: boolean };
return { volume: Math.min(1, Math.max(0, parsed.volume ?? 1)), muted: !!parsed.muted };
} catch {
return { volume: 1, muted: false };
}
}
/** How far the element has buffered past the point being played, flattened to one number for the bar. */
function bufferedAhead(video: HTMLVideoElement): number {
const { buffered, currentTime } = video;
for (let i = 0; i < buffered.length; i += 1) {
if (buffered.start(i) <= currentTime && buffered.end(i) >= currentTime) return buffered.end(i);
}
return currentTime;
}
export const VideoPlayer = ({ id }: { id: string }) => {
const { token } = useClient();
const { pathname } = useLocation();
const [params] = useSearchParams();
const { data: item } = useJellyfinItem(id);
const negotiate = usePlaybackPlan();
const report = usePlaystateReporter();
const shellRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [plan, setPlan] = useState<PlaybackPlan | null>(null);
const [error, setError] = useState<string | null>(null);
const [position, setPosition] = useState(0);
const [bufferedTo, setBufferedTo] = useState(0);
const [playing, setPlaying] = useState(false);
const stored = useMemo(readStoredVolume, []);
const [volume, setVolume] = useState(stored.volume);
const [muted, setMuted] = useState(stored.muted);
const [rate, setRate] = useState(1);
const [subtitleIndex, setSubtitleIndex] = useState<number | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const [chrome, setChrome] = useState(true);
// Refs shadow the state the unmount cleanup needs. A cleanup closes over the values from the render that
// registered it, and the last thing this component does — report where playback stopped — must use the
// position at the moment of unmount, not the one from whenever the effect last ran.
const planRef = useRef<PlaybackPlan | null>(null);
const positionRef = useRef(0);
const startedRef = useRef(false);
const idRef = useRef(id);
idRef.current = id;
// React Query hands back a new mutation object each render, so this is the same story as `report` below:
// keeping `open` stable is what keeps the start-once effect from re-running on every frame of playback.
const negotiateRef = useRef(negotiate);
negotiateRef.current = negotiate;
// The chosen track and rung, kept in refs so `open` does not have to be rebuilt (and the start-once effect
// re-run) every time one of them changes.
const audioRef = useRef<number | null>(null);
const bitrateRef = useRef<number | null>(null);
// Where the pointer was when it last counted as movement — see `onPointerMove`.
const pointerRef = useRef<{ x: number; y: number } | null>(null);
const mode = useMemo(() => (plan ? transportFor(plan) : null), [plan]);
const hasTimeline = mode !== null && mode !== 'progressive';
const duration = plan?.runtimeSeconds ?? ticksToSeconds(item?.RunTimeTicks);
const subtitle = plan?.subtitles.find((track) => track.index === subtitleIndex && track.url) ?? null;
// `report` is a NEW function on every render — `useClient()` rebuilds its verbs each time — so it is held in
// a ref rather than named as a dependency. This is not a micro-optimisation: an unstable `sendReport` made
// the "report where playback stopped" cleanup below re-run on every render, which nulled `planRef` a few
// milliseconds after the stream opened and turned every later report into a no-op. Nothing ever reached
// Jellyfin, so nothing ever appeared in Continue Watching.
const reportRef = useRef(report);
reportRef.current = report;
const sendReport = useCallback((action: 'playing' | 'progress' | 'stopped', isPaused = false) => {
const current = planRef.current;
if (!current) return;
reportRef.current(action, {
ItemId: idRef.current,
PlaySessionId: current.playSessionId,
MediaSourceId: current.mediaSourceId,
PositionTicks: Math.max(0, Math.round(positionRef.current * TICKS_PER_SECOND)),
IsPaused: isPaused,
CanSeek: current.seekable,
});
}, []);
/**
* Open (or re-open) the stream. In progressive mode a seek is exactly this call; so is any audio-track or
* quality change in every mode, because both are decided during negotiation and baked into the stream.
*/
const open = useCallback(
async (startSeconds: number) => {
// Tell the server the previous session ended before starting another, or its ffmpeg keeps running and
// Jellyfin shows two sessions playing the same thing.
if (planRef.current) sendReport('stopped');
setError(null);
try {
const next = await negotiateRef.current.mutateAsync({
id: idRef.current,
startSeconds,
audioStreamIndex: audioRef.current ?? undefined,
maxStreamingBitrate: bitrateRef.current ?? undefined,
});
planRef.current = next;
positionRef.current = next.startSeconds;
setPlan(next);
setPosition(next.startSeconds);
setBufferedTo(next.startSeconds);
} catch (err) {
setError((err as { message?: string } | null)?.message ?? 'Playback could not be started');
}
},
[sendReport],
);
// Start once, at the stored resume point. Guarded by a ref rather than by the dependency list because
// React runs effects twice in development and a second negotiation would start a second transcode.
useEffect(() => {
if (startedRef.current || !item) return;
startedRef.current = true;
void open(ticksToSeconds(item.UserData?.PlaybackPositionTicks));
}, [item, open]);
// hls.js owns the element's buffer in `hls-mse` mode, so the element gets no `src` at all — attaching one
// alongside MSE is how you get two sources fighting over the same video.
//
// The credential goes on as a header rather than in the URL because hls.js fetches every segment itself:
// segment URIs are RELATIVE to the playlist, so a `?token=` on the playlist would not survive onto them.
// `xhrSetup` runs before hls.js opens the request and it skips its own `open` if one already happened,
// which is what makes setting a header here possible at all.
useEffect(() => {
const video = videoRef.current;
if (mode !== 'hls-mse' || !plan?.hlsUrl || !video) return;
const hls = new Hls({
startPosition: plan.startSeconds > 0 ? plan.startSeconds : -1,
xhrSetup: (xhr, url) => {
xhr.open('GET', url, true);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
},
});
// Non-fatal errors are hls.js's normal weather and it recovers from them itself. A fatal one is worth one
// attempt at the documented recovery for its kind before giving the player back to the user as broken.
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();
else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else setError('The stream stopped unexpectedly.');
});
hls.loadSource(`/api/jellyfin${plan.hlsUrl}`);
hls.attachMedia(video);
return () => hls.destroy();
}, [mode, plan, token]);
// The stop report, on the way out. Also the only place the server learns a transcode is no longer wanted.
// This must run exactly once, at unmount: the cleanup nulls `planRef`, so anything that made it re-run
// mid-playback would silently switch off every report that follows. `sendReport` is `useCallback(…, [])`
// and never changes identity, which is what makes naming it here equivalent to an empty list — and is why
// it has to stay that way. If it ever grows a dependency, this becomes the Continue Watching bug again.
useEffect(
() => () => {
sendReport('stopped');
planRef.current = null;
},
[sendReport],
);
useEffect(() => {
if (!plan) return;
const timer = setInterval(() => sendReport('progress', videoRef.current?.paused ?? false), PROGRESS_INTERVAL_MS);
return () => clearInterval(timer);
}, [plan, sendReport]);
// Volume, mute and rate are element properties, not attributes — React will not set them from JSX, and they
// have to be re-applied after the element remounts (which in progressive mode is every scrub).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
video.muted = muted;
video.playbackRate = rate;
}, [volume, muted, rate, plan]);
useEffect(() => {
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
}, [volume, muted]);
// Only the selected subtitle is rendered as a <track>, so Jellyfin is asked to extract exactly one — and
// `mode` has to be set imperatively because `default` only applies on the element's first parse.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
for (let i = 0; i < video.textTracks.length; i += 1) {
video.textTracks[i]!.mode = subtitle ? 'showing' : 'disabled';
}
}, [subtitle, plan]);
useEffect(() => {
const onChange = () => setFullscreen(document.fullscreenElement === shellRef.current);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
// Auto-hide, but never while paused — a paused player with no controls looks broken rather than clean.
//
// `position` must NOT be a dependency, however much it reads like activity. It advances ~4x a second from
// onTimeUpdate, so naming it here re-ran this effect — and so cleared the pending timer — long before 2.5s
// had passed. The chrome could then only hide while playback was stopped, which is to say never. Real
// activity is `wake()`, which sets `chrome` back to true and restarts the timer through the dep below.
useEffect(() => {
if (!chrome || !playing) return;
const timer = setTimeout(() => setChrome(false), CHROME_HIDE_MS);
return () => clearTimeout(timer);
}, [chrome, playing]);
const seek = useCallback(
(seconds: number) => {
const video = videoRef.current;
const clamped = Math.max(0, duration ? Math.min(seconds, duration - 1) : seconds);
if (hasTimeline && video) {
video.currentTime = clamped;
return;
}
void open(clamped);
},
[duration, hasTimeline, open],
);
const togglePlay = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused) void video.play().catch(() => undefined);
else video.pause();
}, []);
const toggleFullscreen = useCallback(() => {
// The container, not the <video> — fullscreening the element itself would take the controls with it into
// a UA-drawn shell that has none of these buttons.
if (document.fullscreenElement) void document.exitFullscreen().catch(() => undefined);
else void shellRef.current?.requestFullscreen().catch(() => undefined);
}, []);
const changeAudio = (index: number) => {
audioRef.current = index;
void open(positionRef.current);
};
const changeQuality = (bitrate: number | null) => {
bitrateRef.current = bitrate;
void open(positionRef.current);
};
const wake = () => setChrome(true);
/**
* Wake on real movement only.
*
* A bare `pointermove` handler is too credulous to sit in front of a two-hour film: a mouse resting on a
* desk still emits the odd one-pixel event, a bumped table emits a few, and a browser will synthesise a
* zero-delta move of its own when the cursor style changes — which this player does every time it hides the
* cursor. Any one of those read as "the user is here" and put the scrubber back for two and a half seconds,
* minutes apart, with nobody having touched anything.
*
* The anchor only moves when a wake actually happens, so a slow deliberate drift still accumulates past the
* threshold and wakes; it is jitter around a fixed point that never does.
*/
const onPointerMove = (ev: React.PointerEvent) => {
const last = pointerRef.current;
if (last && Math.abs(ev.clientX - last.x) + Math.abs(ev.clientY - last.y) < WAKE_THRESHOLD_PX) return;
pointerRef.current = { x: ev.clientX, y: ev.clientY };
wake();
};
// Keyboard on the container rather than on window: the player is a panel inside a workspace, and stealing
// space or the arrow keys from whatever else is on screen would be wrong. Autofocus puts it in reach.
const onKeyDown = (ev: React.KeyboardEvent) => {
const target = ev.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
const bump = (delta: number) => setVolume((was) => Math.min(1, Math.max(0, was + delta)));
const keys: Record<string, () => void> = {
' ': togglePlay,
k: togglePlay,
ArrowLeft: () => seek(positionRef.current - 10),
j: () => seek(positionRef.current - 10),
ArrowRight: () => seek(positionRef.current + 10),
l: () => seek(positionRef.current + 10),
ArrowUp: () => bump(0.05),
ArrowDown: () => bump(-0.05),
m: () => setMuted((was) => !was),
f: toggleFullscreen,
c: () => setSubtitleIndex((was) => (was !== null ? null : (plan?.subtitles.find((s) => s.url)?.index ?? null))),
};
const action = keys[ev.key];
if (!action) return;
ev.preventDefault();
wake();
action();
};
const onTimeUpdate = () => {
const video = videoRef.current;
if (!video || !plan) return;
// Only progressive starts the element's clock at zero for a stream that begins mid-film.
const offset = hasTimeline ? 0 : plan.startSeconds;
positionRef.current = offset + video.currentTime;
setPosition(offset + video.currentTime);
setBufferedTo(offset + bufferedAhead(video));
};
// `src` for everything except MSE, where hls.js feeds the element instead. The key remounts the element when
// the source genuinely changes — which in progressive mode is every scrub.
const source = plan && mode !== 'hls-mse' ? streamUrl(plan.url, token) : null;
const close = closeHref(pathname, params, PLAY_PARAM);
const title = item ? episodeLabel(item) : '';
const idle = playing && !chrome;
return (
<div
ref={shellRef}
tabIndex={-1}
autoFocus
onKeyDown={onKeyDown}
onPointerMove={onPointerMove}
onPointerDown={wake}
className={`absolute inset-0 z-30 bg-black outline-none ${idle ? 'cursor-none' : ''}`}
>
{/* Chrome floats OVER the video rather than sitting beside it in a column, so that going fullscreen —
which fullscreens this container — does not change the video's size or the layout around it. */}
<div
className={`absolute inset-x-0 top-0 z-10 flex items-center gap-2 bg-gradient-to-b from-black/80 to-transparent px-3 py-2 text-white transition-opacity ${
idle ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<span className="min-w-0 flex-1 truncate text-xs font-medium">{title}</span>
{plan && plan.playMethod !== 'DirectPlay' && (
<span className="rounded bg-white/10 px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
{plan.playMethod === 'Transcode' ? 'transcoding' : 'remuxing'}
</span>
)}
<Link
to={close}
aria-label="Close player"
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-white/10"
>
<X className="h-4 w-4" />
</Link>
</div>
<div className="absolute inset-0 bg-black">
{error ? (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center text-white">
<p className="text-sm font-medium">This will not play</p>
<p className="max-w-md text-xs text-white/60">{error}</p>
</div>
) : plan ? (
<video
key={mode === 'hls-mse' ? (plan.hlsUrl ?? plan.url) : plan.url}
ref={videoRef}
src={source ?? undefined}
className="h-full w-full"
autoPlay
playsInline
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
onTimeUpdate={onTimeUpdate}
onProgress={onTimeUpdate}
onPlay={() => {
setPlaying(true);
sendReport('playing');
}}
onPause={() => {
setPlaying(false);
setChrome(true);
sendReport('progress', true);
}}
onEnded={() => {
setPlaying(false);
sendReport('stopped');
}}
// hls.js reports its own errors through the ERROR event, and sets a MediaError on the element for
// ones it is already recovering from — so in MSE mode this handler would fire on the recoverable.
onError={() => mode !== 'hls-mse' && setError('The stream stopped unexpectedly.')}
>
{/* A <track> cannot send headers, so this is the `?token=` form. Safe here in a way an m3u8 is
not: it is one self-contained file with no relative children to lose the query string. */}
{subtitle && (
<track
key={subtitle.index}
kind="subtitles"
src={streamUrl(subtitle.url!, token)}
srcLang={subtitle.language ?? undefined}
label={subtitle.label}
default
/>
)}
</video>
) : (
<div className="flex h-full items-center justify-center text-white/60">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</div>
{plan && !error && (
<div
className={`absolute inset-x-0 bottom-0 z-10 transition-opacity ${
idle ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<PlayerControls
playing={playing}
position={position}
duration={duration}
bufferedTo={bufferedTo}
liveScrub={mode === 'file'}
volume={volume}
muted={muted}
rate={rate}
fullscreen={fullscreen}
audio={plan.audio}
audioIndex={plan.audioStreamIndex}
subtitles={plan.subtitles}
subtitleIndex={subtitleIndex}
qualityRungs={plan.qualityRungs}
maxBitrate={plan.maxStreamingBitrate}
busy={negotiate.isPending}
onTogglePlay={togglePlay}
onScrub={seek}
onScrubCommit={seek}
onSkip={(delta) => seek(positionRef.current + delta)}
onVolume={(next) => {
setVolume(next);
setMuted(next === 0);
}}
onToggleMute={() => setMuted((was) => !was)}
onRate={setRate}
onAudio={changeAudio}
onSubtitle={setSubtitleIndex}
onQuality={changeQuality}
onFullscreen={toggleFullscreen}
/>
</div>
)}
</div>
);
};
@@ -1,19 +0,0 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Clapperboard, PanelLeft } from 'lucide-react';
import { JellyfinNav } from './JellyfinNav';
import { JellyfinView } from './JellyfinView';
import { JellyfinViewHeader } from './JellyfinViewHeader';
export { JellyfinNav, JellyfinView };
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'jellyfin-nav', name: 'Video', icon: PanelLeft, component: JellyfinNav, availableOnPanel: false },
{
key: 'jellyfin-view',
name: 'Video',
icon: Clapperboard,
component: JellyfinView,
header: JellyfinViewHeader,
availableOnPanel: false,
},
];
@@ -1,331 +0,0 @@
// Shared types, constants and pure helpers for the /jellyfin workspace panels.
//
// The types mirror Jellyfin's `BaseItemDto` rather than reshaping it, because the sidecar's `/_officer/*`
// façade is a pass-through for the item shapes themselves — what it adds is the user id, the Fields list and
// the playback negotiation, not a different vocabulary. Every field here is optional in Jellyfin's own schema
// and is optional here too: an Episode has no `CollectionType`, a Movie has no `SeriesId`, and a library
// mid-scan can return an item with nothing but an Id.
//
// VIDEO ONLY. The owner's music is Officer's own /music player, and this machine runs separate Jellyfin
// instances for albums and DJ sets; the sidecar already filters music libraries out of `/views` and `/home`.
export const JELLYFIN_SECTIONS = [
{ id: 'home', label: 'Home' },
{ id: 'library', label: 'Library' },
{ id: 'search', label: 'Search' },
{ id: 'settings', label: 'Connection' },
] as const;
export type JellyfinSectionId = (typeof JELLYFIN_SECTIONS)[number]['id'];
/** Where /jellyfin lands, and where an unrecognised section redirects to. */
export const DEFAULT_JELLYFIN_SECTION: JellyfinSectionId = 'home';
export const isJellyfinSection = (value: string | undefined): value is JellyfinSectionId =>
JELLYFIN_SECTIONS.some((s) => s.id === value);
/** The one place the section URL is spelled, so nav, guard and deep links cannot drift apart. */
export const jellyfinSectionPath = (id: JellyfinSectionId) => `/jellyfin/${id}`;
/** Which library the browse grid is showing — `?view=<id>`. */
export const VIEW_PARAM = 'view';
/** Which item is open in the detail sheet — `?item=<id>`. */
export const ITEM_PARAM = 'item';
/** Which item is playing — `?play=<id>`. Separate from `item` so closing the player returns to the detail. */
export const PLAY_PARAM = 'play';
/** The search text — `?q=…`. */
export const QUERY_PARAM = 'q';
/**
* The href that opens an item, keeping the page you opened it from.
*
* Selection is a query parameter rather than a route because the detail sheet is an overlay ON the grid: the
* grid stays mounted and scrolled behind it, and closing is dropping one parameter. Building the href from the
* CURRENT params is what makes that work in both directions — open an episode from a search and closing it
* puts the search back, unchanged.
*/
export function itemHref(pathname: string, params: URLSearchParams, id: string, play = false): string {
const next = new URLSearchParams(params);
next.set(play ? PLAY_PARAM : ITEM_PARAM, id);
if (play) next.delete(ITEM_PARAM);
return `${pathname}?${next}`;
}
/** The href that closes whichever overlay is open, leaving everything else about the page alone. */
export function closeHref(pathname: string, params: URLSearchParams, which: typeof ITEM_PARAM | typeof PLAY_PARAM) {
const next = new URLSearchParams(params);
next.delete(which);
const qs = next.toString();
return qs ? `${pathname}?${qs}` : pathname;
}
// ── Wire types ────────────────────────────────────────────────────────────────────────────────────
export type UserItemData = {
PlaybackPositionTicks?: number;
PlayCount?: number;
IsFavorite?: boolean;
Played?: boolean;
PlayedPercentage?: number | null;
UnplayedItemCount?: number;
};
export type MediaStream = {
Type?: string;
Index?: number;
Codec?: string | null;
Language?: string | null;
DisplayTitle?: string | null;
Height?: number | null;
Width?: number | null;
Channels?: number | null;
IsDefault?: boolean;
IsForced?: boolean;
};
export type MediaSource = {
Id?: string;
Name?: string | null;
Container?: string | null;
Size?: number | null;
RunTimeTicks?: number | null;
Bitrate?: number | null;
MediaStreams?: MediaStream[];
SupportsDirectPlay?: boolean;
SupportsDirectStream?: boolean;
};
export type Person = { Id?: string; Name?: string; Role?: string | null; Type?: string; PrimaryImageTag?: string };
export type JellyItem = {
Id: string;
Name?: string;
Type?: string;
CollectionType?: string | null;
ProductionYear?: number | null;
PremiereDate?: string | null;
EndDate?: string | null;
Status?: string | null;
Overview?: string | null;
Taglines?: string[];
Genres?: string[];
OfficialRating?: string | null;
CommunityRating?: number | null;
CriticRating?: number | null;
RunTimeTicks?: number | null;
IndexNumber?: number | null;
ParentIndexNumber?: number | null;
SeriesId?: string | null;
SeriesName?: string | null;
SeasonId?: string | null;
SeasonName?: string | null;
ParentId?: string | null;
ChildCount?: number | null;
RecursiveItemCount?: number | null;
PrimaryImageAspectRatio?: number | null;
ImageTags?: Record<string, string>;
BackdropImageTags?: string[];
ParentBackdropItemId?: string | null;
ParentBackdropImageTags?: string[];
SeriesPrimaryImageTag?: string | null;
Studios?: { Id?: string; Name?: string }[];
People?: Person[];
MediaSources?: MediaSource[];
MediaStreams?: MediaStream[];
UserData?: UserItemData;
};
export type ItemsResponse = { Items?: JellyItem[]; TotalRecordCount?: number };
export type HomeResponse = {
views: JellyItem[];
resume: JellyItem[];
nextUp: JellyItem[];
latest: { viewId: string; viewName: string; collectionType: string | null; items: JellyItem[] }[];
};
export type AudioTrack = {
index: number;
label: string;
language: string | null;
codec: string | null;
channels: number | null;
isDefault: boolean;
};
export type SubtitleTrack = {
index: number;
label: string;
language: string | null;
codec: string | null;
isDefault: boolean;
isForced: boolean;
/** A `/_jf` path to a WebVTT file, or null when this track cannot be delivered as one. */
url: string | null;
/** Why `url` is null, in words the picker shows. Null when the track is fine. */
unavailable: string | null;
};
export type QualityRung = { label: string; videoBitrate: number; maxWidth: number };
/** What `/_officer/items/{id}/playback` answers — the sidecar's own shape, not Jellyfin's. */
export type PlaybackPlan = {
playMethod: 'DirectPlay' | 'DirectStream' | 'Transcode';
url: string;
/** A direct file seeks in the browser; a live transcode does not, and the player restarts it at an offset. */
seekable: boolean;
hlsUrl: string | null;
playSessionId: string | null;
mediaSourceId: string | null;
mediaSource: MediaSource;
startSeconds: number;
runtimeSeconds: number | null;
audio: AudioTrack[];
subtitles: SubtitleTrack[];
/** Echoed back so the picker shows what is actually playing, not what was last clicked. */
audioStreamIndex: number | null;
/** Null means Auto — the server's own copy-or-encode decision rather than a rung the owner picked. */
maxStreamingBitrate: number | null;
qualityRungs: QualityRung[];
};
// ── Media URLs ────────────────────────────────────────────────────────────────────────────────────
//
// An <img> cannot send an Authorization header, so media URLs carry `?token=` instead — the escape hatch
// userMiddleware already supports for <img>/<audio>/<video>, and the one the music and photos apps use. These
// point at `/_jf`, the authenticated byte pass-through, which keeps Jellyfin's own path shapes: an HLS
// playlist references its segments relatively, so any renaming here would mean rewriting m3u8 bodies.
export const JELLYFIN_BYTES = '/api/jellyfin/_jf';
const withToken = (url: string, token: string | null) =>
token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url;
/** A `/_jf/...` path from a playback plan, made absolute and authenticated for a `<video>` element. */
export const streamUrl = (path: string, token: string | null): string => withToken(`/api/jellyfin${path}`, token);
type ImageKind = 'Primary' | 'Backdrop' | 'Thumb' | 'Logo';
type ImageOptions = { tag?: string | null; maxWidth?: number; maxHeight?: number; index?: number };
export function imageUrl(id: string, kind: ImageKind, token: string | null, options: ImageOptions = {}): string {
const params = new URLSearchParams();
// The tag is the image's content hash. Including it is what makes these URLs safely cacheable forever —
// without it, a replaced poster keeps showing the old one until the browser cache expires.
if (options.tag) params.set('tag', options.tag);
if (options.maxWidth) params.set('maxWidth', String(options.maxWidth));
if (options.maxHeight) params.set('maxHeight', String(options.maxHeight));
params.set('quality', '90');
const suffix = options.index != null ? `/${options.index}` : '';
return withToken(`${JELLYFIN_BYTES}/Items/${id}/Images/${kind}${suffix}?${params}`, token);
}
/**
* The poster for a grid tile, falling back the way Jellyfin's own clients do.
*
* An episode usually has no poster of its own — it has a Thumb, and its series has the Primary. Falling back
* to the series poster is what stops a season view from being a wall of grey rectangles.
*/
export function posterUrl(item: JellyItem, token: string | null, maxWidth = 400): string | null {
const primary = item.ImageTags?.Primary;
if (primary) return imageUrl(item.Id, 'Primary', token, { tag: primary, maxWidth });
if (item.SeriesPrimaryImageTag && item.SeriesId) {
return imageUrl(item.SeriesId, 'Primary', token, { tag: item.SeriesPrimaryImageTag, maxWidth });
}
const thumb = item.ImageTags?.Thumb;
if (thumb) return imageUrl(item.Id, 'Thumb', token, { tag: thumb, maxWidth });
return null;
}
/** The wide still for a Continue Watching card: the episode's own thumb, else a backdrop, else the poster. */
export function thumbUrl(item: JellyItem, token: string | null, maxWidth = 520): string | null {
const thumb = item.ImageTags?.Thumb;
if (thumb) return imageUrl(item.Id, 'Thumb', token, { tag: thumb, maxWidth });
const backdrop = item.BackdropImageTags?.[0];
if (backdrop) return imageUrl(item.Id, 'Backdrop', token, { tag: backdrop, maxWidth, index: 0 });
const parentBackdrop = item.ParentBackdropImageTags?.[0];
if (parentBackdrop && item.ParentBackdropItemId) {
return imageUrl(item.ParentBackdropItemId, 'Backdrop', token, { tag: parentBackdrop, maxWidth, index: 0 });
}
return posterUrl(item, token, maxWidth);
}
export function backdropUrl(item: JellyItem, token: string | null, maxWidth = 1600): string | null {
const backdrop = item.BackdropImageTags?.[0];
if (backdrop) return imageUrl(item.Id, 'Backdrop', token, { tag: backdrop, maxWidth, index: 0 });
const parentBackdrop = item.ParentBackdropImageTags?.[0];
if (parentBackdrop && item.ParentBackdropItemId) {
return imageUrl(item.ParentBackdropItemId, 'Backdrop', token, { tag: parentBackdrop, maxWidth, index: 0 });
}
return null;
}
// ── Formatting ────────────────────────────────────────────────────────────────────────────────────
export const TICKS_PER_SECOND = 10_000_000;
export const ticksToSeconds = (ticks: number | null | undefined): number => (ticks ? ticks / TICKS_PER_SECOND : 0);
/** Runtime as "1h 42m" / "48m". Blank rather than "0m" when unknown — a wrong number reads as a real one. */
export function formatRuntime(ticks: number | null | undefined): string {
const total = Math.round(ticksToSeconds(ticks) / 60);
if (!total) return '';
const hours = Math.floor(total / 60);
const minutes = total % 60;
return hours ? `${hours}h ${minutes}m` : `${minutes}m`;
}
/** Clock time for the player scrubber — "12:34" under an hour, "1:02:03" over it. */
export function formatClock(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const total = Math.floor(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
export function formatBytes(bytes: number | null | undefined): string {
if (!bytes) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
}
export const formatShortDate = (iso: string | null | undefined): string =>
iso ? new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '—';
/** How far through an item is, 0–100. Jellyfin sends this only sometimes, so it is also derived. */
export function progressPercent(item: JellyItem): number {
const data = item.UserData;
if (!data) return 0;
if (typeof data.PlayedPercentage === 'number') return Math.min(100, Math.max(0, data.PlayedPercentage));
const position = ticksToSeconds(data.PlaybackPositionTicks);
const runtime = ticksToSeconds(item.RunTimeTicks);
return runtime > 0 && position > 0 ? Math.min(100, (position / runtime) * 100) : 0;
}
/** "S2:E5 · Episode name" for an episode, the plain name for anything else. */
export function episodeLabel(item: JellyItem): string {
if (item.Type !== 'Episode') return item.Name ?? 'Untitled';
const season = item.ParentIndexNumber != null ? `S${item.ParentIndexNumber}` : '';
const episode = item.IndexNumber != null ? `E${item.IndexNumber}` : '';
const number = [season, episode].filter(Boolean).join(':');
return number ? `${number} · ${item.Name ?? ''}`.trim() : (item.Name ?? 'Untitled');
}
/** The line under a title in a grid: the series for an episode, otherwise the year. */
export function subtitleFor(item: JellyItem): string {
if (item.Type === 'Episode') return item.SeriesName ?? '';
if (item.Type === 'Season') return item.SeriesName ?? '';
return item.ProductionYear ? String(item.ProductionYear) : '';
}
/** Which grids get a 2:3 poster and which get a 16:9 still. Episodes are wide; everything else is tall. */
export const isWideItem = (item: JellyItem): boolean => item.Type === 'Episode';
@@ -1,323 +0,0 @@
import type { HomeResponse, ItemsResponse, JellyItem, PlaybackPlan } from './shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
// Reads for the /jellyfin panels. Everything goes through /api/jellyfin/_officer/*, the auth proxy in front
// of the officer-jellyfin sidecar — the browser never learns the Jellyfin URL or its access token.
//
// One query key namespace, ['jellyfin'], and every mutation invalidates all of it. That is coarse on purpose:
// marking an episode watched changes Continue Watching, Next Up, the season list and the item itself, and
// enumerating those relationships would be four chances to forget one.
const KEY = 'jellyfin';
const query = (params: Record<string, string | number | boolean | null | undefined>): string => {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') search.set(key, String(value));
}
const qs = search.toString();
return qs ? `?${qs}` : '';
};
/** Libraries, Continue Watching, Next Up and the newest thing per library — one round trip. */
export function useJellyfinHome(enabled = true) {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'home'],
queryFn: () => get<HomeResponse>('/jellyfin/_officer/home'),
enabled,
staleTime: 30_000,
});
}
/** Just the libraries — what the nav lists. Cheap enough to keep separate from the whole home payload. */
export function useJellyfinViews(enabled = true) {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'views'],
queryFn: () => get<{ views: JellyItem[] }>('/jellyfin/_officer/views'),
enabled,
staleTime: 5 * 60_000,
});
}
export type BrowseFilter = {
parentId?: string;
includeItemTypes?: string;
sortBy?: string;
sortOrder?: 'Ascending' | 'Descending';
startIndex?: number;
limit?: number;
filters?: string;
isFavorite?: boolean;
recursive?: boolean;
collapseBoxSetItems?: boolean;
searchTerm?: string;
};
export function useJellyfinItems(filter: BrowseFilter, enabled = true) {
const { get } = useClient();
const qs = query(filter);
return useQuery({
queryKey: [KEY, 'items', qs],
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/items${qs}`),
enabled,
staleTime: 30_000,
});
}
export function useJellyfinItem(id: string | null) {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'item', id],
queryFn: () => get<JellyItem>(`/jellyfin/_officer/items/${id}`),
enabled: !!id,
staleTime: 30_000,
});
}
export function useSeasons(seriesId: string | null) {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'seasons', seriesId],
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/shows/${seriesId}/seasons`),
enabled: !!seriesId,
staleTime: 60_000,
});
}
export function useEpisodes(seriesId: string | null, seasonId: string | null) {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'episodes', seriesId, seasonId],
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/shows/${seriesId}/episodes${query({ seasonId })}`),
enabled: !!seriesId && !!seasonId,
staleTime: 60_000,
});
}
export function useJellyfinSearch(term: string, enabled = true) {
const { get } = useClient();
const trimmed = term.trim();
return useQuery({
queryKey: [KEY, 'search', trimmed],
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/search${query({ q: trimmed })}`),
enabled: enabled && trimmed.length > 1,
staleTime: 30_000,
});
}
/** Watched and favourite marks. Both invalidate everything, because both change Next Up and Resume. */
export function useItemFlags() {
const { post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: [KEY] });
const setPlayed = useMutation({
mutationFn: ({ id, played }: { id: string; played: boolean }) =>
played ? post(`/jellyfin/_officer/items/${id}/played`, {}) : del(`/jellyfin/_officer/items/${id}/played`),
onSuccess: invalidate,
});
const setFavorite = useMutation({
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
favorite ? post(`/jellyfin/_officer/items/${id}/favorite`, {}) : del(`/jellyfin/_officer/items/${id}/favorite`),
onSuccess: invalidate,
});
return { setPlayed, setFavorite };
}
// ── Playback ──────────────────────────────────────────────────────────────────────────────────────
export type PlaybackRequest = {
id: string;
startSeconds: number;
mediaSourceId?: string;
audioStreamIndex?: number;
/** A rung the owner picked. Omitted means Auto — the server decides whether it can copy the video. */
maxStreamingBitrate?: number;
};
/**
* Negotiate a stream. A mutation rather than a query on purpose: it has a side effect on the server —
* `AutoOpenLiveStream` starts an ffmpeg process for a transcode — so it must never be replayed by a refetch,
* a window focus, or React's strict-mode double render.
*/
export function usePlaybackPlan() {
const { post } = useClient();
return useMutation({
mutationFn: ({ id, startSeconds, mediaSourceId, audioStreamIndex, maxStreamingBitrate }: PlaybackRequest) =>
post<PlaybackPlan>(`/jellyfin/_officer/items/${id}/playback${query({ startSeconds })}`, {
mediaSourceId,
audioStreamIndex,
maxStreamingBitrate,
}),
});
}
export type PlaystateReport = {
ItemId: string;
PlaySessionId?: string | null;
MediaSourceId?: string | null;
PositionTicks: number;
IsPaused?: boolean;
CanSeek?: boolean;
};
/**
* Tell Jellyfin where playback is, so resume works and the server knows a session is alive.
*
* `fire-and-forget` is deliberate: a dropped progress ping costs a few seconds of resume accuracy, and
* surfacing it as an error over the video would be a worse trade. The stop report is the one that matters,
* and it is also the one most likely to race a page unload — hence `sendBeacon`-shaped semantics (no
* response read, no await from the caller).
*/
export function usePlaystateReporter() {
const { post } = useClient();
const qc = useQueryClient();
const report = (action: 'playing' | 'progress' | 'stopped', body: PlaystateReport) => {
void post(`/jellyfin/_officer/sessions/${action}`, body).catch(() => undefined);
// Only a stop changes what the shelves should show. Pinging progress every ten seconds and invalidating
// each time would refetch the whole home screen underneath a playing video.
if (action === 'stopped') void qc.invalidateQueries({ queryKey: [KEY] });
};
return report;
}
// ── Servers ───────────────────────────────────────────────────────────────────────────────────────
//
// Jellyfin instances and the account each is signed in as are the owner's to set from /jellyfin/settings.
// It is a REGISTRY — any number of labelled servers, one selected — because this machine genuinely runs
// several. The access token is WRITE-ONLY across this boundary: a server carries its label, URL, account name
// and whether it is selected, and has no field that could carry the token back to the browser.
export type JellyfinHealth = {
ok: boolean;
/** False only when no server is stored. It is what separates "set this up" from "this used to work". */
configured?: boolean;
/** Label of the selected server, so a failure names which one did not answer. */
server?: string;
serverName?: string | null;
version?: string | null;
ms?: number;
error?: string;
};
export type JellyfinServerRow = {
id: number;
label: string;
url: string;
jellyfinUsername: string | null;
serverName: string | null;
version: string | null;
isActive: boolean;
lastSeenAt: string | null;
createdAt: string;
};
export type JellyfinServers = { configured: boolean; activeId: number | null; servers: JellyfinServerRow[] };
/** Unwrap the `{ status, message }` useClient throws, where `message` is the sidecar's JSON body. */
export function jellyfinErrorMessage(err: unknown): string {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
try {
const parsed = JSON.parse(raw) as { error?: unknown };
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
} catch {
/* plain text */
}
return raw.slice(0, 300);
}
/**
* Health, including its failure bodies.
*
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
* the UI most needs to tell apart — 503 not configured and 502 configured-but-broken. Both carry a JSON body,
* so the throw is turned back into the answer rather than an error state.
*/
export function useJellyfinHealth() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'health'],
queryFn: async (): Promise<JellyfinHealth> => {
try {
return await get<JellyfinHealth>('/jellyfin/_health');
} catch (err) {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw === 'string') {
try {
const body = JSON.parse(raw) as JellyfinHealth;
if (body && body.ok === false) return body;
} catch {
/* not the sidecar's body */
}
}
// Anything else — the platform proxy, auth, the sidecar being down — is a configured server that is
// failing, not an unconfigured one. Never offer the setup form on a guess.
return { ok: false, configured: true, error: jellyfinErrorMessage(err) };
}
},
staleTime: 60_000,
retry: false,
});
}
export function useJellyfinServers() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'servers'],
queryFn: () => get<JellyfinServers>('/jellyfin/_config'),
staleTime: 60_000,
retry: false,
});
}
export type AddServerInput = { label: string; url: string; username: string; password: string };
export type EditServerInput = { id: number; label?: string; url?: string; username?: string; password?: string };
export function useJellyfinServerActions() {
const { get, post, patch, delete: del } = useClient();
const qc = useQueryClient();
// Adding, editing, switching and removing all change what every other query here can even answer — a switch
// in particular changes the answer to all of them without changing any of their inputs.
const invalidate = () => qc.invalidateQueries({ queryKey: [KEY] });
const add = useMutation({
mutationFn: (input: AddServerInput) => post<{ server: JellyfinServerRow }>('/jellyfin/_config', input),
onSuccess: invalidate,
});
const edit = useMutation({
mutationFn: ({ id, ...rest }: EditServerInput) =>
patch<{ server: JellyfinServerRow }>(`/jellyfin/_config/${id}`, rest),
onSuccess: invalidate,
});
const activate = useMutation({
mutationFn: (id: number) => post<JellyfinServers>(`/jellyfin/_config/${id}/activate`, {}),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: number) => del<JellyfinServers>(`/jellyfin/_config/${id}`),
onSuccess: invalidate,
});
// GET — probes one server and reports whether it answered. It does not switch to it (that is
// `activate`) and writes nothing. useMutation is still right: it is fired by a button, not rendered.
const test = useMutation({
mutationFn: (id: number) =>
get<{ ok: boolean; version?: string | null; serverName?: string | null; ms: number }>(
`/jellyfin/_config/${id}/test`,
),
onSuccess: invalidate,
});
return { add, edit, activate, remove, test };
}
@@ -1,10 +0,0 @@
import { useParams } from 'react-router';
import { DEFAULT_JELLYFIN_SECTION, isJellyfinSection, type JellyfinSectionId } from './shared';
// The URL names the section; nothing else does. JellyfinScreen redirects anything unrecognised, so the
// fallback here only covers the instant before that lands.
export function useJellyfinSection(): JellyfinSectionId {
const { section } = useParams();
return isJellyfinSection(section) ? section : DEFAULT_JELLYFIN_SECTION;
}
@@ -1,51 +0,0 @@
# Vendored hls.js
`hls.mjs` is **hls.js 1.6.16**, checked in rather than installed.
## Why it is here and not in `package.json`
Chromium cannot play an HLS playlist natively, so seeking inside a live Jellyfin transcode needs a player
library. `bunfig.toml` freezes installs (`frozenLockfile = true`) specifically so that adding a package is a
deliberate act with a reviewed lockfile diff — see `platform/CLAUDE.md` → "Installs are frozen", and the
2026-08-04 npm cache-package worm that prompted it.
Vendoring makes the trade explicit instead of implicit:
- **No install-time code.** A committed file has no `preinstall`/`postinstall` hook, which is the exact vector
that compromise used. hls.js's own tarball has no install script either — but a future version of it, or of
anything it might later depend on, could.
- **No transitive tree.** hls.js 1.6.16 publishes `dependencies: {}`, `peerDependencies: {}` and
`optionalDependencies: {}`, so nothing is being skipped by not resolving it. Its *build-time* devDependencies
do reach the affected packages (eslint 8 → file-entry-cache → flat-cache → keyv), but this artifact was
published 2026-04-13, months before those versions existed.
- **It is reviewable.** One unminified file in the diff. That is the point; do not replace it with a minified
build to save bytes.
The upstream `.d.ts` is *not* vendored — it imports a tree of internal type modules, which would undo the one
file rule. `hls.d.mts` next to it declares the API this player actually uses, by hand.
## Provenance
| | |
|---|---|
| Package | `hls.js@1.6.16` |
| Published | 2026-04-13 |
| Tarball | `https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz` |
| Tarball sha512 | `VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==` (matches the registry's published `dist.integrity`) |
| Extracted from | `package/dist/hls.mjs` |
| Local change | the trailing `//# sourceMappingURL=hls.mjs.map` line removed — the map is not vendored, and leaving the reference makes devtools 404 |
| `hls.mjs` sha256 | `45a4bb55e5346bf9284fa7e46b6fac7d62dce5b90394dcba9747d471940fea6e` as published; `ca442f1f963ff66607a242cc7be227b243770d86bfc3251bf675f8a342dfe798` as committed here (the two differ only by that removed line) |
| Licence | Apache-2.0 — `hls.LICENSE` |
## Updating it
```sh
V=1.6.17
curl -sO https://registry.npmjs.org/hls.js/-/hls.js-$V.tgz
# compare the sha512 against `curl -s https://registry.npmjs.org/hls.js/$V | jq -r .dist.integrity`
tar xzf hls.js-$V.tgz package/dist/hls.mjs package/LICENSE
```
Copy `package/dist/hls.mjs` over this one, drop the `sourceMappingURL` line, update the table above, and read
the diff. A version bump that arrives with a diff you did not read is the thing this directory exists to
prevent.
@@ -1,28 +0,0 @@
Copyright (c) 2017 Dailymotion (http://www.dailymotion.com)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project
are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls)
That work is also covered by the Apache 2 License, following copyright:
Copyright (c) 2013-2015 Brightcove
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -1,63 +0,0 @@
// Hand-written declarations for the vendored `hls.mjs`.
//
// Deliberately narrow: it declares the API this player actually calls, not hls.js's full surface. The upstream
// `.d.ts` is not vendored because it pulls in a tree of internal type modules, and the point of vendoring was
// to have ONE reviewable file. Anything added to the player that hls.js supports but this file does not gets
// declared here, in the open, rather than cast away at the call site.
export type HlsErrorData = {
fatal: boolean;
type: string;
details: string;
reason?: string;
error?: Error;
};
export type HlsLevel = { height?: number; width?: number; bitrate?: number };
export type HlsConfig = {
/** Where playback begins, in seconds. -1 means "the start", which is not the same as 0 for a live edge. */
startPosition?: number;
/** Called before `xhr.open`, so it may open the request itself and then set headers on it. */
xhrSetup?: (xhr: XMLHttpRequest, url: string) => void;
enableWorker?: boolean;
lowLatencyMode?: boolean;
maxBufferLength?: number;
maxMaxBufferLength?: number;
backBufferLength?: number;
};
declare class Hls {
constructor(config?: HlsConfig);
static isSupported(): boolean;
static readonly Events: {
MANIFEST_PARSED: string;
LEVEL_LOADED: string;
ERROR: string;
MEDIA_ATTACHED: string;
};
static readonly ErrorTypes: {
NETWORK_ERROR: string;
MEDIA_ERROR: string;
KEY_SYSTEM_ERROR: string;
MUX_ERROR: string;
OTHER_ERROR: string;
};
loadSource(url: string): void;
attachMedia(media: HTMLMediaElement): void;
detachMedia(): void;
startLoad(startPosition?: number): void;
stopLoad(): void;
recoverMediaError(): void;
destroy(): void;
on(event: string, listener: (event: string, data: HlsErrorData) => void): void;
off(event: string, listener?: (event: string, data: HlsErrorData) => void): void;
readonly levels: HlsLevel[];
currentLevel: number;
}
export default Hls;
File diff suppressed because it is too large Load Diff
-3
View File
@@ -33,9 +33,6 @@ export type { SelectedSession } from './apps/ChatHistory';
// The chat URL vocabulary, so the /chat screen, the panels and the server all spell a group the same way.
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './apps/ChatHistory';
export { CodeEditorView } from './apps/CodeEditor';
// Same for /jellyfin.
export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared';
export type { JellyfinSectionId } from './apps/Jellyfin/shared';
// Same for /system-monitor, where the param is the scope rather than a section.
export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared';