diff --git a/musicdb-client/Cargo.toml b/musicdb-client/Cargo.toml index d028a70..065c000 100755 --- a/musicdb-client/Cargo.toml +++ b/musicdb-client/Cargo.toml @@ -6,9 +6,9 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +musicdb-lib = { path = "../musicdb-lib" } clap = { version = "4.4.6", features = ["derive"] } directories = "5.0.1" -musicdb-lib = { version = "0.1.0", path = "../musicdb-lib" } regex = "1.9.3" speedy2d = { version = "1.12.0", optional = true } toml = "0.7.6" diff --git a/musicdb-lib/src/server/get.rs b/musicdb-lib/src/server/get.rs index d901d48..04131db 100755 --- a/musicdb-lib/src/server/get.rs +++ b/musicdb-lib/src/server/get.rs @@ -1,8 +1,7 @@ use std::{ fs, - io::BufRead, - io::{BufReader, Read, Write}, - path::Path, + io::{BufRead, BufReader, Read, Write}, + path::{Path, PathBuf}, sync::{Arc, Mutex}, }; @@ -12,6 +11,7 @@ pub struct Client(BufReader); impl Client { pub fn new(mut con: BufReader) -> std::io::Result { writeln!(con.get_mut(), "get")?; + con.get_mut().flush()?; Ok(Self(con)) } pub fn cover_bytes(&mut self, id: CoverId) -> Result, String>, std::io::Error> { @@ -20,9 +20,9 @@ impl Client { "{}", con_get_encode_string(&format!("cover-bytes\n{id}")) )?; + self.0.get_mut().flush()?; let mut response = String::new(); self.0.read_line(&mut response)?; - let response = con_get_decode_line(&response); if response.starts_with("len: ") { if let Ok(len) = response[4..].trim().parse() { let mut bytes = vec![0; len]; @@ -41,9 +41,9 @@ impl Client { "{}", con_get_encode_string(&format!("song-file\n{id}",)) )?; + self.0.get_mut().flush()?; let mut response = String::new(); self.0.read_line(&mut response)?; - let response = con_get_decode_line(&response); if response.starts_with("len: ") { if let Ok(len) = response[4..].trim().parse() { let mut bytes = vec![0; len]; @@ -62,9 +62,9 @@ impl Client { "{}", con_get_encode_string(&format!("custom-file\n{path}",)) )?; + self.0.get_mut().flush()?; let mut response = String::new(); self.0.read_line(&mut response)?; - let response = con_get_decode_line(&response); if response.starts_with("len: ") { if let Ok(len) = response[4..].trim().parse() { let mut bytes = vec![0; len]; @@ -77,15 +77,91 @@ impl Client { Ok(Err(response)) } } + pub fn song_file_by_path( + &mut self, + path: &str, + ) -> Result, String>, std::io::Error> { + writeln!( + self.0.get_mut(), + "{}", + con_get_encode_string(&format!("song-file-by-path\n{path}",)) + )?; + self.0.get_mut().flush()?; + let mut response = String::new(); + self.0.read_line(&mut response)?; + if response.starts_with("len: ") { + if let Ok(len) = response[4..].trim().parse() { + let mut bytes = vec![0; len]; + self.0.read_exact(&mut bytes)?; + Ok(Ok(bytes)) + } else { + Ok(Err(response)) + } + } else { + Ok(Err(response)) + } + } + /// tell the server to search for files that are not in its song database. + /// + /// ## `extensions`: + /// If `None`, the server uses a default set of music-related extensions (`[".mp3", ...]`). + /// If `Some([])`, allow all extensions, even ones like `.jpg` and files without extensions. + /// If `Some(...)`, only allow the specified extensions. Note: These are actually suffixes, for example `mp3` would allow a file named `test_mp3`, while `.mp3` would only allow `test.mp3`. + /// Because of this, you usually want to include the `.` before the extension, and double extensions like `.tar.gz` are also supported. + pub fn find_unused_song_files( + &mut self, + extensions: Option<&[&str]>, + ) -> Result, String>, std::io::Error> { + let mut str = "find-unused-song-files".to_owned(); + if let Some(extensions) = extensions { + if extensions.is_empty() { + str.push_str("\nextensions"); + } else { + str.push_str("\nextensions="); + for (i, ext) in extensions.iter().enumerate() { + if i > 0 { + str.push(':'); + } + str.push_str(ext); + } + } + } + writeln!(self.0.get_mut(), "{}", con_get_encode_string(&str))?; + self.0.get_mut().flush()?; + let mut response = String::new(); + self.0.read_line(&mut response)?; + let len_line = response.trim(); + if len_line.starts_with("len: ") { + if let Ok(len) = len_line[4..].trim().parse() { + let mut out = Vec::with_capacity(len); + for _ in 0..len { + let mut line = String::new(); + self.0.read_line(&mut line)?; + let line = line.trim_end_matches(['\n', '\r']); + if line.starts_with('#') { + out.push((line[1..].to_owned(), false)) + } else if line.starts_with('!') { + out.push((line[1..].to_owned(), true)) + } else { + return Ok(Err(format!("bad line-format: {line}"))); + } + } + Ok(Ok(out)) + } else { + Ok(Err(format!("bad len in len-line: {len_line}"))) + } + } else { + Ok(Err(format!("bad len-line: {len_line}"))) + } + } } pub fn handle_one_connection_as_get( db: Arc>, connection: &mut BufReader, ) -> Result<(), std::io::Error> { - let mut line = String::new(); loop { - line.clear(); + let mut line = String::new(); if connection.read_line(&mut line).is_ok() { if line.is_empty() { return Ok(()); @@ -145,7 +221,12 @@ pub fn handle_one_connection_as_get( parent = None; } if let Some(parent) = parent { - fs::read(parent.join(path)).ok() + let path = parent.join(path); + if path.starts_with(parent) { + fs::read(path).ok() + } else { + None + } } else { None } @@ -156,6 +237,78 @@ pub fn handle_one_connection_as_get( writeln!(connection.get_mut(), "no data")?; } } + "song-file-by-path" => { + if let Some(bytes) = request.next().and_then(|path| { + let db = db.lock().unwrap(); + let mut parent = Some(db.lib_directory.clone()); + // check for malicious paths [TODO: Improve] + if Path::new(path).is_absolute() { + parent = None; + } + if let Some(parent) = parent { + let path = parent.join(path); + if path.starts_with(parent) { + fs::read(path).ok() + } else { + None + } + } else { + None + } + }) { + writeln!(connection.get_mut(), "len: {}", bytes.len())?; + connection.get_mut().write_all(&bytes)?; + } else { + writeln!(connection.get_mut(), "no data")?; + } + } + "find-unused-song-files" => { + // configure search + let mut extensions = None; + loop { + if let Some(line) = request.next() { + if let Some((key, value)) = line.split_once("=") { + match key.trim() { + "extensions" => { + extensions = Some(Some( + value + .split(':') + .map(|v| v.trim().to_owned()) + .collect::>(), + )) + } + _ => (), + } + } else { + match line.trim() { + "extensions" => extensions = Some(None), + _ => (), + } + } + } else { + break; + } + } + // search + let lib_dir = db.lock().unwrap().lib_directory.clone(); + let unused = find_unused_song_files( + &db, + &lib_dir, + &FindUnusedSongFilesConfig { + extensions: extensions + .unwrap_or_else(|| Some(vec![".mp3".to_owned()])), + }, + ); + writeln!(connection.get_mut(), "len: {}", unused.len())?; + for path in unused { + if let Some(path) = path.to_str().filter(|v| !v.contains('\n')) { + writeln!(connection.get_mut(), "#{path}")?; + } else { + let path = path.to_string_lossy().replace('\n', ""); + writeln!(connection.get_mut(), "!{path}")?; + } + } + } _ => {} } } @@ -195,3 +348,74 @@ pub fn con_get_encode_string(line: &str) -> String { } o } + +fn find_unused_song_files( + db: &Arc>, + path: &impl AsRef, + cfg: &FindUnusedSongFilesConfig, +) -> Vec { + let mut files = vec![]; + find_unused_song_files_internal(db, path, &"", cfg, &mut files, &mut vec![], true); + files +} + +struct FindUnusedSongFilesConfig { + extensions: Option>, +} + +fn find_unused_song_files_internal( + db: &Arc>, + path: &impl AsRef, + rel_path: &impl AsRef, + cfg: &FindUnusedSongFilesConfig, + unused_files: &mut Vec, + files_buf: &mut Vec, + is_final: bool, +) { + if let Ok(rd) = std::fs::read_dir(path.as_ref()) { + for entry in rd { + if let Ok(entry) = entry { + if let Ok(file_type) = entry.file_type() { + let path = entry.path(); + let rel_path = rel_path.as_ref().join(entry.file_name()); + if file_type.is_dir() { + find_unused_song_files_internal( + db, + &path, + &rel_path, + cfg, + unused_files, + files_buf, + false, + ); + } else if file_type.is_file() { + if match &cfg.extensions { + None => true, + Some(exts) => { + if let Some(name) = path.file_name().and_then(|v| v.to_str()) { + exts.iter().any(|ext| name.ends_with(ext)) + } else { + false + } + } + } { + files_buf.push(rel_path); + } + } + } + } + } + } + if (is_final && files_buf.len() > 0) || files_buf.len() > 50 { + let db = db.lock().unwrap(); + for song in db.songs().values() { + if let Some(i) = files_buf + .iter() + .position(|path| path == &song.location.rel_path) + { + files_buf.remove(i); + } + } + unused_files.extend(std::mem::replace(files_buf, vec![]).into_iter()); + } +} diff --git a/musicdb-server/Cargo.toml b/musicdb-server/Cargo.toml index 0ad9807..eadcb17 100755 --- a/musicdb-server/Cargo.toml +++ b/musicdb-server/Cargo.toml @@ -6,11 +6,11 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +musicdb-lib = { path = "../musicdb-lib", features = ["playback"] } axum = { version = "0.6.19", features = ["headers"] } clap = { version = "4.4.6", features = ["derive"] } futures = "0.3.28" headers = "0.3.8" -musicdb-lib = { version = "0.1.0", path = "../musicdb-lib", features = ["playback"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.0", features = ["full"] }