diff --git a/src-tauri/src/commands/repository.rs b/src-tauri/src/commands/repository.rs index 8557c49..5b0e6e7 100644 --- a/src-tauri/src/commands/repository.rs +++ b/src-tauri/src/commands/repository.rs @@ -410,6 +410,49 @@ pub async fn repository_get_audio_stream_url( .map_err(|e| format!("{:?}", e)) } +/// Get Live TV channels (broadcast / IPTV) for browsing +#[tauri::command] +#[specta::specta] +pub async fn repository_get_live_tv_channels( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, +) -> Result, String> { + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref() + .get_live_tv_channels() + .await + .map_err(|e| format!("{:?}", e)) +} + +/// Get the root list of plugin "Channels" +#[tauri::command] +#[specta::specta] +pub async fn repository_get_channels( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, +) -> Result { + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref() + .get_channels() + .await + .map_err(|e| format!("{:?}", e)) +} + +/// Open a live stream for a Live TV channel / live item +#[tauri::command] +#[specta::specta] +pub async fn repository_open_live_stream( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + item_id: String, +) -> Result { + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref() + .open_live_stream(&item_id) + .await + .map_err(|e| format!("{:?}", e)) +} + /// Report playback start #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 455ef19..a147da7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -101,6 +101,7 @@ use commands::{ repository_get_rediscover_albums, repository_get_genres, repository_search, repository_get_playback_info, repository_get_video_stream_url, repository_get_audio_stream_url, + repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, repository_get_image_url, repository_mark_favorite, repository_unmark_favorite, repository_get_person, repository_get_items_by_person, repository_get_similar_items, @@ -572,6 +573,9 @@ fn specta_builder() -> Builder { repository_get_playback_info, repository_get_video_stream_url, repository_get_audio_stream_url, + repository_get_live_tv_channels, + repository_get_channels, + repository_open_live_stream, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index fd72aba..4c40177 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -415,6 +415,21 @@ impl MediaRepository for HybridRepository { self.online.get_audio_stream_url(item_id).await } + async fn get_live_tv_channels(&self) -> Result, RepoError> { + // Live TV requires server communication - delegate to online repository + self.online.get_live_tv_channels().await + } + + async fn get_channels(&self) -> Result { + // Plugin channels require server communication - delegate to online repository + self.online.get_channels().await + } + + async fn open_live_stream(&self, item_id: &str) -> Result { + // Opening a live stream requires server communication - delegate to online + self.online.open_live_stream(item_id).await + } + async fn report_playback_start(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> { // Playback reporting goes directly to server self.online.report_playback_start(item_id, position_ticks).await @@ -722,6 +737,18 @@ mod tests { unimplemented!() } + async fn get_live_tv_channels(&self) -> Result, RepoError> { + unimplemented!() + } + + async fn get_channels(&self) -> Result { + unimplemented!() + } + + async fn open_live_stream(&self, _item_id: &str) -> Result { + unimplemented!() + } + async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { unimplemented!() } @@ -887,6 +914,18 @@ mod tests { unimplemented!() } + async fn get_live_tv_channels(&self) -> Result, RepoError> { + unimplemented!() + } + + async fn get_channels(&self) -> Result { + unimplemented!() + } + + async fn open_live_stream(&self, _item_id: &str) -> Result { + unimplemented!() + } + async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { unimplemented!() } @@ -976,6 +1015,7 @@ mod tests { id: id.to_string(), name: name.to_string(), item_type: "Movie".to_string(), + is_folder: false, server_id: "test-server".to_string(), parent_id: Some("parent-123".to_string()), library_id: Some("library-456".to_string()), diff --git a/src-tauri/src/repository/mod.rs b/src-tauri/src/repository/mod.rs index 94ac332..1edbbc9 100644 --- a/src-tauri/src/repository/mod.rs +++ b/src-tauri/src/repository/mod.rs @@ -117,6 +117,19 @@ pub trait MediaRepository: Send + Sync { /// @req: JA-007 - Get playback info and stream URL async fn get_audio_stream_url(&self, item_id: &str) -> Result; + /// Get Live TV channels (broadcast / IPTV) for browsing. + async fn get_live_tv_channels(&self) -> Result, RepoError>; + + /// Get the root list of plugin "Channels" (Jellyfin Channels feature). + /// Drill-down into a channel reuses `get_items(channel_id, ...)`. + async fn get_channels(&self) -> Result; + + /// Open a live stream (Live TV channel or live channel item) for playback. + /// + /// Returns the server transcoding URL plus identifiers needed to manage the + /// stream. Required before a live channel can be played over HLS. + async fn open_live_stream(&self, item_id: &str) -> Result; + /// Report playback start /// /// @req: UR-025 - Sync watch history and progress back to Jellyfin diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index 360369c..0b97119 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -29,6 +29,7 @@ impl OfflineRepository { id: item.id.clone(), name: item.name, item_type: item.item_type, + is_folder: item.is_folder, server_id: item.server_id, parent_id: item.parent_id, library_id: item.library_id, @@ -92,6 +93,7 @@ struct CachedItem { id: String, name: String, item_type: String, + is_folder: bool, server_id: String, parent_id: Option, library_id: Option, @@ -143,6 +145,8 @@ fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result { season_id: row.get(20)?, season_name: row.get(21)?, parent_index_number: row.get(22)?, + // Appended as the final column in every SELECT that maps through this fn. + is_folder: row.get::<_, Option>(23)?.unwrap_or(0) != 0, }) } @@ -230,7 +234,7 @@ impl OfflineRepository { let query = Query::with_params( "INSERT OR REPLACE INTO items ( id, server_id, library_id, parent_id, - name, item_type, overview, + name, item_type, is_folder, overview, genres, series_id, series_name, season_id, season_name, index_number, parent_index_number, album_id, album_name, album_artist, artists, @@ -240,14 +244,14 @@ impl OfflineRepository { synced_at ) VALUES ( ?1, ?2, ?3, ?4, - ?5, ?6, ?7, - ?8, ?9, ?10, - ?11, ?12, ?13, ?14, - ?15, ?16, ?17, ?18, - ?19, ?20, - ?21, ?22, - ?23, ?24, - ?25 + ?5, ?6, ?7, ?8, + ?9, ?10, ?11, + ?12, ?13, ?14, ?15, + ?16, ?17, ?18, ?19, + ?20, ?21, + ?22, ?23, + ?24, ?25, + ?26 )", vec![ QueryParam::String(item.id.clone()), @@ -261,6 +265,7 @@ impl OfflineRepository { }, QueryParam::String(item.name.clone()), QueryParam::String(item.item_type.clone()), + QueryParam::Int(if item.is_folder { 1 } else { 0 }), match &item.overview { Some(o) => QueryParam::String(o.clone()), None => QueryParam::Null, @@ -491,7 +496,7 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, - i.parent_index_number + i.parent_index_number, i.is_folder FROM items i INNER JOIN available_items ai ON i.id = ai.id WHERE i.server_id = ? AND i.parent_id = ?{} @@ -556,7 +561,7 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, - i.parent_index_number + i.parent_index_number, i.is_folder FROM items i INNER JOIN downloaded_items di ON i.id = di.id WHERE i.id = ?", @@ -601,7 +606,7 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, - i.parent_index_number + i.parent_index_number, i.is_folder FROM items i INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = ? AND i.library_id = ? @@ -639,7 +644,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id @@ -664,7 +669,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id @@ -752,7 +757,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM ranked_plays rp JOIN items i ON rp.display_id = i.id INNER JOIN downloaded_items di ON i.id = di.id @@ -800,7 +805,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id @@ -934,7 +939,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM items i JOIN items_fts fts ON fts.rowid = i.rowid INNER JOIN downloaded_items di ON i.id = di.id @@ -980,6 +985,21 @@ impl MediaRepository for OfflineRepository { Err(RepoError::Offline) } + async fn get_live_tv_channels(&self) -> Result, RepoError> { + // Live TV is inherently online-only. + Err(RepoError::Offline) + } + + async fn get_channels(&self) -> Result { + // Plugin channels are inherently online-only. + Err(RepoError::Offline) + } + + async fn open_live_stream(&self, _item_id: &str) -> Result { + // Live streams cannot be opened offline. + Err(RepoError::Offline) + } + async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { // Cannot report to server while offline Err(RepoError::Offline) @@ -1071,6 +1091,7 @@ impl MediaRepository for OfflineRepository { id: person_data.0, name: person_data.1, item_type: "Person".to_string(), + is_folder: false, server_id: self.server_id.clone(), parent_id: None, library_id: None, @@ -1131,7 +1152,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM items i JOIN item_people ip ON i.id = ip.item_id INNER JOIN downloaded_items di ON i.id = di.id @@ -1242,7 +1263,7 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \ i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \ i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \ - i.parent_index_number \ + i.parent_index_number, i.is_folder \ FROM playlist_items pi \ JOIN items i ON pi.item_id = i.id \ WHERE pi.playlist_id = ? \ @@ -1280,6 +1301,7 @@ impl MediaRepository for OfflineRepository { season_id: row.get(21)?, season_name: row.get(22)?, parent_index_number: row.get(23)?, + is_folder: row.get::<_, Option>(24)?.unwrap_or(0) != 0, }; Ok((entry_id.to_string(), cached)) }) @@ -1446,6 +1468,7 @@ mod tests { parent_id TEXT REFERENCES items(id) ON DELETE CASCADE, name TEXT NOT NULL, item_type TEXT NOT NULL, + is_folder INTEGER DEFAULT 0, overview TEXT, genres TEXT, runtime_ticks INTEGER, @@ -1518,6 +1541,7 @@ mod tests { id: id.to_string(), name: name.to_string(), item_type: "Audio".to_string(), + is_folder: false, server_id: "test-server".to_string(), parent_id: parent_id.map(|s| s.to_string()), library_id: None, diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 0b7e4d0..1652a09 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -363,6 +363,8 @@ struct JellyfinItem { name: String, #[serde(rename = "Type")] item_type: String, + #[serde(default)] + is_folder: bool, parent_id: Option, overview: Option, genres: Option>, @@ -450,6 +452,7 @@ impl JellyfinItem { id: self.id, name: self.name, item_type: self.item_type, + is_folder: self.is_folder, server_id, parent_id: self.parent_id, library_id: None, // Not provided by Jellyfin API directly @@ -728,6 +731,7 @@ impl MediaRepository for OnlineRepository { id: album_id, name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()), item_type: "MusicAlbum".to_string(), + is_folder: true, server_id: first_track.server_id.clone(), parent_id: None, library_id: None, @@ -1139,6 +1143,105 @@ impl MediaRepository for OnlineRepository { Ok(url) } + async fn get_live_tv_channels(&self) -> Result, RepoError> { + // Live TV channels (broadcast tuners / IPTV M3U). Returned as items with + // type "TvChannel" — playable via open_live_stream. + let endpoint = format!( + "/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary", + self.user_id + ); + let response: ItemsResponse = self.get_json(&endpoint).await?; + Ok(response + .items + .into_iter() + .map(|item| item.to_media_item(self.server_url.clone())) + .collect()) + } + + async fn get_channels(&self) -> Result { + // Root list of plugin "Channels". Drill-down into a channel folder reuses + // get_items(channel_id, ...). + let endpoint = format!("/Channels?UserId={}", self.user_id); + let response: ItemsResponse = self.get_json(&endpoint).await?; + let total = response.total_record_count; + let items = response + .items + .into_iter() + .map(|item| item.to_media_item(self.server_url.clone())) + .collect(); + Ok(SearchResult { + items, + total_record_count: total, + }) + } + + async fn open_live_stream(&self, item_id: &str) -> Result { + // Live channels require a PlaybackInfo call with AutoOpenLiveStream so the + // server opens the live stream and returns a ready-to-play transcoding URL. + // We send a minimal request; the server applies its own defaults for live. + #[derive(Debug, Serialize)] + #[serde(rename_all = "PascalCase")] + struct OpenLiveStreamRequest { + user_id: String, + #[serde(rename = "AutoOpenLiveStream")] + auto_open_live_stream: bool, + is_playback: bool, + max_streaming_bitrate: u64, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + struct OpenLiveStreamResponse { + #[serde(default)] + media_sources: Vec, + play_session_id: Option, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + struct LiveMediaSource { + id: String, + transcoding_url: Option, + live_stream_id: Option, + } + + let endpoint = format!("/Items/{}/PlaybackInfo", item_id); + let request = OpenLiveStreamRequest { + user_id: self.user_id.clone(), + auto_open_live_stream: true, + is_playback: true, + max_streaming_bitrate: 20_000_000, + }; + + let response: OpenLiveStreamResponse = + self.post_json_response(&endpoint, &request).await?; + + let source = response.media_sources.into_iter().next().ok_or(RepoError::NotFound { + message: "No live media source returned".to_string(), + })?; + + // The transcoding URL is server-relative; make it absolute. If the server + // did not provide one (rare for live), fall back to the HLS master endpoint. + let stream_url = match source.transcoding_url { + Some(url) => format!("{}{}", self.server_url, url), + None => format!( + "{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts", + self.server_url, + item_id, + self.access_token, + source.id, + source.live_stream_id.clone().unwrap_or_default(), + ), + }; + + Ok(LiveStreamInfo { + stream_url, + play_session_id: response.play_session_id, + live_stream_id: source.live_stream_id, + media_source_id: Some(source.id), + }) + } + async fn report_playback_start( &self, item_id: &str, diff --git a/src-tauri/src/repository/types.rs b/src-tauri/src/repository/types.rs index e3c4998..0f3e572 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -104,6 +104,10 @@ pub struct MediaItem { pub name: String, #[serde(rename = "type")] pub item_type: String, + /// Whether this item is a folder/container (vs a playable leaf). Used to + /// decide whether a channel item drills into a list or plays directly. + #[serde(default)] + pub is_folder: bool, pub server_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub parent_id: Option, @@ -249,6 +253,20 @@ pub struct PlaybackInfo { pub needs_transcoding: bool, } +/// Live stream information returned from opening a Live TV / channel stream. +/// +/// Unlike on-demand video, a live channel must be "opened" before it can be +/// streamed; the server returns a transcoding URL (already absolute) plus a +/// `live_stream_id` that can later be used to close the stream. +#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveStreamInfo { + pub stream_url: String, + pub play_session_id: Option, + pub live_stream_id: Option, + pub media_source_id: Option, +} + /// Genre #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -481,6 +499,7 @@ mod tests { id: "1".to_string(), name: "Test".to_string(), item_type: "Audio".to_string(), + is_folder: false, server_id: "server1".to_string(), parent_id: None, library_id: None, @@ -620,6 +639,7 @@ mod tests { id: "track1".to_string(), name: "Test Track".to_string(), item_type: "Audio".to_string(), + is_folder: false, server_id: "server1".to_string(), parent_id: None, library_id: None, @@ -679,6 +699,7 @@ mod tests { id: "1".to_string(), name: "Track".to_string(), item_type: "Audio".to_string(), + is_folder: false, server_id: "s1".to_string(), parent_id: None, library_id: None, diff --git a/src-tauri/src/storage/schema.rs b/src-tauri/src/storage/schema.rs index 1e67db9..237fbdf 100644 --- a/src-tauri/src/storage/schema.rs +++ b/src-tauri/src/storage/schema.rs @@ -22,6 +22,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[ ("015_device_id", MIGRATION_015), ("016_autoplay_max_episodes", MIGRATION_016), ("017_downloads_resume_url", MIGRATION_017), + ("018_items_is_folder", MIGRATION_018), ]; /// Initial schema migration @@ -680,3 +681,15 @@ const MIGRATION_017: &str = r#" ALTER TABLE downloads ADD COLUMN stream_url TEXT; ALTER TABLE downloads ADD COLUMN target_dir TEXT; "#; + +/// Migration to record whether a cached item is a folder/container vs a playable +/// leaf. Needed so channel items (which can be either) route to the player or to +/// a browse list correctly. Existing cached rows predate the column and have an +/// unknown folder flag, so we force a refresh by clearing their `synced_at`, +/// causing the hybrid repository to re-fetch them from the server on next browse. +const MIGRATION_018: &str = r#" +ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0; + +-- Force re-fetch of all cached items so is_folder is populated from the server. +UPDATE items SET synced_at = NULL; +"#; diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 63856f6..818ef4f 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -1134,6 +1134,24 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise { return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId }); }, +/** + * Get Live TV channels (broadcast / IPTV) for browsing + */ +async repositoryGetLiveTvChannels(handle: string) : Promise { + return await TAURI_INVOKE("repository_get_live_tv_channels", { handle }); +}, +/** + * Get the root list of plugin "Channels" + */ +async repositoryGetChannels(handle: string) : Promise { + return await TAURI_INVOKE("repository_get_channels", { handle }); +}, +/** + * Open a live stream for a Live TV channel / live item + */ +async repositoryOpenLiveStream(handle: string, itemId: string) : Promise { + return await TAURI_INVOKE("repository_open_live_stream", { handle, itemId }); +}, /** * Report playback start */ @@ -1539,6 +1557,14 @@ export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo" * Library (media collection) */ export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null } +/** + * Live stream information returned from opening a Live TV / channel stream. + * + * Unlike on-demand video, a live channel must be "opened" before it can be + * streamed; the server returns a transcoding URL (already absolute) plus a + * `live_stream_id` that can later be used to close the stream. + */ +export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null } /** * An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`. * @@ -1549,7 +1575,12 @@ export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?: /** * Media item */ -export type MediaItem = { id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null } +export type MediaItem = { id: string; name: string; type: string; +/** + * Whether this item is a folder/container (vs a playable leaf). Used to + * decide whether a channel item drills into a list or plays directly. + */ +isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null } /** * Media session type tracking the high-level playback context */ @@ -1927,7 +1958,12 @@ export type PlaylistEntry = /** * The underlying media item */ -({ id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & { +({ id: string; name: string; type: string; +/** + * Whether this item is a folder/container (vs a playable leaf). Used to + * decide whether a channel item drills into a list or plays directly. + */ +isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & { /** * The playlist-scoped entry ID (Jellyfin's PlaylistItemId) */ diff --git a/src/lib/api/repository-client.ts b/src/lib/api/repository-client.ts index 61ef689..59a3581 100644 --- a/src/lib/api/repository-client.ts +++ b/src/lib/api/repository-client.ts @@ -11,6 +11,7 @@ import type { GetItemsOptions, SearchOptions, PlaybackInfo, + LiveStreamInfo, ImageType, ImageOptions, Genre, @@ -161,6 +162,23 @@ export class RepositoryClient { ); } + // ===== Live TV / Channels ===== + + /** Browse Live TV channels (broadcast / IPTV). */ + async getLiveTvChannels(): Promise { + return commands.repositoryGetLiveTvChannels(this.ensureHandle()); + } + + /** Browse the root list of plugin "Channels". Drill-down uses getItems(channelId). */ + async getChannels(): Promise { + return commands.repositoryGetChannels(this.ensureHandle()); + } + + /** Open a live stream for a Live TV channel / live item before HLS playback. */ + async openLiveStream(itemId: string): Promise { + return commands.repositoryOpenLiveStream(this.ensureHandle(), itemId); + } + // ===== URL Construction Methods (sync, no server call) ===== /** diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index b9f848f..ec30363 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -12,6 +12,7 @@ export type { ImageOptions, ImageType, Library, + LiveStreamInfo, MediaItem, MediaSource, MediaStream, @@ -51,6 +52,7 @@ export type ItemType = | "CollectionFolder" | "Channel" | "ChannelFolderItem" + | "TvChannel" | "Person"; export type LibraryType = @@ -63,6 +65,7 @@ export type LibraryType = | "boxsets" | "playlists" | "channels" + | "livetv" | "unknown"; export type PersonType = diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index dcc72f8..a8185ca 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -30,9 +30,10 @@ onEnded?: () => void; // Called when video playback ends naturally onNext?: () => void; // Called when user clicks next episode button hasNext?: boolean; // Whether there is a next episode available + isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting } - let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props(); + let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props(); // The id this player instance reports progress against. Snapshotted from the // media prop so a late reportStop (e.g. from onDestroy during autoplay @@ -488,12 +489,15 @@ // Load series audio preference (for TV shows) await loadSeriesAudioPreference(); - // Report progress every 10 seconds while playing - progressInterval = setInterval(() => { - if (isPlaying && !isSeeking && onReportProgress) { - onReportProgress(currentTime, false, reportMediaId); - } - }, 10000); + // Report progress every 10 seconds while playing. Live streams have no + // meaningful position to report, so skip progress reporting entirely. + if (!isLive) { + progressInterval = setInterval(() => { + if (isPlaying && !isSeeking && onReportProgress) { + onReportProgress(currentTime, false, reportMediaId); + } + }, 10000); + } // Debug logging every second debugLogInterval = setInterval(() => { @@ -555,8 +559,8 @@ } } - // Report stop when component is destroyed - if (onReportStop && currentTime > 0) { + // Report stop when component is destroyed (skip for live - no resume tracking) + if (!isLive && onReportStop && currentTime > 0) { onReportStop(currentTime, reportMediaId); } }); @@ -749,8 +753,8 @@ function handlePlay() { isPlaying = true; startTimeUpdates(); // Start RAF loop for smooth time updates - // Report playback start on first play - if (!hasReportedStart && onReportStart) { + // Report playback start on first play (skip for live - no resume tracking) + if (!isLive && !hasReportedStart && onReportStart) { onReportStart(currentTime, reportMediaId); hasReportedStart = true; } @@ -768,8 +772,8 @@ function handleEnded() { isPlaying = false; stopTimeUpdates(); // Stop RAF loop when ended - // Report stop when video ends - if (onReportStop) { + // Report stop when video ends (skip for live - no resume tracking) + if (!isLive && onReportStop) { onReportStop(currentTime, reportMediaId); } // Notify parent that video has ended (for next episode popup) @@ -1413,26 +1417,35 @@

{media?.name || "Video"}

- -
- {formatTime(currentTime)} - isDraggingSeekBar = true} - onmouseup={() => isDraggingSeekBar = false} - ontouchstart={() => isDraggingSeekBar = true} - ontouchend={() => isDraggingSeekBar = false} - class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 - [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full" - /> - {formatTime(duration)} -
+ + {#if isLive} +
+ + + LIVE + +
+ {:else} +
+ {formatTime(currentTime)} + isDraggingSeekBar = true} + onmouseup={() => isDraggingSeekBar = false} + ontouchstart={() => isDraggingSeekBar = true} + ontouchend={() => isDraggingSeekBar = false} + class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer + [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full" + /> + {formatTime(duration)} +
+ {/if}
diff --git a/src/lib/stores/library.ts b/src/lib/stores/library.ts index 08daacb..391bb40 100644 --- a/src/lib/stores/library.ts +++ b/src/lib/stores/library.ts @@ -152,6 +152,47 @@ function createLibraryStore() { } } + // Load Live TV channels (broadcast / IPTV) into the items list. Live TV uses a + // dedicated Jellyfin endpoint rather than the generic /Items browse. + async function loadLiveTvChannels() { + update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null })); + try { + const repo = auth.getRepository(); + const channels = await repo.getLiveTvChannels(); + update((s) => ({ + ...s, + items: channels, + totalItems: channels.length, + loadingCount: Math.max(0, s.loadingCount - 1), + })); + return channels; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load Live TV channels"; + update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message })); + throw error; + } + } + + // Load the root list of plugin "Channels" into the items list. + async function loadChannels() { + update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null })); + try { + const repo = auth.getRepository(); + const result = await repo.getChannels(); + update((s) => ({ + ...s, + items: result.items, + totalItems: result.totalRecordCount, + loadingCount: Math.max(0, s.loadingCount - 1), + })); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load channels"; + update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message })); + throw error; + } + } + async function loadItem(itemId: string) { update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null })); @@ -297,6 +338,8 @@ function createLibraryStore() { subscribe, loadLibraries, loadItems, + loadLiveTvChannels, + loadChannels, loadItem, search, setCurrentLibrary, diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index 3f22de5..d89a819 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -76,6 +76,22 @@ return; } + // Live TV channels use a dedicated endpoint + if (lib.collectionType === "livetv") { + library.setCurrentLibrary(lib); + library.clearGenres(); + await library.loadLiveTvChannels(); + return; + } + + // Plugin "Channels" root list uses a dedicated endpoint + if (lib.collectionType === "channels") { + library.setCurrentLibrary(lib); + library.clearGenres(); + await library.loadChannels(); + return; + } + // For other library types, load items normally library.setCurrentLibrary(lib); library.clearGenres(); @@ -97,6 +113,11 @@ if ("type" in item) { // It's a MediaItem const mediaItem = item as MediaItem; + // A ChannelFolderItem can be a folder (drill in) or a playable leaf. + if (mediaItem.type === "ChannelFolderItem" && !mediaItem.isFolder) { + goto(`/player/${mediaItem.id}`); + return; + } switch (mediaItem.type) { case "Series": case "Movie": @@ -111,7 +132,8 @@ goto(`/library/${mediaItem.id}`); break; case "Episode": - // Episodes play directly + case "TvChannel": + // Episodes and live TV channels play directly goto(`/player/${mediaItem.id}`); break; default: diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index 26226b9..f34ec5c 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -187,6 +187,12 @@ goto(`/library/${clickedItem.id}`); return; } + // A ChannelFolderItem can be either a folder (drill in) or a playable leaf. + // Route non-folder channel items straight to the player. + if (clickedItem.type === "ChannelFolderItem" && !clickedItem.isFolder) { + goto(`/player/${clickedItem.id}`); + return; + } switch (clickedItem.type) { case "Series": case "Season": diff --git a/src/routes/player/[id]/+page.svelte b/src/routes/player/[id]/+page.svelte index d54c954..d70dc57 100644 --- a/src/routes/player/[id]/+page.svelte +++ b/src/routes/player/[id]/+page.svelte @@ -57,6 +57,7 @@ let streamUrl = $state(null); let mediaSourceId = $state(null); let isVideo = $state(false); + let isLive = $state(false); // Whether this is a live stream (Live TV channel) - no seek/resume let videoInitialPosition = $state(0); // Position in seconds to seek to after video loads let videoNeedsTranscoding = $state(false); // Whether video needs transcoding (HEVC/10-bit) let isOfflinePlayback = $state(false); // Whether playing from local file @@ -104,6 +105,15 @@ } }); + // A playable channel leaf (ChannelFolderItem) has no dedicated item type, so + // treat it as video when it carries a video media stream. + function isVideoChannelItem(item: MediaItem): boolean { + return ( + item.type === "ChannelFolderItem" && + (item.mediaStreams?.some((s) => s.type === "Video") ?? false) + ); + } + async function loadAndPlay(id: string, startPosition?: number, forceRestart = false) { loading = true; error = null; @@ -118,8 +128,9 @@ currentMedia = item; // Check if this is a non-playable collection type that should be viewed in library instead - const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist"]; - if (collectionTypes.includes(item.type)) { + const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist", "Channel"]; + // A ChannelFolderItem that is itself a folder is a container, not playable. + if (collectionTypes.includes(item.type) || (item.type === "ChannelFolderItem" && item.isFolder)) { console.log("loadAndPlay: Redirecting collection type to library:", item.type); goto(`/library/${id}`); return; @@ -132,7 +143,8 @@ const alreadyPlayingMedia = get(storeCurrentMedia); if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) { console.log("loadAndPlay: Track already playing, showing UI without restarting"); - isVideo = item.type === "Movie" || item.type === "Episode"; + isLive = item.type === "TvChannel"; + isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item); isPlaying = true; loading = false; // hasNext/hasPrevious come from the event-driven queue store. @@ -143,8 +155,10 @@ return; } - // Determine if this is video content (Movie and Episode are video types) - isVideo = item.type === "Movie" || item.type === "Episode"; + // Determine if this is video content (Movie, Episode, live TV channels, and + // channel leaf items that carry a video stream). + isLive = item.type === "TvChannel"; + isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item); // When switching to video, stop audio playback and clear the queue // This prevents audio from continuing in the background and clears stale state @@ -164,7 +178,8 @@ const userId = auth.getUserId(); console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart); - if (!startPosition && !forceRestart && userId) { + // Live streams have no fixed position - never resume. + if (!startPosition && !forceRestart && userId && !isLive) { try { const progress = await commands.storageGetPlaybackProgress(userId, id); console.log("Resume check - retrieved progress:", progress); @@ -251,6 +266,22 @@ // Online playback - get playback info from server isOfflinePlayback = false; const repo = auth.getRepository(); + + if (isLive) { + // Live TV channels must be "opened" before streaming; the server returns + // a ready-to-play HLS transcoding URL. No resume, no seek, no progress. + console.log("loadAndPlay: Opening live stream for channel:", id); + const liveInfo = await repo.openLiveStream(id); + console.log("loadAndPlay: Live stream URL:", liveInfo.streamUrl); + mediaSourceId = liveInfo.mediaSourceId; + streamUrl = liveInfo.streamUrl; + videoNeedsTranscoding = true; + videoInitialPosition = 0; + isPlaying = true; + loading = false; + return; + } + console.log("loadAndPlay: Getting playback info"); const playbackInfo = await repo.getPlaybackInfo(id); console.log("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId); @@ -613,6 +644,7 @@ mediaSourceId={mediaSourceId ?? undefined} initialPosition={videoInitialPosition} needsTranscoding={videoNeedsTranscoding} + {isLive} onClose={handleClose} onSeek={handleVideoSeek} onReportStart={handleReportStart}