Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
This commit is contained in:
@@ -10,6 +10,7 @@ pub mod offline;
|
||||
pub mod playback_mode;
|
||||
pub mod playback_reporting;
|
||||
pub mod player;
|
||||
pub mod playlist;
|
||||
pub mod repository;
|
||||
pub mod sessions;
|
||||
pub mod storage;
|
||||
@@ -25,6 +26,7 @@ pub use playback_mode::*;
|
||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||
pub use playback_reporting::*;
|
||||
pub use player::*;
|
||||
pub use playlist::*;
|
||||
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
||||
pub use sessions::*;
|
||||
pub use storage::*;
|
||||
|
||||
@@ -2396,11 +2396,15 @@ pub async fn player_play_next_episode(
|
||||
|
||||
/// Handle playback ended event - triggers autoplay decision logic
|
||||
/// This is called from:
|
||||
/// - Frontend when HTML5 video ends (Linux/desktop)
|
||||
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
||||
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||
/// - Android JNI callback also triggers this logic directly
|
||||
#[tauri::command]
|
||||
pub async fn player_on_playback_ended(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
item_id: Option<String>,
|
||||
repository_handle: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use crate::player::autoplay::AutoplayDecision;
|
||||
use crate::player::PlayerStatusEvent;
|
||||
@@ -2408,9 +2412,24 @@ pub async fn player_on_playback_ended(
|
||||
let controller_arc = player.0.clone();
|
||||
|
||||
// Run autoplay decision logic
|
||||
// If item_id is provided (HTML5 video case), use the video-specific path
|
||||
// that bypasses the backend queue and stale end_reason
|
||||
let decision = {
|
||||
let controller = controller_arc.lock().await;
|
||||
controller.on_playback_ended().await?
|
||||
if let Some(ref id) = item_id {
|
||||
// Video path: need repository to look up episode info
|
||||
let repo = repository_handle
|
||||
.as_ref()
|
||||
.and_then(|handle| repository_manager.0.get(handle));
|
||||
if let Some(repo) = repo {
|
||||
controller.on_video_playback_ended(id, repo).await?
|
||||
} else {
|
||||
log::warn!("[Autoplay] No repository available for video autoplay (itemId: {})", id);
|
||||
AutoplayDecision::Stop
|
||||
}
|
||||
} else {
|
||||
controller.on_playback_ended().await?
|
||||
}
|
||||
};
|
||||
|
||||
// Handle the decision
|
||||
@@ -2420,12 +2439,12 @@ pub async fn player_on_playback_ended(
|
||||
let controller = controller_arc.lock().await;
|
||||
if let Some(emitter) = controller.event_emitter() {
|
||||
// Emit StateChanged to idle to clear the current media from mini player
|
||||
// Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop
|
||||
// (frontend receives PlaybackEnded → calls player_on_playback_ended → Stop → PlaybackEnded → ...)
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: "idle".to_string(),
|
||||
media_id: None,
|
||||
});
|
||||
// Also emit PlaybackEnded event
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
AutoplayDecision::AdvanceToNext => {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Tauri commands for playlist management
|
||||
//! Uses handle-based system: UUID -> Arc<HybridRepository>
|
||||
//!
|
||||
//! TRACES: UR-014 | JA-019, JA-020
|
||||
|
||||
use log::debug;
|
||||
use tauri::State;
|
||||
|
||||
use crate::repository::{MediaRepository, types::*};
|
||||
use super::repository::RepositoryManagerWrapper;
|
||||
|
||||
/// Create a new playlist
|
||||
#[tauri::command]
|
||||
pub async fn playlist_create(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
name: String,
|
||||
item_ids: Option<Vec<String>>,
|
||||
) -> Result<PlaylistCreatedResult, String> {
|
||||
debug!("[PLAYLIST] create called: name={}", name);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
let ids = item_ids.unwrap_or_default();
|
||||
repo.as_ref().create_playlist(&name, &ids)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Delete a playlist
|
||||
#[tauri::command]
|
||||
pub async fn playlist_delete(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
playlist_id: String,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] delete called: id={}", playlist_id);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().delete_playlist(&playlist_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Rename a playlist
|
||||
#[tauri::command]
|
||||
pub async fn playlist_rename(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
playlist_id: String,
|
||||
name: String,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] rename called: id={}, name={}", playlist_id, name);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().rename_playlist(&playlist_id, &name)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get playlist items with PlaylistItemId
|
||||
#[tauri::command]
|
||||
pub async fn playlist_get_items(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
playlist_id: String,
|
||||
) -> Result<Vec<PlaylistEntry>, String> {
|
||||
debug!("[PLAYLIST] get_items called: id={}", playlist_id);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_playlist_items(&playlist_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Add items to a playlist
|
||||
#[tauri::command]
|
||||
pub async fn playlist_add_items(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
playlist_id: String,
|
||||
item_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] add_items called: id={}, count={}", playlist_id, item_ids.len());
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().add_to_playlist(&playlist_id, &item_ids)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
|
||||
#[tauri::command]
|
||||
pub async fn playlist_remove_items(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
playlist_id: String,
|
||||
entry_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] remove_items called: id={}, count={}", playlist_id, entry_ids.len());
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().remove_from_playlist(&playlist_id, &entry_ids)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Move a playlist item to a new position
|
||||
#[tauri::command]
|
||||
pub async fn playlist_move_item(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
playlist_id: String,
|
||||
item_id: String,
|
||||
new_index: u32,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] move_item called: playlist={}, item={}, index={}", playlist_id, item_id, new_index);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().move_playlist_item(&playlist_id, &item_id, new_index)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -224,20 +224,20 @@ impl JellyfinClient {
|
||||
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
|
||||
session_id, item_ids.len(), start_index);
|
||||
|
||||
// Build URL with query parameters (Jellyfin expects query params, not JSON body!)
|
||||
// Build URL with query parameters (Jellyfin expects PascalCase query params)
|
||||
let mut url = format!(
|
||||
"{}/Sessions/{}/Playing?playCommand=PlayNow&startIndex={}",
|
||||
"{}/Sessions/{}/Playing?PlayCommand=PlayNow&StartIndex={}",
|
||||
self.config.server_url, session_id, start_index
|
||||
);
|
||||
|
||||
// Add item IDs as repeated query parameters
|
||||
for item_id in &item_ids {
|
||||
url.push_str(&format!("&itemIds={}", item_id));
|
||||
url.push_str(&format!("&ItemIds={}", item_id));
|
||||
}
|
||||
|
||||
// Add start position if provided
|
||||
if let Some(ticks) = start_position_ticks {
|
||||
url.push_str(&format!("&startPositionTicks={}", ticks));
|
||||
url.push_str(&format!("&StartPositionTicks={}", ticks));
|
||||
log::info!("[JellyfinClient] Starting at position: {} ticks", ticks);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,9 @@ use commands::{
|
||||
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,
|
||||
// Playlist commands
|
||||
playlist_create, playlist_delete, playlist_rename, playlist_get_items,
|
||||
playlist_add_items, playlist_remove_items, playlist_move_item,
|
||||
// Conversion commands
|
||||
format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds,
|
||||
calc_progress, convert_percent_to_volume,
|
||||
@@ -769,6 +772,14 @@ pub fn run() {
|
||||
repository_get_person,
|
||||
repository_get_items_by_person,
|
||||
repository_get_similar_items,
|
||||
// Playlist commands
|
||||
playlist_create,
|
||||
playlist_delete,
|
||||
playlist_rename,
|
||||
playlist_get_items,
|
||||
playlist_add_items,
|
||||
playlist_remove_items,
|
||||
playlist_move_item,
|
||||
// Conversion commands
|
||||
format_time_seconds,
|
||||
format_time_seconds_long,
|
||||
|
||||
+135
-24
@@ -770,8 +770,18 @@ impl PlayerController {
|
||||
}
|
||||
|
||||
// For video episodes, fetch next episode and show popup
|
||||
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
|
||||
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
|
||||
if current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
||||
if let Some(next_ep) = self.fetch_next_episode_for_item(¤t).await? {
|
||||
let repo = self.repository.lock().unwrap().clone();
|
||||
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
|
||||
let next_ep_result = if let Some(repo) = &repo {
|
||||
self.fetch_next_episode_for_item(jellyfin_id, repo).await?
|
||||
} else {
|
||||
debug!("[PlayerController] No repository available for audio-path episode lookup");
|
||||
None
|
||||
};
|
||||
if let Some(next_ep) = next_ep_result {
|
||||
let settings = self.autoplay_settings.lock().unwrap().clone();
|
||||
|
||||
// Check if auto-play episode limit is reached
|
||||
@@ -806,6 +816,78 @@ impl PlayerController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle video playback ended from HTML5 video element.
|
||||
///
|
||||
/// HTML5 video plays independently of the Rust backend, so the backend
|
||||
/// queue has no knowledge of the video item. This method bypasses the
|
||||
/// queue lookup and end_reason check, using the provided Jellyfin item ID
|
||||
/// to look up the item and check for next episodes.
|
||||
pub async fn on_video_playback_ended(
|
||||
&self,
|
||||
item_id: &str,
|
||||
repo: Arc<dyn crate::repository::MediaRepository>,
|
||||
) -> Result<AutoplayDecision, String> {
|
||||
// Clear any stale end_reason (e.g., UserStop from stopping audio before video)
|
||||
let stale_reason = self.take_end_reason();
|
||||
if stale_reason.is_some() {
|
||||
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
|
||||
}
|
||||
|
||||
debug!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
|
||||
|
||||
// Check sleep timer state
|
||||
let timer_mode = {
|
||||
let timer = self.sleep_timer.lock().unwrap();
|
||||
timer.mode.clone()
|
||||
};
|
||||
|
||||
match &timer_mode {
|
||||
SleepTimerMode::Time { end_time } => {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
if now >= *end_time {
|
||||
debug!("[PlayerController] Time-based sleep timer expired at video end");
|
||||
self.sleep_timer.lock().unwrap().cancel();
|
||||
self.emit_sleep_timer_changed();
|
||||
return Ok(AutoplayDecision::Stop);
|
||||
}
|
||||
}
|
||||
SleepTimerMode::EndOfTrack => {
|
||||
self.sleep_timer.lock().unwrap().cancel();
|
||||
self.emit_sleep_timer_changed();
|
||||
return Ok(AutoplayDecision::Stop);
|
||||
}
|
||||
SleepTimerMode::Episodes { .. } => {
|
||||
let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
|
||||
self.emit_sleep_timer_changed();
|
||||
if should_stop {
|
||||
return Ok(AutoplayDecision::Stop);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Fetch next episode for the video that just ended
|
||||
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? {
|
||||
let settings = self.autoplay_settings.lock().unwrap().clone();
|
||||
|
||||
let limit_reached = self.increment_autoplay_count();
|
||||
if limit_reached {
|
||||
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
||||
}
|
||||
|
||||
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
current_episode: next_ep.0,
|
||||
next_episode: next_ep.1,
|
||||
countdown_seconds: settings.countdown_seconds,
|
||||
auto_advance: settings.enabled && !limit_reached,
|
||||
});
|
||||
}
|
||||
|
||||
// No next episode found
|
||||
debug!("[PlayerController] No next episode found for {}", item_id);
|
||||
Ok(AutoplayDecision::Stop)
|
||||
}
|
||||
|
||||
/// Check if a media item is an episode (has Jellyfin ID to query)
|
||||
async fn is_episode_item(&self, item: &MediaItem) -> bool {
|
||||
// For now, assume video items are episodes
|
||||
@@ -813,34 +895,63 @@ impl PlayerController {
|
||||
item.media_type == MediaType::Video
|
||||
}
|
||||
|
||||
/// Fetch next episode for a series (using Repository)
|
||||
async fn fetch_next_episode_for_item(&self, current: &MediaItem) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
|
||||
let repo = self.repository.lock().unwrap().clone();
|
||||
let Some(repo) = repo else {
|
||||
return Ok(None);
|
||||
};
|
||||
/// Fetch next episode for a series by looking up the season's episodes
|
||||
/// sorted by index number and picking the one after the current episode.
|
||||
///
|
||||
/// This is deterministic and doesn't depend on Jellyfin's "Next Up" API
|
||||
/// (which relies on watch history that may not be updated yet due to
|
||||
/// the async nature of playback progress reporting).
|
||||
async fn fetch_next_episode_for_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
repo: &Arc<dyn crate::repository::MediaRepository>,
|
||||
) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
|
||||
use crate::repository::types::GetItemsOptions;
|
||||
|
||||
let jellyfin_id = current.jellyfin_id()
|
||||
.ok_or_else(|| "No Jellyfin ID for current item".to_string())?;
|
||||
|
||||
// First, get the current item details from repository
|
||||
let current_repo_item = repo.get_item(jellyfin_id)
|
||||
// Get the current item details from repository
|
||||
let current_repo_item = repo.get_item(item_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get current item: {}", e))?;
|
||||
|
||||
let series_id = current_repo_item.series_id.clone()
|
||||
.ok_or_else(|| "Current item is not an episode".to_string())?;
|
||||
|
||||
// Fetch next up episodes for this series
|
||||
let next_episodes = repo.get_next_up_episodes(Some(&series_id), Some(1))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch next episodes: {}", e))?;
|
||||
|
||||
if let Some(next) = next_episodes.first() {
|
||||
// Verify it's not the same episode
|
||||
if next.id != current_repo_item.id {
|
||||
return Ok(Some((current_repo_item, next.clone())));
|
||||
// Need season_id to fetch sibling episodes
|
||||
let season_id = match ¤t_repo_item.season_id {
|
||||
Some(sid) => sid.clone(),
|
||||
None => {
|
||||
debug!("[PlayerController] Current item has no season_id, cannot find next episode");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch all episodes in the season sorted by episode number
|
||||
let options = GetItemsOptions {
|
||||
sort_by: Some("IndexNumber".to_string()),
|
||||
sort_order: Some("Ascending".to_string()),
|
||||
limit: Some(500),
|
||||
include_item_types: Some(vec!["Episode".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = repo.get_items(&season_id, Some(options))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
|
||||
|
||||
// Sort client-side by index_number to ensure correct ordering
|
||||
// (offline repo ignores sort_by and sorts by sort_name instead)
|
||||
let mut episodes = result.items;
|
||||
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
|
||||
debug!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
|
||||
|
||||
// Find the current episode by ID and return the next one
|
||||
if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
|
||||
if current_idx + 1 < episodes.len() {
|
||||
let next = &episodes[current_idx + 1];
|
||||
debug!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
|
||||
return Ok(Some((current_repo_item, next.clone())));
|
||||
} else {
|
||||
debug!("[PlayerController] Current episode is the last in the season");
|
||||
}
|
||||
} else {
|
||||
debug!("[PlayerController] Current episode not found in season episodes");
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
|
||||
@@ -414,6 +414,107 @@ impl MediaRepository for HybridRepository {
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
// ===== Playlist Methods =====
|
||||
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
name: &str,
|
||||
item_ids: &[String],
|
||||
) -> Result<PlaylistCreatedResult, RepoError> {
|
||||
// Write operation - delegate directly to server
|
||||
self.online.create_playlist(name, item_ids).await
|
||||
}
|
||||
|
||||
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
||||
// Write operation - delegate directly to server
|
||||
self.online.delete_playlist(playlist_id).await
|
||||
}
|
||||
|
||||
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
|
||||
// Write operation - delegate directly to server
|
||||
self.online.rename_playlist(playlist_id, name).await
|
||||
}
|
||||
|
||||
async fn get_playlist_items(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let offline_for_save = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let playlist_id = playlist_id.to_string();
|
||||
let playlist_id_clone = playlist_id.clone();
|
||||
let playlist_id_for_save = playlist_id.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_playlist_items(&playlist_id).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_playlist_items(&playlist_id_clone).await
|
||||
};
|
||||
|
||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
||||
|
||||
let cache_had_content = cache_result.as_ref()
|
||||
.map(|data| data.has_content())
|
||||
.unwrap_or(false);
|
||||
|
||||
if cache_had_content {
|
||||
// If server also succeeded, update cache in background
|
||||
if let Ok(server_entries) = server_result {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &server_entries).await {
|
||||
warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
return cache_result;
|
||||
}
|
||||
|
||||
// Cache miss - use server result
|
||||
match server_result {
|
||||
Ok(entries) => {
|
||||
let entries_clone = entries.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone).await {
|
||||
warn!("[HybridRepo] Failed to save playlist items to cache: {:?}", e);
|
||||
}
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
Err(e) => cache_result.or(Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_to_playlist(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
item_ids: &[String],
|
||||
) -> Result<(), RepoError> {
|
||||
// Write operation - delegate directly to server
|
||||
self.online.add_to_playlist(playlist_id, item_ids).await
|
||||
}
|
||||
|
||||
async fn remove_from_playlist(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
entry_ids: &[String],
|
||||
) -> Result<(), RepoError> {
|
||||
// Write operation - delegate directly to server
|
||||
self.online.remove_from_playlist(playlist_id, entry_ids).await
|
||||
}
|
||||
|
||||
async fn move_playlist_item(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
item_id: &str,
|
||||
new_index: u32,
|
||||
) -> Result<(), RepoError> {
|
||||
// Write operation - delegate directly to server
|
||||
self.online.move_playlist_item(playlist_id, item_id, new_index).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -562,6 +663,34 @@ mod tests {
|
||||
async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn create_playlist(&self, _name: &str, _item_ids: &[String]) -> Result<PlaylistCreatedResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_playlist_items(&self, _playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn add_to_playlist(&self, _playlist_id: &str, _item_ids: &[String]) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn remove_from_playlist(&self, _playlist_id: &str, _entry_ids: &[String]) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_playlist_item(&self, _playlist_id: &str, _item_id: &str, _new_index: u32) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock online repository that returns predefined items
|
||||
@@ -691,6 +820,34 @@ mod tests {
|
||||
async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn create_playlist(&self, _name: &str, _item_ids: &[String]) -> Result<PlaylistCreatedResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_playlist_items(&self, _playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn add_to_playlist(&self, _playlist_id: &str, _item_ids: &[String]) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn remove_from_playlist(&self, _playlist_id: &str, _entry_ids: &[String]) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_playlist_item(&self, _playlist_id: &str, _item_id: &str, _new_index: u32) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_item(id: &str, name: &str) -> MediaItem {
|
||||
|
||||
@@ -191,4 +191,68 @@ pub trait MediaRepository: Send + Sync {
|
||||
item_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
|
||||
// ===== Playlist Methods =====
|
||||
|
||||
/// Create a new playlist on the server
|
||||
///
|
||||
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||
/// @req: JA-019 - Get/create/update playlists
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
name: &str,
|
||||
item_ids: &[String],
|
||||
) -> Result<PlaylistCreatedResult, RepoError>;
|
||||
|
||||
/// Delete a playlist
|
||||
///
|
||||
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||
/// @req: JA-019 - Get/create/update playlists
|
||||
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Rename a playlist
|
||||
///
|
||||
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||
/// @req: JA-019 - Get/create/update playlists
|
||||
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Get playlist items with PlaylistItemId (needed for remove/reorder)
|
||||
///
|
||||
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||
/// @req: JA-019 - Get/create/update playlists
|
||||
async fn get_playlist_items(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
) -> Result<Vec<PlaylistEntry>, RepoError>;
|
||||
|
||||
/// Add items to a playlist
|
||||
///
|
||||
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||
/// @req: JA-020 - Add/remove items from playlist
|
||||
async fn add_to_playlist(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
item_ids: &[String],
|
||||
) -> Result<(), RepoError>;
|
||||
|
||||
/// Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs)
|
||||
///
|
||||
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||
/// @req: JA-020 - Add/remove items from playlist
|
||||
async fn remove_from_playlist(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
entry_ids: &[String],
|
||||
) -> Result<(), RepoError>;
|
||||
|
||||
/// Move a playlist item to a new position
|
||||
///
|
||||
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||
/// @req: JA-020 - Add/remove items from playlist
|
||||
async fn move_playlist_item(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
item_id: &str,
|
||||
new_index: u32,
|
||||
) -> Result<(), RepoError>;
|
||||
}
|
||||
|
||||
@@ -346,6 +346,56 @@ impl OfflineRepository {
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Cache playlist items from server into local database
|
||||
/// Called by HybridRepository after fetching from online
|
||||
pub async fn save_playlist_items_to_cache(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
entries: &[PlaylistEntry],
|
||||
) -> Result<(), RepoError> {
|
||||
let playlist_id = playlist_id.to_string();
|
||||
let user_id = self.user_id.clone();
|
||||
let entries: Vec<(String, String, usize)> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
|
||||
.collect();
|
||||
|
||||
self.db_service
|
||||
.transaction(move |tx| {
|
||||
use crate::storage::db_service::{Query, QueryParam};
|
||||
|
||||
// Ensure playlist record exists
|
||||
tx.execute(Query::with_params(
|
||||
"INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
|
||||
vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
|
||||
))?;
|
||||
|
||||
// Clear existing entries and re-insert
|
||||
tx.execute(Query::with_params(
|
||||
"DELETE FROM playlist_items WHERE playlist_id = ?",
|
||||
vec![QueryParam::String(playlist_id.clone())],
|
||||
))?;
|
||||
|
||||
for (_, item_id, sort_order) in &entries {
|
||||
tx.execute(Query::with_params(
|
||||
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
|
||||
vec![
|
||||
QueryParam::String(playlist_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::Int(*sort_order as i32),
|
||||
],
|
||||
))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database {
|
||||
message: format!("Failed to cache playlist items: {}", e),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -1088,6 +1138,254 @@ impl MediaRepository for OfflineRepository {
|
||||
// Similar items require server-side computation and are not available offline
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
// ===== Playlist Methods =====
|
||||
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
name: &str,
|
||||
item_ids: &[String],
|
||||
) -> Result<PlaylistCreatedResult, RepoError> {
|
||||
let playlist_id = uuid::Uuid::new_v4().to_string();
|
||||
let user_id = self.user_id.clone();
|
||||
let name = name.to_string();
|
||||
let item_ids = item_ids.to_vec();
|
||||
let pid = playlist_id.clone();
|
||||
|
||||
self.db_service
|
||||
.transaction(move |tx| {
|
||||
use crate::storage::db_service::{Query, QueryParam};
|
||||
|
||||
tx.execute(Query::with_params(
|
||||
"INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
|
||||
vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
|
||||
))?;
|
||||
|
||||
for (i, item_id) in item_ids.iter().enumerate() {
|
||||
tx.execute(Query::with_params(
|
||||
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
|
||||
vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
|
||||
))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database {
|
||||
message: format!("Failed to create playlist: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(PlaylistCreatedResult { id: playlist_id })
|
||||
}
|
||||
|
||||
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
||||
let query = Query::with_params(
|
||||
"DELETE FROM playlists WHERE id = ?",
|
||||
vec![QueryParam::String(playlist_id.to_string())],
|
||||
);
|
||||
self.db_service.execute(query).await.map_err(|e| RepoError::Database {
|
||||
message: format!("Failed to delete playlist: {}", e),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
|
||||
let query = Query::with_params(
|
||||
"UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
vec![
|
||||
QueryParam::String(name.to_string()),
|
||||
QueryParam::String(playlist_id.to_string()),
|
||||
],
|
||||
);
|
||||
self.db_service.execute(query).await.map_err(|e| RepoError::Database {
|
||||
message: format!("Failed to rename playlist: {}", e),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_playlist_items(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||
let query = Query::with_params(
|
||||
"SELECT pi.id, \
|
||||
i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
|
||||
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 \
|
||||
FROM playlist_items pi \
|
||||
JOIN items i ON pi.item_id = i.id \
|
||||
WHERE pi.playlist_id = ? \
|
||||
ORDER BY pi.sort_order ASC",
|
||||
vec![QueryParam::String(playlist_id.to_string())],
|
||||
);
|
||||
|
||||
let items = self.db_service
|
||||
.query_many(query, |row| {
|
||||
let entry_id: i64 = row.get(0)?;
|
||||
// Columns offset by 1 because first column is pi.id
|
||||
let cached = CachedItem {
|
||||
id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
item_type: row.get(3)?,
|
||||
server_id: row.get(4)?,
|
||||
parent_id: row.get(5)?,
|
||||
library_id: row.get(6)?,
|
||||
overview: row.get(7)?,
|
||||
genres: row.get(8)?,
|
||||
runtime_ticks: row.get(9)?,
|
||||
production_year: row.get(10)?,
|
||||
community_rating: row.get(11)?,
|
||||
official_rating: row.get(12)?,
|
||||
primary_image_tag: row.get(13)?,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: row.get(14)?,
|
||||
album_name: row.get(15)?,
|
||||
album_artist: row.get(16)?,
|
||||
artists: row.get(17)?,
|
||||
index_number: row.get(18)?,
|
||||
series_id: row.get(19)?,
|
||||
series_name: row.get(20)?,
|
||||
season_id: row.get(21)?,
|
||||
season_name: row.get(22)?,
|
||||
parent_index_number: row.get(23)?,
|
||||
};
|
||||
Ok((entry_id.to_string(), cached))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database {
|
||||
message: format!("Failed to get playlist items: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.map(|(entry_id, cached)| PlaylistEntry {
|
||||
playlist_item_id: entry_id,
|
||||
item: Self::cached_item_to_media_item(cached, None),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn add_to_playlist(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
item_ids: &[String],
|
||||
) -> Result<(), RepoError> {
|
||||
// Get current max sort_order
|
||||
let max_query = Query::with_params(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
|
||||
vec![QueryParam::String(playlist_id.to_string())],
|
||||
);
|
||||
let max_order: i32 = self.db_service
|
||||
.query_one(max_query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
|
||||
let playlist_id = playlist_id.to_string();
|
||||
let item_ids = item_ids.to_vec();
|
||||
|
||||
self.db_service
|
||||
.transaction(move |tx| {
|
||||
use crate::storage::db_service::{Query, QueryParam};
|
||||
|
||||
for (i, item_id) in item_ids.iter().enumerate() {
|
||||
tx.execute(Query::with_params(
|
||||
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
|
||||
vec![
|
||||
QueryParam::String(playlist_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::Int(max_order + 1 + i as i32),
|
||||
],
|
||||
))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database {
|
||||
message: format!("Failed to add items to playlist: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_from_playlist(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
entry_ids: &[String],
|
||||
) -> Result<(), RepoError> {
|
||||
let playlist_id = playlist_id.to_string();
|
||||
let entry_ids = entry_ids.to_vec();
|
||||
|
||||
self.db_service
|
||||
.transaction(move |tx| {
|
||||
use crate::storage::db_service::{Query, QueryParam};
|
||||
|
||||
for entry_id in &entry_ids {
|
||||
tx.execute(Query::with_params(
|
||||
"DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
|
||||
vec![QueryParam::String(playlist_id.clone()), QueryParam::String(entry_id.clone())],
|
||||
))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database {
|
||||
message: format!("Failed to remove items from playlist: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn move_playlist_item(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
item_id: &str,
|
||||
new_index: u32,
|
||||
) -> Result<(), RepoError> {
|
||||
let playlist_id = playlist_id.to_string();
|
||||
let item_id = item_id.to_string();
|
||||
|
||||
self.db_service
|
||||
.transaction(move |tx| {
|
||||
use crate::storage::db_service::{Query, QueryParam};
|
||||
|
||||
// Get all items ordered by sort_order
|
||||
let items: Vec<(i64, String)> = tx.query_many(
|
||||
Query::with_params(
|
||||
"SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
|
||||
vec![QueryParam::String(playlist_id)],
|
||||
),
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
|
||||
// Find the item to move
|
||||
let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
|
||||
if let Some(old_pos) = old_idx {
|
||||
let mut ids = items;
|
||||
let entry = ids.remove(old_pos);
|
||||
let insert_at = (new_index as usize).min(ids.len());
|
||||
ids.insert(insert_at, entry);
|
||||
|
||||
// Renumber all sort_orders
|
||||
for (i, (entry_id, _)) in ids.iter().enumerate() {
|
||||
tx.execute(Query::with_params(
|
||||
"UPDATE playlist_items SET sort_order = ? WHERE id = ?",
|
||||
vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
|
||||
))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database {
|
||||
message: format!("Failed to move playlist item: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1153,6 +1451,27 @@ mod tests {
|
||||
playback_context_id TEXT,
|
||||
PRIMARY KEY (user_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
is_local INTEGER DEFAULT 0,
|
||||
jellyfin_id TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE playlist_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL,
|
||||
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(playlist_id, item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
|
||||
"#).unwrap();
|
||||
|
||||
// Insert a test server
|
||||
@@ -1330,4 +1649,244 @@ mod tests {
|
||||
assert!(result.is_ok(), "Simple case should work: {:?}", result);
|
||||
assert_eq!(result.unwrap(), 3);
|
||||
}
|
||||
|
||||
// ===== Playlist Tests =====
|
||||
|
||||
/// Helper to seed items into the DB for playlist tests
|
||||
async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
|
||||
let items: Vec<MediaItem> = ids.iter().map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1"))).collect();
|
||||
repo.save_to_cache("library-1", &items).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_create_empty() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
|
||||
let result = repo.create_playlist("My Playlist", &[]).await;
|
||||
assert!(result.is_ok());
|
||||
let created = result.unwrap();
|
||||
assert!(!created.id.is_empty(), "Should return a non-empty playlist ID");
|
||||
|
||||
// Verify playlist exists in DB
|
||||
let name: String = db_service
|
||||
.query_one(
|
||||
Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(name, "My Playlist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_create_with_items() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
seed_items(&repo, &["t1", "t2", "t3"]).await;
|
||||
|
||||
let created = repo.create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap();
|
||||
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0].item.id, "t1");
|
||||
assert_eq!(items[1].item.id, "t2");
|
||||
assert_eq!(items[2].item.id, "t3");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_delete() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
seed_items(&repo, &["t1"]).await;
|
||||
|
||||
let created = repo.create_playlist("To Delete", &["t1".into()]).await.unwrap();
|
||||
|
||||
// Delete it
|
||||
repo.delete_playlist(&created.id).await.unwrap();
|
||||
|
||||
// Verify playlist is gone
|
||||
let count: i32 = db_service
|
||||
.query_one(
|
||||
Query::with_params("SELECT COUNT(*) FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
|
||||
// Verify cascade deleted playlist_items
|
||||
let item_count: i32 = db_service
|
||||
.query_one(
|
||||
Query::with_params("SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?", vec![QueryParam::String(created.id)]),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(item_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_rename() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
|
||||
let created = repo.create_playlist("Original Name", &[]).await.unwrap();
|
||||
repo.rename_playlist(&created.id, "New Name").await.unwrap();
|
||||
|
||||
let name: String = db_service
|
||||
.query_one(
|
||||
Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id)]),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(name, "New Name");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_get_items_preserves_order() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
seed_items(&repo, &["a", "b", "c"]).await;
|
||||
|
||||
let created = repo.create_playlist("Ordered", &["c".into(), "a".into(), "b".into()]).await.unwrap();
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
|
||||
assert_eq!(items.len(), 3);
|
||||
// Order should match insertion order: c, a, b
|
||||
assert_eq!(items[0].item.id, "c");
|
||||
assert_eq!(items[1].item.id, "a");
|
||||
assert_eq!(items[2].item.id, "b");
|
||||
// Each entry should have a unique playlist_item_id
|
||||
assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
|
||||
assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_get_items_empty_playlist() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
|
||||
let created = repo.create_playlist("Empty", &[]).await.unwrap();
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
assert!(items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_add_items() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
seed_items(&repo, &["t1", "t2", "t3"]).await;
|
||||
|
||||
let created = repo.create_playlist("Addable", &["t1".into()]).await.unwrap();
|
||||
|
||||
// Add two more tracks
|
||||
repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()]).await.unwrap();
|
||||
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0].item.id, "t1");
|
||||
assert_eq!(items[1].item.id, "t2");
|
||||
assert_eq!(items[2].item.id, "t3");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_add_duplicate_items_ignored() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
seed_items(&repo, &["t1"]).await;
|
||||
|
||||
let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
|
||||
|
||||
// Try to add the same item again
|
||||
repo.add_to_playlist(&created.id, &["t1".into()]).await.unwrap();
|
||||
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
assert_eq!(items.len(), 1, "Duplicate should be ignored (UNIQUE constraint)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_remove_items() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
seed_items(&repo, &["t1", "t2", "t3"]).await;
|
||||
|
||||
let created = repo.create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap();
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
assert_eq!(items.len(), 3);
|
||||
|
||||
// Remove the middle track by its entry ID
|
||||
let entry_id_to_remove = items[1].playlist_item_id.clone();
|
||||
repo.remove_from_playlist(&created.id, &[entry_id_to_remove]).await.unwrap();
|
||||
|
||||
let items_after = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
assert_eq!(items_after.len(), 2);
|
||||
assert_eq!(items_after[0].item.id, "t1");
|
||||
assert_eq!(items_after[1].item.id, "t3");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_move_item_forward() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
seed_items(&repo, &["a", "b", "c", "d"]).await;
|
||||
|
||||
let created = repo.create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap();
|
||||
|
||||
// Move 'a' (index 0) to index 2: expect b, c, a, d
|
||||
repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
|
||||
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["b", "c", "a", "d"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_move_item_backward() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
seed_items(&repo, &["a", "b", "c", "d"]).await;
|
||||
|
||||
let created = repo.create_playlist("Reorder2", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap();
|
||||
|
||||
// Move 'd' (index 3) to index 0: expect d, a, b, c
|
||||
repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
|
||||
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["d", "a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_move_item_to_end() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
seed_items(&repo, &["a", "b", "c"]).await;
|
||||
|
||||
let created = repo.create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()]).await.unwrap();
|
||||
|
||||
// Move 'a' to index 99 (beyond end, should clamp): expect b, c, a
|
||||
repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
|
||||
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["b", "c", "a"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_move_nonexistent_item_is_noop() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
seed_items(&repo, &["a", "b"]).await;
|
||||
|
||||
let created = repo.create_playlist("NoOp", &["a".into(), "b".into()]).await.unwrap();
|
||||
|
||||
// Move a nonexistent item - should not error, just no-op
|
||||
repo.move_playlist_item(&created.id, "nonexistent", 0).await.unwrap();
|
||||
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["a", "b"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,31 @@ struct ItemsResponse {
|
||||
total_record_count: usize,
|
||||
}
|
||||
|
||||
/// Jellyfin playlist creation response
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct CreatePlaylistResponse {
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Jellyfin playlist items response — items include PlaylistItemId
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
#[allow(dead_code)]
|
||||
struct PlaylistItemsResponse {
|
||||
items: Vec<JellyfinPlaylistItem>,
|
||||
total_record_count: usize,
|
||||
}
|
||||
|
||||
/// A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct JellyfinPlaylistItem {
|
||||
playlist_item_id: String,
|
||||
#[serde(flatten)]
|
||||
item: JellyfinItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct JellyfinItem {
|
||||
@@ -1192,6 +1217,146 @@ impl MediaRepository for OnlineRepository {
|
||||
total_record_count: response.total_record_count,
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Playlist Methods =====
|
||||
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
name: &str,
|
||||
item_ids: &[String],
|
||||
) -> Result<PlaylistCreatedResult, RepoError> {
|
||||
info!("[OnlineRepo] Creating playlist '{}' with {} items", name, item_ids.len());
|
||||
let body = serde_json::json!({
|
||||
"Name": name,
|
||||
"Ids": item_ids,
|
||||
"MediaType": "Audio",
|
||||
"UserId": self.user_id,
|
||||
});
|
||||
let response: CreatePlaylistResponse =
|
||||
self.post_json_response("/Playlists", &body).await?;
|
||||
Ok(PlaylistCreatedResult { id: response.id })
|
||||
}
|
||||
|
||||
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
||||
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
|
||||
let endpoint = format!("/Items/{}", playlist_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let request = self.http_client.client.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(RepoError::Server {
|
||||
message: format!("HTTP {}", response.status()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
|
||||
info!("[OnlineRepo] Renaming playlist {} to '{}'", playlist_id, name);
|
||||
let endpoint = format!("/Items/{}", playlist_id);
|
||||
self.post_json(&endpoint, &serde_json::json!({ "Name": name })).await
|
||||
}
|
||||
|
||||
async fn get_playlist_items(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||
let endpoint = format!(
|
||||
"/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
|
||||
playlist_id, self.user_id
|
||||
);
|
||||
|
||||
let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
|
||||
debug!(
|
||||
"[OnlineRepo] Got {} playlist items for {}",
|
||||
response.items.len(),
|
||||
playlist_id
|
||||
);
|
||||
|
||||
Ok(response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|pi| PlaylistEntry {
|
||||
playlist_item_id: pi.playlist_item_id,
|
||||
item: pi.item.to_media_item(self.user_id.clone()),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn add_to_playlist(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
item_ids: &[String],
|
||||
) -> Result<(), RepoError> {
|
||||
info!(
|
||||
"[OnlineRepo] Adding {} items to playlist {}",
|
||||
item_ids.len(),
|
||||
playlist_id
|
||||
);
|
||||
let ids_param = item_ids.join(",");
|
||||
let endpoint = format!("/Playlists/{}/Items?Ids={}", playlist_id, ids_param);
|
||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
async fn remove_from_playlist(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
entry_ids: &[String],
|
||||
) -> Result<(), RepoError> {
|
||||
info!(
|
||||
"[OnlineRepo] Removing {} entries from playlist {}",
|
||||
entry_ids.len(),
|
||||
playlist_id
|
||||
);
|
||||
let ids_param = entry_ids.join(",");
|
||||
let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let request = self.http_client.client.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(RepoError::Server {
|
||||
message: format!("HTTP {}", response.status()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn move_playlist_item(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
item_id: &str,
|
||||
new_index: u32,
|
||||
) -> Result<(), RepoError> {
|
||||
info!(
|
||||
"[OnlineRepo] Moving item {} in playlist {} to index {}",
|
||||
item_id, playlist_id, new_index
|
||||
);
|
||||
let endpoint = format!(
|
||||
"/Playlists/{}/Items/{}/Move/{}",
|
||||
playlist_id, item_id, new_index
|
||||
);
|
||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -331,6 +331,41 @@ impl MeaningfulContent for PlaybackInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId
|
||||
/// needed for remove/reorder operations (distinct from the media item's ID)
|
||||
///
|
||||
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistEntry {
|
||||
/// The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
|
||||
pub playlist_item_id: String,
|
||||
/// The underlying media item
|
||||
#[serde(flatten)]
|
||||
pub item: MediaItem,
|
||||
}
|
||||
|
||||
/// Result of creating a playlist
|
||||
///
|
||||
/// @req: JA-019 - Get/create/update playlists
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistCreatedResult {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl MeaningfulContent for Vec<PlaylistEntry> {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MeaningfulContent for PlaylistCreatedResult {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.id.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -563,4 +598,103 @@ mod tests {
|
||||
assert!(people_array[0].get("type").is_some(), "Serialized person should have 'type' field");
|
||||
assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_playlist_entry_serialization() {
|
||||
let entry = PlaylistEntry {
|
||||
playlist_item_id: "entry-abc-123".to_string(),
|
||||
item: MediaItem {
|
||||
id: "track1".to_string(),
|
||||
name: "Test Track".to_string(),
|
||||
item_type: "Audio".to_string(),
|
||||
server_id: "server1".to_string(),
|
||||
parent_id: None,
|
||||
library_id: None,
|
||||
overview: None,
|
||||
genres: None,
|
||||
production_year: None,
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
runtime_ticks: None,
|
||||
primary_image_tag: None,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
album_name: None,
|
||||
album_artist: None,
|
||||
artists: Some(vec!["Artist One".to_string()]),
|
||||
artist_items: None,
|
||||
index_number: None,
|
||||
parent_index_number: None,
|
||||
series_id: None,
|
||||
series_name: None,
|
||||
season_id: None,
|
||||
season_name: None,
|
||||
user_data: None,
|
||||
media_streams: None,
|
||||
media_sources: None,
|
||||
people: None,
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&entry).expect("Failed to serialize");
|
||||
// playlistItemId is camelCase
|
||||
assert!(json.contains(r#""playlistItemId":"entry-abc-123""#));
|
||||
// Flattened MediaItem fields appear at top level
|
||||
assert!(json.contains(r#""id":"track1""#));
|
||||
assert!(json.contains(r#""name":"Test Track""#));
|
||||
assert!(json.contains(r#""type":"Audio""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_playlist_created_result_serialization() {
|
||||
let result = PlaylistCreatedResult {
|
||||
id: "playlist-new-123".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&result).expect("Failed to serialize");
|
||||
assert!(json.contains(r#""id":"playlist-new-123""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_playlist_entry_meaningful_content() {
|
||||
let empty: Vec<PlaylistEntry> = vec![];
|
||||
assert!(!empty.has_content());
|
||||
|
||||
let non_empty = vec![PlaylistEntry {
|
||||
playlist_item_id: "e1".to_string(),
|
||||
item: MediaItem {
|
||||
id: "1".to_string(),
|
||||
name: "Track".to_string(),
|
||||
item_type: "Audio".to_string(),
|
||||
server_id: "s1".to_string(),
|
||||
parent_id: None,
|
||||
library_id: None,
|
||||
overview: None,
|
||||
genres: None,
|
||||
production_year: None,
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
runtime_ticks: None,
|
||||
primary_image_tag: None,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
album_name: None,
|
||||
album_artist: None,
|
||||
artists: None,
|
||||
artist_items: None,
|
||||
index_number: None,
|
||||
parent_index_number: None,
|
||||
series_id: None,
|
||||
series_name: None,
|
||||
season_id: None,
|
||||
season_name: None,
|
||||
user_data: None,
|
||||
media_streams: None,
|
||||
media_sources: None,
|
||||
people: None,
|
||||
},
|
||||
}];
|
||||
assert!(non_empty.has_content());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,13 @@ impl<'a> Transaction<'a> {
|
||||
pub fn execute(&mut self, query: Query) -> DbResult<usize> {
|
||||
execute_query(self.conn, query)
|
||||
}
|
||||
|
||||
pub fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
|
||||
where
|
||||
F: Fn(&Row) -> SqliteResult<T>,
|
||||
{
|
||||
query_many(self.conn, query, mapper)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rusqlite-based database service implementation
|
||||
|
||||
Reference in New Issue
Block a user