Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 12s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s

This commit is contained in:
2026-03-01 19:47:46 +01:00
parent 3a9c126dfe
commit 09780103a7
45 changed files with 5663 additions and 3332 deletions
+157
View File
@@ -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 {
+64
View File
@@ -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>;
}
+559
View File
@@ -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"]);
}
}
+165
View File
@@ -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)]
+134
View File
@@ -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());
}
}