diff --git a/k-tv-backend/api/src/dto.rs b/k-tv-backend/api/src/dto.rs index 435b7cb..9f94134 100644 --- a/k-tv-backend/api/src/dto.rs +++ b/k-tv-backend/api/src/dto.rs @@ -110,9 +110,13 @@ pub struct MediaItemResponse { pub title: String, pub content_type: domain::ContentType, pub duration_secs: u32, + pub description: Option, pub genres: Vec, pub year: Option, pub tags: Vec, + pub series_name: Option, + pub season_number: Option, + pub episode_number: Option, } impl From for MediaItemResponse { @@ -122,9 +126,13 @@ impl From for MediaItemResponse { title: i.title, content_type: i.content_type, duration_secs: i.duration_secs, + description: i.description, genres: i.genres, year: i.year, tags: i.tags, + series_name: i.series_name, + season_number: i.season_number, + episode_number: i.episode_number, } } } diff --git a/k-tv-backend/domain/src/entities.rs b/k-tv-backend/domain/src/entities.rs index 75c5f25..97c7ff1 100644 --- a/k-tv-backend/domain/src/entities.rs +++ b/k-tv-backend/domain/src/entities.rs @@ -226,9 +226,16 @@ pub struct MediaItem { pub title: String, pub content_type: ContentType, pub duration_secs: u32, + pub description: Option, pub genres: Vec, pub year: Option, pub tags: Vec, + /// For episodes: the parent TV show name. + pub series_name: Option, + /// For episodes: season number (1-based). + pub season_number: Option, + /// For episodes: episode number within the season (1-based). + pub episode_number: Option, } /// A fully resolved 48-hour broadcast program for one channel. diff --git a/k-tv-backend/infra/src/jellyfin.rs b/k-tv-backend/infra/src/jellyfin.rs index 8846e87..46b756a 100644 --- a/k-tv-backend/infra/src/jellyfin.rs +++ b/k-tv-backend/infra/src/jellyfin.rs @@ -65,7 +65,7 @@ impl IMediaProvider for JellyfinMediaProvider { let mut params: Vec<(&str, String)> = vec![ ("Recursive", "true".into()), - ("Fields", "Genres,Tags,RunTimeTicks,ProductionYear".into()), + ("Fields", "Genres,Tags,RunTimeTicks,ProductionYear,Overview".into()), ]; if let Some(ct) = &filter.content_type { @@ -198,12 +198,23 @@ struct JellyfinItem { item_type: String, #[serde(rename = "RunTimeTicks")] run_time_ticks: Option, + #[serde(rename = "Overview")] + overview: Option, #[serde(rename = "Genres")] genres: Option>, #[serde(rename = "ProductionYear")] production_year: Option, #[serde(rename = "Tags")] tags: Option>, + /// TV show name (episodes only) + #[serde(rename = "SeriesName")] + series_name: Option, + /// Season number (episodes only) + #[serde(rename = "ParentIndexNumber")] + parent_index_number: Option, + /// Episode number within the season (episodes only) + #[serde(rename = "IndexNumber")] + index_number: Option, } // ============================================================================ @@ -238,8 +249,12 @@ fn map_jellyfin_item(item: JellyfinItem) -> Option { title: item.name, content_type, duration_secs, + description: item.overview, genres: item.genres.unwrap_or_default(), year: item.production_year, tags: item.tags.unwrap_or_default(), + series_name: item.series_name, + season_number: item.parent_index_number, + episode_number: item.index_number, }) } diff --git a/k-tv-frontend/app/(main)/tv/components/channel-info.tsx b/k-tv-frontend/app/(main)/tv/components/channel-info.tsx index d741d34..85813ff 100644 --- a/k-tv-frontend/app/(main)/tv/components/channel-info.tsx +++ b/k-tv-frontend/app/(main)/tv/components/channel-info.tsx @@ -1,25 +1,47 @@ +import type { MediaItemResponse } from "@/lib/types"; + interface ChannelInfoProps { channelNumber: number; channelName: string; - showTitle: string; - showStartTime: string; // "HH:MM" - showEndTime: string; // "HH:MM" + item: MediaItemResponse; + showStartTime: string; + showEndTime: string; /** Progress through the current show, 0–100 */ 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({ channelNumber, channelName, - showTitle, + item, showStartTime, showEndTime, progress, - description, }: ChannelInfoProps) { 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 (
{/* Channel badge */} @@ -32,14 +54,35 @@ export function ChannelInfo({
- {/* Show title */} -

- {showTitle} -

+ {/* Title block */} +
+

+ {headline} +

+ {subtitle && ( +

{subtitle}

+ )} +
{/* Description */} - {description && ( -

{description}

+ {item.description && ( +

+ {item.description} +

+ )} + + {/* Genres */} + {item.genres.length > 0 && ( +
+ {item.genres.slice(0, 4).map((g) => ( + + {g} + + ))} +
)} {/* Progress bar */} diff --git a/k-tv-frontend/app/(main)/tv/page.tsx b/k-tv-frontend/app/(main)/tv/page.tsx index ffcaa76..fd96ce3 100644 --- a/k-tv-frontend/app/(main)/tv/page.tsx +++ b/k-tv-frontend/app/(main)/tv/page.tsx @@ -279,11 +279,10 @@ export default function TvPage() { ) : ( /* Minimal channel badge when no broadcast */ diff --git a/k-tv-frontend/lib/types.ts b/k-tv-frontend/lib/types.ts index 7871f32..67094f6 100644 --- a/k-tv-frontend/lib/types.ts +++ b/k-tv-frontend/lib/types.ts @@ -90,6 +90,12 @@ export interface MediaItemResponse { genres: string[]; tags: string[]; 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 {