From 7849dbd4db2c979af22096fdcfd71a4062127021 Mon Sep 17 00:00:00 2001 From: Aaron Roach Date: Sun, 4 Oct 2026 10:12:04 -0400 Subject: [PATCH 1/6] Add card sizes for the Plex and FileFlows widgets, with validator and type support for the card size and progress --- _lib/contract.ts | 10 ++++++++- _lib/validate.ts | 37 ++++++++++++++++++++++++++++++--- _lib/widget_context.ts | 13 ++++++++++-- fileflows.json | 13 ++++++++++++ fileflows/widget_conversions.ts | 3 +++ plex.json | 12 +++++++++++ plex/widget_now_playing.ts | 36 ++++++++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 6 deletions(-) diff --git a/_lib/contract.ts b/_lib/contract.ts index 1ee0908..39067a2 100644 --- a/_lib/contract.ts +++ b/_lib/contract.ts @@ -102,4 +102,12 @@ export const HOOK_TARGET_TYPES = ["files"] as const; export const SUPPORTED_WIDGETS_SCHEMA = 2; /** Widget slot types. */ -export const WIDGET_SLOT_TYPES = ["text", "stat", "list", "image"] as const; +export const WIDGET_SLOT_TYPES = ["text", "stat", "list", "image", "progress"] as const; + +/** + * Slot types that joined widgetsSchema 2 after it shipped. A platform that + * predates one drops a whole widget for using it in `small` or `large`, so the + * validator warns there. The `card` size is newer than all of them and is + * simply ignored by such a platform, so any slot type is safe in it. + */ +export const WIDGET_SLOT_TYPES_SINCE_CARD = ["progress"] as const; diff --git a/_lib/validate.ts b/_lib/validate.ts index 83c7682..6fbddfb 100644 --- a/_lib/validate.ts +++ b/_lib/validate.ts @@ -24,6 +24,7 @@ import { SUPPORTED_VERSIONS, SUPPORTED_WIDGETS_SCHEMA, WIDGET_SLOT_TYPES, + WIDGET_SLOT_TYPES_SINCE_CARD, } from "./contract"; type Problem = { file: string; message: string }; @@ -582,14 +583,27 @@ function validate(file: string, script: Record) { if (!isObject(widget.sizes)) { err(file, `${where}: sizes must be an object`); } else { - for (const sizeKey of ["small", "large"] as const) { + for (const sizeKey of ["small", "large", "card"] 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) { + // The card has artwork behind it, not a media slot beside its text. + if (sizeKey === "card") { + if (size.media !== undefined) { + err(file, `${where}.sizes.card: has no media slot; name an image field in background instead`); + } + if ( + size.background !== undefined && + (!isObject(size.background) || + typeof size.background.field !== "string" || + !size.background.field.trim()) + ) { + err(file, `${where}.sizes.card.background: field is missing`); + } + } else 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("/")}`); @@ -601,7 +615,7 @@ function validate(file: string, script: Record) { 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; + const maxSlots = sizeKey === "large" ? 4 : 3; if (slots.length > maxSlots) { err(file, `${where}.sizes.${sizeKey}: max ${maxSlots} slots`); } @@ -609,10 +623,27 @@ function validate(file: string, script: Record) { 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`); + } else if ( + sizeKey !== "card" && + (WIDGET_SLOT_TYPES_SINCE_CARD as readonly string[]).includes(slot.type) + ) { + warn( + file, + `${where}.sizes.${sizeKey}.slots[${k}]: a "${slot.type}" slot hides this whole widget on servers that have not updated yet; keep it to the card size until they have`, + ); } if (typeof slot.field !== "string" || !slot.field.trim()) { err(file, `${where}.sizes.${sizeKey}.slots[${k}]: field is missing`); } + if (slot.rows !== undefined) { + if (sizeKey !== "card") { + err(file, `${where}.sizes.${sizeKey}.slots[${k}]: rows is a card-size setting`); + } else if (slot.type !== "list") { + err(file, `${where}.sizes.card.slots[${k}]: rows applies to a list slot only`); + } else if (typeof slot.rows !== "number" || ![1, 2, 3].includes(slot.rows)) { + err(file, `${where}.sizes.card.slots[${k}]: rows must be 1, 2 or 3`); + } + } } } } diff --git a/_lib/widget_context.ts b/_lib/widget_context.ts index cf6ffb8..0456011 100644 --- a/_lib/widget_context.ts +++ b/_lib/widget_context.ts @@ -35,6 +35,12 @@ export interface WidgetListEntry { * representation. state "paused" = not accruing. */ elapsed?: { ms: number; ofMs?: number; state?: "running" | "paused" }; + /** + * 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; /** * Optional thumbnail as a size-capped (60KB) data URI. Data URIs ONLY — * fetch and inline server-side; never emit app URLs (key leak, @@ -46,14 +52,17 @@ export interface WidgetListEntry { /** * 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 + * image field is the source of a media slot or of the card's background. + * 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 diff --git a/fileflows.json b/fileflows.json index ebddf47..a10a6eb 100644 --- a/fileflows.json +++ b/fileflows.json @@ -172,6 +172,19 @@ "field": "files" } ] + }, + "card": { + "slots": [ + { + "type": "stat", + "field": "active" + }, + { + "type": "list", + "field": "files", + "rows": 1 + } + ] } }, "script": "fileflows/widget_conversions.ts", 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/plex.json b/plex.json index 2e1a6b9..ba3f359 100644 --- a/plex.json +++ b/plex.json @@ -197,6 +197,18 @@ "field": "sessions" } ] + }, + "card": { + "background": { + "field": "backdrop" + }, + "slots": [ + { + "type": "list", + "field": "sessions", + "rows": 1 + } + ] } }, "script": "plex/widget_now_playing.ts", diff --git a/plex/widget_now_playing.ts b/plex/widget_now_playing.ts index 3e45c53..ab63135 100644 --- a/plex/widget_now_playing.ts +++ b/plex/widget_now_playing.ts @@ -40,6 +40,31 @@ async function fetchThumb( } } +/** + * The item's wide artwork as a size-capped data URI, for the app card's + * background. A card is about 330x130, so the transcoder is asked for a + * landscape crop near that, not the full backdrop. + */ +async function fetchBackdrop( + base: string, + token: string, + art: string | undefined, +): Promise { + if (!art) return undefined; + try { + const response = await fetch( + `${base}/photo/:/transcode?width=480&height=190&minSize=1&format=jpeg&url=${encodeURIComponent(art)}&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; + } catch { + return undefined; + } +} + interface PlexSession { title: string; type?: string; @@ -50,6 +75,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 @@ -193,5 +220,14 @@ export async function run(ctx: WidgetContext): Promise { const art = images.find((image) => image !== undefined); if (art) fields.art = { type: "image", image: art, alt: sessionTitle(sessions[images.indexOf(art)]) }; + // The app card's background: the first session's wide artwork, since the + // card features that session. Its poster stands in when it has none, or + // when the backdrop is over the size cap. + const first = sessions[0]; + if (first) { + const backdrop = (await fetchBackdrop(base, token, first.art || first.grandparentArt)) ?? images[0]; + if (backdrop) fields.backdrop = { type: "image", image: backdrop, alt: sessionTitle(first) }; + } + return { fields }; } From 219b998f857ae40f882031f767094623f6a312a2 Mon Sep 17 00:00:00 2001 From: Aaron Roach Date: Thu, 8 Oct 2026 20:03:41 -0400 Subject: [PATCH 2/6] Validated widgets against widgetsSchema 3: a widget is the app's card, with its own background and slots The sizes of widgetsSchema 2 are gone, an image is never a slot (the artwork is the background), and a list slot may say how many rows it shows. Co-Authored-By: Claude Opus 5.5 (1M context) --- _lib/contract.ts | 24 +++++---- _lib/validate.ts | 114 +++++++++++++++++------------------------ _lib/widget_context.ts | 34 +++++++----- 3 files changed, 81 insertions(+), 91 deletions(-) diff --git a/_lib/contract.ts b/_lib/contract.ts index 39067a2..7b0dbb8 100644 --- a/_lib/contract.ts +++ b/_lib/contract.ts @@ -98,16 +98,18 @@ export const HOOK_TARGET_TYPES = ["files"] as const; // --- v6 widget contract --- -/** Supported widgetsSchema version. */ -export const SUPPORTED_WIDGETS_SCHEMA = 2; - -/** Widget slot types. */ -export const WIDGET_SLOT_TYPES = ["text", "stat", "list", "image", "progress"] as const; - /** - * Slot types that joined widgetsSchema 2 after it shipped. A platform that - * predates one drops a whole widget for using it in `small` or `large`, so the - * validator warns there. The `card` size is newer than all of them and is - * simply ignored by such a platform, so any slot type is safe in it. + * 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 WIDGET_SLOT_TYPES_SINCE_CARD = ["progress"] as const; +export const SUPPORTED_WIDGETS_SCHEMA = 3; + +/** 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 6fbddfb..0980f48 100644 --- a/_lib/validate.ts +++ b/_lib/validate.ts @@ -23,8 +23,9 @@ import { SPEC_REGEX, SUPPORTED_VERSIONS, SUPPORTED_WIDGETS_SCHEMA, + WIDGET_MAX_SLOTS, + WIDGET_MIN_REFRESH_S, WIDGET_SLOT_TYPES, - WIDGET_SLOT_TYPES_SINCE_CARD, } from "./contract"; type Problem = { file: string; message: string }; @@ -551,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; @@ -579,72 +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", "card"] as const) { - const size = widget.sizes[sizeKey]; - if (size === undefined) continue; - if (!isObject(size)) { - err(file, `${where}.sizes.${sizeKey}: must be an object`); - continue; - } - // The card has artwork behind it, not a media slot beside its text. - if (sizeKey === "card") { - if (size.media !== undefined) { - err(file, `${where}.sizes.card: has no media slot; name an image field in background instead`); - } - if ( - size.background !== undefined && - (!isObject(size.background) || - typeof size.background.field !== "string" || - !size.background.field.trim()) - ) { - err(file, `${where}.sizes.card.background: field is missing`); - } - } else 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 === "large" ? 4 : 3; - 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`); - } else if ( - sizeKey !== "card" && - (WIDGET_SLOT_TYPES_SINCE_CARD as readonly string[]).includes(slot.type) - ) { - warn( - file, - `${where}.sizes.${sizeKey}.slots[${k}]: a "${slot.type}" slot hides this whole widget on servers that have not updated yet; keep it to the card size until they have`, - ); - } - if (typeof slot.field !== "string" || !slot.field.trim()) { - err(file, `${where}.sizes.${sizeKey}.slots[${k}]: field is missing`); - } - if (slot.rows !== undefined) { - if (sizeKey !== "card") { - err(file, `${where}.sizes.${sizeKey}.slots[${k}]: rows is a card-size setting`); - } else if (slot.type !== "list") { - err(file, `${where}.sizes.card.slots[${k}]: rows applies to a list slot only`); - } else if (typeof slot.rows !== "number" || ![1, 2, 3].includes(slot.rows)) { - err(file, `${where}.sizes.card.slots[${k}]: rows must be 1, 2 or 3`); - } - } - } + 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 0456011..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 @@ -42,17 +49,20 @@ export interface WidgetListEntry { */ progress?: number; /** - * 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). + * 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 source of a media slot or of the card's background. + * 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. */ @@ -65,12 +75,12 @@ export type WidgetFieldValue = | { 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; From 56e96e1cac04f51a72c76ea69952d29e23d5a4ef Mon Sep 17 00:00:00 2001 From: Aaron Roach Date: Thu, 8 Oct 2026 20:03:41 -0400 Subject: [PATCH 3/6] Moved the FileFlows and Scrutiny widgets to widgetsSchema 3 Co-Authored-By: Claude Opus 5.5 (1M context) --- fileflows.json | 44 ++++++++++---------------------------------- scrutiny.json | 31 ++++++++++--------------------- 2 files changed, 20 insertions(+), 55 deletions(-) diff --git a/fileflows.json b/fileflows.json index a10a6eb..24e092d 100644 --- a/fileflows.json +++ b/fileflows.json @@ -138,7 +138,7 @@ "retries": 0 } ], - "widgetsSchema": 2, + "widgetsSchema": 3, "widgets": [ { "id": "conversions", @@ -152,41 +152,17 @@ "app": "fileflows" } ], - "sizes": { - "small": { - "slots": [ - { - "type": "stat", - "field": "active" - } - ] - }, - "large": { - "slots": [ - { - "type": "stat", - "field": "active" - }, - { - "type": "list", - "field": "files" - } - ] + "slots": [ + { + "type": "stat", + "field": "active" }, - "card": { - "slots": [ - { - "type": "stat", - "field": "active" - }, - { - "type": "list", - "field": "files", - "rows": 1 - } - ] + { + "type": "list", + "field": "files", + "rows": 1 } - }, + ], "script": "fileflows/widget_conversions.ts", "entrypoint": "run", "timeout": 10 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 From 06b302f2fe3ac9816cab4253b6b2d0a6d1fbd54c Mon Sep 17 00:00:00 2001 From: Aaron Roach Date: Thu, 8 Oct 2026 20:03:41 -0400 Subject: [PATCH 4/6] Moved the Plex widget to widgetsSchema 3, with each stream's artwork behind the card The script fetches each stream's wide artwork once, caps it to the image field's size and keeps it while the stream plays. Co-Authored-By: Claude Opus 5.5 (1M context) --- plex.json | 50 ++------- plex/widget_now_playing.ts | 214 ++++++++++++++++++------------------- 2 files changed, 118 insertions(+), 146 deletions(-) diff --git a/plex.json b/plex.json index ba3f359..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,44 +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" - } - ] - }, - "card": { - "background": { - "field": "backdrop" - }, - "slots": [ - { - "type": "list", - "field": "sessions", - "rows": 1 - } - ] - } + "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 ab63135..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,47 +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; - 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; - } catch { - 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 item's wide artwork as a size-capped data URI, for the app card's - * background. A card is about 330x130, so the transcoder is asked for a - * landscape crop near that, not the full backdrop. + * 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. */ -async function fetchBackdrop( - base: string, - token: string, - art: string | undefined, -): Promise { - if (!art) return undefined; +// 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=480&height=190&minSize=1&format=jpeg&url=${encodeURIComponent(art)}&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; } @@ -91,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. */ @@ -122,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 { @@ -165,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)); @@ -177,57 +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)]) }; - - // The app card's background: the first session's wide artwork, since the - // card features that session. Its poster stands in when it has none, or - // when the backdrop is over the size cap. - const first = sessions[0]; - if (first) { - const backdrop = (await fetchBackdrop(base, token, first.art || first.grandparentArt)) ?? images[0]; - if (backdrop) fields.backdrop = { type: "image", image: backdrop, alt: sessionTitle(first) }; - } - - return { fields }; } From e1a7a90aa7f9c49c4cfb0687d73d9cddff9e7be0 Mon Sep 17 00:00:00 2001 From: Aaron Roach Date: Thu, 8 Oct 2026 20:03:41 -0400 Subject: [PATCH 5/6] Added a now-playing widget to Jellyfin, the same card Plex draws The setup hook creates the API key the widget reads sessions with; without it the widget asks the user to sign in. Co-Authored-By: Claude Opus 5.5 (1M context) --- jellyfin.json | 41 ++++- jellyfin/jellyfin_hook.ts | 37 ++++- jellyfin/widget_now_playing.ts | 265 +++++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 jellyfin/widget_now_playing.ts 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}`, + }, + }, + }; +} From eabf767561b662deb1d6dc5f3188502d9840f0ed Mon Sep 17 00:00:00 2001 From: Aaron Roach Date: Thu, 8 Oct 2026 20:04:11 -0400 Subject: [PATCH 6/6] Moved the hexos-test harness widgets to widgetsSchema 3 Co-Authored-By: Claude Opus 5.5 (1M context) --- hexos-test.json | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) 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" }] } ] }