utils/plexApi.ts
Rama main · réplica de lectura del repositorio original.
import { SessionsResponse, SessionItem, Track, Movie, Episode, Rating, Person } from "../types/media";
import { normalizeVideoResolution } from "./mediaCardUtils";
const FETCH_TIMEOUT = parseInt(process.env.NEXT_PUBLIC_FETCH_TIMEOUT || "8000");
export async function fetchWithTimeout(
url: string,
options: RequestInit & { signal?: AbortSignal } = {},
timeout = FETCH_TIMEOUT
): Promise<Response> {
const { signal, ...rest } = options;
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(new DOMException("Request timed out", "TimeoutError")),
timeout
);
// Forward the caller's abort reason so cancellation stays distinguishable
// from a timeout (AbortError vs TimeoutError)
const onAbort = () => controller.abort(signal?.reason);
if (signal?.aborted) onAbort();
else signal?.addEventListener("abort", onAbort, { once: true });
try {
return await fetch(url, { ...rest, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
signal?.removeEventListener("abort", onAbort);
}
}
export function isTimeoutError(error: unknown): boolean {
return error instanceof DOMException && error.name === "TimeoutError";
}
// ── Plex API shape types ──────────────────────────────────────────────────────
interface PlexTag {
tag: string;
role?: string;
thumb?: string;
}
interface PlexRating {
image?: string;
type: string;
value: string;
count?: string;
}
interface PlexStream {
streamType: number;
bitDepth?: number;
samplingRate?: number;
bitrate?: number;
channels?: number;
audioChannelLayout?: string;
}
interface PlexPart {
Stream?: PlexStream[];
}
interface PlexMedia {
duration?: number;
videoResolution?: string;
audioCodec?: string;
videoCodec?: string;
videoProfile?: string;
audioChannels?: number;
bitrate?: number;
Part?: PlexPart[];
}
interface PlexTranscodeSession {
videoDecision?: string;
audioDecision?: string;
progress?: number;
transcodeHwRequested?: boolean;
}
export interface PlexSession {
ratingKey: string;
// Identifies the session in websocket notifications; never sent to the client
sessionKey?: string;
title: string;
thumb?: string;
type: string;
year?: number;
duration?: number;
summary?: string;
contentRating?: string;
studio?: string;
tagline?: string;
rating?: number;
audienceRating?: number;
viewOffset?: number;
Session?: { id?: string };
User?: { title?: string; thumb?: string };
Player?: { state?: string; title?: string; product?: string };
TranscodeSession?: PlexTranscodeSession;
Media?: PlexMedia[];
Genre?: PlexTag[];
Director?: PlexTag[];
Writer?: PlexTag[];
Role?: PlexTag[];
Rating?: PlexRating[];
art?: string;
}
interface PlexEpisodeSession extends PlexSession {
grandparentTitle?: string;
grandparentThumb?: string;
grandparentRatingKey?: string;
parentIndex?: number;
index?: number;
}
interface PlexTrackSession extends PlexSession {
originalTitle?: string;
artist?: string;
grandparentTitle?: string;
album?: string;
parentTitle?: string;
}
// ── Shared mapping helpers ────────────────────────────────────────────────────
// Copy only rendered fields — Plex tag objects also carry ids, filters and keys
function mapRatings(ratings: PlexRating[] | undefined): Rating[] | undefined {
return ratings?.map(({ image, type, value }) => ({ image, type, value }));
}
function mapPeople(tags: PlexTag[] | undefined, limit?: number): Person[] | undefined {
return tags?.slice(0, limit).map(({ tag, role, thumb }) => ({ tag, role, thumb }));
}
export function mapPlexState(plexState: string | undefined): "playing" | "paused" {
return plexState === "playing" ? "playing" : "paused";
}
function extractPlexStreams(session: PlexSession) {
const mediaInfo = session.Media?.[0];
const audioStream = mediaInfo?.Part?.[0]?.Stream?.find(
(s) => s.streamType === 2
);
return {
duration: session.duration || mediaInfo?.duration || 0,
videoResolution: normalizeVideoResolution(mediaInfo?.videoResolution),
audioCodec: mediaInfo?.audioCodec || "",
videoCodec: mediaInfo?.videoCodec,
videoProfile: mediaInfo?.videoProfile,
audioChannels: mediaInfo?.audioChannels || audioStream?.channels,
audioChannelLayout: audioStream?.audioChannelLayout,
bitrate: mediaInfo?.bitrate,
};
}
function mapPlexBaseFields(session: PlexSession, defaultPlayer: string) {
const sessionId =
session.Session?.id ||
`${session.type}-${session.ratingKey}-${session.User?.title || ""}-${session.Player?.title || session.Player?.product || ""}`;
return {
source: "plex" as const,
id: session.ratingKey,
title: session.title,
thumbnailFileId: session.thumb,
state: mapPlexState(session.Player?.state),
userId: session.User?.title || "Unknown User",
userAvatar: session.User?.thumb || undefined,
player: session.Player?.title || session.Player?.product || defaultPlayer,
sessionId,
viewOffset: session.viewOffset || 0,
videoDecision: session.TranscodeSession?.videoDecision || "copy",
audioDecision: session.TranscodeSession?.audioDecision || "copy",
transcodeProgress: session.TranscodeSession?.progress,
transcodeHwRequested: session.TranscodeSession?.transcodeHwRequested,
};
}
// ── Mappers ───────────────────────────────────────────────────────────────────
function mapToMovie(session: PlexSession): SessionItem<Movie> {
const streams = extractPlexStreams(session);
return {
...mapPlexBaseFields(session, "Video Player"),
...streams,
year: session.year || 0,
director: session.Director?.[0]?.tag,
studio: session.studio,
summary: session.summary || "",
contentRating: session.contentRating || "",
genre: session.Genre?.map((g) => g.tag) || [],
rating: session.rating ?? session.audienceRating,
tagline: session.tagline,
ratings: mapRatings(session.Rating),
writers: session.Writer?.map(({ tag }) => ({ tag })),
actors: mapPeople(session.Role, 15),
backdropPath: session.art,
};
}
function mapToEpisode(session: PlexEpisodeSession): SessionItem<Episode> {
const streams = extractPlexStreams(session);
return {
...mapPlexBaseFields(session, "Video Player"),
...streams,
showTitle: session.grandparentTitle || "Unknown Show",
seriesThumbId: session.grandparentThumb,
seriesRatingKey: session.grandparentRatingKey,
season: session.parentIndex || 0,
episode: session.index || 0,
summary: session.summary || "",
contentRating: session.contentRating || "",
genre: session.Genre?.map((g) => g.tag) || [],
rating: session.rating ?? session.audienceRating,
ratings: mapRatings(session.Rating),
writers: session.Writer?.map(({ tag }) => ({ tag })),
actors: mapPeople(session.Role, 15),
};
}
function mapToTrack(session: PlexTrackSession): SessionItem<Track> {
const mediaInfo = session.Media?.[0];
const stream = mediaInfo?.Part?.[0]?.Stream?.[0];
const quality =
stream?.bitDepth && stream?.samplingRate
? `${stream.samplingRate / 1000} kHz / ${stream.bitDepth} bit`
: stream?.bitrate
? `${stream.bitrate} kbps`
: "";
return {
...mapPlexBaseFields(session, "Music Player"),
artist:
session.originalTitle ||
session.artist ||
session.grandparentTitle ||
"Unknown Artist",
album: session.album || session.parentTitle || "Unknown Album",
audioCodec: mediaInfo?.audioCodec || "",
quality,
year: session.year,
duration: session.duration || mediaInfo?.duration || 0,
};
}
// ── Public API ────────────────────────────────────────────────────────────────
export type PlexSessionEntry =
| { category: "tracks"; item: SessionItem<Track> }
| { category: "movies"; item: SessionItem<Movie> }
| { category: "episodes"; item: SessionItem<Episode> };
/** Server-side: maps one raw /status/sessions item to the fields the UI renders. */
export function mapPlexSession(session: PlexSession): PlexSessionEntry | null {
try {
if (session.type === "track") {
return { category: "tracks", item: mapToTrack(session as PlexTrackSession) };
} else if (session.type === "movie") {
return { category: "movies", item: mapToMovie(session) };
} else if (session.type === "episode") {
return { category: "episodes", item: mapToEpisode(session as PlexEpisodeSession) };
}
} catch (err) {
console.error(`Error mapping Plex ${session.type}:`, err);
}
return null;
}
export function collectPlexSessions(entries: Iterable<PlexSessionEntry>): SessionsResponse {
const result: SessionsResponse = { tracks: [], movies: [], episodes: [] };
for (const entry of entries) {
if (entry.category === "tracks") result.tracks.push(entry.item);
else if (entry.category === "movies") result.movies.push(entry.item);
else result.episodes.push(entry.item);
}
return result;
}
/** Server-side: maps raw /status/sessions metadata to the fields the UI renders. */
export function mapPlexSessions(sessions: PlexSession[]): SessionsResponse {
return collectPlexSessions(
sessions.map(mapPlexSession).filter((entry): entry is PlexSessionEntry => entry !== null)
);
}
export function getThumbnailUrl(
thumbnailPath: string | undefined,
options?: {
quality?: "low" | "medium" | "high" | "original";
width?: number;
}
): string | null {
if (!thumbnailPath) return null;
const params = new URLSearchParams({
path: thumbnailPath,
quality: options?.quality || "low",
});
if (options?.width) {
params.append("width", options.width.toString());
}
return `/api/plex/thumbnail?${params.toString()}`;
}