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 ( + <> +
+
+
+ {streamTitle} +
+
+
+ {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" + /> + )} +
+
+
+ {streamTitle} +
+
+
{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,