feat(library and playback): Support for serverside channel plugins and hls streaming
This commit is contained in:
parent
f1d25c4f4d
commit
7d7f27aa10
@ -410,6 +410,49 @@ pub async fn repository_get_audio_stream_url(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.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<Vec<MediaItem>, 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<SearchResult, String> {
|
||||||
|
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<LiveStreamInfo, String> {
|
||||||
|
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
|
/// Report playback start
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
|||||||
@ -101,6 +101,7 @@ use commands::{
|
|||||||
repository_get_rediscover_albums,
|
repository_get_rediscover_albums,
|
||||||
repository_get_genres, repository_search, repository_get_playback_info,
|
repository_get_genres, repository_search, repository_get_playback_info,
|
||||||
repository_get_video_stream_url, repository_get_audio_stream_url,
|
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_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
|
||||||
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
|
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
|
||||||
repository_get_person, repository_get_items_by_person, repository_get_similar_items,
|
repository_get_person, repository_get_items_by_person, repository_get_similar_items,
|
||||||
@ -572,6 +573,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
repository_get_playback_info,
|
repository_get_playback_info,
|
||||||
repository_get_video_stream_url,
|
repository_get_video_stream_url,
|
||||||
repository_get_audio_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_start,
|
||||||
repository_report_playback_progress,
|
repository_report_playback_progress,
|
||||||
repository_report_playback_stopped,
|
repository_report_playback_stopped,
|
||||||
|
|||||||
@ -415,6 +415,21 @@ impl MediaRepository for HybridRepository {
|
|||||||
self.online.get_audio_stream_url(item_id).await
|
self.online.get_audio_stream_url(item_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
|
// Live TV requires server communication - delegate to online repository
|
||||||
|
self.online.get_live_tv_channels().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
|
||||||
|
// Plugin channels require server communication - delegate to online repository
|
||||||
|
self.online.get_channels().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
|
||||||
|
// 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> {
|
async fn report_playback_start(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> {
|
||||||
// Playback reporting goes directly to server
|
// Playback reporting goes directly to server
|
||||||
self.online.report_playback_start(item_id, position_ticks).await
|
self.online.report_playback_start(item_id, position_ticks).await
|
||||||
@ -722,6 +737,18 @@ mod tests {
|
|||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@ -887,6 +914,18 @@ mod tests {
|
|||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@ -976,6 +1015,7 @@ mod tests {
|
|||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
item_type: "Movie".to_string(),
|
item_type: "Movie".to_string(),
|
||||||
|
is_folder: false,
|
||||||
server_id: "test-server".to_string(),
|
server_id: "test-server".to_string(),
|
||||||
parent_id: Some("parent-123".to_string()),
|
parent_id: Some("parent-123".to_string()),
|
||||||
library_id: Some("library-456".to_string()),
|
library_id: Some("library-456".to_string()),
|
||||||
|
|||||||
@ -117,6 +117,19 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
/// @req: JA-007 - Get playback info and stream URL
|
/// @req: JA-007 - Get playback info and stream URL
|
||||||
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
|
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
|
||||||
|
|
||||||
|
/// Get Live TV channels (broadcast / IPTV) for browsing.
|
||||||
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, 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<SearchResult, RepoError>;
|
||||||
|
|
||||||
|
/// 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<LiveStreamInfo, RepoError>;
|
||||||
|
|
||||||
/// Report playback start
|
/// Report playback start
|
||||||
///
|
///
|
||||||
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
||||||
|
|||||||
@ -29,6 +29,7 @@ impl OfflineRepository {
|
|||||||
id: item.id.clone(),
|
id: item.id.clone(),
|
||||||
name: item.name,
|
name: item.name,
|
||||||
item_type: item.item_type,
|
item_type: item.item_type,
|
||||||
|
is_folder: item.is_folder,
|
||||||
server_id: item.server_id,
|
server_id: item.server_id,
|
||||||
parent_id: item.parent_id,
|
parent_id: item.parent_id,
|
||||||
library_id: item.library_id,
|
library_id: item.library_id,
|
||||||
@ -92,6 +93,7 @@ struct CachedItem {
|
|||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
item_type: String,
|
item_type: String,
|
||||||
|
is_folder: bool,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
parent_id: Option<String>,
|
parent_id: Option<String>,
|
||||||
library_id: Option<String>,
|
library_id: Option<String>,
|
||||||
@ -143,6 +145,8 @@ fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
|
|||||||
season_id: row.get(20)?,
|
season_id: row.get(20)?,
|
||||||
season_name: row.get(21)?,
|
season_name: row.get(21)?,
|
||||||
parent_index_number: row.get(22)?,
|
parent_index_number: row.get(22)?,
|
||||||
|
// Appended as the final column in every SELECT that maps through this fn.
|
||||||
|
is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,7 +234,7 @@ impl OfflineRepository {
|
|||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"INSERT OR REPLACE INTO items (
|
"INSERT OR REPLACE INTO items (
|
||||||
id, server_id, library_id, parent_id,
|
id, server_id, library_id, parent_id,
|
||||||
name, item_type, overview,
|
name, item_type, is_folder, overview,
|
||||||
genres, series_id, series_name,
|
genres, series_id, series_name,
|
||||||
season_id, season_name, index_number, parent_index_number,
|
season_id, season_name, index_number, parent_index_number,
|
||||||
album_id, album_name, album_artist, artists,
|
album_id, album_name, album_artist, artists,
|
||||||
@ -240,14 +244,14 @@ impl OfflineRepository {
|
|||||||
synced_at
|
synced_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?1, ?2, ?3, ?4,
|
?1, ?2, ?3, ?4,
|
||||||
?5, ?6, ?7,
|
?5, ?6, ?7, ?8,
|
||||||
?8, ?9, ?10,
|
?9, ?10, ?11,
|
||||||
?11, ?12, ?13, ?14,
|
?12, ?13, ?14, ?15,
|
||||||
?15, ?16, ?17, ?18,
|
?16, ?17, ?18, ?19,
|
||||||
?19, ?20,
|
?20, ?21,
|
||||||
?21, ?22,
|
?22, ?23,
|
||||||
?23, ?24,
|
?24, ?25,
|
||||||
?25
|
?26
|
||||||
)",
|
)",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(item.id.clone()),
|
QueryParam::String(item.id.clone()),
|
||||||
@ -261,6 +265,7 @@ impl OfflineRepository {
|
|||||||
},
|
},
|
||||||
QueryParam::String(item.name.clone()),
|
QueryParam::String(item.name.clone()),
|
||||||
QueryParam::String(item.item_type.clone()),
|
QueryParam::String(item.item_type.clone()),
|
||||||
|
QueryParam::Int(if item.is_folder { 1 } else { 0 }),
|
||||||
match &item.overview {
|
match &item.overview {
|
||||||
Some(o) => QueryParam::String(o.clone()),
|
Some(o) => QueryParam::String(o.clone()),
|
||||||
None => QueryParam::Null,
|
None => QueryParam::Null,
|
||||||
@ -491,7 +496,7 @@ impl MediaRepository for OfflineRepository {
|
|||||||
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
|
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.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.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
|
FROM items i
|
||||||
INNER JOIN available_items ai ON i.id = ai.id
|
INNER JOIN available_items ai ON i.id = ai.id
|
||||||
WHERE i.server_id = ? AND i.parent_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.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.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.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
|
FROM items i
|
||||||
INNER JOIN downloaded_items di ON i.id = di.id
|
INNER JOIN downloaded_items di ON i.id = di.id
|
||||||
WHERE i.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.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.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.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
|
FROM items i
|
||||||
INNER JOIN downloaded_items di ON i.id = di.id
|
INNER JOIN downloaded_items di ON i.id = di.id
|
||||||
WHERE i.server_id = ? AND i.library_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.community_rating, i.official_rating, i.primary_image_tag,
|
||||||
i.album_id, i.album_name, i.album_artist, i.artists,
|
i.album_id, i.album_name, i.album_artist, i.artists,
|
||||||
i.index_number, i.series_id, i.series_name, i.season_id,
|
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
|
FROM items i
|
||||||
JOIN user_data ud ON i.id = ud.item_id
|
JOIN user_data ud ON i.id = ud.item_id
|
||||||
INNER JOIN downloads d ON i.id = d.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.community_rating, i.official_rating, i.primary_image_tag,
|
||||||
i.album_id, i.album_name, i.album_artist, i.artists,
|
i.album_id, i.album_name, i.album_artist, i.artists,
|
||||||
i.index_number, i.series_id, i.series_name, i.season_id,
|
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
|
FROM items i
|
||||||
JOIN user_data ud ON i.id = ud.item_id
|
JOIN user_data ud ON i.id = ud.item_id
|
||||||
INNER JOIN downloads d ON i.id = d.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.community_rating, i.official_rating, i.primary_image_tag,
|
||||||
i.album_id, i.album_name, i.album_artist, i.artists,
|
i.album_id, i.album_name, i.album_artist, i.artists,
|
||||||
i.index_number, i.series_id, i.series_name, i.season_id,
|
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
|
FROM ranked_plays rp
|
||||||
JOIN items i ON rp.display_id = i.id
|
JOIN items i ON rp.display_id = i.id
|
||||||
INNER JOIN downloaded_items di ON i.id = di.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.community_rating, i.official_rating, i.primary_image_tag,
|
||||||
i.album_id, i.album_name, i.album_artist, i.artists,
|
i.album_id, i.album_name, i.album_artist, i.artists,
|
||||||
i.index_number, i.series_id, i.series_name, i.season_id,
|
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
|
FROM items i
|
||||||
JOIN user_data ud ON i.id = ud.item_id
|
JOIN user_data ud ON i.id = ud.item_id
|
||||||
INNER JOIN downloads d ON i.id = d.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.community_rating, i.official_rating, i.primary_image_tag,
|
||||||
i.album_id, i.album_name, i.album_artist, i.artists,
|
i.album_id, i.album_name, i.album_artist, i.artists,
|
||||||
i.index_number, i.series_id, i.series_name, i.season_id,
|
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
|
FROM items i
|
||||||
JOIN items_fts fts ON fts.rowid = i.rowid
|
JOIN items_fts fts ON fts.rowid = i.rowid
|
||||||
INNER JOIN downloaded_items di ON i.id = di.id
|
INNER JOIN downloaded_items di ON i.id = di.id
|
||||||
@ -980,6 +985,21 @@ impl MediaRepository for OfflineRepository {
|
|||||||
Err(RepoError::Offline)
|
Err(RepoError::Offline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
|
// Live TV is inherently online-only.
|
||||||
|
Err(RepoError::Offline)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
|
||||||
|
// Plugin channels are inherently online-only.
|
||||||
|
Err(RepoError::Offline)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
|
||||||
|
// Live streams cannot be opened offline.
|
||||||
|
Err(RepoError::Offline)
|
||||||
|
}
|
||||||
|
|
||||||
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||||
// Cannot report to server while offline
|
// Cannot report to server while offline
|
||||||
Err(RepoError::Offline)
|
Err(RepoError::Offline)
|
||||||
@ -1071,6 +1091,7 @@ impl MediaRepository for OfflineRepository {
|
|||||||
id: person_data.0,
|
id: person_data.0,
|
||||||
name: person_data.1,
|
name: person_data.1,
|
||||||
item_type: "Person".to_string(),
|
item_type: "Person".to_string(),
|
||||||
|
is_folder: false,
|
||||||
server_id: self.server_id.clone(),
|
server_id: self.server_id.clone(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
@ -1131,7 +1152,7 @@ impl MediaRepository for OfflineRepository {
|
|||||||
i.community_rating, i.official_rating, i.primary_image_tag,
|
i.community_rating, i.official_rating, i.primary_image_tag,
|
||||||
i.album_id, i.album_name, i.album_artist, i.artists,
|
i.album_id, i.album_name, i.album_artist, i.artists,
|
||||||
i.index_number, i.series_id, i.series_name, i.season_id,
|
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
|
FROM items i
|
||||||
JOIN item_people ip ON i.id = ip.item_id
|
JOIN item_people ip ON i.id = ip.item_id
|
||||||
INNER JOIN downloaded_items di ON i.id = di.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.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.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.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 \
|
FROM playlist_items pi \
|
||||||
JOIN items i ON pi.item_id = i.id \
|
JOIN items i ON pi.item_id = i.id \
|
||||||
WHERE pi.playlist_id = ? \
|
WHERE pi.playlist_id = ? \
|
||||||
@ -1280,6 +1301,7 @@ impl MediaRepository for OfflineRepository {
|
|||||||
season_id: row.get(21)?,
|
season_id: row.get(21)?,
|
||||||
season_name: row.get(22)?,
|
season_name: row.get(22)?,
|
||||||
parent_index_number: row.get(23)?,
|
parent_index_number: row.get(23)?,
|
||||||
|
is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
|
||||||
};
|
};
|
||||||
Ok((entry_id.to_string(), cached))
|
Ok((entry_id.to_string(), cached))
|
||||||
})
|
})
|
||||||
@ -1446,6 +1468,7 @@ mod tests {
|
|||||||
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
|
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
item_type TEXT NOT NULL,
|
item_type TEXT NOT NULL,
|
||||||
|
is_folder INTEGER DEFAULT 0,
|
||||||
overview TEXT,
|
overview TEXT,
|
||||||
genres TEXT,
|
genres TEXT,
|
||||||
runtime_ticks INTEGER,
|
runtime_ticks INTEGER,
|
||||||
@ -1518,6 +1541,7 @@ mod tests {
|
|||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
item_type: "Audio".to_string(),
|
item_type: "Audio".to_string(),
|
||||||
|
is_folder: false,
|
||||||
server_id: "test-server".to_string(),
|
server_id: "test-server".to_string(),
|
||||||
parent_id: parent_id.map(|s| s.to_string()),
|
parent_id: parent_id.map(|s| s.to_string()),
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
|||||||
@ -363,6 +363,8 @@ struct JellyfinItem {
|
|||||||
name: String,
|
name: String,
|
||||||
#[serde(rename = "Type")]
|
#[serde(rename = "Type")]
|
||||||
item_type: String,
|
item_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
is_folder: bool,
|
||||||
parent_id: Option<String>,
|
parent_id: Option<String>,
|
||||||
overview: Option<String>,
|
overview: Option<String>,
|
||||||
genres: Option<Vec<String>>,
|
genres: Option<Vec<String>>,
|
||||||
@ -450,6 +452,7 @@ impl JellyfinItem {
|
|||||||
id: self.id,
|
id: self.id,
|
||||||
name: self.name,
|
name: self.name,
|
||||||
item_type: self.item_type,
|
item_type: self.item_type,
|
||||||
|
is_folder: self.is_folder,
|
||||||
server_id,
|
server_id,
|
||||||
parent_id: self.parent_id,
|
parent_id: self.parent_id,
|
||||||
library_id: None, // Not provided by Jellyfin API directly
|
library_id: None, // Not provided by Jellyfin API directly
|
||||||
@ -728,6 +731,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
id: album_id,
|
id: album_id,
|
||||||
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
|
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
|
||||||
item_type: "MusicAlbum".to_string(),
|
item_type: "MusicAlbum".to_string(),
|
||||||
|
is_folder: true,
|
||||||
server_id: first_track.server_id.clone(),
|
server_id: first_track.server_id.clone(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
@ -1139,6 +1143,105 @@ impl MediaRepository for OnlineRepository {
|
|||||||
Ok(url)
|
Ok(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, 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<SearchResult, RepoError> {
|
||||||
|
// 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<LiveStreamInfo, RepoError> {
|
||||||
|
// 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<LiveMediaSource>,
|
||||||
|
play_session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct LiveMediaSource {
|
||||||
|
id: String,
|
||||||
|
transcoding_url: Option<String>,
|
||||||
|
live_stream_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
async fn report_playback_start(
|
||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
|
|||||||
@ -104,6 +104,10 @@ pub struct MediaItem {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub item_type: String,
|
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,
|
pub server_id: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub parent_id: Option<String>,
|
pub parent_id: Option<String>,
|
||||||
@ -249,6 +253,20 @@ pub struct PlaybackInfo {
|
|||||||
pub needs_transcoding: bool,
|
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<String>,
|
||||||
|
pub live_stream_id: Option<String>,
|
||||||
|
pub media_source_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Genre
|
/// Genre
|
||||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -481,6 +499,7 @@ mod tests {
|
|||||||
id: "1".to_string(),
|
id: "1".to_string(),
|
||||||
name: "Test".to_string(),
|
name: "Test".to_string(),
|
||||||
item_type: "Audio".to_string(),
|
item_type: "Audio".to_string(),
|
||||||
|
is_folder: false,
|
||||||
server_id: "server1".to_string(),
|
server_id: "server1".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
@ -620,6 +639,7 @@ mod tests {
|
|||||||
id: "track1".to_string(),
|
id: "track1".to_string(),
|
||||||
name: "Test Track".to_string(),
|
name: "Test Track".to_string(),
|
||||||
item_type: "Audio".to_string(),
|
item_type: "Audio".to_string(),
|
||||||
|
is_folder: false,
|
||||||
server_id: "server1".to_string(),
|
server_id: "server1".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
@ -679,6 +699,7 @@ mod tests {
|
|||||||
id: "1".to_string(),
|
id: "1".to_string(),
|
||||||
name: "Track".to_string(),
|
name: "Track".to_string(),
|
||||||
item_type: "Audio".to_string(),
|
item_type: "Audio".to_string(),
|
||||||
|
is_folder: false,
|
||||||
server_id: "s1".to_string(),
|
server_id: "s1".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
|||||||
@ -22,6 +22,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
|||||||
("015_device_id", MIGRATION_015),
|
("015_device_id", MIGRATION_015),
|
||||||
("016_autoplay_max_episodes", MIGRATION_016),
|
("016_autoplay_max_episodes", MIGRATION_016),
|
||||||
("017_downloads_resume_url", MIGRATION_017),
|
("017_downloads_resume_url", MIGRATION_017),
|
||||||
|
("018_items_is_folder", MIGRATION_018),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Initial schema migration
|
/// 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 stream_url TEXT;
|
||||||
ALTER TABLE downloads ADD COLUMN target_dir 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;
|
||||||
|
"#;
|
||||||
|
|||||||
@ -1134,6 +1134,24 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId:
|
|||||||
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
|
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
|
||||||
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId });
|
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId });
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Get Live TV channels (broadcast / IPTV) for browsing
|
||||||
|
*/
|
||||||
|
async repositoryGetLiveTvChannels(handle: string) : Promise<MediaItem[]> {
|
||||||
|
return await TAURI_INVOKE("repository_get_live_tv_channels", { handle });
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the root list of plugin "Channels"
|
||||||
|
*/
|
||||||
|
async repositoryGetChannels(handle: string) : Promise<SearchResult> {
|
||||||
|
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<LiveStreamInfo> {
|
||||||
|
return await TAURI_INVOKE("repository_open_live_stream", { handle, itemId });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Report playback start
|
* Report playback start
|
||||||
*/
|
*/
|
||||||
@ -1539,6 +1557,14 @@ export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo"
|
|||||||
* Library (media collection)
|
* Library (media collection)
|
||||||
*/
|
*/
|
||||||
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null }
|
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`.
|
* 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
|
* 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
|
* Media session type tracking the high-level playback context
|
||||||
*/
|
*/
|
||||||
@ -1927,7 +1958,12 @@ export type PlaylistEntry =
|
|||||||
/**
|
/**
|
||||||
* The underlying media item
|
* 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)
|
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type {
|
|||||||
GetItemsOptions,
|
GetItemsOptions,
|
||||||
SearchOptions,
|
SearchOptions,
|
||||||
PlaybackInfo,
|
PlaybackInfo,
|
||||||
|
LiveStreamInfo,
|
||||||
ImageType,
|
ImageType,
|
||||||
ImageOptions,
|
ImageOptions,
|
||||||
Genre,
|
Genre,
|
||||||
@ -161,6 +162,23 @@ export class RepositoryClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Live TV / Channels =====
|
||||||
|
|
||||||
|
/** Browse Live TV channels (broadcast / IPTV). */
|
||||||
|
async getLiveTvChannels(): Promise<MediaItem[]> {
|
||||||
|
return commands.repositoryGetLiveTvChannels(this.ensureHandle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browse the root list of plugin "Channels". Drill-down uses getItems(channelId). */
|
||||||
|
async getChannels(): Promise<SearchResult> {
|
||||||
|
return commands.repositoryGetChannels(this.ensureHandle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a live stream for a Live TV channel / live item before HLS playback. */
|
||||||
|
async openLiveStream(itemId: string): Promise<LiveStreamInfo> {
|
||||||
|
return commands.repositoryOpenLiveStream(this.ensureHandle(), itemId);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== URL Construction Methods (sync, no server call) =====
|
// ===== URL Construction Methods (sync, no server call) =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export type {
|
|||||||
ImageOptions,
|
ImageOptions,
|
||||||
ImageType,
|
ImageType,
|
||||||
Library,
|
Library,
|
||||||
|
LiveStreamInfo,
|
||||||
MediaItem,
|
MediaItem,
|
||||||
MediaSource,
|
MediaSource,
|
||||||
MediaStream,
|
MediaStream,
|
||||||
@ -51,6 +52,7 @@ export type ItemType =
|
|||||||
| "CollectionFolder"
|
| "CollectionFolder"
|
||||||
| "Channel"
|
| "Channel"
|
||||||
| "ChannelFolderItem"
|
| "ChannelFolderItem"
|
||||||
|
| "TvChannel"
|
||||||
| "Person";
|
| "Person";
|
||||||
|
|
||||||
export type LibraryType =
|
export type LibraryType =
|
||||||
@ -63,6 +65,7 @@ export type LibraryType =
|
|||||||
| "boxsets"
|
| "boxsets"
|
||||||
| "playlists"
|
| "playlists"
|
||||||
| "channels"
|
| "channels"
|
||||||
|
| "livetv"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
|
|
||||||
export type PersonType =
|
export type PersonType =
|
||||||
|
|||||||
@ -30,9 +30,10 @@
|
|||||||
onEnded?: () => void; // Called when video playback ends naturally
|
onEnded?: () => void; // Called when video playback ends naturally
|
||||||
onNext?: () => void; // Called when user clicks next episode button
|
onNext?: () => void; // Called when user clicks next episode button
|
||||||
hasNext?: boolean; // Whether there is a next episode available
|
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
|
// The id this player instance reports progress against. Snapshotted from the
|
||||||
// media prop so a late reportStop (e.g. from onDestroy during autoplay
|
// media prop so a late reportStop (e.g. from onDestroy during autoplay
|
||||||
@ -488,12 +489,15 @@
|
|||||||
// Load series audio preference (for TV shows)
|
// Load series audio preference (for TV shows)
|
||||||
await loadSeriesAudioPreference();
|
await loadSeriesAudioPreference();
|
||||||
|
|
||||||
// Report progress every 10 seconds while playing
|
// Report progress every 10 seconds while playing. Live streams have no
|
||||||
progressInterval = setInterval(() => {
|
// meaningful position to report, so skip progress reporting entirely.
|
||||||
if (isPlaying && !isSeeking && onReportProgress) {
|
if (!isLive) {
|
||||||
onReportProgress(currentTime, false, reportMediaId);
|
progressInterval = setInterval(() => {
|
||||||
}
|
if (isPlaying && !isSeeking && onReportProgress) {
|
||||||
}, 10000);
|
onReportProgress(currentTime, false, reportMediaId);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
// Debug logging every second
|
// Debug logging every second
|
||||||
debugLogInterval = setInterval(() => {
|
debugLogInterval = setInterval(() => {
|
||||||
@ -555,8 +559,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report stop when component is destroyed
|
// Report stop when component is destroyed (skip for live - no resume tracking)
|
||||||
if (onReportStop && currentTime > 0) {
|
if (!isLive && onReportStop && currentTime > 0) {
|
||||||
onReportStop(currentTime, reportMediaId);
|
onReportStop(currentTime, reportMediaId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -749,8 +753,8 @@
|
|||||||
function handlePlay() {
|
function handlePlay() {
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
startTimeUpdates(); // Start RAF loop for smooth time updates
|
startTimeUpdates(); // Start RAF loop for smooth time updates
|
||||||
// Report playback start on first play
|
// Report playback start on first play (skip for live - no resume tracking)
|
||||||
if (!hasReportedStart && onReportStart) {
|
if (!isLive && !hasReportedStart && onReportStart) {
|
||||||
onReportStart(currentTime, reportMediaId);
|
onReportStart(currentTime, reportMediaId);
|
||||||
hasReportedStart = true;
|
hasReportedStart = true;
|
||||||
}
|
}
|
||||||
@ -768,8 +772,8 @@
|
|||||||
function handleEnded() {
|
function handleEnded() {
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
stopTimeUpdates(); // Stop RAF loop when ended
|
stopTimeUpdates(); // Stop RAF loop when ended
|
||||||
// Report stop when video ends
|
// Report stop when video ends (skip for live - no resume tracking)
|
||||||
if (onReportStop) {
|
if (!isLive && onReportStop) {
|
||||||
onReportStop(currentTime, reportMediaId);
|
onReportStop(currentTime, reportMediaId);
|
||||||
}
|
}
|
||||||
// Notify parent that video has ended (for next episode popup)
|
// Notify parent that video has ended (for next episode popup)
|
||||||
@ -1413,26 +1417,35 @@
|
|||||||
<h2 class="text-white text-lg font-semibold">{media?.name || "Video"}</h2>
|
<h2 class="text-white text-lg font-semibold">{media?.name || "Video"}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar (hidden for live streams - no fixed timeline) -->
|
||||||
<div class="flex items-center gap-2 mb-2">
|
{#if isLive}
|
||||||
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<input
|
<span class="flex items-center gap-1.5 text-white text-sm font-semibold">
|
||||||
type="range"
|
<span class="inline-block w-2 h-2 rounded-full bg-red-500"></span>
|
||||||
min="0"
|
LIVE
|
||||||
max={duration || 100}
|
</span>
|
||||||
value={currentTime}
|
</div>
|
||||||
oninput={handleSeekBarInput}
|
{:else}
|
||||||
onchange={handleSeekBarChange}
|
<div class="flex items-center gap-2 mb-2">
|
||||||
onmousedown={() => isDraggingSeekBar = true}
|
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
|
||||||
onmouseup={() => isDraggingSeekBar = false}
|
<input
|
||||||
ontouchstart={() => isDraggingSeekBar = true}
|
type="range"
|
||||||
ontouchend={() => isDraggingSeekBar = false}
|
min="0"
|
||||||
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
|
max={duration || 100}
|
||||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
value={currentTime}
|
||||||
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
oninput={handleSeekBarInput}
|
||||||
/>
|
onchange={handleSeekBarChange}
|
||||||
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
|
onmousedown={() => isDraggingSeekBar = true}
|
||||||
</div>
|
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"
|
||||||
|
/>
|
||||||
|
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Control buttons -->
|
<!-- Control buttons -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
@ -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) {
|
async function loadItem(itemId: string) {
|
||||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||||
|
|
||||||
@ -297,6 +338,8 @@ function createLibraryStore() {
|
|||||||
subscribe,
|
subscribe,
|
||||||
loadLibraries,
|
loadLibraries,
|
||||||
loadItems,
|
loadItems,
|
||||||
|
loadLiveTvChannels,
|
||||||
|
loadChannels,
|
||||||
loadItem,
|
loadItem,
|
||||||
search,
|
search,
|
||||||
setCurrentLibrary,
|
setCurrentLibrary,
|
||||||
|
|||||||
@ -76,6 +76,22 @@
|
|||||||
return;
|
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
|
// For other library types, load items normally
|
||||||
library.setCurrentLibrary(lib);
|
library.setCurrentLibrary(lib);
|
||||||
library.clearGenres();
|
library.clearGenres();
|
||||||
@ -97,6 +113,11 @@
|
|||||||
if ("type" in item) {
|
if ("type" in item) {
|
||||||
// It's a MediaItem
|
// It's a MediaItem
|
||||||
const mediaItem = item as 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) {
|
switch (mediaItem.type) {
|
||||||
case "Series":
|
case "Series":
|
||||||
case "Movie":
|
case "Movie":
|
||||||
@ -111,7 +132,8 @@
|
|||||||
goto(`/library/${mediaItem.id}`);
|
goto(`/library/${mediaItem.id}`);
|
||||||
break;
|
break;
|
||||||
case "Episode":
|
case "Episode":
|
||||||
// Episodes play directly
|
case "TvChannel":
|
||||||
|
// Episodes and live TV channels play directly
|
||||||
goto(`/player/${mediaItem.id}`);
|
goto(`/player/${mediaItem.id}`);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -187,6 +187,12 @@
|
|||||||
goto(`/library/${clickedItem.id}`);
|
goto(`/library/${clickedItem.id}`);
|
||||||
return;
|
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) {
|
switch (clickedItem.type) {
|
||||||
case "Series":
|
case "Series":
|
||||||
case "Season":
|
case "Season":
|
||||||
|
|||||||
@ -57,6 +57,7 @@
|
|||||||
let streamUrl = $state<string | null>(null);
|
let streamUrl = $state<string | null>(null);
|
||||||
let mediaSourceId = $state<string | null>(null);
|
let mediaSourceId = $state<string | null>(null);
|
||||||
let isVideo = $state(false);
|
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 videoInitialPosition = $state(0); // Position in seconds to seek to after video loads
|
||||||
let videoNeedsTranscoding = $state(false); // Whether video needs transcoding (HEVC/10-bit)
|
let videoNeedsTranscoding = $state(false); // Whether video needs transcoding (HEVC/10-bit)
|
||||||
let isOfflinePlayback = $state(false); // Whether playing from local file
|
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) {
|
async function loadAndPlay(id: string, startPosition?: number, forceRestart = false) {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
@ -118,8 +128,9 @@
|
|||||||
currentMedia = item;
|
currentMedia = item;
|
||||||
|
|
||||||
// Check if this is a non-playable collection type that should be viewed in library instead
|
// 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"];
|
const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist", "Channel"];
|
||||||
if (collectionTypes.includes(item.type)) {
|
// 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);
|
console.log("loadAndPlay: Redirecting collection type to library:", item.type);
|
||||||
goto(`/library/${id}`);
|
goto(`/library/${id}`);
|
||||||
return;
|
return;
|
||||||
@ -132,7 +143,8 @@
|
|||||||
const alreadyPlayingMedia = get(storeCurrentMedia);
|
const alreadyPlayingMedia = get(storeCurrentMedia);
|
||||||
if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) {
|
if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) {
|
||||||
console.log("loadAndPlay: Track already playing, showing UI without restarting");
|
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;
|
isPlaying = true;
|
||||||
loading = false;
|
loading = false;
|
||||||
// hasNext/hasPrevious come from the event-driven queue store.
|
// hasNext/hasPrevious come from the event-driven queue store.
|
||||||
@ -143,8 +155,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if this is video content (Movie and Episode are video types)
|
// Determine if this is video content (Movie, Episode, live TV channels, and
|
||||||
isVideo = item.type === "Movie" || item.type === "Episode";
|
// 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
|
// When switching to video, stop audio playback and clear the queue
|
||||||
// This prevents audio from continuing in the background and clears stale state
|
// This prevents audio from continuing in the background and clears stale state
|
||||||
@ -164,7 +178,8 @@
|
|||||||
const userId = auth.getUserId();
|
const userId = auth.getUserId();
|
||||||
console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart);
|
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 {
|
try {
|
||||||
const progress = await commands.storageGetPlaybackProgress(userId, id);
|
const progress = await commands.storageGetPlaybackProgress(userId, id);
|
||||||
console.log("Resume check - retrieved progress:", progress);
|
console.log("Resume check - retrieved progress:", progress);
|
||||||
@ -251,6 +266,22 @@
|
|||||||
// Online playback - get playback info from server
|
// Online playback - get playback info from server
|
||||||
isOfflinePlayback = false;
|
isOfflinePlayback = false;
|
||||||
const repo = auth.getRepository();
|
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");
|
console.log("loadAndPlay: Getting playback info");
|
||||||
const playbackInfo = await repo.getPlaybackInfo(id);
|
const playbackInfo = await repo.getPlaybackInfo(id);
|
||||||
console.log("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
|
console.log("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
|
||||||
@ -613,6 +644,7 @@
|
|||||||
mediaSourceId={mediaSourceId ?? undefined}
|
mediaSourceId={mediaSourceId ?? undefined}
|
||||||
initialPosition={videoInitialPosition}
|
initialPosition={videoInitialPosition}
|
||||||
needsTranscoding={videoNeedsTranscoding}
|
needsTranscoding={videoNeedsTranscoding}
|
||||||
|
{isLive}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onSeek={handleVideoSeek}
|
onSeek={handleVideoSeek}
|
||||||
onReportStart={handleReportStart}
|
onReportStart={handleReportStart}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user