1946 lines
78 KiB
Rust
1946 lines
78 KiB
Rust
// Offline repository - queries SQLite database for cached data
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use log::debug;
|
|
|
|
use super::{MediaRepository, types::*};
|
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
|
|
|
pub struct OfflineRepository {
|
|
db_service: Arc<RusqliteService>,
|
|
server_id: String,
|
|
user_id: String,
|
|
}
|
|
|
|
impl OfflineRepository {
|
|
pub fn new(db_service: Arc<RusqliteService>, server_id: String, user_id: String) -> Self {
|
|
Self { db_service, server_id, user_id }
|
|
}
|
|
|
|
/// Helper to convert CachedItem from storage to MediaItem
|
|
fn cached_item_to_media_item(item: CachedItem, user_data: Option<UserData>) -> MediaItem {
|
|
let artists_vec = item.artists
|
|
.as_ref()
|
|
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
|
|
.unwrap_or_default();
|
|
|
|
MediaItem {
|
|
id: item.id.clone(),
|
|
name: item.name,
|
|
item_type: item.item_type,
|
|
is_folder: item.is_folder,
|
|
server_id: item.server_id,
|
|
parent_id: item.parent_id,
|
|
library_id: item.library_id,
|
|
overview: item.overview,
|
|
genres: item.genres
|
|
.as_ref()
|
|
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
|
|
runtime_ticks: item.runtime_ticks,
|
|
production_year: item.production_year,
|
|
community_rating: item.community_rating,
|
|
official_rating: item.official_rating,
|
|
primary_image_tag: item.primary_image_tag,
|
|
backdrop_image_tags: item.backdrop_image_tags,
|
|
parent_backdrop_image_tags: item.parent_backdrop_image_tags,
|
|
album_id: item.album_id,
|
|
album_name: item.album_name,
|
|
album_artist: item.album_artist,
|
|
artists: Some(artists_vec),
|
|
artist_items: None, // Not stored in cache yet - TODO: add to database schema
|
|
index_number: item.index_number,
|
|
series_id: item.series_id,
|
|
series_name: item.series_name,
|
|
season_id: item.season_id,
|
|
season_name: item.season_name,
|
|
parent_index_number: item.parent_index_number,
|
|
user_data,
|
|
media_streams: None, // Not cached offline
|
|
media_sources: None, // Not cached offline
|
|
people: None, // Not cached offline - TODO: add to database schema
|
|
}
|
|
}
|
|
|
|
/// Get user data for an item (playback position, favorite, etc.)
|
|
async fn get_user_data(&self, item_id: &str) -> Option<UserData> {
|
|
let query = Query::with_params(
|
|
"SELECT playback_position_ticks, is_played, is_favorite, play_count, last_played_at, playback_context_type, playback_context_id
|
|
FROM user_data WHERE user_id = ? AND item_id = ?",
|
|
vec![
|
|
QueryParam::String(self.user_id.clone()),
|
|
QueryParam::String(item_id.to_string()),
|
|
],
|
|
);
|
|
|
|
self.db_service.query_optional(query, |row| {
|
|
Ok(UserData {
|
|
playback_position_ticks: row.get(0).ok(),
|
|
is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0),
|
|
is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0),
|
|
play_count: row.get(3).ok(),
|
|
last_played_date: row.get(4).ok(),
|
|
playback_context_type: row.get(5).ok(),
|
|
playback_context_id: row.get(6).ok(),
|
|
})
|
|
}).await.ok().flatten()
|
|
}
|
|
}
|
|
|
|
// Helper struct matching storage.rs CachedItem structure
|
|
#[derive(Debug)]
|
|
struct CachedItem {
|
|
id: String,
|
|
name: String,
|
|
item_type: String,
|
|
is_folder: bool,
|
|
server_id: String,
|
|
parent_id: Option<String>,
|
|
library_id: Option<String>,
|
|
overview: Option<String>,
|
|
genres: Option<String>,
|
|
runtime_ticks: Option<i64>,
|
|
production_year: Option<i32>,
|
|
community_rating: Option<f64>,
|
|
official_rating: Option<String>,
|
|
primary_image_tag: Option<String>,
|
|
backdrop_image_tags: Option<Vec<String>>,
|
|
parent_backdrop_image_tags: Option<Vec<String>>,
|
|
album_id: Option<String>,
|
|
album_name: Option<String>,
|
|
album_artist: Option<String>,
|
|
artists: Option<String>,
|
|
index_number: Option<i32>,
|
|
series_id: Option<String>,
|
|
series_name: Option<String>,
|
|
season_id: Option<String>,
|
|
season_name: Option<String>,
|
|
parent_index_number: Option<i32>,
|
|
}
|
|
|
|
fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
|
|
Ok(CachedItem {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
item_type: row.get(2)?,
|
|
server_id: row.get(3)?,
|
|
parent_id: row.get(4)?,
|
|
library_id: row.get(5)?,
|
|
overview: row.get(6)?,
|
|
genres: row.get(7)?,
|
|
runtime_ticks: row.get(8)?,
|
|
production_year: row.get(9)?,
|
|
community_rating: row.get(10)?,
|
|
official_rating: row.get(11)?,
|
|
primary_image_tag: row.get(12)?,
|
|
backdrop_image_tags: None, // TODO: Add to DB schema
|
|
parent_backdrop_image_tags: None, // TODO: Add to DB schema
|
|
album_id: row.get(13)?,
|
|
album_name: row.get(14)?,
|
|
album_artist: row.get(15)?,
|
|
artists: row.get(16)?,
|
|
index_number: row.get(17)?,
|
|
series_id: row.get(18)?,
|
|
series_name: row.get(19)?,
|
|
season_id: row.get(20)?,
|
|
season_name: row.get(21)?,
|
|
parent_index_number: row.get(22)?,
|
|
// Appended as the final column in every SELECT that maps through this fn.
|
|
is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
|
|
})
|
|
}
|
|
|
|
impl OfflineRepository {
|
|
/// Save browsed items to cache for faster subsequent loading
|
|
///
|
|
/// This persists metadata for items that were browsed (not necessarily downloaded).
|
|
/// Items are marked with current timestamp for freshness tracking.
|
|
pub async fn save_to_cache(
|
|
&self,
|
|
parent_id: &str,
|
|
items: &[MediaItem],
|
|
) -> Result<usize, RepoError> {
|
|
if items.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
|
|
// Temporarily disable foreign key constraints to avoid CASCADE DELETE issues
|
|
// when replacing stub parent items with their actual data
|
|
self.db_service.execute(Query::new("PRAGMA foreign_keys = OFF")).await
|
|
.map_err(|e| RepoError::Database { message: e })?;
|
|
|
|
// Ensure we re-enable foreign keys even if an error occurs
|
|
let result = self.save_to_cache_impl(parent_id, items, &now).await;
|
|
|
|
// Re-enable foreign key constraints
|
|
let _ = self.db_service.execute(Query::new("PRAGMA foreign_keys = ON")).await;
|
|
|
|
result
|
|
}
|
|
|
|
async fn save_to_cache_impl(
|
|
&self,
|
|
parent_id: &str,
|
|
items: &[MediaItem],
|
|
now: &str,
|
|
) -> Result<usize, RepoError> {
|
|
// Collect all unique parent IDs referenced by items being saved
|
|
let mut parent_ids = std::collections::HashSet::new();
|
|
parent_ids.insert(parent_id.to_string());
|
|
|
|
for item in items {
|
|
if let Some(pid) = &item.parent_id {
|
|
parent_ids.insert(pid.clone());
|
|
}
|
|
}
|
|
|
|
// Insert stub entries for all parent IDs to satisfy FK constraints
|
|
#[cfg(test)]
|
|
println!("Creating stub parents for: {:?}", parent_ids);
|
|
|
|
for pid in parent_ids {
|
|
let parent_query = Query::with_params(
|
|
"INSERT OR IGNORE INTO items (id, server_id, name, item_type, synced_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
vec![
|
|
QueryParam::String(pid.clone()),
|
|
QueryParam::String(self.server_id.clone()),
|
|
QueryParam::String("Parent".to_string()),
|
|
QueryParam::String("Folder".to_string()),
|
|
QueryParam::String(now.to_string()),
|
|
],
|
|
);
|
|
|
|
let _stub_rows = self.db_service.execute(parent_query).await
|
|
.map_err(|e| RepoError::Database { message: e })?;
|
|
#[cfg(test)]
|
|
println!(" Created stub parent {} (rows affected: {})", pid, _stub_rows);
|
|
}
|
|
|
|
let mut count = 0;
|
|
|
|
for item in items {
|
|
// Convert Option<Vec<String>> to JSON strings for storage
|
|
let genres_json = item.genres.as_ref()
|
|
.map(|g| serde_json::to_string(g).unwrap_or_else(|_| "[]".to_string()));
|
|
let artists_json = item.artists.as_ref()
|
|
.map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
|
|
let backdrop_tags_json = item.backdrop_image_tags.as_ref()
|
|
.map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));
|
|
|
|
// Use INSERT OR REPLACE to upsert items
|
|
let query = Query::with_params(
|
|
"INSERT OR REPLACE INTO items (
|
|
id, server_id, library_id, parent_id,
|
|
name, item_type, is_folder, overview,
|
|
genres, series_id, series_name,
|
|
season_id, season_name, index_number, parent_index_number,
|
|
album_id, album_name, album_artist, artists,
|
|
production_year, runtime_ticks,
|
|
primary_image_tag, backdrop_image_tags,
|
|
community_rating, official_rating,
|
|
synced_at
|
|
) VALUES (
|
|
?1, ?2, ?3, ?4,
|
|
?5, ?6, ?7, ?8,
|
|
?9, ?10, ?11,
|
|
?12, ?13, ?14, ?15,
|
|
?16, ?17, ?18, ?19,
|
|
?20, ?21,
|
|
?22, ?23,
|
|
?24, ?25,
|
|
?26
|
|
)",
|
|
vec![
|
|
QueryParam::String(item.id.clone()),
|
|
QueryParam::String(self.server_id.clone()),
|
|
// Library is NULL for cached items (may not be synced yet)
|
|
QueryParam::Null, // library_id
|
|
// Use the item's actual parent_id, not the function parameter
|
|
match &item.parent_id {
|
|
Some(pid) => QueryParam::String(pid.clone()),
|
|
None => QueryParam::Null,
|
|
},
|
|
QueryParam::String(item.name.clone()),
|
|
QueryParam::String(item.item_type.clone()),
|
|
QueryParam::Int(if item.is_folder { 1 } else { 0 }),
|
|
match &item.overview {
|
|
Some(o) => QueryParam::String(o.clone()),
|
|
None => QueryParam::Null,
|
|
},
|
|
match genres_json {
|
|
Some(g) => QueryParam::String(g),
|
|
None => QueryParam::Null,
|
|
},
|
|
match &item.series_id {
|
|
Some(s) => QueryParam::String(s.clone()),
|
|
None => QueryParam::Null,
|
|
},
|
|
match &item.series_name {
|
|
Some(s) => QueryParam::String(s.clone()),
|
|
None => QueryParam::Null,
|
|
},
|
|
match &item.season_id {
|
|
Some(s) => QueryParam::String(s.clone()),
|
|
None => QueryParam::Null,
|
|
},
|
|
match &item.season_name {
|
|
Some(s) => QueryParam::String(s.clone()),
|
|
None => QueryParam::Null,
|
|
},
|
|
match item.index_number {
|
|
Some(i) => QueryParam::Int(i),
|
|
None => QueryParam::Null,
|
|
},
|
|
match item.parent_index_number {
|
|
Some(i) => QueryParam::Int(i),
|
|
None => QueryParam::Null,
|
|
},
|
|
match &item.album_id {
|
|
Some(a) => QueryParam::String(a.clone()),
|
|
None => QueryParam::Null,
|
|
},
|
|
match &item.album_name {
|
|
Some(a) => QueryParam::String(a.clone()),
|
|
None => QueryParam::Null,
|
|
},
|
|
match &item.album_artist {
|
|
Some(a) => QueryParam::String(a.clone()),
|
|
None => QueryParam::Null,
|
|
},
|
|
match artists_json {
|
|
Some(a) => QueryParam::String(a),
|
|
None => QueryParam::Null,
|
|
},
|
|
match item.production_year {
|
|
Some(y) => QueryParam::Int(y),
|
|
None => QueryParam::Null,
|
|
},
|
|
match item.runtime_ticks {
|
|
Some(r) => QueryParam::Int64(r),
|
|
None => QueryParam::Null,
|
|
},
|
|
match &item.primary_image_tag {
|
|
Some(t) => QueryParam::String(t.clone()),
|
|
None => QueryParam::Null,
|
|
},
|
|
match backdrop_tags_json {
|
|
Some(b) => QueryParam::String(b),
|
|
None => QueryParam::Null,
|
|
},
|
|
match item.community_rating {
|
|
Some(r) => QueryParam::Float(r),
|
|
None => QueryParam::Null,
|
|
},
|
|
match &item.official_rating {
|
|
Some(r) => QueryParam::String(r.clone()),
|
|
None => QueryParam::Null,
|
|
},
|
|
QueryParam::String(now.to_string()),
|
|
],
|
|
);
|
|
|
|
let _rows_affected = self.db_service.execute(query).await
|
|
.map_err(|e| RepoError::Database { message: format!("Failed to insert item {}: {}", item.id, e) })?;
|
|
#[cfg(test)]
|
|
println!(" [save_to_cache] Saved item {} (rows affected: {})", item.id, _rows_affected);
|
|
count += 1;
|
|
}
|
|
|
|
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]
|
|
impl MediaRepository for OfflineRepository {
|
|
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
|
// Only show libraries that have downloaded content
|
|
// Check both direct children and nested children (e.g., albums inside library)
|
|
let query = Query::with_params(
|
|
"SELECT DISTINCT l.id, l.name, l.collection_type, l.image_tag
|
|
FROM libraries l
|
|
INNER JOIN items i ON i.library_id = l.id
|
|
WHERE l.server_id = ?
|
|
AND (
|
|
-- Direct playable items with downloads
|
|
(i.item_type IN ('Audio', 'Movie', 'Episode')
|
|
AND EXISTS (SELECT 1 FROM downloads d WHERE d.item_id = i.id AND d.status = 'completed'))
|
|
OR
|
|
-- Container items with downloaded children
|
|
(i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
|
AND EXISTS (
|
|
SELECT 1 FROM items children
|
|
INNER JOIN downloads d ON children.id = d.item_id
|
|
WHERE children.parent_id = i.id AND d.status = 'completed'
|
|
))
|
|
)
|
|
ORDER BY l.sort_order ASC, l.name ASC",
|
|
vec![QueryParam::String(self.server_id.clone())],
|
|
);
|
|
|
|
self.db_service.query_many(query, |row| {
|
|
Ok(Library {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
collection_type: row.get::<_, Option<String>>(2)?.unwrap_or_else(|| "unknown".to_string()),
|
|
image_tag: row.get(3)?,
|
|
})
|
|
}).await.map_err(|e| RepoError::Database { message: e })
|
|
}
|
|
|
|
async fn get_items(&self, parent_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
|
debug!("[OfflineRepo] get_items called for parent_id: {}", &parent_id[..8.min(parent_id.len())]);
|
|
let opts = options.unwrap_or_default();
|
|
let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading
|
|
let start_index = opts.start_index.unwrap_or(0);
|
|
|
|
// Build type filter for optional filtering
|
|
let type_filter = if let Some(include_item_types) = &opts.include_item_types {
|
|
if !include_item_types.is_empty() {
|
|
let types = include_item_types
|
|
.iter()
|
|
.map(|t| format!("'{}'", t))
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
format!(" AND i.item_type IN ({})", types)
|
|
} else {
|
|
String::new()
|
|
}
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
// Use CTE to find items that are either:
|
|
// 1. Playable items (Audio, Movie, Episode) with completed downloads (offline mode)
|
|
// 2. Container items (MusicAlbum, Series, Season) with at least one downloaded child (offline mode)
|
|
// 3. Cached items with recent synced_at timestamp (online mode - for fast browsing)
|
|
let sql = format!(
|
|
"WITH available_items AS (
|
|
-- Playable items with completed downloads
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN downloads d ON i.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type IN ('Audio', 'Movie', 'Episode')
|
|
|
|
UNION
|
|
|
|
-- Containers with downloaded children
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN items children ON children.parent_id = i.id
|
|
INNER JOIN downloads d ON children.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
|
|
|
UNION
|
|
|
|
-- Cached items for fast browsing (when online)
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
WHERE i.synced_at IS NOT NULL
|
|
)
|
|
SELECT 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, i.is_folder
|
|
FROM items i
|
|
INNER JOIN available_items ai ON i.id = ai.id
|
|
WHERE i.server_id = ? AND i.parent_id = ?{}
|
|
ORDER BY i.sort_name ASC, i.name ASC
|
|
LIMIT {} OFFSET {}",
|
|
type_filter, limit, start_index
|
|
);
|
|
|
|
let query = Query::with_params(
|
|
sql,
|
|
vec![
|
|
QueryParam::String(self.server_id.clone()),
|
|
QueryParam::String(parent_id.to_string()),
|
|
],
|
|
);
|
|
|
|
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
|
.await
|
|
.map_err(|e| RepoError::Database { message: e })?;
|
|
|
|
debug!("[OfflineRepo] Found {} cached items for parent {}", cached_items.len(), &parent_id[..8.min(parent_id.len())]);
|
|
|
|
// Fetch user data for each item
|
|
let mut items = Vec::new();
|
|
for cached in cached_items {
|
|
let user_data = self.get_user_data(&cached.id).await;
|
|
items.push(Self::cached_item_to_media_item(cached, user_data));
|
|
}
|
|
|
|
let total_record_count = items.len();
|
|
|
|
debug!("[OfflineRepo] Returning {} items for parent {}", total_record_count, &parent_id[..8.min(parent_id.len())]);
|
|
|
|
Ok(SearchResult {
|
|
items,
|
|
total_record_count,
|
|
})
|
|
}
|
|
|
|
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
|
// Check if item is available offline (either downloaded itself or has downloaded children)
|
|
let query = Query::with_params(
|
|
"WITH downloaded_items AS (
|
|
-- Playable items with completed downloads
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN downloads d ON i.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type IN ('Audio', 'Movie', 'Episode')
|
|
|
|
UNION
|
|
|
|
-- Containers with downloaded children
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN items children ON children.parent_id = i.id
|
|
INNER JOIN downloads d ON children.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
|
)
|
|
SELECT 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, i.is_folder
|
|
FROM items i
|
|
INNER JOIN downloaded_items di ON i.id = di.id
|
|
WHERE i.id = ?",
|
|
vec![QueryParam::String(item_id.to_string())],
|
|
);
|
|
|
|
let cached = self.db_service.query_optional(query, row_to_cached_item)
|
|
.await
|
|
.map_err(|e| RepoError::Database { message: e })?
|
|
.ok_or_else(|| RepoError::NotFound {
|
|
message: format!("Item {} not found in offline cache or not downloaded", item_id),
|
|
})?;
|
|
|
|
let user_data = self.get_user_data(item_id).await;
|
|
Ok(Self::cached_item_to_media_item(cached, user_data))
|
|
}
|
|
|
|
async fn get_latest_items(&self, parent_id: &str, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
|
let limit_val = limit.unwrap_or(16);
|
|
|
|
let query = Query::with_params(
|
|
format!(
|
|
"WITH downloaded_items AS (
|
|
-- Playable items with completed downloads
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN downloads d ON i.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type IN ('Audio', 'Movie', 'Episode')
|
|
|
|
UNION
|
|
|
|
-- Containers with downloaded children
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN items children ON children.parent_id = i.id
|
|
INNER JOIN downloads d ON children.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
|
)
|
|
SELECT 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, i.is_folder
|
|
FROM items i
|
|
INNER JOIN downloaded_items di ON i.id = di.id
|
|
WHERE i.server_id = ? AND i.library_id = ?
|
|
ORDER BY i.synced_at DESC
|
|
LIMIT {}", limit_val
|
|
),
|
|
vec![
|
|
QueryParam::String(self.server_id.clone()),
|
|
QueryParam::String(parent_id.to_string()),
|
|
],
|
|
);
|
|
|
|
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
|
.await
|
|
.map_err(|e| RepoError::Database { message: e })?;
|
|
|
|
let mut items = Vec::new();
|
|
for cached in cached_items {
|
|
let user_data = self.get_user_data(&cached.id).await;
|
|
items.push(Self::cached_item_to_media_item(cached, user_data));
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
async fn get_resume_items(&self, parent_id: Option<&str>, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
|
let limit_val = limit.unwrap_or(12);
|
|
|
|
// Resume items are video-only (Movie, Episode) - audio is handled by get_recently_played_audio
|
|
let (sql, params) = if let Some(pid) = parent_id {
|
|
(
|
|
format!(
|
|
"SELECT 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, i.is_folder
|
|
FROM items i
|
|
JOIN user_data ud ON i.id = ud.item_id
|
|
INNER JOIN downloads d ON i.id = d.item_id
|
|
WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
|
|
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
|
AND d.status = 'completed'
|
|
AND i.item_type IN ('Movie', 'Episode')
|
|
ORDER BY ud.last_played_at DESC
|
|
LIMIT {}", limit_val
|
|
),
|
|
vec![
|
|
QueryParam::String(self.server_id.clone()),
|
|
QueryParam::String(self.user_id.clone()),
|
|
QueryParam::String(pid.to_string()),
|
|
]
|
|
)
|
|
} else {
|
|
(
|
|
format!(
|
|
"SELECT 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, i.is_folder
|
|
FROM items i
|
|
JOIN user_data ud ON i.id = ud.item_id
|
|
INNER JOIN downloads d ON i.id = d.item_id
|
|
WHERE i.server_id = ? AND ud.user_id = ?
|
|
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
|
AND d.status = 'completed'
|
|
AND i.item_type IN ('Movie', 'Episode')
|
|
ORDER BY ud.last_played_at DESC
|
|
LIMIT {}", limit_val
|
|
),
|
|
vec![
|
|
QueryParam::String(self.server_id.clone()),
|
|
QueryParam::String(self.user_id.clone()),
|
|
]
|
|
)
|
|
};
|
|
|
|
let query = Query::with_params(sql, params);
|
|
|
|
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
|
.await
|
|
.map_err(|e| RepoError::Database { message: e })?;
|
|
|
|
let mut items = Vec::new();
|
|
for cached in cached_items {
|
|
let user_data = self.get_user_data(&cached.id).await;
|
|
items.push(Self::cached_item_to_media_item(cached, user_data));
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
async fn get_next_up_episodes(&self, _series_id: Option<&str>, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
|
// Next up is complex - would need to track watched episodes and find the next unwatched
|
|
// For now, return empty for offline mode
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn get_recently_played_audio(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
|
let limit_val = limit.unwrap_or(12);
|
|
|
|
// Use CTE to intelligently group by playback context and filter by downloads
|
|
// Shows containers (albums) when context_type='container', individual tracks when context_type='single'
|
|
// Falls back to album grouping for legacy data (NULL context)
|
|
// Only shows items that are downloaded or have downloaded children
|
|
let query = Query::with_params(
|
|
format!(
|
|
"WITH downloaded_items AS (
|
|
-- Playable items with completed downloads (Audio tracks)
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN downloads d ON i.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type = 'Audio'
|
|
|
|
UNION
|
|
|
|
-- Containers with downloaded children (Albums)
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN items children ON children.parent_id = i.id
|
|
INNER JOIN downloads d ON children.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type = 'MusicAlbum'
|
|
),
|
|
ranked_plays AS (
|
|
SELECT
|
|
CASE
|
|
WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id
|
|
WHEN ud.playback_context_type = 'single' THEN ud.item_id
|
|
ELSE COALESCE(i.album_id, ud.item_id)
|
|
END AS display_id,
|
|
MAX(ud.last_played_at) AS most_recent_play
|
|
FROM user_data ud
|
|
JOIN items i ON ud.item_id = i.id
|
|
WHERE ud.user_id = ? AND i.server_id = ?
|
|
AND i.item_type = 'Audio'
|
|
AND ud.last_played_at IS NOT NULL
|
|
GROUP BY display_id
|
|
ORDER BY most_recent_play DESC
|
|
LIMIT {}
|
|
)
|
|
SELECT DISTINCT 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, i.is_folder
|
|
FROM ranked_plays rp
|
|
JOIN items i ON rp.display_id = i.id
|
|
INNER JOIN downloaded_items di ON i.id = di.id
|
|
ORDER BY rp.most_recent_play DESC",
|
|
limit_val
|
|
),
|
|
vec![
|
|
QueryParam::String(self.user_id.clone()),
|
|
QueryParam::String(self.server_id.clone()),
|
|
],
|
|
);
|
|
|
|
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
|
.await
|
|
.map_err(|e| RepoError::Database { message: e })?;
|
|
|
|
let mut items = Vec::new();
|
|
for cached in cached_items {
|
|
let user_data = self.get_user_data(&cached.id).await;
|
|
items.push(Self::cached_item_to_media_item(cached, user_data));
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
async fn get_rediscover_albums(
|
|
&self,
|
|
_parent_id: Option<&str>,
|
|
_limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, RepoError> {
|
|
// "Rediscover" is a discovery feature over the full server library.
|
|
// Offline only holds downloaded items, so there is nothing meaningful
|
|
// to surface here; the hybrid repo serves this from the server instead.
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
|
let limit_val = limit.unwrap_or(12);
|
|
|
|
// Resume movies are playable items, so simple JOIN with downloads
|
|
let query = Query::with_params(
|
|
format!(
|
|
"SELECT 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, i.is_folder
|
|
FROM items i
|
|
JOIN user_data ud ON i.id = ud.item_id
|
|
INNER JOIN downloads d ON i.id = d.item_id
|
|
WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie'
|
|
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
|
AND d.status = 'completed'
|
|
ORDER BY ud.last_played_at DESC
|
|
LIMIT {}", limit_val
|
|
),
|
|
vec![
|
|
QueryParam::String(self.server_id.clone()),
|
|
QueryParam::String(self.user_id.clone()),
|
|
],
|
|
);
|
|
|
|
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
|
.await
|
|
.map_err(|e| RepoError::Database { message: e })?;
|
|
|
|
let mut items = Vec::new();
|
|
for cached in cached_items {
|
|
let user_data = self.get_user_data(&cached.id).await;
|
|
items.push(Self::cached_item_to_media_item(cached, user_data));
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
|
// Derive genres from cached albums, tallying how many albums carry each
|
|
// so the frontend can rank by popularity. We scope to MusicAlbum (genres
|
|
// power the music landing) and read every matching row — NOT DISTINCT —
|
|
// so the per-genre counts are real. Genres are stored as a JSON array
|
|
// string per item.
|
|
let (sql, params) = if let Some(pid) = parent_id {
|
|
(
|
|
"SELECT genres FROM items WHERE server_id = ? AND library_id = ? \
|
|
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
|
|
vec![
|
|
QueryParam::String(self.server_id.clone()),
|
|
QueryParam::String(pid.to_string()),
|
|
],
|
|
)
|
|
} else {
|
|
(
|
|
"SELECT genres FROM items WHERE server_id = ? \
|
|
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
|
|
vec![QueryParam::String(self.server_id.clone())],
|
|
)
|
|
};
|
|
|
|
let query = Query::with_params(sql, params);
|
|
|
|
let genres_rows: Vec<String> = self
|
|
.db_service
|
|
.query_many(query, |row| row.get(0))
|
|
.await
|
|
.map_err(|e| RepoError::Database { message: e })?;
|
|
|
|
// genre name -> album count
|
|
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
|
for genres_json in genres_rows {
|
|
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
|
|
// De-dupe within one album so a genre listed twice counts once.
|
|
let mut seen = std::collections::HashSet::new();
|
|
for genre in genres_vec {
|
|
if seen.insert(genre.clone()) {
|
|
*counts.entry(genre).or_insert(0) += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let genres = counts
|
|
.into_iter()
|
|
.map(|(name, count)| Genre {
|
|
id: name.clone(),
|
|
name,
|
|
album_count: Some(count),
|
|
})
|
|
.collect();
|
|
|
|
Ok(genres)
|
|
}
|
|
|
|
async fn search(&self, query: &str, options: Option<SearchOptions>) -> Result<SearchResult, RepoError> {
|
|
let opts = options.unwrap_or_default();
|
|
let limit = opts.limit.unwrap_or(20);
|
|
|
|
// Escape FTS special characters and add prefix matching
|
|
let fts_query = format!("{}*", query.replace('"', "\"\""));
|
|
|
|
// Build type filter
|
|
let type_filter = if let Some(include_item_types) = &opts.include_item_types {
|
|
if !include_item_types.is_empty() {
|
|
let types = include_item_types
|
|
.iter()
|
|
.map(|t| format!("'{}'", t))
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
format!(" AND i.item_type IN ({})", types)
|
|
} else {
|
|
String::new()
|
|
}
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
// Use CTE to filter by downloads, then search within downloaded content
|
|
let sql = format!(
|
|
"WITH downloaded_items AS (
|
|
-- Playable items with completed downloads
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN downloads d ON i.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type IN ('Audio', 'Movie', 'Episode')
|
|
|
|
UNION
|
|
|
|
-- Containers with downloaded children
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN items children ON children.parent_id = i.id
|
|
INNER JOIN downloads d ON children.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
|
)
|
|
SELECT 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, i.is_folder
|
|
FROM items i
|
|
JOIN items_fts fts ON fts.rowid = i.rowid
|
|
INNER JOIN downloaded_items di ON i.id = di.id
|
|
WHERE i.server_id = ? AND items_fts MATCH ?{}
|
|
ORDER BY rank
|
|
LIMIT {}",
|
|
type_filter, limit
|
|
);
|
|
|
|
let db_query = Query::with_params(
|
|
sql,
|
|
vec![
|
|
QueryParam::String(self.server_id.clone()),
|
|
QueryParam::String(fts_query),
|
|
],
|
|
);
|
|
|
|
let cached_items: Vec<CachedItem> = self.db_service.query_many(db_query, row_to_cached_item)
|
|
.await
|
|
.map_err(|e| RepoError::Database { message: e })?;
|
|
|
|
let mut items = Vec::new();
|
|
for cached in cached_items {
|
|
let user_data = self.get_user_data(&cached.id).await;
|
|
items.push(Self::cached_item_to_media_item(cached, user_data));
|
|
}
|
|
|
|
let total_record_count = items.len();
|
|
|
|
Ok(SearchResult {
|
|
items,
|
|
total_record_count,
|
|
})
|
|
}
|
|
|
|
async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
|
// Playback info requires server communication for transcoding decisions
|
|
Err(RepoError::Offline)
|
|
}
|
|
|
|
async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
|
|
// Cannot get stream URLs while offline - offline tracks use local paths
|
|
Err(RepoError::Offline)
|
|
}
|
|
|
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
|
// Live TV is inherently online-only.
|
|
Err(RepoError::Offline)
|
|
}
|
|
|
|
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
|
|
// Plugin channels are inherently online-only.
|
|
Err(RepoError::Offline)
|
|
}
|
|
|
|
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
|
|
// Live streams cannot be opened offline.
|
|
Err(RepoError::Offline)
|
|
}
|
|
|
|
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
|
// Cannot report to server while offline
|
|
Err(RepoError::Offline)
|
|
}
|
|
|
|
async fn report_playback_progress(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
|
// Cannot report to server while offline
|
|
Err(RepoError::Offline)
|
|
}
|
|
|
|
async fn report_playback_stopped(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
|
// Cannot report to server while offline
|
|
Err(RepoError::Offline)
|
|
}
|
|
|
|
fn get_image_url(&self, item_id: &str, image_type: ImageType, options: Option<ImageOptions>) -> String {
|
|
// Return a placeholder path for offline image retrieval
|
|
// The actual image should be in thumbnail cache
|
|
let type_str = match image_type {
|
|
ImageType::Primary => "Primary",
|
|
ImageType::Backdrop => "Backdrop",
|
|
ImageType::Logo => "Logo",
|
|
ImageType::Thumb => "Thumb",
|
|
ImageType::Banner => "Banner",
|
|
};
|
|
|
|
if let Some(opts) = options {
|
|
if let Some(tag) = opts.tag {
|
|
return format!("offline://{}/{}/{}", item_id, type_str, tag);
|
|
}
|
|
}
|
|
|
|
format!("offline://{}/{}", item_id, type_str)
|
|
}
|
|
|
|
fn get_subtitle_url(
|
|
&self,
|
|
_item_id: &str,
|
|
_media_source_id: &str,
|
|
_stream_index: i32,
|
|
_format: &str,
|
|
) -> String {
|
|
// Subtitles not available offline
|
|
String::new()
|
|
}
|
|
|
|
fn get_video_download_url(
|
|
&self,
|
|
_item_id: &str,
|
|
_quality: &str,
|
|
_media_source_id: Option<&str>,
|
|
) -> String {
|
|
// Cannot download while offline
|
|
String::new()
|
|
}
|
|
|
|
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
|
// Cannot update server while offline
|
|
Err(RepoError::Offline)
|
|
}
|
|
|
|
async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
|
// Cannot update server while offline
|
|
Err(RepoError::Offline)
|
|
}
|
|
|
|
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
|
let query = Query::with_params(
|
|
"SELECT id, name, overview, primary_image_tag
|
|
FROM people WHERE id = ?",
|
|
vec![QueryParam::String(person_id.to_string())],
|
|
);
|
|
|
|
let person_data = self.db_service.query_optional(query, |row| {
|
|
Ok((
|
|
row.get::<_, String>(0)?,
|
|
row.get::<_, String>(1)?,
|
|
row.get::<_, Option<String>>(2)?,
|
|
row.get::<_, Option<String>>(3)?,
|
|
))
|
|
})
|
|
.await
|
|
.map_err(|e| RepoError::Database { message: e })?
|
|
.ok_or_else(|| RepoError::NotFound {
|
|
message: format!("Person {} not found in cache", person_id),
|
|
})?;
|
|
|
|
Ok(MediaItem {
|
|
id: person_data.0,
|
|
name: person_data.1,
|
|
item_type: "Person".to_string(),
|
|
is_folder: false,
|
|
server_id: self.server_id.clone(),
|
|
parent_id: None,
|
|
library_id: None,
|
|
overview: person_data.2,
|
|
genres: None,
|
|
runtime_ticks: None,
|
|
production_year: None,
|
|
community_rating: None,
|
|
official_rating: None,
|
|
primary_image_tag: person_data.3,
|
|
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,
|
|
series_id: None,
|
|
series_name: None,
|
|
season_id: None,
|
|
season_name: None,
|
|
parent_index_number: None,
|
|
user_data: None,
|
|
media_streams: None,
|
|
media_sources: None,
|
|
people: None,
|
|
})
|
|
}
|
|
|
|
async fn get_items_by_person(&self, person_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
|
let opts = options.unwrap_or_default();
|
|
let limit = opts.limit.unwrap_or(10000); // Match frontend limit
|
|
|
|
// Filter by downloads using CTE
|
|
let query = Query::with_params(
|
|
format!(
|
|
"WITH downloaded_items AS (
|
|
-- Playable items with completed downloads
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN downloads d ON i.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type IN ('Audio', 'Movie', 'Episode')
|
|
|
|
UNION
|
|
|
|
-- Containers with downloaded children
|
|
SELECT DISTINCT i.id
|
|
FROM items i
|
|
INNER JOIN items children ON children.parent_id = i.id
|
|
INNER JOIN downloads d ON children.id = d.item_id
|
|
WHERE d.status = 'completed'
|
|
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
|
)
|
|
SELECT 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, i.is_folder
|
|
FROM items i
|
|
JOIN item_people ip ON i.id = ip.item_id
|
|
INNER JOIN downloaded_items di ON i.id = di.id
|
|
WHERE i.server_id = ? AND ip.person_id = ?
|
|
ORDER BY i.production_year DESC, i.sort_name ASC
|
|
LIMIT {}", limit
|
|
),
|
|
vec![
|
|
QueryParam::String(self.server_id.clone()),
|
|
QueryParam::String(person_id.to_string()),
|
|
],
|
|
);
|
|
|
|
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
|
.await
|
|
.map_err(|e| RepoError::Database { message: e })?;
|
|
|
|
let mut items = Vec::new();
|
|
for cached in cached_items {
|
|
let user_data = self.get_user_data(&cached.id).await;
|
|
items.push(Self::cached_item_to_media_item(cached, user_data));
|
|
}
|
|
|
|
let total_record_count = items.len();
|
|
|
|
Ok(SearchResult {
|
|
items,
|
|
total_record_count,
|
|
})
|
|
}
|
|
|
|
async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> {
|
|
// 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, i.is_folder \
|
|
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)?,
|
|
is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
|
|
};
|
|
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)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::storage::db_service::RusqliteService;
|
|
use rusqlite::Connection;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
/// Helper to create a test database with the necessary schema
|
|
fn create_test_db() -> Arc<RusqliteService> {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
|
|
// Enable foreign key constraints (they're disabled by default in SQLite)
|
|
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
|
|
|
// Create minimal schema for testing
|
|
conn.execute_batch(r#"
|
|
CREATE TABLE servers (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
url TEXT NOT NULL UNIQUE
|
|
);
|
|
|
|
CREATE TABLE items (
|
|
id TEXT PRIMARY KEY,
|
|
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
|
library_id TEXT,
|
|
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
item_type TEXT NOT NULL,
|
|
is_folder INTEGER DEFAULT 0,
|
|
overview TEXT,
|
|
genres TEXT,
|
|
runtime_ticks INTEGER,
|
|
production_year INTEGER,
|
|
community_rating REAL,
|
|
official_rating TEXT,
|
|
primary_image_tag TEXT,
|
|
backdrop_image_tags TEXT,
|
|
album_id TEXT,
|
|
album_name TEXT,
|
|
album_artist TEXT,
|
|
artists TEXT,
|
|
index_number INTEGER,
|
|
series_id TEXT,
|
|
series_name TEXT,
|
|
season_id TEXT,
|
|
season_name TEXT,
|
|
parent_index_number INTEGER,
|
|
synced_at TEXT,
|
|
sort_name TEXT
|
|
);
|
|
|
|
CREATE TABLE user_data (
|
|
user_id TEXT NOT NULL,
|
|
item_id TEXT NOT NULL,
|
|
playback_position_ticks INTEGER,
|
|
is_played INTEGER,
|
|
is_favorite INTEGER,
|
|
play_count INTEGER,
|
|
last_played_at TEXT,
|
|
playback_context_type TEXT,
|
|
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
|
|
conn.execute(
|
|
"INSERT INTO servers (id, name, url) VALUES ('test-server', 'Test Server', 'http://test')",
|
|
[],
|
|
).unwrap();
|
|
|
|
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
|
}
|
|
|
|
/// Helper to create a test MediaItem
|
|
fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem {
|
|
MediaItem {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
item_type: "Audio".to_string(),
|
|
is_folder: false,
|
|
server_id: "test-server".to_string(),
|
|
parent_id: parent_id.map(|s| s.to_string()),
|
|
library_id: None,
|
|
overview: None,
|
|
genres: None,
|
|
runtime_ticks: None,
|
|
production_year: None,
|
|
community_rating: None,
|
|
official_rating: 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,
|
|
series_id: None,
|
|
series_name: None,
|
|
season_id: None,
|
|
season_name: None,
|
|
parent_index_number: None,
|
|
user_data: None,
|
|
media_streams: None,
|
|
media_sources: None,
|
|
people: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_save_to_cache_with_missing_parent_fk() {
|
|
let db_service = create_test_db();
|
|
|
|
// Verify FK constraints are actually enabled
|
|
let fk_enabled: i32 = db_service
|
|
.query_one(Query::new("PRAGMA foreign_keys"), |row| row.get(0))
|
|
.await
|
|
.unwrap();
|
|
println!("Foreign keys enabled: {}", fk_enabled);
|
|
assert_eq!(fk_enabled, 1, "Foreign keys should be enabled");
|
|
|
|
let repo = OfflineRepository::new(
|
|
db_service.clone(),
|
|
"test-server".to_string(),
|
|
"test-user".to_string(),
|
|
);
|
|
|
|
// Create items where CHILDREN come BEFORE their PARENTS in the list
|
|
// This simulates the real-world scenario where Jellyfin's API
|
|
// returns items in an arbitrary order (e.g., alphabetically)
|
|
let items = vec![
|
|
// Tracks from Album 1 (album-1 doesn't exist yet!)
|
|
create_test_item("track-1", "Track 1", Some("album-1")),
|
|
create_test_item("track-2", "Track 2", Some("album-1")),
|
|
// Tracks from Album 2 (album-2 doesn't exist yet!)
|
|
create_test_item("track-3", "Track 3", Some("album-2")),
|
|
create_test_item("track-4", "Track 4", Some("album-2")),
|
|
// Albums come later in the list
|
|
create_test_item("album-1", "Album One", Some("library-123")),
|
|
create_test_item("album-2", "Album Two", Some("library-123")),
|
|
];
|
|
|
|
println!("Attempting to save {} items...", items.len());
|
|
for (i, item) in items.iter().enumerate() {
|
|
println!(" Item {}: {} (parent: {:?})", i, item.id, item.parent_id);
|
|
}
|
|
|
|
// This should fail with the current implementation because:
|
|
// 1. It only creates a stub for "library-123" (the parent_id parameter)
|
|
// 2. When it tries to insert track-1 with parent_id = "album-1",
|
|
// album-1 doesn't exist yet, causing FK constraint failure
|
|
let result = repo.save_to_cache("library-123", &items).await;
|
|
|
|
// After the fix, this should succeed and preserve parent relationships
|
|
match &result {
|
|
Ok(count) => {
|
|
println!("✓ Saved {} items", count);
|
|
assert_eq!(*count, 6);
|
|
|
|
// Debug: Check all items in the database
|
|
let all_items: Vec<(String, Option<String>)> = db_service
|
|
.query_many(
|
|
Query::new("SELECT id, parent_id FROM items ORDER BY id"),
|
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
println!("\nAll items in database:");
|
|
for (id, parent) in &all_items {
|
|
println!(" {} -> parent: {:?}", id, parent);
|
|
}
|
|
|
|
// Verify the ACTUAL parent_ids in the database are preserved correctly
|
|
let track1_parent: Option<String> = db_service
|
|
.query_optional(
|
|
Query::with_params(
|
|
"SELECT parent_id FROM items WHERE id = ?",
|
|
vec![QueryParam::String("track-1".to_string())],
|
|
),
|
|
|row| row.get(0),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.flatten();
|
|
|
|
println!("\ntrack-1 parent_id in DB: {:?}", track1_parent);
|
|
println!("track-1 expected parent_id: Some(\"album-1\")");
|
|
|
|
// Verify parent relationships are preserved
|
|
assert_eq!(
|
|
track1_parent,
|
|
Some("album-1".to_string()),
|
|
"track-1 should have parent_id='album-1'"
|
|
);
|
|
|
|
// Verify album-1 has the correct parent too
|
|
let album1_parent: Option<String> = db_service
|
|
.query_optional(
|
|
Query::with_params(
|
|
"SELECT parent_id FROM items WHERE id = ?",
|
|
vec![QueryParam::String("album-1".to_string())],
|
|
),
|
|
|row| row.get(0),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.flatten();
|
|
|
|
assert_eq!(
|
|
album1_parent,
|
|
Some("library-123".to_string()),
|
|
"album-1 should have parent_id='library-123'"
|
|
);
|
|
}
|
|
Err(e) => panic!("Unexpected error: {:?}", e),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_save_to_cache_simple_case() {
|
|
let db_service = create_test_db();
|
|
let repo = OfflineRepository::new(
|
|
db_service.clone(),
|
|
"test-server".to_string(),
|
|
"test-user".to_string(),
|
|
);
|
|
|
|
// Simple case: all items have the same parent_id as the parameter
|
|
let items = vec![
|
|
create_test_item("item-1", "Item 1", Some("parent-123")),
|
|
create_test_item("item-2", "Item 2", Some("parent-123")),
|
|
create_test_item("item-3", "Item 3", Some("parent-123")),
|
|
];
|
|
|
|
let result = repo.save_to_cache("parent-123", &items).await;
|
|
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"]);
|
|
}
|
|
}
|