From a7f1a717854d01feb8927815d0b3a16d9c29f478 Mon Sep 17 00:00:00 2001 From: officer Date: Wed, 19 Aug 2026 03:17:32 +0000 Subject: [PATCH] jellyfin, extracted from the platform as a plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighth plugin out, and the second resurrection: like soulseek, hono.ts carried the router import and its mount commented out since 2026-08-13, with the permission reduced to api: [], so the server booted clean while every panel request 404'd. jellyfin_servers was also absent from the drizzle barrel, so bun db:push would have dropped it. Easier than soulseek in one specific way: mountPrefix derives /jellyfin from the appName, which is exactly what the router already hardcoded, so no frontend call site needed repointing. Soulseek needed 47 because its appName and its old prefix differed. The frontend also already routed through one helper, so there were five call sites rather than forty-seven. Self-contained, unlike transmission and soulseek: jellyfin_servers is a registry this plugin owns outright — label, URL, encrypted token, one active — because more than one Jellyfin is a thing someone has, and more than one Transmission is not. Carries its own .prettierignore for the vendored hls.mjs, rather than leaving a stale rule behind in the platform naming a path that no longer exists. --- .prettierignore | 5 + PLUGIN.md | 72 + README.md | 37 + api/router.ts | 25 + db/index.ts | 12 + db/queries.ts | 204 + db/schema.ts | 64 + manifest.ts | 47 + sidecar/config.ts | 249 + sidecar/index.ts | 174 + sidecar/profile.ts | 85 + sidecar/routes.ts | 753 + sidecar/upstream.ts | 232 + web/ConnectionSection.tsx | 331 + web/HomeSection.tsx | 57 + web/ItemDetail.tsx | 252 + web/JellyfinNav.tsx | 103 + web/JellyfinView.tsx | 57 + web/JellyfinViewHeader.tsx | 17 + web/LibrarySection.tsx | 114 + web/MediaCard.tsx | 106 + web/PlayerControls.tsx | 404 + web/SearchSection.tsx | 54 + web/ServerSwitcher.tsx | 81 + web/VideoPlayer.tsx | 506 + web/layout.ts | 11 + web/panels.ts | 18 + web/shared.ts | 331 + web/useJellyfinData.ts | 323 + web/useJellyfinSection.ts | 10 + web/vendor/README.md | 51 + web/vendor/hls.LICENSE | 28 + web/vendor/hls.d.mts | 63 + web/vendor/hls.mjs | 36705 +++++++++++++++++++++++++++++++++++ 34 files changed, 41581 insertions(+) create mode 100644 .prettierignore create mode 100644 PLUGIN.md create mode 100644 README.md create mode 100644 api/router.ts create mode 100644 db/index.ts create mode 100644 db/queries.ts create mode 100644 db/schema.ts create mode 100644 manifest.ts create mode 100644 sidecar/config.ts create mode 100644 sidecar/index.ts create mode 100644 sidecar/profile.ts create mode 100644 sidecar/routes.ts create mode 100644 sidecar/upstream.ts create mode 100644 web/ConnectionSection.tsx create mode 100644 web/HomeSection.tsx create mode 100644 web/ItemDetail.tsx create mode 100644 web/JellyfinNav.tsx create mode 100644 web/JellyfinView.tsx create mode 100644 web/JellyfinViewHeader.tsx create mode 100644 web/LibrarySection.tsx create mode 100644 web/MediaCard.tsx create mode 100644 web/PlayerControls.tsx create mode 100644 web/SearchSection.tsx create mode 100644 web/ServerSwitcher.tsx create mode 100644 web/VideoPlayer.tsx create mode 100644 web/layout.ts create mode 100644 web/panels.ts create mode 100644 web/shared.ts create mode 100644 web/useJellyfinData.ts create mode 100644 web/useJellyfinSection.ts create mode 100644 web/vendor/README.md create mode 100644 web/vendor/hls.LICENSE create mode 100644 web/vendor/hls.d.mts create mode 100644 web/vendor/hls.mjs diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..fff6acc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules + +# Vendored third-party source — reformatting it would destroy the diff against upstream, which is the +# only thing that makes a checked-in library reviewable. See the README beside it. +web/vendor/hls.mjs diff --git a/PLUGIN.md b/PLUGIN.md new file mode 100644 index 0000000..4989a41 --- /dev/null +++ b/PLUGIN.md @@ -0,0 +1,72 @@ +# Jellyfin — the extraction record + +Extracted from the platform on **2026-08-19**, the eighth plugin out, immediately after soulseek. +`plugins/EXTRACTING-A-PLUGIN.md` is the runbook. + +## Also a resurrection + +Like soulseek, and for the same reason: `hono.ts` carried both the router import and its +`PROTECTED_MOUNTS` entry commented out (`// plugin — switched off 2026-08-13`), and the permission had +been reduced to `api: []`. So `assertPermissionTotality` was satisfied, the server booted clean, and +every request from the panels 404'd. It had been dead for six days. + +The same data hazard applied: `schema.ts` had `export * from './jellyfin/schema'` commented out, so +`jellyfin_servers` was absent from the drizzle barrel and `bun db:push` would have dropped it. + +## What moved + +| Was | Is now | +| --------------------------------------------------------- | --------------- | +| `src/servers/sidecar/jellyfin/*` (5 files) | `sidecar/*` | +| `src/servers/api/jellyfin/router.ts` | `api/router.ts` | +| `src/databases/officer_db/src/jellyfin/*` (3 files) | `db/*` | +| `src/workspaces/officerdev/src/apps/Jellyfin/*` (20 files, incl. `vendor/`) | `web/*` | +| `src/apps/officer-web/Screens/Dashboard/Jellyfin/*` | `web/layout.ts` | + +## Easier than soulseek, in one specific way + +The mount prefix is unchanged. `mountPrefix` derives `/jellyfin` from the appName, which is what the +router was already hardcoding — so **no frontend call site had to be repointed**. Soulseek needed 47, +because its appName (`soulseek`) and its old prefix (`slskd`) differed. + +The frontend also already routed everything through one helper (`useJellyfinData.ts`), so there were +five call sites in total rather than forty-seven spread across every panel. + +## It owns its data, unlike transmission and soulseek + +`jellyfin_servers` is a **registry** — label, URL, encrypted token, one active — and it moved into this +plugin whole. That is the right shape here for the same reason `service_connections` is the right shape +there: more than one Jellyfin is a thing someone actually has, and more than one Transmission is not. + +So this plugin is self-contained. No core table, no platform union to extend. + +## Fixtures: fixed properly this time + +`jellyfin` was the canonical app-store test fixture, and extracting it broke **18 tests**. That is the +fourth extraction in a row to break the same files — photos and transmission on 2026-08-18, slskd +earlier on 2026-08-19, jellyfin an hour later. + +`installer.test.ts` now builds **literal** fixtures and asks the catalogue nothing: + +```ts +const fullInstall: CatalogueEntry = { id: 'test-full', members: 'accounts', modes: [...], ... }; +``` + +Those tests pin `planSteps` and `runInstall` — installer *logic*. A service leaving for `plugins/` says +nothing about whether the installer still plans the right steps, so it should never have been able to +break them. `catalogue.test.ts` remains the place where real entries are asserted well-formed. + +`members.test.ts`, `catalogue.test.ts` and `preflight.test.ts` were re-anchored to `memos`, which has +jellyfin's exact shape (`members: 'accounts'`, `modes: ['existing', 'provisioned']`). + +## Carried its own ignore rule + +`.prettierignore` in the platform named `src/workspaces/officerdev/src/apps/Jellyfin/vendor/hls.mjs`. +That path no longer exists, so the rule moved into this repo's own `.prettierignore` rather than being +left behind as a stale line matching nothing. + +## Not carried over + +`composeTemplate: 'jellyfin'` and `modes: ['existing', 'provisioned']` died with the catalogue entry. +Like every plugin so far, this is **point-at-your-own-server only** until plugin Docker provisioning +exists. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1467c76 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Video (Jellyfin) + +Films and shows from your own [Jellyfin](https://jellyfin.org) server, inside Officer — with a player +that handles direct play, HLS and progressive, and an access token that never leaves the server. + +## What you get + +- **Home** — resume, next up, recently added +- **Libraries** — browse films, shows, seasons and episodes +- **Search** across the library +- **A real player** — direct play where the file allows it, HLS where it doesn't, progressive as the + fallback, with playback reported back to Jellyfin so resume works everywhere +- **Multiple servers** — add several and switch between them + +## Requirements + +A running Jellyfin server Officer can reach, and an access token. + +## Setup + +Install the plugin, open **/jellyfin**, and add a server: a label, the URL, and the token. The token is +encrypted at rest and never sent to the browser. + +Unlike Transmission and Soulseek, this plugin keeps its own registry — `jellyfin_servers` — because +having more than one Jellyfin is a real thing (a home server and a friend's). One is marked active. + +The plugin does **not** install or supervise Jellyfin. Point it at one you already run. + +## Permissions + +One permission, `jellyfin`, grantable per role like any other app permission. + +## Notes + +- `web/vendor/hls.mjs` is a checked-in third-party build (~1.3 MB), deliberately excluded from + formatting so the diff against upstream stays readable. +- Transcoding is the Jellyfin server's job. This plugin asks for a stream and plays what it gets. diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..b9b93a0 --- /dev/null +++ b/api/router.ts @@ -0,0 +1,25 @@ +import { createSidecarProxy } from '@@/sidecar/create-proxy'; +import { mountPrefix } from '@@/plugins/manifest'; +import { manifest } from '../manifest'; + +// /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', + // DERIVED, never written out: `mountPrefix` returns `/jellyfin` for a first-party plugin and + // `/p//jellyfin` for anyone else's, so a literal is right only by coincidence. + prefix: `/api${mountPrefix({ appName: 'jellyfin', manifest })}`, + timeoutSeconds: 3600, +}); + +export const router = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getJellyfinServerUrl = proxy.getHttpUrl; diff --git a/db/index.ts b/db/index.ts new file mode 100644 index 0000000..b9b5e02 --- /dev/null +++ b/db/index.ts @@ -0,0 +1,12 @@ +export { + listJellyfinServers, + getActiveJellyfinCredentials, + getJellyfinCredentials, + createJellyfinServer, + updateJellyfinServer, + setActiveJellyfinServer, + deleteJellyfinServer, + recordJellyfinProbe, +} from './queries'; + +export type { JellyfinServer, JellyfinCredentials } from './queries'; diff --git a/db/queries.ts b/db/queries.ts new file mode 100644 index 0000000..00dc753 --- /dev/null +++ b/db/queries.ts @@ -0,0 +1,204 @@ +import { eq, and, desc } from 'drizzle-orm'; +import { db } from 'officerdb/db'; +import { jellyfinServers } from './schema'; +import { encryptSecret, decryptSecret } from 'officerdb/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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await db + .update(jellyfinServers) + .set({ version, lastSeenAt: new Date(), updatedAt: new Date() }) + .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))); +} diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..0fc1ed8 --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,64 @@ +import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { users } from 'officerdb/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}`), + ], +); diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..fa950b5 --- /dev/null +++ b/manifest.ts @@ -0,0 +1,47 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +/** + * Jellyfin — films and shows from your own server, with a player that handles direct, HLS and + * progressive playback. + * + * ── What this plugin is, and what it is not ── + * + * It does not install or supervise Jellyfin, and it stores no media. You point it at a server and it + * speaks Jellyfin's API on your behalf, holding the access token server-side so no device carries it. + * + * ── Where the connection lives ── + * + * In this plugin's OWN table, unlike transmission and soulseek. `jellyfin_servers` is a registry — label, + * URL, encrypted token, one active — because more than one Jellyfin really is a thing someone has (a + * home server and a friend's, say). That is the same reason `photos_config` is plural and + * `service_connections` is not. + * + * ── The vendored player ── + * + * `web/vendor/hls.mjs` is a checked-in third-party build, ~1.3 MB. It is deliberately excluded from + * formatting and lint: reformatting it would destroy the diff against upstream, which is the only thing + * that makes a vendored library reviewable. See the README beside it. + * + * ── No `osDependencies` ── + * + * Nothing shells out. The sidecar speaks Jellyfin's HTTP API over `fetch`, and transcoding is the + * server's job, not ours. + */ +export const manifest: PluginManifest = { + 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', + icon: 'Clapperboard', + color: '#a855f7', + + permissions: [ + { + key: 'jellyfin', + label: 'Video', + description: 'Browse and play the Jellyfin library', + }, + ], +}; diff --git a/sidecar/config.ts b/sidecar/config.ts new file mode 100644 index 0000000..b4c07c1 --- /dev/null +++ b/sidecar/config.ts @@ -0,0 +1,249 @@ +import type { JellyfinServer } from '../db'; +import { + createJellyfinServer, + deleteJellyfinServer, + getJellyfinCredentials, + listJellyfinServers, + recordJellyfinProbe, + setActiveJellyfinServer, + updateJellyfinServer, +} from '../db'; +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 { + 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 { + 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 => + ((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 { + 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 { + 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 { + 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 '/', '//activate', '//test'. */ +export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { + 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); +} diff --git a/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..58f1d02 --- /dev/null +++ b/sidecar/index.ts @@ -0,0 +1,174 @@ +import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; +import { createSidecarConnector } from '@@/sidecar/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).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')); diff --git a/sidecar/profile.ts b/sidecar/profile.ts new file mode 100644 index 0000000..54f2cea --- /dev/null +++ b/sidecar/profile.ts @@ -0,0 +1,85 @@ +// 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 `