feat(library): focused music/TV/movie landing screens + self-draining download queue
Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
listened to in a while") albums via a new repository method across
online/offline/hybrid repos plus the repository_get_rediscover_albums
command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.
Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
persist the resolved stream URL + target dir on each row (migration
017), and the pump starts up to max_concurrent and drains the rest
automatically as slots free, instead of the frontend silently dropping
items past the concurrency limit. Album/series/season buttons now
enqueue rather than calling start_download directly.
Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
cache+server union via a request-id-tagged search-event, so superseded
queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
This commit is contained in:
parent
dcf08f30bc
commit
17a35573a0
9
src-tauri/Cargo.lock
generated
9
src-tauri/Cargo.lock
generated
@ -2028,6 +2028,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rusqlite",
|
||||
"tokio-util",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@ -4959,6 +4960,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
@ -5281,7 +5288,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -33,6 +33,7 @@ rand = "0.8"
|
||||
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||
tokio-util = "0.7"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
urlencoding = "2"
|
||||
futures-util = "0.3"
|
||||
async-trait = "0.1"
|
||||
|
||||
|
||||
@ -171,6 +171,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
// Create ExoPlayer with audio focus handling
|
||||
exoPlayer = ExoPlayer.Builder(appContext)
|
||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||
// Pause when the audio output is removed (wired headphones unplugged or
|
||||
// Bluetooth device disconnected). ExoPlayer listens for the system
|
||||
// ACTION_AUDIO_BECOMING_NOISY broadcast, which fires for both cases.
|
||||
// The resulting pause flows through onIsPlayingChanged, keeping Rust and
|
||||
// the lockscreen notification in sync automatically.
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
|
||||
// Set up player listener
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::State;
|
||||
use tauri::{Manager, State};
|
||||
use log::{debug, error, info, warn};
|
||||
|
||||
use crate::download::{DownloadInfo, DownloadManager};
|
||||
@ -818,9 +819,7 @@ pub async fn start_download(
|
||||
stream_url: String,
|
||||
target_dir: String,
|
||||
) -> Result<(), String> {
|
||||
use crate::download::{DownloadTask, DownloadWorker};
|
||||
use crate::download::events::DownloadEvent;
|
||||
use std::path::PathBuf;
|
||||
use tauri::Emitter;
|
||||
|
||||
debug!("start_download called for download_id: {}", download_id);
|
||||
@ -906,16 +905,27 @@ pub async fn start_download(
|
||||
}
|
||||
};
|
||||
|
||||
// Update status to downloading and save file_size if we got it
|
||||
// Update status to downloading and save file_size if we got it.
|
||||
// Also persist the resolved stream URL + target dir so the queue pump can
|
||||
// restart/resume this download by itself if needed.
|
||||
let update_query = if let Some(size) = file_size_from_server {
|
||||
Query::with_params(
|
||||
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ? WHERE id = ?",
|
||||
vec![QueryParam::Int64(size), QueryParam::Int64(download_id)],
|
||||
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?",
|
||||
vec![
|
||||
QueryParam::Int64(size),
|
||||
QueryParam::String(stream_url.clone()),
|
||||
QueryParam::String(target_dir.clone()),
|
||||
QueryParam::Int64(download_id),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
Query::with_params(
|
||||
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?",
|
||||
vec![
|
||||
QueryParam::String(stream_url.clone()),
|
||||
QueryParam::String(target_dir.clone()),
|
||||
QueryParam::Int64(download_id),
|
||||
],
|
||||
)
|
||||
};
|
||||
|
||||
@ -936,29 +946,304 @@ pub async fn start_download(
|
||||
// Build target path
|
||||
let target_path = PathBuf::from(&target_dir).join(&file_path);
|
||||
|
||||
// Create download task
|
||||
let task = DownloadTask {
|
||||
url: stream_url,
|
||||
target_path: target_path.clone(),
|
||||
};
|
||||
|
||||
// Start download in background
|
||||
let app_clone = app.clone();
|
||||
let item_id_clone = item_id.clone();
|
||||
|
||||
// Get a clone of the active downloads Arc for unregistering later
|
||||
let active_downloads = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.get_active_downloads()
|
||||
};
|
||||
|
||||
// Run the worker in the background; on completion/failure it frees the slot
|
||||
// and pumps the next pending download.
|
||||
spawn_download_worker(
|
||||
app.clone(),
|
||||
download_id,
|
||||
item_id,
|
||||
stream_url,
|
||||
target_path,
|
||||
active_downloads,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enqueue a download with its resolved stream URL, then let the queue pump
|
||||
/// start it (or a higher-priority pending item) when a slot is free.
|
||||
///
|
||||
/// Unlike [`start_download`], this never errors when the concurrency limit is
|
||||
/// reached: the URL is persisted on the row and the pump will pick it up once a
|
||||
/// slot frees. This is the path bulk operations (album/series/season) use so
|
||||
/// every queued item eventually downloads without the frontend re-issuing it.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn enqueue_download(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_manager: State<'_, DownloadManagerWrapper>,
|
||||
app: tauri::AppHandle,
|
||||
download_id: i64,
|
||||
stream_url: String,
|
||||
target_dir: String,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// Persist the resolved URL/dir and mark the row pending so the pump can
|
||||
// start it. We don't flip to 'downloading' here — the pump owns that.
|
||||
let update_query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
||||
vec![
|
||||
QueryParam::String(stream_url),
|
||||
QueryParam::String(target_dir),
|
||||
QueryParam::Int64(download_id),
|
||||
],
|
||||
);
|
||||
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Kick the pump: it will start as many pending downloads as there are slots.
|
||||
let active_downloads = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.get_active_downloads()
|
||||
};
|
||||
pump_download_queue(app, db_service, active_downloads).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enqueue a batch of already-queued video downloads, resolving each one's
|
||||
/// transcode URL from the repository using the `quality_preset` stored on the
|
||||
/// row. Then let the pump start them subject to the concurrency limit.
|
||||
///
|
||||
/// This is the bulk video path (series/season): `download_series`/
|
||||
/// `download_season` insert the rows, then this resolves URLs and enqueues them
|
||||
/// so they actually start. Resolving server-side avoids round-tripping every
|
||||
/// episode URL through the frontend.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn enqueue_video_downloads(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_manager: State<'_, DownloadManagerWrapper>,
|
||||
repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
|
||||
app: tauri::AppHandle,
|
||||
handle: String,
|
||||
download_ids: Vec<i64>,
|
||||
target_dir: String,
|
||||
) -> Result<(), String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
for download_id in download_ids {
|
||||
// Read the item + quality preset for this queued download.
|
||||
let info_query = Query::with_params(
|
||||
"SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?",
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
let (item_id, quality): (String, String) = match db_service
|
||||
.query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.await
|
||||
{
|
||||
Ok(row) => row,
|
||||
Err(e) => {
|
||||
warn!("[enqueue_video] Skipping download {}: {}", download_id, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Build the transcode URL (pure URL builder, no server round-trip).
|
||||
let stream_url = repo.as_ref().get_video_download_url(&item_id, &quality, None);
|
||||
|
||||
let update_query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
||||
vec![
|
||||
QueryParam::String(stream_url),
|
||||
QueryParam::String(target_dir.clone()),
|
||||
QueryParam::Int64(download_id),
|
||||
],
|
||||
);
|
||||
if let Err(e) = db_service.execute(update_query).await {
|
||||
warn!("[enqueue_video] Failed to persist URL for download {}: {}", download_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Pump once: starts up to max_concurrent, the rest drain as slots free.
|
||||
let active_downloads = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.get_active_downloads()
|
||||
};
|
||||
pump_download_queue(app, db_service, active_downloads).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start as many pending downloads as there are free concurrency slots.
|
||||
///
|
||||
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
|
||||
/// (FIFO within a priority), registers each, flips it to `downloading`, and
|
||||
/// spawns a worker. Each spawned worker calls this again on completion/failure,
|
||||
/// so the queue drains itself without any frontend involvement.
|
||||
async fn pump_download_queue(
|
||||
app: tauri::AppHandle,
|
||||
db_service: Arc<crate::storage::db_service::RusqliteService>,
|
||||
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
||||
) {
|
||||
use crate::download::events::DownloadEvent;
|
||||
use tauri::Emitter;
|
||||
|
||||
let max_concurrent = {
|
||||
let manager = app.state::<DownloadManagerWrapper>();
|
||||
let manager = match manager.0.lock() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to lock download manager: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
manager.max_concurrent()
|
||||
};
|
||||
|
||||
loop {
|
||||
// How many slots are free right now?
|
||||
let free_slots = {
|
||||
let active = match active_downloads.lock() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to lock active downloads: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
max_concurrent.saturating_sub(active.len())
|
||||
};
|
||||
if free_slots == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the next pending, startable download (has a stream URL). Exclude
|
||||
// anything already registered as active to avoid double-starting.
|
||||
let next_query = Query::with_params(
|
||||
"SELECT id, item_id, file_path, stream_url, target_dir
|
||||
FROM downloads
|
||||
WHERE status = 'pending'
|
||||
AND stream_url IS NOT NULL
|
||||
AND target_dir IS NOT NULL
|
||||
ORDER BY priority DESC, queued_at ASC",
|
||||
vec![],
|
||||
);
|
||||
|
||||
let candidates: Vec<(i64, String, String, String, String)> = match db_service
|
||||
.query_many(next_query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to query pending downloads: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Pick the first candidate not already active.
|
||||
let next = candidates.into_iter().find(|(id, _, _, _, _)| {
|
||||
active_downloads
|
||||
.lock()
|
||||
.map(|active| !active.contains(id))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
let (download_id, item_id, file_path, stream_url, target_dir) = match next {
|
||||
Some(n) => n,
|
||||
None => return, // Nothing pending to start
|
||||
};
|
||||
|
||||
// Register the slot. If registration fails (race: another pump filled
|
||||
// the last slot), stop — we'll be re-pumped when a slot frees.
|
||||
{
|
||||
let manager = app.state::<DownloadManagerWrapper>();
|
||||
let manager = match manager.0.lock() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to lock download manager: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !manager.register_download(download_id) {
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
"[pump] Download {} started. Active downloads: {}/{}",
|
||||
download_id,
|
||||
manager.active_count(),
|
||||
manager.max_concurrent()
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as downloading and stamp started_at.
|
||||
let update_query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
if let Err(e) = db_service.execute(update_query).await {
|
||||
error!("[pump] Failed to mark download {} downloading: {}", download_id, e);
|
||||
if let Ok(mut a) = active_downloads.lock() {
|
||||
a.remove(&download_id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Emit started event so the UI flips the row.
|
||||
let _ = app.emit(
|
||||
"download-event",
|
||||
DownloadEvent::Started {
|
||||
download_id,
|
||||
item_id: item_id.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let target_path = PathBuf::from(&target_dir).join(&file_path);
|
||||
spawn_download_worker(
|
||||
app.clone(),
|
||||
download_id,
|
||||
item_id,
|
||||
stream_url,
|
||||
target_path,
|
||||
active_downloads.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the background worker for one download. On completion or failure it
|
||||
/// unregisters the slot, emits the terminal event, and pumps the queue so the
|
||||
/// next pending download starts automatically.
|
||||
fn spawn_download_worker(
|
||||
app: tauri::AppHandle,
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
stream_url: String,
|
||||
target_path: std::path::PathBuf,
|
||||
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
||||
) {
|
||||
use crate::download::{DownloadTask, DownloadWorker};
|
||||
use crate::download::events::DownloadEvent;
|
||||
use tauri::Emitter;
|
||||
|
||||
let task = DownloadTask {
|
||||
url: stream_url,
|
||||
target_path: target_path.clone(),
|
||||
};
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
debug!("Download task started for download_id: {}", download_id);
|
||||
let worker = DownloadWorker::new();
|
||||
|
||||
// Progress callback that emits events to the frontend
|
||||
let progress_app = app_clone.clone();
|
||||
let progress_item_id = item_id_clone.clone();
|
||||
let progress_app = app.clone();
|
||||
let progress_item_id = item_id.clone();
|
||||
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
|
||||
let progress = total_bytes
|
||||
.filter(|&t| t > 0)
|
||||
@ -975,54 +1260,55 @@ pub async fn start_download(
|
||||
let _ = progress_app.emit("download-event", event);
|
||||
};
|
||||
|
||||
match worker.download(&task, on_progress).await {
|
||||
Ok(result) => {
|
||||
info!("Download completed successfully: {} bytes", result.bytes_downloaded);
|
||||
let result = worker.download(&task, on_progress).await;
|
||||
|
||||
// Unregister from download manager
|
||||
// Free the slot before pumping so the next download can take it.
|
||||
if let Ok(mut active) = active_downloads.lock() {
|
||||
active.remove(&download_id);
|
||||
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
|
||||
}
|
||||
|
||||
// Emit completed event - the frontend will handle state updates
|
||||
match result {
|
||||
Ok(res) => {
|
||||
info!("Download completed successfully: {} bytes", res.bytes_downloaded);
|
||||
let completed_event = DownloadEvent::Completed {
|
||||
download_id,
|
||||
item_id: item_id_clone,
|
||||
item_id,
|
||||
file_path: target_path.to_string_lossy().to_string(),
|
||||
};
|
||||
debug!("Emitting completed event: {:?}", completed_event);
|
||||
debug!(" Serialized: {}", serde_json::to_string(&completed_event).unwrap_or_default());
|
||||
match app_clone.emit("download-event", completed_event) {
|
||||
match app.emit("download-event", completed_event) {
|
||||
Ok(_) => debug!(" Completed event emitted successfully"),
|
||||
Err(e) => error!(" Completed event emit failed: {:?}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Download failed: {:?}", e);
|
||||
|
||||
// Unregister from download manager
|
||||
if let Ok(mut active) = active_downloads.lock() {
|
||||
active.remove(&download_id);
|
||||
debug!(" Unregistered failed download {}. Active downloads: {}", download_id, active.len());
|
||||
}
|
||||
|
||||
// Emit failed event - the frontend will handle state updates
|
||||
let failed_event = DownloadEvent::Failed {
|
||||
download_id,
|
||||
item_id: item_id_clone.clone(),
|
||||
item_id,
|
||||
error: e.to_string(),
|
||||
};
|
||||
debug!("Emitting failed event: {:?}", failed_event);
|
||||
match app_clone.emit("download-event", failed_event) {
|
||||
match app.emit("download-event", failed_event) {
|
||||
Ok(_) => debug!(" Failed event emitted successfully"),
|
||||
Err(e) => error!(" Failed event emit failed: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
// A slot just freed — start the next pending download (if any).
|
||||
let db_service = {
|
||||
let db = app.state::<DatabaseWrapper>();
|
||||
let database = match db.0.lock() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
Arc::new(database.service())
|
||||
};
|
||||
pump_download_queue(app.clone(), db_service, active_downloads).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete a completed download
|
||||
|
||||
@ -7,8 +7,9 @@ use crate::utils::lock::MutexSafe;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use log::{debug, error, info};
|
||||
use tauri::State;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jellyfin::HttpClient;
|
||||
@ -259,6 +260,21 @@ pub async fn repository_get_resume_movies(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get albums the user hasn't listened to recently ("rediscover")
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_rediscover_albums(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
parent_id: Option<String>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_rediscover_albums(parent_id.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get genres for a library
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@ -273,19 +289,73 @@ pub async fn repository_get_genres(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Tauri event name carrying the merged (cache + server) search results.
|
||||
pub const SEARCH_EVENT_NAME: &str = "search-event";
|
||||
|
||||
/// Payload for the deferred, merged search results pushed to the frontend.
|
||||
///
|
||||
/// `request_id` matches the value the frontend passed to `repository_search`,
|
||||
/// letting it discard updates from queries that have since been superseded.
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchUpdateEvent {
|
||||
pub request_id: u32,
|
||||
pub result: SearchResult,
|
||||
}
|
||||
|
||||
/// Search for items
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_search(
|
||||
app: AppHandle,
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
query: String,
|
||||
options: Option<SearchOptions>,
|
||||
request_id: u32,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().search(&query, options)
|
||||
|
||||
// Phase 1: instant local results from the cache (downloaded content) so the
|
||||
// UI can render immediately while the server is still being queried.
|
||||
let cache_result = repo
|
||||
.search_cache_only(&query, options.clone())
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
.unwrap_or_else(|e| {
|
||||
debug!("[Search] Cache search miss/timeout: {:?}", e);
|
||||
SearchResult {
|
||||
items: Vec::new(),
|
||||
total_record_count: 0,
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 2: query the live server in the background, merge with the cache,
|
||||
// and push the union to the frontend via a `search-event`. Tagged with
|
||||
// `request_id` so the frontend can discard results from superseded queries.
|
||||
let repo_bg = repo.clone();
|
||||
let cache_for_merge = cache_result.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match repo_bg.search_server_only(&query, options).await {
|
||||
Ok(server_result) => {
|
||||
let merged =
|
||||
HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||
let event = SearchUpdateEvent {
|
||||
request_id,
|
||||
result: merged,
|
||||
};
|
||||
if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
|
||||
error!("[Search] Failed to emit search update: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Server failed — the cache results are already on screen, so
|
||||
// just log. (Offline / unreachable server falls here.)
|
||||
warn!("[Search] Server search failed, keeping cache results: {:?}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(cache_result)
|
||||
}
|
||||
|
||||
/// Get playback info for an item
|
||||
|
||||
@ -28,7 +28,7 @@ use commands::{
|
||||
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
|
||||
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
|
||||
get_album_affinity_status,
|
||||
mark_download_completed, mark_download_failed, start_download,
|
||||
mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads,
|
||||
pin_item, unpin_item, is_item_pinned,
|
||||
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
|
||||
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
|
||||
@ -96,6 +96,7 @@ use commands::{
|
||||
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
|
||||
repository_get_item, repository_get_latest_items, repository_get_resume_items,
|
||||
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
|
||||
repository_get_rediscover_albums,
|
||||
repository_get_genres, repository_search, repository_get_playback_info,
|
||||
repository_get_video_stream_url, repository_get_audio_stream_url,
|
||||
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
|
||||
@ -493,6 +494,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
mark_download_completed,
|
||||
mark_download_failed,
|
||||
start_download,
|
||||
enqueue_download,
|
||||
enqueue_video_downloads,
|
||||
get_download_manager_stats,
|
||||
set_max_concurrent_downloads,
|
||||
get_smart_cache_stats,
|
||||
@ -552,6 +555,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_next_up_episodes,
|
||||
repository_get_recently_played_audio,
|
||||
repository_get_resume_movies,
|
||||
repository_get_rediscover_albums,
|
||||
repository_get_genres,
|
||||
repository_search,
|
||||
repository_get_playback_info,
|
||||
|
||||
@ -57,6 +57,81 @@ impl HybridRepository {
|
||||
self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await
|
||||
}
|
||||
|
||||
/// Search only the local SQLite cache (downloaded content).
|
||||
///
|
||||
/// Fast (100ms timeout) — used to render instant results before the server
|
||||
/// responds. Returns an empty result rather than erroring on timeout so the
|
||||
/// caller can still fall through to the server.
|
||||
pub async fn search_cache_only(
|
||||
&self,
|
||||
query: &str,
|
||||
options: Option<SearchOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let query = query.to_string();
|
||||
self.cache_with_timeout(async move { offline.search(&query, options).await })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Search only the live Jellyfin server (full library).
|
||||
pub async fn search_server_only(
|
||||
&self,
|
||||
query: &str,
|
||||
options: Option<SearchOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
self.online.search(query, options).await
|
||||
}
|
||||
|
||||
/// Merge cache and server search results into a single de-duplicated list.
|
||||
///
|
||||
/// Ordering: local (cached/downloaded) items first, then server-only items
|
||||
/// appended. On a duplicate `id`, the server's item wins (fresher, more
|
||||
/// complete metadata) but keeps the local item's earlier position.
|
||||
pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Index server items by id so we can (a) override duplicates with the
|
||||
// server's metadata and (b) know which server items are brand new.
|
||||
let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
|
||||
let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
|
||||
for item in server.items {
|
||||
if !server_by_id.contains_key(&item.id) {
|
||||
server_order.push(item.id.clone());
|
||||
}
|
||||
server_by_id.insert(item.id.clone(), item);
|
||||
}
|
||||
|
||||
let mut items: Vec<MediaItem> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
// Local items first, in their original order. If the server also
|
||||
// returned this item, take the server's copy (newer metadata).
|
||||
for local in cache.items {
|
||||
if !seen.insert(local.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
match server_by_id.remove(&local.id) {
|
||||
Some(server_item) => items.push(server_item),
|
||||
None => items.push(local),
|
||||
}
|
||||
}
|
||||
|
||||
// Then append server-only items, preserving the server's order.
|
||||
for id in server_order {
|
||||
if let Some(server_item) = server_by_id.remove(&id) {
|
||||
if seen.insert(id) {
|
||||
items.push(server_item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_record_count = items.len();
|
||||
SearchResult {
|
||||
items,
|
||||
total_record_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache-first query: try cache, fall back to server on miss.
|
||||
///
|
||||
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
|
||||
@ -274,6 +349,27 @@ impl MediaRepository for HybridRepository {
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_rediscover_albums(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let parent_id_owned = parent_id.map(|s| s.to_string());
|
||||
let parent_id_clone = parent_id_owned.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_rediscover_albums(parent_id_owned.as_deref(), limit).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_rediscover_albums(parent_id_clone.as_deref(), limit).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
@ -602,6 +698,14 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_rediscover_albums(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@ -759,6 +863,14 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_rediscover_albums(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@ -1058,4 +1170,75 @@ mod tests {
|
||||
};
|
||||
assert!(result_with_items.has_content(), "Result with items should have content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_search_local_first_then_server_appended() {
|
||||
let cache = SearchResult {
|
||||
items: vec![
|
||||
create_test_item("a", "Cached A"),
|
||||
create_test_item("b", "Cached B"),
|
||||
],
|
||||
total_record_count: 2,
|
||||
};
|
||||
let server = SearchResult {
|
||||
items: vec![
|
||||
create_test_item("c", "Server C"),
|
||||
create_test_item("d", "Server D"),
|
||||
],
|
||||
total_record_count: 2,
|
||||
};
|
||||
|
||||
let merged = HybridRepository::merge_search_results(cache, server);
|
||||
|
||||
// Local items first (in order), then server-only items appended.
|
||||
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["a", "b", "c", "d"]);
|
||||
assert_eq!(merged.total_record_count, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_search_dedupes_with_server_winning() {
|
||||
// "b" appears in both. Server metadata should win, but the item keeps
|
||||
// its earlier (local) position and is not duplicated.
|
||||
let cache = SearchResult {
|
||||
items: vec![
|
||||
create_test_item("a", "Cached A"),
|
||||
create_test_item("b", "Cached B"),
|
||||
],
|
||||
total_record_count: 2,
|
||||
};
|
||||
let server = SearchResult {
|
||||
items: vec![
|
||||
create_test_item("b", "Server B (fresher)"),
|
||||
create_test_item("c", "Server C"),
|
||||
],
|
||||
total_record_count: 2,
|
||||
};
|
||||
|
||||
let merged = HybridRepository::merge_search_results(cache, server);
|
||||
|
||||
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["a", "b", "c"], "no duplicate, local position kept");
|
||||
|
||||
let b = merged.items.iter().find(|i| i.id == "b").unwrap();
|
||||
assert_eq!(b.name, "Server B (fresher)", "server metadata wins on conflict");
|
||||
assert_eq!(merged.total_record_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_search_handles_empty_sides() {
|
||||
let only_server = HybridRepository::merge_search_results(
|
||||
SearchResult { items: vec![], total_record_count: 0 },
|
||||
SearchResult { items: vec![create_test_item("x", "X")], total_record_count: 1 },
|
||||
);
|
||||
assert_eq!(only_server.items.len(), 1);
|
||||
assert_eq!(only_server.items[0].id, "x");
|
||||
|
||||
let only_cache = HybridRepository::merge_search_results(
|
||||
SearchResult { items: vec![create_test_item("y", "Y")], total_record_count: 1 },
|
||||
SearchResult { items: vec![], total_record_count: 0 },
|
||||
);
|
||||
assert_eq!(only_cache.items.len(), 1);
|
||||
assert_eq!(only_cache.items[0].id, "y");
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,6 +79,15 @@ pub trait MediaRepository: Send + Sync {
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get albums the user has played, but not recently ("rediscover" / haven't
|
||||
/// listened to in a while). Returns albums sorted by least-recently played
|
||||
/// first, optionally restricted to a parent library.
|
||||
async fn get_rediscover_albums(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get resume movies
|
||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
|
||||
@ -778,6 +778,17 @@ impl MediaRepository for OfflineRepository {
|
||||
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);
|
||||
|
||||
|
||||
@ -568,6 +568,16 @@ impl MediaRepository for OnlineRepository {
|
||||
if let Some(recursive) = opts.recursive {
|
||||
endpoint.push_str(&format!("&Recursive={}", recursive));
|
||||
}
|
||||
if let Some(genres) = opts.genres {
|
||||
if !genres.is_empty() {
|
||||
// Genre names may contain spaces/ampersands, so percent-encode each.
|
||||
let encoded: Vec<String> = genres
|
||||
.iter()
|
||||
.map(|g| urlencoding::encode(g).into_owned())
|
||||
.collect();
|
||||
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request image fields for list views (People only needed in get_item detail view)
|
||||
@ -759,6 +769,32 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(final_result)
|
||||
}
|
||||
|
||||
async fn get_rediscover_albums(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_val = limit.unwrap_or(12);
|
||||
// Ask Jellyfin for played albums sorted by least-recently played first.
|
||||
// Filters=IsPlayed keeps only albums the user has actually listened to,
|
||||
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
|
||||
let mut endpoint = format!(
|
||||
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||
self.user_id, limit_val
|
||||
);
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
endpoint.push_str(&format!("&ParentId={}", pid));
|
||||
}
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
Ok(response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_str = limit.unwrap_or(16);
|
||||
let endpoint = format!(
|
||||
@ -811,14 +847,24 @@ impl MediaRepository for OnlineRepository {
|
||||
options: Option<SearchOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
|
||||
// SearchTerm is arbitrary user input and must be percent-encoded so that
|
||||
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
|
||||
// search like "Star Wars" would otherwise produce a malformed URL).
|
||||
let mut endpoint = format!(
|
||||
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
|
||||
self.user_id, query, limit
|
||||
self.user_id,
|
||||
urlencoding::encode(query),
|
||||
limit
|
||||
);
|
||||
|
||||
if let Some(opts) = options {
|
||||
if let Some(types) = opts.include_item_types {
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
|
||||
let encoded_types = types
|
||||
.iter()
|
||||
.map(|t| urlencoding::encode(t).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1768,4 +1814,13 @@ mod tests {
|
||||
assert_eq!(response.items[0].id, "item1");
|
||||
assert_eq!(response.items[1].id, "item2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_term_is_url_encoded() {
|
||||
// A multi-word query (and one with a reserved character) must be
|
||||
// percent-encoded before being placed in the SearchTerm query param,
|
||||
// otherwise the request URL is malformed and search returns nothing.
|
||||
assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
|
||||
assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("014_series_audio_preferences", MIGRATION_014),
|
||||
("015_device_id", MIGRATION_015),
|
||||
("016_autoplay_max_episodes", MIGRATION_016),
|
||||
("017_downloads_resume_url", MIGRATION_017),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@ -667,3 +668,15 @@ const MIGRATION_016: &str = r#"
|
||||
ALTER TABLE user_player_settings
|
||||
ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
|
||||
"#;
|
||||
|
||||
/// Migration to persist the resolved stream URL and target directory on each
|
||||
/// download row. This lets the backend queue pump start a pending download by
|
||||
/// itself (replaying the stored URL) once a concurrency slot frees up, instead
|
||||
/// of relying on the frontend to re-issue every queued item.
|
||||
const MIGRATION_017: &str = r#"
|
||||
-- Resolved download source URL and on-disk target directory, captured when the
|
||||
-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a
|
||||
-- URL simply won't be auto-started by the pump.
|
||||
ALTER TABLE downloads ADD COLUMN stream_url TEXT;
|
||||
ALTER TABLE downloads ADD COLUMN target_dir TEXT;
|
||||
"#;
|
||||
|
||||
@ -726,6 +726,31 @@ async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<nul
|
||||
async startDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("start_download", { downloadId, streamUrl, targetDir });
|
||||
},
|
||||
/**
|
||||
* Enqueue a download with its resolved stream URL, then let the queue pump
|
||||
* start it (or a higher-priority pending item) when a slot is free.
|
||||
*
|
||||
* Unlike [`start_download`], this never errors when the concurrency limit is
|
||||
* reached: the URL is persisted on the row and the pump will pick it up once a
|
||||
* slot frees. This is the path bulk operations (album/series/season) use so
|
||||
* every queued item eventually downloads without the frontend re-issuing it.
|
||||
*/
|
||||
async enqueueDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("enqueue_download", { downloadId, streamUrl, targetDir });
|
||||
},
|
||||
/**
|
||||
* Enqueue a batch of already-queued video downloads, resolving each one's
|
||||
* transcode URL from the repository using the `quality_preset` stored on the
|
||||
* row. Then let the pump start them subject to the concurrency limit.
|
||||
*
|
||||
* This is the bulk video path (series/season): `download_series`/
|
||||
* `download_season` insert the rows, then this resolves URLs and enqueues them
|
||||
* so they actually start. Resolving server-side avoids round-tripping every
|
||||
* episode URL through the frontend.
|
||||
*/
|
||||
async enqueueVideoDownloads(handle: string, downloadIds: number[], targetDir: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("enqueue_video_downloads", { handle, downloadIds, targetDir });
|
||||
},
|
||||
/**
|
||||
* Get download manager statistics
|
||||
*/
|
||||
@ -1037,6 +1062,12 @@ async repositoryGetRecentlyPlayedAudio(handle: string, limit: number | null) : P
|
||||
async repositoryGetResumeMovies(handle: string, limit: number | null) : Promise<MediaItem[]> {
|
||||
return await TAURI_INVOKE("repository_get_resume_movies", { handle, limit });
|
||||
},
|
||||
/**
|
||||
* Get albums the user hasn't listened to recently ("rediscover")
|
||||
*/
|
||||
async repositoryGetRediscoverAlbums(handle: string, parentId: string | null, limit: number | null) : Promise<MediaItem[]> {
|
||||
return await TAURI_INVOKE("repository_get_rediscover_albums", { handle, parentId, limit });
|
||||
},
|
||||
/**
|
||||
* Get genres for a library
|
||||
*/
|
||||
@ -1046,8 +1077,8 @@ async repositoryGetGenres(handle: string, parentId: string | null) : Promise<Gen
|
||||
/**
|
||||
* Search for items
|
||||
*/
|
||||
async repositorySearch(handle: string, query: string, options: SearchOptions | null) : Promise<SearchResult> {
|
||||
return await TAURI_INVOKE("repository_search", { handle, query, options });
|
||||
async repositorySearch(handle: string, query: string, options: SearchOptions | null, requestId: number) : Promise<SearchResult> {
|
||||
return await TAURI_INVOKE("repository_search", { handle, query, options, requestId });
|
||||
},
|
||||
/**
|
||||
* Get playback info for an item
|
||||
|
||||
@ -334,6 +334,7 @@ describe("RepositoryClient", () => {
|
||||
includeItemTypes: ["Audio"],
|
||||
limit: 100,
|
||||
},
|
||||
requestId: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -109,12 +109,17 @@ export class RepositoryClient {
|
||||
return commands.repositoryGetResumeMovies(this.ensureHandle(), limit ?? null);
|
||||
}
|
||||
|
||||
/** Albums the user has played but not listened to recently ("rediscover"). */
|
||||
async getRediscoverAlbums(parentId?: string, limit?: number): Promise<MediaItem[]> {
|
||||
return commands.repositoryGetRediscoverAlbums(this.ensureHandle(), parentId ?? null, limit ?? null);
|
||||
}
|
||||
|
||||
async getGenres(parentId?: string): Promise<Genre[]> {
|
||||
return commands.repositoryGetGenres(this.ensureHandle(), parentId ?? null);
|
||||
}
|
||||
|
||||
async search(query: string, options?: SearchOptions): Promise<SearchResult> {
|
||||
return commands.repositorySearch(this.ensureHandle(), query, options ?? null);
|
||||
async search(query: string, options?: SearchOptions, requestId = 0): Promise<SearchResult> {
|
||||
return commands.repositorySearch(this.ensureHandle(), query, options ?? null, requestId);
|
||||
}
|
||||
|
||||
// ===== Playback Methods (all via Rust) =====
|
||||
|
||||
@ -91,15 +91,18 @@
|
||||
// Get target directory for downloads
|
||||
const targetDir = await commands.storageGetPath();
|
||||
|
||||
// Start each queued track download
|
||||
// Enqueue each track with its resolved stream URL. The backend queue
|
||||
// pump starts up to max_concurrent at a time and advances through the
|
||||
// rest automatically as slots free up — so we never hit (and silently
|
||||
// drop) the concurrency limit the way startDownload did.
|
||||
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
|
||||
try {
|
||||
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
|
||||
if (streamUrl) {
|
||||
await commands.startDownload(downloadIds[i], streamUrl, targetDir);
|
||||
await commands.enqueueDownload(downloadIds[i], streamUrl, targetDir);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to start download for track ${tracks[i].id}:`, e);
|
||||
console.error(`Failed to enqueue download for track ${tracks[i].id}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
123
src/lib/components/library/AlphabetScrollBar.svelte
Normal file
123
src/lib/components/library/AlphabetScrollBar.svelte
Normal file
@ -0,0 +1,123 @@
|
||||
<!-- TRACES: UR-007 | DR-007 -->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A vertical A-Z index strip for long, alphabetically-sorted lists.
|
||||
* Tapping or dragging a letter jumps to the first item that starts with it.
|
||||
*
|
||||
* The parent owns the actual scrolling: it passes `availableLetters`
|
||||
* (which letters have items) and an `onJump(letter)` callback.
|
||||
*/
|
||||
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
||||
const HASH = "#"; // bucket for names starting with a digit/symbol
|
||||
|
||||
interface Props {
|
||||
/** Uppercase letters (or "#") that currently have at least one item. */
|
||||
availableLetters: Set<string>;
|
||||
/** Called with the chosen letter when the user picks one. */
|
||||
onJump: (letter: string) => void;
|
||||
/**
|
||||
* CSS length reserved at the bottom of the viewport for the bottom nav /
|
||||
* mini-player bars. The strip stretches to fill the space between the top
|
||||
* sticky offset and this gap, so it ends just above those bars.
|
||||
*/
|
||||
bottomGap?: string;
|
||||
}
|
||||
|
||||
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
|
||||
|
||||
// The strip stretches to fill the height of its scroll container (the page's
|
||||
// <main>, which sits below the sticky header) from the sticky top offset down
|
||||
// to the bottom gap reserved for the nav / mini-player bars. We measure the
|
||||
// container's visible height live so it stays correct regardless of header
|
||||
// size, platform, or window resizes.
|
||||
let container = $state<HTMLDivElement | null>(null);
|
||||
let viewportHeight = $state(0);
|
||||
|
||||
function measure() {
|
||||
const scroller = container?.closest("main") ?? document.documentElement;
|
||||
viewportHeight = scroller.clientHeight;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
measure();
|
||||
const scroller = container?.closest("main");
|
||||
const ro = new ResizeObserver(measure);
|
||||
if (scroller) ro.observe(scroller);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
});
|
||||
|
||||
const letters = $derived([HASH, ...ALPHABET]);
|
||||
let activeLetter = $state<string | null>(null);
|
||||
|
||||
function jumpTo(letter: string) {
|
||||
if (!availableLetters.has(letter)) return;
|
||||
activeLetter = letter;
|
||||
onJump(letter);
|
||||
}
|
||||
|
||||
// Allow dragging a finger/mouse down the strip to scrub through letters.
|
||||
function letterAt(clientY: number): string | null {
|
||||
const el = document.elementFromPoint(
|
||||
// x is supplied by the caller via the bound container; we read from the
|
||||
// element under the pointer instead to stay layout-agnostic.
|
||||
lastClientX,
|
||||
clientY
|
||||
);
|
||||
const letter = el?.getAttribute?.("data-letter");
|
||||
return letter ?? null;
|
||||
}
|
||||
|
||||
let lastClientX = $state(0);
|
||||
let isScrubbing = $state(false);
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
isScrubbing = true;
|
||||
lastClientX = e.clientX;
|
||||
const letter = letterAt(e.clientY);
|
||||
if (letter) jumpTo(letter);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!isScrubbing) return;
|
||||
lastClientX = e.clientX;
|
||||
const letter = letterAt(e.clientY);
|
||||
if (letter && letter !== activeLetter) jumpTo(letter);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
isScrubbing = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="flex flex-col items-center justify-between select-none touch-none py-1"
|
||||
style="height: calc({viewportHeight}px - 1.5rem - {bottomGap})"
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
role="navigation"
|
||||
aria-label="Jump to letter"
|
||||
>
|
||||
{#each letters as letter (letter)}
|
||||
{@const enabled = availableLetters.has(letter)}
|
||||
<button
|
||||
type="button"
|
||||
data-letter={letter}
|
||||
disabled={!enabled}
|
||||
onclick={() => jumpTo(letter)}
|
||||
class="w-5 leading-tight text-[10px] sm:text-xs font-semibold transition-colors
|
||||
{enabled ? 'text-gray-400 hover:text-[var(--color-jellyfin)]' : 'text-gray-700 cursor-default'}
|
||||
{activeLetter === letter && enabled ? 'text-[var(--color-jellyfin)] scale-125' : ''}"
|
||||
aria-label={`Jump to ${letter}`}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@ -4,6 +4,8 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
|
||||
import { isAndroid } from "$lib/stores/appState";
|
||||
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
||||
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
|
||||
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
|
||||
@ -13,6 +15,8 @@
|
||||
import type { MediaItem, Library, ItemType } from "$lib/api/types";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import TrackList from "./TrackList.svelte";
|
||||
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
|
||||
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
||||
|
||||
/**
|
||||
* Generic media list page supporting Albums, Artists, Playlists, and Tracks
|
||||
@ -41,6 +45,7 @@
|
||||
|
||||
let items = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let gridWrapper = $state<HTMLDivElement | null>(null);
|
||||
let searchQuery = $state("");
|
||||
let debouncedSearchQuery = $state("");
|
||||
let sortBy = $state<string>("");
|
||||
@ -74,12 +79,13 @@
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Use backend search if search query is provided, otherwise use getItems with sort
|
||||
// HACK: excludePodcasts drops the "Podcasts" folder stored in the music library.
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
const result = await repo.search(debouncedSearchQuery, {
|
||||
includeItemTypes: [config.itemType],
|
||||
limit: 10000,
|
||||
});
|
||||
items = result.items;
|
||||
items = excludePodcasts(result.items);
|
||||
} else {
|
||||
const result = await repo.getItems($currentLibrary.id, {
|
||||
includeItemTypes: [config.itemType],
|
||||
@ -88,7 +94,7 @@
|
||||
recursive: true,
|
||||
limit: 10000,
|
||||
});
|
||||
items = result.items;
|
||||
items = excludePodcasts(result.items);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to load ${config.itemType}:`, e);
|
||||
@ -142,6 +148,54 @@
|
||||
goto(`/library/${track.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== A-Z jump bar =====
|
||||
// Bucket a name to its index letter: A-Z, or "#" for digits/symbols/empty.
|
||||
function letterFor(name: string): string {
|
||||
const first = (name ?? "").trim().charAt(0).toUpperCase();
|
||||
return first >= "A" && first <= "Z" ? first : "#";
|
||||
}
|
||||
|
||||
// Only meaningful when the list is sorted alphabetically and long enough to scroll.
|
||||
const isAlphaSorted = $derived(sortBy === "SortName");
|
||||
const showAlphaBar = $derived(
|
||||
isAlphaSorted &&
|
||||
!loading &&
|
||||
!debouncedSearchQuery.trim() &&
|
||||
items.length > 30
|
||||
);
|
||||
|
||||
const availableLetters = $derived.by(() => {
|
||||
const set = new Set<string>();
|
||||
if (showAlphaBar) {
|
||||
for (const item of items) set.add(letterFor(item.name));
|
||||
}
|
||||
return set;
|
||||
});
|
||||
|
||||
// First item index for each letter, honouring current ascending/descending order.
|
||||
const firstIndexForLetter = $derived.by(() => {
|
||||
const map = new Map<string, number>();
|
||||
items.forEach((item, index) => {
|
||||
const letter = letterFor(item.name);
|
||||
if (!map.has(letter)) map.set(letter, index);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
function jumpToLetter(letter: string) {
|
||||
const index = firstIndexForLetter.get(letter);
|
||||
if (index === undefined || !gridWrapper) return;
|
||||
const target = gridWrapper.querySelector(`[data-grid-index="${index}"]`);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
|
||||
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
|
||||
// whichever bars are visible.
|
||||
const bottomGap = $derived(
|
||||
$shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem"
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@ -192,10 +246,19 @@
|
||||
<p>No {config.title.toLowerCase()} found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-2">
|
||||
<div bind:this={gridWrapper} class="flex-1 min-w-0">
|
||||
{#if config.displayComponent === "grid"}
|
||||
<LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} />
|
||||
{:else if config.displayComponent === "tracklist"}
|
||||
<TrackList tracks={items} onTrackClick={handleTrackClick} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if showAlphaBar}
|
||||
<div class="sticky top-2 self-start flex-shrink-0 h-fit">
|
||||
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} {bottomGap} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@ -69,12 +69,14 @@
|
||||
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{#each items as item (item.id)}
|
||||
{#each items as item, index (item.id)}
|
||||
<div data-grid-index={index}>
|
||||
<MediaCard
|
||||
{item}
|
||||
showProgress={true}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@ -68,6 +68,7 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-grid-index={index}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
|
||||
>
|
||||
|
||||
@ -71,6 +71,13 @@
|
||||
|
||||
// Pin the season item
|
||||
await downloads.pinItem(seasonId);
|
||||
|
||||
// Resolve each episode's transcode URL (server-side) and enqueue. The
|
||||
// backend pump then starts up to max_concurrent and advances through the
|
||||
// rest as slots free up.
|
||||
const handle = auth.getRepository().getHandle();
|
||||
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
|
||||
console.log(" Episodes enqueued; backend pump will start them");
|
||||
} catch (error) {
|
||||
console.error("Failed to start season download:", error);
|
||||
} finally {
|
||||
|
||||
@ -64,9 +64,12 @@
|
||||
// Pin the series item
|
||||
await downloads.pinItem(seriesId);
|
||||
|
||||
// Start downloads (the backend will handle queuing)
|
||||
// For now, we'll rely on a download manager to pick them up
|
||||
// TODO: Implement batch download start
|
||||
// Resolve each episode's transcode URL (server-side) and enqueue. The
|
||||
// backend pump then starts up to max_concurrent and advances through the
|
||||
// rest as slots free up.
|
||||
const handle = auth.getRepository().getHandle();
|
||||
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
|
||||
console.log(" Episodes enqueued; backend pump will start them");
|
||||
} catch (error) {
|
||||
console.error("Failed to start series download:", error);
|
||||
} finally {
|
||||
|
||||
@ -204,7 +204,7 @@
|
||||
<!-- Track Rows -->
|
||||
<div class="space-y-1">
|
||||
{#each tracks as track, index (track.id)}
|
||||
<div class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}">
|
||||
<div data-grid-index={index} class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}">
|
||||
<!-- Desktop View -->
|
||||
<button
|
||||
onclick={() => handleTrackClick(track, index)}
|
||||
|
||||
@ -2,9 +2,19 @@
|
||||
// TRACES: UR-007, UR-008, UR-029, UR-030 | DR-007, DR-011, DR-033
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
|
||||
/**
|
||||
* Payload of the backend `search-event` (mirrors Rust `SearchUpdateEvent`).
|
||||
* Carries the merged cache+server results for a given search request.
|
||||
*/
|
||||
interface SearchUpdateEvent {
|
||||
requestId: number;
|
||||
result: SearchResult;
|
||||
}
|
||||
|
||||
export type ViewMode = "grid" | "list";
|
||||
|
||||
interface LibraryState {
|
||||
@ -47,8 +57,23 @@ function createLibraryStore() {
|
||||
|
||||
const { subscribe, set, update } = writable<LibraryState>(initialState);
|
||||
|
||||
// Test log to confirm cache logging is active
|
||||
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
|
||||
// Monotonic id identifying the most recent search request. Each new search
|
||||
// bumps it; the deferred `search-event` (carrying merged cache+server
|
||||
// results) is only applied when its requestId still matches the latest one,
|
||||
// so out-of-order / superseded results never clobber fresher ones.
|
||||
let searchRequestId = 0;
|
||||
let unlistenSearch: UnlistenFn | null = null;
|
||||
|
||||
// Lazily subscribe to backend search updates the first time we search.
|
||||
async function ensureSearchListener() {
|
||||
if (unlistenSearch) return;
|
||||
unlistenSearch = await listen<SearchUpdateEvent>("search-event", (event) => {
|
||||
const { requestId, result } = event.payload;
|
||||
// Ignore results from a query the user has already moved on from.
|
||||
if (requestId !== searchRequestId) return;
|
||||
update((s) => ({ ...s, searchResults: result.items }));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLibraries() {
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
@ -157,11 +182,17 @@ function createLibraryStore() {
|
||||
}
|
||||
|
||||
async function search(query: string) {
|
||||
// Bump the request id for every call (including clears) so any in-flight
|
||||
// backend update for a previous query is ignored when it arrives.
|
||||
const requestId = ++searchRequestId;
|
||||
|
||||
if (!query.trim()) {
|
||||
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureSearchListener();
|
||||
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null, searchQuery: query }));
|
||||
|
||||
try {
|
||||
@ -172,16 +203,21 @@ function createLibraryStore() {
|
||||
setTimeout(() => reject(new Error("Search timeout - please try again")), 10000)
|
||||
);
|
||||
|
||||
// Phase 1: the command resolves with instant local-cache results. The
|
||||
// merged (cache + server) union arrives later via the `search-event`
|
||||
// listener above, tagged with this same requestId.
|
||||
const result = await Promise.race([
|
||||
repo.search(query, { limit: 10000 }),
|
||||
repo.search(query, { limit: 10000 }, requestId),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
searchResults: result.items,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
// Only apply if this is still the active query (a newer search may have
|
||||
// started while we awaited).
|
||||
if (requestId === searchRequestId) {
|
||||
update((s) => ({ ...s, searchResults: result.items }));
|
||||
}
|
||||
|
||||
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1) }));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
@ -196,6 +232,8 @@ function createLibraryStore() {
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
// Invalidate any in-flight backend search update.
|
||||
searchRequestId++;
|
||||
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
|
||||
}
|
||||
|
||||
@ -247,6 +285,11 @@ function createLibraryStore() {
|
||||
}
|
||||
|
||||
function reset() {
|
||||
searchRequestId++;
|
||||
if (unlistenSearch) {
|
||||
unlistenSearch();
|
||||
unlistenSearch = null;
|
||||
}
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
|
||||
161
src/lib/stores/movies.ts
Normal file
161
src/lib/stores/movies.ts
Normal file
@ -0,0 +1,161 @@
|
||||
// Movies library landing page data store.
|
||||
// Powers the focused movies landing: hero + horizontal sliders.
|
||||
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
|
||||
/** A single "by genre" row: the genre name plus the movies in it. */
|
||||
export interface GenreRow {
|
||||
id: string;
|
||||
name: string;
|
||||
items: MediaItem[];
|
||||
}
|
||||
|
||||
interface MoviesState {
|
||||
// Movies the user can resume (continue watching).
|
||||
continueWatching: MediaItem[];
|
||||
// Recently added movies in the library.
|
||||
recentlyAdded: MediaItem[];
|
||||
// One slider per genre (top genres by movie count).
|
||||
genreRows: GenreRow[];
|
||||
// Mix used for the hero banner.
|
||||
heroItems: MediaItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const SECTION_LIMIT = 16;
|
||||
// How many genre sliders to show, and how many genres to probe to find them.
|
||||
const MAX_GENRE_ROWS = 8;
|
||||
const MAX_GENRES_PROBED = 20;
|
||||
|
||||
function createMoviesStore() {
|
||||
const initialState: MoviesState = {
|
||||
continueWatching: [],
|
||||
recentlyAdded: [],
|
||||
genreRows: [],
|
||||
heroItems: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<MoviesState>(initialState);
|
||||
|
||||
/**
|
||||
* Build the hero rotation. Prefer in-progress movies (most personal), then
|
||||
* fall back to recently added. De-duplicates by id and prefers items that
|
||||
* carry backdrop/primary artwork for a good banner.
|
||||
*/
|
||||
function buildHero(continueWatching: MediaItem[], recentlyAdded: MediaItem[]): MediaItem[] {
|
||||
const hasArt = (i: MediaItem) =>
|
||||
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag;
|
||||
|
||||
const result: MediaItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const pool of [continueWatching, recentlyAdded]) {
|
||||
for (const item of pool) {
|
||||
if (result.length >= 6) break;
|
||||
if (hasArt(item) && !seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.slice(0, 6);
|
||||
}
|
||||
|
||||
async function loadSections(libraryId: string) {
|
||||
update(s => ({
|
||||
...s,
|
||||
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
const [resume, latest] = await Promise.all([
|
||||
repo.getResumeMovies(SECTION_LIMIT),
|
||||
repo.getLatestItems(libraryId, SECTION_LIMIT),
|
||||
]);
|
||||
|
||||
const heroItems = buildHero(resume, latest);
|
||||
|
||||
update(s => ({
|
||||
...s,
|
||||
continueWatching: resume,
|
||||
recentlyAdded: latest,
|
||||
heroItems,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
// Genre rows are secondary — load them after the main sections paint so
|
||||
// the page isn't blocked on N per-genre queries.
|
||||
loadGenreRows(libraryId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load movie sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
console.error("Failed to load movie sections:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one slider per genre, showing the top-rated movies in each. We probe
|
||||
* a bounded set of genres in parallel, drop empty ones, then keep the genres
|
||||
* with the most movies (so niche/near-empty genres don't crowd the page).
|
||||
*/
|
||||
async function loadGenreRows(libraryId: string) {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const genres = await repo.getGenres(libraryId);
|
||||
if (genres.length === 0) return;
|
||||
|
||||
const probed = genres.slice(0, MAX_GENRES_PROBED);
|
||||
const rows = await Promise.all(
|
||||
probed.map(async (genre): Promise<GenreRow> => {
|
||||
try {
|
||||
const result = await repo.getItems(libraryId, {
|
||||
includeItemTypes: ["Movie"],
|
||||
genres: [genre.name],
|
||||
sortBy: "CommunityRating",
|
||||
sortOrder: "Descending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
});
|
||||
return { id: genre.id, name: genre.name, items: result.items };
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load genre row "${genre.name}":`, e);
|
||||
return { id: genre.id, name: genre.name, items: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const genreRows = rows
|
||||
.filter(row => row.items.length > 0)
|
||||
.sort((a, b) => b.items.length - a.items.length)
|
||||
.slice(0, MAX_GENRE_ROWS);
|
||||
|
||||
update(s => ({ ...s, genreRows }));
|
||||
} catch (e) {
|
||||
console.warn("Failed to load movie genre rows:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
loadSections,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const movies = createMoviesStore();
|
||||
|
||||
export const moviesHeroItems = derived(movies, $m => $m.heroItems);
|
||||
export const isMoviesLoading = derived(movies, $m => $m.isLoading);
|
||||
134
src/lib/stores/music.ts
Normal file
134
src/lib/stores/music.ts
Normal file
@ -0,0 +1,134 @@
|
||||
// Music library landing page data store.
|
||||
// Powers the focused music landing: hero + horizontal sliders.
|
||||
// TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
||||
|
||||
interface MusicState {
|
||||
// Albums grouped from recently played tracks (backend handles grouping).
|
||||
recentlyPlayed: MediaItem[];
|
||||
// Most recently added albums in the music library.
|
||||
newlyAdded: MediaItem[];
|
||||
// User playlists.
|
||||
playlists: MediaItem[];
|
||||
// Albums the user has played but hasn't returned to in a while.
|
||||
rediscover: MediaItem[];
|
||||
// Mix of recently played + rediscover, used for the hero banner.
|
||||
heroItems: MediaItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const SECTION_LIMIT = 16;
|
||||
|
||||
function createMusicStore() {
|
||||
const initialState: MusicState = {
|
||||
recentlyPlayed: [],
|
||||
newlyAdded: [],
|
||||
playlists: [],
|
||||
rediscover: [],
|
||||
heroItems: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<MusicState>(initialState);
|
||||
|
||||
/**
|
||||
* Build the hero rotation from a mix of recently played and rediscover
|
||||
* albums, interleaved so the banner alternates "fresh in your ears" with
|
||||
* "remember this?". De-duplicates by id and prefers items that have artwork.
|
||||
*/
|
||||
function buildHero(recent: MediaItem[], rediscover: MediaItem[]): MediaItem[] {
|
||||
const hasArt = (i: MediaItem) =>
|
||||
!!i.primaryImageTag || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
|
||||
|
||||
const recentPool = recent.filter(hasArt);
|
||||
const rediscoverPool = rediscover.filter(hasArt);
|
||||
|
||||
const result: MediaItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
const maxLen = Math.max(recentPool.length, rediscoverPool.length);
|
||||
|
||||
for (let i = 0; i < maxLen && result.length < 6; i++) {
|
||||
for (const candidate of [recentPool[i], rediscoverPool[i]]) {
|
||||
if (candidate && !seen.has(candidate.id)) {
|
||||
seen.add(candidate.id);
|
||||
result.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.slice(0, 6);
|
||||
}
|
||||
|
||||
async function loadSections(libraryId: string) {
|
||||
update(s => ({
|
||||
...s,
|
||||
isLoading: s.recentlyPlayed.length === 0 && s.newlyAdded.length === 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
const [recentlyPlayed, newlyAdded, playlistsResult, rediscover] = await Promise.all([
|
||||
repo.getRecentlyPlayedAudio(SECTION_LIMIT),
|
||||
repo.getItems(libraryId, {
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
sortBy: "DateCreated",
|
||||
sortOrder: "Descending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
}),
|
||||
repo.getItems(libraryId, {
|
||||
includeItemTypes: ["Playlist"],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
}),
|
||||
repo.getRediscoverAlbums(libraryId, SECTION_LIMIT),
|
||||
]);
|
||||
|
||||
// HACK: drop the "Podcasts" folder that lives inside the music library.
|
||||
const recentlyPlayedAlbums = excludePodcasts(recentlyPlayed);
|
||||
const newlyAddedAlbums = excludePodcasts(newlyAdded.items);
|
||||
const playlistItems = excludePodcasts(playlistsResult.items);
|
||||
const rediscoverAlbums = excludePodcasts(rediscover);
|
||||
|
||||
const heroItems = buildHero(recentlyPlayedAlbums, rediscoverAlbums);
|
||||
|
||||
update(s => ({
|
||||
...s,
|
||||
recentlyPlayed: recentlyPlayedAlbums,
|
||||
newlyAdded: newlyAddedAlbums,
|
||||
playlists: playlistItems,
|
||||
rediscover: rediscoverAlbums,
|
||||
heroItems,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load music sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
console.error("Failed to load music sections:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
loadSections,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const music = createMusicStore();
|
||||
|
||||
export const musicHeroItems = derived(music, $m => $m.heroItems);
|
||||
export const isMusicLoading = derived(music, $m => $m.isLoading);
|
||||
176
src/lib/stores/tv.ts
Normal file
176
src/lib/stores/tv.ts
Normal file
@ -0,0 +1,176 @@
|
||||
// TV library landing page data store.
|
||||
// Powers the focused TV landing: hero + horizontal sliders.
|
||||
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
|
||||
/** A single "by genre" row: the genre name plus the series in it. */
|
||||
export interface GenreRow {
|
||||
id: string;
|
||||
name: string;
|
||||
items: MediaItem[];
|
||||
}
|
||||
|
||||
interface TvState {
|
||||
// Episodes the user can resume (continue watching).
|
||||
continueWatching: MediaItem[];
|
||||
// Next unwatched episode per in-progress series.
|
||||
nextUp: MediaItem[];
|
||||
// Recently added items in the TV library (series/seasons/episodes).
|
||||
recentlyAdded: MediaItem[];
|
||||
// One slider per genre (top genres by show count).
|
||||
genreRows: GenreRow[];
|
||||
// Mix used for the hero banner.
|
||||
heroItems: MediaItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const SECTION_LIMIT = 16;
|
||||
// How many genre sliders to show, and how many genres to probe to find them.
|
||||
const MAX_GENRE_ROWS = 8;
|
||||
const MAX_GENRES_PROBED = 20;
|
||||
|
||||
function createTvStore() {
|
||||
const initialState: TvState = {
|
||||
continueWatching: [],
|
||||
nextUp: [],
|
||||
recentlyAdded: [],
|
||||
genreRows: [],
|
||||
heroItems: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<TvState>(initialState);
|
||||
|
||||
/**
|
||||
* Build the hero rotation. Prefer in-progress episodes (most personal),
|
||||
* then fall back to next-up, then recently added. De-duplicates by id and
|
||||
* prefers items that carry backdrop/primary artwork for a good banner.
|
||||
*/
|
||||
function buildHero(
|
||||
continueWatching: MediaItem[],
|
||||
nextUp: MediaItem[],
|
||||
recentlyAdded: MediaItem[]
|
||||
): MediaItem[] {
|
||||
const hasArt = (i: MediaItem) =>
|
||||
!!(i.backdropImageTags && i.backdropImageTags.length > 0) ||
|
||||
!!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) ||
|
||||
!!i.primaryImageTag;
|
||||
|
||||
const result: MediaItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const pool of [continueWatching, nextUp, recentlyAdded]) {
|
||||
for (const item of pool) {
|
||||
if (result.length >= 6) break;
|
||||
if (hasArt(item) && !seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.slice(0, 6);
|
||||
}
|
||||
|
||||
async function loadSections(libraryId: string) {
|
||||
update(s => ({
|
||||
...s,
|
||||
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
const [resume, nextUp, latest] = await Promise.all([
|
||||
repo.getResumeItems(libraryId, SECTION_LIMIT),
|
||||
repo.getNextUpEpisodes(undefined, SECTION_LIMIT),
|
||||
repo.getLatestItems(libraryId, SECTION_LIMIT),
|
||||
]);
|
||||
|
||||
// Resume items are already video-only from the server, but keep episodes
|
||||
// (and the occasional movie that lives in a mixed library) defensively.
|
||||
const continueWatching = resume.filter(i => i.type === "Episode" || i.type === "Movie");
|
||||
|
||||
const heroItems = buildHero(continueWatching, nextUp, latest);
|
||||
|
||||
update(s => ({
|
||||
...s,
|
||||
continueWatching,
|
||||
nextUp,
|
||||
recentlyAdded: latest,
|
||||
heroItems,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
// Genre rows are secondary — load them after the main sections paint so
|
||||
// the page isn't blocked on N per-genre queries.
|
||||
loadGenreRows(libraryId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load TV sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
console.error("Failed to load TV sections:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one slider per genre, showing the top-rated series in each. We probe
|
||||
* a bounded set of genres in parallel, drop empty ones, then keep the genres
|
||||
* with the most shows (so niche/near-empty genres don't crowd the page).
|
||||
*/
|
||||
async function loadGenreRows(libraryId: string) {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const genres = await repo.getGenres(libraryId);
|
||||
if (genres.length === 0) return;
|
||||
|
||||
const probed = genres.slice(0, MAX_GENRES_PROBED);
|
||||
const rows = await Promise.all(
|
||||
probed.map(async (genre): Promise<GenreRow> => {
|
||||
try {
|
||||
const result = await repo.getItems(libraryId, {
|
||||
includeItemTypes: ["Series"],
|
||||
genres: [genre.name],
|
||||
sortBy: "CommunityRating",
|
||||
sortOrder: "Descending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
});
|
||||
return { id: genre.id, name: genre.name, items: result.items };
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load genre row "${genre.name}":`, e);
|
||||
return { id: genre.id, name: genre.name, items: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const genreRows = rows
|
||||
.filter(row => row.items.length > 0)
|
||||
.sort((a, b) => b.items.length - a.items.length)
|
||||
.slice(0, MAX_GENRE_ROWS);
|
||||
|
||||
update(s => ({ ...s, genreRows }));
|
||||
} catch (e) {
|
||||
console.warn("Failed to load TV genre rows:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
loadSections,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const tv = createTvStore();
|
||||
|
||||
export const tvHeroItems = derived(tv, $t => $t.heroItems);
|
||||
export const isTvLoading = derived(tv, $t => $t.isLoading);
|
||||
30
src/lib/utils/podcastFilter.ts
Normal file
30
src/lib/utils/podcastFilter.ts
Normal file
@ -0,0 +1,30 @@
|
||||
// HACK: hide "Podcasts" from the music library.
|
||||
//
|
||||
// The user stores podcasts inside the music library under a folder/album named
|
||||
// "Podcasts", so they leak into album/artist/track/playlist queries. Jellyfin's
|
||||
// item queries here don't give us a clean server-side exclusion for that folder,
|
||||
// so we filter client-side by name. This is intentionally a blunt instrument:
|
||||
// anything whose own name, album, or (album) artist is literally "Podcasts" is
|
||||
// dropped. If the folder is ever renamed, update PODCAST_FOLDER_NAME.
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
const PODCAST_FOLDER_NAME = "podcasts";
|
||||
|
||||
function isPodcastName(value: string | null | undefined): boolean {
|
||||
return value?.trim().toLowerCase() === PODCAST_FOLDER_NAME;
|
||||
}
|
||||
|
||||
/** True when an item belongs to the "Podcasts" folder/album and should be hidden. */
|
||||
export function isPodcastItem(item: MediaItem): boolean {
|
||||
return (
|
||||
isPodcastName(item.name) ||
|
||||
isPodcastName(item.albumName) ||
|
||||
isPodcastName(item.albumArtist) ||
|
||||
(item.artists?.some(isPodcastName) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove "Podcasts" entries from a list of music items. */
|
||||
export function excludePodcasts(items: MediaItem[]): MediaItem[] {
|
||||
return items.filter((item) => !isPodcastItem(item));
|
||||
}
|
||||
@ -62,6 +62,20 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Route to dedicated TV library landing page
|
||||
if (lib.collectionType === "tvshows") {
|
||||
library.setCurrentLibrary(lib);
|
||||
goto("/library/tv");
|
||||
return;
|
||||
}
|
||||
|
||||
// Route to dedicated movies library landing page
|
||||
if (lib.collectionType === "movies") {
|
||||
library.setCurrentLibrary(lib);
|
||||
goto("/library/movies");
|
||||
return;
|
||||
}
|
||||
|
||||
// For other library types, load items normally
|
||||
library.setCurrentLibrary(lib);
|
||||
library.clearGenres();
|
||||
|
||||
160
src/routes/library/movies/+page.svelte
Normal file
160
src/routes/library/movies/+page.svelte
Normal file
@ -0,0 +1,160 @@
|
||||
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { movies } from "$lib/stores/movies";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
||||
import Carousel from "$lib/components/home/Carousel.svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
const categories: Category[] = [
|
||||
{
|
||||
id: "all",
|
||||
name: "All Movies",
|
||||
icon: "M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z",
|
||||
description: "Browse all movies",
|
||||
route: "/library/movies/all",
|
||||
},
|
||||
{
|
||||
id: "genres",
|
||||
name: "Genres",
|
||||
icon: "M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
|
||||
description: "Browse by genre",
|
||||
route: "/library/movies/genres",
|
||||
},
|
||||
];
|
||||
|
||||
async function load() {
|
||||
if (!$currentLibrary) {
|
||||
goto("/library");
|
||||
return;
|
||||
}
|
||||
await movies.loadSections($currentLibrary.id);
|
||||
}
|
||||
|
||||
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
|
||||
|
||||
onMount(async () => {
|
||||
await load();
|
||||
markLoaded();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
checkServerReachability($isServerReachable);
|
||||
});
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
if (item.type === "Folder") {
|
||||
goto(`/library/${item.id}`);
|
||||
} else {
|
||||
// Movies play directly.
|
||||
goto(`/player/${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const heroItems = $derived($movies.heroItems);
|
||||
const continueWatching = $derived($movies.continueWatching);
|
||||
const recentlyAdded = $derived($movies.recentlyAdded);
|
||||
const genreRows = $derived($movies.genreRows);
|
||||
const isLoading = $derived($movies.isLoading);
|
||||
const hasContent = $derived(
|
||||
heroItems.length > 0 ||
|
||||
continueWatching.length > 0 ||
|
||||
recentlyAdded.length > 0
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center items-center py-32">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-8 pb-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4">
|
||||
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
|
||||
<button
|
||||
onclick={() => goto("/library")}
|
||||
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
||||
title="Back to libraries"
|
||||
aria-label="Back to libraries"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hero Banner -->
|
||||
{#if heroItems.length > 0}
|
||||
<HeroBanner items={heroItems} />
|
||||
{/if}
|
||||
|
||||
<!-- Continue Watching -->
|
||||
{#if continueWatching.length > 0}
|
||||
<Carousel
|
||||
title="Continue Watching"
|
||||
items={continueWatching}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Recently Added -->
|
||||
{#if recentlyAdded.length > 0}
|
||||
<Carousel
|
||||
title="Recently Added"
|
||||
items={recentlyAdded}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto("/library/movies/all")}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- One slider per genre -->
|
||||
{#each genreRows as row (row.id)}
|
||||
<Carousel
|
||||
title={row.name}
|
||||
items={row.items}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto(`/library/movies/genres`)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if !hasContent}
|
||||
<p class="px-4 text-gray-400">Nothing here yet. Add some movies to your library to fill this page.</p>
|
||||
{/if}
|
||||
|
||||
<!-- Browse by category -->
|
||||
<div class="space-y-3 px-4 pt-4">
|
||||
<h2 class="text-2xl font-semibold text-white">Browse</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{#each categories as category (category.id)}
|
||||
<button
|
||||
onclick={() => goto(category.route)}
|
||||
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
|
||||
>
|
||||
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d={category.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-white font-semibold truncate">{category.name}</div>
|
||||
<div class="text-gray-400 text-xs truncate">{category.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
27
src/routes/library/movies/all/+page.svelte
Normal file
27
src/routes/library/movies/all/+page.svelte
Normal file
@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
|
||||
|
||||
/**
|
||||
* Movie browser (all movies)
|
||||
* @req: UR-007 - Navigate media in library
|
||||
* @req: UR-008 - Search media across libraries
|
||||
* @req: DR-007 - Library browsing screens
|
||||
*/
|
||||
|
||||
const config = {
|
||||
itemType: "Movie" as const,
|
||||
title: "Movies",
|
||||
backPath: "/library/movies",
|
||||
searchPlaceholder: "Search movies...",
|
||||
sortOptions: [
|
||||
{ key: "SortName", label: "A-Z" },
|
||||
{ key: "ProductionYear", label: "Year" },
|
||||
{ key: "DateCreated", label: "Recently Added" },
|
||||
{ key: "CommunityRating", label: "Rating" },
|
||||
],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "grid" as const,
|
||||
};
|
||||
</script>
|
||||
|
||||
<GenericMediaListPage {config} />
|
||||
@ -1,8 +1,15 @@
|
||||
<!-- TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039 -->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { music } from "$lib/stores/music";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
||||
import Carousel from "$lib/components/home/Carousel.svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
@ -10,10 +17,9 @@
|
||||
icon: string;
|
||||
description: string;
|
||||
route: string;
|
||||
backgroundImage?: string;
|
||||
}
|
||||
|
||||
let categories: Category[] = [
|
||||
const categories: Category[] = [
|
||||
{
|
||||
id: "tracks",
|
||||
name: "Tracks",
|
||||
@ -42,73 +48,68 @@
|
||||
description: "Browse by genre",
|
||||
route: "/library/music/genres",
|
||||
},
|
||||
{
|
||||
id: "playlists",
|
||||
name: "Playlists",
|
||||
icon: "M4 6h16M4 10h16M4 14h10M14 14v6l5-3-5-3z",
|
||||
description: "Your playlists",
|
||||
route: "/library/music/playlists",
|
||||
},
|
||||
];
|
||||
|
||||
// Fetch album art for categories
|
||||
async function loadCategoryImages() {
|
||||
async function load() {
|
||||
if (!$currentLibrary) {
|
||||
console.log("Current library not set yet, retrying...");
|
||||
goto("/library");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Fetch a recent album to use as background for albums category
|
||||
const albums = await repo.getLatestItems($currentLibrary.id, 5);
|
||||
if (albums.length > 0) {
|
||||
const albumWithImage = albums.find(a => a.primaryImageTag);
|
||||
if (albumWithImage) {
|
||||
categories = categories.map(cat =>
|
||||
cat.id === "albums"
|
||||
? { ...cat, backgroundImage: albumWithImage.id }
|
||||
: cat
|
||||
);
|
||||
}
|
||||
await music.loadSections($currentLibrary.id);
|
||||
}
|
||||
|
||||
// Fetch a recent audio track for tracks category
|
||||
const tracks = await repo.getRecentlyPlayedAudio(5);
|
||||
if (tracks.length > 0) {
|
||||
const trackWithImage = tracks.find((t: typeof tracks[0]) => t.primaryImageTag);
|
||||
if (trackWithImage) {
|
||||
categories = categories.map(cat =>
|
||||
cat.id === "tracks"
|
||||
? { ...cat, backgroundImage: trackWithImage.id }
|
||||
: cat
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load category images:", error);
|
||||
}
|
||||
}
|
||||
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
|
||||
|
||||
function getImageUrl(itemId: string | undefined) {
|
||||
if (!itemId) return undefined;
|
||||
return `http://tauri.localhost/image/primary/${itemId}?size=400&quality=95`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadCategoryImages();
|
||||
onMount(async () => {
|
||||
await load();
|
||||
markLoaded();
|
||||
});
|
||||
|
||||
function handleCategoryClick(route: string) {
|
||||
goto(route);
|
||||
$effect(() => {
|
||||
checkServerReachability($isServerReachable);
|
||||
});
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
// Albums, artists, and playlists all browse to a detail page.
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
|
||||
const heroItems = $derived($music.heroItems);
|
||||
const recentlyPlayed = $derived($music.recentlyPlayed);
|
||||
const newlyAdded = $derived($music.newlyAdded);
|
||||
const playlists = $derived($music.playlists);
|
||||
const rediscover = $derived($music.rediscover);
|
||||
const isLoading = $derived($music.isLoading);
|
||||
const hasContent = $derived(
|
||||
heroItems.length > 0 ||
|
||||
recentlyPlayed.length > 0 ||
|
||||
newlyAdded.length > 0 ||
|
||||
playlists.length > 0 ||
|
||||
rediscover.length > 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">Music Library</h1>
|
||||
<p class="text-gray-400 mt-1">Choose a category to browse</p>
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center items-center py-32">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-8 pb-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4">
|
||||
<h1 class="text-3xl font-bold text-white">Music</h1>
|
||||
<button
|
||||
onclick={() => goto('/library')}
|
||||
onclick={() => goto("/library")}
|
||||
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
||||
title="Back to libraries"
|
||||
aria-label="Back to libraries"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
@ -116,50 +117,74 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Category Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{#each categories as category (category.id)}
|
||||
<button
|
||||
onclick={() => handleCategoryClick(category.route)}
|
||||
class="group relative bg-[var(--color-surface)] rounded-xl overflow-hidden hover:shadow-lg transition-all duration-200 text-left h-48"
|
||||
style={category.backgroundImage ? `background-image: url('${getImageUrl(category.backgroundImage)}')` : ''}
|
||||
>
|
||||
<!-- Background image overlay -->
|
||||
{#if category.backgroundImage}
|
||||
<div class="absolute inset-0 bg-black/40 group-hover:bg-black/50 transition-colors"></div>
|
||||
{:else}
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)]/20 to-transparent"></div>
|
||||
<!-- Hero Banner -->
|
||||
{#if heroItems.length > 0}
|
||||
<HeroBanner items={heroItems} />
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-10 h-full flex flex-col justify-between p-6">
|
||||
<!-- Icon and text section -->
|
||||
<div>
|
||||
<!-- Icon -->
|
||||
<div class="w-14 h-14 mb-4 rounded-full bg-[var(--color-jellyfin)]/30 backdrop-blur-sm flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<svg class="w-7 h-7 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<!-- Recently Listened -->
|
||||
{#if recentlyPlayed.length > 0}
|
||||
<Carousel
|
||||
title="Recently Listened"
|
||||
items={recentlyPlayed}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Playlists -->
|
||||
{#if playlists.length > 0}
|
||||
<Carousel
|
||||
title="Playlists"
|
||||
items={playlists}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto("/library/music/playlists")}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Newly Added -->
|
||||
{#if newlyAdded.length > 0}
|
||||
<Carousel
|
||||
title="Newly Added"
|
||||
items={newlyAdded}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto("/library/music/albums")}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Rediscover -->
|
||||
{#if rediscover.length > 0}
|
||||
<Carousel
|
||||
title="Haven't listened to in a while"
|
||||
items={rediscover}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !hasContent}
|
||||
<p class="px-4 text-gray-400">Nothing here yet. Start playing some music to fill this page.</p>
|
||||
{/if}
|
||||
|
||||
<!-- Browse by category -->
|
||||
<div class="space-y-3 px-4 pt-4">
|
||||
<h2 class="text-2xl font-semibold text-white">Browse</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{#each categories as category (category.id)}
|
||||
<button
|
||||
onclick={() => goto(category.route)}
|
||||
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
|
||||
>
|
||||
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d={category.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<h2 class="text-xl font-bold text-white mb-1 group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{category.name}
|
||||
</h2>
|
||||
<p class="text-gray-300 text-sm">
|
||||
{category.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Arrow indicator -->
|
||||
<div class="flex items-center text-[var(--color-jellyfin)] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="text-sm font-medium mr-1">Browse</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-white font-semibold truncate">{category.name}</div>
|
||||
<div class="text-gray-400 text-xs truncate">{category.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
176
src/routes/library/tv/+page.svelte
Normal file
176
src/routes/library/tv/+page.svelte
Normal file
@ -0,0 +1,176 @@
|
||||
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { tv } from "$lib/stores/tv";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
||||
import Carousel from "$lib/components/home/Carousel.svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
const categories: Category[] = [
|
||||
{
|
||||
id: "shows",
|
||||
name: "All Shows",
|
||||
icon: "M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z",
|
||||
description: "Browse all series",
|
||||
route: "/library/tv/shows",
|
||||
},
|
||||
{
|
||||
id: "genres",
|
||||
name: "Genres",
|
||||
icon: "M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
|
||||
description: "Browse by genre",
|
||||
route: "/library/shows/genres",
|
||||
},
|
||||
];
|
||||
|
||||
async function load() {
|
||||
if (!$currentLibrary) {
|
||||
goto("/library");
|
||||
return;
|
||||
}
|
||||
await tv.loadSections($currentLibrary.id);
|
||||
}
|
||||
|
||||
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
|
||||
|
||||
onMount(async () => {
|
||||
await load();
|
||||
markLoaded();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
checkServerReachability($isServerReachable);
|
||||
});
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
switch (item.type) {
|
||||
case "Series":
|
||||
case "Season":
|
||||
case "Folder":
|
||||
goto(`/library/${item.id}`);
|
||||
break;
|
||||
default:
|
||||
// Episodes and movies play directly.
|
||||
goto(`/player/${item.id}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const heroItems = $derived($tv.heroItems);
|
||||
const continueWatching = $derived($tv.continueWatching);
|
||||
const nextUp = $derived($tv.nextUp);
|
||||
const recentlyAdded = $derived($tv.recentlyAdded);
|
||||
const genreRows = $derived($tv.genreRows);
|
||||
const isLoading = $derived($tv.isLoading);
|
||||
const hasContent = $derived(
|
||||
heroItems.length > 0 ||
|
||||
continueWatching.length > 0 ||
|
||||
nextUp.length > 0 ||
|
||||
recentlyAdded.length > 0
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center items-center py-32">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-8 pb-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4">
|
||||
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
|
||||
<button
|
||||
onclick={() => goto("/library")}
|
||||
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
||||
title="Back to libraries"
|
||||
aria-label="Back to libraries"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hero Banner -->
|
||||
{#if heroItems.length > 0}
|
||||
<HeroBanner items={heroItems} />
|
||||
{/if}
|
||||
|
||||
<!-- Continue Watching -->
|
||||
{#if continueWatching.length > 0}
|
||||
<Carousel
|
||||
title="Continue Watching"
|
||||
items={continueWatching}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Next Up -->
|
||||
{#if nextUp.length > 0}
|
||||
<Carousel
|
||||
title="Next Up"
|
||||
items={nextUp}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Recently Added -->
|
||||
{#if recentlyAdded.length > 0}
|
||||
<Carousel
|
||||
title="Recently Added"
|
||||
items={recentlyAdded}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto("/library/tv/shows")}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- One slider per genre -->
|
||||
{#each genreRows as row (row.id)}
|
||||
<Carousel
|
||||
title={row.name}
|
||||
items={row.items}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto(`/library/shows/genres`)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if !hasContent}
|
||||
<p class="px-4 text-gray-400">Nothing here yet. Start watching something to fill this page.</p>
|
||||
{/if}
|
||||
|
||||
<!-- Browse by category -->
|
||||
<div class="space-y-3 px-4 pt-4">
|
||||
<h2 class="text-2xl font-semibold text-white">Browse</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{#each categories as category (category.id)}
|
||||
<button
|
||||
onclick={() => goto(category.route)}
|
||||
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
|
||||
>
|
||||
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d={category.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-white font-semibold truncate">{category.name}</div>
|
||||
<div class="text-gray-400 text-xs truncate">{category.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
27
src/routes/library/tv/shows/+page.svelte
Normal file
27
src/routes/library/tv/shows/+page.svelte
Normal file
@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
|
||||
|
||||
/**
|
||||
* TV show browser (all series)
|
||||
* @req: UR-007 - Navigate media in library
|
||||
* @req: UR-008 - Search media across libraries
|
||||
* @req: DR-007 - Library browsing screens
|
||||
*/
|
||||
|
||||
const config = {
|
||||
itemType: "Series" as const,
|
||||
title: "TV Shows",
|
||||
backPath: "/library/tv",
|
||||
searchPlaceholder: "Search shows...",
|
||||
sortOptions: [
|
||||
{ key: "SortName", label: "A-Z" },
|
||||
{ key: "ProductionYear", label: "Year" },
|
||||
{ key: "DateCreated", label: "Recently Added" },
|
||||
{ key: "CommunityRating", label: "Rating" },
|
||||
],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "grid" as const,
|
||||
};
|
||||
</script>
|
||||
|
||||
<GenericMediaListPage {config} />
|
||||
Loading…
x
Reference in New Issue
Block a user