diff --git a/.prettierignore b/.prettierignore index b1bcc99c..a2ee9b4d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 diff --git a/scripts/theme-baseline.json b/scripts/theme-baseline.json index 1ff1946c..91fb88ed 100644 --- a/scripts/theme-baseline.json +++ b/scripts/theme-baseline.json @@ -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, diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 611dd611..84de90dd 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -102,8 +102,6 @@ export function App() { element={} />, ])} - } /> - } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Jellyfin/JellyfinScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Jellyfin/JellyfinScreen.tsx deleted file mode 100644 index 21796454..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Jellyfin/JellyfinScreen.tsx +++ /dev/null @@ -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('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 ; - } - - return ( -
- -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Jellyfin/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Jellyfin/defaultLayout.ts deleted file mode 100644 index 2e6bd1d2..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Jellyfin/defaultLayout.ts +++ /dev/null @@ -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 }, - ], -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Jellyfin/index.tsx b/src/apps/officer-web/Screens/Dashboard/Jellyfin/index.tsx deleted file mode 100644 index 9c74ebaa..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Jellyfin/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './JellyfinScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index aaa17b71..056eaff5 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -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'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index acebe7fc..1b7a7cfe 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -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' }, diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index ecc908f1..5cc80889 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -47,7 +47,6 @@ export * from './user-data'; export * from './dav'; export * from './invoiceshelf'; -export * from './jellyfin'; export * from './notify'; export * from './wallet'; diff --git a/src/databases/officer_db/src/jellyfin/index.ts b/src/databases/officer_db/src/jellyfin/index.ts deleted file mode 100644 index b9b5e026..00000000 --- a/src/databases/officer_db/src/jellyfin/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - listJellyfinServers, - getActiveJellyfinCredentials, - getJellyfinCredentials, - createJellyfinServer, - updateJellyfinServer, - setActiveJellyfinServer, - deleteJellyfinServer, - recordJellyfinProbe, -} from './queries'; - -export type { JellyfinServer, JellyfinCredentials } from './queries'; diff --git a/src/databases/officer_db/src/jellyfin/queries.ts b/src/databases/officer_db/src/jellyfin/queries.ts deleted file mode 100644 index 4cdef2bf..00000000 --- a/src/databases/officer_db/src/jellyfin/queries.ts +++ /dev/null @@ -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 { - 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/src/databases/officer_db/src/jellyfin/schema.ts b/src/databases/officer_db/src/jellyfin/schema.ts deleted file mode 100644 index 736dacd6..00000000 --- a/src/databases/officer_db/src/jellyfin/schema.ts +++ /dev/null @@ -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}`), - ], -); diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index 2f26f6b4..a329eb7b 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -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 diff --git a/src/servers/api/jellyfin/router.ts b/src/servers/api/jellyfin/router.ts deleted file mode 100644 index db8a6dd3..00000000 --- a/src/servers/api/jellyfin/router.ts +++ /dev/null @@ -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; diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts index 2afaea31..b234a150 100644 --- a/src/servers/app-store/catalogue.test.ts +++ b/src/servers/app-store/catalogue.test.ts @@ -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(); }); }); diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index 2be1b7e9..493399dd 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -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'] }, diff --git a/src/servers/app-store/installer.test.ts b/src/servers/app-store/installer.test.ts index 400c6b64..3d0c4c80 100644 --- a/src/servers/app-store/installer.test.ts +++ b/src/servers/app-store/installer.test.ts @@ -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. */ diff --git a/src/servers/app-store/members.test.ts b/src/servers/app-store/members.test.ts index 009dc25f..0f821191 100644 --- a/src/servers/app-store/members.test.ts +++ b/src/servers/app-store/members.test.ts @@ -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']); }); }); diff --git a/src/servers/app-store/preflight.test.ts b/src/servers/app-store/preflight.test.ts index dbe573bb..82630586 100644 --- a/src/servers/app-store/preflight.test.ts +++ b/src/servers/app-store/preflight.test.ts @@ -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 () => { diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 47b53bb0..14974e2d 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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 // ['/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], diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 4652269d..62e51f27 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -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', diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index 6edf66bb..7759937a 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -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', diff --git a/src/servers/sidecar/jellyfin/config.ts b/src/servers/sidecar/jellyfin/config.ts deleted file mode 100644 index f5902b1c..00000000 --- a/src/servers/sidecar/jellyfin/config.ts +++ /dev/null @@ -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 { - 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/src/servers/sidecar/jellyfin/index.ts b/src/servers/sidecar/jellyfin/index.ts deleted file mode 100644 index eee6925b..00000000 --- a/src/servers/sidecar/jellyfin/index.ts +++ /dev/null @@ -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).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/src/servers/sidecar/jellyfin/profile.ts b/src/servers/sidecar/jellyfin/profile.ts deleted file mode 100644 index 54f2ceac..00000000 --- a/src/servers/sidecar/jellyfin/profile.ts +++ /dev/null @@ -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 `