feat(library and playback): Support for serverside channel plugins and hls streaming
This commit is contained in:
@@ -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<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
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -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<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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<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> {
|
||||
// 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<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> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -887,6 +914,18 @@ mod tests {
|
||||
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> {
|
||||
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()),
|
||||
|
||||
@@ -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<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
|
||||
///
|
||||
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
||||
|
||||
@@ -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<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_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<i64>>(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<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> {
|
||||
// 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<i64>>(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,
|
||||
|
||||
@@ -363,6 +363,8 @@ struct JellyfinItem {
|
||||
name: String,
|
||||
#[serde(rename = "Type")]
|
||||
item_type: String,
|
||||
#[serde(default)]
|
||||
is_folder: bool,
|
||||
parent_id: Option<String>,
|
||||
overview: Option<String>,
|
||||
genres: Option<Vec<String>>,
|
||||
@@ -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<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(
|
||||
&self,
|
||||
item_id: &str,
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -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<String>,
|
||||
pub live_stream_id: Option<String>,
|
||||
pub media_source_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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;
|
||||
"#;
|
||||
|
||||
Reference in New Issue
Block a user