feat: enhance MediaItem with additional episode details and update ChannelInfo component

This commit is contained in:
2026-03-11 21:45:11 +01:00
parent d1122656f3
commit f6ff65094b
6 changed files with 93 additions and 15 deletions

View File

@@ -110,9 +110,13 @@ pub struct MediaItemResponse {
pub title: String, pub title: String,
pub content_type: domain::ContentType, pub content_type: domain::ContentType,
pub duration_secs: u32, pub duration_secs: u32,
pub description: Option<String>,
pub genres: Vec<String>, pub genres: Vec<String>,
pub year: Option<u16>, pub year: Option<u16>,
pub tags: Vec<String>, pub tags: Vec<String>,
pub series_name: Option<String>,
pub season_number: Option<u32>,
pub episode_number: Option<u32>,
} }
impl From<domain::MediaItem> for MediaItemResponse { impl From<domain::MediaItem> for MediaItemResponse {
@@ -122,9 +126,13 @@ impl From<domain::MediaItem> for MediaItemResponse {
title: i.title, title: i.title,
content_type: i.content_type, content_type: i.content_type,
duration_secs: i.duration_secs, duration_secs: i.duration_secs,
description: i.description,
genres: i.genres, genres: i.genres,
year: i.year, year: i.year,
tags: i.tags, tags: i.tags,
series_name: i.series_name,
season_number: i.season_number,
episode_number: i.episode_number,
} }
} }
} }

View File

@@ -226,9 +226,16 @@ pub struct MediaItem {
pub title: String, pub title: String,
pub content_type: ContentType, pub content_type: ContentType,
pub duration_secs: u32, pub duration_secs: u32,
pub description: Option<String>,
pub genres: Vec<String>, pub genres: Vec<String>,
pub year: Option<u16>, pub year: Option<u16>,
pub tags: Vec<String>, pub tags: Vec<String>,
/// For episodes: the parent TV show name.
pub series_name: Option<String>,
/// For episodes: season number (1-based).
pub season_number: Option<u32>,
/// For episodes: episode number within the season (1-based).
pub episode_number: Option<u32>,
} }
/// A fully resolved 48-hour broadcast program for one channel. /// A fully resolved 48-hour broadcast program for one channel.

View File

@@ -65,7 +65,7 @@ impl IMediaProvider for JellyfinMediaProvider {
let mut params: Vec<(&str, String)> = vec![ let mut params: Vec<(&str, String)> = vec![
("Recursive", "true".into()), ("Recursive", "true".into()),
("Fields", "Genres,Tags,RunTimeTicks,ProductionYear".into()), ("Fields", "Genres,Tags,RunTimeTicks,ProductionYear,Overview".into()),
]; ];
if let Some(ct) = &filter.content_type { if let Some(ct) = &filter.content_type {
@@ -198,12 +198,23 @@ struct JellyfinItem {
item_type: String, item_type: String,
#[serde(rename = "RunTimeTicks")] #[serde(rename = "RunTimeTicks")]
run_time_ticks: Option<i64>, run_time_ticks: Option<i64>,
#[serde(rename = "Overview")]
overview: Option<String>,
#[serde(rename = "Genres")] #[serde(rename = "Genres")]
genres: Option<Vec<String>>, genres: Option<Vec<String>>,
#[serde(rename = "ProductionYear")] #[serde(rename = "ProductionYear")]
production_year: Option<u16>, production_year: Option<u16>,
#[serde(rename = "Tags")] #[serde(rename = "Tags")]
tags: Option<Vec<String>>, tags: Option<Vec<String>>,
/// TV show name (episodes only)
#[serde(rename = "SeriesName")]
series_name: Option<String>,
/// Season number (episodes only)
#[serde(rename = "ParentIndexNumber")]
parent_index_number: Option<u32>,
/// Episode number within the season (episodes only)
#[serde(rename = "IndexNumber")]
index_number: Option<u32>,
} }
// ============================================================================ // ============================================================================
@@ -238,8 +249,12 @@ fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
title: item.name, title: item.name,
content_type, content_type,
duration_secs, duration_secs,
description: item.overview,
genres: item.genres.unwrap_or_default(), genres: item.genres.unwrap_or_default(),
year: item.production_year, year: item.production_year,
tags: item.tags.unwrap_or_default(), tags: item.tags.unwrap_or_default(),
series_name: item.series_name,
season_number: item.parent_index_number,
episode_number: item.index_number,
}) })
} }

View File

@@ -1,25 +1,47 @@
import type { MediaItemResponse } from "@/lib/types";
interface ChannelInfoProps { interface ChannelInfoProps {
channelNumber: number; channelNumber: number;
channelName: string; channelName: string;
showTitle: string; item: MediaItemResponse;
showStartTime: string; // "HH:MM" showStartTime: string;
showEndTime: string; // "HH:MM" showEndTime: string;
/** Progress through the current show, 0100 */ /** Progress through the current show, 0100 */
progress: number; progress: number;
description?: string; }
function formatEpisodeLabel(item: MediaItemResponse): string | null {
if (item.content_type !== "episode") return null;
const parts: string[] = [];
if (item.season_number != null) parts.push(`S${item.season_number}`);
if (item.episode_number != null) parts.push(`E${item.episode_number}`);
return parts.length > 0 ? parts.join(" · ") : null;
} }
export function ChannelInfo({ export function ChannelInfo({
channelNumber, channelNumber,
channelName, channelName,
showTitle, item,
showStartTime, showStartTime,
showEndTime, showEndTime,
progress, progress,
description,
}: ChannelInfoProps) { }: ChannelInfoProps) {
const clampedProgress = Math.min(100, Math.max(0, progress)); const clampedProgress = Math.min(100, Math.max(0, progress));
const isEpisode = item.content_type === "episode";
const episodeLabel = formatEpisodeLabel(item);
// For episodes: series name as headline (fall back to episode title if missing)
const headline = isEpisode && item.series_name ? item.series_name : item.title;
// Subtitle: only include episode title when series name is the headline (otherwise
// the title is already the headline and repeating it would duplicate it)
const subtitle = isEpisode
? item.series_name
? [episodeLabel, item.title].filter(Boolean).join(" · ")
: episodeLabel // title is the headline — just show S·E label
: item.year
? String(item.year)
: null;
return ( return (
<div className="flex flex-col gap-2 rounded-lg bg-black/60 p-4 backdrop-blur-md w-80"> <div className="flex flex-col gap-2 rounded-lg bg-black/60 p-4 backdrop-blur-md w-80">
{/* Channel badge */} {/* Channel badge */}
@@ -32,14 +54,35 @@ export function ChannelInfo({
</span> </span>
</div> </div>
{/* Show title */} {/* Title block */}
<p className="text-base font-semibold leading-tight text-white"> <div className="space-y-0.5">
{showTitle} <p className="text-base font-semibold leading-tight text-white">
</p> {headline}
</p>
{subtitle && (
<p className="text-xs text-zinc-400">{subtitle}</p>
)}
</div>
{/* Description */} {/* Description */}
{description && ( {item.description && (
<p className="line-clamp-2 text-xs text-zinc-400">{description}</p> <p className="line-clamp-2 text-xs leading-relaxed text-zinc-500">
{item.description}
</p>
)}
{/* Genres */}
{item.genres.length > 0 && (
<div className="flex flex-wrap gap-1">
{item.genres.slice(0, 4).map((g) => (
<span
key={g}
className="rounded bg-zinc-800/80 px-1.5 py-0.5 text-[10px] text-zinc-400"
>
{g}
</span>
))}
</div>
)} )}
{/* Progress bar */} {/* Progress bar */}

View File

@@ -279,11 +279,10 @@ export default function TvPage() {
<ChannelInfo <ChannelInfo
channelNumber={channelIdx + 1} channelNumber={channelIdx + 1}
channelName={channel.name} channelName={channel.name}
showTitle={broadcast.slot.item.title} item={broadcast.slot.item}
showStartTime={fmtTime(broadcast.slot.start_at)} showStartTime={fmtTime(broadcast.slot.start_at)}
showEndTime={fmtTime(broadcast.slot.end_at)} showEndTime={fmtTime(broadcast.slot.end_at)}
progress={progress} progress={progress}
description={broadcast.slot.item.description ?? undefined}
/> />
) : ( ) : (
/* Minimal channel badge when no broadcast */ /* Minimal channel badge when no broadcast */

View File

@@ -90,6 +90,12 @@ export interface MediaItemResponse {
genres: string[]; genres: string[];
tags: string[]; tags: string[];
year?: number | null; year?: number | null;
/** Episodes only: the parent TV show name. */
series_name?: string | null;
/** Episodes only: season number (1-based). */
season_number?: number | null;
/** Episodes only: episode number within the season (1-based). */
episode_number?: number | null;
} }
export interface ScheduledSlotResponse { export interface ScheduledSlotResponse {