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
+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 () => {