utils/jellyfinApi.ts
Rama main · réplica de lectura del repositorio original.
import { SessionsResponse, SessionItem, Track, Movie, Episode, Person } from "../types/media";
import { normalizeVideoResolution } from "./mediaCardUtils";
interface JellyfinPerson {
Id: string;
Name: string;
Role?: string;
Type: string;
PrimaryImageTag?: string;
}
interface JellyfinMediaStream {
Type: "Video" | "Audio" | "Subtitle" | "EmbeddedImage";
Codec?: string;
Profile?: string;
Width?: number;
Height?: number;
BitRate?: number;
Channels?: number;
ChannelLayout?: string;
IsDefault?: boolean;
}
export interface JellyfinItemDetail {
People?: JellyfinPerson[];
Genres?: string[];
Studios?: { Name: string }[];
Overview?: string;
OfficialRating?: string;
CommunityRating?: number;
Taglines?: string[];
AlbumArtist?: string;
ProductionYear?: number;
BackdropImageTags?: string[];
}
interface JellyfinNowPlayingItem {
Id: string;
Name: string;
Type: string;
SeriesId?: string;
SeriesName?: string;
IndexNumber?: number;
ParentIndexNumber?: number;
RunTimeTicks?: number;
ImageTags?: { Primary?: string };
ProductionYear?: number;
AlbumArtist?: string;
Album?: string;
Artists?: string[];
MediaStreams?: JellyfinMediaStream[];
}
interface JellyfinPlayState {
IsPaused?: boolean;
PositionTicks?: number;
}
interface JellyfinTranscodingInfo {
IsVideoDirect?: boolean;
IsAudioDirect?: boolean;
CompletionPercentage?: number;
}
export interface JellyfinSession {
Id: string;
UserId: string;
UserName?: string;
DeviceName?: string;
Client?: string;
IsActive?: boolean;
NowPlayingItem?: JellyfinNowPlayingItem;
PlayState?: JellyfinPlayState;
TranscodingInfo?: JellyfinTranscodingInfo;
}
export interface EnrichedSession {
session: JellyfinSession & { NowPlayingItem: JellyfinNowPlayingItem };
detail: JellyfinItemDetail;
}
type ActiveSession = EnrichedSession["session"];
function ticksToMs(ticks: number | undefined): number {
return ticks ? Math.floor(ticks / 10000) : 0;
}
export function jellyfinThumbnailUrl(
itemId: string,
quality: "low" | "medium" | "high" = "medium",
width?: number,
imageType: string = "Primary"
): string {
const params = new URLSearchParams({ itemId, imageType, quality });
if (width) params.set("width", width.toString());
return `/api/jellyfin/thumbnail?${params.toString()}`;
}
function mapPeople(
people: JellyfinPerson[] | undefined,
type: string
): Person[] {
if (!people) return [];
return people
.filter((p) => p.Type === type)
.map((p) => ({
tag: p.Name,
role: p.Role,
thumb: p.PrimaryImageTag
? `/api/jellyfin/thumbnail?itemId=${p.Id}&imageType=Primary&quality=low&width=80`
: undefined,
}));
}
function mapJellyfinState(isPaused: boolean | undefined): "playing" | "paused" {
return isPaused ? "paused" : "playing";
}
function extractStreams(streams: JellyfinMediaStream[] | undefined) {
const videoStream = streams?.find((s) => s.Type === "Video");
const audioStream =
streams?.find((s) => s.Type === "Audio" && s.IsDefault !== false) ??
streams?.find((s) => s.Type === "Audio");
// Use the larger of the actual height or the 16:9-equivalent height derived
// from width, so letterboxed content (e.g. cinemascope 3840×1608) isn't
// misclassified — 3840px wide is 4K even when height is only 1608.
const effectiveHeight = videoStream
? Math.max(videoStream.Height ?? 0, Math.round((videoStream.Width ?? 0) * 9 / 16))
: undefined;
return {
videoResolution: normalizeVideoResolution(effectiveHeight?.toString()),
videoCodec: videoStream?.Codec,
videoProfile: videoStream?.Profile,
// BitRate from Jellyfin is bits/sec; our type stores kbps (card renders kbps/1000 = Mbps)
bitrate: videoStream?.BitRate ? Math.round(videoStream.BitRate / 1000) : undefined,
audioCodec: audioStream?.Codec,
audioChannels: audioStream?.Channels,
audioChannelLayout: audioStream?.ChannelLayout,
};
}
function jellyfinUserAvatarUrl(userId: string | undefined): string | undefined {
if (!userId) return undefined;
return `/api/jellyfin/thumbnail?itemId=${userId}&imageType=Primary&type=user&quality=low&width=80`;
}
function mapJellyfinBaseFields(session: ActiveSession, defaultPlayer: string) {
const item = session.NowPlayingItem;
const viewOffset = ticksToMs(session.PlayState?.PositionTicks);
return {
source: "jellyfin" as const,
id: item.Id,
title: item.Name,
thumbnailFileId: item.Id,
state: mapJellyfinState(session.PlayState?.IsPaused),
userId: session.UserName ?? "Unknown User",
userAvatar: jellyfinUserAvatarUrl(session.UserId),
player: session.DeviceName ?? session.Client ?? defaultPlayer,
sessionId: session.Id,
viewOffset,
};
}
function mapToMovie(session: ActiveSession, detail: JellyfinItemDetail): SessionItem<Movie> {
const item = session.NowPlayingItem;
const tc = session.TranscodingInfo;
const streams = extractStreams(item.MediaStreams);
const videoDecision = tc ? (tc.IsVideoDirect ? "copy" : "transcode") : "copy";
const audioDecision = tc ? (tc.IsAudioDirect ? "copy" : "transcode") : "copy";
return {
...mapJellyfinBaseFields(session, "Video Player"),
year: detail.ProductionYear ?? item.ProductionYear ?? 0,
director: detail.People?.find((p) => p.Type === "Director")?.Name,
studio: detail.Studios?.[0]?.Name,
duration: ticksToMs(item.RunTimeTicks),
summary: detail.Overview ?? "",
contentRating: detail.OfficialRating,
genre: detail.Genres ?? [],
rating: detail.CommunityRating,
tagline: detail.Taglines?.[0],
videoDecision,
audioDecision,
transcodeProgress: tc?.CompletionPercentage,
transcodeHwRequested: false,
actors: mapPeople(detail.People, "Actor").slice(0, 15),
writers: mapPeople(detail.People, "Writer").map(({ tag }) => ({ tag })),
backdropPath: detail.BackdropImageTags?.length ? item.Id : undefined,
...streams,
};
}
function mapToEpisode(
session: ActiveSession,
detail: JellyfinItemDetail
): SessionItem<Episode> {
const item = session.NowPlayingItem;
const tc = session.TranscodingInfo;
const streams = extractStreams(item.MediaStreams);
const videoDecision = tc ? (tc.IsVideoDirect ? "copy" : "transcode") : "copy";
const audioDecision = tc ? (tc.IsAudioDirect ? "copy" : "transcode") : "copy";
return {
...mapJellyfinBaseFields(session, "Video Player"),
showTitle: item.SeriesName ?? "Unknown Show",
seriesThumbId: item.SeriesId,
season: item.ParentIndexNumber ?? 0,
episode: item.IndexNumber ?? 0,
duration: ticksToMs(item.RunTimeTicks),
summary: detail.Overview ?? "",
contentRating: detail.OfficialRating,
genre: detail.Genres ?? [],
rating: detail.CommunityRating,
videoDecision,
audioDecision,
transcodeProgress: tc?.CompletionPercentage,
transcodeHwRequested: false,
actors: mapPeople(detail.People, "Actor").slice(0, 15),
writers: mapPeople(detail.People, "Writer").map(({ tag }) => ({ tag })),
...streams,
};
}
function mapToTrack(session: ActiveSession, detail: JellyfinItemDetail): SessionItem<Track> {
const item = session.NowPlayingItem;
const audioStream =
item.MediaStreams?.find((s) => s.Type === "Audio" && s.IsDefault !== false) ??
item.MediaStreams?.find((s) => s.Type === "Audio");
const artist =
item.Artists?.[0] ??
item.AlbumArtist ??
detail.AlbumArtist ??
"Unknown Artist";
const quality = audioStream
? audioStream.BitRate
? `${Math.round(audioStream.BitRate / 1000)} kbps`
: audioStream.Codec?.toUpperCase() ?? ""
: "";
return {
...mapJellyfinBaseFields(session, "Music Player"),
artist,
album: item.Album ?? "Unknown Album",
audioCodec: audioStream?.Codec ?? "",
quality,
year: item.ProductionYear ?? detail.ProductionYear,
duration: ticksToMs(item.RunTimeTicks),
};
}
/** Server-side: maps enriched sessions to the fields the UI renders. */
export function mapJellyfinSessions(enrichedSessions: EnrichedSession[]): SessionsResponse {
const result: SessionsResponse = { tracks: [], movies: [], episodes: [] };
enrichedSessions.forEach(({ session, detail }) => {
try {
const type = session.NowPlayingItem.Type;
if (type === "Audio") {
result.tracks.push(mapToTrack(session, detail));
} else if (type === "Movie") {
result.movies.push(mapToMovie(session, detail));
} else if (type === "Episode") {
result.episodes.push(mapToEpisode(session, detail));
}
} catch (err) {
console.error("Error mapping Jellyfin session:", err);
}
});
return result;
}