feat/plugin-channel-support #6
@ -1,4 +1,7 @@
|
||||
# JellyTau
|
||||
<h1 align="center">
|
||||
<img src="docs/assets/logo.png" alt="JellyTau logo" width="120" /><br />
|
||||
JellyTau
|
||||
</h1>
|
||||
|
||||
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
||||
|
||||
|
||||
BIN
docs/assets/logo.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 342 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 998 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 915 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
@ -51,6 +51,22 @@ pub async fn playback_mode_transfer_to_remote(
|
||||
manager.0.transfer_to_remote(session_id, position).await
|
||||
}
|
||||
|
||||
/// Set the transferring flag on the playback mode manager.
|
||||
///
|
||||
/// Used by the frontend remote->local flow to mark the whole two-step sequence
|
||||
/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
|
||||
/// casting back to the remote session it's leaving. Always pair `true` with a
|
||||
/// later `false` (including on error) so the flag can't stick.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn playback_mode_set_transferring(
|
||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||
transferring: bool,
|
||||
) -> Result<(), String> {
|
||||
manager.0.set_transferring(transferring);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transfer playback from remote session back to local device
|
||||
///
|
||||
/// Parameters:
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use tauri::State;
|
||||
|
||||
use super::PlayerStateWrapper;
|
||||
use crate::jellyfin::client::LmsSyncGroup;
|
||||
|
||||
/// Play items on a remote Jellyfin session (casting)
|
||||
#[tauri::command]
|
||||
@ -129,3 +130,92 @@ pub async fn remote_session_toggle_mute(
|
||||
Err("Jellyfin client not configured".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// --- JellyLMS multi-room sync groups (fuse / unfuse LMS zones) --------------
|
||||
//
|
||||
// The frontend addresses LMS players by MAC address, which it derives from a
|
||||
// session's device id (`lms-{mac}`). These commands forward to the JellyLMS
|
||||
// plugin REST API via the configured JellyfinClient.
|
||||
|
||||
/// List current LMS sync groups.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lms_get_sync_groups(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
) -> Result<Vec<LmsSyncGroup>, String> {
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
client.lms_get_sync_groups().await
|
||||
} else {
|
||||
Err("Jellyfin client not configured".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuse LMS zones into a sync group. `master_mac` keeps playing and the
|
||||
/// `slave_macs` zones join it in sync.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lms_create_sync_group(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
master_mac: String,
|
||||
slave_macs: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
client.lms_create_sync_group(&master_mac, slave_macs).await
|
||||
} else {
|
||||
Err("Jellyfin client not configured".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a single LMS zone from its sync group (decouple one player).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lms_unsync_player(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
mac: String,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[LmsSync] Decoupling zone {}", mac);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
client.lms_unsync_player(&mac).await
|
||||
} else {
|
||||
Err("Jellyfin client not configured".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Dissolve an entire LMS sync group, identified by its master's MAC.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lms_dissolve_sync_group(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
master_mac: String,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[LmsSync] Dissolving group with master {}", master_mac);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
client.lms_dissolve_sync_group(&master_mac).await
|
||||
} else {
|
||||
Err("Jellyfin client not configured".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -384,6 +384,82 @@ impl JellyfinClient {
|
||||
let sessions = self.get_sessions().await?;
|
||||
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
|
||||
}
|
||||
|
||||
// --- JellyLMS multi-room sync groups -----------------------------------
|
||||
//
|
||||
// The JellyLMS plugin exposes a REST API under `/JellyLms` for grouping LMS
|
||||
// players ("zones") into synchronized multi-room sync groups. Players are
|
||||
// addressed by MAC address; JellyTau maps a Jellyfin session to a MAC by
|
||||
// stripping the `lms-` prefix off the session's device id (see
|
||||
// LmsDeviceDiscoveryService in the jellyLMS repo, which registers each player
|
||||
// with deviceId = "lms-{MacAddress}").
|
||||
|
||||
/// List current LMS sync groups.
|
||||
pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String> {
|
||||
self.get("/JellyLms/SyncGroups").await
|
||||
}
|
||||
|
||||
/// Fuse LMS zones: create a sync group with `master_mac` as the sync master
|
||||
/// and `slave_macs` joining it. The master keeps playing; slaves follow.
|
||||
pub async fn lms_create_sync_group(
|
||||
&self,
|
||||
master_mac: &str,
|
||||
slave_macs: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let payload = serde_json::json!({
|
||||
"MasterMac": master_mac,
|
||||
"SlaveMacs": slave_macs,
|
||||
});
|
||||
self.post("/JellyLms/SyncGroups", &payload).await
|
||||
}
|
||||
|
||||
/// Remove a single LMS player from whatever sync group it's in.
|
||||
pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
|
||||
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)).await
|
||||
}
|
||||
|
||||
/// Dissolve an entire LMS sync group, identified by its master's MAC.
|
||||
pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
|
||||
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)).await
|
||||
}
|
||||
|
||||
/// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
|
||||
async fn delete(&self, endpoint: &str) -> Result<(), String> {
|
||||
let url = format!("{}{}", self.config.server_url, endpoint);
|
||||
|
||||
log::debug!("[JellyfinClient] DELETE {}", endpoint);
|
||||
|
||||
let response = self.http_client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
|
||||
///
|
||||
/// Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
|
||||
/// follow it in lockstep.
|
||||
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LmsSyncGroup {
|
||||
#[serde(alias = "MasterMac")]
|
||||
pub master_mac: String,
|
||||
#[serde(default, alias = "MasterName")]
|
||||
pub master_name: String,
|
||||
#[serde(default, alias = "SlaveMacs")]
|
||||
pub slave_macs: Vec<String>,
|
||||
#[serde(default, alias = "SlaveNames")]
|
||||
pub slave_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// Default value for supports_remote_control when missing from API
|
||||
|
||||
@ -52,11 +52,13 @@ use commands::{
|
||||
// Remote session control commands
|
||||
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
|
||||
remote_session_toggle_mute,
|
||||
// LMS multi-room sync group commands
|
||||
lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group,
|
||||
// Session polling commands
|
||||
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
|
||||
// Playback mode commands
|
||||
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
|
||||
playback_mode_transfer_to_remote, playback_mode_transfer_to_local,
|
||||
playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring,
|
||||
playback_mode_get_remote_status,
|
||||
// Playback reporting commands
|
||||
playback_reporter_init, playback_reporter_destroy,
|
||||
@ -99,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,
|
||||
@ -417,6 +420,11 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
remote_session_seek,
|
||||
remote_session_set_volume,
|
||||
remote_session_toggle_mute,
|
||||
// LMS multi-room sync group commands
|
||||
lms_get_sync_groups,
|
||||
lms_create_sync_group,
|
||||
lms_unsync_player,
|
||||
lms_dissolve_sync_group,
|
||||
// Session polling commands
|
||||
sessions_set_polling_hint,
|
||||
sessions_poll_now,
|
||||
@ -427,6 +435,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
playback_mode_transfer_to_remote,
|
||||
playback_mode_get_remote_status,
|
||||
playback_mode_transfer_to_local,
|
||||
playback_mode_set_transferring,
|
||||
// Playback reporting commands
|
||||
playback_reporter_init,
|
||||
playback_reporter_destroy,
|
||||
@ -564,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,
|
||||
|
||||
@ -81,6 +81,19 @@ impl PlaybackModeManager {
|
||||
self.is_transferring.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Set the transferring flag directly.
|
||||
///
|
||||
/// The remote->local transfer is driven from the frontend in two steps
|
||||
/// (`player_play_tracks` to start local playback, then
|
||||
/// `playback_mode_transfer_to_local` to stop the remote). The first step's
|
||||
/// routing depends on this flag: while it's set, `player_play_tracks` plays
|
||||
/// locally instead of casting back to the remote session. The frontend must
|
||||
/// raise the flag *before* that first call and lower it when the sequence is
|
||||
/// done (or aborts), so it can't be left stuck on.
|
||||
pub fn set_transferring(&self, transferring: bool) {
|
||||
self.is_transferring.store(transferring, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Send volume command to remote session
|
||||
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
||||
#[allow(dead_code)] // Called from Android JNI callback
|
||||
|
||||
@ -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;
|
||||
"#;
|
||||
|
||||
@ -270,6 +270,31 @@ async remoteSessionSetVolume(sessionId: string, volume: number) : Promise<null>
|
||||
async remoteSessionToggleMute(sessionId: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("remote_session_toggle_mute", { sessionId });
|
||||
},
|
||||
/**
|
||||
* List current LMS sync groups.
|
||||
*/
|
||||
async lmsGetSyncGroups() : Promise<LmsSyncGroup[]> {
|
||||
return await TAURI_INVOKE("lms_get_sync_groups");
|
||||
},
|
||||
/**
|
||||
* Fuse LMS zones into a sync group. `master_mac` keeps playing and the
|
||||
* `slave_macs` zones join it in sync.
|
||||
*/
|
||||
async lmsCreateSyncGroup(masterMac: string, slaveMacs: string[]) : Promise<null> {
|
||||
return await TAURI_INVOKE("lms_create_sync_group", { masterMac, slaveMacs });
|
||||
},
|
||||
/**
|
||||
* Remove a single LMS zone from its sync group (decouple one player).
|
||||
*/
|
||||
async lmsUnsyncPlayer(mac: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("lms_unsync_player", { mac });
|
||||
},
|
||||
/**
|
||||
* Dissolve an entire LMS sync group, identified by its master's MAC.
|
||||
*/
|
||||
async lmsDissolveSyncGroup(masterMac: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("lms_dissolve_sync_group", { masterMac });
|
||||
},
|
||||
/**
|
||||
* Set polling frequency hint based on UI state
|
||||
*/
|
||||
@ -322,6 +347,17 @@ async playbackModeGetRemoteStatus() : Promise<RemoteSessionStatus> {
|
||||
async playbackModeTransferToLocal(currentItemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("playback_mode_transfer_to_local", { currentItemId, positionTicks });
|
||||
},
|
||||
/**
|
||||
* Set the transferring flag on the playback mode manager.
|
||||
*
|
||||
* Used by the frontend remote->local flow to mark the whole two-step sequence
|
||||
* as a transfer, so `player_play_tracks` starts LOCAL playback instead of
|
||||
* casting back to the remote session it's leaving. Always pair `true` with a
|
||||
* later `false` (including on error) so the flag can't stick.
|
||||
*/
|
||||
async playbackModeSetTransferring(transferring: boolean) : Promise<null> {
|
||||
return await TAURI_INVOKE("playback_mode_set_transferring", { transferring });
|
||||
},
|
||||
/**
|
||||
* Initialize playback reporter (called after login)
|
||||
*/
|
||||
@ -1098,6 +1134,24 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId:
|
||||
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
|
||||
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
|
||||
*/
|
||||
@ -1503,10 +1557,30 @@ 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`.
|
||||
*
|
||||
* Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
|
||||
* follow it in lockstep.
|
||||
*/
|
||||
export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?: string[]; slaveNames?: string[] }
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@ -1884,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)
|
||||
*/
|
||||
|
||||
@ -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<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) =====
|
||||
|
||||
/**
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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 @@
|
||||
<h2 class="text-white text-lg font-semibold">{media?.name || "Video"}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
oninput={handleSeekBarInput}
|
||||
onchange={handleSeekBarChange}
|
||||
onmousedown={() => 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"
|
||||
/>
|
||||
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
|
||||
</div>
|
||||
<!-- Progress bar (hidden for live streams - no fixed timeline) -->
|
||||
{#if isLive}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="flex items-center gap-1.5 text-white text-sm font-semibold">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-red-500"></span>
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
oninput={handleSeekBarInput}
|
||||
onchange={handleSeekBarChange}
|
||||
onmousedown={() => 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"
|
||||
/>
|
||||
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Control buttons -->
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
|
||||
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
|
||||
import { playbackPosition } from "$lib/stores/player";
|
||||
import { lmsSync, isLmsSession, macForSession } from "$lib/stores/lmsSync";
|
||||
import type { Session } from "$lib/api/types";
|
||||
|
||||
interface Props {
|
||||
@ -14,6 +15,20 @@
|
||||
|
||||
let { isOpen = false, onClose, onSelectSession }: Props = $props();
|
||||
|
||||
// MACs currently part of any sync group, for rendering toggle state.
|
||||
const groupedMacs = $derived(new Set(($lmsSync.groups ?? []).flatMap(
|
||||
(g) => [g.masterMac, ...(g.slaveMacs ?? [])]
|
||||
)));
|
||||
|
||||
// The sync master is the LMS zone we're currently controlling. Zones can only
|
||||
// be fused once we have a master to fuse them into.
|
||||
const masterMac = $derived(isLmsSession($selectedSession) ? macForSession($selectedSession) : null);
|
||||
|
||||
function isZoneFused(session: Session): boolean {
|
||||
const mac = macForSession(session);
|
||||
return mac !== null && groupedMacs.has(mac);
|
||||
}
|
||||
|
||||
async function handleSessionSelect(session: Session) {
|
||||
try {
|
||||
// Transfer playback to remote session, resuming at our current position
|
||||
@ -31,6 +46,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle an LMS zone in/out of the current master's sync group. If there's no
|
||||
// master yet (we're not controlling an LMS zone), the click falls back to a
|
||||
// normal transfer so the user can start playing on a zone first.
|
||||
async function handleLmsZoneToggle(session: Session) {
|
||||
const zoneMac = macForSession(session);
|
||||
if (!zoneMac) return;
|
||||
|
||||
if (!masterMac) {
|
||||
// No master yet — start playback on this zone; it becomes the master.
|
||||
await handleSessionSelect(session);
|
||||
await lmsSync.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isZoneFused(session)) {
|
||||
await lmsSync.decoupleZone(zoneMac);
|
||||
} else {
|
||||
await lmsSync.fuseZone(masterMac, zoneMac);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle LMS zone:", error);
|
||||
// Error is surfaced via the lmsSync store
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransferToLocal() {
|
||||
try {
|
||||
await playbackMode.transferToLocal();
|
||||
@ -80,6 +121,7 @@
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
sessions.refresh();
|
||||
lmsSync.refresh();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -167,14 +209,24 @@
|
||||
<!-- Available sessions -->
|
||||
{#each $controllableSessions as session (session.id)}
|
||||
{#if session.id !== $selectedSession?.id}
|
||||
{@const lmsZone = isLmsSession(session)}
|
||||
{@const fused = lmsZone && isZoneFused(session)}
|
||||
<button
|
||||
onclick={() => handleSessionSelect(session)}
|
||||
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left"
|
||||
onclick={() => (lmsZone ? handleLmsZoneToggle(session) : handleSessionSelect(session))}
|
||||
disabled={$lmsSync.isBusy}
|
||||
aria-pressed={lmsZone ? fused : undefined}
|
||||
class="w-full p-4 rounded-lg border transition-all text-left disabled:opacity-60
|
||||
{fused
|
||||
? 'border-[var(--color-jellyfin)] bg-[var(--color-jellyfin)]/10'
|
||||
: 'border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5'}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gray-800 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
{#if getSessionIcon(session.client) === "tv"}
|
||||
{#if lmsZone}
|
||||
<!-- speaker icon for LMS zones -->
|
||||
<path d="M12 2a1 1 0 011 1v18a1 1 0 01-1 1H6a2 2 0 01-2-2V4a2 2 0 012-2h6zm0 2H6v16h6V4zm-3 9a2 2 0 110 4 2 2 0 010-4zm0-2a4 4 0 100 8 4 4 0 000-8zm0-4a1 1 0 110 2 1 1 0 010-2zm9 0h2v2h-2V7zm0 4h2v2h-2v-2z" />
|
||||
{:else if getSessionIcon(session.client) === "tv"}
|
||||
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
|
||||
{:else if getSessionIcon(session.client) === "web"}
|
||||
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z" />
|
||||
@ -187,14 +239,33 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-white truncate">{session.deviceName}</h3>
|
||||
<p class="text-sm text-gray-400 truncate">{session.client}</p>
|
||||
<p class="text-sm text-gray-400 truncate">
|
||||
{#if lmsZone}
|
||||
{fused ? "In sync group — tap to remove" : masterMac ? "Tap to add to group" : "Speaker zone"}
|
||||
{:else}
|
||||
{session.client}
|
||||
{/if}
|
||||
</p>
|
||||
{#if session.nowPlayingItem}
|
||||
<p class="text-xs text-gray-500 truncate mt-1">
|
||||
Playing: {session.nowPlayingItem.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if session.playState}
|
||||
{#if lmsZone}
|
||||
<!-- Fuse toggle indicator -->
|
||||
<div class="flex-shrink-0">
|
||||
{#if fused}
|
||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke-width="2" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if session.playState}
|
||||
<div class="flex-shrink-0">
|
||||
{#if session.playState.isPaused}
|
||||
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
@ -295,6 +366,27 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- LMS sync error -->
|
||||
{#if $lmsSync.error}
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
||||
</svg>
|
||||
<span class="text-sm">{$lmsSync.error}</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => lmsSync.clearError()}
|
||||
class="text-white hover:text-gray-200 transition-colors"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if $transferError}
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl">
|
||||
|
||||
@ -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,
|
||||
|
||||
91
src/lib/stores/lmsSync.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import type { Session } from "$lib/api/types";
|
||||
|
||||
const mockInvoke = vi.fn();
|
||||
vi.mock("$lib/api/bindings", () => ({
|
||||
commands: {
|
||||
lmsGetSyncGroups: () => mockInvoke("lms_get_sync_groups"),
|
||||
lmsCreateSyncGroup: (masterMac: string, slaveMacs: string[]) =>
|
||||
mockInvoke("lms_create_sync_group", { masterMac, slaveMacs }),
|
||||
lmsUnsyncPlayer: (mac: string) => mockInvoke("lms_unsync_player", { mac }),
|
||||
lmsDissolveSyncGroup: (masterMac: string) =>
|
||||
mockInvoke("lms_dissolve_sync_group", { masterMac }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { lmsSync, isLmsSession, macForSession } from "./lmsSync";
|
||||
|
||||
function session(deviceId: string | null): Session {
|
||||
return { id: "s", deviceId } as unknown as Session;
|
||||
}
|
||||
|
||||
describe("LMS session detection", () => {
|
||||
it("detects LMS zones by the lms- device-id prefix", () => {
|
||||
expect(isLmsSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe(true);
|
||||
expect(isLmsSession(session("chromecast-123"))).toBe(false);
|
||||
expect(isLmsSession(session(null))).toBe(false);
|
||||
expect(isLmsSession(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("recovers the MAC from an LMS session, null otherwise", () => {
|
||||
expect(macForSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe("aa:bb:cc:dd:ee:ff");
|
||||
expect(macForSession(session("web-xyz"))).toBeNull();
|
||||
expect(macForSession(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lmsSync store", () => {
|
||||
beforeEach(() => {
|
||||
mockInvoke.mockReset();
|
||||
lmsSync.reset();
|
||||
});
|
||||
|
||||
it("fusing preserves existing slaves and adds the new zone", async () => {
|
||||
// Initial group: master M with slave A.
|
||||
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
|
||||
await lmsSync.refresh();
|
||||
|
||||
// create returns void; refresh after returns the updated group.
|
||||
mockInvoke.mockResolvedValueOnce(undefined);
|
||||
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A", "B"], slaveNames: [] }]);
|
||||
|
||||
await lmsSync.fuseZone("M", "B");
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("lms_create_sync_group", {
|
||||
masterMac: "M",
|
||||
slaveMacs: ["A", "B"],
|
||||
});
|
||||
expect(get(lmsSync).groups[0].slaveMacs).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("decoupling a slave unsyncs just that player", async () => {
|
||||
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
|
||||
await lmsSync.refresh();
|
||||
|
||||
mockInvoke.mockResolvedValueOnce(undefined); // unsync
|
||||
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] }]);
|
||||
|
||||
await lmsSync.decoupleZone("A");
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("lms_unsync_player", { mac: "A" });
|
||||
});
|
||||
|
||||
it("decoupling the master dissolves the whole group", async () => {
|
||||
mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
|
||||
await lmsSync.refresh();
|
||||
|
||||
mockInvoke.mockResolvedValueOnce(undefined); // dissolve
|
||||
mockInvoke.mockResolvedValueOnce([]);
|
||||
|
||||
await lmsSync.decoupleZone("M");
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("lms_dissolve_sync_group", { masterMac: "M" });
|
||||
});
|
||||
|
||||
it("treats a missing plugin (refresh error) as no groups, not fatal", async () => {
|
||||
mockInvoke.mockRejectedValueOnce(new Error("404"));
|
||||
await lmsSync.refresh();
|
||||
expect(get(lmsSync).groups).toEqual([]);
|
||||
});
|
||||
});
|
||||
133
src/lib/stores/lmsSync.ts
Normal file
@ -0,0 +1,133 @@
|
||||
/**
|
||||
* LMS multi-room sync ("fuse zones") store.
|
||||
*
|
||||
* The JellyLMS plugin registers each LMS player as a Jellyfin session whose
|
||||
* device id is `lms-{MacAddress}`. We use that prefix both to detect which cast
|
||||
* targets are fuseable LMS zones and to recover the MAC the SyncGroups API needs.
|
||||
*
|
||||
* Fusing model (per product decision): the currently-controlled zone is the sync
|
||||
* master; other selected zones join it. Toggling an already-grouped zone off
|
||||
* decouples just that zone; toggling the master off dissolves the group.
|
||||
*
|
||||
* TRACES: UR-010 | JA-021, JA-025 | DR-037
|
||||
*/
|
||||
|
||||
import { writable, get } from "svelte/store";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { Session } from "$lib/api/types";
|
||||
import type { LmsSyncGroup } from "$lib/api/bindings";
|
||||
|
||||
const LMS_DEVICE_PREFIX = "lms-";
|
||||
|
||||
/** Is this cast target an LMS zone (and therefore fuseable)? */
|
||||
export function isLmsSession(session: Session | null | undefined): boolean {
|
||||
return !!session?.deviceId?.startsWith(LMS_DEVICE_PREFIX);
|
||||
}
|
||||
|
||||
/** Recover the LMS player MAC from a session, or null if it isn't an LMS zone. */
|
||||
export function macForSession(session: Session | null | undefined): string | null {
|
||||
const deviceId = session?.deviceId;
|
||||
if (!deviceId?.startsWith(LMS_DEVICE_PREFIX)) return null;
|
||||
return deviceId.slice(LMS_DEVICE_PREFIX.length);
|
||||
}
|
||||
|
||||
interface LmsSyncState {
|
||||
groups: LmsSyncGroup[];
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function createLmsSyncStore() {
|
||||
const { subscribe, update, set } = writable<LmsSyncState>({
|
||||
groups: [],
|
||||
isBusy: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
/** Refresh the list of current sync groups from the server. */
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
const groups = await commands.lmsGetSyncGroups();
|
||||
update((s) => ({ ...s, groups, error: null }));
|
||||
} catch (error) {
|
||||
// The plugin may not be installed; treat as "no groups" rather than fatal.
|
||||
console.warn("[LmsSync] Failed to load sync groups:", error);
|
||||
update((s) => ({ ...s, groups: [] }));
|
||||
}
|
||||
}
|
||||
|
||||
/** All MACs that are part of any sync group (master or slave). */
|
||||
function groupedMacs(): Set<string> {
|
||||
const macs = new Set<string>();
|
||||
for (const g of get({ subscribe }).groups) {
|
||||
macs.add(g.masterMac);
|
||||
for (const slave of g.slaveMacs ?? []) macs.add(slave);
|
||||
}
|
||||
return macs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an LMS zone to the master's sync group (fuse it in).
|
||||
* Preserves any zones already grouped with the master.
|
||||
*/
|
||||
async function fuseZone(masterMac: string, zoneMac: string): Promise<void> {
|
||||
if (masterMac === zoneMac) return;
|
||||
update((s) => ({ ...s, isBusy: true, error: null }));
|
||||
try {
|
||||
const existing = get({ subscribe }).groups.find((g) => g.masterMac === masterMac);
|
||||
const slaves = new Set(existing?.slaveMacs ?? []);
|
||||
slaves.add(zoneMac);
|
||||
await commands.lmsCreateSyncGroup(masterMac, [...slaves]);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fuse zone";
|
||||
update((s) => ({ ...s, error: message }));
|
||||
throw error;
|
||||
} finally {
|
||||
update((s) => ({ ...s, isBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single zone from its group. If the zone is a group's master, the
|
||||
* whole group is dissolved (a group can't outlive its master).
|
||||
*/
|
||||
async function decoupleZone(zoneMac: string): Promise<void> {
|
||||
update((s) => ({ ...s, isBusy: true, error: null }));
|
||||
try {
|
||||
const asMaster = get({ subscribe }).groups.find((g) => g.masterMac === zoneMac);
|
||||
if (asMaster) {
|
||||
await commands.lmsDissolveSyncGroup(zoneMac);
|
||||
} else {
|
||||
await commands.lmsUnsyncPlayer(zoneMac);
|
||||
}
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to decouple zone";
|
||||
update((s) => ({ ...s, error: message }));
|
||||
throw error;
|
||||
} finally {
|
||||
update((s) => ({ ...s, isBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function clearError(): void {
|
||||
update((s) => ({ ...s, error: null }));
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
set({ groups: [], isBusy: false, error: null });
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
refresh,
|
||||
groupedMacs,
|
||||
fuseZone,
|
||||
decoupleZone,
|
||||
clearError,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const lmsSync = createLmsSyncStore();
|
||||
@ -198,6 +198,12 @@ function createPlaybackModeStore() {
|
||||
// Get repository for handle (backend will fetch playback info via player_play_tracks)
|
||||
const repository = auth.getRepository();
|
||||
|
||||
// Mark the whole sequence as a transfer in the *Rust* manager too. Without
|
||||
// this, player_play_tracks sees mode=Remote and casts the track back to the
|
||||
// remote session instead of playing it locally (the frontend's own
|
||||
// isTransferring flag is invisible to Rust). Cleared in `finally`.
|
||||
await commands.playbackModeSetTransferring(true);
|
||||
|
||||
// Start local playback (events allowed through because isTransferring=true)
|
||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||
const repositoryHandle = repository.getHandle();
|
||||
@ -248,6 +254,14 @@ function createPlaybackModeStore() {
|
||||
console.error("Transfer to local failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
// Always lower the Rust transferring flag so it can't stick on if any step
|
||||
// above threw (transfer_to_local lowers it on success, but not if we never
|
||||
// reached it). Safe to call unconditionally.
|
||||
try {
|
||||
await commands.playbackModeSetTransferring(false);
|
||||
} catch (e) {
|
||||
console.warn("[PlaybackMode] Failed to clear transferring flag:", e);
|
||||
}
|
||||
currentTransferAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -57,6 +57,7 @@
|
||||
let streamUrl = $state<string | null>(null);
|
||||
let mediaSourceId = $state<string | null>(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}
|
||||
|
||||