Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
This commit is contained in:
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user