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