feat: enhance MediaItem with additional episode details and update ChannelInfo component
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, 0–100 */
|
/** Progress through the current show, 0–100 */
|
||||||
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 */}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user