Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Migrate server handlers to Actix-web

Replace custom Context/Request/Response with actix-web AppState and
HttpResponse. Update handlers to accept web::Data, web::Path and
web::Query and return actix_web::Result<HttpResponse>. Add actix-web,
actix-rt and actix-cors deps, use web::block for blocking work, and
adjust cache/update_cache to use Arc<Mutex<...>>. Also add small tracing
imports and Cargo.lock updates.

+1627 -2071
+4 -24
Cargo.lock
··· 162 162 source = "registry+https://github.com/rust-lang/crates.io-index" 163 163 checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" 164 164 dependencies = [ 165 + "actix-macros", 165 166 "futures-core", 166 167 "tokio", 167 168 ] ··· 8563 8564 ] 8564 8565 8565 8566 [[package]] 8566 - name = "queryst" 8567 - version = "3.0.0" 8568 - source = "registry+https://github.com/rust-lang/crates.io-index" 8569 - checksum = "c1cbeb75ac695daf201ca2d66d9c684f873b135f28af4f2c79952478cab3b9d9" 8570 - dependencies = [ 8571 - "lazy_static", 8572 - "percent-encoding", 8573 - "regex", 8574 - "serde", 8575 - "serde_json", 8576 - ] 8577 - 8578 - [[package]] 8579 8567 name = "quick-error" 8580 8568 version = "1.2.3" 8581 8569 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 9477 9465 name = "rockbox-server" 9478 9466 version = "0.1.0" 9479 9467 dependencies = [ 9468 + "actix-cors", 9469 + "actix-rt", 9470 + "actix-web", 9480 9471 "anyhow", 9481 9472 "async-std", 9482 9473 "futures-util", ··· 9485 9476 "md5", 9486 9477 "netstream", 9487 9478 "owo-colors 4.1.0", 9488 - "queryst", 9489 9479 "rand 0.8.5", 9490 9480 "reqwest", 9491 9481 "rockbox-bluetooth", ··· 9510 9500 "serde", 9511 9501 "serde_json", 9512 9502 "sqlx", 9513 - "threadpool", 9514 9503 "tokio", 9515 9504 "tracing", 9516 9505 "url", ··· 11703 11692 dependencies = [ 11704 11693 "cfg-if 1.0.0", 11705 11694 "once_cell", 11706 - ] 11707 - 11708 - [[package]] 11709 - name = "threadpool" 11710 - version = "1.8.1" 11711 - source = "registry+https://github.com/rust-lang/crates.io-index" 11712 - checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" 11713 - dependencies = [ 11714 - "num_cpus", 11715 11695 ] 11716 11696 11717 11697 [[package]]
+11 -9
crates/library/src/lib.rs
··· 1 1 use std::env; 2 2 3 3 use sqlx::{sqlite::SqliteConnectOptions, Error, Executor, Pool, Sqlite, SqlitePool}; 4 + use tracing::{debug, warn}; 4 5 5 6 pub mod album_art; 6 7 pub mod artists; ··· 16 17 std::fs::create_dir_all(&rockbox_dir).unwrap(); 17 18 let rockbox_db_path = format!("{}/rockbox-library.db", rockbox_dir); 18 19 let db_url = env::var("DATABASE_URL").unwrap_or(rockbox_db_path); 19 - println!("db url {}", db_url); 20 + debug!("db url {}", db_url); 20 21 env::set_var("DATABASE_URL", &db_url); 21 22 let options = SqliteConnectOptions::new() 22 23 .filename(db_url) 23 - .create_if_missing(true); 24 + .create_if_missing(true) 25 + .busy_timeout(std::time::Duration::from_secs(30)); 24 26 let pool = SqlitePool::connect_with(options).await?; 25 27 pool.execute(include_str!( 26 28 "../migrations/20240923093823_create_tables.sql" ··· 33 35 .await 34 36 { 35 37 Ok(_) => {} 36 - Err(_) => println!("artist_id column already exists"), 38 + Err(_) => warn!("artist_id column already exists"), 37 39 } 38 40 39 41 match pool ··· 43 45 .await 44 46 { 45 47 Ok(_) => {} 46 - Err(_) => println!("album_id column already exists"), 48 + Err(_) => warn!("album_id column already exists"), 47 49 } 48 50 match pool 49 51 .execute(include_str!( ··· 52 54 .await 53 55 { 54 56 Ok(_) => {} 55 - Err(_) => println!("label column already exists"), 57 + Err(_) => warn!("label column already exists"), 56 58 } 57 59 58 60 match pool ··· 62 64 .await 63 65 { 64 66 Ok(_) => {} 65 - Err(_) => println!("copyright_message column already exists"), 67 + Err(_) => warn!("copyright_message column already exists"), 66 68 } 67 69 68 70 match pool ··· 72 74 .await 73 75 { 74 76 Ok(_) => {} 75 - Err(_) => println!("genres column already exists"), 77 + Err(_) => warn!("genres column already exists"), 76 78 } 77 79 78 80 match pool ··· 82 84 .await 83 85 { 84 86 Ok(_) => {} 85 - Err(_) => println!("playlist tables already exist"), 87 + Err(_) => warn!("playlist tables already exist"), 86 88 } 87 89 88 90 match pool ··· 92 94 .await 93 95 { 94 96 Ok(_) => {} 95 - Err(_) => println!("is_remote column already exists"), 97 + Err(_) => warn!("is_remote column already exists"), 96 98 } 97 99 98 100 pool.execute(include_str!(
+3 -2
crates/server/Cargo.toml
··· 14 14 local-ip-addr = "0.1.1" 15 15 md5 = "0.7.0" 16 16 owo-colors = "4.0.0" 17 - queryst = "3.0.0" 18 17 rand = "0.8.5" 19 18 reqwest = {version = "0.12.5", features = ["blocking", "rustls-tls"], default-features = false} 19 + actix-web = "4" 20 + actix-rt = "2" 21 + actix-cors = "0.7" 20 22 rockbox-chromecast = {path = "../chromecast"} 21 23 rockbox-slim = {path = "../slim"} 22 24 rockbox-upnp = {path = "../upnp"} ··· 39 41 serde = {version = "1.0.210", features = ["derive"]} 40 42 serde_json = "1.0.128" 41 43 sqlx = {version = "0.8.2", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "derive", "macros"]} 42 - threadpool = "1.8.1" 43 44 tokio = {version = "1.36.0", features = ["full"]} 44 45 url = "2.3.1" 45 46 urlencoding = "2.1.3"
+10 -6
crates/server/src/cache.rs
··· 1 - use std::{fs, thread}; 1 + use std::{collections::HashMap, fs, sync::Arc, thread}; 2 2 3 3 use anyhow::Error; 4 4 use rockbox_sys::types::tree::Entry; 5 + use tokio::sync::Mutex; 5 6 6 - use crate::{http::Context, AUDIO_EXTENSIONS}; 7 + use crate::AUDIO_EXTENSIONS; 7 8 8 - pub fn update_cache(ctx: &Context, path: &str, show_hidden: bool) { 9 - let fs_cache = ctx.fs_cache.clone(); 9 + pub fn update_cache( 10 + fs_cache: Arc<Mutex<HashMap<String, Vec<Entry>>>>, 11 + path: &str, 12 + show_hidden: bool, 13 + ) { 10 14 let path = path.to_string(); 11 15 thread::spawn(move || { 12 16 let mut fs_cache = fs_cache.blocking_lock(); ··· 25 29 continue; 26 30 } 27 31 28 - if file.file_name().to_string_lossy().starts_with(".") && !show_hidden { 32 + if file.file_name().to_string_lossy().starts_with('.') && !show_hidden { 29 33 continue; 30 34 } 31 35 ··· 44 48 }); 45 49 } 46 50 47 - fs_cache.insert(path, entries.clone()); 51 + fs_cache.insert(path, entries); 48 52 Ok::<(), Error>(()) 49 53 }); 50 54 }
+21 -17
crates/server/src/handlers/albums.rs
··· 1 - use anyhow::Error; 1 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 2 2 use rockbox_library::repo; 3 3 4 - use crate::http::{Context, Request, Response}; 4 + use crate::http::AppState; 5 5 6 - pub async fn get_albums(ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 7 - let albums = repo::album::all(ctx.pool.clone()).await?; 8 - res.json(&albums); 9 - Ok(()) 6 + type HandlerResult = actix_web::Result<HttpResponse>; 7 + 8 + pub async fn get_albums(state: web::Data<AppState>) -> HandlerResult { 9 + let albums = repo::album::all(state.pool.clone()) 10 + .await 11 + .map_err(ErrorInternalServerError)?; 12 + Ok(HttpResponse::Ok().json(albums)) 10 13 } 11 14 12 - pub async fn get_album(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 13 - let album = repo::album::find(ctx.pool.clone(), &req.params[0]).await?; 14 - res.json(&album); 15 - Ok(()) 15 + pub async fn get_album(state: web::Data<AppState>, path: web::Path<String>) -> HandlerResult { 16 + let album = repo::album::find(state.pool.clone(), &path.into_inner()) 17 + .await 18 + .map_err(ErrorInternalServerError)?; 19 + Ok(HttpResponse::Ok().json(album)) 16 20 } 17 21 18 22 pub async fn get_album_tracks( 19 - ctx: &Context, 20 - req: &Request, 21 - res: &mut Response, 22 - ) -> Result<(), Error> { 23 - let tracks = repo::album_tracks::find_by_album(ctx.pool.clone(), &req.params[0]).await?; 24 - res.json(&tracks); 25 - Ok(()) 23 + state: web::Data<AppState>, 24 + path: web::Path<String>, 25 + ) -> HandlerResult { 26 + let tracks = repo::album_tracks::find_by_album(state.pool.clone(), &path.into_inner()) 27 + .await 28 + .map_err(ErrorInternalServerError)?; 29 + Ok(HttpResponse::Ok().json(tracks)) 26 30 }
+28 -24
crates/server/src/handlers/artists.rs
··· 1 - use anyhow::Error; 1 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 2 2 use rockbox_library::repo; 3 3 4 - use crate::http::{Context, Request, Response}; 4 + use crate::http::AppState; 5 + 6 + type HandlerResult = actix_web::Result<HttpResponse>; 5 7 6 - pub async fn get_artists(ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 7 - let artists = repo::artist::all(ctx.pool.clone()).await?; 8 - res.json(&artists); 9 - Ok(()) 8 + pub async fn get_artists(state: web::Data<AppState>) -> HandlerResult { 9 + let artists = repo::artist::all(state.pool.clone()) 10 + .await 11 + .map_err(ErrorInternalServerError)?; 12 + Ok(HttpResponse::Ok().json(artists)) 10 13 } 11 14 12 - pub async fn get_artist(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 13 - let artist = repo::artist::find(ctx.pool.clone(), &req.params[0]).await?; 14 - res.json(&artist); 15 - Ok(()) 15 + pub async fn get_artist(state: web::Data<AppState>, path: web::Path<String>) -> HandlerResult { 16 + let artist = repo::artist::find(state.pool.clone(), &path.into_inner()) 17 + .await 18 + .map_err(ErrorInternalServerError)?; 19 + Ok(HttpResponse::Ok().json(artist)) 16 20 } 17 21 18 22 pub async fn get_artist_albums( 19 - ctx: &Context, 20 - req: &Request, 21 - res: &mut Response, 22 - ) -> Result<(), Error> { 23 - let albums = repo::album::find_by_artist(ctx.pool.clone(), &req.params[0]).await?; 24 - res.json(&albums); 25 - Ok(()) 23 + state: web::Data<AppState>, 24 + path: web::Path<String>, 25 + ) -> HandlerResult { 26 + let albums = repo::album::find_by_artist(state.pool.clone(), &path.into_inner()) 27 + .await 28 + .map_err(ErrorInternalServerError)?; 29 + Ok(HttpResponse::Ok().json(albums)) 26 30 } 27 31 28 32 pub async fn get_artist_tracks( 29 - ctx: &Context, 30 - req: &Request, 31 - res: &mut Response, 32 - ) -> Result<(), Error> { 33 - let tracks = repo::artist_tracks::find_by_artist(ctx.pool.clone(), &req.params[0]).await?; 34 - res.json(&tracks); 35 - Ok(()) 33 + state: web::Data<AppState>, 34 + path: web::Path<String>, 35 + ) -> HandlerResult { 36 + let tracks = repo::artist_tracks::find_by_artist(state.pool.clone(), &path.into_inner()) 37 + .await 38 + .map_err(ErrorInternalServerError)?; 39 + Ok(HttpResponse::Ok().json(tracks)) 36 40 }
+24 -43
crates/server/src/handlers/bluetooth.rs
··· 1 - use anyhow::Error; 1 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 2 2 use rockbox_bluetooth::{connect, disconnect, get_devices, scan}; 3 + use serde::Deserialize; 3 4 4 - use crate::http::{Context, Request, Response}; 5 + type HandlerResult = actix_web::Result<HttpResponse>; 5 6 6 - pub async fn scan_bluetooth( 7 - _ctx: &Context, 8 - req: &Request, 9 - res: &mut Response, 10 - ) -> Result<(), Error> { 11 - let timeout_secs = req.query_params["timeout_secs"] 12 - .as_str() 13 - .and_then(|s| s.parse::<u64>().ok()) 14 - .or_else(|| req.query_params["timeout_secs"].as_u64()) 15 - .unwrap_or(10); 7 + #[derive(Deserialize)] 8 + pub struct ScanQuery { 9 + timeout_secs: Option<u64>, 10 + } 16 11 17 - let devices = scan(timeout_secs).await?; 18 - res.json(&devices); 19 - Ok(()) 12 + pub async fn scan_bluetooth(query: web::Query<ScanQuery>) -> HandlerResult { 13 + let timeout_secs = query.timeout_secs.unwrap_or(10); 14 + let devices = scan(timeout_secs).await.map_err(ErrorInternalServerError)?; 15 + Ok(HttpResponse::Ok().json(devices)) 20 16 } 21 17 22 - pub async fn get_bluetooth_devices( 23 - _ctx: &Context, 24 - _req: &Request, 25 - res: &mut Response, 26 - ) -> Result<(), Error> { 27 - let devices = get_devices().await?; 28 - res.json(&devices); 29 - Ok(()) 18 + pub async fn get_bluetooth_devices() -> HandlerResult { 19 + let devices = get_devices().await.map_err(ErrorInternalServerError)?; 20 + Ok(HttpResponse::Ok().json(devices)) 30 21 } 31 22 32 - pub async fn connect_bluetooth_device( 33 - _ctx: &Context, 34 - req: &Request, 35 - res: &mut Response, 36 - ) -> Result<(), Error> { 37 - let address = &req.params[0]; 38 - match connect(address).await { 39 - Ok(_) => res.set_status(200), 23 + pub async fn connect_bluetooth_device(path: web::Path<String>) -> HandlerResult { 24 + let address = path.into_inner(); 25 + match connect(&address).await { 26 + Ok(_) => Ok(HttpResponse::Ok().finish()), 40 27 Err(e) => { 41 28 tracing::error!("bluetooth: connect {}: {}", address, e); 42 - res.set_status(500); 29 + Ok(HttpResponse::InternalServerError().finish()) 43 30 } 44 31 } 45 - Ok(()) 46 32 } 47 33 48 - pub async fn disconnect_bluetooth_device( 49 - _ctx: &Context, 50 - req: &Request, 51 - res: &mut Response, 52 - ) -> Result<(), Error> { 53 - let address = &req.params[0]; 54 - match disconnect(address).await { 55 - Ok(_) => res.set_status(200), 34 + pub async fn disconnect_bluetooth_device(path: web::Path<String>) -> HandlerResult { 35 + let address = path.into_inner(); 36 + match disconnect(&address).await { 37 + Ok(_) => Ok(HttpResponse::Ok().finish()), 56 38 Err(e) => { 57 39 tracing::error!("bluetooth: disconnect {}: {}", address, e); 58 - res.set_status(500); 40 + Ok(HttpResponse::InternalServerError().finish()) 59 41 } 60 42 } 61 - Ok(()) 62 43 }
+44 -41
crates/server/src/handlers/browse.rs
··· 1 1 use std::{env, fs}; 2 2 3 - use crate::{ 4 - cache::update_cache, 5 - http::{Context, Request, Response}, 6 - AUDIO_EXTENSIONS, 7 - }; 8 - use anyhow::Error; 3 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 9 4 use rockbox_sys::types::tree::Entry; 5 + use serde::Deserialize; 6 + 7 + use crate::{cache::update_cache, http::AppState, AUDIO_EXTENSIONS}; 8 + 9 + type HandlerResult = actix_web::Result<HttpResponse>; 10 + 11 + #[derive(Deserialize)] 12 + pub struct TreeQuery { 13 + q: Option<String>, 14 + show_hidden: Option<String>, 15 + } 10 16 11 17 pub async fn get_tree_entries( 12 - ctx: &Context, 13 - req: &Request, 14 - res: &mut Response, 15 - ) -> Result<(), Error> { 16 - let home = env::var("HOME").unwrap(); 17 - let music_library = env::var("ROCKBOX_LIBRARY").unwrap_or(format!("{}/Music", home)); 18 + state: web::Data<AppState>, 19 + query: web::Query<TreeQuery>, 20 + ) -> HandlerResult { 21 + let home = env::var("HOME").map_err(ErrorInternalServerError)?; 22 + let music_library = env::var("ROCKBOX_LIBRARY").unwrap_or_else(|_| format!("{}/Music", home)); 18 23 19 - let path = match req.query_params.get("q") { 20 - Some(path) => path.as_str().unwrap_or(&music_library), 21 - None => &music_library, 22 - }; 23 - let show_hidden = match req.query_params.get("show_hidden") { 24 - Some(show_hidden) => show_hidden.as_str().unwrap_or("false") == "true", 25 - None => false, 26 - }; 24 + let path = query.q.as_deref().unwrap_or(&music_library).to_string(); 25 + let show_hidden = query.show_hidden.as_deref() == Some("true"); 27 26 28 - if !fs::metadata(path)?.is_dir() { 29 - res.set_status(500); 30 - res.text("Path is not a directory"); 31 - return Ok(()); 27 + if !fs::metadata(&path) 28 + .map_err(ErrorInternalServerError)? 29 + .is_dir() 30 + { 31 + return Ok(HttpResponse::InternalServerError().body("Path is not a directory")); 32 32 } 33 33 34 - let mut fs_cache = ctx.fs_cache.lock().await; 35 - if let Some(entries) = fs_cache.get(&path.to_string()) { 36 - update_cache(ctx, path, show_hidden); 37 - res.json(entries); 38 - return Ok(()); 34 + let mut fs_cache = state.fs_cache.lock().await; 35 + if let Some(entries) = fs_cache.get(&path) { 36 + let entries = entries.clone(); 37 + drop(fs_cache); 38 + update_cache(state.fs_cache.clone(), &path, show_hidden); 39 + return Ok(HttpResponse::Ok().json(entries)); 39 40 } 40 41 41 - let mut entries = vec![]; 42 + let mut entries: Vec<Entry> = vec![]; 42 43 43 - for file in fs::read_dir(path)? { 44 - let file = file?; 44 + for file in fs::read_dir(&path).map_err(ErrorInternalServerError)? { 45 + let file = file.map_err(ErrorInternalServerError)?; 45 46 46 - if file.metadata()?.is_file() 47 + if file.metadata().map_err(ErrorInternalServerError)?.is_file() 47 48 && !AUDIO_EXTENSIONS.iter().any(|ext| { 48 49 file.path() 49 50 .to_string_lossy() ··· 53 54 continue; 54 55 } 55 56 56 - if file.file_name().to_string_lossy().starts_with(".") && !show_hidden { 57 + if file.file_name().to_string_lossy().starts_with('.') && !show_hidden { 57 58 continue; 58 59 } 59 60 60 61 entries.push(Entry { 61 62 name: file.path().to_string_lossy().to_string(), 62 63 time_write: file 63 - .metadata()? 64 - .modified()? 65 - .duration_since(std::time::SystemTime::UNIX_EPOCH)? 64 + .metadata() 65 + .map_err(ErrorInternalServerError)? 66 + .modified() 67 + .map_err(ErrorInternalServerError)? 68 + .duration_since(std::time::SystemTime::UNIX_EPOCH) 69 + .map_err(ErrorInternalServerError)? 66 70 .as_secs() as u32, 67 - attr: match file.metadata()?.is_dir() { 71 + attr: match file.metadata().map_err(ErrorInternalServerError)?.is_dir() { 68 72 true => 0x10, 69 73 false => 0, 70 74 }, ··· 72 76 }); 73 77 } 74 78 75 - fs_cache.insert(path.to_string(), entries.clone()); 79 + fs_cache.insert(path, entries.clone()); 76 80 77 - res.json(&entries); 78 - Ok(()) 81 + Ok(HttpResponse::Ok().json(entries)) 79 82 }
+39 -76
crates/server/src/handlers/devices.rs
··· 1 - use anyhow::Error; 1 + use actix_web::{web, HttpResponse}; 2 2 use rockbox_settings::{read_settings, save_settings_to_file}; 3 3 use rockbox_sys::sound::pcm; 4 4 5 - use crate::{ 6 - http::{Context, Request, Response}, 7 - GLOBAL_MUTEX, 8 - }; 5 + use crate::{http::AppState, GLOBAL_MUTEX}; 6 + 7 + type HandlerResult = actix_web::Result<HttpResponse>; 9 8 10 - pub async fn connect(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 11 - let id = &req.params[0]; 12 - let mut player = ctx.player.lock().unwrap(); 13 - let mut current_device = ctx.current_device.lock().unwrap(); 14 - let mut devices = ctx.devices.lock().unwrap(); 9 + pub async fn connect(state: web::Data<AppState>, path: web::Path<String>) -> HandlerResult { 10 + let id = path.into_inner(); 11 + let mut player = state.player.lock().unwrap(); 12 + let mut current_device = state.current_device.lock().unwrap(); 13 + let mut devices = state.devices.lock().unwrap(); 15 14 16 - let device = match devices.iter().find(|d| d.id == *id).cloned().or_else(|| { 17 - // Synthetic device from settings (mDNS not yet found it). 18 - current_device.as_ref().filter(|d| d.id == *id).cloned() 19 - }) { 15 + let device = match devices 16 + .iter() 17 + .find(|d| d.id == id) 18 + .cloned() 19 + .or_else(|| current_device.as_ref().filter(|d| d.id == id).cloned()) 20 + { 20 21 Some(d) => d, 21 - None => { 22 - res.set_status(404); 23 - return Ok(()); 24 - } 22 + None => return Ok(HttpResponse::NotFound().finish()), 25 23 }; 26 24 27 - // Stop any existing player session. 28 25 if let Some(p) = player.as_mut() { 29 26 let _ = p.stop().await; 30 27 let _ = p.disconnect().await; 31 28 } 32 29 *player = None; 33 30 34 - // If switching away from or to Chromecast, tear down the cast session so 35 - // the next pcm_chromecast_start() always gets a clean slate. 36 31 let old_service = current_device 37 32 .as_ref() 38 33 .map(|d| d.service.as_str()) ··· 41 36 pcm::chromecast_teardown(); 42 37 } 43 38 44 - // Read current settings so we preserve all other fields. 45 39 let mut settings = read_settings().unwrap_or_default(); 46 40 47 41 match device.service.as_str() { ··· 89 83 pcm::upnp_set_renderer_url(url); 90 84 } 91 85 pcm::upnp_set_http_port(http_port); 92 - // Reset renderer state so the very next sink_dma_start always fires 93 - // SetAVTransportURI + Play — without this, switching back to UPnP after 94 - // using another output would leave RENDERER_PLAYING=true and send no 95 - // play command, silently producing no audio until daemon restart. 96 86 pcm::upnp_reset_renderer(); 97 87 pcm::switch_sink(pcm::PCM_SINK_UPNP); 98 88 *GLOBAL_MUTEX.lock().unwrap() = 0; ··· 119 109 } 120 110 other => { 121 111 tracing::warn!("connect: unknown device service {:?}", other); 122 - res.set_status(400); 123 - return Ok(()); 112 + return Ok(HttpResponse::BadRequest().finish()); 124 113 } 125 114 } 126 115 127 - // Persist and update state before any potentially-failing connection attempt 128 - // so that the selection survives even if e.g. the Chromecast is temporarily unreachable. 129 116 if let Err(e) = save_settings_to_file(&settings) { 130 117 tracing::warn!("connect: failed to save settings: {e}"); 131 118 } 132 119 133 - // Mark new current device; clear is_current_device on all others. 134 120 for d in devices.iter_mut() { 135 121 d.is_current_device = d.id == device.id; 136 122 } 137 - *current_device = Some(device.clone()); 138 - 139 - // The Cast protocol session is managed entirely by the pcm.rs cast_loop; 140 - // no separate lib.rs Chromecast::connect() needed here. 123 + *current_device = Some(device); 141 124 142 - res.set_status(200); 143 - Ok(()) 125 + Ok(HttpResponse::Ok().finish()) 144 126 } 145 127 146 - pub async fn disconnect(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 147 - let _id = &req.params[0]; 148 - let mut player = ctx.player.lock().unwrap(); 149 - let mut current_device = ctx.current_device.lock().unwrap(); 150 - let mut devices = ctx.devices.lock().unwrap(); 128 + pub async fn disconnect(state: web::Data<AppState>, _path: web::Path<String>) -> HandlerResult { 129 + let mut player = state.player.lock().unwrap(); 130 + let mut current_device = state.current_device.lock().unwrap(); 131 + let mut devices = state.devices.lock().unwrap(); 151 132 152 133 if let Some(p) = player.as_mut() { 153 134 let _ = p.stop().await; ··· 156 137 *GLOBAL_MUTEX.lock().unwrap() = 0; 157 138 *player = None; 158 139 159 - // If disconnecting from Chromecast, stop the cast loop before switching sink. 160 140 if current_device 161 141 .as_ref() 162 142 .map_or(false, |d| d.service == "chromecast") ··· 164 144 pcm::chromecast_teardown(); 165 145 } 166 146 167 - // Fall back to built-in sink. 168 147 pcm::switch_sink(pcm::PCM_SINK_BUILTIN); 169 148 170 149 let mut settings = read_settings().unwrap_or_default(); ··· 173 152 tracing::warn!("disconnect: failed to save settings: {e}"); 174 153 } 175 154 176 - // Mark built-in as current. 177 155 for d in devices.iter_mut() { 178 156 d.is_current_device = d.id == "builtin"; 179 157 } 180 158 *current_device = devices.iter().find(|d| d.id == "builtin").cloned(); 181 159 182 - res.set_status(200); 183 - Ok(()) 160 + Ok(HttpResponse::Ok().finish()) 184 161 } 185 162 186 - pub async fn get_devices(ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 187 - let current = ctx.current_device.lock().unwrap().clone(); 188 - let devices = ctx.devices.lock().unwrap(); 163 + pub async fn get_devices(state: web::Data<AppState>) -> HandlerResult { 164 + let current = state.current_device.lock().unwrap().clone(); 165 + let devices = state.devices.lock().unwrap(); 189 166 190 167 let mut result: Vec<_> = devices 191 168 .iter() ··· 199 176 }) 200 177 .collect(); 201 178 202 - // If the current device isn't in the discovered list yet (e.g. Chromecast 203 - // from settings but mDNS hasn't found it), include it so UIs can show it. 204 179 if let Some(ref cd) = current { 205 180 if !result.iter().any(|d| devices_match(cd, d)) { 206 181 result.push(cd.clone()); 207 182 } 208 183 } 209 184 210 - res.json(&result); 211 - Ok(()) 185 + Ok(HttpResponse::Ok().json(result)) 212 186 } 213 187 214 - /// Two devices represent the same physical output if their IDs match OR if 215 - /// their service + IP match (handles ID format differences between settings- 216 - /// based synthetic devices and mDNS-discovered ones). 217 188 fn devices_match(a: &rockbox_types::device::Device, b: &rockbox_types::device::Device) -> bool { 218 189 if a.id == b.id { 219 190 return true; ··· 227 198 } 228 199 } 229 200 230 - pub async fn get_device(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 231 - let id = &req.params[0]; 201 + pub async fn get_device(state: web::Data<AppState>, path: web::Path<String>) -> HandlerResult { 202 + let id = path.into_inner(); 232 203 if id == "current" { 233 - let current_device = ctx.current_device.lock().unwrap(); 234 - if let Some(device) = current_device.as_ref() { 235 - res.json(&device.clone()); 236 - return Ok(()); 237 - } 238 - res.set_status(404); 239 - return Ok(()); 204 + let current_device = state.current_device.lock().unwrap(); 205 + return match current_device.as_ref() { 206 + Some(device) => Ok(HttpResponse::Ok().json(device)), 207 + None => Ok(HttpResponse::NotFound().finish()), 208 + }; 240 209 } 241 210 242 - let devices = ctx.devices.lock().unwrap(); 243 - let device = devices.iter().find(|d| d.id == *id); 244 - 245 - if let Some(device) = device { 246 - res.json(&device.clone()); 247 - return Ok(()); 211 + let devices = state.devices.lock().unwrap(); 212 + match devices.iter().find(|d| d.id == id) { 213 + Some(device) => Ok(HttpResponse::Ok().json(device)), 214 + None => Ok(HttpResponse::NotFound().finish()), 248 215 } 249 - 250 - res.json(&device); 251 - res.set_status(404); 252 - Ok(()) 253 216 }
+9 -11
crates/server/src/handlers/docs.rs
··· 1 - use anyhow::Error; 1 + use actix_web::HttpResponse; 2 2 3 - use crate::http::{Context, Request, Response}; 3 + type HandlerResult = actix_web::Result<HttpResponse>; 4 4 5 - pub async fn get_openapi(_ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 5 + pub async fn get_openapi() -> HandlerResult { 6 6 let spec = include_str!("../../openapi.json"); 7 - res.add_header("Content-Type", "application/json"); 8 - res.set_body(spec); 9 - Ok(()) 7 + Ok(HttpResponse::Ok() 8 + .content_type("application/json") 9 + .body(spec)) 10 10 } 11 11 12 - pub async fn index(_ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 13 - let index = include_str!("../../docs/index.html"); 14 - res.add_header("Content-Type", "text/html"); 15 - res.set_body(index); 16 - Ok(()) 12 + pub async fn index() -> HandlerResult { 13 + let html = include_str!("../../docs/index.html"); 14 + Ok(HttpResponse::Ok().content_type("text/html").body(html)) 17 15 }
-100
crates/server/src/handlers/mod.rs
··· 13 13 pub mod smart_playlists; 14 14 pub mod system; 15 15 pub mod tracks; 16 - 17 - use crate::http::{Context, Request, Response}; 18 - use anyhow::Error; 19 - 20 - macro_rules! async_handler { 21 - ($module:ident, $handler:ident) => { 22 - pub fn $handler( 23 - context: &Context, 24 - request: &Request, 25 - response: &mut Response, 26 - ) -> Result<(), Error> { 27 - context 28 - .rt 29 - .block_on($module::$handler(context, request, response))?; 30 - Ok(()) 31 - } 32 - }; 33 - } 34 - 35 - async_handler!(albums, get_albums); 36 - async_handler!(albums, get_album); 37 - async_handler!(albums, get_album_tracks); 38 - async_handler!(artists, get_artists); 39 - async_handler!(artists, get_artist); 40 - async_handler!(artists, get_artist_albums); 41 - async_handler!(artists, get_artist_tracks); 42 - async_handler!(browse, get_tree_entries); 43 - async_handler!(player, load); 44 - async_handler!(player, play); 45 - async_handler!(player, pause); 46 - async_handler!(player, resume); 47 - async_handler!(player, ff_rewind); 48 - async_handler!(player, status); 49 - async_handler!(player, current_track); 50 - async_handler!(player, next_track); 51 - async_handler!(player, flush_and_reload_tracks); 52 - async_handler!(player, next); 53 - async_handler!(player, previous); 54 - async_handler!(player, stop); 55 - async_handler!(player, get_file_position); 56 - async_handler!(player, get_volume); 57 - async_handler!(player, adjust_volume); 58 - async_handler!(player, get_current_player); 59 - async_handler!(playlists, create_playlist); 60 - async_handler!(playlists, start_playlist); 61 - async_handler!(playlists, shuffle_playlist); 62 - async_handler!(playlists, get_playlist_amount); 63 - async_handler!(playlists, resume_playlist); 64 - async_handler!(playlists, resume_track); 65 - async_handler!(playlists, get_playlist_tracks); 66 - async_handler!(playlists, insert_tracks); 67 - async_handler!(playlists, remove_tracks); 68 - async_handler!(playlists, get_playlist); 69 - async_handler!(saved_playlists, list_saved_playlists); 70 - async_handler!(saved_playlists, get_saved_playlist); 71 - async_handler!(saved_playlists, create_saved_playlist); 72 - async_handler!(saved_playlists, update_saved_playlist); 73 - async_handler!(saved_playlists, delete_saved_playlist); 74 - async_handler!(saved_playlists, get_saved_playlist_tracks); 75 - async_handler!(saved_playlists, get_saved_playlist_track_ids); 76 - async_handler!(saved_playlists, add_tracks_to_saved_playlist); 77 - async_handler!(saved_playlists, remove_track_from_saved_playlist); 78 - async_handler!(saved_playlists, play_saved_playlist); 79 - async_handler!(saved_playlists, list_playlist_folders); 80 - async_handler!(saved_playlists, create_playlist_folder); 81 - async_handler!(saved_playlists, delete_playlist_folder); 82 - async_handler!(tracks, get_tracks); 83 - async_handler!(tracks, get_track); 84 - async_handler!(tracks, save_stream_track_metadata); 85 - async_handler!(system, get_rockbox_version); 86 - async_handler!(system, get_status); 87 - async_handler!(system, scan_library); 88 - async_handler!(settings, get_global_settings); 89 - async_handler!(settings, update_global_settings); 90 - async_handler!(docs, get_openapi); 91 - async_handler!(docs, index); 92 - async_handler!(search, search); 93 - async_handler!(devices, connect); 94 - async_handler!(devices, disconnect); 95 - async_handler!(devices, get_devices); 96 - async_handler!(devices, get_device); 97 - async_handler!(smart_playlists, list_smart_playlists); 98 - async_handler!(smart_playlists, get_smart_playlist); 99 - async_handler!(smart_playlists, create_smart_playlist); 100 - async_handler!(smart_playlists, update_smart_playlist); 101 - async_handler!(smart_playlists, delete_smart_playlist); 102 - async_handler!(smart_playlists, get_smart_playlist_tracks); 103 - async_handler!(smart_playlists, play_smart_playlist); 104 - async_handler!(smart_playlists, record_track_played); 105 - async_handler!(smart_playlists, record_track_skipped); 106 - async_handler!(smart_playlists, get_track_stats); 107 - 108 - #[cfg(target_os = "linux")] 109 - async_handler!(bluetooth, scan_bluetooth); 110 - #[cfg(target_os = "linux")] 111 - async_handler!(bluetooth, get_bluetooth_devices); 112 - #[cfg(target_os = "linux")] 113 - async_handler!(bluetooth, connect_bluetooth_device); 114 - #[cfg(target_os = "linux")] 115 - async_handler!(bluetooth, disconnect_bluetooth_device);
+324 -304
crates/server/src/handlers/player.rs
··· 1 1 use std::{env, ffi::CString}; 2 2 3 - use crate::PLAYER_MUTEX; 4 - use crate::{ 5 - http::{Context, Request, Response}, 6 - GLOBAL_MUTEX, 7 - }; 8 - use anyhow::{anyhow, Error}; 3 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 9 4 use local_ip_addr::get_local_ip_address; 10 5 use rand::seq::SliceRandom; 11 6 use rockbox_chromecast::Chromecast; ··· 16 11 }; 17 12 use rockbox_traits::types::track::Track; 18 13 use rockbox_types::{device::Device, LoadTracks, NewVolume}; 14 + use serde::Deserialize; 15 + 16 + use crate::{http::AppState, GLOBAL_MUTEX, PLAYER_MUTEX}; 17 + 18 + type HandlerResult = actix_web::Result<HttpResponse>; 19 19 20 20 unsafe extern "C" { 21 21 fn save_remote_track_metadata(url: *const std::ffi::c_char) -> i32; 22 22 } 23 23 24 - pub async fn load(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 25 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 26 - let mut player = ctx.player.lock().unwrap(); 24 + pub async fn load(state: web::Data<AppState>, body: web::Json<LoadTracks>) -> HandlerResult { 25 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 26 + let mut player = state.player.lock().unwrap(); 27 27 if player.is_none() { 28 - res.set_status(404); 29 - return Ok(()); 28 + return Ok(HttpResponse::NotFound().finish()); 30 29 } 31 30 32 - let mut current_device = ctx.current_device.lock().unwrap(); 33 - let devices = ctx.devices.lock().unwrap(); 31 + let mut current_device = state.current_device.lock().unwrap(); 32 + let devices = state.devices.lock().unwrap(); 34 33 let device = devices 35 34 .iter() 36 - .find(|d| d.id == *current_device.as_ref().unwrap().id); 35 + .find(|d| d.id == *current_device.as_ref().unwrap().id) 36 + .cloned(); 37 37 if let Some(device) = device { 38 38 let mut mutex = GLOBAL_MUTEX.lock().unwrap(); 39 39 *mutex = 1; 40 - *player = Chromecast::connect(device.clone())?; 41 - *current_device = Some(device.clone()); 40 + *player = Chromecast::connect(device.clone()).map_err(ErrorInternalServerError)?; 41 + *current_device = Some(device); 42 42 } 43 43 44 44 let player = player.as_deref_mut().unwrap(); 45 45 46 - let req_body = req.body.as_ref().unwrap(); 47 - let request: LoadTracks = serde_json::from_str(&req_body)?; 46 + let request = body.into_inner(); 48 47 49 48 for path in &request.tracks { 50 49 if path.starts_with("http://") || path.starts_with("https://") { 51 - ensure_remote_track_metadata(path.clone()).await?; 50 + ensure_remote_track_metadata(path.clone()) 51 + .await 52 + .map_err(ErrorInternalServerError)?; 52 53 } 53 54 } 54 55 ··· 58 59 59 60 for requested_path in &request.tracks { 60 61 let track = { 61 - let kv = ctx.kv.lock().unwrap(); 62 + let kv = state.kv.lock().unwrap(); 62 63 kv.get(requested_path).cloned() 63 64 }; 64 65 65 66 let track = match track { 66 - Some(track) => Some(track), 67 + Some(t) => Some(t), 67 68 None => { 68 - let track = repo::track::find_by_path(ctx.pool.clone(), requested_path).await?; 69 - if let Some(ref track) = track { 70 - let mut kv = ctx.kv.lock().unwrap(); 71 - kv.set(requested_path, track.clone()); 69 + let t = repo::track::find_by_path(state.pool.clone(), requested_path) 70 + .await 71 + .map_err(ErrorInternalServerError)?; 72 + if let Some(ref t) = t { 73 + let mut kv = state.kv.lock().unwrap(); 74 + kv.set(requested_path, t.clone()); 72 75 } 73 - track 76 + t 74 77 } 75 78 }; 76 79 ··· 100 103 } 101 104 102 105 if tracks.is_empty() { 103 - res.set_status(404); 104 - res.text("No playable tracks found"); 105 - return Ok(()); 106 + return Ok(HttpResponse::NotFound().body("No playable tracks found")); 106 107 } 107 108 109 + let mut tracks = tracks; 108 110 if Some(true) == request.shuffle { 109 111 tracks.shuffle(&mut rand::thread_rng()); 110 112 } 111 113 112 - player.load_tracks(tracks, None).await?; 114 + player 115 + .load_tracks(tracks, None) 116 + .await 117 + .map_err(ErrorInternalServerError)?; 113 118 114 - res.set_status(200); 119 + Ok(HttpResponse::Ok().finish()) 120 + } 115 121 116 - drop(player_mutex); 117 - 118 - Ok(()) 122 + #[derive(Deserialize)] 123 + pub struct PlayQuery { 124 + pub elapsed: Option<i64>, 125 + pub offset: Option<i64>, 119 126 } 120 127 121 - pub async fn play(ctx: &Context, req: &Request, _res: &mut Response) -> Result<(), Error> { 122 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 123 - let elapsed = match req.query_params.get("elapsed") { 124 - Some(elapsed) => elapsed.as_str().unwrap_or("0").parse().unwrap_or(0), 125 - None => 0, 126 - }; 127 - let offset = match req.query_params.get("offset") { 128 - Some(offset) => offset.as_str().unwrap_or("0").parse().unwrap_or(0), 129 - None => 0, 130 - }; 131 - let player = ctx.player.lock().unwrap(); 128 + pub async fn play(state: web::Data<AppState>, query: web::Query<PlayQuery>) -> HandlerResult { 129 + let elapsed = query.elapsed.unwrap_or(0); 130 + let offset = query.offset.unwrap_or(0); 132 131 133 - if player.is_none() { 134 - rb::playback::play(elapsed, offset); 135 - } 132 + web::block(move || { 133 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 134 + if state.player.lock().unwrap().is_none() { 135 + rb::playback::play(elapsed, offset); 136 + } 137 + }) 138 + .await 139 + .map_err(ErrorInternalServerError)?; 136 140 137 - drop(player_mutex); 138 - 139 - Ok(()) 141 + Ok(HttpResponse::Ok().finish()) 140 142 } 141 143 142 - pub async fn pause(ctx: &Context, _req: &Request, _res: &mut Response) -> Result<(), Error> { 143 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 144 - let player = ctx.player.lock().unwrap(); 144 + pub async fn pause(state: web::Data<AppState>) -> HandlerResult { 145 + let has_external = state.player.lock().unwrap().is_some(); 145 146 146 - match player.as_deref() { 147 - Some(player) => { 148 - player.pause().await?; 147 + if has_external { 148 + let mut player = state.player.lock().unwrap(); 149 + if let Some(p) = player.as_deref_mut() { 150 + p.pause().await.map_err(ErrorInternalServerError)?; 149 151 } 150 - None => { 152 + } else { 153 + web::block(move || { 154 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 151 155 rb::playback::pause(); 152 - } 156 + }) 157 + .await 158 + .map_err(ErrorInternalServerError)?; 153 159 } 154 160 155 - drop(player_mutex); 161 + Ok(HttpResponse::Ok().finish()) 162 + } 156 163 157 - Ok(()) 164 + #[derive(Deserialize)] 165 + pub struct FfRewindQuery { 166 + pub newtime: Option<i32>, 158 167 } 159 168 160 - pub async fn ff_rewind(_ctx: &Context, req: &Request, _res: &mut Response) -> Result<(), Error> { 161 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 162 - let newtime = match req.query_params.get("newtime") { 163 - Some(newtime) => newtime.as_str().unwrap_or("0").parse().unwrap_or(0), 164 - None => 0, 165 - }; 166 - rb::playback::ff_rewind(newtime); 167 - 168 - drop(player_mutex); 169 - 170 - Ok(()) 169 + pub async fn ff_rewind(query: web::Query<FfRewindQuery>) -> HandlerResult { 170 + let newtime = query.newtime.unwrap_or(0); 171 + web::block(move || { 172 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 173 + rb::playback::ff_rewind(newtime); 174 + }) 175 + .await 176 + .map_err(ErrorInternalServerError)?; 177 + Ok(HttpResponse::Ok().finish()) 171 178 } 172 179 173 - pub async fn status(ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 174 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 175 - let mut player = ctx.player.lock().unwrap(); 180 + pub async fn status(state: web::Data<AppState>) -> HandlerResult { 181 + let has_external = state.player.lock().unwrap().is_some(); 176 182 177 - if let Some(player) = player.as_deref_mut() { 178 - let current_playback = player.get_current_playback().await?; 179 - res.json(&AudioStatus { 180 - status: match current_playback.is_playing { 181 - true => 1, 182 - false => 0, 183 - }, 184 - }); 185 - return Ok(()); 183 + if has_external { 184 + let mut player = state.player.lock().unwrap(); 185 + if let Some(p) = player.as_deref_mut() { 186 + let current_playback = p 187 + .get_current_playback() 188 + .await 189 + .map_err(ErrorInternalServerError)?; 190 + return Ok(HttpResponse::Ok().json(AudioStatus { 191 + status: if current_playback.is_playing { 1 } else { 0 }, 192 + })); 193 + } 186 194 } 187 195 188 - let status = rb::playback::status(); 189 - res.json(&status); 196 + let status = web::block(|| { 197 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 198 + rb::playback::status() 199 + }) 200 + .await 201 + .map_err(ErrorInternalServerError)?; 202 + 203 + Ok(HttpResponse::Ok().json(status)) 204 + } 190 205 191 - drop(player_mutex); 206 + pub async fn current_track(state: web::Data<AppState>) -> HandlerResult { 207 + let has_external = state.player.lock().unwrap().is_some(); 208 + 209 + if has_external { 210 + let mut player = state.player.lock().unwrap(); 211 + if let Some(p) = player.as_deref_mut() { 212 + let current_playback = p 213 + .get_current_playback() 214 + .await 215 + .map_err(ErrorInternalServerError)?; 216 + let mut track: Option<Mp3Entry> = current_playback.current_track.map(|mut t| { 217 + if t.path.is_empty() { 218 + t.path = t.uri.clone(); 219 + } 220 + t.into() 221 + }); 192 222 193 - Ok(()) 194 - } 223 + if let Some(lookup_path) = track 224 + .as_ref() 225 + .map(|t| t.path.clone()) 226 + .filter(|path| !path.is_empty()) 227 + { 228 + let metadata = find_track_metadata(&state, &lookup_path) 229 + .await 230 + .map_err(ErrorInternalServerError)?; 231 + if let Some(metadata) = metadata { 232 + let t = track.as_mut().unwrap(); 233 + t.id = Some(metadata.id); 234 + t.album_art = metadata.album_art.or(t.album_art.clone()); 235 + t.album_id = Some(metadata.album_id); 236 + t.artist_id = Some(metadata.artist_id); 237 + } 238 + } 195 239 196 - pub async fn current_track(ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 197 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 198 - let mut player = ctx.player.lock().unwrap(); 240 + let track = track.map(|mut t| { 241 + t.elapsed = current_playback.position_ms as u64; 242 + t 243 + }); 244 + return Ok(HttpResponse::Ok().json(track)); 245 + } 246 + } 199 247 200 - if let Some(player) = player.as_deref_mut() { 201 - let current_playback = player.get_current_playback().await?; 202 - let mut track: Option<Mp3Entry> = current_playback.current_track.map(|mut t| { 203 - if t.path.is_empty() { 204 - t.path = t.uri.clone(); 248 + // Builtin player: FFI calls on a blocking thread, then DB lookup async 249 + let (track, audio_path, playlist_path) = web::block(|| { 250 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 251 + let track = rb::playback::current_track(); 252 + let audio_path: Option<String> = track.as_ref().map(|t| t.path.clone()); 253 + let playlist_index = rb::playlist::index(); 254 + let playlist_path = if playlist_index >= 0 { 255 + let info = rb::playlist::get_track_info(playlist_index); 256 + if !info.filename.is_empty() { 257 + Some(info.filename) 258 + } else { 259 + None 205 260 } 206 - t.into() 207 - }); 261 + } else { 262 + None 263 + }; 264 + (track, audio_path, playlist_path) 265 + }) 266 + .await 267 + .map_err(ErrorInternalServerError)?; 208 268 209 - if let Some(lookup_path) = track 210 - .as_ref() 211 - .map(|t| t.path.clone()) 212 - .filter(|path| !path.is_empty()) 269 + let mut track = track; 270 + let lookup_path = playlist_path.or(audio_path); 271 + if let Some(path) = lookup_path { 272 + if let Some(metadata) = find_track_metadata(&state, &path) 273 + .await 274 + .map_err(ErrorInternalServerError)? 213 275 { 214 - let metadata = find_track_metadata(ctx, &lookup_path).await?; 215 - if let Some(metadata) = metadata { 216 - let current_track = track.as_mut().unwrap(); 217 - current_track.id = Some(metadata.id); 218 - current_track.album_art = metadata.album_art.or(current_track.album_art.clone()); 219 - current_track.album_id = Some(metadata.album_id); 220 - current_track.artist_id = Some(metadata.artist_id); 276 + if let Some(t) = track.as_mut() { 277 + t.id = Some(metadata.id); 278 + t.album_art = metadata.album_art; 279 + t.album_id = Some(metadata.album_id); 280 + t.artist_id = Some(metadata.artist_id); 221 281 } 222 282 } 283 + } 284 + Ok(HttpResponse::Ok().json(track)) 285 + } 223 286 224 - let track = track.map(|mut t| { 225 - t.elapsed = current_playback.position_ms as u64; 226 - t 227 - }); 228 - res.json(&track); 229 - return Ok(()); 287 + pub async fn next_track(state: web::Data<AppState>) -> HandlerResult { 288 + if state.player.lock().unwrap().is_some() { 289 + return Ok(HttpResponse::Ok().json(Option::<Mp3Entry>::None)); 230 290 } 231 291 232 - let mut track = rb::playback::current_track(); 233 - let audio_path: Option<String> = track.as_ref().map(|t| t.path.clone()); 234 - 235 - // Use the playlist filename for DB lookup — it matches the key under which 236 - // metadata was saved (e.g. the HTTP URL) and is what the broker uses to 237 - // display album art correctly. audio_current_track()->path can diverge for 238 - // HTTP stream tracks so we treat it only as a fallback. 239 - let playlist_index = rb::playlist::index(); 240 - let playlist_path = if playlist_index >= 0 { 241 - let info = rb::playlist::get_track_info(playlist_index); 242 - if !info.filename.is_empty() { 243 - Some(info.filename) 292 + let (track, audio_path, playlist_path) = web::block(|| { 293 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 294 + let track = rb::playback::next_track(); 295 + let audio_path: Option<String> = track.as_ref().map(|t| t.path.clone()); 296 + let current_index = rb::playlist::index(); 297 + let next_index = current_index + 1; 298 + let playlist_path = if next_index >= 0 && next_index < rb::playlist::amount() { 299 + let info = rb::playlist::get_track_info(next_index); 300 + if !info.filename.is_empty() { 301 + Some(info.filename) 302 + } else { 303 + None 304 + } 244 305 } else { 245 306 None 246 - } 247 - } else { 248 - None 249 - }; 307 + }; 308 + (track, audio_path, playlist_path) 309 + }) 310 + .await 311 + .map_err(ErrorInternalServerError)?; 250 312 313 + let mut track = track; 251 314 let lookup_path = playlist_path.or(audio_path); 252 315 if let Some(path) = lookup_path { 253 - if let Some(metadata) = find_track_metadata(ctx, &path).await? { 254 - track.as_mut().unwrap().id = Some(metadata.id); 255 - track.as_mut().unwrap().album_art = metadata.album_art; 256 - track.as_mut().unwrap().album_id = Some(metadata.album_id); 257 - track.as_mut().unwrap().artist_id = Some(metadata.artist_id); 316 + let hash = format!("{:x}", md5::compute(path.as_bytes())); 317 + if let Some(metadata) = repo::track::find_by_md5(state.pool.clone(), &hash) 318 + .await 319 + .map_err(ErrorInternalServerError)? 320 + { 321 + if let Some(t) = track.as_mut() { 322 + t.id = Some(metadata.id); 323 + t.album_art = metadata.album_art; 324 + t.album_id = Some(metadata.album_id); 325 + t.artist_id = Some(metadata.artist_id); 326 + } 327 + } 328 + } 329 + Ok(HttpResponse::Ok().json(track)) 330 + } 331 + 332 + pub async fn flush_and_reload_tracks() -> HandlerResult { 333 + web::block(|| { 334 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 335 + rb::playback::flush_and_reload_tracks(); 336 + }) 337 + .await 338 + .map_err(ErrorInternalServerError)?; 339 + Ok(HttpResponse::Ok().finish()) 340 + } 341 + 342 + pub async fn resume(state: web::Data<AppState>) -> HandlerResult { 343 + let has_external = state.player.lock().unwrap().is_some(); 344 + 345 + if has_external { 346 + let mut player = state.player.lock().unwrap(); 347 + if let Some(p) = player.as_deref_mut() { 348 + p.play().await.map_err(ErrorInternalServerError)?; 258 349 } 350 + } else { 351 + web::block(|| { 352 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 353 + rb::playback::resume(); 354 + }) 355 + .await 356 + .map_err(ErrorInternalServerError)?; 259 357 } 260 - res.json(&track); 358 + 359 + Ok(HttpResponse::Ok().finish()) 360 + } 361 + 362 + pub async fn next() -> HandlerResult { 363 + web::block(|| { 364 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 365 + rb::playback::next(); 366 + }) 367 + .await 368 + .map_err(ErrorInternalServerError)?; 369 + Ok(HttpResponse::Ok().finish()) 370 + } 371 + 372 + pub async fn previous() -> HandlerResult { 373 + web::block(|| { 374 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 375 + rb::playback::prev(); 376 + }) 377 + .await 378 + .map_err(ErrorInternalServerError)?; 379 + Ok(HttpResponse::Ok().finish()) 380 + } 261 381 262 - drop(player_mutex); 382 + pub async fn stop() -> HandlerResult { 383 + web::block(|| { 384 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 385 + rb::playback::hard_stop(); 386 + }) 387 + .await 388 + .map_err(ErrorInternalServerError)?; 389 + Ok(HttpResponse::Ok().finish()) 390 + } 391 + 392 + pub async fn get_file_position() -> HandlerResult { 393 + let position = web::block(|| { 394 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 395 + rb::playback::get_file_pos() 396 + }) 397 + .await 398 + .map_err(ErrorInternalServerError)?; 399 + Ok(HttpResponse::Ok().json(position)) 400 + } 263 401 264 - Ok(()) 402 + pub async fn get_volume() -> HandlerResult { 403 + let (volume, min, max) = web::block(|| { 404 + const SOUND_VOLUME: i32 = 0; 405 + ( 406 + rb::sound::current(SOUND_VOLUME), 407 + rb::sound::min(SOUND_VOLUME), 408 + rb::sound::max(SOUND_VOLUME), 409 + ) 410 + }) 411 + .await 412 + .map_err(ErrorInternalServerError)?; 413 + Ok(HttpResponse::Ok().json(serde_json::json!({ "volume": volume, "min": min, "max": max }))) 414 + } 415 + 416 + pub async fn adjust_volume(body: web::Json<NewVolume>) -> HandlerResult { 417 + let new_volume = body.into_inner(); 418 + let steps = new_volume.steps; 419 + web::block(move || rb::sound::adjust_volume(steps)) 420 + .await 421 + .map_err(ErrorInternalServerError)?; 422 + Ok(HttpResponse::Ok().json(new_volume)) 423 + } 424 + 425 + pub async fn get_current_player(state: web::Data<AppState>) -> HandlerResult { 426 + let device = state.current_device.lock().unwrap(); 427 + 428 + if let Some(device) = device.as_ref() { 429 + return Ok(HttpResponse::Ok().json(device)); 430 + } 431 + 432 + Ok(HttpResponse::Ok().json(Device { 433 + name: "Rockbox (Default Player)".to_string(), 434 + app: "default".to_string(), 435 + service: "rockbox".to_string(), 436 + ..Default::default() 437 + })) 265 438 } 266 439 267 440 async fn find_track_metadata( 268 - ctx: &Context, 441 + state: &AppState, 269 442 path: &str, 270 - ) -> Result<Option<rockbox_library::entity::track::Track>, Error> { 443 + ) -> Result<Option<rockbox_library::entity::track::Track>, anyhow::Error> { 271 444 let hash = format!("{:x}", md5::compute(path.as_bytes())); 272 - let mut metadata = repo::track::find_by_md5(ctx.pool.clone(), &hash).await?; 273 - let internal_track = find_internal_track_by_url(ctx, path).await?; 445 + let mut metadata = repo::track::find_by_md5(state.pool.clone(), &hash).await?; 446 + let internal_track = find_internal_track_by_url(state, path).await?; 274 447 275 448 if metadata 276 449 .as_ref() ··· 290 463 && internal_track.is_none() 291 464 { 292 465 ensure_remote_track_metadata(path.to_string()).await?; 293 - metadata = repo::track::find_by_md5(ctx.pool.clone(), &hash).await?; 466 + metadata = repo::track::find_by_md5(state.pool.clone(), &hash).await?; 294 467 } 295 468 } 296 469 297 470 Ok(metadata) 298 471 } 299 472 300 - async fn ensure_remote_track_metadata(path: String) -> Result<(), Error> { 301 - let status = tokio::task::spawn_blocking(move || -> Result<i32, Error> { 473 + async fn ensure_remote_track_metadata(path: String) -> Result<(), anyhow::Error> { 474 + let status = tokio::task::spawn_blocking(move || -> Result<i32, anyhow::Error> { 302 475 let path_cstr = CString::new(path.as_str())?; 303 476 Ok(unsafe { save_remote_track_metadata(path_cstr.as_ptr()) }) 304 477 }) 305 478 .await??; 306 479 307 480 if status != 0 { 308 - return Err(anyhow!("failed to save remote metadata")); 481 + return Err(anyhow::anyhow!("failed to save remote metadata")); 309 482 } 310 483 311 484 Ok(()) 312 485 } 313 486 314 487 async fn find_internal_track_by_url( 315 - ctx: &Context, 488 + state: &AppState, 316 489 path: &str, 317 - ) -> Result<Option<rockbox_library::entity::track::Track>, Error> { 490 + ) -> Result<Option<rockbox_library::entity::track::Track>, anyhow::Error> { 318 491 let url = match reqwest::Url::parse(path) { 319 492 Ok(url) => url, 320 493 Err(_) => return Ok(None), ··· 336 509 return Ok(None); 337 510 } 338 511 339 - repo::track::find(ctx.pool.clone(), track_id) 512 + repo::track::find(state.pool.clone(), track_id) 340 513 .await 341 514 .map_err(Into::into) 342 515 } 343 - 344 - pub async fn next_track(ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 345 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 346 - let player = ctx.player.lock().unwrap(); 347 - 348 - if let Some(_player) = player.as_deref() { 349 - return Ok(()); 350 - } 351 - 352 - let mut track = rb::playback::next_track(); 353 - let audio_path: Option<String> = track.as_ref().map(|t| t.path.clone()); 354 - 355 - // Use the next playlist entry's filename for DB lookup for the same reason 356 - // as in current_track: playlist filename matches the saved metadata key. 357 - let current_index = rb::playlist::index(); 358 - let next_index = current_index + 1; 359 - let playlist_path = if next_index >= 0 && next_index < rb::playlist::amount() { 360 - let info = rb::playlist::get_track_info(next_index); 361 - if !info.filename.is_empty() { 362 - Some(info.filename) 363 - } else { 364 - None 365 - } 366 - } else { 367 - None 368 - }; 369 - 370 - let lookup_path = playlist_path.or(audio_path); 371 - if let Some(path) = lookup_path { 372 - let hash = format!("{:x}", md5::compute(path.as_bytes())); 373 - if let Some(metadata) = repo::track::find_by_md5(ctx.pool.clone(), &hash).await? { 374 - track.as_mut().unwrap().id = Some(metadata.id); 375 - track.as_mut().unwrap().album_art = metadata.album_art; 376 - track.as_mut().unwrap().album_id = Some(metadata.album_id); 377 - track.as_mut().unwrap().artist_id = Some(metadata.artist_id); 378 - } 379 - } 380 - res.json(&track); 381 - 382 - drop(player_mutex); 383 - 384 - Ok(()) 385 - } 386 - 387 - pub async fn flush_and_reload_tracks( 388 - _ctx: &Context, 389 - _req: &Request, 390 - _res: &mut Response, 391 - ) -> Result<(), Error> { 392 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 393 - rb::playback::flush_and_reload_tracks(); 394 - drop(player_mutex); 395 - Ok(()) 396 - } 397 - 398 - pub async fn resume(ctx: &Context, _req: &Request, _res: &mut Response) -> Result<(), Error> { 399 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 400 - let player = ctx.player.lock().unwrap(); 401 - 402 - match player.as_deref() { 403 - Some(player) => { 404 - player.play().await?; 405 - } 406 - None => { 407 - rb::playback::resume(); 408 - } 409 - } 410 - 411 - drop(player_mutex); 412 - 413 - Ok(()) 414 - } 415 - 416 - pub async fn next(ctx: &Context, _req: &Request, _res: &mut Response) -> Result<(), Error> { 417 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 418 - // Always advance the Rockbox playlist regardless of active player (e.g. 419 - // Chromecast). The Cast monitor loop detects the track change and reloads. 420 - rb::playback::next(); 421 - drop(player_mutex); 422 - let _ = ctx; 423 - Ok(()) 424 - } 425 - 426 - pub async fn previous(ctx: &Context, _req: &Request, _res: &mut Response) -> Result<(), Error> { 427 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 428 - rb::playback::prev(); 429 - drop(player_mutex); 430 - let _ = ctx; 431 - Ok(()) 432 - } 433 - 434 - pub async fn stop(_ctx: &Context, _req: &Request, _res: &mut Response) -> Result<(), Error> { 435 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 436 - 437 - rb::playback::hard_stop(); 438 - 439 - drop(player_mutex); 440 - 441 - Ok(()) 442 - } 443 - 444 - pub async fn get_file_position( 445 - _ctx: &Context, 446 - _req: &Request, 447 - res: &mut Response, 448 - ) -> Result<(), Error> { 449 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 450 - let position = rb::playback::get_file_pos(); 451 - res.json(&position); 452 - 453 - drop(player_mutex); 454 - 455 - Ok(()) 456 - } 457 - 458 - pub async fn get_volume(_ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 459 - const SOUND_VOLUME: i32 = 0; 460 - let volume = rb::sound::current(SOUND_VOLUME); 461 - let min = rb::sound::min(SOUND_VOLUME); 462 - let max = rb::sound::max(SOUND_VOLUME); 463 - res.json(&serde_json::json!({ "volume": volume, "min": min, "max": max })); 464 - Ok(()) 465 - } 466 - 467 - pub async fn adjust_volume(_ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 468 - let req_body = req.body.as_ref().unwrap(); 469 - let new_volume: NewVolume = serde_json::from_str(&req_body).unwrap(); 470 - 471 - rb::sound::adjust_volume(new_volume.steps); 472 - res.json(&new_volume); 473 - Ok(()) 474 - } 475 - 476 - pub async fn get_current_player( 477 - ctx: &Context, 478 - _req: &Request, 479 - res: &mut Response, 480 - ) -> Result<(), Error> { 481 - let device = ctx.current_device.lock().unwrap(); 482 - 483 - if let Some(device) = device.as_ref() { 484 - res.json(device); 485 - return Ok(()); 486 - } 487 - 488 - res.json(&Device { 489 - name: "Rockbox (Default Player)".to_string(), 490 - app: "default".to_string(), 491 - service: "rockbox".to_string(), 492 - ..Default::default() 493 - }); 494 - Ok(()) 495 - }
+262 -347
crates/server/src/handlers/playlists.rs
··· 1 1 use std::{env, sync::atomic::Ordering, sync::Arc}; 2 2 3 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 3 4 use futures_util::stream::{FuturesUnordered, StreamExt}; 4 - use tokio::sync::Semaphore; 5 - 6 - use crate::http::{Context, Request, Response}; 7 - use crate::{PLAYER_MUTEX, PLAYLIST_DIRTY}; 8 - use anyhow::{anyhow, Error}; 9 5 use local_ip_addr::get_local_ip_address; 10 6 use rand::seq::SliceRandom; 11 - use rockbox_graphql::{read_files, read_files_with_art}; 7 + use rockbox_graphql::read_files_with_art; 12 8 use rockbox_library::audio_scan::save_audio_metadata; 13 9 use rockbox_library::repo; 14 10 use rockbox_sys::{ ··· 18 14 }; 19 15 use rockbox_traits::types::track::Track; 20 16 use rockbox_types::{DeleteTracks, InsertTracks, NewPlaylist, StatusCode}; 17 + use serde::Deserialize; 18 + use tokio::sync::Semaphore; 19 + 20 + use crate::{http::AppState, PLAYER_MUTEX, PLAYLIST_DIRTY}; 21 + 22 + type HandlerResult = actix_web::Result<HttpResponse>; 21 23 22 24 fn trim_path(s: String) -> String { 23 25 let s = s.trim(); ··· 25 27 } 26 28 27 29 pub async fn create_playlist( 28 - _ctx: &Context, 29 - req: &Request, 30 - res: &mut Response, 31 - ) -> Result<(), Error> { 32 - if req.body.is_none() { 33 - res.set_status(400); 34 - return Ok(()); 35 - } 36 - let body = req.body.as_ref().unwrap(); 37 - let mut new_playlist: NewPlaylist = serde_json::from_str(body).unwrap(); 30 + state: web::Data<AppState>, 31 + body: web::Json<NewPlaylist>, 32 + ) -> HandlerResult { 33 + let mut new_playlist = body.into_inner(); 38 34 new_playlist.tracks = new_playlist.tracks.into_iter().map(trim_path).collect(); 39 35 40 36 if new_playlist.tracks.is_empty() { 41 - return Ok(()); 37 + return Ok(HttpResponse::Ok().finish()); 42 38 } 43 39 44 40 let tracks_with_art: Vec<(String, Option<String>)> = new_playlist ··· 46 42 .iter() 47 43 .map(|t| (t.clone(), None)) 48 44 .collect(); 49 - persist_remote_track_metadata(_ctx.pool.clone(), tracks_with_art).await; 45 + persist_remote_track_metadata(state.pool.clone(), tracks_with_art).await; 50 46 51 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 47 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 52 48 53 - // For HTTP streams: flush the audio thread's message queue before replacing 54 - // the playlist. Stale Q_AUDIO_FILL_BUFFER messages from a previous HTTP 55 - // session (e.g. auto-resume) would otherwise act on the new playlist's 56 - // handle with the old stream context, causing the new play to be silently 57 - // ignored. For local files the queue is always drained by the time the 58 - // user starts a new playlist, so hard_stop is a no-op cost there. 59 49 let current_is_http = rb::playback::current_track() 60 50 .map(|t| t.path.starts_with("http://") || t.path.starts_with("https://")) 61 51 .unwrap_or(false); ··· 65 55 rb::playback::hard_stop(); 66 56 } 67 57 68 - // Always create a fresh playlist so the currently-playing track is 69 - // fully replaced rather than appended to. 70 - // Local paths: use the track's parent directory (required by Rockbox). 71 - // HTTP URLs: use the home directory — "/" fails because it isn't writable 72 - // and playlist_create needs to create its control file there. 73 58 let first = &new_playlist.tracks[0]; 74 59 let dir = if first.starts_with("http://") || first.starts_with("https://") { 75 60 std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()) ··· 79 64 }; 80 65 rb::playlist::create(&dir, None); 81 66 82 - // URLs are passed as-is; codec detection happens in the C metadata layer 83 - // via probe_content_type_format(), which reads the HTTP Content-Type header 84 - // and overrides any extension-based guess. 85 67 let start_index = rb::playlist::build_playlist( 86 68 new_playlist.tracks.iter().map(|t| t.as_str()).collect(), 87 69 0, 88 70 new_playlist.tracks.len() as i32, 89 71 ); 90 72 PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 91 - res.text(&start_index.to_string()); 92 - drop(player_mutex); 93 - Ok(()) 73 + Ok(HttpResponse::Ok().body(start_index.to_string())) 94 74 } 95 75 96 - pub async fn start_playlist( 97 - _ctx: &Context, 98 - req: &Request, 99 - _res: &mut Response, 100 - ) -> Result<(), Error> { 101 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 102 - let start_index = match req.query_params.get("start_index") { 103 - Some(start_index) => start_index.as_str().unwrap_or("0").parse().unwrap_or(0), 104 - None => 0, 105 - }; 106 - let elapsed = match req.query_params.get("elapsed") { 107 - Some(elapsed) => elapsed.as_str().unwrap_or("0").parse().unwrap_or(0), 108 - None => 0, 109 - }; 110 - let offset = match req.query_params.get("offset") { 111 - Some(offset) => offset.as_str().unwrap_or("0").parse().unwrap_or(0), 112 - None => 0, 113 - }; 114 - rb::playlist::start(start_index, elapsed, offset); 115 - PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 116 - drop(player_mutex); 117 - Ok(()) 76 + #[derive(Deserialize)] 77 + pub struct StartPlaylistQuery { 78 + start_index: Option<i32>, 79 + elapsed: Option<u64>, 80 + offset: Option<u64>, 118 81 } 119 82 120 - pub async fn shuffle_playlist( 121 - _ctx: &Context, 122 - req: &Request, 123 - res: &mut Response, 124 - ) -> Result<(), Error> { 125 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 126 - let start_index = match req.query_params.get("start_index") { 127 - Some(start_index) => start_index.as_str().unwrap_or("0").parse().unwrap_or(0), 128 - None => 0, 129 - }; 130 - let seed = rb::system::current_tick(); 131 - let ret = rb::playlist::shuffle(seed as i32, start_index as i32); 132 - res.text(&ret.to_string()); 133 - PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 134 - drop(player_mutex); 135 - Ok(()) 83 + pub async fn start_playlist(query: web::Query<StartPlaylistQuery>) -> HandlerResult { 84 + let start_index = query.start_index.unwrap_or(0); 85 + let elapsed = query.elapsed.unwrap_or(0); 86 + let offset = query.offset.unwrap_or(0); 87 + web::block(move || { 88 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 89 + rb::playlist::start(start_index, elapsed, offset); 90 + PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 91 + }) 92 + .await 93 + .map_err(ErrorInternalServerError)?; 94 + Ok(HttpResponse::Ok().finish()) 136 95 } 137 96 138 - pub async fn get_playlist_amount( 139 - _ctx: &Context, 140 - _req: &Request, 141 - res: &mut Response, 142 - ) -> Result<(), Error> { 143 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 144 - let amount = rb::playlist::amount(); 145 - res.json(&PlaylistAmount { amount }); 146 - drop(player_mutex); 147 - Ok(()) 97 + #[derive(Deserialize)] 98 + pub struct ShuffleQuery { 99 + start_index: Option<i32>, 148 100 } 149 101 150 - pub async fn resume_playlist( 151 - _ctx: &Context, 152 - _req: &Request, 153 - res: &mut Response, 154 - ) -> Result<(), Error> { 155 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 156 - let status = rb::system::get_global_status(); 157 - let playback_status = rb::playback::status(); 102 + pub async fn shuffle_playlist(query: web::Query<ShuffleQuery>) -> HandlerResult { 103 + let start_index = query.start_index.unwrap_or(0); 104 + let ret = web::block(move || { 105 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 106 + let seed = rb::system::current_tick(); 107 + let ret = rb::playlist::shuffle(seed as i32, start_index); 108 + PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 109 + ret 110 + }) 111 + .await 112 + .map_err(ErrorInternalServerError)?; 113 + Ok(HttpResponse::Ok().body(ret.to_string())) 114 + } 158 115 159 - if status.resume_index == -1 || playback_status.status == 1 { 160 - res.json(&StatusCode { code: -1 }); 161 - return Ok(()); 162 - } 116 + pub async fn get_playlist_amount() -> HandlerResult { 117 + let amount = web::block(|| { 118 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 119 + rb::playlist::amount() 120 + }) 121 + .await 122 + .map_err(ErrorInternalServerError)?; 123 + Ok(HttpResponse::Ok().json(PlaylistAmount { amount })) 124 + } 163 125 164 - let code = rb::playlist::resume(); 165 - res.json(&StatusCode { code }); 166 - PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 167 - drop(player_mutex); 168 - Ok(()) 126 + pub async fn resume_playlist() -> HandlerResult { 127 + let code = web::block(|| { 128 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 129 + let status = rb::system::get_global_status(); 130 + let playback_status = rb::playback::status(); 131 + if status.resume_index == -1 || playback_status.status == 1 { 132 + return -1; 133 + } 134 + let code = rb::playlist::resume(); 135 + PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 136 + code 137 + }) 138 + .await 139 + .map_err(ErrorInternalServerError)?; 140 + Ok(HttpResponse::Ok().json(StatusCode { code })) 169 141 } 170 142 171 - pub async fn resume_track( 172 - _ctx: &Context, 173 - _req: &Request, 174 - _res: &mut Response, 175 - ) -> Result<(), Error> { 176 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 177 - let status = rb::system::get_global_status(); 178 - if status.resume_index == -1 { 179 - return Ok(()); 180 - } 181 - // Rebuild playlist from control file if not already loaded — matches the 182 - // root_menu.c pattern: playlist_resume() then playlist_resume_track(). 183 - if rb::playlist::amount() == 0 { 184 - let ret = rb::playlist::resume(); 185 - if ret == -1 { 186 - return Ok(()); 143 + pub async fn resume_track() -> HandlerResult { 144 + web::block(|| { 145 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 146 + let status = rb::system::get_global_status(); 147 + if status.resume_index == -1 { 148 + return; 187 149 } 188 - } 189 - rb::playlist::resume_track( 190 - status.resume_index, 191 - status.resume_crc32, 192 - status.resume_elapsed.into(), 193 - status.resume_offset.into(), 194 - ); 195 - PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 196 - drop(player_mutex); 197 - Ok(()) 150 + if rb::playlist::amount() == 0 { 151 + let ret = rb::playlist::resume(); 152 + if ret == -1 { 153 + return; 154 + } 155 + } 156 + rb::playlist::resume_track( 157 + status.resume_index, 158 + status.resume_crc32, 159 + status.resume_elapsed.into(), 160 + status.resume_offset.into(), 161 + ); 162 + PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 163 + }) 164 + .await 165 + .map_err(ErrorInternalServerError)?; 166 + Ok(HttpResponse::Ok().finish()) 198 167 } 199 168 200 - pub async fn get_playlist_tracks( 201 - _ctx: &Context, 202 - _req: &Request, 203 - res: &mut Response, 204 - ) -> Result<(), Error> { 205 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 206 - let mut entries = vec![]; 207 - let amount = rb::playlist::amount(); 208 - 209 - for i in 0..amount { 210 - let info = rb::playlist::get_track_info(i); 211 - let entry = rb::metadata::get_metadata(-1, &info.filename); 212 - entries.push(entry); 213 - } 214 - 215 - res.json(&entries); 216 - 217 - drop(player_mutex); 218 - Ok(()) 169 + pub async fn get_playlist_tracks(_path: web::Path<String>) -> HandlerResult { 170 + let entries = web::block(|| { 171 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 172 + let amount = rb::playlist::amount(); 173 + let mut entries = Vec::with_capacity(amount as usize); 174 + for i in 0..amount { 175 + let info = rb::playlist::get_track_info(i); 176 + entries.push(rb::metadata::get_metadata(-1, &info.filename)); 177 + } 178 + entries 179 + }) 180 + .await 181 + .map_err(ErrorInternalServerError)?; 182 + Ok(HttpResponse::Ok().json(entries)) 219 183 } 220 184 221 - pub async fn insert_tracks(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 222 - let req_body = req.body.as_ref().unwrap(); 223 - let mut tracklist: InsertTracks = serde_json::from_str(&req_body).unwrap(); 185 + pub async fn insert_tracks( 186 + state: web::Data<AppState>, 187 + _path: web::Path<String>, 188 + body: web::Json<InsertTracks>, 189 + ) -> HandlerResult { 190 + let mut tracklist = body.into_inner(); 224 191 tracklist.tracks = tracklist.tracks.into_iter().map(trim_path).collect(); 225 192 226 193 let mut tracks_with_art: Vec<(String, Option<String>)> = 227 194 tracklist.tracks.iter().map(|t| (t.clone(), None)).collect(); 228 195 229 196 if let Some(dir) = &tracklist.directory { 230 - let entries = read_files_with_art(dir.clone()).await?; 197 + let entries = read_files_with_art(dir.clone()) 198 + .await 199 + .map_err(ErrorInternalServerError)?; 231 200 tracklist.tracks = entries.iter().map(|(uri, _)| uri.clone()).collect(); 232 201 tracks_with_art = entries; 233 202 } 234 203 235 204 if tracklist.tracks.is_empty() { 236 - res.text("0"); 237 - return Ok(()); 205 + return Ok(HttpResponse::Ok().body("0")); 238 206 } 239 207 240 - persist_remote_track_metadata(ctx.pool.clone(), tracks_with_art).await; 208 + persist_remote_track_metadata(state.pool.clone(), tracks_with_art).await; 241 209 242 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 210 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 243 211 let amount = rb::playlist::amount(); 244 - 245 - let mut player = ctx.player.lock().unwrap(); 212 + let mut player = state.player.lock().unwrap(); 246 213 247 214 if let Some(player) = player.as_deref_mut() { 248 - let kv = ctx.kv.lock().unwrap(); 215 + let kv = state.kv.lock().unwrap(); 249 216 let rockbox_addr = 250 217 env::var("ROCKBOX_ADDR").unwrap_or_else(|_| get_local_ip_address().unwrap()); 251 218 let rockbox_port = env::var("ROCKBOX_GRAPHQL_PORT").unwrap_or_else(|_| "6062".to_string()); ··· 281 248 .collect::<Vec<Track>>(); 282 249 283 250 for track in tracks { 284 - player.play_next(track).await?; 251 + player 252 + .play_next(track) 253 + .await 254 + .map_err(ErrorInternalServerError)?; 285 255 } 286 256 287 - res.text("0"); 288 - return Ok(()); 257 + return Ok(HttpResponse::Ok().body("0")); 289 258 } 290 259 291 260 if amount == 0 { ··· 298 267 }; 299 268 let status = rb::playlist::create(&dir, None); 300 269 if status == -1 { 301 - res.set_status(500); 302 - res.text("Failed to create playlist"); 303 - return Ok(()); 270 + return Ok(HttpResponse::InternalServerError().body("Failed to create playlist")); 304 271 } 305 - let start_index = 0; 306 272 let start_index = rb::playlist::build_playlist( 307 273 tracklist.tracks.iter().map(|t| t.as_str()).collect(), 308 - start_index, 274 + 0, 309 275 tracklist.tracks.len() as i32, 310 276 ); 311 - res.text(&start_index.to_string()); 312 277 PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 313 - return Ok(()); 278 + return Ok(HttpResponse::Ok().body(start_index.to_string())); 314 279 } 315 280 316 281 let mut tracks: Vec<&str> = tracklist.tracks.iter().map(|t| t.as_str()).collect(); ··· 323 288 }; 324 289 rb::playlist::insert_tracks(tracks, position, tracklist.tracks.len() as i32); 325 290 326 - res.text(&tracklist.position.to_string()); 291 + PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 292 + Ok(HttpResponse::Ok().body(tracklist.position.to_string())) 293 + } 294 + 295 + pub async fn remove_tracks( 296 + state: web::Data<AppState>, 297 + _path: web::Path<String>, 298 + body: web::Json<DeleteTracks>, 299 + ) -> HandlerResult { 300 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 301 + let player = state.player.lock().unwrap(); 302 + 303 + if player.as_deref().is_some() { 304 + return Ok(HttpResponse::Ok().body("0")); 305 + } 306 + 307 + let params = body.into_inner(); 308 + let mut ret = 0; 309 + 310 + for position in &params.positions { 311 + ret = rb::playlist::delete_track(*position); 312 + } 313 + 314 + if params.positions.is_empty() { 315 + ret = rb::playlist::remove_all_tracks(); 316 + PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 317 + return Ok(HttpResponse::Ok().body(ret.to_string())); 318 + } 319 + 327 320 PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 328 - drop(player_mutex); 321 + Ok(HttpResponse::Ok().body(ret.to_string())) 322 + } 323 + 324 + pub async fn get_playlist(state: web::Data<AppState>, _path: web::Path<String>) -> HandlerResult { 325 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 326 + let mut player = state.player.lock().unwrap(); 329 327 330 - Ok(()) 328 + if let Some(player) = player.as_deref_mut() { 329 + let current_playback = player 330 + .get_current_playback() 331 + .await 332 + .map_err(ErrorInternalServerError)?; 333 + let tracks = current_playback.items; 334 + let index = match tracks.len() >= 2 { 335 + true => tracks.len() - 2, 336 + false => 0, 337 + } as i32; 338 + 339 + let mut entries = Vec::with_capacity(tracks.len()); 340 + for (mut track, _) in tracks { 341 + if track.path.is_empty() { 342 + track.path = track.uri.clone(); 343 + } 344 + 345 + let mut entry: rockbox_sys::types::mp3_entry::Mp3Entry = track.into(); 346 + if !entry.path.is_empty() { 347 + if let Some(metadata) = find_track_metadata(&state, &entry.path) 348 + .await 349 + .map_err(ErrorInternalServerError)? 350 + { 351 + entry.id = Some(metadata.id); 352 + entry.album_art = metadata.album_art.or(entry.album_art.clone()); 353 + entry.album_id = Some(metadata.album_id); 354 + entry.artist_id = Some(metadata.artist_id); 355 + entry.genre_id = Some(metadata.genre_id); 356 + } 357 + } 358 + 359 + entries.push(entry); 360 + } 361 + 362 + let result = PlaylistInfo { 363 + amount: entries.len() as i32, 364 + index, 365 + entries, 366 + ..Default::default() 367 + }; 368 + return Ok(HttpResponse::Ok().json(result)); 369 + } 370 + 371 + let mut metadata_cache = state.metadata_cache.lock().await; 372 + let mut result = rb::playlist::get_current(); 373 + let mut entries = vec![]; 374 + let amount = rb::playlist::amount(); 375 + 376 + for i in 0..amount { 377 + let info = rb::playlist::get_track_info(i); 378 + let mut entry = rb::metadata::get_metadata(-1, &info.filename); 379 + let hash = format!("{:x}", md5::compute(info.filename.as_bytes())); 380 + 381 + if let Some(cached) = metadata_cache.get(&hash) { 382 + entries.push(cached.clone()); 383 + continue; 384 + } 385 + 386 + let track = find_track_metadata(&state, &info.filename) 387 + .await 388 + .map_err(ErrorInternalServerError)?; 389 + 390 + if track.is_none() { 391 + entries.push(entry.clone()); 392 + continue; 393 + } 394 + 395 + entry.album_art = track.as_ref().and_then(|t| t.album_art.clone()); 396 + entry.album_id = track.as_ref().map(|t| t.album_id.clone()); 397 + entry.artist_id = track.as_ref().map(|t| t.artist_id.clone()); 398 + entry.genre_id = track.as_ref().map(|t| t.genre_id.clone()); 399 + entry.id = track.as_ref().map(|t| t.id.clone()); 400 + 401 + metadata_cache.insert(hash, entry.clone()); 402 + entries.push(entry); 403 + } 404 + 405 + result.amount = amount; 406 + result.max_playlist_size = rb::playlist::max_playlist_size(); 407 + result.index = rb::playlist::index(); 408 + result.first_index = rb::playlist::first_index(); 409 + result.last_insert_pos = rb::playlist::last_insert_pos(); 410 + result.seed = rb::playlist::seed(); 411 + result.last_shuffled_start = rb::playlist::last_shuffled_start(); 412 + result.entries = entries; 413 + 414 + Ok(HttpResponse::Ok().json(result)) 331 415 } 332 416 333 417 async fn persist_remote_track_metadata( 334 418 pool: sqlx::Pool<sqlx::Sqlite>, 335 419 tracks: Vec<(String, Option<String>)>, 336 420 ) { 337 - // Raw PCM streams served at /stream.wav have no embedded metadata and 338 - // probing them would block for ~47 s (8 MB at audio bitrate) then time out. 339 421 let sem = Arc::new(Semaphore::new(8)); 340 422 let mut futs: FuturesUnordered<tokio::task::JoinHandle<()>> = FuturesUnordered::new(); 341 423 ··· 349 431 { 350 432 continue; 351 433 } 352 - // Internal /tracks/{id} URLs are already in the library DB. 353 434 match find_internal_track_by_pool(&pool, &track).await { 354 435 Ok(Some(_)) => continue, 355 436 Ok(None) => {} ··· 372 453 } 373 454 374 455 async fn find_track_metadata( 375 - ctx: &Context, 456 + state: &AppState, 376 457 path: &str, 377 - ) -> Result<Option<rockbox_library::entity::track::Track>, Error> { 458 + ) -> Result<Option<rockbox_library::entity::track::Track>, anyhow::Error> { 378 459 let hash = format!("{:x}", md5::compute(path.as_bytes())); 379 - let mut metadata = repo::track::find_by_md5(ctx.pool.clone(), &hash).await?; 380 - let internal_track = find_internal_track_by_url(ctx, path).await?; 460 + let mut metadata = repo::track::find_by_md5(state.pool.clone(), &hash).await?; 461 + let internal_track = find_internal_track_by_url(state, path).await?; 381 462 382 463 if metadata 383 464 .as_ref() ··· 389 470 } 390 471 } 391 472 392 - // Metadata for remote tracks is probed in the background when tracks are 393 - // inserted (insert_tracks / create_playlist). Do not probe here — this 394 - // function is called once per track while holding PLAYER_MUTEX, so a 395 - // synchronous HTTP probe per track would block the entire server. 396 - 397 473 Ok(metadata) 398 474 } 399 475 400 476 async fn find_internal_track_by_url( 401 - ctx: &Context, 477 + state: &AppState, 402 478 path: &str, 403 - ) -> Result<Option<rockbox_library::entity::track::Track>, Error> { 404 - find_internal_track_by_pool(&ctx.pool, path).await 479 + ) -> Result<Option<rockbox_library::entity::track::Track>, anyhow::Error> { 480 + find_internal_track_by_pool(&state.pool, path).await 405 481 } 406 482 407 483 async fn find_internal_track_by_pool( 408 484 pool: &sqlx::Pool<sqlx::Sqlite>, 409 485 path: &str, 410 - ) -> Result<Option<rockbox_library::entity::track::Track>, Error> { 486 + ) -> Result<Option<rockbox_library::entity::track::Track>, anyhow::Error> { 411 487 let url = match reqwest::Url::parse(path) { 412 488 Ok(url) => url, 413 489 Err(_) => return Ok(None), ··· 433 509 .await 434 510 .map_err(Into::into) 435 511 } 436 - 437 - pub async fn remove_tracks(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 438 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 439 - let player = ctx.player.lock().unwrap(); 440 - 441 - if let Some(_) = player.as_deref() { 442 - res.text("0"); 443 - return Ok(()); 444 - } 445 - 446 - let req_body = req.body.as_ref().unwrap(); 447 - let params = serde_json::from_str::<DeleteTracks>(&req_body)?; 448 - let mut ret = 0; 449 - 450 - for position in &params.positions { 451 - ret = rb::playlist::delete_track(position.clone()); 452 - } 453 - 454 - if params.positions.is_empty() { 455 - ret = rb::playlist::remove_all_tracks(); 456 - res.text(&ret.to_string()); 457 - PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 458 - return Ok(()); 459 - } 460 - 461 - res.text(&ret.to_string()); 462 - PLAYLIST_DIRTY.store(true, Ordering::Relaxed); 463 - drop(player_mutex); 464 - Ok(()) 465 - } 466 - 467 - pub async fn current_playlist( 468 - ctx: &Context, 469 - _req: &Request, 470 - res: &mut Response, 471 - ) -> Result<(), Error> { 472 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 473 - let mut metadata_cache = ctx.metadata_cache.lock().await; 474 - let mut entries = vec![]; 475 - let amount = rb::playlist::amount(); 476 - 477 - for i in 0..amount { 478 - let info = rb::playlist::get_track_info(i); 479 - let mut entry = rb::metadata::get_metadata(-1, &info.filename); 480 - let hash = format!("{:x}", md5::compute(info.filename.as_bytes())); 481 - 482 - if let Some(entry) = metadata_cache.get(&hash) { 483 - entries.push(entry.clone()); 484 - continue; 485 - } 486 - 487 - let track = find_track_metadata(ctx, &info.filename).await?; 488 - 489 - if track.is_none() { 490 - entries.push(entry.clone()); 491 - continue; 492 - } 493 - 494 - entry.album_art = track.as_ref().map(|t| t.album_art.clone()).flatten(); 495 - entry.album_id = track.as_ref().map(|t| t.album_id.clone()); 496 - entry.artist_id = track.as_ref().map(|t| t.artist_id.clone()); 497 - entry.genre_id = track.as_ref().map(|t| t.genre_id.clone()); 498 - entry.id = track.as_ref().map(|t| t.id.clone()); 499 - 500 - metadata_cache.insert(hash, entry.clone()); 501 - entries.push(entry); 502 - } 503 - 504 - res.json(&entries); 505 - 506 - drop(player_mutex); 507 - Ok(()) 508 - } 509 - 510 - pub async fn get_playlist(ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 511 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 512 - let mut player = ctx.player.lock().unwrap(); 513 - 514 - if let Some(player) = player.as_deref_mut() { 515 - let current_playback = player.get_current_playback().await?; 516 - let tracks = current_playback.items; 517 - let index = match tracks.len() >= 2 { 518 - true => tracks.len() - 2, 519 - false => 0, 520 - } as i32; 521 - 522 - let mut entries = Vec::with_capacity(tracks.len()); 523 - for (mut track, _) in tracks { 524 - if track.path.is_empty() { 525 - track.path = track.uri.clone(); 526 - } 527 - 528 - let mut entry: rockbox_sys::types::mp3_entry::Mp3Entry = track.into(); 529 - if !entry.path.is_empty() { 530 - if let Some(metadata) = find_track_metadata(ctx, &entry.path).await? { 531 - entry.id = Some(metadata.id); 532 - entry.album_art = metadata.album_art.or(entry.album_art.clone()); 533 - entry.album_id = Some(metadata.album_id); 534 - entry.artist_id = Some(metadata.artist_id); 535 - entry.genre_id = Some(metadata.genre_id); 536 - } 537 - } 538 - 539 - entries.push(entry); 540 - } 541 - 542 - let result = PlaylistInfo { 543 - amount: entries.len() as i32, 544 - index, 545 - entries, 546 - ..Default::default() 547 - }; 548 - res.json(&result); 549 - return Ok(()); 550 - } 551 - 552 - let mut metadata_cache = ctx.metadata_cache.lock().await; 553 - let mut result = rb::playlist::get_current(); 554 - let mut entries = vec![]; 555 - let amount = rb::playlist::amount(); 556 - 557 - for i in 0..amount { 558 - let info = rb::playlist::get_track_info(i); 559 - let mut entry = rb::metadata::get_metadata(-1, &info.filename); 560 - let hash = format!("{:x}", md5::compute(info.filename.as_bytes())); 561 - 562 - if let Some(entry) = metadata_cache.get(&hash) { 563 - entries.push(entry.clone()); 564 - continue; 565 - } 566 - 567 - let track = find_track_metadata(ctx, &info.filename).await?; 568 - 569 - if track.is_none() { 570 - entries.push(entry.clone()); 571 - continue; 572 - } 573 - 574 - entry.album_art = track.as_ref().map(|t| t.album_art.clone()).flatten(); 575 - entry.album_id = track.as_ref().map(|t| t.album_id.clone()); 576 - entry.artist_id = track.as_ref().map(|t| t.artist_id.clone()); 577 - entry.genre_id = track.as_ref().map(|t| t.genre_id.clone()); 578 - entry.id = track.as_ref().map(|t| t.id.clone()); 579 - 580 - metadata_cache.insert(hash, entry.clone()); 581 - entries.push(entry); 582 - } 583 - 584 - result.amount = amount; 585 - result.max_playlist_size = rb::playlist::max_playlist_size(); 586 - result.index = rb::playlist::index(); 587 - result.first_index = rb::playlist::first_index(); 588 - result.last_insert_pos = rb::playlist::last_insert_pos(); 589 - result.seed = rb::playlist::seed(); 590 - result.last_shuffled_start = rb::playlist::last_shuffled_start(); 591 - result.entries = entries; 592 - 593 - res.json(&result); 594 - drop(player_mutex); 595 - Ok(()) 596 - }
+173 -178
crates/server/src/handlers/saved_playlists.rs
··· 1 - use crate::http::{Context, Request, Response}; 2 - use crate::PLAYER_MUTEX; 3 - use anyhow::Error; 1 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 4 2 use rockbox_library::repo; 5 3 use rockbox_sys::{self as rb}; 6 4 use serde::Deserialize; 7 5 6 + use crate::{http::AppState, PLAYER_MUTEX}; 7 + 8 + type HandlerResult = actix_web::Result<HttpResponse>; 9 + 8 10 #[derive(Deserialize)] 9 - struct CreatePlaylistBody { 11 + pub struct CreatePlaylistBody { 10 12 name: String, 11 13 description: Option<String>, 12 14 image: Option<String>, ··· 15 17 } 16 18 17 19 #[derive(Deserialize)] 18 - struct UpdatePlaylistBody { 20 + pub struct UpdatePlaylistBody { 19 21 name: String, 20 22 description: Option<String>, 21 23 image: Option<String>, ··· 23 25 } 24 26 25 27 #[derive(Deserialize)] 26 - struct AddTracksBody { 28 + pub struct AddTracksBody { 27 29 track_ids: Vec<String>, 28 30 } 29 31 30 32 #[derive(Deserialize)] 31 - struct CreateFolderBody { 33 + pub struct CreateFolderBody { 32 34 name: String, 33 35 } 34 36 35 - pub async fn list_saved_playlists( 36 - ctx: &Context, 37 - req: &Request, 38 - res: &mut Response, 39 - ) -> Result<(), Error> { 40 - let folder_id = req 41 - .query_params 42 - .get("folder_id") 43 - .and_then(|v| v.as_str()) 44 - .map(|s| s.to_string()); 37 + #[derive(Deserialize)] 38 + pub struct ListQuery { 39 + folder_id: Option<String>, 40 + } 45 41 46 - let playlists = match folder_id.as_deref() { 47 - Some(fid) if !fid.is_empty() => ctx.playlist_store.list_by_folder(fid).await?, 48 - _ => ctx.playlist_store.list().await?, 42 + pub async fn list_saved_playlists( 43 + state: web::Data<AppState>, 44 + query: web::Query<ListQuery>, 45 + ) -> HandlerResult { 46 + let playlists = match query.folder_id.as_deref() { 47 + Some(fid) if !fid.is_empty() => state 48 + .playlist_store 49 + .list_by_folder(fid) 50 + .await 51 + .map_err(ErrorInternalServerError)?, 52 + _ => state 53 + .playlist_store 54 + .list() 55 + .await 56 + .map_err(ErrorInternalServerError)?, 49 57 }; 50 - res.json(&playlists); 51 - Ok(()) 58 + Ok(HttpResponse::Ok().json(playlists)) 52 59 } 53 60 54 61 pub async fn get_saved_playlist( 55 - ctx: &Context, 56 - req: &Request, 57 - res: &mut Response, 58 - ) -> Result<(), Error> { 59 - let id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 60 - match ctx.playlist_store.get(id).await? { 61 - Some(p) => res.json(&p), 62 - None => res.set_status(404), 62 + state: web::Data<AppState>, 63 + path: web::Path<String>, 64 + ) -> HandlerResult { 65 + let id = path.into_inner(); 66 + match state 67 + .playlist_store 68 + .get(&id) 69 + .await 70 + .map_err(ErrorInternalServerError)? 71 + { 72 + Some(p) => Ok(HttpResponse::Ok().json(p)), 73 + None => Ok(HttpResponse::NotFound().finish()), 63 74 } 64 - Ok(()) 65 75 } 66 76 67 77 pub async fn create_saved_playlist( 68 - ctx: &Context, 69 - req: &Request, 70 - res: &mut Response, 71 - ) -> Result<(), Error> { 72 - let body = match req.body.as_ref() { 73 - Some(b) => b, 74 - None => { 75 - res.set_status(400); 76 - return Ok(()); 77 - } 78 - }; 79 - let payload: CreatePlaylistBody = serde_json::from_str(body)?; 78 + state: web::Data<AppState>, 79 + body: web::Json<CreatePlaylistBody>, 80 + ) -> HandlerResult { 81 + let payload = body.into_inner(); 80 82 if payload.name.is_empty() { 81 - res.set_status(400); 82 - return Ok(()); 83 + return Ok(HttpResponse::BadRequest().finish()); 83 84 } 84 85 85 - let playlist = ctx 86 + let playlist = state 86 87 .playlist_store 87 88 .create( 88 89 &payload.name, ··· 90 91 payload.image.as_deref(), 91 92 payload.folder_id.as_deref(), 92 93 ) 93 - .await?; 94 + .await 95 + .map_err(ErrorInternalServerError)?; 94 96 if let Some(ids) = payload.track_ids { 95 97 if !ids.is_empty() { 96 - ctx.playlist_store.add_tracks(&playlist.id, &ids).await?; 98 + state 99 + .playlist_store 100 + .add_tracks(&playlist.id, &ids) 101 + .await 102 + .map_err(ErrorInternalServerError)?; 97 103 } 98 104 } 99 - let playlist = ctx 105 + let playlist = state 100 106 .playlist_store 101 107 .get(&playlist.id) 102 - .await? 108 + .await 109 + .map_err(ErrorInternalServerError)? 103 110 .unwrap_or(playlist); 104 - res.set_status(201); 105 - res.json(&playlist); 106 - Ok(()) 111 + Ok(HttpResponse::Created().json(playlist)) 107 112 } 108 113 109 114 pub async fn update_saved_playlist( 110 - ctx: &Context, 111 - req: &Request, 112 - res: &mut Response, 113 - ) -> Result<(), Error> { 114 - let id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 115 - let body = match req.body.as_ref() { 116 - Some(b) => b, 117 - None => { 118 - res.set_status(400); 119 - return Ok(()); 120 - } 121 - }; 122 - let payload: UpdatePlaylistBody = serde_json::from_str(body)?; 123 - ctx.playlist_store 115 + state: web::Data<AppState>, 116 + path: web::Path<String>, 117 + body: web::Json<UpdatePlaylistBody>, 118 + ) -> HandlerResult { 119 + let id = path.into_inner(); 120 + let payload = body.into_inner(); 121 + state 122 + .playlist_store 124 123 .update( 125 - id, 124 + &id, 126 125 &payload.name, 127 126 payload.description.as_deref(), 128 127 payload.image.as_deref(), 129 128 payload.folder_id.as_deref(), 130 129 ) 131 - .await?; 132 - res.set_status(204); 133 - Ok(()) 130 + .await 131 + .map_err(ErrorInternalServerError)?; 132 + Ok(HttpResponse::NoContent().finish()) 134 133 } 135 134 136 135 pub async fn delete_saved_playlist( 137 - ctx: &Context, 138 - req: &Request, 139 - res: &mut Response, 140 - ) -> Result<(), Error> { 141 - let id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 142 - ctx.playlist_store.delete(id).await?; 143 - res.set_status(204); 144 - Ok(()) 136 + state: web::Data<AppState>, 137 + path: web::Path<String>, 138 + ) -> HandlerResult { 139 + let id = path.into_inner(); 140 + state 141 + .playlist_store 142 + .delete(&id) 143 + .await 144 + .map_err(ErrorInternalServerError)?; 145 + Ok(HttpResponse::NoContent().finish()) 145 146 } 146 147 147 148 pub async fn get_saved_playlist_tracks( 148 - ctx: &Context, 149 - req: &Request, 150 - res: &mut Response, 151 - ) -> Result<(), Error> { 152 - let playlist_id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 153 - let track_ids = ctx.playlist_store.get_track_ids(playlist_id).await?; 149 + state: web::Data<AppState>, 150 + path: web::Path<String>, 151 + ) -> HandlerResult { 152 + let playlist_id = path.into_inner(); 153 + let track_ids = state 154 + .playlist_store 155 + .get_track_ids(&playlist_id) 156 + .await 157 + .map_err(ErrorInternalServerError)?; 154 158 let mut tracks = Vec::with_capacity(track_ids.len()); 155 159 for id in &track_ids { 156 - if let Some(track) = repo::track::find(ctx.pool.clone(), id).await? { 160 + if let Some(track) = repo::track::find(state.pool.clone(), id) 161 + .await 162 + .map_err(ErrorInternalServerError)? 163 + { 157 164 tracks.push(track); 158 165 } 159 166 } 160 - res.json(&tracks); 161 - Ok(()) 167 + Ok(HttpResponse::Ok().json(tracks)) 162 168 } 163 169 164 - pub async fn add_tracks_to_saved_playlist( 165 - ctx: &Context, 166 - req: &Request, 167 - res: &mut Response, 168 - ) -> Result<(), Error> { 169 - let playlist_id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 170 - let body = match req.body.as_ref() { 171 - Some(b) => b, 172 - None => { 173 - res.set_status(400); 174 - return Ok(()); 175 - } 176 - }; 177 - let payload: AddTracksBody = serde_json::from_str(body)?; 178 - ctx.playlist_store 179 - .add_tracks(playlist_id, &payload.track_ids) 180 - .await?; 181 - res.set_status(204); 182 - Ok(()) 170 + pub async fn get_saved_playlist_track_ids( 171 + state: web::Data<AppState>, 172 + path: web::Path<String>, 173 + ) -> HandlerResult { 174 + let playlist_id = path.into_inner(); 175 + let track_ids = state 176 + .playlist_store 177 + .get_track_ids(&playlist_id) 178 + .await 179 + .map_err(ErrorInternalServerError)?; 180 + Ok(HttpResponse::Ok().json(track_ids)) 183 181 } 184 182 185 - pub async fn get_saved_playlist_track_ids( 186 - ctx: &Context, 187 - req: &Request, 188 - res: &mut Response, 189 - ) -> Result<(), Error> { 190 - let playlist_id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 191 - let track_ids = ctx.playlist_store.get_track_ids(playlist_id).await?; 192 - res.json(&track_ids); 193 - Ok(()) 183 + pub async fn add_tracks_to_saved_playlist( 184 + state: web::Data<AppState>, 185 + path: web::Path<String>, 186 + body: web::Json<AddTracksBody>, 187 + ) -> HandlerResult { 188 + let playlist_id = path.into_inner(); 189 + let payload = body.into_inner(); 190 + state 191 + .playlist_store 192 + .add_tracks(&playlist_id, &payload.track_ids) 193 + .await 194 + .map_err(ErrorInternalServerError)?; 195 + Ok(HttpResponse::NoContent().finish()) 194 196 } 195 197 196 198 pub async fn remove_track_from_saved_playlist( 197 - ctx: &Context, 198 - req: &Request, 199 - res: &mut Response, 200 - ) -> Result<(), Error> { 201 - let playlist_id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 202 - let track_id = req.params.get(1).map(|s| s.as_str()).unwrap_or(""); 203 - ctx.playlist_store 204 - .remove_track(playlist_id, track_id) 205 - .await?; 206 - res.set_status(204); 207 - Ok(()) 199 + state: web::Data<AppState>, 200 + path: web::Path<(String, String)>, 201 + ) -> HandlerResult { 202 + let (playlist_id, track_id) = path.into_inner(); 203 + state 204 + .playlist_store 205 + .remove_track(&playlist_id, &track_id) 206 + .await 207 + .map_err(ErrorInternalServerError)?; 208 + Ok(HttpResponse::NoContent().finish()) 208 209 } 209 210 210 211 pub async fn play_saved_playlist( 211 - ctx: &Context, 212 - req: &Request, 213 - res: &mut Response, 214 - ) -> Result<(), Error> { 215 - let playlist_id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 216 - let track_ids = ctx.playlist_store.get_track_ids(playlist_id).await?; 212 + state: web::Data<AppState>, 213 + path: web::Path<String>, 214 + ) -> HandlerResult { 215 + let playlist_id = path.into_inner(); 216 + let track_ids = state 217 + .playlist_store 218 + .get_track_ids(&playlist_id) 219 + .await 220 + .map_err(ErrorInternalServerError)?; 217 221 218 222 if track_ids.is_empty() { 219 - res.set_status(422); 220 - return Ok(()); 223 + return Ok(HttpResponse::UnprocessableEntity().finish()); 221 224 } 222 225 223 226 let mut paths = Vec::with_capacity(track_ids.len()); 224 227 for id in &track_ids { 225 - if let Some(track) = repo::track::find(ctx.pool.clone(), id).await? { 228 + if let Some(track) = repo::track::find(state.pool.clone(), id) 229 + .await 230 + .map_err(ErrorInternalServerError)? 231 + { 226 232 paths.push(track.path); 227 233 } 228 234 } 229 235 230 236 if paths.is_empty() { 231 - res.set_status(422); 232 - return Ok(()); 237 + return Ok(HttpResponse::UnprocessableEntity().finish()); 233 238 } 234 239 235 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 240 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 236 241 let first = &paths[0]; 237 242 let dir = { 238 243 let parts: Vec<_> = first.split('/').collect(); ··· 245 250 paths.len() as i32, 246 251 ); 247 252 rb::playlist::start(0, 0, 0); 248 - drop(player_mutex); 249 253 250 - res.set_status(204); 251 - Ok(()) 254 + Ok(HttpResponse::NoContent().finish()) 252 255 } 253 256 254 - // ── Folders ──────────────────────────────────────────────────────────────── 255 - 256 - pub async fn list_playlist_folders( 257 - ctx: &Context, 258 - _req: &Request, 259 - res: &mut Response, 260 - ) -> Result<(), Error> { 261 - let folders = ctx.playlist_store.list_folders().await?; 262 - res.json(&folders); 263 - Ok(()) 257 + pub async fn list_playlist_folders(state: web::Data<AppState>) -> HandlerResult { 258 + let folders = state 259 + .playlist_store 260 + .list_folders() 261 + .await 262 + .map_err(ErrorInternalServerError)?; 263 + Ok(HttpResponse::Ok().json(folders)) 264 264 } 265 265 266 266 pub async fn create_playlist_folder( 267 - ctx: &Context, 268 - req: &Request, 269 - res: &mut Response, 270 - ) -> Result<(), Error> { 271 - let body = match req.body.as_ref() { 272 - Some(b) => b, 273 - None => { 274 - res.set_status(400); 275 - return Ok(()); 276 - } 277 - }; 278 - let payload: CreateFolderBody = serde_json::from_str(body)?; 267 + state: web::Data<AppState>, 268 + body: web::Json<CreateFolderBody>, 269 + ) -> HandlerResult { 270 + let payload = body.into_inner(); 279 271 if payload.name.is_empty() { 280 - res.set_status(400); 281 - return Ok(()); 272 + return Ok(HttpResponse::BadRequest().finish()); 282 273 } 283 - let folder = ctx.playlist_store.create_folder(&payload.name).await?; 284 - res.set_status(201); 285 - res.json(&folder); 286 - Ok(()) 274 + let folder = state 275 + .playlist_store 276 + .create_folder(&payload.name) 277 + .await 278 + .map_err(ErrorInternalServerError)?; 279 + Ok(HttpResponse::Created().json(folder)) 287 280 } 288 281 289 282 pub async fn delete_playlist_folder( 290 - ctx: &Context, 291 - req: &Request, 292 - res: &mut Response, 293 - ) -> Result<(), Error> { 294 - let id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 295 - ctx.playlist_store.delete_folder(id).await?; 296 - res.set_status(204); 297 - Ok(()) 283 + state: web::Data<AppState>, 284 + path: web::Path<String>, 285 + ) -> HandlerResult { 286 + let id = path.into_inner(); 287 + state 288 + .playlist_store 289 + .delete_folder(&id) 290 + .await 291 + .map_err(ErrorInternalServerError)?; 292 + Ok(HttpResponse::NoContent().finish()) 298 293 }
+19 -16
crates/server/src/handlers/search.rs
··· 1 - use crate::http::{Context, Request, Response}; 2 - use anyhow::Error; 1 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 3 2 use rockbox_typesense::client::{search_albums, search_artists, search_tracks}; 4 - use serde::Serialize; 3 + use serde::{Deserialize, Serialize}; 4 + 5 + type HandlerResult = actix_web::Result<HttpResponse>; 6 + 7 + #[derive(Deserialize)] 8 + pub struct SearchQuery { 9 + q: Option<String>, 10 + } 5 11 6 12 #[derive(Default, Serialize)] 7 13 struct SearchResponse { ··· 10 16 artists: Vec<rockbox_typesense::types::Artist>, 11 17 } 12 18 13 - pub async fn search(_ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 14 - let term = req 15 - .query_params 16 - .get("q") 17 - .and_then(|t| t.as_str()) 18 - .unwrap_or_default(); 19 + pub async fn search(query: web::Query<SearchQuery>) -> HandlerResult { 20 + let term = query.q.as_deref().unwrap_or_default(); 19 21 20 22 let tracks = search_tracks(term) 21 - .await? 23 + .await 24 + .map_err(ErrorInternalServerError)? 22 25 .map(|r| r.hits.into_iter().map(|h| h.document).collect()) 23 26 .unwrap_or_default(); 24 27 let albums = search_albums(term) 25 - .await? 28 + .await 29 + .map_err(ErrorInternalServerError)? 26 30 .map(|r| r.hits.into_iter().map(|h| h.document).collect()) 27 31 .unwrap_or_default(); 28 32 let artists = search_artists(term) 29 - .await? 33 + .await 34 + .map_err(ErrorInternalServerError)? 30 35 .map(|r| r.hits.into_iter().map(|h| h.document).collect()) 31 36 .unwrap_or_default(); 32 37 33 - res.json(&SearchResponse { 38 + Ok(HttpResponse::Ok().json(SearchResponse { 34 39 tracks, 35 40 albums, 36 41 artists, 37 - }); 38 - 39 - Ok(()) 42 + })) 40 43 }
+24 -26
crates/server/src/handlers/settings.rs
··· 1 - use crate::http::{Context, Request, Response}; 2 - use crate::PLAYER_MUTEX; 3 - use anyhow::Error; 1 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 4 2 use rockbox_sys as rb; 5 3 use rockbox_sys::types::user_settings::NewGlobalSettings; 6 4 7 - pub async fn get_global_settings( 8 - _ctx: &Context, 9 - _req: &Request, 10 - res: &mut Response, 11 - ) -> Result<(), Error> { 12 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 13 - let settings = rb::settings::get_global_settings(); 14 - res.json(&settings); 15 - drop(player_mutex); 16 - Ok(()) 5 + use crate::PLAYER_MUTEX; 6 + 7 + type HandlerResult = actix_web::Result<HttpResponse>; 8 + 9 + pub async fn get_global_settings() -> HandlerResult { 10 + let settings = web::block(|| { 11 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 12 + rb::settings::get_global_settings() 13 + }) 14 + .await 15 + .map_err(ErrorInternalServerError)?; 16 + Ok(HttpResponse::Ok().json(settings)) 17 17 } 18 18 19 - pub async fn update_global_settings( 20 - _ctx: &Context, 21 - req: &Request, 22 - res: &mut Response, 23 - ) -> Result<(), Error> { 24 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 25 - let body = req.body.as_ref().unwrap(); 26 - let settings: NewGlobalSettings = serde_json::from_str(body)?; 27 - rockbox_settings::load_settings(Some(settings))?; 28 - rockbox_settings::write_settings()?; 29 - res.set_status(204); 30 - drop(player_mutex); 31 - Ok(()) 19 + pub async fn update_global_settings(body: web::Json<NewGlobalSettings>) -> HandlerResult { 20 + let settings = body.into_inner(); 21 + web::block(move || { 22 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 23 + rockbox_settings::load_settings(Some(settings))?; 24 + rockbox_settings::write_settings() 25 + }) 26 + .await 27 + .map_err(ErrorInternalServerError)? 28 + .map_err(ErrorInternalServerError)?; 29 + Ok(HttpResponse::NoContent().finish()) 32 30 }
+114 -126
crates/server/src/handlers/smart_playlists.rs
··· 1 - use crate::http::{Context, Request, Response}; 2 - use crate::PLAYER_MUTEX; 3 - use anyhow::Error; 1 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 4 2 use rockbox_library::repo; 5 3 use rockbox_playlists::rules::{Candidate, RuleCriteria}; 6 4 use rockbox_sys::{self as rb}; 7 5 use serde::Deserialize; 8 6 use std::collections::HashMap; 7 + 8 + use crate::{http::AppState, PLAYER_MUTEX}; 9 + 10 + type HandlerResult = actix_web::Result<HttpResponse>; 9 11 10 12 #[derive(Deserialize)] 11 - struct CreateSmartPlaylistBody { 13 + pub struct CreateSmartPlaylistBody { 12 14 name: String, 13 15 description: Option<String>, 14 16 image: Option<String>, ··· 17 19 } 18 20 19 21 #[derive(Deserialize)] 20 - struct UpdateSmartPlaylistBody { 22 + pub struct UpdateSmartPlaylistBody { 21 23 name: String, 22 24 description: Option<String>, 23 25 image: Option<String>, ··· 25 27 rules: RuleCriteria, 26 28 } 27 29 28 - pub async fn list_smart_playlists( 29 - ctx: &Context, 30 - _req: &Request, 31 - res: &mut Response, 32 - ) -> Result<(), Error> { 33 - let playlists = ctx.playlist_store.list_smart_playlists().await?; 34 - res.json(&playlists); 35 - Ok(()) 30 + pub async fn list_smart_playlists(state: web::Data<AppState>) -> HandlerResult { 31 + let playlists = state 32 + .playlist_store 33 + .list_smart_playlists() 34 + .await 35 + .map_err(ErrorInternalServerError)?; 36 + Ok(HttpResponse::Ok().json(playlists)) 36 37 } 37 38 38 39 pub async fn get_smart_playlist( 39 - ctx: &Context, 40 - req: &Request, 41 - res: &mut Response, 42 - ) -> Result<(), Error> { 43 - let id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 44 - match ctx.playlist_store.get_smart_playlist(id).await? { 45 - Some(p) => res.json(&p), 46 - None => res.set_status(404), 40 + state: web::Data<AppState>, 41 + path: web::Path<String>, 42 + ) -> HandlerResult { 43 + let id = path.into_inner(); 44 + match state 45 + .playlist_store 46 + .get_smart_playlist(&id) 47 + .await 48 + .map_err(ErrorInternalServerError)? 49 + { 50 + Some(p) => Ok(HttpResponse::Ok().json(p)), 51 + None => Ok(HttpResponse::NotFound().finish()), 47 52 } 48 - Ok(()) 49 53 } 50 54 51 55 pub async fn create_smart_playlist( 52 - ctx: &Context, 53 - req: &Request, 54 - res: &mut Response, 55 - ) -> Result<(), Error> { 56 - let body = match req.body.as_ref() { 57 - Some(b) => b, 58 - None => { 59 - res.set_status(400); 60 - return Ok(()); 61 - } 62 - }; 63 - let payload: CreateSmartPlaylistBody = serde_json::from_str(body)?; 56 + state: web::Data<AppState>, 57 + body: web::Json<CreateSmartPlaylistBody>, 58 + ) -> HandlerResult { 59 + let payload = body.into_inner(); 64 60 if payload.name.is_empty() { 65 - res.set_status(400); 66 - return Ok(()); 61 + return Ok(HttpResponse::BadRequest().finish()); 67 62 } 68 - let playlist = ctx 63 + let playlist = state 69 64 .playlist_store 70 65 .create_smart_playlist( 71 66 &payload.name, ··· 74 69 payload.folder_id.as_deref(), 75 70 &payload.rules, 76 71 ) 77 - .await?; 78 - res.set_status(201); 79 - res.json(&playlist); 80 - Ok(()) 72 + .await 73 + .map_err(ErrorInternalServerError)?; 74 + Ok(HttpResponse::Created().json(playlist)) 81 75 } 82 76 83 77 pub async fn update_smart_playlist( 84 - ctx: &Context, 85 - req: &Request, 86 - res: &mut Response, 87 - ) -> Result<(), Error> { 88 - let id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 89 - let body = match req.body.as_ref() { 90 - Some(b) => b, 91 - None => { 92 - res.set_status(400); 93 - return Ok(()); 94 - } 95 - }; 96 - let payload: UpdateSmartPlaylistBody = serde_json::from_str(body)?; 97 - match ctx 78 + state: web::Data<AppState>, 79 + path: web::Path<String>, 80 + body: web::Json<UpdateSmartPlaylistBody>, 81 + ) -> HandlerResult { 82 + let id = path.into_inner(); 83 + let payload = body.into_inner(); 84 + match state 98 85 .playlist_store 99 86 .update_smart_playlist( 100 - id, 87 + &id, 101 88 &payload.name, 102 89 payload.description.as_deref(), 103 90 payload.image.as_deref(), ··· 106 93 ) 107 94 .await 108 95 { 109 - Ok(()) => res.set_status(204), 110 - Err(_) => res.set_status(404), 96 + Ok(()) => Ok(HttpResponse::NoContent().finish()), 97 + Err(_) => Ok(HttpResponse::NotFound().finish()), 111 98 } 112 - Ok(()) 113 99 } 114 100 115 101 pub async fn delete_smart_playlist( 116 - ctx: &Context, 117 - req: &Request, 118 - res: &mut Response, 119 - ) -> Result<(), Error> { 120 - let id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 121 - let deleted = ctx.playlist_store.delete_smart_playlist(id).await?; 102 + state: web::Data<AppState>, 103 + path: web::Path<String>, 104 + ) -> HandlerResult { 105 + let id = path.into_inner(); 106 + let deleted = state 107 + .playlist_store 108 + .delete_smart_playlist(&id) 109 + .await 110 + .map_err(ErrorInternalServerError)?; 122 111 if deleted { 123 - res.set_status(204); 112 + Ok(HttpResponse::NoContent().finish()) 124 113 } else { 125 - res.set_status(404); 114 + Ok(HttpResponse::NotFound().finish()) 126 115 } 127 - Ok(()) 128 116 } 129 117 130 118 async fn resolve_smart_playlist_tracks( 131 - ctx: &Context, 119 + state: &AppState, 132 120 id: &str, 133 - ) -> Result<Option<(RuleCriteria, Vec<rockbox_library::entity::track::Track>)>, Error> { 134 - let criteria = match ctx.playlist_store.get_smart_playlist(id).await? { 121 + ) -> Result<Option<(RuleCriteria, Vec<rockbox_library::entity::track::Track>)>, anyhow::Error> { 122 + let criteria = match state.playlist_store.get_smart_playlist(id).await? { 135 123 Some(p) => p.rules, 136 124 None => return Ok(None), 137 125 }; 138 126 139 - let all_tracks = repo::track::all(ctx.pool.clone()).await?; 127 + let all_tracks = repo::track::all(state.pool.clone()).await?; 140 128 141 - let stats_map: HashMap<String, rockbox_playlists::TrackStats> = ctx 129 + let stats_map: HashMap<String, rockbox_playlists::TrackStats> = state 142 130 .playlist_store 143 131 .get_all_track_stats() 144 132 .await? ··· 147 135 .collect(); 148 136 149 137 let liked_ids: std::collections::HashSet<String> = 150 - repo::favourites::all_tracks(ctx.pool.clone()) 138 + repo::favourites::all_tracks(state.pool.clone()) 151 139 .await? 152 140 .into_iter() 153 141 .map(|t| t.id) ··· 177 165 .collect(); 178 166 179 167 let resolved = rockbox_playlists::rules::resolve(&criteria, candidates); 180 - 181 168 let resolved_ids: Vec<&str> = resolved.iter().map(|c| c.id.as_str()).collect(); 182 169 let track_map: HashMap<&str, &rockbox_library::entity::track::Track> = 183 170 all_tracks.iter().map(|t| (t.id.as_str(), t)).collect(); ··· 191 178 } 192 179 193 180 pub async fn get_smart_playlist_tracks( 194 - ctx: &Context, 195 - req: &Request, 196 - res: &mut Response, 197 - ) -> Result<(), Error> { 198 - let id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 199 - match resolve_smart_playlist_tracks(ctx, id).await? { 200 - Some((_, tracks)) => res.json(&tracks), 201 - None => res.set_status(404), 181 + state: web::Data<AppState>, 182 + path: web::Path<String>, 183 + ) -> HandlerResult { 184 + let id = path.into_inner(); 185 + match resolve_smart_playlist_tracks(&state, &id) 186 + .await 187 + .map_err(ErrorInternalServerError)? 188 + { 189 + Some((_, tracks)) => Ok(HttpResponse::Ok().json(tracks)), 190 + None => Ok(HttpResponse::NotFound().finish()), 202 191 } 203 - Ok(()) 204 192 } 205 193 206 194 pub async fn play_smart_playlist( 207 - ctx: &Context, 208 - req: &Request, 209 - res: &mut Response, 210 - ) -> Result<(), Error> { 211 - let id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 212 - let tracks = match resolve_smart_playlist_tracks(ctx, id).await? { 195 + state: web::Data<AppState>, 196 + path: web::Path<String>, 197 + ) -> HandlerResult { 198 + let id = path.into_inner(); 199 + let tracks = match resolve_smart_playlist_tracks(&state, &id) 200 + .await 201 + .map_err(ErrorInternalServerError)? 202 + { 213 203 Some((_, t)) => t, 214 - None => { 215 - res.set_status(404); 216 - return Ok(()); 217 - } 204 + None => return Ok(HttpResponse::NotFound().finish()), 218 205 }; 219 206 220 207 if tracks.is_empty() { 221 - res.set_status(422); 222 - return Ok(()); 208 + return Ok(HttpResponse::UnprocessableEntity().finish()); 223 209 } 224 210 225 211 let paths: Vec<String> = tracks.iter().map(|t| t.path.clone()).collect(); 226 - let player_mutex = PLAYER_MUTEX.lock().unwrap(); 212 + let _player_mutex = PLAYER_MUTEX.lock().unwrap(); 227 213 let first = &paths[0]; 228 214 let dir = { 229 215 let parts: Vec<_> = first.split('/').collect(); ··· 236 222 paths.len() as i32, 237 223 ); 238 224 rb::playlist::start(0, 0, 0); 239 - drop(player_mutex); 240 225 241 - res.set_status(204); 242 - Ok(()) 226 + Ok(HttpResponse::NoContent().finish()) 243 227 } 244 228 245 229 pub async fn record_track_played( 246 - ctx: &Context, 247 - req: &Request, 248 - res: &mut Response, 249 - ) -> Result<(), Error> { 250 - let track_id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 251 - ctx.playlist_store.record_play(track_id).await?; 252 - res.set_status(204); 253 - Ok(()) 230 + state: web::Data<AppState>, 231 + path: web::Path<String>, 232 + ) -> HandlerResult { 233 + let track_id = path.into_inner(); 234 + state 235 + .playlist_store 236 + .record_play(&track_id) 237 + .await 238 + .map_err(ErrorInternalServerError)?; 239 + Ok(HttpResponse::NoContent().finish()) 254 240 } 255 241 256 242 pub async fn record_track_skipped( 257 - ctx: &Context, 258 - req: &Request, 259 - res: &mut Response, 260 - ) -> Result<(), Error> { 261 - let track_id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 262 - ctx.playlist_store.record_skip(track_id).await?; 263 - res.set_status(204); 264 - Ok(()) 243 + state: web::Data<AppState>, 244 + path: web::Path<String>, 245 + ) -> HandlerResult { 246 + let track_id = path.into_inner(); 247 + state 248 + .playlist_store 249 + .record_skip(&track_id) 250 + .await 251 + .map_err(ErrorInternalServerError)?; 252 + Ok(HttpResponse::NoContent().finish()) 265 253 } 266 254 267 - pub async fn get_track_stats( 268 - ctx: &Context, 269 - req: &Request, 270 - res: &mut Response, 271 - ) -> Result<(), Error> { 272 - let track_id = req.params.first().map(|s| s.as_str()).unwrap_or(""); 273 - match ctx.playlist_store.get_track_stats(track_id).await? { 274 - Some(s) => res.json(&s), 275 - None => res.set_status(404), 255 + pub async fn get_track_stats(state: web::Data<AppState>, path: web::Path<String>) -> HandlerResult { 256 + let track_id = path.into_inner(); 257 + match state 258 + .playlist_store 259 + .get_track_stats(&track_id) 260 + .await 261 + .map_err(ErrorInternalServerError)? 262 + { 263 + Some(s) => Ok(HttpResponse::Ok().json(s)), 264 + None => Ok(HttpResponse::NotFound().finish()), 276 265 } 277 - Ok(()) 278 266 }
+64 -44
crates/server/src/handlers/system.rs
··· 1 1 use std::env; 2 2 3 - use crate::http::{Context, Request, Response}; 4 - use anyhow::Error; 3 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 5 4 use rockbox_graphql::{simplebroker::SimpleBroker, types::ScanCompleted}; 6 5 use rockbox_library::{artists::update_metadata, audio_scan::scan_audio_files, repo}; 7 6 use rockbox_sys as rb; 8 - use rockbox_typesense::client::*; 9 - use rockbox_typesense::types::*; 7 + use rockbox_typesense::{client::*, types::*}; 8 + use serde::Deserialize; 10 9 11 - pub async fn get_status(_ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 10 + use crate::http::AppState; 11 + 12 + type HandlerResult = actix_web::Result<HttpResponse>; 13 + 14 + pub async fn get_status() -> HandlerResult { 12 15 let status = rb::system::get_global_status(); 13 - res.json(&status); 14 - Ok(()) 16 + Ok(HttpResponse::Ok().json(status)) 15 17 } 16 18 17 - pub async fn get_rockbox_version( 18 - _ctx: &Context, 19 - _req: &Request, 20 - res: &mut Response, 21 - ) -> Result<(), Error> { 19 + pub async fn get_rockbox_version() -> HandlerResult { 22 20 let version = rb::system::get_rockbox_version(); 23 - res.json(&version); 24 - Ok(()) 21 + Ok(HttpResponse::Ok().json(version)) 25 22 } 26 23 27 - pub async fn scan_library(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 28 - let home = env::var("HOME")?; 24 + #[derive(Deserialize)] 25 + pub struct ScanQuery { 26 + path: Option<String>, 27 + rebuild_index: Option<String>, 28 + } 29 + 30 + pub async fn scan_library( 31 + state: web::Data<AppState>, 32 + query: web::Query<ScanQuery>, 33 + ) -> HandlerResult { 34 + let home = env::var("HOME").map_err(ErrorInternalServerError)?; 29 35 let music_library = format!("{}/Music", home); 30 36 31 - let path = match req.query_params.get("path") { 32 - Some(path) => path.as_str().unwrap_or(&music_library), 33 - None => &music_library, 34 - }; 37 + let path = query.path.clone().unwrap_or_else(|| music_library.clone()); 35 38 36 - scan_audio_files(ctx.pool.clone(), path.into()).await?; 39 + scan_audio_files(state.pool.clone(), path.clone().into()) 40 + .await 41 + .map_err(ErrorInternalServerError)?; 37 42 38 - let rebuild_index = match req.query_params.get("rebuild_index") { 39 - Some(rebuild_index) => { 40 - let rebuild_index = rebuild_index.as_str().unwrap_or("false"); 41 - rebuild_index == "true" || rebuild_index == "1" 42 - } 43 - None => false, 44 - }; 43 + let rebuild_index = query 44 + .rebuild_index 45 + .as_deref() 46 + .map(|s| s == "true" || s == "1") 47 + .unwrap_or(false); 45 48 46 49 if path != music_library { 47 50 SimpleBroker::publish(ScanCompleted); 48 - res.text("0"); 49 - return Ok(()); 51 + return Ok(HttpResponse::Ok().body("0")); 50 52 } 51 53 52 - update_metadata(ctx.pool.clone()).await?; 54 + update_metadata(state.pool.clone()) 55 + .await 56 + .map_err(ErrorInternalServerError)?; 53 57 54 58 if !rebuild_index { 55 59 SimpleBroker::publish(ScanCompleted); 56 - res.text("0"); 57 - return Ok(()); 60 + return Ok(HttpResponse::Ok().body("0")); 58 61 } 59 62 60 - let tracks = repo::track::all(ctx.pool.clone()).await?; 61 - let albums = repo::album::all(ctx.pool.clone()).await?; 62 - let artists = repo::artist::all(ctx.pool.clone()).await?; 63 + let tracks = repo::track::all(state.pool.clone()) 64 + .await 65 + .map_err(ErrorInternalServerError)?; 66 + let albums = repo::album::all(state.pool.clone()) 67 + .await 68 + .map_err(ErrorInternalServerError)?; 69 + let artists = repo::artist::all(state.pool.clone()) 70 + .await 71 + .map_err(ErrorInternalServerError)?; 63 72 64 - create_tracks_collection().await?; 65 - create_albums_collection().await?; 66 - create_artists_collection().await?; 73 + create_tracks_collection() 74 + .await 75 + .map_err(ErrorInternalServerError)?; 76 + create_albums_collection() 77 + .await 78 + .map_err(ErrorInternalServerError)?; 79 + create_artists_collection() 80 + .await 81 + .map_err(ErrorInternalServerError)?; 67 82 68 - insert_tracks(tracks.into_iter().map(Track::from).collect()).await?; 69 - insert_artists(artists.into_iter().map(Artist::from).collect()).await?; 70 - insert_albums(albums.into_iter().map(Album::from).collect()).await?; 83 + insert_tracks(tracks.into_iter().map(Track::from).collect()) 84 + .await 85 + .map_err(ErrorInternalServerError)?; 86 + insert_artists(artists.into_iter().map(Artist::from).collect()) 87 + .await 88 + .map_err(ErrorInternalServerError)?; 89 + insert_albums(albums.into_iter().map(Album::from).collect()) 90 + .await 91 + .map_err(ErrorInternalServerError)?; 71 92 72 93 SimpleBroker::publish(ScanCompleted); 73 - res.text("0"); 74 - Ok(()) 94 + Ok(HttpResponse::Ok().body("0")) 75 95 }
+27 -32
crates/server/src/handlers/tracks.rs
··· 1 - use anyhow::Error; 1 + use actix_web::{error::ErrorInternalServerError, web, HttpResponse}; 2 2 use rockbox_library::{audio_scan, repo}; 3 3 use serde::Deserialize; 4 4 5 - use crate::http::{Context, Request, Response}; 5 + use crate::http::AppState; 6 6 7 - pub async fn get_tracks(ctx: &Context, _req: &Request, res: &mut Response) -> Result<(), Error> { 8 - let tracks = repo::track::all(ctx.pool.clone()).await?; 9 - res.json(&tracks); 10 - Ok(()) 7 + type HandlerResult = actix_web::Result<HttpResponse>; 8 + 9 + pub async fn get_tracks(state: web::Data<AppState>) -> HandlerResult { 10 + let tracks = repo::track::all(state.pool.clone()) 11 + .await 12 + .map_err(ErrorInternalServerError)?; 13 + Ok(HttpResponse::Ok().json(tracks)) 11 14 } 12 15 13 - pub async fn get_track(ctx: &Context, req: &Request, res: &mut Response) -> Result<(), Error> { 14 - let track = repo::track::find(ctx.pool.clone(), &req.params[0]).await?; 15 - res.json(&track); 16 - Ok(()) 16 + pub async fn get_track(state: web::Data<AppState>, path: web::Path<String>) -> HandlerResult { 17 + let track = repo::track::find(state.pool.clone(), &path.into_inner()) 18 + .await 19 + .map_err(ErrorInternalServerError)?; 20 + Ok(HttpResponse::Ok().json(track)) 17 21 } 18 22 19 23 #[derive(Deserialize)] 20 - struct StreamMetadataBody { 24 + pub struct StreamMetadataBody { 21 25 url: String, 22 26 title: String, 23 27 artist: String, ··· 26 30 } 27 31 28 32 pub async fn save_stream_track_metadata( 29 - ctx: &Context, 30 - req: &Request, 31 - res: &mut Response, 32 - ) -> Result<(), Error> { 33 - let body = match req.body.as_ref() { 34 - Some(b) => b, 35 - None => { 36 - res.set_status(400); 37 - return Ok(()); 38 - } 39 - }; 40 - let params: StreamMetadataBody = serde_json::from_str(body)?; 33 + state: web::Data<AppState>, 34 + body: web::Json<StreamMetadataBody>, 35 + ) -> HandlerResult { 41 36 audio_scan::save_stream_metadata( 42 - ctx.pool.clone(), 43 - &params.url, 44 - &params.title, 45 - &params.artist, 46 - &params.album, 47 - params.duration_ms, 37 + state.pool.clone(), 38 + &body.url, 39 + &body.title, 40 + &body.artist, 41 + &body.album, 42 + body.duration_ms, 48 43 ) 49 - .await?; 50 - res.set_status(204); 51 - Ok(()) 44 + .await 45 + .map_err(ErrorInternalServerError)?; 46 + Ok(HttpResponse::NoContent().finish()) 52 47 }
+3 -543
crates/server/src/http.rs
··· 1 - use anyhow::Error; 2 1 use rockbox_library::entity::track::Track; 3 2 use rockbox_playlists::PlaylistStore; 4 - use rockbox_sys::{ 5 - self as rb, 6 - types::{mp3_entry::Mp3Entry, tree::Entry}, 7 - }; 3 + use rockbox_sys::types::{mp3_entry::Mp3Entry, tree::Entry}; 8 4 use rockbox_traits::Player; 9 5 use rockbox_types::device::Device; 10 - use serde::Serialize; 11 - use serde_json::Value; 12 6 use sqlx::Sqlite; 13 7 use std::{ 14 8 collections::HashMap, 15 - io::{BufRead, BufReader, Read, Write}, 16 - net::{TcpListener, TcpStream}, 17 9 sync::{Arc, Mutex}, 18 - thread, 19 - time::Duration, 20 10 }; 21 - use threadpool::ThreadPool; 22 - use tracing::{debug, error}; 23 11 24 - use crate::{ 25 - kv::{build_tracks_kv, KV}, 26 - player_events::listen_for_playback_changes, 27 - scan::{ 28 - scan_airplay_devices, scan_chromecast_devices, scan_snapcast_servers, 29 - scan_squeezelite_clients, scan_upnp_devices, virtual_devices, 30 - }, 31 - }; 12 + use crate::kv::KV; 32 13 33 - type Handler = fn(&Context, &Request, &mut Response) -> Result<(), Error>; 34 - 35 - pub struct Context { 14 + pub struct AppState { 36 15 pub pool: sqlx::Pool<Sqlite>, 37 - pub rt: Arc<tokio::runtime::Runtime>, 38 16 pub fs_cache: Arc<tokio::sync::Mutex<HashMap<String, Vec<Entry>>>>, 39 17 pub metadata_cache: Arc<tokio::sync::Mutex<HashMap<String, Mp3Entry>>>, 40 18 pub devices: Arc<Mutex<Vec<Device>>>, ··· 43 21 pub kv: Arc<Mutex<KV<Track>>>, 44 22 pub playlist_store: PlaylistStore, 45 23 } 46 - 47 - #[derive(Debug)] 48 - pub struct Request { 49 - pub method: String, 50 - pub params: Vec<String>, 51 - pub query_params: Value, 52 - pub body: Option<String>, 53 - } 54 - 55 - #[derive(Debug)] 56 - pub struct Response { 57 - body: String, 58 - status_code: u16, 59 - headers: HashMap<String, String>, 60 - } 61 - 62 - impl Response { 63 - pub fn new() -> Self { 64 - Response { 65 - body: String::new(), 66 - status_code: 200, 67 - headers: HashMap::new(), 68 - } 69 - } 70 - 71 - pub fn json<T: Serialize>(&mut self, value: &T) { 72 - let json_value = serde_json::to_value(value).unwrap(); 73 - self.add_header("Content-Type", "application/json"); 74 - self.body = serde_json::to_string(&json_value).unwrap(); 75 - } 76 - 77 - pub fn text(&mut self, text: &str) { 78 - self.add_header("Content-Type", "text/plain"); 79 - self.body = text.to_string(); 80 - } 81 - 82 - pub fn set_body(&mut self, body: &str) { 83 - self.body = body.to_string(); 84 - } 85 - 86 - pub fn set_status(&mut self, status: u16) { 87 - self.status_code = status; 88 - } 89 - 90 - pub fn add_header(&mut self, key: &str, value: &str) { 91 - self.headers.insert(key.to_string(), value.to_string()); 92 - } 93 - 94 - pub fn send(self, stream: &mut TcpStream) { 95 - let status_line = format!("HTTP/1.1 {} OK\r\n", self.status_code); 96 - let mut response = status_line; 97 - 98 - for (key, value) in self.headers { 99 - response.push_str(&format!("{}: {}\r\n", key, value)); 100 - } 101 - response.push_str(&format!("Content-Length: {}\r\n", self.body.len())); 102 - response.push_str("\r\n"); 103 - response.push_str(&self.body); 104 - 105 - if let Err(e) = stream.write_all(response.as_bytes()) { 106 - tracing::debug!("http: write error: {e}"); 107 - return; 108 - } 109 - if let Err(e) = stream.flush() { 110 - tracing::debug!("http: flush error: {e}"); 111 - } 112 - } 113 - } 114 - 115 - fn split_path_and_query(path: &str) -> (&str, Option<&str>) { 116 - match path.find('?') { 117 - Some(pos) => (&path[..pos], Some(&path[pos + 1..])), 118 - None => (path, None), 119 - } 120 - } 121 - 122 - #[derive(Clone)] 123 - struct Router { 124 - routes: HashMap<String, HashMap<String, Handler>>, // method -> path -> handler 125 - } 126 - 127 - impl Router { 128 - pub fn new() -> Self { 129 - Router { 130 - routes: HashMap::new(), 131 - } 132 - } 133 - 134 - // Define the `get` method for routing GET requests 135 - fn get(&mut self, path: &str, handler: Handler) { 136 - self.add_route("GET", path, handler); 137 - } 138 - 139 - // Define the `post` method for routing POST requests 140 - fn post(&mut self, path: &str, handler: Handler) { 141 - self.add_route("POST", path, handler); 142 - } 143 - 144 - // Define the `put` method for routing PUT requests 145 - fn put(&mut self, path: &str, handler: Handler) { 146 - self.add_route("PUT", path, handler); 147 - } 148 - 149 - fn delete(&mut self, path: &str, handler: Handler) { 150 - self.add_route("DELETE", path, handler); 151 - } 152 - 153 - // Add route to the routing table 154 - pub fn add_route(&mut self, method: &str, path: &str, handler: Handler) { 155 - self.routes 156 - .entry(method.to_string()) 157 - .or_insert_with(HashMap::new) 158 - .insert(path.to_string(), handler); 159 - } 160 - 161 - // Match the method and path to find the corresponding handler 162 - pub fn route(&self, method: &str, path: &str) -> Option<(&Handler, Vec<String>)> { 163 - let (path_without_query, _) = split_path_and_query(path); 164 - if let Some(routes) = self.routes.get(method) { 165 - for (route_path, handler) in routes { 166 - let mut params = Vec::new(); 167 - if self.match_route(route_path, path_without_query, &mut params) { 168 - return Some((handler, params)); 169 - } 170 - } 171 - } 172 - None 173 - } 174 - 175 - // Simple route matching to support dynamic parameters 176 - pub fn match_route( 177 - &self, 178 - route_path: &str, 179 - request_path: &str, 180 - params: &mut Vec<String>, 181 - ) -> bool { 182 - let route_parts: Vec<&str> = route_path.split('/').collect(); 183 - let request_parts: Vec<&str> = request_path.split('/').collect(); 184 - 185 - if route_parts.len() > request_parts.len() { 186 - return false; 187 - } 188 - 189 - for (route_part, request_part) in route_parts.iter().zip(request_parts.iter()) { 190 - if route_part.starts_with(":") { 191 - params.push(request_part.to_string()); // Capture the parameter 192 - } else if route_part != request_part { 193 - return false; // Paths don't match 194 - } 195 - } 196 - 197 - // Ensure that the remaining parts of the request path are empty if the route path is shorter 198 - if route_parts.len() < request_parts.len() { 199 - for remaining_part in &request_parts[route_parts.len()..] { 200 - if !remaining_part.is_empty() { 201 - return false; 202 - } 203 - } 204 - } 205 - 206 - true 207 - } 208 - } 209 - 210 - #[derive(Clone)] 211 - pub struct RockboxHttpServer { 212 - router: Router, 213 - } 214 - 215 - impl RockboxHttpServer { 216 - pub fn new() -> Self { 217 - RockboxHttpServer { 218 - router: Router::new(), 219 - } 220 - } 221 - 222 - // Define the `get` method for routing GET requests 223 - pub fn get(&mut self, path: &str, handler: Handler) { 224 - self.router.get(path, handler); 225 - } 226 - 227 - // Define the `post` method for routing POST requests 228 - pub fn post(&mut self, path: &str, handler: Handler) { 229 - self.router.post(path, handler); 230 - } 231 - 232 - // Define the `put` method for routing PUT requests 233 - pub fn put(&mut self, path: &str, handler: Handler) { 234 - self.router.put(path, handler); 235 - } 236 - 237 - // Define the `delete` method for routing DELETE requests 238 - pub fn delete(&mut self, path: &str, handler: Handler) { 239 - self.router.delete(path, handler); 240 - } 241 - 242 - // Start listening and handling incoming requests 243 - pub fn listen(&mut self) -> Result<(), Error> { 244 - let port = std::env::var("ROCKBOX_TCP_PORT").unwrap_or_else(|_| "6063".to_string()); 245 - let addr = format!("0.0.0.0:{}", port); 246 - let listener = TcpListener::bind(&addr)?; 247 - listener.set_nonblocking(true)?; 248 - 249 - let pool = ThreadPool::new(4); 250 - let active_connections = Arc::new(Mutex::new(0)); 251 - let rt = Arc::new(tokio::runtime::Runtime::new()?); 252 - let db_pool = rt.block_on(rockbox_library::create_connection_pool())?; 253 - let fs_cache = Arc::new(tokio::sync::Mutex::new(HashMap::new())); 254 - let metadata_cache = Arc::new(tokio::sync::Mutex::new(HashMap::new())); 255 - // Seed device list with always-present virtual outputs. 256 - let devices = Arc::new(Mutex::new(virtual_devices())); 257 - 258 - // Determine which device is currently active from settings.toml. 259 - let current_device = { 260 - let active = rockbox_settings::read_settings().ok().and_then(|s| { 261 - let output = s.audio_output.as_deref().unwrap_or("builtin"); 262 - let mut device = match output { 263 - "builtin" | "fifo" => { 264 - virtual_devices().into_iter().find(|d| d.service == output) 265 - } 266 - "airplay" => { 267 - let host = s.airplay_host.clone().unwrap_or_default(); 268 - Some(Device { 269 - id: format!("airplay-{}", host), 270 - name: if host.is_empty() { 271 - "AirPlay".to_string() 272 - } else { 273 - format!("AirPlay ({})", host) 274 - }, 275 - host: host.clone(), 276 - ip: host, 277 - port: s.airplay_port.unwrap_or(5000), 278 - service: "airplay".to_string(), 279 - app: "AirPlay".to_string(), 280 - ..Default::default() 281 - }) 282 - } 283 - "squeezelite" => Some(Device { 284 - id: "squeezelite".to_string(), 285 - name: "Squeezelite".to_string(), 286 - host: "localhost".to_string(), 287 - ip: "127.0.0.1".to_string(), 288 - port: s.squeezelite_port.unwrap_or(3483), 289 - service: "squeezelite".to_string(), 290 - app: "squeezelite".to_string(), 291 - ..Default::default() 292 - }), 293 - "upnp" => { 294 - let url = s.upnp_renderer_url.clone().unwrap_or_default(); 295 - Some(Device { 296 - id: format!( 297 - "upnp-{:.8}", 298 - format!("{:x}", md5::compute(url.as_bytes())) 299 - ), 300 - name: "UPnP/DLNA".to_string(), 301 - host: "localhost".to_string(), 302 - ip: "127.0.0.1".to_string(), 303 - port: 0, 304 - service: "upnp".to_string(), 305 - app: "upnp".to_string(), 306 - base_url: Some(url), 307 - ..Default::default() 308 - }) 309 - } 310 - "chromecast" => { 311 - let host = s.chromecast_host.clone().unwrap_or_default(); 312 - Some(Device { 313 - id: format!("chromecast-{}", host), 314 - name: if host.is_empty() { 315 - "Chromecast".to_string() 316 - } else { 317 - format!("Chromecast ({})", host) 318 - }, 319 - host: host.clone(), 320 - ip: host, 321 - port: s.chromecast_port.unwrap_or(8009), 322 - service: "chromecast".to_string(), 323 - app: "Chromecast".to_string(), 324 - is_cast_device: true, 325 - ..Default::default() 326 - }) 327 - } 328 - "snapcast_tcp" => { 329 - let host = s.snapcast_tcp_host.clone().unwrap_or_default(); 330 - Some(Device { 331 - id: format!("snapcast-{}", host), 332 - name: if host.is_empty() { 333 - "Snapcast".to_string() 334 - } else { 335 - format!("Snapcast ({})", host) 336 - }, 337 - host: host.clone(), 338 - ip: host, 339 - port: s.snapcast_tcp_port.unwrap_or(4953), 340 - service: "snapcast".to_string(), 341 - app: "Snapcast".to_string(), 342 - is_cast_device: true, 343 - ..Default::default() 344 - }) 345 - } 346 - _ => virtual_devices() 347 - .into_iter() 348 - .find(|d| d.service == "builtin"), 349 - }; 350 - if let Some(ref mut d) = device { 351 - d.is_current_device = true; 352 - } 353 - device 354 - }); 355 - Arc::new(Mutex::new(active)) 356 - }; 357 - 358 - let player = Arc::new(Mutex::new(None)); 359 - let kv = Arc::new(Mutex::new(rt.block_on(build_tracks_kv(db_pool.clone()))?)); 360 - 361 - let playlist_store = PlaylistStore::new(db_pool.clone()); 362 - rt.block_on(playlist_store.seed()) 363 - .expect("Failed to seed playlist store"); 364 - 365 - // Start scanning for devices 366 - scan_chromecast_devices(devices.clone()); 367 - scan_upnp_devices(devices.clone()); 368 - scan_airplay_devices(devices.clone()); 369 - scan_snapcast_servers(devices.clone()); 370 - scan_squeezelite_clients(devices.clone()); 371 - listen_for_playback_changes(player.clone(), db_pool.clone()); 372 - 373 - loop { 374 - match listener.accept() { 375 - Ok((stream, _)) => { 376 - // The listener is non-blocking so the accept loop can check 377 - // active connections; reset the accepted stream to blocking 378 - // so handler-thread reads/writes don't get WouldBlock. 379 - if let Err(e) = stream.set_nonblocking(false) { 380 - tracing::warn!("http: set_nonblocking(false) failed: {e}"); 381 - continue; 382 - } 383 - let db_pool = db_pool.clone(); 384 - let active_connections = Arc::clone(&active_connections); 385 - { 386 - let mut active_connections = active_connections.lock().unwrap(); 387 - *active_connections += 1; 388 - } 389 - let mut cloned_self = self.clone(); 390 - let cloned_rt = rt.clone(); 391 - let cloned_fs_cache = fs_cache.clone(); 392 - let cloned_metadata_cache = metadata_cache.clone(); 393 - let cloned_devices = devices.clone(); 394 - let cloned_current_device = current_device.clone(); 395 - let cloned_player = player.clone(); 396 - let cloned_kv = kv.clone(); 397 - let cloned_playlist_store = playlist_store.clone(); 398 - pool.execute(move || { 399 - let mut buf_reader = BufReader::new(&stream); 400 - let mut request = String::new(); 401 - 402 - if buf_reader.read_line(&mut request).is_err() { 403 - let mut active_connections = active_connections.lock().unwrap(); 404 - *active_connections -= 1; 405 - return; 406 - } 407 - 408 - let request_line_parts: Vec<&str> = request.split_whitespace().collect(); 409 - if request_line_parts.len() >= 2 { 410 - let method = request_line_parts[0]; 411 - let path_with_query = request_line_parts[1]; 412 - 413 - let (path, query_string) = split_path_and_query(path_with_query); 414 - 415 - let query_params: Value = match query_string { 416 - Some(query_str) => queryst::parse(query_str).unwrap_or_default(), 417 - None => Value::default(), 418 - }; 419 - 420 - let mut content_length = 0; 421 - 422 - loop { 423 - let mut line = Default::default(); 424 - let res = buf_reader.read_line(&mut line); 425 - if res.is_ok() { 426 - if line.starts_with("Content-Length") 427 - || line.starts_with("content-length") 428 - { 429 - let parts: Vec<_> = line.split(":").collect(); 430 - content_length = parts[1].trim().parse().unwrap_or(0); 431 - } 432 - 433 - if line.as_str() == "\r\n" || line == "\n" { 434 - break; 435 - } 436 - } else { 437 - break; 438 - } 439 - } 440 - 441 - let mut body: Vec<u8> = vec![0; content_length]; 442 - let mut total_read: usize = 0; 443 - 444 - while total_read < content_length { 445 - match buf_reader.read(&mut body[total_read..]) { 446 - Ok(0) => break, 447 - Ok(n) => total_read += n, 448 - Err(_) => break, 449 - } 450 - } 451 - 452 - let req_body = match content_length { 453 - 0 => None, 454 - _ => Some(String::from_utf8_lossy(&body).to_string()), 455 - }; 456 - 457 - cloned_self.handle_request( 458 - method, 459 - path, 460 - query_params, 461 - stream, 462 - db_pool, 463 - req_body, 464 - cloned_rt, 465 - cloned_fs_cache, 466 - cloned_metadata_cache, 467 - cloned_devices, 468 - cloned_current_device, 469 - cloned_player, 470 - cloned_kv, 471 - cloned_playlist_store, 472 - ); 473 - } 474 - 475 - { 476 - let mut active_connections = active_connections.lock().unwrap(); 477 - *active_connections -= 1; 478 - } 479 - }); 480 - } 481 - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { 482 - // No incoming connection, just sleep and retry 483 - rb::system::sleep(rb::HZ); 484 - } 485 - Err(e) => { 486 - error!("Error accepting connection: {}", e); 487 - break; 488 - } 489 - } 490 - 491 - // Check if there are no active connections (idle state) 492 - let active = *active_connections.lock().unwrap(); 493 - if active == 0 { 494 - rb::system::sleep(rb::HZ); 495 - } 496 - 497 - // Add a small sleep to avoid tight looping when idle 498 - thread::sleep(Duration::from_millis(100)); 499 - } 500 - 501 - Ok(()) 502 - } 503 - 504 - // Handle incoming requests 505 - fn handle_request( 506 - &mut self, 507 - method: &str, 508 - path: &str, 509 - query_params: Value, 510 - mut stream: TcpStream, 511 - pool: sqlx::Pool<Sqlite>, 512 - body: Option<String>, 513 - rt: Arc<tokio::runtime::Runtime>, 514 - fs_cache: Arc<tokio::sync::Mutex<HashMap<String, Vec<Entry>>>>, 515 - metadata_cache: Arc<tokio::sync::Mutex<HashMap<String, Mp3Entry>>>, 516 - devices: Arc<Mutex<Vec<Device>>>, 517 - current_device: Arc<Mutex<Option<Device>>>, 518 - player: Arc<Mutex<Option<Box<dyn Player + Send>>>>, 519 - kv: Arc<Mutex<KV<Track>>>, 520 - playlist_store: PlaylistStore, 521 - ) { 522 - debug!("{} {}", method, path); 523 - match self.router.route(method, path) { 524 - Some((handler, params)) => { 525 - let mut response = Response::new(); 526 - let context = Context { 527 - pool, 528 - rt, 529 - fs_cache, 530 - metadata_cache, 531 - devices, 532 - current_device, 533 - player, 534 - kv, 535 - playlist_store, 536 - }; 537 - let request = Request { 538 - method: method.to_string(), 539 - params, 540 - query_params, 541 - body, 542 - }; 543 - match handler(&context, &request, &mut response) { 544 - Ok(_) => { 545 - response.send(&mut stream); 546 - } 547 - Err(e) => { 548 - let mut response = Response::new(); 549 - response.set_status(500); 550 - response.set_body(&format!("Internal Server Error: {:?}", e)); 551 - response.send(&mut stream); 552 - } 553 - } 554 - } 555 - None => { 556 - let mut response = Response::new(); 557 - response.set_status(404); 558 - response.set_body("404 Not Found"); 559 - response.send(&mut stream); 560 - } 561 - }; 562 - } 563 - }
+424 -102
crates/server/src/lib.rs
··· 1 1 use anyhow::Error; 2 - use handlers::*; 3 2 use tracing::{error, warn}; 4 3 5 - use http::RockboxHttpServer; 6 4 use lazy_static::lazy_static; 7 5 use rockbox_graphql::{ 8 6 schema::objects::{self, audio_status::AudioStatus, track::Track}, ··· 79 77 } 80 78 } 81 79 82 - let mut app = RockboxHttpServer::new(); 80 + // Pre-initialize the UPnP tokio runtime before any other runtime starts. 81 + rockbox_upnp::init(); 83 82 84 - app.get("/albums", get_albums); 85 - app.get("/albums/:id", get_album); 86 - app.get("/albums/:id/tracks", get_album_tracks); 83 + // Run the HTTP server in a dedicated Rust OS thread — exactly like gRPC 84 + // and GraphQL are run from start_servers(). This avoids actix detecting an 85 + // "existing Tokio runtime" on the Rockbox C server_thread, which would 86 + // collapse all actix workers onto a single thread. 87 + let handle = thread::spawn( 88 + || match actix_rt::System::new().block_on(run_http_server()) { 89 + Ok(_) => {} 90 + Err(e) => { 91 + error!("Error starting HTTP server: {}", e); 92 + } 93 + }, 94 + ); 87 95 88 - app.get("/artists", get_artists); 89 - app.get("/artists/:id", get_artist); 90 - app.get("/artists/:id/albums", get_artist_albums); 91 - app.get("/artists/:id/tracks", get_artist_tracks); 96 + // Keep the Rockbox C server_thread alive while the HTTP server runs. 97 + let _ = handle.join(); 98 + } 92 99 93 - app.get("/browse/tree-entries", get_tree_entries); 100 + async fn run_http_server() -> Result<(), Error> { 101 + use crate::http::AppState; 102 + use actix_cors::Cors; 103 + use actix_web::{web, App, HttpServer}; 104 + use rockbox_types::device::Device; 94 105 95 - app.get("/player", get_current_player); 96 - app.put("/player/load", load); 97 - app.put("/player/play", play); 98 - app.put("/player/pause", pause); 99 - app.put("/player/resume", resume); 100 - app.put("/player/ff-rewind", ff_rewind); 101 - app.get("/player/status", status); 102 - app.get("/player/current-track", current_track); 103 - app.get("/player/next-track", next_track); 104 - app.put("/player/flush-and-reload-tracks", flush_and_reload_tracks); 105 - app.put("/player/next", next); 106 - app.put("/player/previous", previous); 107 - app.put("/player/stop", stop); 108 - app.get("/player/file-position", get_file_position); 109 - app.get("/player/volume", get_volume); 110 - app.put("/player/volume", adjust_volume); 106 + let port = std::env::var("ROCKBOX_TCP_PORT").unwrap_or_else(|_| "6063".to_string()); 107 + let addr = format!("0.0.0.0:{}", port); 111 108 112 - app.post("/playlists", create_playlist); 113 - app.put("/playlists/start", start_playlist); 114 - app.put("/playlists/shuffle", shuffle_playlist); 115 - app.get("/playlists/amount", get_playlist_amount); 116 - app.put("/playlists/resume", resume_playlist); 117 - app.put("/playlists/resume-track", resume_track); 118 - app.get("/playlists/:id/tracks", get_playlist_tracks); 119 - app.post("/playlists/:id/tracks", insert_tracks); 120 - app.delete("/playlists/:id/tracks", remove_tracks); 121 - app.get("/playlists/:id", get_playlist); 109 + let pool = rockbox_library::create_connection_pool().await?; 110 + let fs_cache = Arc::new(tokio::sync::Mutex::new(HashMap::new())); 111 + let metadata_cache = Arc::new(tokio::sync::Mutex::new(HashMap::new())); 112 + let devices = Arc::new(Mutex::new(scan::virtual_devices())); 122 113 123 - app.get("/saved-playlists/folders", list_playlist_folders); 124 - app.post("/saved-playlists/folders", create_playlist_folder); 125 - app.delete("/saved-playlists/folders/:id", delete_playlist_folder); 126 - app.get("/saved-playlists", list_saved_playlists); 127 - app.post("/saved-playlists", create_saved_playlist); 128 - app.get("/saved-playlists/:id/tracks", get_saved_playlist_tracks); 129 - app.get( 130 - "/saved-playlists/:id/track-ids", 131 - get_saved_playlist_track_ids, 132 - ); 133 - app.post("/saved-playlists/:id/tracks", add_tracks_to_saved_playlist); 134 - app.delete( 135 - "/saved-playlists/:id/tracks/:track_id", 136 - remove_track_from_saved_playlist, 137 - ); 138 - app.post("/saved-playlists/:id/play", play_saved_playlist); 139 - app.get("/saved-playlists/:id", get_saved_playlist); 140 - app.put("/saved-playlists/:id", update_saved_playlist); 141 - app.delete("/saved-playlists/:id", delete_saved_playlist); 114 + let current_device = { 115 + let active = rockbox_settings::read_settings().ok().and_then(|s| { 116 + let output = s.audio_output.as_deref().unwrap_or("builtin"); 117 + let mut device = match output { 118 + "builtin" | "fifo" => scan::virtual_devices() 119 + .into_iter() 120 + .find(|d| d.service == output), 121 + "airplay" => { 122 + let host = s.airplay_host.clone().unwrap_or_default(); 123 + Some(Device { 124 + id: format!("airplay-{}", host), 125 + name: if host.is_empty() { 126 + "AirPlay".to_string() 127 + } else { 128 + format!("AirPlay ({})", host) 129 + }, 130 + host: host.clone(), 131 + ip: host, 132 + port: s.airplay_port.unwrap_or(5000), 133 + service: "airplay".to_string(), 134 + app: "AirPlay".to_string(), 135 + ..Default::default() 136 + }) 137 + } 138 + "squeezelite" => Some(Device { 139 + id: "squeezelite".to_string(), 140 + name: "Squeezelite".to_string(), 141 + host: "localhost".to_string(), 142 + ip: "127.0.0.1".to_string(), 143 + port: s.squeezelite_port.unwrap_or(3483), 144 + service: "squeezelite".to_string(), 145 + app: "squeezelite".to_string(), 146 + ..Default::default() 147 + }), 148 + "upnp" => { 149 + let url = s.upnp_renderer_url.clone().unwrap_or_default(); 150 + Some(Device { 151 + id: format!("upnp-{:.8}", format!("{:x}", md5::compute(url.as_bytes()))), 152 + name: "UPnP/DLNA".to_string(), 153 + host: "localhost".to_string(), 154 + ip: "127.0.0.1".to_string(), 155 + port: 0, 156 + service: "upnp".to_string(), 157 + app: "upnp".to_string(), 158 + base_url: Some(url), 159 + ..Default::default() 160 + }) 161 + } 162 + "chromecast" => { 163 + let host = s.chromecast_host.clone().unwrap_or_default(); 164 + Some(Device { 165 + id: format!("chromecast-{}", host), 166 + name: if host.is_empty() { 167 + "Chromecast".to_string() 168 + } else { 169 + format!("Chromecast ({})", host) 170 + }, 171 + host: host.clone(), 172 + ip: host, 173 + port: s.chromecast_port.unwrap_or(8009), 174 + service: "chromecast".to_string(), 175 + app: "Chromecast".to_string(), 176 + is_cast_device: true, 177 + ..Default::default() 178 + }) 179 + } 180 + "snapcast_tcp" => { 181 + let host = s.snapcast_tcp_host.clone().unwrap_or_default(); 182 + Some(Device { 183 + id: format!("snapcast-{}", host), 184 + name: if host.is_empty() { 185 + "Snapcast".to_string() 186 + } else { 187 + format!("Snapcast ({})", host) 188 + }, 189 + host: host.clone(), 190 + ip: host, 191 + port: s.snapcast_tcp_port.unwrap_or(4953), 192 + service: "snapcast".to_string(), 193 + app: "Snapcast".to_string(), 194 + is_cast_device: true, 195 + ..Default::default() 196 + }) 197 + } 198 + _ => scan::virtual_devices() 199 + .into_iter() 200 + .find(|d| d.service == "builtin"), 201 + }; 202 + if let Some(ref mut d) = device { 203 + d.is_current_device = true; 204 + } 205 + device 206 + }); 207 + Arc::new(Mutex::new(active)) 208 + }; 142 209 143 - app.get("/smart-playlists", list_smart_playlists); 144 - app.post("/smart-playlists", create_smart_playlist); 145 - app.get("/smart-playlists/:id/tracks", get_smart_playlist_tracks); 146 - app.post("/smart-playlists/:id/play", play_smart_playlist); 147 - app.get("/smart-playlists/:id", get_smart_playlist); 148 - app.put("/smart-playlists/:id", update_smart_playlist); 149 - app.delete("/smart-playlists/:id", delete_smart_playlist); 210 + let player = Arc::new(Mutex::new(None)); 211 + let kv = Arc::new(Mutex::new(kv::build_tracks_kv(pool.clone()).await?)); 150 212 151 - app.post("/track-stats/:id/played", record_track_played); 152 - app.post("/track-stats/:id/skipped", record_track_skipped); 153 - app.get("/track-stats/:id", get_track_stats); 213 + let playlist_store = rockbox_playlists::PlaylistStore::new(pool.clone()); 214 + playlist_store.seed().await?; 154 215 155 - app.get("/tracks", get_tracks); 156 - app.get("/tracks/:id", get_track); 157 - app.put("/tracks/stream-metadata", save_stream_track_metadata); 216 + scan::scan_chromecast_devices(devices.clone()); 217 + scan::scan_upnp_devices(devices.clone()); 218 + scan::scan_airplay_devices(devices.clone()); 219 + scan::scan_snapcast_servers(devices.clone()); 220 + scan::scan_squeezelite_clients(devices.clone()); 221 + player_events::listen_for_playback_changes(player.clone(), pool.clone()); 158 222 159 - app.get("/version", get_rockbox_version); 160 - app.get("/status", get_status); 161 - app.get("/settings", get_global_settings); 162 - app.put("/settings", update_global_settings); 163 - app.put("/scan-library", scan_library); 164 - app.get("/search", search); 223 + let state = web::Data::new(AppState { 224 + pool, 225 + fs_cache, 226 + metadata_cache, 227 + devices, 228 + current_device, 229 + player, 230 + kv, 231 + playlist_store, 232 + }); 165 233 166 - app.get("/devices", get_devices); 167 - app.get("/devices/:id", get_device); 168 - app.put("/devices/:id/connect", connect); 169 - app.put("/devices/:id/disconnect", disconnect); 234 + HttpServer::new(move || { 235 + let cors = Cors::permissive(); 236 + App::new() 237 + .app_data(state.clone()) 238 + .wrap(cors) 239 + // Albums 240 + .route("/albums", web::get().to(handlers::albums::get_albums)) 241 + .route("/albums/{id}", web::get().to(handlers::albums::get_album)) 242 + .route( 243 + "/albums/{id}/tracks", 244 + web::get().to(handlers::albums::get_album_tracks), 245 + ) 246 + // Artists 247 + .route("/artists", web::get().to(handlers::artists::get_artists)) 248 + .route( 249 + "/artists/{id}", 250 + web::get().to(handlers::artists::get_artist), 251 + ) 252 + .route( 253 + "/artists/{id}/albums", 254 + web::get().to(handlers::artists::get_artist_albums), 255 + ) 256 + .route( 257 + "/artists/{id}/tracks", 258 + web::get().to(handlers::artists::get_artist_tracks), 259 + ) 260 + // Browse 261 + .route( 262 + "/browse/tree-entries", 263 + web::get().to(handlers::browse::get_tree_entries), 264 + ) 265 + // Player 266 + .route( 267 + "/player", 268 + web::get().to(handlers::player::get_current_player), 269 + ) 270 + .route("/player/load", web::put().to(handlers::player::load)) 271 + .route("/player/play", web::put().to(handlers::player::play)) 272 + .route("/player/pause", web::put().to(handlers::player::pause)) 273 + .route("/player/resume", web::put().to(handlers::player::resume)) 274 + .route( 275 + "/player/ff-rewind", 276 + web::put().to(handlers::player::ff_rewind), 277 + ) 278 + .route("/player/status", web::get().to(handlers::player::status)) 279 + .route( 280 + "/player/current-track", 281 + web::get().to(handlers::player::current_track), 282 + ) 283 + .route( 284 + "/player/next-track", 285 + web::get().to(handlers::player::next_track), 286 + ) 287 + .route( 288 + "/player/flush-and-reload-tracks", 289 + web::put().to(handlers::player::flush_and_reload_tracks), 290 + ) 291 + .route("/player/next", web::put().to(handlers::player::next)) 292 + .route( 293 + "/player/previous", 294 + web::put().to(handlers::player::previous), 295 + ) 296 + .route("/player/stop", web::put().to(handlers::player::stop)) 297 + .route( 298 + "/player/file-position", 299 + web::get().to(handlers::player::get_file_position), 300 + ) 301 + .route( 302 + "/player/volume", 303 + web::get().to(handlers::player::get_volume), 304 + ) 305 + .route( 306 + "/player/volume", 307 + web::put().to(handlers::player::adjust_volume), 308 + ) 309 + // Playlists — fixed routes before parametric ones 310 + .route( 311 + "/playlists/start", 312 + web::put().to(handlers::playlists::start_playlist), 313 + ) 314 + .route( 315 + "/playlists/shuffle", 316 + web::put().to(handlers::playlists::shuffle_playlist), 317 + ) 318 + .route( 319 + "/playlists/amount", 320 + web::get().to(handlers::playlists::get_playlist_amount), 321 + ) 322 + .route( 323 + "/playlists/resume", 324 + web::put().to(handlers::playlists::resume_playlist), 325 + ) 326 + .route( 327 + "/playlists/resume-track", 328 + web::put().to(handlers::playlists::resume_track), 329 + ) 330 + .route( 331 + "/playlists", 332 + web::post().to(handlers::playlists::create_playlist), 333 + ) 334 + .route( 335 + "/playlists/{id}/tracks", 336 + web::get().to(handlers::playlists::get_playlist_tracks), 337 + ) 338 + .route( 339 + "/playlists/{id}/tracks", 340 + web::post().to(handlers::playlists::insert_tracks), 341 + ) 342 + .route( 343 + "/playlists/{id}/tracks", 344 + web::delete().to(handlers::playlists::remove_tracks), 345 + ) 346 + .route( 347 + "/playlists/{id}", 348 + web::get().to(handlers::playlists::get_playlist), 349 + ) 350 + // Saved playlists — fixed routes before parametric ones 351 + .route( 352 + "/saved-playlists/folders", 353 + web::get().to(handlers::saved_playlists::list_playlist_folders), 354 + ) 355 + .route( 356 + "/saved-playlists/folders", 357 + web::post().to(handlers::saved_playlists::create_playlist_folder), 358 + ) 359 + .route( 360 + "/saved-playlists/folders/{id}", 361 + web::delete().to(handlers::saved_playlists::delete_playlist_folder), 362 + ) 363 + .route( 364 + "/saved-playlists", 365 + web::get().to(handlers::saved_playlists::list_saved_playlists), 366 + ) 367 + .route( 368 + "/saved-playlists", 369 + web::post().to(handlers::saved_playlists::create_saved_playlist), 370 + ) 371 + .route( 372 + "/saved-playlists/{id}/tracks", 373 + web::get().to(handlers::saved_playlists::get_saved_playlist_tracks), 374 + ) 375 + .route( 376 + "/saved-playlists/{id}/track-ids", 377 + web::get().to(handlers::saved_playlists::get_saved_playlist_track_ids), 378 + ) 379 + .route( 380 + "/saved-playlists/{id}/tracks", 381 + web::post().to(handlers::saved_playlists::add_tracks_to_saved_playlist), 382 + ) 383 + .route( 384 + "/saved-playlists/{id}/tracks/{track_id}", 385 + web::delete().to(handlers::saved_playlists::remove_track_from_saved_playlist), 386 + ) 387 + .route( 388 + "/saved-playlists/{id}/play", 389 + web::post().to(handlers::saved_playlists::play_saved_playlist), 390 + ) 391 + .route( 392 + "/saved-playlists/{id}", 393 + web::get().to(handlers::saved_playlists::get_saved_playlist), 394 + ) 395 + .route( 396 + "/saved-playlists/{id}", 397 + web::put().to(handlers::saved_playlists::update_saved_playlist), 398 + ) 399 + .route( 400 + "/saved-playlists/{id}", 401 + web::delete().to(handlers::saved_playlists::delete_saved_playlist), 402 + ) 403 + // Smart playlists 404 + .route( 405 + "/smart-playlists", 406 + web::get().to(handlers::smart_playlists::list_smart_playlists), 407 + ) 408 + .route( 409 + "/smart-playlists", 410 + web::post().to(handlers::smart_playlists::create_smart_playlist), 411 + ) 412 + .route( 413 + "/smart-playlists/{id}/tracks", 414 + web::get().to(handlers::smart_playlists::get_smart_playlist_tracks), 415 + ) 416 + .route( 417 + "/smart-playlists/{id}/play", 418 + web::post().to(handlers::smart_playlists::play_smart_playlist), 419 + ) 420 + .route( 421 + "/smart-playlists/{id}", 422 + web::get().to(handlers::smart_playlists::get_smart_playlist), 423 + ) 424 + .route( 425 + "/smart-playlists/{id}", 426 + web::put().to(handlers::smart_playlists::update_smart_playlist), 427 + ) 428 + .route( 429 + "/smart-playlists/{id}", 430 + web::delete().to(handlers::smart_playlists::delete_smart_playlist), 431 + ) 432 + // Track stats 433 + .route( 434 + "/track-stats/{id}/played", 435 + web::post().to(handlers::smart_playlists::record_track_played), 436 + ) 437 + .route( 438 + "/track-stats/{id}/skipped", 439 + web::post().to(handlers::smart_playlists::record_track_skipped), 440 + ) 441 + .route( 442 + "/track-stats/{id}", 443 + web::get().to(handlers::smart_playlists::get_track_stats), 444 + ) 445 + // Tracks — fixed route before parametric 446 + .route( 447 + "/tracks/stream-metadata", 448 + web::put().to(handlers::tracks::save_stream_track_metadata), 449 + ) 450 + .route("/tracks", web::get().to(handlers::tracks::get_tracks)) 451 + .route("/tracks/{id}", web::get().to(handlers::tracks::get_track)) 452 + // System 453 + .route( 454 + "/version", 455 + web::get().to(handlers::system::get_rockbox_version), 456 + ) 457 + .route("/status", web::get().to(handlers::system::get_status)) 458 + .route( 459 + "/settings", 460 + web::get().to(handlers::settings::get_global_settings), 461 + ) 462 + .route( 463 + "/settings", 464 + web::put().to(handlers::settings::update_global_settings), 465 + ) 466 + .route( 467 + "/scan-library", 468 + web::put().to(handlers::system::scan_library), 469 + ) 470 + .route("/search", web::get().to(handlers::search::search)) 471 + // Devices 472 + .route("/devices", web::get().to(handlers::devices::get_devices)) 473 + .route( 474 + "/devices/{id}", 475 + web::get().to(handlers::devices::get_device), 476 + ) 477 + .route( 478 + "/devices/{id}/connect", 479 + web::put().to(handlers::devices::connect), 480 + ) 481 + .route( 482 + "/devices/{id}/disconnect", 483 + web::put().to(handlers::devices::disconnect), 484 + ) 485 + // Docs 486 + .route("/", web::get().to(handlers::docs::index)) 487 + .route("/operations/{id}", web::get().to(handlers::docs::index)) 488 + .route("/schemas/{id}", web::get().to(handlers::docs::index)) 489 + .route("/openapi.json", web::get().to(handlers::docs::get_openapi)) 490 + .configure(bluetooth_routes) 491 + }) 492 + .bind(addr)? 493 + .run() 494 + .await?; 495 + 496 + Ok(()) 497 + } 170 498 499 + fn bluetooth_routes(_cfg: &mut actix_web::web::ServiceConfig) { 171 500 #[cfg(target_os = "linux")] 172 501 { 173 - app.post("/bluetooth/scan", scan_bluetooth); 174 - app.get("/bluetooth/devices", get_bluetooth_devices); 175 - app.put("/bluetooth/devices/:addr/connect", connect_bluetooth_device); 176 - app.put( 177 - "/bluetooth/devices/:addr/disconnect", 178 - disconnect_bluetooth_device, 502 + let cfg = _cfg; 503 + cfg.route( 504 + "/bluetooth/scan", 505 + actix_web::web::post().to(handlers::bluetooth::scan_bluetooth), 506 + ) 507 + .route( 508 + "/bluetooth/devices", 509 + actix_web::web::get().to(handlers::bluetooth::get_bluetooth_devices), 510 + ) 511 + .route( 512 + "/bluetooth/devices/{addr}/connect", 513 + actix_web::web::put().to(handlers::bluetooth::connect_bluetooth_device), 514 + ) 515 + .route( 516 + "/bluetooth/devices/{addr}/disconnect", 517 + actix_web::web::put().to(handlers::bluetooth::disconnect_bluetooth_device), 179 518 ); 180 - } 181 - 182 - app.get("/", index); 183 - app.get("/operations/:id", index); 184 - app.get("/schemas/:id", index); 185 - app.get("/openapi.json", get_openapi); 186 - 187 - // Pre-initialize the UPnP tokio runtime before any HTTP handler runs. 188 - // If initialized lazily inside a handler's block_on context, tokio 1.27+ 189 - // panics with "Cannot start a runtime from within a runtime." 190 - rockbox_upnp::init(); 191 - 192 - match app.listen() { 193 - Ok(_) => {} 194 - Err(e) => { 195 - error!("Error starting server: {}", e); 196 - } 197 519 } 198 520 } 199 521