Enhancement: support jellyfin 10.12 breaking API changes (#6252)
Some checks failed
Crowdin Action / Crowdin Sync (push) Has been cancelled
Docker CI / Linting Checks (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Repository Maintenance / Stale (push) Has been cancelled
Repository Maintenance / Lock Old Threads (push) Has been cancelled
Repository Maintenance / Close Outdated Discussions (push) Has been cancelled
Repository Maintenance / Close Unsupported Feature Requests (push) Has been cancelled
Repository Maintenance / Close Answered Discussions (push) Has been cancelled

This commit is contained in:
shamoon
2026-01-30 22:05:19 -08:00
committed by GitHub
parent 79b63e4099
commit 4ebc24a1b4
9 changed files with 477 additions and 5 deletions

View File

@@ -566,6 +566,7 @@ export function cleanServiceGroups(groups) {
"beszel",
"glances",
"immich",
"jellyfin",
"komga",
"mealie",
"netalertx",

View File

@@ -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")),

View File

@@ -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 (

View File

@@ -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 (
<>
<div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
<div className="grow text-xs z-10 self-center ml-2 relative w-full h-4 mr-2">
<div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden" title={streamTitle}>
{streamTitle}
</div>
</div>
<div className="self-center text-xs flex justify-end mr-1.5 pl-1">
{IsVideoDirect && <MdOutlineSmartDisplay className="opacity-50" />}
{!IsVideoDirect && (!VideoDecoderIsHardware || !VideoEncoderIsHardware) && <BsCpu className="opacity-50" />}
{!IsVideoDirect && VideoDecoderIsHardware && VideoEncoderIsHardware && (
<BsFillCpuFill className="opacity-50" />
)}
</div>
</div>
<div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
<div
className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0"
style={{
width: `${percent}%`,
}}
/>
<div className="text-xs z-10 self-center ml-1">
{enableMediaControl && IsPaused && (
<BsFillPlayFill
onClick={() => {
playCommand(session, "Unpause");
}}
className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
/>
)}
{enableMediaControl && !IsPaused && (
<BsPauseFill
onClick={() => {
playCommand(session, "Pause");
}}
className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
/>
)}
</div>
<div className="grow " />
<div className="self-center text-xs flex justify-end mr-1 z-10">{IsMuted && <BsVolumeMuteFill />}</div>
<div className="self-center text-xs flex justify-end mr-2 z-10">
{ticksToString(PositionTicks)}
<span className="mx-0.5 text-[8px]">/</span>
{ticksToString(RunTimeTicks)}
</div>
</div>
</>
);
}
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 (
<div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
<div
className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0"
style={{
width: `${percent}%`,
}}
/>
<div className="text-xs z-10 self-center ml-1">
{enableMediaControl && IsPaused && (
<BsFillPlayFill
onClick={() => {
playCommand(session, "Unpause");
}}
className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
/>
)}
{enableMediaControl && !IsPaused && (
<BsPauseFill
onClick={() => {
playCommand(session, "Pause");
}}
className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
/>
)}
</div>
<div className="grow text-xs z-10 self-center relative w-full h-4">
<div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden" title={streamTitle}>
{streamTitle}
</div>
</div>
<div className="self-center text-xs flex justify-end mr-1 z-10">{IsMuted && <BsVolumeMuteFill />}</div>
<div className="self-center text-xs flex justify-end mr-1 z-10">{ticksToString(PositionTicks)}</div>
<div className="self-center items-center text-xs flex justify-end mr-1.5 pl-1 z-10">
{IsVideoDirect && <MdOutlineSmartDisplay className="opacity-50" />}
{!IsVideoDirect && (!VideoDecoderIsHardware || !VideoEncoderIsHardware) && <BsCpu className="opacity-50" />}
{!IsVideoDirect && VideoDecoderIsHardware && VideoEncoderIsHardware && <BsFillCpuFill className="opacity-50" />}
</div>
</div>
);
}
function CountBlocks({ service, countData }) {
const { t } = useTranslation();
if (!countData) {
return (
<Container service={service}>
<Block label="jellyfin.movies" />
<Block label="jellyfin.series" />
<Block label="jellyfin.episodes" />
<Block label="jellyfin.songs" />
</Container>
);
}
return (
<Container service={service}>
<Block label="jellyfin.movies" value={t("common.number", { value: countData.MovieCount })} />
<Block label="jellyfin.series" value={t("common.number", { value: countData.SeriesCount })} />
<Block label="jellyfin.episodes" value={t("common.number", { value: countData.EpisodeCount })} />
<Block label="jellyfin.songs" value={t("common.number", { value: countData.SongCount })} />
</Container>
);
}
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 <Container service={service} error={sessionsError ?? countError} />;
}
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 && <CountBlocks service={service} countData={null} />}
{enableNowPlaying && (
<div className="flex flex-col pb-1">
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">-</span>
</div>
{expandOneStreamToTwoRows && (
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">-</span>
</div>
)}
</div>
)}
</>
);
}
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 && <CountBlocks service={service} countData={countData} />}
<div className="flex flex-col pb-1 mx-1">
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">{t("jellyfin.no_active")}</span>
</div>
{expandOneStreamToTwoRows && (
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">-</span>
</div>
)}
</div>
</>
);
}
if (expandOneStreamToTwoRows && playing.length === 1) {
const session = playing[0];
return (
<>
{enableBlocks && <CountBlocks service={service} countData={countData} />}
<div className="flex flex-col pb-1 mx-1">
<SingleSessionEntry
playCommand={(currentSession, command) => handlePlayCommand(currentSession, command)}
session={session}
enableUser={enableUser}
showEpisodeNumber={showEpisodeNumber}
enableMediaControl={enableMediaControl}
/>
</div>
</>
);
}
return (
<>
{enableBlocks && <CountBlocks service={service} countData={countData} />}
<div className="flex flex-col pb-1 mx-1">
{playing.map((session) => (
<SessionEntry
key={session.Id}
playCommand={(currentSession, command) => handlePlayCommand(currentSession, command)}
session={session}
enableUser={enableUser}
showEpisodeNumber={showEpisodeNumber}
enableMediaControl={enableMediaControl}
/>
))}
</div>
</>
);
}
if (enableBlocks) {
return <CountBlocks service={service} countData={countData} />;
}
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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,