diff --git a/_lib/contract.ts b/_lib/contract.ts index 1ee0908..7b0dbb8 100644 --- a/_lib/contract.ts +++ b/_lib/contract.ts @@ -98,8 +98,18 @@ export const HOOK_TARGET_TYPES = ["files"] as const; // --- v6 widget contract --- -/** Supported widgetsSchema version. */ -export const SUPPORTED_WIDGETS_SCHEMA = 2; +/** + * Supported widgetsSchema version. In 3 a widget is the app's dashboard card, + * so its `slots` and `background` sit on the declaration itself; the `sizes` + * of 2 are gone. + */ +export const SUPPORTED_WIDGETS_SCHEMA = 3; -/** Widget slot types. */ -export const WIDGET_SLOT_TYPES = ["text", "stat", "list", "image"] as const; +/** Widget slot types. An image is never a slot: the card's artwork is its background. */ +export const WIDGET_SLOT_TYPES = ["text", "stat", "list", "progress"] as const; + +/** The shortest widget `refresh` the platform honours, in seconds: one deck poll (MIN_REFRESH_S). */ +export const WIDGET_MIN_REFRESH_S = 5; + +/** Most slots a widget declares. */ +export const WIDGET_MAX_SLOTS = 3; diff --git a/_lib/validate.ts b/_lib/validate.ts index 83c7682..0980f48 100644 --- a/_lib/validate.ts +++ b/_lib/validate.ts @@ -23,6 +23,8 @@ import { SPEC_REGEX, SUPPORTED_VERSIONS, SUPPORTED_WIDGETS_SCHEMA, + WIDGET_MAX_SLOTS, + WIDGET_MIN_REFRESH_S, WIDGET_SLOT_TYPES, } from "./contract"; @@ -550,8 +552,11 @@ function validate(file: string, script: Record) { if (widget.refresh !== undefined) { if (typeof widget.refresh !== "number" || !Number.isInteger(widget.refresh) || widget.refresh <= 0) { err(file, `${where}: refresh must be a positive integer`); - } else if (widget.refresh < 10) { - warn(file, `${where}: refresh ${widget.refresh} is below the 10s floor — the platform clamps it to 10`); + } else if (widget.refresh < WIDGET_MIN_REFRESH_S) { + warn( + file, + `${where}: refresh ${widget.refresh} is below the ${WIDGET_MIN_REFRESH_S}s floor; the platform clamps it to ${WIDGET_MIN_REFRESH_S}`, + ); } } const hasFile = typeof widget.script === "string" && widget.script.trim().length > 0; @@ -578,42 +583,46 @@ function validate(file: string, script: Record) { } } } + // widgetsSchema 3 made the widget the app's card: no sizes, one layout. if (widget.sizes !== undefined) { - if (!isObject(widget.sizes)) { - err(file, `${where}: sizes must be an object`); - } else { - for (const sizeKey of ["small", "large"] as const) { - const size = widget.sizes[sizeKey]; - if (size === undefined) continue; - if (!isObject(size)) { - err(file, `${where}.sizes.${sizeKey}: must be an object`); - continue; - } - if (size.media !== undefined) { - const placements = sizeKey === "small" ? ["top", "bottom"] : ["left", "right", "both"]; - if (!isObject(size.media) || typeof size.media.placement !== "string" || !placements.includes(size.media.placement)) { - err(file, `${where}.sizes.${sizeKey}.media: placement must be one of ${placements.join("/")}`); - } else if (typeof size.media.field !== "string" || !size.media.field.trim()) { - err(file, `${where}.sizes.${sizeKey}.media: field is missing`); - } - } - const slots = size.slots; - if (!Array.isArray(slots) || slots.length === 0) { - err(file, `${where}.sizes.${sizeKey}: slots must be a non-empty array`); - } else { - const maxSlots = sizeKey === "small" ? 3 : 4; - if (slots.length > maxSlots) { - err(file, `${where}.sizes.${sizeKey}: max ${maxSlots} slots`); - } - for (const [k, slot] of slots.entries()) { - if (!isObject(slot)) continue; - if (typeof slot.type !== "string" || !(WIDGET_SLOT_TYPES as readonly string[]).includes(slot.type)) { - err(file, `${where}.sizes.${sizeKey}.slots[${k}]: type "${String(slot.type)}" is not known`); - } - if (typeof slot.field !== "string" || !slot.field.trim()) { - err(file, `${where}.sizes.${sizeKey}.slots[${k}]: field is missing`); - } - } + err(file, `${where}: sizes is gone in widgetsSchema 3; put slots and background on the widget itself`); + } + if (widget.media !== undefined) { + err(file, `${where}: has no media slot; name an image field in background instead`); + } + if ( + widget.background !== undefined && + (!isObject(widget.background) || + typeof widget.background.field !== "string" || + !widget.background.field.trim()) + ) { + err(file, `${where}.background: field is missing`); + } + const slots = widget.slots; + if (!Array.isArray(slots) || slots.length === 0) { + err(file, `${where}: slots must be a non-empty array`); + } else { + if (slots.length > WIDGET_MAX_SLOTS) { + err(file, `${where}: max ${WIDGET_MAX_SLOTS} slots`); + } + for (const [k, slot] of slots.entries()) { + if (!isObject(slot)) { + err(file, `${where}.slots[${k}]: must be an object`); + continue; + } + if (slot.type === "image") { + err(file, `${where}.slots[${k}]: an image is not a slot; name the image field in background instead`); + } else if (typeof slot.type !== "string" || !(WIDGET_SLOT_TYPES as readonly string[]).includes(slot.type)) { + err(file, `${where}.slots[${k}]: type "${String(slot.type)}" is not known`); + } + if (typeof slot.field !== "string" || !slot.field.trim()) { + err(file, `${where}.slots[${k}]: field is missing`); + } + if (slot.rows !== undefined) { + if (slot.type !== "list") { + err(file, `${where}.slots[${k}]: rows applies to a list slot only`); + } else if (typeof slot.rows !== "number" || ![1, 2, 3].includes(slot.rows)) { + err(file, `${where}.slots[${k}]: rows must be 1, 2 or 3`); } } } diff --git a/_lib/widget_context.ts b/_lib/widget_context.ts index cf6ffb8..d99d15b 100644 --- a/_lib/widget_context.ts +++ b/_lib/widget_context.ts @@ -28,6 +28,13 @@ export interface WidgetContext { export interface WidgetListEntry { title: string; subtitle?: string; + /** + * Lines that take turns under the entry (up to 4, each at most 80 chars). + * The featured form steps through them at twice the pace of its entries, + * starting over with each entry. `subtitle` stays the floor for renderers + * that ignore this, so repeat the first line there. + */ + subtitles?: string[]; meta?: string; /** * A duration in steady motion — capable renderers advance it in real @@ -36,32 +43,44 @@ export interface WidgetListEntry { */ elapsed?: { ms: number; ofMs?: number; state?: "running" | "paused" }; /** - * Optional thumbnail as a size-capped (60KB) data URI. Data URIs ONLY — - * fetch and inline server-side; never emit app URLs (key leak, - * mixed-content, off-LAN breakage). + * How far along this entry is, 0 to 100, for a quantity that is not time + * in motion (a download, a conversion). Capable renderers draw a bar; the + * text floor (meta) must still carry the figure. + */ + progress?: number; + /** + * The entry's artwork as a size-capped (60KB) data URI. When the widget's + * background names this list, the card draws the image of the entry it has + * on screen, so fetch a wide crop near the card's shape (about 660x260). + * Data URIs ONLY: fetch and inline server-side; never emit app URLs (key + * leak, mixed-content, off-LAN breakage). */ image?: string; } /** - * One named field of the result document (widgetsSchema 2). The - * declaration's `sizes` slots reference these fields by name; a standalone - * image field is the media slot's source. Max 16 fields; images are + * One named field of the result document (widgetsSchema 3). The + * declaration's `slots` reference these fields by name; the card's background + * names a standalone image field, or a list whose entries carry images (the + * card then draws the image of the entry on screen). An image is never a slot. + * Max 16 fields; images are * size-capped (60KB) data URIs, text is capped at 500 chars. */ export type WidgetFieldValue = | { type: "text"; text: string } | { type: "stat"; label: string; value: string } | { type: "list"; entries: WidgetListEntry[] } - | { type: "image"; image: string; alt?: string }; + | { type: "image"; image: string; alt?: string } + /** A bounded quantity: `value` is 0 to 100, `text` the caption beside the label ("3.2 GB of 5 GB", max 60 chars). */ + | { type: "progress"; label: string; value: number; text?: string }; /** - * What a widget script returns: named fields the platform's size templates - * project from — one widget, ONE query; sizes never add polls. Return - * `needsSetup` (with a human reason) when the data source isn't usable - * yet. Harvest credentials first — needs-setup is the fallback, not the - * default. Field names must be STABLE (sizes reference them); dynamic - * collections (users, library sections) ride as `list` entries. + * What a widget script returns: named fields the card's slots project + * from. One widget is ONE query. Return `needsSetup` (with a human reason) + * when the data source isn't usable yet. Harvest credentials first: + * needs-setup is the fallback, not the default. Field names must be STABLE + * (slots reference them); dynamic collections (users, library sections) + * ride as `list` entries. */ export interface WidgetQueryResult { needsSetup?: boolean; diff --git a/fileflows.json b/fileflows.json index ebddf47..24e092d 100644 --- a/fileflows.json +++ b/fileflows.json @@ -138,7 +138,7 @@ "retries": 0 } ], - "widgetsSchema": 2, + "widgetsSchema": 3, "widgets": [ { "id": "conversions", @@ -152,28 +152,17 @@ "app": "fileflows" } ], - "sizes": { - "small": { - "slots": [ - { - "type": "stat", - "field": "active" - } - ] + "slots": [ + { + "type": "stat", + "field": "active" }, - "large": { - "slots": [ - { - "type": "stat", - "field": "active" - }, - { - "type": "list", - "field": "files" - } - ] + { + "type": "list", + "field": "files", + "rows": 1 } - }, + ], "script": "fileflows/widget_conversions.ts", "entrypoint": "run", "timeout": 10 diff --git a/fileflows/widget_conversions.ts b/fileflows/widget_conversions.ts index 5060334..23168b0 100644 --- a/fileflows/widget_conversions.ts +++ b/fileflows/widget_conversions.ts @@ -200,7 +200,10 @@ export async function run(ctx: WidgetContext): Promise { return { title: basename(file.name ?? "Unknown file"), subtitle: conversion || undefined, + // Text floor: the figure any renderer can show as-is. meta: meta || undefined, + // Enrichment: capable renderers draw this as a bar. + progress: percent !== undefined ? Math.min(Math.max(percent, 0), 100) : undefined, }; }), }, diff --git a/hexos-test.json b/hexos-test.json index a4a7255..c5916d7 100644 --- a/hexos-test.json +++ b/hexos-test.json @@ -296,7 +296,7 @@ "retries": 0 } ], - "widgetsSchema": 2, + "widgetsSchema": 3, "widgets": [ { "id": "harness-stats", @@ -307,16 +307,11 @@ "scriptContent": "export async function run(ctx) {\n return {\n fields: {\n headline: { type: \"text\", text: \"Harness widget\" },\n streams: { type: \"stat\", label: \"Streams\", value: \"3\" },\n items: { type: \"list\", entries: [{ title: \"First\" }, { title: \"Second\" }] },\n },\n };\n}\n", "timeout": 10, "buttons": ["harness-widget-button"], - "sizes": { - "small": { "slots": [{ "type": "stat", "field": "streams" }] }, - "large": { - "slots": [ - { "type": "text", "field": "headline" }, - { "type": "stat", "field": "streams" }, - { "type": "list", "field": "items" } - ] - } - } + "slots": [ + { "type": "text", "field": "headline" }, + { "type": "stat", "field": "streams" }, + { "type": "list", "field": "items" } + ] }, { "id": "harness-needs-setup", @@ -326,7 +321,7 @@ "entrypoint": "run", "scriptContent": "export async function run(ctx) {\n return { needsSetup: true, reason: \"Harness widget needs setup\" };\n}\n", "timeout": 10, - "sizes": { "small": { "slots": [{ "type": "text", "field": "headline" }] } } + "slots": [{ "type": "text", "field": "headline" }] } ] } diff --git a/jellyfin.json b/jellyfin.json index 117126c..3867ad3 100644 --- a/jellyfin.json +++ b/jellyfin.json @@ -1,8 +1,8 @@ { - "version": 5, + "version": 6, "script": { - "version": "2.0.0", - "changeLog": "V5 with post-install hook for automated Jellyfin configuration" + "version": "2.1.0", + "changeLog": "Added the now-playing widget and moved to the v6 install script format" }, "requirements": { "locations": ["ApplicationsPerformance", "ApplicationsCapacity", "Media", "Photos", "Music", "Movies", "Shows", "Videos"], @@ -49,7 +49,10 @@ "hooks": [ { "id": "configure-jellyfin", - "event": "onAfterInstall", + "title": "Pre-configure Jellyfin", + "events": [ + "onAfterInstall" + ], "script": "jellyfin/jellyfin_hook.ts", "entrypoint": "afterInstall", "timeout": 300, @@ -57,7 +60,6 @@ "optional": false, "retries": 0, "userOptional": { - "label": "Pre-configure Jellyfin", "description": "Automatically set up your server, apply preferences, and create media libraries. By enabling this, you acknowledge Jellyfin's media server policy.", "default": true, "link": { @@ -103,5 +105,34 @@ } ] } + ], + "widgetsSchema": 3, + "widgets": [ + { + "id": "now-playing", + "title": "Now playing", + "description": "Active Jellyfin streams right now.", + "refresh": 5, + "conditions": [ + { + "role": "availability", + "type": "appRunning", + "app": "jellyfin" + } + ], + "background": { + "field": "sessions" + }, + "slots": [ + { + "type": "list", + "field": "sessions", + "rows": 1 + } + ], + "script": "jellyfin/widget_now_playing.ts", + "entrypoint": "run", + "timeout": 10 + } ] } diff --git a/jellyfin/jellyfin_hook.ts b/jellyfin/jellyfin_hook.ts index e704156..3f566ef 100644 --- a/jellyfin/jellyfin_hook.ts +++ b/jellyfin/jellyfin_hook.ts @@ -3,6 +3,9 @@ import type { HookContext } from "../_lib/hook_context"; const DEVICE_ID = "hexos-jellyfin-setup"; const CLIENT_HEADER = `MediaBrowser Client="HexOS", Device="Setup", DeviceId="${DEVICE_ID}", Version="1.0.0"`; +/** The name of the API key the now-playing widget reads sessions with. */ +const WIDGET_KEY_NAME = "HexOS"; + const AUTO_RETRIES = 5; const AUTO_RETRY_DELAY_MS = 5000; const LIBRARY_PACE_MS = 5000; @@ -51,6 +54,36 @@ export async function afterInstall(ctx: HookContext) { } await setServerName(ctx, token, serverName); await createLibraries(ctx, token); + await ensureWidgetKey(ctx, token); +} + +/** + * The API key the now-playing widget reads sessions with (jellyfin/widget_now_playing.ts finds it + * by name in Jellyfin's database). Made once: a rerun finds it and leaves it. A failure here costs + * only the widget, which then asks for a key, so it never fails the setup. + */ +async function ensureWidgetKey(ctx: HookContext, token: string): Promise { + const headers = { "Authorization": `MediaBrowser Token="${token}"` }; + try { + const listResp = await fetch(`${ctx.baseUrl}/Auth/Keys`, { headers, signal: AbortSignal.timeout(10000) }); + if (!listResp.ok) { + ctx.log(`Auth/Keys returned ${listResp.status}; the widget will ask for an API key.`); + return; + } + const keys = await listResp.json() as { Items?: Array<{ AppName?: string }> }; + if (keys.Items?.some((key) => key.AppName === WIDGET_KEY_NAME)) { + ctx.log("Widget API key already exists."); + return; + } + const createResp = await fetch(`${ctx.baseUrl}/Auth/Keys?app=${WIDGET_KEY_NAME}`, { + method: "POST", + headers, + signal: AbortSignal.timeout(10000), + }); + ctx.log(createResp.ok ? "Created the widget API key." : `Creating the widget API key returned ${createResp.status}.`); + } catch (e) { + ctx.log(`Could not create the widget API key: ${e}`); + } } function getServerName(ctx: HookContext): string { @@ -144,7 +177,9 @@ async function authenticate(ctx: HookContext, username: string, password: string method: "POST", headers: { "Content-Type": "application/json", - "X-Emby-Authorization": CLIENT_HEADER, + // Jellyfin dropped the legacy X-Emby-Authorization header (EnableLegacyAuthorization is off + // since its 2026-05-31 migration); without client details in Authorization the sign-in is a 400. + "Authorization": CLIENT_HEADER, }, body: JSON.stringify({ Username: username, Pw: password }), signal: AbortSignal.timeout(10000), diff --git a/jellyfin/widget_now_playing.ts b/jellyfin/widget_now_playing.ts new file mode 100644 index 0000000..082c320 --- /dev/null +++ b/jellyfin/widget_now_playing.ts @@ -0,0 +1,265 @@ +// Sibling files are type imports only: each script is compiled standalone, so runtime imports of them wouldn't resolve. +import { Database } from "bun:sqlite"; +import { Buffer } from "node:buffer"; + +import type { WidgetContext, WidgetListEntry, WidgetQueryResult } from "../_lib/widget_context"; + +// What Jellyfin is playing, one stream at a time on the app card, each with its own artwork. +// The same card Plex draws, from Jellyfin's sessions. + +const DB_RELATIVE = "/data/jellyfin.db"; +/** The name the setup hook gives the API key it creates. */ +const KEY_NAME = "HexOS"; +/** The device the setup hook signs in as; its session is the first that stands in for the key. */ +const SETUP_DEVICE_ID = "hexos-jellyfin-setup"; +/** Jellyfin's PermissionKind.IsAdministrator. Only an administrator sees every session. */ +const IS_ADMINISTRATOR = 0; +const SETUP_REASON = "Sign in to Jellyfin to see what's playing here"; +/** Jellyfin counts time in ticks of 100 ns. */ +const TICKS_PER_MS = 10_000; + +/** + * A token the widget can read sessions with, from Jellyfin's own database, read-only, so the owner + * never has to hand HexOS one: the API key HexOS's setup made, else any API key, else the session + * of an administrator signed in to any Jellyfin app (HexOS's setup sign-in first, then the most + * recently used). Only an administrator sees every stream. Null when no administrator has ever + * signed in, which signing in fixes. + */ +function harvestToken(ctx: WidgetContext): string | null { + const config = + ctx.mounts.find((m) => m.containerPath === "/config") ?? + ctx.mounts.find((m) => m.hostPath.endsWith("/jellyfin/config")); + if (!config) return null; + let db: Database | undefined; + try { + db = new Database(config.localPath + DB_RELATIVE, { readonly: true }); + const key = db + .query<{ AccessToken: string }, [string]>( + "SELECT AccessToken FROM ApiKeys ORDER BY (Name = ?) DESC, DateCreated DESC LIMIT 1", + ) + .get(KEY_NAME); + if (key?.AccessToken) return key.AccessToken; + const session = db + .query<{ AccessToken: string }, [number, string]>( + `SELECT d.AccessToken FROM Devices d + JOIN Permissions p ON p.UserId = d.UserId AND p.Kind = ? AND p.Value = 1 + WHERE d.IsActive = 1 + ORDER BY (d.DeviceId = ?) DESC, d.DateLastActivity DESC + LIMIT 1`, + ) + .get(IS_ADMINISTRATOR, SETUP_DEVICE_ID); + return session?.AccessToken ?? null; + } catch { + return null; + } finally { + db?.close(); + } +} + +interface JellyfinItem { + Id: string; + Name?: string; + Type?: string; + SeriesName?: string; + ParentIndexNumber?: number; + IndexNumber?: number; + RunTimeTicks?: number; + BackdropImageTags?: string[]; + ParentBackdropItemId?: string; + ParentBackdropImageTags?: string[]; + ImageTags?: { Primary?: string; Thumb?: string }; + ParentThumbItemId?: string; +} + +interface JellyfinSession { + UserName?: string; + Client?: string; + DeviceName?: string; + NowPlayingItem?: JellyfinItem; + PlayState?: { PositionTicks?: number; IsPaused?: boolean; PlayMethod?: string }; + TranscodingInfo?: { Bitrate?: number; IsVideoDirect?: boolean; IsAudioDirect?: boolean }; +} + +/** SxxExx when both indices are known. */ +function episodeCode(season: number | undefined, episode: number | undefined): string | undefined { + if (season === undefined || episode === undefined) return undefined; + return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; +} + +/** A movie's title; an episode's show and code ("The Pitt · S01E12"), which is what fits a line. */ +function sessionTitle(item: JellyfinItem): string { + const name = item.Name || "Untitled"; + if (item.Type === "Episode" && item.SeriesName) { + return `${item.SeriesName} · ${episodeCode(item.ParentIndexNumber, item.IndexNumber) ?? name}`; + } + return name; +} + +/** bps → human bitrate; drops non-positive/unknown. */ +function bitrateLabel(bps: number | undefined): string | undefined { + const n = Number(bps); + if (!Number.isFinite(n) || n <= 0) return undefined; + return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} Mbps` : `${Math.round(n / 1000)} kbps`; +} + +/** + * What the server does to the picture, the one thing about serving a stream an owner weighs: + * Transcode when the video is re-encoded, DirectPlay otherwise (a remux, or only the audio + * converted, costs little). Jellyfin's PlayMethod says Transcode whenever any stream is + * re-encoded, so the transcoding flags decide. + */ +function videoMethod(session: JellyfinSession): string { + const method = session.PlayState?.PlayMethod; + if (method !== "Transcode") return "DirectPlay"; + return session.TranscodingInfo?.IsVideoDirect ? "DirectPlay" : "Transcode"; +} + +/** + * The lines that take turns under the stream: who is watching where, then how the picture is + * served and, while the server is producing the stream, at what bitrate. + */ +function sessionLines(session: JellyfinSession): string[] { + const who = [session.UserName, session.DeviceName || session.Client].filter(Boolean).join(" · "); + const how = [videoMethod(session), bitrateLabel(session.TranscodingInfo?.Bitrate)].filter(Boolean).join(" · "); + return [who, how].filter(Boolean); +} + +function timecode(ms: number): string { + const total = Math.floor(ms / 1000); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` + : `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +/** + * Which picture of the item is its wide artwork: its own backdrop, its series' backdrop for an + * episode, else its thumb, else its primary image (an episode's primary is a 16:9 still). + */ +function artworkPath(item: JellyfinItem): string | undefined { + if (item.BackdropImageTags?.length) return `/Items/${item.Id}/Images/Backdrop/0`; + if (item.ParentBackdropItemId && item.ParentBackdropImageTags?.length) { + return `/Items/${item.ParentBackdropItemId}/Images/Backdrop/0`; + } + if (item.ImageTags?.Thumb) return `/Items/${item.Id}/Images/Thumb`; + if (item.ParentThumbItemId) return `/Items/${item.ParentThumbItemId}/Images/Thumb`; + if (item.ImageTags?.Primary) return `/Items/${item.Id}/Images/Primary`; + return undefined; +} + +/** A crop of the image filling `width` x `height`, as a data URI, or undefined when over the cap. */ +async function fetchImage( + base: string, + headers: Record, + path: string, + width: number, + height: number, +): Promise { + const response = await fetch(`${base}${path}?fillWidth=${width}&fillHeight=${height}&quality=80&format=Jpg`, { + headers, + }); + if (!response.ok) return undefined; + const type = response.headers.get("content-type") ?? ""; + if (!type.startsWith("image/")) return undefined; + const uri = `data:${type};base64,${Buffer.from(await response.arrayBuffer()).toString("base64")}`; + return uri.length <= 60_000 ? uri : undefined; +} + +/** + * The item's wide artwork as a size-capped data URI, for the app card's background while the + * card shows this stream. Near twice the card's size for a sharp picture, then near its own size + * when the sharp one is over the cap. + */ +// A stream's artwork does not change while it plays, and the card asks every few seconds, so each +// picture is fetched once and kept: the box reuses this module between runs until the script +// changes. Only pictures that arrived are kept, so a failed one is tried again on the next run. +const ARTWORK_CACHE_SIZE = 20; +const artworkCache = new Map(); + +function rememberArtwork(key: string, image: string): void { + artworkCache.delete(key); + artworkCache.set(key, image); + const oldest = artworkCache.keys().next().value; + if (artworkCache.size > ARTWORK_CACHE_SIZE && oldest !== undefined) artworkCache.delete(oldest); +} + +async function fetchArtwork( + base: string, + headers: Record, + item: JellyfinItem, +): Promise { + const path = artworkPath(item); + if (!path) return undefined; + const key = `${base}${path}`; + const kept = artworkCache.get(key); + if (kept) return kept; + try { + const image = + (await fetchImage(base, headers, path, 660, 260)) ?? (await fetchImage(base, headers, path, 480, 190)); + if (image) rememberArtwork(key, image); + return image; + } catch { + return undefined; + } +} + +export async function run(ctx: WidgetContext): Promise { + const token = harvestToken(ctx); + if (!token) return { needsSetup: true, reason: SETUP_REASON }; + + const base = `http://${ctx.host}:${ctx.port ?? 30013}`; + const headers = { Accept: "application/json", Authorization: `MediaBrowser Token="${token}"` }; + + const response = await fetch(`${base}/Sessions?activeWithinSeconds=960`, { headers }); + // A session token dies when its device signs out; the next run picks another. + if (response.status === 401) return { needsSetup: true, reason: SETUP_REASON }; + if (!response.ok) throw new Error(`Jellyfin sessions query failed (${response.status})`); + const sessions = ((await response.json()) as JellyfinSession[]).filter( + (session): session is JellyfinSession & { NowPlayingItem: JellyfinItem } => !!session.NowPlayingItem, + ); + + // The count reflects every active stream; the card steps through the first few, one dot each. + const total = sessions.length; + const shown = sessions.slice(0, 5); + const artwork = await Promise.all(shown.map((session) => fetchArtwork(base, headers, session.NowPlayingItem))); + + const entries: WidgetListEntry[] = shown.map((session, i) => { + const item = session.NowPlayingItem; + const paused = session.PlayState?.IsPaused === true; + const positionMs = session.PlayState?.PositionTicks !== undefined + ? Math.floor(session.PlayState.PositionTicks / TICKS_PER_MS) + : undefined; + const durationMs = item.RunTimeTicks ? Math.floor(item.RunTimeTicks / TICKS_PER_MS) : undefined; + const timed = positionMs !== undefined && durationMs !== undefined && durationMs > 0; + return { + title: sessionTitle(item), + // The floor shows the first line; the card takes turns with all of them. + subtitle: sessionLines(session)[0], + subtitles: sessionLines(session), + // Text floor: a static timecode any renderer can show as-is. + meta: [paused ? "paused" : undefined, timed ? `${timecode(positionMs)} / ${timecode(durationMs)}` : undefined] + .filter(Boolean) + .join(" · ") || undefined, + // Enrichment: capable renderers tick this between polls. + elapsed: timed + ? { ms: Math.min(positionMs, durationMs), ofMs: durationMs, state: paused ? "paused" : "running" } + : undefined, + image: artwork[i], + }; + }); + + return { + fields: { + streams: { type: "stat", label: total === 1 ? "Stream" : "Streams", value: String(total) }, + // Each entry carries its own artwork: the card's background names this list, so the + // picture behind the card is the one for the stream on screen. + sessions: { type: "list", entries }, + summary: { + type: "text", + text: total === 0 ? "Nothing playing" : `${total} stream${total === 1 ? "" : "s"} · ${entries[0].title}`, + }, + }, + }; +} diff --git a/plex.json b/plex.json index 2e1a6b9..6ed1d2a 100644 --- a/plex.json +++ b/plex.json @@ -159,13 +159,13 @@ "retries": 0 } ], - "widgetsSchema": 2, + "widgetsSchema": 3, "widgets": [ { "id": "now-playing", "title": "Now playing", "description": "Active Plex streams right now.", - "refresh": 10, + "refresh": 5, "conditions": [ { "role": "availability", @@ -173,32 +173,16 @@ "app": "plex" } ], - "sizes": { - "small": { - "slots": [ - { - "type": "stat", - "field": "streams" - } - ] - }, - "large": { - "media": { - "placement": "left", - "field": "art" - }, - "slots": [ - { - "type": "stat", - "field": "streams" - }, - { - "type": "list", - "field": "sessions" - } - ] - } + "background": { + "field": "sessions" }, + "slots": [ + { + "type": "list", + "field": "sessions", + "rows": 1 + } + ], "script": "plex/widget_now_playing.ts", "entrypoint": "run", "timeout": 10 diff --git a/plex/widget_now_playing.ts b/plex/widget_now_playing.ts index 3e45c53..6698d16 100644 --- a/plex/widget_now_playing.ts +++ b/plex/widget_now_playing.ts @@ -1,4 +1,4 @@ -// Type imports only — each script is compiled standalone, so runtime imports of sibling files wouldn't resolve. +// Type imports only: each script is compiled standalone, so runtime imports of sibling files wouldn't resolve. import { Buffer } from "node:buffer"; import { readFileSync } from "node:fs"; @@ -19,22 +19,57 @@ function harvestPlexToken(ctx: WidgetContext): string | null { } } -/** Poster as a size-capped data URI via Plex's photo transcoder. */ -async function fetchThumb( +/** A Plex photo-transcoder crop of `path` as a data URI, or undefined when it is over the size cap. */ +async function transcodeImage( base: string, token: string, - thumb: string | undefined, + path: string, + width: number, + height: number, ): Promise { - if (!thumb) return undefined; + const response = await fetch( + `${base}/photo/:/transcode?width=${width}&height=${height}&minSize=1&format=jpeg&url=${encodeURIComponent(path)}&X-Plex-Token=${token}`, + ); + if (!response.ok) return undefined; + const type = response.headers.get("content-type") ?? ""; + if (!type.startsWith("image/")) return undefined; + const uri = `data:${type};base64,${Buffer.from(await response.arrayBuffer()).toString("base64")}`; + return uri.length <= 60_000 ? uri : undefined; +} + +/** + * The session's wide artwork as a size-capped data URI, for the app card's background while the + * card shows this session. A card is about 330x130, so the transcoder is asked for a landscape + * crop near twice that for a sharp picture, then near the card's own size when the sharp one is + * over the cap. An item with no wide artwork falls back to its poster, cropped the same way. + */ +// A stream's artwork does not change while it plays, and the card asks every few seconds, so each +// picture is fetched once and kept: the box reuses this module between runs until the script +// changes. Only pictures that arrived are kept, so a failed one is tried again on the next run. +const ARTWORK_CACHE_SIZE = 20; + +/** Streams the last run saw, kept like the artwork: an empty answer is retried only after a busy one. */ +let lastActiveCount = 0; +const artworkCache = new Map(); + +function rememberArtwork(key: string, image: string): void { + artworkCache.delete(key); + artworkCache.set(key, image); + const oldest = artworkCache.keys().next().value; + if (artworkCache.size > ARTWORK_CACHE_SIZE && oldest !== undefined) artworkCache.delete(oldest); +} + +async function fetchArtwork(base: string, token: string, session: PlexSession): Promise { + const path = session.art || session.grandparentArt || session.grandparentThumb || session.thumb; + if (!path) return undefined; + const key = `${base}${path}`; + const kept = artworkCache.get(key); + if (kept) return kept; try { - const response = await fetch( - `${base}/photo/:/transcode?width=120&height=180&minSize=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${token}`, - ); - if (!response.ok) return undefined; - const type = response.headers.get("content-type") ?? ""; - if (!type.startsWith("image/")) return undefined; - const uri = `data:${type};base64,${Buffer.from(await response.arrayBuffer()).toString("base64")}`; - return uri.length <= 60_000 ? uri : undefined; + const image = + (await transcodeImage(base, token, path, 660, 260)) ?? (await transcodeImage(base, token, path, 480, 190)); + if (image) rememberArtwork(key, image); + return image; } catch { return undefined; } @@ -50,6 +85,8 @@ interface PlexSession { duration?: number; thumb?: string; grandparentThumb?: string; + art?: string; + grandparentArt?: string; User?: { title?: string }; Player?: { title?: string; product?: string; state?: string }; // The TranscodeSession is the authoritative decision signal; absent means @@ -64,28 +101,25 @@ function episodeCode(season: number | undefined, episode: number | undefined): s return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; } +/** A movie's title; an episode's show and code ("The Pitt · S01E12"), which is what fits a line. */ function sessionTitle(session: PlexSession): string { if (session.type === "episode" && session.grandparentTitle) { const code = episodeCode(session.parentIndex, session.index); - return code - ? `${session.grandparentTitle} — ${code} · ${session.title}` - : `${session.grandparentTitle} — ${session.title}`; + return `${session.grandparentTitle} · ${code ?? session.title}`; } return session.title; } /** - * Direct Play / Direct Stream (remux) / Transcode. No TranscodeSession = - * direct play; one whose streams all "copy" = remux (Direct Stream); any - * stream re-encoding = Transcode. (Part.decision only ever says - * directplay/transcode — it cannot express remux — and reading Media[0] - * misreports multi-version items, so neither is used.) + * What the server does to the picture, the one thing about serving a stream an + * owner weighs: Transcode when the video is re-encoded, DirectPlay otherwise + * (no TranscodeSession, or one that copies the video, remuxing or converting + * only the audio, which costs little). Decided by videoDecision, the per-stream + * decision; Part.decision cannot tell a remux from a transcode, and reading + * Media[0] misreports multi-version items. */ -function streamLabel(session: PlexSession): string { - const transcode = session.TranscodeSession; - if (!transcode) return "Direct Play"; - if (transcode.videoDecision === "transcode" || transcode.audioDecision === "transcode") return "Transcode"; - return "Direct Stream"; +function videoMethod(session: PlexSession): string { + return session.TranscodeSession?.videoDecision === "transcode" ? "Transcode" : "DirectPlay"; } /** kbps → human bitrate; drops non-positive/unknown. */ @@ -95,14 +129,15 @@ function bandwidthLabel(kbps: number | undefined): string | undefined { return n >= 1000 ? `${(n / 1000).toFixed(1)} Mbps` : `${Math.round(n)} kbps`; } -function sessionSubtitle(session: PlexSession): string | undefined { - const who = session.User?.title; - const where = session.Player?.product || session.Player?.title; - const method = streamLabel(session); - // Identity for a plain direct play; the load story (method · bitrate) when - // it isn't — the tight subtitle only fits two short facts. - if (method === "Direct Play") return [who, where].filter(Boolean).join(" · ") || undefined; - return [method, bandwidthLabel(session.Session?.bandwidth)].filter(Boolean).join(" · ") || undefined; +/** + * The lines that take turns under the stream: who is watching where, then how + * the picture is served and at what bitrate. Either is left out when Plex says + * nothing for it. + */ +function sessionLines(session: PlexSession): string[] { + const who = [session.User?.title, session.Player?.product || session.Player?.title].filter(Boolean).join(" · "); + const how = [videoMethod(session), bandwidthLabel(session.Session?.bandwidth)].filter(Boolean).join(" · "); + return [who, how].filter(Boolean); } function timecode(ms: number): string { @@ -138,11 +173,14 @@ export async function run(ctx: WidgetContext): Promise { return body.MediaContainer?.Metadata ?? []; }; // /status/sessions has brief empty windows mid-playback (a missed client - // timeline ping) — retry before believing a 0, or a blip gets cached for a - // whole refresh cycle. Retries are bounded (a slow app can't eat the 10s - // budget) and a retry failure keeps the confirmed-good empty answer. + // timeline ping): retry before believing a 0, or a blip gets cached for a + // whole refresh cycle. Only when the last run saw streams, though: with + // nothing playing before, an empty answer is simply true, and retrying it + // would hold every poll of an idle server for 1.4 s. Retries are bounded (a + // slow app can't eat the timeout) and a retry failure keeps the + // confirmed-good empty answer. let active = await fetchActive(); - for (let i = 0; active.length === 0 && i < 2; i++) { + for (let i = 0; active.length === 0 && lastActiveCount > 0 && i < 2; i++) { await new Promise((resolve) => setTimeout(resolve, 700)); try { active = await fetchActive(AbortSignal.timeout(2000)); @@ -150,48 +188,46 @@ export async function run(ctx: WidgetContext): Promise { break; } } - // The count reflects every active stream; the list shows the first few. + lastActiveCount = active.length; + // The count reflects every active stream; the card steps through the first few, one dot each. const total = active.length; const sessions = active.slice(0, 5); - const images = await Promise.all( - sessions.map((session) => fetchThumb(base, token, session.grandparentThumb || session.thumb)), - ); - - const fields: NonNullable = { - streams: { type: "stat", label: total === 1 ? "Stream" : "Streams", value: String(total) }, - sessions: { - type: "list", - entries: sessions.map((session, i) => ({ - title: sessionTitle(session), - subtitle: sessionSubtitle(session), - // Text floor: a static timecode any renderer can show as-is. - meta: sessionMeta(session), - // Enrichment: capable renderers tick this between polls. - elapsed: - session.viewOffset !== undefined && session.duration - ? { - ms: session.viewOffset, - ofMs: session.duration, - state: session.Player?.state === "paused" ? ("paused" as const) : ("running" as const), - } - : undefined, - image: images[i], - })), - }, - summary: { - type: "text", - text: - total === 0 - ? "Nothing playing" - : `${total} stream${total === 1 ? "" : "s"} · ${sessionTitle(sessions[0])}`, + const artwork = await Promise.all(sessions.map((session) => fetchArtwork(base, token, session))); + + return { + fields: { + streams: { type: "stat", label: total === 1 ? "Stream" : "Streams", value: String(total) }, + // Each entry carries its own artwork: the card's background names this list, so the + // picture behind the card is the one for the stream on screen. + sessions: { + type: "list", + entries: sessions.map((session, i) => ({ + title: sessionTitle(session), + // The floor shows the first line; the card takes turns with all of them. + subtitle: sessionLines(session)[0], + subtitles: sessionLines(session), + // Text floor: a static timecode any renderer can show as-is. + meta: sessionMeta(session), + // Enrichment: capable renderers tick this between polls. + elapsed: + session.viewOffset !== undefined && session.duration + ? { + ms: session.viewOffset, + ofMs: session.duration, + state: session.Player?.state === "paused" ? ("paused" as const) : ("running" as const), + } + : undefined, + image: artwork[i], + })), + }, + summary: { + type: "text", + text: + total === 0 + ? "Nothing playing" + : `${total} stream${total === 1 ? "" : "s"} · ${sessionTitle(sessions[0])}`, + }, }, }; - - // The media slot's source: the first session with a poster — its own title - // labels the art. - const art = images.find((image) => image !== undefined); - if (art) fields.art = { type: "image", image: art, alt: sessionTitle(sessions[images.indexOf(art)]) }; - - return { fields }; } diff --git a/scrutiny.json b/scrutiny.json index 2f8f830..30fe21a 100644 --- a/scrutiny.json +++ b/scrutiny.json @@ -59,7 +59,7 @@ } } }, - "widgetsSchema": 2, + "widgetsSchema": 3, "widgets": [ { "id": "disk-health", @@ -73,28 +73,17 @@ "app": "scrutiny" } ], - "sizes": { - "small": { - "slots": [ - { - "type": "stat", - "field": "passing" - } - ] + "slots": [ + { + "type": "stat", + "field": "passing" }, - "large": { - "slots": [ - { - "type": "stat", - "field": "passing" - }, - { - "type": "list", - "field": "disks" - } - ] + { + "type": "list", + "field": "disks", + "rows": 2 } - }, + ], "script": "scrutiny/widget_disk_health.ts", "entrypoint": "run", "timeout": 10