From 4ebc24a1b45136cf6598ca678dc917247f698d00 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Fri, 30 Jan 2026 22:05:19 -0800
Subject: [PATCH] Enhancement: support jellyfin 10.12 breaking API changes
(#6252)
---
docs/widgets/services/jellyfin.md | 6 +
public/locales/en/common.json | 10 +
src/utils/config/service-helpers.js | 1 +
src/widgets/components.js | 2 +-
src/widgets/emby/component.jsx | 3 -
src/widgets/jellyfin/component.jsx | 345 ++++++++++++++++++++++++++++
src/widgets/jellyfin/proxy.js | 69 ++++++
src/widgets/jellyfin/widget.js | 43 ++++
src/widgets/widgets.js | 3 +-
9 files changed, 477 insertions(+), 5 deletions(-)
create mode 100644 src/widgets/jellyfin/component.jsx
create mode 100644 src/widgets/jellyfin/proxy.js
create mode 100644 src/widgets/jellyfin/widget.js
diff --git a/docs/widgets/services/jellyfin.md b/docs/widgets/services/jellyfin.md
index 667930e3c..8849e0645 100644
--- a/docs/widgets/services/jellyfin.md
+++ b/docs/widgets/services/jellyfin.md
@@ -9,11 +9,17 @@ You can create an API key from inside Jellyfin at `Settings > Advanced > Api Key
As of v0.6.11 the widget supports fields `["movies", "series", "episodes", "songs"]`. These blocks are disabled by default but can be enabled with the `enableBlocks` option, and the "Now Playing" feature (enabled by default) can be disabled with the `enableNowPlaying` option.
+| Jellyfin Version | Homepage Widget Version |
+| ---------------- | ----------------------- |
+| < 10.12 | 1 (default) |
+| >= 10.12 | 2 |
+
```yaml
widget:
type: jellyfin
url: http://jellyfin.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
+ version: 2 # optional, default is 1
enableBlocks: true # optional, defaults to false
enableNowPlaying: true # optional, defaults to true
enableUser: true # optional, defaults to false
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index c002e2771..2f655bf32 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -107,6 +107,16 @@
"episodes": "Episodes",
"songs": "Songs"
},
+ "jellyfin": {
+ "playing": "Playing",
+ "transcoding": "Transcoding",
+ "bitrate": "Bitrate",
+ "no_active": "No Active Streams",
+ "movies": "Movies",
+ "series": "Series",
+ "episodes": "Episodes",
+ "songs": "Songs"
+ },
"esphome": {
"offline": "Offline",
"offline_alt": "Offline",
diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js
index 42e85243b..75740c3ef 100644
--- a/src/utils/config/service-helpers.js
+++ b/src/utils/config/service-helpers.js
@@ -566,6 +566,7 @@ export function cleanServiceGroups(groups) {
"beszel",
"glances",
"immich",
+ "jellyfin",
"komga",
"mealie",
"netalertx",
diff --git a/src/widgets/components.js b/src/widgets/components.js
index e69a985c5..c114a82a5 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -63,7 +63,7 @@ const components = {
immich: dynamic(() => import("./immich/component")),
jackett: dynamic(() => import("./jackett/component")),
jdownloader: dynamic(() => import("./jdownloader/component")),
- jellyfin: dynamic(() => import("./emby/component")),
+ jellyfin: dynamic(() => import("./jellyfin/component")),
jellyseerr: dynamic(() => import("./jellyseerr/component")),
jellystat: dynamic(() => import("./jellystat/component")),
kavita: dynamic(() => import("./kavita/component")),
diff --git a/src/widgets/emby/component.jsx b/src/widgets/emby/component.jsx
index b03a00415..39a347185 100644
--- a/src/widgets/emby/component.jsx
+++ b/src/widgets/emby/component.jsx
@@ -176,9 +176,6 @@ function SessionEntry({ playCommand, session, enableUser, showEpisodeNumber, ena
function CountBlocks({ service, countData }) {
const { t } = useTranslation();
- // allows filtering
- // eslint-disable-next-line no-param-reassign
- if (service.widget?.type === "jellyfin") service.widget.type = "emby";
if (!countData) {
return (
diff --git a/src/widgets/jellyfin/component.jsx b/src/widgets/jellyfin/component.jsx
new file mode 100644
index 000000000..591ee73de
--- /dev/null
+++ b/src/widgets/jellyfin/component.jsx
@@ -0,0 +1,345 @@
+import Block from "components/services/widget/block";
+import Container from "components/services/widget/container";
+import { useTranslation } from "next-i18next";
+import { BsCpu, BsFillCpuFill, BsFillPlayFill, BsPauseFill, BsVolumeMuteFill } from "react-icons/bs";
+import { MdOutlineSmartDisplay } from "react-icons/md";
+
+import { getURLSearchParams } from "utils/proxy/api-helpers";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+function ticksToTime(ticks) {
+ const milliseconds = ticks / 10000;
+ const seconds = Math.floor((milliseconds / 1000) % 60);
+ const minutes = Math.floor((milliseconds / (1000 * 60)) % 60);
+ const hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24);
+ return { hours, minutes, seconds };
+}
+
+function ticksToString(ticks) {
+ const { hours, minutes, seconds } = ticksToTime(ticks);
+ const parts = [];
+ if (hours > 0) {
+ parts.push(hours);
+ }
+ parts.push(minutes);
+ parts.push(seconds);
+
+ return parts.map((part) => part.toString().padStart(2, "0")).join(":");
+}
+
+function generateStreamTitle(session, enableUser, showEpisodeNumber) {
+ const {
+ NowPlayingItem: { Name, SeriesName, Type, ParentIndexNumber, IndexNumber, AlbumArtist, Album },
+ UserName,
+ } = session;
+ let streamTitle = "";
+
+ if (Type === "Episode" && showEpisodeNumber) {
+ const seasonStr = ParentIndexNumber ? `S${ParentIndexNumber.toString().padStart(2, "0")}` : "";
+ const episodeStr = IndexNumber ? `E${IndexNumber.toString().padStart(2, "0")}` : "";
+ streamTitle = `${SeriesName}: ${seasonStr} ยท ${episodeStr} - ${Name}`;
+ } else if (Type === "Audio") {
+ streamTitle = `${AlbumArtist} - ${Album} - ${Name}`;
+ } else {
+ streamTitle = `${Name}${SeriesName ? ` - ${SeriesName}` : ""}`;
+ }
+
+ return enableUser ? `${streamTitle} (${UserName})` : streamTitle;
+}
+
+function SingleSessionEntry({ playCommand, session, enableUser, showEpisodeNumber, enableMediaControl }) {
+ const {
+ PlayState: { PositionTicks, IsPaused, IsMuted },
+ } = session;
+
+ const RunTimeTicks =
+ session.NowPlayingItem?.RunTimeTicks ?? session.NowPlayingItem?.CurrentProgram?.RunTimeTicks ?? 0;
+
+ const { IsVideoDirect, VideoDecoderIsHardware, VideoEncoderIsHardware } = session?.TranscodingInfo || {
+ IsVideoDirect: true,
+ }; // if no transcodinginfo its videodirect
+
+ const percent = Math.min(1, PositionTicks / RunTimeTicks) * 100;
+
+ const streamTitle = generateStreamTitle(session, enableUser, showEpisodeNumber);
+ return (
+ <>
+
+
+
+ {IsVideoDirect && }
+ {!IsVideoDirect && (!VideoDecoderIsHardware || !VideoEncoderIsHardware) && }
+ {!IsVideoDirect && VideoDecoderIsHardware && VideoEncoderIsHardware && (
+
+ )}
+
+
+
+
+
+
+ {enableMediaControl && IsPaused && (
+ {
+ playCommand(session, "Unpause");
+ }}
+ className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
+ />
+ )}
+ {enableMediaControl && !IsPaused && (
+ {
+ playCommand(session, "Pause");
+ }}
+ className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
+ />
+ )}
+
+
+
{IsMuted && }
+
+ {ticksToString(PositionTicks)}
+ /
+ {ticksToString(RunTimeTicks)}
+
+
+ >
+ );
+}
+
+function SessionEntry({ playCommand, session, enableUser, showEpisodeNumber, enableMediaControl }) {
+ const {
+ PlayState: { PositionTicks, IsPaused, IsMuted },
+ } = session;
+
+ const RunTimeTicks =
+ session.NowPlayingItem?.RunTimeTicks ?? session.NowPlayingItem?.CurrentProgram?.RunTimeTicks ?? 0;
+
+ const { IsVideoDirect, VideoDecoderIsHardware, VideoEncoderIsHardware } = session?.TranscodingInfo || {
+ IsVideoDirect: true,
+ }; // if no transcodinginfo its videodirect
+
+ const streamTitle = generateStreamTitle(session, enableUser, showEpisodeNumber);
+
+ const percent = Math.min(1, PositionTicks / RunTimeTicks) * 100;
+
+ return (
+
+
+
+ {enableMediaControl && IsPaused && (
+ {
+ playCommand(session, "Unpause");
+ }}
+ className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
+ />
+ )}
+ {enableMediaControl && !IsPaused && (
+ {
+ playCommand(session, "Pause");
+ }}
+ className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
+ />
+ )}
+
+
+
{IsMuted && }
+
{ticksToString(PositionTicks)}
+
+ {IsVideoDirect && }
+ {!IsVideoDirect && (!VideoDecoderIsHardware || !VideoEncoderIsHardware) && }
+ {!IsVideoDirect && VideoDecoderIsHardware && VideoEncoderIsHardware && }
+
+
+ );
+}
+
+function CountBlocks({ service, countData }) {
+ const { t } = useTranslation();
+
+ if (!countData) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+
+ const { widget } = service;
+ const version = widget?.version ?? 1;
+ const useJellyfinV2 = version === 2;
+ const sessionsEndpoint = useJellyfinV2 ? "SessionsV2" : "Sessions";
+ const countEndpoint = useJellyfinV2 ? "CountV2" : "Count";
+ const commandMap = {
+ Pause: useJellyfinV2 ? "PauseV2" : "Pause",
+ Unpause: useJellyfinV2 ? "UnpauseV2" : "Unpause",
+ };
+ const enableNowPlaying = service.widget?.enableNowPlaying ?? true;
+
+ const {
+ data: sessionsData,
+ error: sessionsError,
+ mutate: sessionMutate,
+ } = useWidgetAPI(widget, enableNowPlaying ? sessionsEndpoint : "", {
+ refreshInterval: enableNowPlaying ? 5000 : undefined,
+ });
+
+ const { data: countData, error: countError } = useWidgetAPI(widget, countEndpoint, {
+ refreshInterval: 60000,
+ });
+
+ async function handlePlayCommand(session, command) {
+ const mappedCommand = commandMap[command] ?? command;
+ const params = getURLSearchParams(widget, mappedCommand);
+ params.append(
+ "segments",
+ JSON.stringify({
+ sessionId: session.Id,
+ }),
+ );
+ const url = `/api/services/proxy?${params.toString()}`;
+ await fetch(url, {
+ method: "POST",
+ }).then(() => {
+ sessionMutate();
+ });
+ }
+
+ if (sessionsError || countError) {
+ return ;
+ }
+
+ const enableBlocks = service.widget?.enableBlocks;
+ const enableMediaControl = service.widget?.enableMediaControl !== false; // default is true
+ const enableUser = !!service.widget?.enableUser; // default is false
+ const expandOneStreamToTwoRows = service.widget?.expandOneStreamToTwoRows !== false; // default is true
+ const showEpisodeNumber = !!service.widget?.showEpisodeNumber; // default is false
+
+ if ((enableNowPlaying && !sessionsData) || !countData) {
+ return (
+ <>
+ {enableBlocks && }
+ {enableNowPlaying && (
+
+
+ -
+
+ {expandOneStreamToTwoRows && (
+
+ -
+
+ )}
+
+ )}
+ >
+ );
+ }
+
+ if (enableNowPlaying) {
+ const playing = sessionsData
+ .filter((session) => session?.NowPlayingItem)
+ .sort((a, b) => {
+ if (a.PlayState.PositionTicks > b.PlayState.PositionTicks) {
+ return 1;
+ }
+ if (a.PlayState.PositionTicks < b.PlayState.PositionTicks) {
+ return -1;
+ }
+ return 0;
+ });
+
+ if (playing.length === 0) {
+ return (
+ <>
+ {enableBlocks && }
+
+
+ {t("jellyfin.no_active")}
+
+ {expandOneStreamToTwoRows && (
+
+ -
+
+ )}
+
+ >
+ );
+ }
+
+ if (expandOneStreamToTwoRows && playing.length === 1) {
+ const session = playing[0];
+ return (
+ <>
+ {enableBlocks && }
+
+ handlePlayCommand(currentSession, command)}
+ session={session}
+ enableUser={enableUser}
+ showEpisodeNumber={showEpisodeNumber}
+ enableMediaControl={enableMediaControl}
+ />
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {enableBlocks && }
+
+ {playing.map((session) => (
+ handlePlayCommand(currentSession, command)}
+ session={session}
+ enableUser={enableUser}
+ showEpisodeNumber={showEpisodeNumber}
+ enableMediaControl={enableMediaControl}
+ />
+ ))}
+
+ >
+ );
+ }
+
+ if (enableBlocks) {
+ return ;
+ }
+}
diff --git a/src/widgets/jellyfin/proxy.js b/src/widgets/jellyfin/proxy.js
new file mode 100644
index 000000000..28f57d5ce
--- /dev/null
+++ b/src/widgets/jellyfin/proxy.js
@@ -0,0 +1,69 @@
+import getServiceWidget from "utils/config/service-helpers";
+import createLogger from "utils/logger";
+import { formatApiCall, sanitizeErrorURL } from "utils/proxy/api-helpers";
+import { httpProxy } from "utils/proxy/http";
+import validateWidgetData from "utils/proxy/validate-widget-data";
+import widgets from "widgets/widgets";
+
+const logger = createLogger("jellyfinProxyHandler");
+
+export default async function jellyfinProxyHandler(req, res, map) {
+ const { group, service, endpoint, index } = req.query;
+
+ if (!group || !service) {
+ logger.debug("Invalid or missing proxy service type '%s' in group '%s'", service, group);
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+
+ const widget = await getServiceWidget(group, service, index);
+
+ if (!widget || !widgets?.[widget.type]?.api) {
+ logger.debug("Invalid or missing proxy service type '%s' in group '%s'", service, group);
+ return res.status(403).json({ error: "Service does not support API calls" });
+ }
+
+ const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
+
+ const deviceIdRaw = widget.deviceId ?? `${widget.service_group || "group"}-${widget.service_name || "service"}`;
+ const deviceId = encodeURIComponent(deviceIdRaw);
+ const authHeader = `MediaBrowser Token="${encodeURIComponent(
+ widget.key,
+ )}", Client="Homepage", Device="Homepage", DeviceId="${deviceId}", Version="1.0.0"`;
+
+ const headers = {
+ Authorization: authHeader,
+ };
+
+ const params = {
+ method: req.method,
+ withCredentials: true,
+ credentials: "include",
+ headers,
+ };
+
+ const [status, contentType, data] = await httpProxy(url, params);
+
+ let resultData = data;
+
+ if (resultData.error?.url) {
+ resultData.error.url = sanitizeErrorURL(url);
+ }
+
+ if (status === 204 || status === 304) {
+ return res.status(status).end();
+ }
+
+ if (status >= 400) {
+ logger.error("HTTP Error %d calling %s", status, url.toString());
+ }
+
+ if (status === 200) {
+ if (!validateWidgetData(widget, endpoint, resultData)) {
+ return res.status(500).json({ error: { message: "Invalid data", url: sanitizeErrorURL(url), data: resultData } });
+ }
+ if (map) resultData = map(resultData);
+ }
+
+ if (contentType) res.setHeader("Content-Type", contentType);
+ return res.status(status).send(resultData);
+}
diff --git a/src/widgets/jellyfin/widget.js b/src/widgets/jellyfin/widget.js
new file mode 100644
index 000000000..a11121359
--- /dev/null
+++ b/src/widgets/jellyfin/widget.js
@@ -0,0 +1,43 @@
+import jellyfinProxyHandler from "./proxy";
+
+const widget = {
+ api: "{url}/{endpoint}",
+ proxyHandler: jellyfinProxyHandler,
+ mappings: {
+ Sessions: {
+ endpoint: "emby/Sessions?api_key={key}",
+ },
+ Count: {
+ endpoint: "emby/Items/Counts?api_key={key}",
+ },
+ Unpause: {
+ method: "POST",
+ endpoint: "emby/Sessions/{sessionId}/Playing/Unpause?api_key={key}",
+ segments: ["sessionId"],
+ },
+ Pause: {
+ method: "POST",
+ endpoint: "emby/Sessions/{sessionId}/Playing/Pause?api_key={key}",
+ segments: ["sessionId"],
+ },
+ // V2 Endpoints
+ SessionsV2: {
+ endpoint: "Sessions",
+ },
+ CountV2: {
+ endpoint: "Items/Counts",
+ },
+ UnpauseV2: {
+ method: "POST",
+ endpoint: "Sessions/{sessionId}/Playing/Unpause",
+ segments: ["sessionId"],
+ },
+ PauseV2: {
+ method: "POST",
+ endpoint: "Sessions/{sessionId}/Playing/Pause",
+ segments: ["sessionId"],
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 2029a82c6..26235729b 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -54,6 +54,7 @@ import homebridge from "./homebridge/widget";
import immich from "./immich/widget";
import jackett from "./jackett/widget";
import jdownloader from "./jdownloader/widget";
+import jellyfin from "./jellyfin/widget";
import jellyseerr from "./jellyseerr/widget";
import jellystat from "./jellystat/widget";
import karakeep from "./karakeep/widget";
@@ -207,7 +208,7 @@ const widgets = {
immich,
jackett,
jdownloader,
- jellyfin: emby,
+ jellyfin,
jellyseerr,
jellystat,
kavita,