├── api ├── src │ ├── models │ │ ├── mod.rs │ │ ├── folder.rs │ │ └── user.rs │ ├── lib.rs │ ├── server_fns │ │ ├── mod.rs │ │ ├── system.rs │ │ ├── user.rs │ │ ├── guard.rs │ │ ├── folder.rs │ │ ├── search.rs │ │ ├── auth.rs │ │ └── download.rs │ ├── globals.rs │ ├── db.rs │ └── auth.rs ├── build.rs ├── migrations │ └── 20240523000000_init.sql └── Cargo.toml ├── desktop ├── src │ ├── views │ │ ├── mod.rs │ │ └── home.rs │ └── main.rs ├── assets │ ├── blog.css │ └── main.css └── Cargo.toml ├── mobile ├── src │ ├── views │ │ ├── mod.rs │ │ └── home.rs │ └── main.rs ├── assets │ ├── blog.css │ └── main.css └── Cargo.toml ├── screenshots ├── mobile.png ├── search.png └── settings.png ├── web ├── assets │ ├── favicon.ico │ └── input.css ├── README.md ├── src │ ├── views │ │ ├── mod.rs │ │ ├── search.rs │ │ ├── settings.rs │ │ └── login.rs │ ├── auth.rs │ └── main.rs └── Cargo.toml ├── lib ├── shared │ ├── src │ │ ├── lib.rs │ │ ├── system.rs │ │ ├── download.rs │ │ ├── musicbrainz.rs │ │ └── slskd.rs │ └── Cargo.toml └── soulbeet │ ├── src │ ├── lib.rs │ ├── slskd │ │ ├── mod.rs │ │ ├── models.rs │ │ ├── processing.rs │ │ └── utils.rs │ ├── error.rs │ ├── beets │ │ └── mod.rs │ └── musicbrainz.rs │ └── Cargo.toml ├── css.sh ├── package.json ├── ui ├── src │ ├── components │ │ ├── simple │ │ │ ├── mod.rs │ │ │ ├── checkbox.rs │ │ │ └── button.rs │ │ ├── search │ │ │ ├── context.rs │ │ │ ├── search_type_toggle.rs │ │ │ ├── album.rs │ │ │ ├── track.rs │ │ │ ├── download_results.rs │ │ │ └── mod.rs │ │ ├── settings │ │ │ ├── mod.rs │ │ │ ├── user_manager.rs │ │ │ └── folder_manager.rs │ │ ├── mod.rs │ │ ├── footer.rs │ │ ├── album │ │ │ ├── header.rs │ │ │ ├── footer.rs │ │ │ ├── track_item.rs │ │ │ ├── track_list.rs │ │ │ └── mod.rs │ │ ├── status.rs │ │ ├── cover_art.rs │ │ ├── modal.rs │ │ ├── downloads │ │ │ ├── mod.rs │ │ │ └── item.rs │ │ └── login │ │ │ └── mod.rs │ ├── lib.rs │ ├── layout.rs │ ├── navbar.rs │ └── auth.rs └── Cargo.toml ├── .gitignore ├── .dockerignore ├── Cargo.toml ├── beets_config.yaml ├── clippy.toml ├── LICENSE ├── scripts └── bump_version.sh ├── docker-compose.yml ├── .github └── workflows │ ├── release.yml │ └── image-build-push.yml ├── Dockerfile └── README.md /api/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod folder; 2 | pub mod user; 3 | -------------------------------------------------------------------------------- /desktop/src/views/mod.rs: -------------------------------------------------------------------------------- 1 | mod home; 2 | pub use home::Home; 3 | -------------------------------------------------------------------------------- /mobile/src/views/mod.rs: -------------------------------------------------------------------------------- 1 | mod home; 2 | pub use home::Home; 3 | -------------------------------------------------------------------------------- /screenshots/mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terry90/soulbeet/HEAD/screenshots/mobile.png -------------------------------------------------------------------------------- /screenshots/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terry90/soulbeet/HEAD/screenshots/search.png -------------------------------------------------------------------------------- /web/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terry90/soulbeet/HEAD/web/assets/favicon.ico -------------------------------------------------------------------------------- /screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terry90/soulbeet/HEAD/screenshots/settings.png -------------------------------------------------------------------------------- /lib/shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod download; 2 | pub mod musicbrainz; 3 | pub mod slskd; 4 | pub mod system; 5 | -------------------------------------------------------------------------------- /lib/soulbeet/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod beets; 2 | pub mod error; 3 | pub mod musicbrainz; 4 | pub mod slskd; 5 | -------------------------------------------------------------------------------- /css.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | npx @tailwindcss/cli -i ./web/assets/input.css -o ./web/assets/tailwind.css --watch 4 | 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@tailwindcss/cli": "^4.1.17", 4 | "tailwindcss": "^4.1.17" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | ### Serving The Web App 2 | 3 | ```bash 4 | dx serve --package web --platform web --port 9797 5 | ``` 6 | -------------------------------------------------------------------------------- /lib/soulbeet/src/slskd/mod.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | mod models; 3 | mod processing; 4 | mod utils; 5 | 6 | pub use client::*; 7 | -------------------------------------------------------------------------------- /desktop/assets/blog.css: -------------------------------------------------------------------------------- 1 | #blog { 2 | margin-top: 50px; 3 | } 4 | 5 | #blog a { 6 | color: #ffffff; 7 | margin-top: 50px; 8 | } -------------------------------------------------------------------------------- /mobile/assets/blog.css: -------------------------------------------------------------------------------- 1 | #blog { 2 | margin-top: 50px; 3 | } 4 | 5 | #blog a { 6 | color: #ffffff; 7 | margin-top: 50px; 8 | } -------------------------------------------------------------------------------- /ui/src/components/simple/mod.rs: -------------------------------------------------------------------------------- 1 | mod button; 2 | mod checkbox; 3 | 4 | pub use button::Button; 5 | pub use checkbox::Checkbox; 6 | -------------------------------------------------------------------------------- /ui/src/components/search/context.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | #[derive(Clone, Copy)] 4 | pub struct SearchReset(pub Signal); 5 | -------------------------------------------------------------------------------- /api/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod db; 3 | pub mod globals; 4 | pub mod models; 5 | 6 | pub mod server_fns; 7 | 8 | pub use server_fns::*; 9 | -------------------------------------------------------------------------------- /ui/src/components/settings/mod.rs: -------------------------------------------------------------------------------- 1 | mod folder_manager; 2 | mod user_manager; 3 | 4 | pub use folder_manager::FolderManager; 5 | pub use user_manager::UserManager; 6 | -------------------------------------------------------------------------------- /web/src/views/mod.rs: -------------------------------------------------------------------------------- 1 | mod login; 2 | mod search; 3 | mod settings; 4 | 5 | pub use login::LoginPage; 6 | pub use search::SearchPage; 7 | pub use settings::SettingsPage; 8 | -------------------------------------------------------------------------------- /desktop/assets/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #0f1116; 3 | color: #ffffff; 4 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 5 | margin: 20px; 6 | } -------------------------------------------------------------------------------- /mobile/assets/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #0f1116; 3 | color: #ffffff; 4 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 5 | margin: 20px; 6 | } -------------------------------------------------------------------------------- /mobile/src/views/home.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use ui::Search; 3 | 4 | #[component] 5 | pub fn Home() -> Element { 6 | rsx! { 7 | Search {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /desktop/src/views/home.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use ui::Search; 3 | 4 | #[component] 5 | pub fn Home() -> Element { 6 | rsx! { 7 | Search {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /web/src/views/search.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use ui::Search; 3 | 4 | #[component] 5 | pub fn SearchPage() -> Element { 6 | rsx! { 7 | Search {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shared" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | serde = { version = "1", features = ["derive"] } 8 | serde_json = "1" 9 | -------------------------------------------------------------------------------- /lib/shared/src/system.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] 4 | pub struct SystemHealth { 5 | pub slskd_online: bool, 6 | pub beets_ready: bool, 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate contains all shared UI for the workspace. 2 | 3 | mod navbar; 4 | pub use navbar::Navbar; 5 | 6 | mod layout; 7 | pub use layout::Layout; 8 | 9 | mod auth; 10 | pub use auth::*; 11 | 12 | mod components; 13 | pub use components::*; 14 | -------------------------------------------------------------------------------- /lib/shared/src/download.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::musicbrainz::{Album, Track}; 4 | 5 | #[derive(Serialize, Clone, PartialEq, Deserialize, Debug)] 6 | pub struct DownloadQuery { 7 | pub album: Album, 8 | pub tracks: Vec, 9 | } 10 | -------------------------------------------------------------------------------- /mobile/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mobile" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | dioxus = { workspace = true, features = ["router"] } 8 | ui = { workspace = true } 9 | 10 | [features] 11 | default = [] 12 | mobile = ["dioxus/mobile"] 13 | server = ["dioxus/server"] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target 4 | .DS_Store 5 | 6 | node_modules 7 | 8 | # These are backup files generated by rustfmt 9 | **/*.rs.bk 10 | 11 | # Local Data 12 | soulbeet.db 13 | soulbeet.db-shm 14 | soulbeet.db-wal 15 | 16 | # Env files 17 | **/.env -------------------------------------------------------------------------------- /desktop/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "desktop" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | dioxus = { workspace = true, features = ["router"] } 8 | ui = { workspace = true } 9 | 10 | [features] 11 | default = [] 12 | desktop = ["dioxus/desktop"] 13 | server = ["dioxus/server"] 14 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Version Control 2 | .git/ 3 | .gitignore 4 | 5 | # Build Artifacts 6 | target/ 7 | node_modules/ 8 | **/*.rs.bk 9 | 10 | # Docker 11 | Dockerfile 12 | .dockerignore 13 | docker-compose.yml 14 | 15 | # Environment & Config 16 | .env 17 | .DS_Store 18 | *.swp 19 | 20 | # Local Data 21 | soulbeet.db 22 | soulbeet.db-shm 23 | soulbeet.db-wal 24 | 25 | # Documentation & Dev Scripts 26 | README.md 27 | css.sh 28 | clippy.toml -------------------------------------------------------------------------------- /ui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ui" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | dioxus = { workspace = true, features = ["fullstack"] } 8 | api = { workspace = true } 9 | shared = { workspace = true } 10 | web-sys = { version = "0.3.83", features = ["Storage", "Window", "Location"] } 11 | serde_json = "1.0.145" 12 | futures = "0.3.31" 13 | gloo-timers = { version = "0.3.0", features = ["futures"] } 14 | 15 | [features] 16 | default = [] 17 | server = ["api/server"] 18 | -------------------------------------------------------------------------------- /ui/src/layout.rs: -------------------------------------------------------------------------------- 1 | use crate::components::Footer; 2 | use dioxus::prelude::*; 3 | 4 | #[component] 5 | pub fn Layout(children: Element) -> Element { 6 | rsx! { 7 | // CRT Scanline Effect Overlay 8 | div { class: "fixed inset-0 z-50 pointer-events-none opacity-50 crt-overlay h-full w-full" } 9 | 10 | // Main container 11 | div { class: "relative z-10 flex flex-col h-screen max-w-7xl mx-auto", 12 | {children} 13 | Footer {} 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/components/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod album; 2 | pub mod cover_art; 3 | pub mod downloads; 4 | pub mod footer; 5 | pub mod login; 6 | pub mod modal; 7 | pub mod search; 8 | pub mod settings; 9 | pub mod simple; 10 | pub mod status; 11 | 12 | pub use album::{Album, AlbumHeader}; 13 | pub use cover_art::*; 14 | pub use downloads::*; 15 | pub use footer::Footer; 16 | pub use login::Login; 17 | pub use modal::*; 18 | pub use search::*; 19 | pub use settings::*; 20 | pub use simple::*; 21 | pub use status::*; 22 | -------------------------------------------------------------------------------- /api/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | let env_vars = get_env_vars(); 3 | 4 | println!("cargo:rerun-if-changed=.env"); 5 | println!("cargo:rerun-if-changed=api/migrations"); 6 | 7 | if let Some(env_vars) = env_vars { 8 | for env_var in env_vars { 9 | println!("cargo:rustc-env={}={}", &env_var.0, &env_var.1); 10 | } 11 | } 12 | } 13 | 14 | fn get_env_vars() -> Option> { 15 | dotenvy::dotenv().ok()?; 16 | 17 | let env_vars: Vec<(String, String)> = dotenvy::vars().collect(); 18 | Some(env_vars) 19 | } 20 | -------------------------------------------------------------------------------- /api/src/server_fns/mod.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | pub mod auth; 4 | pub mod download; 5 | pub mod folder; 6 | pub mod guard; 7 | pub mod search; 8 | pub mod system; 9 | pub mod user; 10 | 11 | pub use auth::*; 12 | pub use download::*; 13 | pub use folder::*; 14 | pub use guard::*; 15 | pub use search::*; 16 | pub use system::*; 17 | pub use user::*; 18 | 19 | pub fn server_error(e: E) -> ServerFnError { 20 | ServerFnError::ServerError { 21 | message: e.to_string(), 22 | code: 500, 23 | details: None, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "ui", 5 | "web", 6 | # "desktop", 7 | # "mobile", 8 | "api", 9 | "lib/soulbeet", 10 | ] 11 | 12 | [workspace.dependencies] 13 | dioxus = { version = "0.7.2" } 14 | 15 | # workspace 16 | ui = { path = "ui" } 17 | api = { path = "api" } 18 | shared = { path = "lib/shared" } 19 | soulbeet = { path = "lib/soulbeet" } 20 | 21 | [profile] 22 | 23 | [profile.wasm-dev] 24 | inherits = "dev" 25 | opt-level = 1 26 | 27 | [profile.server-dev] 28 | inherits = "dev" 29 | 30 | [profile.android-dev] 31 | inherits = "dev" 32 | -------------------------------------------------------------------------------- /beets_config.yaml: -------------------------------------------------------------------------------- 1 | plugins: musicbrainz 2 | directory: /music # Mapped in docker-compose 3 | import: 4 | copy: no 5 | move: yes 6 | resume: no 7 | duplicate_action: remove 8 | paths: 9 | default: $albumartist/$album%aunique{}/$track $title 10 | singleton: $albumartist/$album%aunique{}/$title 11 | match: 12 | strong_rec_thresh: 0.10 13 | max_rec: 14 | missing_tracks: low 15 | musicbrainz: 16 | searchlimit: 20 # Recommendation from: https://github.com/kernitus/beets-oldestdate 17 | extra_tags: # Enable improved MediaBrainz queries from tags. 18 | [ 19 | catalognum, 20 | country, 21 | label, 22 | media, 23 | year 24 | ] 25 | -------------------------------------------------------------------------------- /lib/soulbeet/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "soulbeet" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | tokio = { version = "1", features = ["sync", "time", "process"] } 8 | reqwest = { version = "0.12.23", features = ["json"] } 9 | serde = { version = "1", features = ["derive"] } 10 | serde_json = "1" 11 | tracing = "0.1.41" 12 | env_logger = "0.11.8" 13 | regex = "1" 14 | thiserror = "2.0.16" 15 | url = "2" 16 | chrono = { version = "0.4.42" } 17 | async-trait = "0.1" 18 | itertools = "0.14.0" 19 | musicbrainz_rs = { git = "https://github.com/RustyNova016/musicbrainz_rs", rev = "44c25c88bc776309b59a7a9d71d91b59aaa44781" } 20 | shared = { workspace = true } 21 | futures = "0.3.31" 22 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | await-holding-invalid-types = [ 2 | "generational_box::GenerationalRef", 3 | { path = "generational_box::GenerationalRef", reason = "Reads should not be held over an await point. This will cause any writes to fail while the await is pending since the read borrow is still active." }, 4 | "generational_box::GenerationalRefMut", 5 | { path = "generational_box::GenerationalRefMut", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." }, 6 | "dioxus_signals::Write", 7 | { path = "dioxus_signals::Write", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." }, 8 | ] 9 | -------------------------------------------------------------------------------- /lib/soulbeet/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum SoulseekError { 5 | #[error("Client is not configured. Base URL is missing.")] 6 | NotConfigured, 7 | 8 | #[error("Request error: {0}")] 9 | Request(#[from] reqwest::Error), 10 | 11 | #[error("URL parsing error: {0}")] 12 | UrlParse(#[from] url::ParseError), 13 | 14 | #[error("API error: {status} - {message}")] 15 | Api { status: u16, message: String }, 16 | 17 | #[error("Failed to acquire lock for rate limiting")] 18 | LockError, 19 | 20 | #[error("Search timed out")] 21 | SearchTimeout, 22 | 23 | #[error("Could not find a username for the given download ID")] 24 | UsernameNotFound, 25 | } 26 | 27 | pub type Result = std::result::Result; 28 | -------------------------------------------------------------------------------- /lib/soulbeet/src/slskd/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | // Internal structs for deserializing raw API responses 4 | #[derive(Deserialize, Debug)] 5 | #[serde(rename_all = "camelCase")] 6 | pub(crate) struct SearchResponseFile { 7 | pub filename: String, 8 | pub size: i64, 9 | pub bit_rate: Option, 10 | pub length: Option, 11 | } 12 | 13 | #[derive(Deserialize, Debug)] 14 | #[serde(rename_all = "camelCase")] 15 | pub(crate) struct SearchResponse { 16 | pub username: String, 17 | pub files: Vec, 18 | pub has_free_upload_slot: bool, 19 | pub upload_speed: i32, 20 | pub queue_length: i32, 21 | } 22 | 23 | #[derive(Serialize)] 24 | pub(crate) struct DownloadRequestFile { 25 | pub filename: String, 26 | pub size: i64, 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/components/footer.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | #[component] 4 | pub fn Footer() -> Element { 5 | rsx! { 6 | footer { class: "py-4 text-center border-t border-white/5", 7 | div { class: "flex justify-center gap-6 text-[10px] font-mono uppercase tracking-widest text-gray-500", 8 | a { 9 | class: "hover:text-beet-accent transition-colors", 10 | href: "https://hub.docker.com/repository/docker/docccccc/soulbeet", 11 | target: "_blank", 12 | "[ Docker Hub ]" 13 | } 14 | a { 15 | class: "hover:text-beet-accent transition-colors", 16 | href: "https://github.com/terry90/soulbeet", 17 | target: "_blank", 18 | "[ Github ]" 19 | } 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /api/migrations/20240523000000_init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users ( 2 | id TEXT PRIMARY KEY NOT NULL, 3 | username TEXT NOT NULL UNIQUE, 4 | password_hash TEXT NOT NULL 5 | ); 6 | 7 | CREATE TABLE IF NOT EXISTS folders ( 8 | id TEXT PRIMARY KEY NOT NULL, 9 | user_id TEXT NOT NULL, 10 | name TEXT NOT NULL, 11 | path TEXT NOT NULL, 12 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 13 | ); 14 | 15 | CREATE INDEX IF NOT EXISTS idx_folders_user_id ON folders(user_id); 16 | 17 | -- Default user: admin / admin 18 | -- Password hash for 'admin' using Argon2 19 | INSERT OR IGNORE INTO users (id, username, password_hash) 20 | VALUES ( 21 | '00000000-0000-0000-0000-000000000000', 22 | 'admin', 23 | '$argon2id$v=19$m=19456,t=2,p=1$llsT7N68SnCXwaqcvFP08g$W+5l4cDaOfsY9nK2jFs7JGwkxtVtmN+VLIWC7ZOM9/E' 24 | ); -------------------------------------------------------------------------------- /web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "web" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | dioxus = { workspace = true, features = ["router", "fullstack"] } 8 | ui = { workspace = true } 9 | api = { workspace = true } 10 | shared = { workspace = true } 11 | futures = "0.3.31" 12 | web-sys = { version = "0.3.83", features = ["Storage", "Window", "Location"] } 13 | url = "2.5.7" 14 | serde = { version = "1.0.228", features = ["derive"] } 15 | serde_json = "1.0.145" 16 | gloo-timers = { version = "0.3.0", features = ["futures"] } 17 | chrono = { version = "0.4.42", features = ["serde", "wasm-bindgen"] } 18 | tower-cookies = { version = "0.11.0", optional = true } 19 | axum = { version = "0.8.7", optional = true } 20 | 21 | [features] 22 | default = [] 23 | web = ["dioxus/web"] 24 | server = ["dioxus/server", "ui/server", "dep:tower-cookies", "dep:axum"] 25 | -------------------------------------------------------------------------------- /api/src/server_fns/system.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use shared::system::SystemHealth; 3 | 4 | #[cfg(feature = "server")] 5 | use crate::globals::SLSKD_CLIENT; 6 | use crate::AuthSession; 7 | 8 | #[get("/api/system/health", _: AuthSession)] 9 | pub async fn get_system_health() -> Result { 10 | #[cfg(feature = "server")] 11 | { 12 | let slskd_online = SLSKD_CLIENT.check_connection().await; 13 | 14 | let beets_ready = { 15 | use tokio::process::Command; 16 | let output = Command::new("beet").arg("version").output().await; 17 | output.map(|o| o.status.success()).unwrap_or(false) 18 | }; 19 | 20 | Ok(SystemHealth { 21 | slskd_online, 22 | beets_ready, 23 | }) 24 | } 25 | #[cfg(not(feature = "server"))] 26 | Ok(SystemHealth::default()) 27 | } 28 | -------------------------------------------------------------------------------- /api/src/server_fns/user.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "server")] 2 | use super::server_error; 3 | use crate::models; 4 | #[cfg(feature = "server")] 5 | use crate::AuthSession; 6 | use dioxus::prelude::*; 7 | 8 | #[get("/api/users", _: AuthSession)] 9 | pub async fn get_users() -> Result, ServerFnError> { 10 | models::user::User::get_all().await.map_err(server_error) 11 | } 12 | 13 | #[post("/api/users/password", _: AuthSession)] 14 | pub async fn update_user_password(user_id: String, password: String) -> Result<(), ServerFnError> { 15 | models::user::User::update_password(&user_id, &password) 16 | .await 17 | .map_err(server_error) 18 | } 19 | 20 | #[delete("/api/users/delete", _: AuthSession)] 21 | pub async fn delete_user(user_id: String) -> Result<(), ServerFnError> { 22 | models::user::User::delete(&user_id) 23 | .await 24 | .map_err(server_error) 25 | } 26 | -------------------------------------------------------------------------------- /web/src/views/settings.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use ui::settings::{FolderManager, UserManager}; 3 | 4 | #[component] 5 | pub fn SettingsPage() -> Element { 6 | rsx! { 7 | div { class: "fixed top-1/4 -left-10 w-64 h-64 bg-beet-accent/10 rounded-full blur-[100px] pointer-events-none" } 8 | div { class: "fixed bottom-1/4 -right-10 w-64 h-64 bg-beet-leaf/10 rounded-full blur-[100px] pointer-events-none" } 9 | 10 | div { class: "space-y-8 text-white w-full max-w-3xl z-10 mx-auto", 11 | div { class: "text-center mb-8", 12 | h1 { class: "text-4xl font-bold text-beet-accent mb-2 font-display", 13 | "Settings" 14 | } 15 | p { class: "text-gray-400 font-mono", "Manage your library and user preferences." } 16 | } 17 | 18 | FolderManager {} 19 | UserManager {} 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web/src/views/login.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | use std::pin::Pin; 3 | 4 | use api::login; 5 | use dioxus::prelude::*; 6 | use ui::Login; 7 | 8 | use crate::auth::use_auth; 9 | use crate::Route; 10 | 11 | #[component] 12 | pub fn LoginPage() -> Element { 13 | let navigator = use_navigator(); 14 | let mut auth = use_auth(); 15 | 16 | let login = use_callback(move |(username, password): (String, String)| 17 | -> Pin>>> 18 | { 19 | Box::pin(async move { 20 | match login(username, password).await { 21 | Ok(response) => { 22 | auth.login(response); 23 | navigator.push(Route::SearchPage {}); 24 | Ok(()) 25 | } 26 | Err(e) => Err(e.to_string()), 27 | } 28 | }) 29 | }); 30 | 31 | 32 | rsx! { 33 | Login { login } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ui/src/components/simple/checkbox.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | #[derive(Clone, PartialEq, Props)] 4 | pub struct Props { 5 | is_selected: bool, 6 | } 7 | 8 | #[component] 9 | pub fn Checkbox(props: Props) -> Element { 10 | rsx! { 11 | div { 12 | class: "w-5 h-5 border rounded flex items-center justify-center transition-colors duration-200", 13 | class: if props.is_selected { "border-beet-leaf bg-beet-leaf text-beet-dark" } else { "border-gray-500 bg-transparent" }, 14 | if props.is_selected { 15 | svg { 16 | class: "w-3 h-3", 17 | fill: "none", 18 | stroke: "currentColor", 19 | view_box: "0 0 24 24", 20 | path { 21 | stroke_linecap: "round", 22 | stroke_linejoin: "round", 23 | stroke_width: "4", 24 | d: "M5 13l4 4L19 7", 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ui/src/components/album/header.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use shared::musicbrainz::Album; 3 | 4 | use crate::CoverArt; 5 | 6 | #[derive(Props, PartialEq, Clone)] 7 | pub struct Props { 8 | album: Album, 9 | } 10 | 11 | #[component] 12 | pub fn AlbumHeader(props: Props) -> Element { 13 | rsx! { 14 | div { class: "flex items-start gap-4 p-4 border-b border-white/10", 15 | CoverArt { 16 | src: format!("https://coverartarchive.org/release/{}/front-250", props.album.id), 17 | alt: format!("Cover for {}", props.album.title), 18 | } 19 | div { class: "flex-grow", 20 | h3 { class: "text-2xl font-bold text-beet-accent font-display", 21 | "{props.album.title}" 22 | } 23 | p { class: "text-lg text-white font-mono", "{props.album.artist}" } 24 | if let Some(date) = &props.album.release_date { 25 | p { class: "text-sm text-gray-400 font-mono", "{date}" } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/src/globals.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "server")] 2 | use std::collections::HashMap; 3 | #[cfg(feature = "server")] 4 | use std::sync::LazyLock; 5 | 6 | #[cfg(feature = "server")] 7 | use shared::slskd::FileEntry; 8 | #[cfg(feature = "server")] 9 | use soulbeet::slskd::{SoulseekClient, SoulseekClientBuilder}; 10 | #[cfg(feature = "server")] 11 | use tokio::sync::{broadcast, RwLock}; 12 | 13 | #[cfg(feature = "server")] 14 | pub static SLSKD_CLIENT: LazyLock = LazyLock::new(|| { 15 | let api_key = std::env::var("SLSKD_API_KEY").expect("Missing SLSKD_API_KEY env var"); 16 | let base_url = std::env::var("SLSKD_URL").expect("Missing SLSKD_URL env var"); 17 | 18 | SoulseekClientBuilder::new() 19 | .api_key(&api_key) 20 | .base_url(&base_url) 21 | .build() 22 | .expect("Failed to create Soulseek client") 23 | }); 24 | 25 | #[cfg(feature = "server")] 26 | pub static USER_CHANNELS: LazyLock>>>> = 27 | LazyLock::new(|| RwLock::new(HashMap::new())); 28 | -------------------------------------------------------------------------------- /ui/src/components/album/footer.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | use crate::Button; 4 | 5 | #[derive(Props, PartialEq, Clone)] 6 | pub struct Props { 7 | is_selection_empty: bool, 8 | on_select: EventHandler, 9 | } 10 | 11 | #[component] 12 | pub fn AlbumFooter(props: Props) -> Element { 13 | rsx! { 14 | div { class: "p-4 border-t border-white/10 mt-auto", 15 | Button { 16 | class: "w-full", 17 | disabled: props.is_selection_empty, 18 | onclick: move |_| props.on_select.call(()), 19 | div { class: "flex items-center justify-center gap-2", 20 | svg { 21 | class: "w-4 h-4", 22 | fill: "none", 23 | stroke: "currentColor", 24 | view_box: "0 0 24 24", 25 | path { 26 | stroke_linecap: "round", 27 | stroke_linejoin: "round", 28 | stroke_width: "2", 29 | d: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z", 30 | } 31 | } 32 | "SEARCH SELECTED" 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Terry Raimondo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ui/src/components/status.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use shared::system::SystemHealth; 3 | 4 | #[component] 5 | pub fn SystemStatus(health: SystemHealth) -> Element { 6 | rsx! { 7 | div { class: "flex justify-center gap-6 text-xs font-mono text-gray-500", 8 | span { class: "flex items-center gap-2", 9 | span { 10 | class: format!( 11 | "w-2 h-2 rounded-full {}", 12 | if health.slskd_online { "bg-beet-leaf animate-pulse" } else { "bg-red-500" }, 13 | ), 14 | } 15 | if health.slskd_online { 16 | "SLSKD ONLINE" 17 | } else { 18 | "SLSKD OFFLINE" 19 | } 20 | } 21 | span { class: "flex items-center gap-2", 22 | span { 23 | class: format!( 24 | "w-2 h-2 rounded-full {}", 25 | if health.beets_ready { "bg-beet-accent" } else { "bg-red-500" }, 26 | ), 27 | } 28 | if health.beets_ready { 29 | "BEETS READY" 30 | } else { 31 | "BEETS MISSING" 32 | } 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /web/src/auth.rs: -------------------------------------------------------------------------------- 1 | use api::auth::AuthResponse; 2 | use dioxus::prelude::*; 3 | use ui::Auth; 4 | 5 | pub fn use_auth() -> Auth { 6 | use_context::() 7 | } 8 | 9 | #[component] 10 | pub fn AuthProvider(children: Element) -> Element { 11 | let auth_state = 12 | use_resource(move || async move { api::get_current_user().await.ok().flatten() }); 13 | 14 | let mut auth_signal = use_signal(|| None::); 15 | let mut initialized = use_signal(|| false); 16 | 17 | use_effect(move || { 18 | let user = auth_state.read().clone().flatten(); 19 | auth_signal.set(user); 20 | initialized.set(true); 21 | }); 22 | 23 | use_context_provider(|| Auth::new(auth_signal)); 24 | 25 | if !*initialized.read() { 26 | return rsx! { 27 | div { class: "flex flex-col items-center justify-center h-screen bg-gray-900", 28 | div { class: "animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-teal-500 mb-6" } 29 | h1 { class: "text-2xl font-bold text-teal-500 animate-pulse", "SoulBeet" } 30 | } 31 | }; 32 | } 33 | 34 | rsx! { 35 | {children} 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/src/db.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "server")] 2 | use dioxus::fullstack::Lazy; 3 | #[cfg(feature = "server")] 4 | use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; 5 | 6 | #[cfg(feature = "server")] 7 | pub static DB: Lazy = Lazy::new(|| async move { 8 | let database_url = 9 | std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:soulbeet.db".to_string()); 10 | 11 | if database_url.starts_with("sqlite:") { 12 | let path_str = database_url.trim_start_matches("sqlite:"); 13 | let path = std::path::Path::new(path_str); 14 | if !path.exists() { 15 | if let Some(parent) = path.parent() { 16 | std::fs::create_dir_all(parent).expect("Failed to create database directory"); 17 | } 18 | std::fs::File::create(path).expect("Failed to create database file"); 19 | } 20 | } 21 | 22 | let pool = SqlitePoolOptions::new() 23 | .max_connections(5) 24 | .connect(&database_url) 25 | .await 26 | .expect("Failed to connect to database"); 27 | 28 | sqlx::migrate!("./migrations") 29 | .run(&pool) 30 | .await 31 | .expect("Failed to run migrations"); 32 | 33 | dioxus::Ok(pool) 34 | }); 35 | -------------------------------------------------------------------------------- /ui/src/components/search/search_type_toggle.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | #[derive(PartialEq, Clone, Copy, Debug)] 4 | pub enum SearchType { 5 | Album, 6 | Track, 7 | } 8 | 9 | #[component] 10 | pub fn SearchTypeToggle(search_type: Signal) -> Element { 11 | let active_class = "text-white bg-white/10 shadow-sm"; 12 | let inactive_class = "text-gray-500 hover:text-gray-300 hover:bg-white/5"; 13 | 14 | let album_class = if search_type() == SearchType::Album { 15 | active_class 16 | } else { 17 | inactive_class 18 | }; 19 | let track_class = if search_type() == SearchType::Track { 20 | active_class 21 | } else { 22 | inactive_class 23 | }; 24 | 25 | rsx! { 26 | div { class: "flex items-center bg-black/20 rounded p-1 mr-2", 27 | button { 28 | class: "px-3 py-1 text-xs font-bold rounded transition-all duration-200 {album_class}", 29 | onclick: move |_| search_type.set(SearchType::Album), 30 | "ALBUM" 31 | } 32 | button { 33 | class: "px-3 py-1 text-xs font-bold rounded transition-all duration-200 {track_class}", 34 | onclick: move |_| search_type.set(SearchType::Track), 35 | "TRACK" 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ui/src/components/album/track_item.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use shared::musicbrainz::Track; 3 | 4 | use crate::Checkbox; 5 | 6 | #[derive(Props, Clone, PartialEq)] 7 | pub struct Props { 8 | track: Track, 9 | is_selected: bool, 10 | on_toggle: EventHandler, 11 | } 12 | 13 | #[component] 14 | pub fn TrackItem(props: Props) -> Element { 15 | let track_id = props.track.id.clone(); 16 | 17 | rsx! { 18 | li { 19 | class: "flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors border border-transparent", 20 | class: if props.is_selected { "bg-beet-leaf/10 border-beet-leaf/30" } else { "hover:bg-white/5 border-white/5" }, 21 | onclick: move |_| props.on_toggle.call(track_id.clone()), 22 | Checkbox { is_selected: props.is_selected } 23 | 24 | span { 25 | class: "flex-grow font-mono text-sm", 26 | class: if props.is_selected { "text-beet-leaf" } else { "text-gray-300" }, 27 | "{props.track.title}" 28 | } 29 | if let Some(duration) = &props.track.duration { 30 | span { 31 | class: "font-mono text-xs", 32 | class: if props.is_selected { "text-beet-leaf/70" } else { "text-gray-500" }, 33 | "{duration}" 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ui/src/components/album/track_list.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use dioxus::prelude::*; 4 | use shared::musicbrainz::Track; 5 | 6 | use crate::{album::track_item::TrackItem, Checkbox}; 7 | 8 | #[derive(Props, PartialEq, Clone)] 9 | pub struct Props { 10 | tracks: Signal>, 11 | selected_tracks: Signal>, 12 | on_toggle_select_all: EventHandler, 13 | on_track_toggle: EventHandler, 14 | all_selected: bool, 15 | } 16 | 17 | #[component] 18 | pub fn TrackList(props: Props) -> Element { 19 | rsx! { 20 | ul { class: "list-none p-4 space-y-2 overflow-y-auto", 21 | li { 22 | class: "flex items-center gap-3 p-2 rounded-md cursor-pointer hover:bg-white/10 transition-colors", 23 | onclick: move |_| props.on_toggle_select_all.call(()), 24 | Checkbox { is_selected: props.all_selected } 25 | span { class: "font-bold text-white font-mono text-sm", "Select / Deselect All" } 26 | } 27 | for track in props.tracks.read().iter() { 28 | TrackItem { 29 | key: "{track.id}", 30 | track: track.clone(), 31 | is_selected: props.selected_tracks.read().contains(&track.id), 32 | on_toggle: props.on_track_toggle, 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "api" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | dioxus = { workspace = true, features = ["fullstack"] } 8 | soulbeet = { workspace = true, optional = true } 9 | shared = { workspace = true } 10 | chrono = { version = "0.4.42", features = ["wasm-bindgen"] } 11 | serde = { version = "1.0.226", features = ["derive"] } 12 | tower-cookies = { version = "0.11.0", optional = true } 13 | jsonwebtoken = { version = "10.2.0", features = [ 14 | "rust_crypto", 15 | ], optional = true } 16 | sqlx = { version = "0.8.6", features = [ 17 | "runtime-tokio-rustls", 18 | "sqlite", 19 | "macros", 20 | "migrate", 21 | "uuid", 22 | ], optional = true } 23 | argon2 = { version = "0.5.3", optional = true } 24 | uuid = { version = "1.19.0", features = ["v4", "serde"], optional = true } 25 | rand = { version = "0.9.2", optional = true } 26 | tokio = { version = "1.48.0", features = [ 27 | "rt-multi-thread", 28 | "fs", 29 | ], optional = true } 30 | tracing = "0.1.41" 31 | axum = { version = "0.8.7", optional = true } 32 | 33 | [build-dependencies] 34 | dotenvy = "0.15.7" 35 | 36 | [features] 37 | default = [] 38 | server = [ 39 | "dep:soulbeet", 40 | "dep:sqlx", 41 | "dep:tokio", 42 | "dep:uuid", 43 | "dep:argon2", 44 | "dep:rand", 45 | "dep:jsonwebtoken", 46 | "dep:tower-cookies", 47 | "dep:axum", 48 | ] 49 | -------------------------------------------------------------------------------- /api/src/server_fns/guard.rs: -------------------------------------------------------------------------------- 1 | use crate::auth::{self, Claims}; 2 | 3 | #[cfg(feature = "server")] 4 | use axum::{extract::FromRequestParts, http::StatusCode}; 5 | 6 | pub struct AuthSession(pub Claims); 7 | 8 | #[cfg(feature = "server")] 9 | impl FromRequestParts for AuthSession 10 | where 11 | S: Send + Sync, 12 | { 13 | type Rejection = (axum::http::StatusCode, String); 14 | 15 | async fn from_request_parts( 16 | parts: &mut axum::http::request::Parts, 17 | _state: &S, 18 | ) -> Result { 19 | let cookies = parts 20 | .extensions 21 | .get::() 22 | .ok_or_else(|| { 23 | ( 24 | StatusCode::UNAUTHORIZED, 25 | String::from("Missing cookie middleware"), 26 | ) 27 | })?; 28 | 29 | let token = cookies 30 | .get(crate::AUTH_COOKIE_NAME) 31 | .map(|c| c.value().to_string()); 32 | 33 | match token { 34 | Some(token) => match auth::verify_token(&token) { 35 | Ok(claims) => Ok(AuthSession(claims)), 36 | Err(e) => Err((StatusCode::UNAUTHORIZED, format!("Invalid token: {}", e))), 37 | }, 38 | None => Err((StatusCode::UNAUTHORIZED, "No auth token found".to_string())), 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ui/src/components/search/album.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use shared::musicbrainz::Album; 3 | 4 | use crate::CoverArt; 5 | 6 | #[derive(Props, PartialEq, Clone)] 7 | pub struct Props { 8 | pub album: Album, 9 | pub on_click: EventHandler, 10 | } 11 | 12 | #[component] 13 | pub fn AlbumResult(props: Props) -> Element { 14 | let album_id = props.album.id.clone(); 15 | let album = &props.album; 16 | 17 | let cover_art_url = format!("https://coverartarchive.org/release/{}/front-250", album_id); 18 | let alt_text = format!("Album cover for {}", album.title); 19 | 20 | rsx! { 21 | div { 22 | onclick: move |_| props.on_click.call(album_id.clone()), 23 | class: "bg-white/5 border border-white/5 p-4 rounded-lg hover:border-beet-accent/50 hover:bg-white/10 transition-all duration-200 flex items-center gap-4 cursor-pointer group", 24 | 25 | CoverArt { src: cover_art_url, alt: alt_text } 26 | 27 | div { class: "flex-grow flex flex-col justify-center", 28 | h5 { class: "text-lg font-bold text-white group-hover:text-beet-accent transition-colors", 29 | "{album.title}" 30 | } 31 | p { class: "text-md text-gray-400 font-mono", "{album.artist}" } 32 | if let Some(release_date) = &album.release_date { 33 | p { class: "text-sm text-gray-500 mt-1 font-mono", "{release_date}" } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /mobile/src/main.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | use ui::{Navbar, SearchReset}; 4 | use views::Home; 5 | 6 | mod views; 7 | 8 | #[derive(Debug, Clone, Routable, PartialEq)] 9 | #[rustfmt::skip] 10 | enum Route { 11 | #[layout(MobileNavbar)] 12 | #[route("/")] 13 | Home {}, 14 | } 15 | 16 | const MAIN_CSS: Asset = asset!("/assets/main.css"); 17 | 18 | fn main() { 19 | dioxus::launch(App); 20 | } 21 | 22 | #[component] 23 | fn App() -> Element { 24 | // Build cool things ✌️ 25 | 26 | rsx! { 27 | // Global app resources 28 | document::Link { rel: "stylesheet", href: MAIN_CSS } 29 | 30 | Router:: {} 31 | } 32 | } 33 | 34 | /// A mobile-specific Router around the shared `Navbar` component 35 | /// which allows us to use the mobile-specific `Route` enum. 36 | #[component] 37 | fn MobileNavbar() -> Element { 38 | let mut search_reset = use_signal(|| 0); 39 | 40 | use_context_provider(|| SearchReset(search_reset)); 41 | 42 | rsx! { 43 | Navbar { 44 | Link { 45 | class: "text-gray-300 hover:text-teal-400 hover:bg-white/5 px-3 py-2 rounded-md text-sm font-medium transition-colors", 46 | to: Route::Home {}, 47 | onclick: move |_| search_reset += 1, 48 | "Search" 49 | } 50 | } 51 | 52 | main { class: "pt-24 pb-12 min-h-screen bg-gray-900", 53 | div { class: "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", Outlet:: {} } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /ui/src/components/cover_art.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | #[derive(Props, PartialEq, Clone)] 4 | pub struct CoverArtProps { 5 | /// The source URL for the image. 6 | pub src: String, 7 | /// The alt text for accessibility. 8 | pub alt: String, 9 | } 10 | 11 | #[component] 12 | pub fn CoverArt(props: CoverArtProps) -> Element { 13 | let mut has_error = use_signal(|| false); 14 | 15 | rsx! { 16 | div { class: "w-20 h-20 flex-shrink-0 bg-beet-panel border border-white/5 rounded-md flex items-center justify-center overflow-hidden", 17 | if !has_error() { 18 | img { 19 | src: "{props.src}", 20 | alt: "{props.alt}", 21 | class: "w-full h-full object-cover", 22 | onerror: move |_| has_error.set(true), 23 | } 24 | } else { 25 | svg { 26 | class: "w-8 h-8 text-white/20", 27 | xmlns: "http://www.w3.org/2000/svg", 28 | fill: "none", 29 | "viewBox": "0 0 24 24", 30 | "stroke-width": "1.5", 31 | stroke: "currentColor", 32 | path { 33 | "stroke-linecap": "round", 34 | "stroke-linejoin": "round", 35 | d: "M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z", 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /scripts/bump_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | BUMP_TYPE=$1 6 | 7 | if [ -z "$BUMP_TYPE" ]; then 8 | echo "Usage: $0 " 9 | exit 1 10 | fi 11 | 12 | # get current version 13 | get_version() { 14 | grep '^version =' api/Cargo.toml | head -n 1 | cut -d '"' -f 2 15 | } 16 | 17 | CURRENT_VERSION=$(get_version) 18 | IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" 19 | 20 | if [ "$BUMP_TYPE" == "major" ]; then 21 | MAJOR=$((MAJOR + 1)) 22 | MINOR=0 23 | PATCH=0 24 | elif [ "$BUMP_TYPE" == "minor" ]; then 25 | MINOR=$((MINOR + 1)) 26 | PATCH=0 27 | elif [ "$BUMP_TYPE" == "patch" ]; then 28 | PATCH=$((PATCH + 1)) 29 | else 30 | echo "Invalid bump type. Use patch, minor, or major." 31 | exit 1 32 | fi 33 | 34 | NEW_VERSION="$MAJOR.$MINOR.$PATCH" 35 | echo "Bumping version from $CURRENT_VERSION to $NEW_VERSION" 36 | 37 | # we don't care about atomic versioning, set everything to the same version 38 | FILES=( 39 | "desktop/Cargo.toml" 40 | "mobile/Cargo.toml" 41 | "api/Cargo.toml" 42 | "lib/soulbeet/Cargo.toml" 43 | "lib/shared/Cargo.toml" 44 | "web/Cargo.toml" 45 | "ui/Cargo.toml" 46 | ) 47 | 48 | for FILE in "${FILES[@]}"; do 49 | if [ -f "$FILE" ]; then 50 | sed -i "s/^version = \"$CURRENT_VERSION\"/version = \"$NEW_VERSION\"/" "$FILE" 51 | echo "Updated $FILE" 52 | else 53 | echo "Warning: $FILE not found" 54 | fi 55 | done 56 | 57 | # for GitHub Actions 58 | if [ -n "$GITHUB_OUTPUT" ]; then 59 | echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT 60 | fi -------------------------------------------------------------------------------- /lib/soulbeet/src/beets/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{io::Result, path::Path}; 2 | use tokio::process::Command; 3 | use tracing::info; 4 | 5 | pub enum ImportResult { 6 | Success, 7 | Skipped, 8 | Failed(String), 9 | } 10 | 11 | pub async fn import(sources: Vec, target: &Path) -> Result { 12 | let config_path = 13 | std::env::var("BEETS_CONFIG").unwrap_or_else(|_| "beets_config.yaml".to_string()); 14 | 15 | info!( 16 | "Starting beet import for {} items to {:?} using config {}", 17 | sources.len(), 18 | target, 19 | config_path 20 | ); 21 | 22 | let mut cmd = Command::new("beet"); 23 | cmd.arg("-c") 24 | .arg(&config_path) 25 | .arg("-d") // destination directory 26 | .arg(target) 27 | .arg("import") 28 | .arg("-s") // singleton mode 29 | .arg("-q"); // quiet mode: do not ask for confirmation 30 | 31 | for source in sources { 32 | cmd.arg(source); 33 | } 34 | 35 | let output = cmd.output().await?; 36 | 37 | if output.status.success() { 38 | let stdout = String::from_utf8_lossy(&output.stdout); 39 | if stdout.contains("Skipping") { 40 | info!("Beet import skipped items"); 41 | Ok(ImportResult::Skipped) 42 | } else { 43 | info!("Beet import successful"); 44 | Ok(ImportResult::Success) 45 | } 46 | } else { 47 | let stderr = String::from_utf8_lossy(&output.stderr); 48 | info!("Beet import failed: {}", stderr); 49 | Ok(ImportResult::Failed(stderr.to_string())) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /api/src/server_fns/folder.rs: -------------------------------------------------------------------------------- 1 | use crate::models; 2 | use dioxus::prelude::*; 3 | 4 | #[cfg(feature = "server")] 5 | use crate::AuthSession; 6 | 7 | #[get("/api/folders", auth: AuthSession)] 8 | pub async fn get_user_folders() -> Result, ServerFnError> { 9 | let claims = auth.0; 10 | 11 | models::folder::Folder::get_all_by_user(&claims.sub) 12 | .await 13 | .map_err(|e| ServerFnError::new(e.to_string())) 14 | } 15 | 16 | #[post("/api/folders", auth: AuthSession)] 17 | pub async fn create_user_folder( 18 | name: String, 19 | path: String, 20 | ) -> Result { 21 | let claims = auth.0; 22 | 23 | if let Err(e) = tokio::fs::create_dir_all(&path).await { 24 | return Err(ServerFnError::new(format!( 25 | "Failed to create directory: {}", 26 | e 27 | ))); 28 | } 29 | 30 | models::folder::Folder::create(&claims.sub, &name, &path) 31 | .await 32 | .map_err(|e| ServerFnError::new(e.to_string())) 33 | } 34 | 35 | #[put("/api/folders/update", _: AuthSession)] 36 | pub async fn update_folder( 37 | folder_id: String, 38 | name: String, 39 | path: String, 40 | ) -> Result<(), ServerFnError> { 41 | models::folder::Folder::update(&folder_id, &name, &path) 42 | .await 43 | .map_err(|e| ServerFnError::new(e.to_string())) 44 | } 45 | 46 | #[delete("/api/folders/delete", _: AuthSession)] 47 | pub async fn delete_folder(folder_id: String) -> Result<(), ServerFnError> { 48 | models::folder::Folder::delete(&folder_id) 49 | .await 50 | .map_err(|e| ServerFnError::new(e.to_string())) 51 | } 52 | -------------------------------------------------------------------------------- /ui/src/navbar.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | #[component] 4 | pub fn Navbar(children: Element) -> Element { 5 | rsx! { 6 | header { class: "px-4 sm:px-6 lg:px-8 flex justify-between items-center py-6 border-b border-white/5", 7 | // Logo area 8 | div { class: "flex items-center gap-3 group cursor-default", 9 | div { class: "w-10 h-10 bg-beet-accent rounded-sm flex items-center justify-center shadow-[0_0_15px_rgba(217,70,239,0.5)] group-hover:rotate-12 transition-transform", 10 | svg { 11 | class: "w-6 h-6 text-white", 12 | fill: "none", 13 | stroke: "currentColor", 14 | view_box: "0 0 24 24", 15 | path { 16 | stroke_linecap: "round", 17 | stroke_linejoin: "round", 18 | stroke_width: "2", 19 | d: "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3", 20 | } 21 | } 22 | } 23 | h1 { class: "hidden md:block text-2xl font-bold tracking-tighter uppercase text-transparent bg-clip-text bg-gradient-to-r from-white to-gray-400", 24 | "Soulbeet" 25 | } 26 | } 27 | 28 | // Menu 29 | nav { class: "flex items-center gap-8 bg-beet-panel/50 px-6 py-2 rounded-full border border-white/5 backdrop-blur-sm", 30 | {children} 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | soulbeet: 3 | build: . 4 | container_name: soulbeet 5 | restart: unless-stopped 6 | ports: 7 | - 9765:9765 8 | environment: 9 | - DATABASE_URL=sqlite:/data/soulbeet.db 10 | - SLSKD_URL=http://slskd:5030 11 | - SLSKD_API_KEY=your_slskd_api_key_here 12 | # This path must match the volume mapping for downloads inside the container 13 | - SLSKD_DOWNLOAD_PATH=/downloads 14 | - SECRET_KEY=a_secret_key # Change this to a secure random key 15 | volumes: 16 | - soulbeet_data:/data 17 | # Map the download folder. 18 | # IMPORTANT: This must be the SAME physical folder that slskd writes to. 19 | - /path/to/your/downloads:/downloads 20 | # Map your music libraries where beets will move files 21 | - /path/to/your/music:/music 22 | # Mount the beets config 23 | # Leave commented to use the default configuration 24 | # - ./beets_config.yaml:/app/beets_config.yaml 25 | # depends_on: 26 | # - slskd 27 | # Optional: Slskd service if you want to run it together 28 | # If you already have slskd running elsewhere, ensure Soulbeet can reach it via network 29 | # slskd: 30 | # image: slskd/slskd 31 | # container_name: slskd 32 | # environment: 33 | # - SLSKD_REMOTE_CONFIGURATION=true 34 | # - SLSKD_SLSK_USERNAME=xxx 35 | # - SLSKD_SLSK_PASSWORD=xxx 36 | # volumes: 37 | # - /path/to/your/slskd_data:/app 38 | # - /path/to/your/downloads:/app/downloads 39 | # ports: 40 | # - "5030:5030" 41 | # restart: unless-stopped 42 | 43 | volumes: 44 | soulbeet_data: 45 | -------------------------------------------------------------------------------- /ui/src/components/search/track.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use shared::musicbrainz::Track; 3 | 4 | #[derive(Props, PartialEq, Clone)] 5 | pub struct Props { 6 | pub track: Track, 7 | pub on_album_click: EventHandler, 8 | } 9 | 10 | #[component] 11 | pub fn TrackResult(props: Props) -> Element { 12 | let track = props.track.clone(); 13 | 14 | rsx! { 15 | div { class: "bg-white/5 border border-white/5 p-4 rounded-lg hover:border-beet-accent/50 hover:bg-white/10 transition-all duration-200 group", 16 | 17 | div { class: "flex justify-between items-center", 18 | 19 | div { 20 | h5 { class: "text-lg font-bold text-white group-hover:text-beet-accent transition-colors", 21 | "{track.title}" 22 | } 23 | p { class: "text-md text-gray-400 font-mono", "{track.artist}" } 24 | 25 | if let (Some(album_title), Some(album_id)) = (&track.album_title, &track.album_id) { 26 | { 27 | let album_id = album_id.clone(); 28 | rsx! { 29 | p { 30 | class: "text-sm text-gray-500 italic cursor-pointer hover:text-beet-leaf transition-colors mt-1", 31 | onclick: move |_| props.on_album_click.call(album_id.clone()), 32 | "from \"{album_title}\"" 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | if let Some(duration) = &track.duration { 40 | p { class: "text-sm font-mono text-gray-500 whitespace-nowrap pl-4", 41 | "{duration}" 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | bump_type: 7 | description: 'Version bump type' 8 | required: true 9 | default: 'patch' 10 | type: choice 11 | options: 12 | - patch 13 | - minor 14 | - major 15 | 16 | jobs: 17 | bump-version: 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: write 21 | outputs: 22 | new_version: ${{ steps.bump.outputs.new_version }} 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | with: 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Configure Git 30 | run: | 31 | git config user.name "GitHub Action" 32 | git config user.email "action@github.com" 33 | 34 | - name: Bump Version 35 | id: bump 36 | run: | 37 | ./scripts/bump_version.sh ${{ inputs.bump_type }} 38 | cargo check || true 39 | 40 | - name: Commit and Push 41 | run: | 42 | git add . 43 | git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }}" 44 | git push 45 | 46 | - name: Create Tag 47 | run: | 48 | git tag v${{ steps.bump.outputs.new_version }} 49 | git push origin v${{ steps.bump.outputs.new_version }} 50 | 51 | call-build-and-push: 52 | needs: bump-version 53 | uses: ./.github/workflows/image-build-push.yml 54 | with: 55 | ref: v${{ needs.bump-version.outputs.new_version }} 56 | tags: | 57 | type=raw,value=latest 58 | type=raw,value=${{ needs.bump-version.outputs.new_version }} 59 | secrets: inherit -------------------------------------------------------------------------------- /lib/shared/src/musicbrainz.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// Represents a search result which can be either a track or an album. 4 | /// The `kind` tag is used by serde to distinguish between the variants. 5 | #[derive(Debug, Serialize, Deserialize)] 6 | #[serde(tag = "kind")] 7 | pub enum SearchResult { 8 | Track(Track), 9 | Album(Album), 10 | } 11 | 12 | /// A detailed structure to hold search results for a track. 13 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 14 | pub struct Track { 15 | /// The MusicBrainz Identifier (MBID). 16 | pub id: String, 17 | /// The title of the track. 18 | pub title: String, 19 | /// A formatted string of the artist(s). 20 | pub artist: String, 21 | /// The MusicBrainz Identifier of the album the track belongs to. 22 | pub album_id: Option, 23 | /// The title of the album the track belongs to. 24 | pub album_title: Option, 25 | /// The release date of the album (YYYY-MM-DD). 26 | pub release_date: Option, 27 | /// The duration of the track in a formatted MM:SS string. 28 | pub duration: Option, 29 | } 30 | 31 | /// A detailed structure to hold search results for an album. 32 | #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] 33 | pub struct Album { 34 | /// The MusicBrainz Identifier (MBID). 35 | pub id: String, 36 | /// The title of the album. 37 | pub title: String, 38 | /// A formatted string of the artist(s). 39 | pub artist: String, 40 | /// The release date of the album (YYYY-MM-DD). 41 | pub release_date: Option, 42 | } 43 | 44 | #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] 45 | pub struct AlbumWithTracks { 46 | pub album: Album, 47 | pub tracks: Vec, 48 | } 49 | -------------------------------------------------------------------------------- /ui/src/components/simple/button.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | #[derive(Clone, PartialEq, Default)] 4 | pub enum ButtonVariant { 5 | #[default] 6 | Primary, 7 | Secondary, 8 | } 9 | 10 | impl ButtonVariant { 11 | fn get_classes(&self) -> &'static str { 12 | match self { 13 | ButtonVariant::Primary => "retro-btn", 14 | // Keep secondary distinctive or map to same if unused? Let's make it a outlined version or just dimmer 15 | ButtonVariant::Secondary => "font-mono uppercase text-xs tracking-widest px-6 py-3 border border-white/10 text-gray-400 transition-all duration-200 hover:bg-white/5 hover:text-white cursor-pointer", 16 | } 17 | } 18 | } 19 | 20 | #[derive(Props, Clone, PartialEq)] 21 | pub struct Props { 22 | children: Element, 23 | #[props(into)] 24 | onclick: EventHandler, 25 | #[props(optional, default)] 26 | variant: ButtonVariant, 27 | #[props(optional, default)] 28 | disabled: bool, 29 | #[props(optional, into)] 30 | class: String, 31 | } 32 | 33 | #[component] 34 | pub fn Button(props: Props) -> Element { 35 | let variant_classes = props.variant.get_classes(); 36 | let disabled_classes = if props.disabled { 37 | "opacity-30 cursor-not-allowed grayscale pointer-events-none" 38 | } else { 39 | "" 40 | }; 41 | let additional_classes = props.class; 42 | 43 | rsx! { 44 | button { 45 | class: "{variant_classes} {disabled_classes} {additional_classes} rounded", 46 | onclick: move |evt| { 47 | if !props.disabled { 48 | props.onclick.call(evt) 49 | } 50 | }, 51 | disabled: props.disabled, 52 | {props.children} 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/image-build-push.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | tags: [ 'v*.*.*' ] 7 | workflow_dispatch: 8 | workflow_call: 9 | inputs: 10 | ref: 11 | description: "Git reference to checkout" 12 | required: false 13 | type: string 14 | tags: 15 | description: "Extra tags" 16 | required: false 17 | type: string 18 | 19 | env: 20 | REGISTRY: docker.io 21 | IMAGE_NAME: docccccc/soulbeet 22 | 23 | jobs: 24 | build-and-push: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | ref: ${{ inputs.ref || github.ref }} 32 | 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v3 35 | 36 | - name: Log in to Docker Hub 37 | uses: docker/login-action@v3 38 | with: 39 | registry: ${{ env.REGISTRY }} 40 | username: ${{ secrets.DOCKER_USERNAME }} 41 | password: ${{ secrets.DOCKER_PASSWORD }} 42 | 43 | - name: Extract metadata (tags, labels) 44 | id: meta 45 | uses: docker/metadata-action@v5 46 | with: 47 | images: ${{ env.IMAGE_NAME }} 48 | tags: | 49 | type=ref,event=branch 50 | type=ref,event=pr 51 | type=semver,pattern={{version}} 52 | type=sha 53 | ${{ inputs.tags }} 54 | 55 | - name: Build and push Docker image 56 | uses: docker/build-push-action@v6 57 | with: 58 | context: . 59 | push: ${{ github.event_name != 'pull_request' }} 60 | tags: ${{ steps.meta.outputs.tags }} 61 | labels: ${{ steps.meta.outputs.labels }} 62 | cache-from: type=gha 63 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /api/src/auth.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] 4 | pub struct AuthResponse { 5 | pub username: String, 6 | pub user_id: String, 7 | } 8 | 9 | #[cfg(feature = "server")] 10 | use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; 11 | #[cfg(feature = "server")] 12 | use std::env; 13 | 14 | pub static EXPIRATION_DAYS: i64 = 30; 15 | 16 | #[derive(Debug, Serialize, Deserialize)] 17 | pub struct Claims { 18 | pub sub: String, // user_id 19 | pub username: String, 20 | pub iat: usize, 21 | pub exp: usize, 22 | } 23 | 24 | #[cfg(feature = "server")] 25 | pub fn create_token(user_id: String, username: String) -> Result { 26 | let secret = env::var("SECRET_KEY").unwrap_or_else(|_| "secret".to_string()); 27 | let encoding_key = EncodingKey::from_secret(secret.as_bytes()); 28 | let now = chrono::Utc::now(); 29 | let iat = now.timestamp() as usize; 30 | 31 | let exp = now 32 | .checked_add_signed(chrono::Duration::days(EXPIRATION_DAYS)) 33 | .expect("valid timestamp") 34 | .timestamp(); 35 | 36 | let claims = Claims { 37 | sub: user_id.clone(), 38 | username: username.clone(), 39 | iat, 40 | exp: exp as usize, 41 | }; 42 | 43 | let token = encode(&Header::default(), &claims, &encoding_key).map_err(|e| e.to_string())?; 44 | 45 | Ok(token) 46 | } 47 | 48 | #[cfg(feature = "server")] 49 | pub fn verify_token(token: &str) -> Result { 50 | let secret = env::var("SECRET_KEY").unwrap_or_else(|_| "secret".to_string()); 51 | 52 | let token_data = decode::( 53 | token, 54 | &DecodingKey::from_secret(secret.as_bytes()), 55 | &Validation::default(), 56 | ) 57 | .map_err(|e| e.to_string())?; 58 | 59 | Ok(token_data.claims) 60 | } 61 | -------------------------------------------------------------------------------- /ui/src/auth.rs: -------------------------------------------------------------------------------- 1 | use api::auth::AuthResponse; 2 | use dioxus::prelude::*; 3 | 4 | #[derive(Clone, Copy, Debug)] 5 | pub struct Auth { 6 | state: Signal>, 7 | } 8 | 9 | impl Auth { 10 | pub fn new(state: Signal>) -> Self { 11 | Self { state } 12 | } 13 | 14 | pub fn login(&mut self, response: AuthResponse) { 15 | self.state.set(Some(response)); 16 | } 17 | 18 | pub async fn logout(&mut self) { 19 | let _ = api::logout().await; 20 | self.state.set(None); 21 | } 22 | 23 | /// Check if a server error is an authentication error. 24 | /// If it is, logs the user out locally. 25 | /// Returns true if the error was handled (user logged out), false otherwise. 26 | pub fn handle_error(&mut self, error: &ServerFnError) -> bool { 27 | if let ServerFnError::ServerError { code: 401, .. } = error { 28 | self.state.set(None); 29 | return true; 30 | } 31 | false 32 | } 33 | 34 | /// Wraps a server function call to automatically handle authentication errors. 35 | pub async fn call( 36 | mut self, 37 | fut: impl std::future::Future>, 38 | ) -> Result { 39 | match fut.await { 40 | Ok(val) => Ok(val), 41 | Err(e) => { 42 | self.handle_error(&e); 43 | Err(e) 44 | } 45 | } 46 | } 47 | 48 | pub fn user_id(&self) -> Option { 49 | self.state.read().as_ref().map(|a| a.user_id.clone()) 50 | } 51 | 52 | pub fn username(&self) -> Option { 53 | self.state.read().as_ref().map(|a| a.username.clone()) 54 | } 55 | 56 | pub fn is_logged_in(&self) -> bool { 57 | self.state.read().is_some() 58 | } 59 | } 60 | 61 | pub fn use_auth() -> Auth { 62 | use_context::() 63 | } 64 | -------------------------------------------------------------------------------- /desktop/src/main.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | use ui::{Downloads, Navbar, SearchReset}; 4 | use views::Home; 5 | 6 | mod views; 7 | 8 | #[derive(Debug, Clone, Routable, PartialEq)] 9 | #[rustfmt::skip] 10 | enum Route { 11 | #[layout(DesktopNavbar)] 12 | #[route("/")] 13 | Home {}, 14 | } 15 | 16 | const MAIN_CSS: Asset = asset!("/assets/main.css"); 17 | 18 | fn main() { 19 | dioxus::launch(App); 20 | } 21 | 22 | #[component] 23 | fn App() -> Element { 24 | // Build cool things ✌️ 25 | 26 | rsx! { 27 | // Global app resources 28 | document::Link { rel: "stylesheet", href: MAIN_CSS } 29 | 30 | Router:: {} 31 | } 32 | } 33 | 34 | /// A desktop-specific Router around the shared `Navbar` component 35 | /// which allows us to use the desktop-specific `Route` enum. 36 | #[component] 37 | fn DesktopNavbar() -> Element { 38 | let mut downloads_open = use_signal(|| false); 39 | let mut search_reset = use_signal(|| 0); 40 | use_context_provider(|| SearchReset(search_reset)); 41 | 42 | rsx! { 43 | Navbar { 44 | Link { 45 | class: "text-gray-300 hover:text-teal-400 hover:bg-white/5 px-3 py-2 rounded-md text-sm font-medium transition-colors", 46 | to: Route::Home {}, 47 | onclick: move |_| search_reset += 1, 48 | "Home" 49 | } 50 | button { 51 | class: "text-gray-300 hover:text-teal-400 hover:bg-white/5 px-3 py-2 rounded-md text-sm font-medium transition-colors ml-4", 52 | onclick: move |_| downloads_open.set(!downloads_open()), 53 | "Downloads" 54 | } 55 | } 56 | 57 | main { class: "pt-24 pb-12 min-h-screen bg-gray-900", 58 | div { class: "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8", Outlet:: {} } 59 | } 60 | Downloads { is_open: downloads_open } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /api/src/models/folder.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "server")] 2 | use crate::db::DB; 3 | use serde::{Deserialize, Serialize}; 4 | #[cfg(feature = "server")] 5 | use uuid::Uuid; 6 | 7 | #[derive(Clone, Debug, Serialize, Deserialize)] 8 | #[cfg_attr(feature = "server", derive(sqlx::FromRow))] 9 | pub struct Folder { 10 | pub id: String, 11 | pub user_id: String, 12 | pub name: String, 13 | pub path: String, 14 | } 15 | 16 | #[cfg(feature = "server")] 17 | impl Folder { 18 | pub async fn create(user_id: &str, name: &str, path: &str) -> Result { 19 | let id = Uuid::new_v4().to_string(); 20 | 21 | let folder = sqlx::query_as::<_, Folder>( 22 | "INSERT INTO folders (id, user_id, name, path) VALUES (?, ?, ?, ?) RETURNING *", 23 | ) 24 | .bind(&id) 25 | .bind(user_id) 26 | .bind(name) 27 | .bind(path) 28 | .fetch_one(&*DB) 29 | .await 30 | .map_err(|e| e.to_string())?; 31 | 32 | Ok(folder) 33 | } 34 | 35 | pub async fn get_all_by_user(user_id: &str) -> Result, String> { 36 | sqlx::query_as::<_, Folder>("SELECT * FROM folders WHERE user_id = ?") 37 | .bind(user_id) 38 | .fetch_all(&*DB) 39 | .await 40 | .map_err(|e| e.to_string()) 41 | } 42 | 43 | pub async fn update(id: &str, name: &str, path: &str) -> Result<(), String> { 44 | sqlx::query("UPDATE folders SET name = ?, path = ? WHERE id = ?") 45 | .bind(name) 46 | .bind(path) 47 | .bind(id) 48 | .execute(&*DB) 49 | .await 50 | .map_err(|e| e.to_string())?; 51 | Ok(()) 52 | } 53 | 54 | pub async fn delete(id: &str) -> Result<(), String> { 55 | sqlx::query("DELETE FROM folders WHERE id = ?") 56 | .bind(id) 57 | .execute(&*DB) 58 | .await 59 | .map_err(|e| e.to_string())?; 60 | Ok(()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /ui/src/components/modal.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | 3 | #[derive(Props, PartialEq, Clone)] 4 | pub struct Props { 5 | /// A signal to control the visibility of the modal 6 | pub on_close: EventHandler, 7 | /// The content to be displayed inside the modal 8 | pub children: Element, 9 | /// The header of the modal 10 | pub header: Element, 11 | } 12 | 13 | #[component] 14 | pub fn Modal(props: Props) -> Element { 15 | rsx! { 16 | // Backdrop 17 | div { 18 | class: "fixed inset-0 bg-black/60 backdrop-blur-sm z-40 transition-opacity", 19 | onclick: move |_| props.on_close.call(()), 20 | } 21 | 22 | // Container 23 | div { 24 | class: "fixed inset-0 flex items-center justify-center z-50 pointer-events-none", 25 | onclick: move |_| props.on_close.call(()), 26 | 27 | // Content 28 | div { 29 | class: "bg-beet-panel border border-white/10 max-h-[85vh] overflow-hidden flex flex-col rounded-lg shadow-2xl max-w-lg w-full pointer-events-auto transform transition-all", 30 | onclick: move |event| event.stop_propagation(), 31 | // Header Container 32 | div { class: "flex items-center justify-between p-4 border-b border-white/10 bg-black/20", 33 | div { class: "flex-1 min-w-0", {props.header} } 34 | button { 35 | class: "text-gray-400 hover:text-white transition-colors ml-4 cursor-pointer", 36 | onclick: move |_| props.on_close.call(()), 37 | // Close icon SVG 38 | svg { 39 | class: "w-6 h-6", 40 | fill: "none", 41 | view_box: "0 0 24 24", 42 | stroke: "currentColor", 43 | path { 44 | stroke_linecap: "round", 45 | stroke_linejoin: "round", 46 | stroke_width: "2", 47 | d: "M6 18L18 6M6 6l12 12", 48 | } 49 | } 50 | } 51 | } 52 | // Scrollable Body 53 | div { class: "overflow-y-auto p-4 scrollbar-thin scrollbar-thumb-beet-accent/50 scrollbar-track-transparent", 54 | {props.children} 55 | } 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build Stage 2 | FROM rust:1.91-bookworm AS builder 3 | 4 | # Install build dependencies 5 | RUN apt-get update && apt-get install -y \ 6 | pkg-config \ 7 | libssl-dev \ 8 | nodejs \ 9 | npm 10 | 11 | # Install Dioxus CLI 12 | RUN cargo install dioxus-cli 13 | 14 | # Create app directory 15 | WORKDIR /app 16 | 17 | # Copy dependency files first for caching 18 | COPY Cargo.toml Cargo.lock ./ 19 | COPY api/Cargo.toml api/ 20 | COPY desktop/Cargo.toml desktop/ 21 | COPY mobile/Cargo.toml mobile/ 22 | COPY ui/Cargo.toml ui/ 23 | COPY web/Cargo.toml web/ 24 | COPY lib/shared/Cargo.toml lib/shared/ 25 | COPY lib/soulbeet/Cargo.toml lib/soulbeet/ 26 | 27 | # Copy source code 28 | COPY . . 29 | 30 | # Install Tailwind dependencies 31 | RUN npm install 32 | 33 | # Build the Tailwind CSS 34 | RUN npx @tailwindcss/cli -i ./web/assets/input.css -o ./web/assets/tailwind.css 35 | 36 | # Build the application 37 | RUN dx bundle --package web --release 38 | 39 | # Create an empty directory for data to be copied to runtime 40 | RUN mkdir -p /empty_data 41 | 42 | FROM python:3.11-slim-bookworm AS beets-builder 43 | 44 | RUN apt-get update && apt-get install -y --no-install-recommends \ 45 | build-essential \ 46 | && rm -rf /var/lib/apt/lists/* 47 | 48 | # Create a virtual environment for beets 49 | ENV VIRTUAL_ENV=/opt/venv 50 | RUN python3 -m venv $VIRTUAL_ENV 51 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 52 | 53 | # Install beets and dependencies 54 | RUN pip install --no-cache-dir wheel 55 | RUN pip install --no-cache-dir beets requests musicbrainzngs 56 | 57 | # rewrite shebang line in the executable script. 58 | RUN sed -i '1s|^.*$|#!/usr/bin/python3|' $VIRTUAL_ENV/bin/beet 59 | 60 | # --- RUNTIME STAGE --- 61 | FROM gcr.io/distroless/python3-debian12 62 | 63 | # COPY --from=docker.io/mwader/static-ffmpeg:8.0.1 /ffmpeg /usr/local/bin/ffmpeg 64 | # COPY --from=docker.io/mwader/static-ffmpeg:8.0.1 /ffprobe /usr/local/bin/ffprobe 65 | 66 | # Copy beets virtual environment 67 | COPY --from=beets-builder /opt/venv /opt/venv 68 | 69 | # Working directory 70 | WORKDIR /app 71 | 72 | # Copy artifacts from builder 73 | COPY --from=builder /app/target/dx/web/release/web /app/server 74 | COPY beets_config.yaml /app/beets_config.yaml 75 | 76 | # Copy empty data directory to ensure /data exists 77 | COPY --from=builder /empty_data /data 78 | 79 | ENV PATH="/opt/venv/bin:/usr/local/bin:$PATH" 80 | 81 | ENV PYTHONPATH="/opt/venv/lib/python3.11/site-packages" 82 | 83 | ENV DATABASE_URL=sqlite:/data/soulbeet.db 84 | ENV PORT=9765 85 | ENV IP=0.0.0.0 86 | 87 | # Expose the port 88 | EXPOSE 9765 89 | 90 | ENTRYPOINT ["/app/server/web"] -------------------------------------------------------------------------------- /api/src/server_fns/search.rs: -------------------------------------------------------------------------------- 1 | use chrono::Duration; 2 | use dioxus::prelude::*; 3 | use serde::{Deserialize, Serialize}; 4 | use shared::{ 5 | download::DownloadQuery, 6 | musicbrainz::{AlbumWithTracks, SearchResult}, 7 | slskd::SearchResponse, 8 | }; 9 | 10 | #[cfg(feature = "server")] 11 | use soulbeet::musicbrainz; 12 | 13 | use super::server_error; 14 | 15 | #[cfg(feature = "server")] 16 | use crate::AuthSession; 17 | 18 | #[cfg(feature = "server")] 19 | use crate::globals::SLSKD_CLIENT; 20 | 21 | static SLSKD_MAX_SEARCH_DURATION: i64 = 120; // seconds 22 | 23 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 24 | pub struct SearchQuery { 25 | pub artist: Option, 26 | pub query: String, 27 | } 28 | 29 | #[post("/api/musicbrainz/search/album", _: AuthSession)] 30 | pub async fn search_album(input: SearchQuery) -> Result, ServerFnError> { 31 | musicbrainz::search( 32 | &input.artist, 33 | &input.query, 34 | musicbrainz::SearchType::Album, 35 | 25, 36 | ) 37 | .await 38 | .map_err(server_error) 39 | } 40 | 41 | #[post("/api/musicbrainz/search/track", _: AuthSession)] 42 | pub async fn search_track(input: SearchQuery) -> Result, ServerFnError> { 43 | musicbrainz::search( 44 | &input.artist, 45 | &input.query, 46 | musicbrainz::SearchType::Track, 47 | 25, 48 | ) 49 | .await 50 | .map_err(server_error) 51 | } 52 | 53 | #[get("/api/musicbrainz/album/:id", _: AuthSession)] 54 | pub async fn find_album(id: String) -> Result { 55 | musicbrainz::find_album(&id).await.map_err(server_error) 56 | } 57 | 58 | #[post("/api/slskd/search/start", _: AuthSession)] 59 | pub async fn start_download_search(data: DownloadQuery) -> Result { 60 | let artist = data.album.artist; 61 | let album = data.album.title; 62 | let tracks = data.tracks; 63 | 64 | SLSKD_CLIENT 65 | .start_search( 66 | artist, 67 | album, 68 | tracks, 69 | Duration::seconds(SLSKD_MAX_SEARCH_DURATION), 70 | ) 71 | .await 72 | .map_err(server_error) 73 | } 74 | 75 | #[post("/api/slskd/search/poll", _: AuthSession)] 76 | pub async fn poll_download_search(search_id: String) -> Result { 77 | let (results, has_more, state) = SLSKD_CLIENT 78 | .poll_search(search_id.clone()) 79 | .await 80 | .map_err(server_error)?; 81 | 82 | Ok(SearchResponse { 83 | search_id, 84 | total_results: results.len(), 85 | results, 86 | has_more, 87 | state, 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /web/assets/input.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;500;700&display=swap'); 2 | @import "tailwindcss"; 3 | @source "../src/**/*.{rs,html,css}"; 4 | @source "../../ui/src/**/*.{rs,html,css}"; 5 | 6 | @theme { 7 | /* Soulbeet Color Palette */ 8 | --color-beet-dark: #0f0518; 9 | --color-beet-panel: #1a0b2e; 10 | --color-beet-accent: #ff00ff; /* Neon Pink */ 11 | --color-beet-leaf: #00ff9d; /* Cyber Green */ 12 | --color-beet-dim: #4a2b6b; 13 | 14 | /* Fonts */ 15 | --font-mono: "JetBrains Mono", monospace; 16 | --font-display: "Space Grotesk", sans-serif; 17 | 18 | /* Custom Animations */ 19 | --animate-glow: glow 2s ease-in-out infinite alternate; 20 | --animate-scanline: scanline 8s linear infinite; 21 | 22 | @keyframes glow { 23 | from { box-shadow: 0 0 5px var(--color-beet-accent), 0 0 10px var(--color-beet-accent); } 24 | to { box-shadow: 0 0 2px var(--color-beet-accent), 0 0 5px var(--color-beet-accent); } 25 | } 26 | @keyframes scanline { 27 | 0% { background-position: 0% 0%; } 28 | 100% { background-position: 0% 100%; } 29 | } 30 | } 31 | 32 | /* Custom Utilities */ 33 | @layer utilities { 34 | .glass-panel { 35 | @apply bg-beet-panel/80 backdrop-blur-md border border-white/10 shadow-xl; 36 | } 37 | 38 | .retro-btn { 39 | @apply font-mono uppercase text-xs tracking-widest px-6 py-3 border border-beet-leaf/30 text-beet-leaf 40 | transition-all duration-200 hover:bg-beet-leaf hover:text-beet-dark hover:shadow-[0_0_15px_rgba(0,255,157,0.6)] cursor-pointer; 41 | } 42 | 43 | .nav-link { 44 | @apply font-display text-sm text-gray-400 hover:text-white hover:scale-105 transition-transform duration-200; 45 | } 46 | 47 | .crt-overlay { 48 | background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06)); 49 | background-size: 100% 2px, 3px 100%; 50 | pointer-events: none; 51 | } 52 | 53 | .no-scrollbar::-webkit-scrollbar { 54 | display: none; 55 | } 56 | 57 | .no-scrollbar { 58 | -ms-overflow-style: none; 59 | scrollbar-width: none; 60 | } 61 | } 62 | 63 | html { 64 | @apply text-sm md:text-base; 65 | } 66 | 67 | /* Base Styles */ 68 | body { 69 | @apply bg-beet-dark text-white min-h-screen overflow-hidden font-display; 70 | background-image: 71 | linear-gradient(rgba(26, 11, 46, 0.5) 1px, transparent 1px), 72 | linear-gradient(90deg, rgba(26, 11, 46, 0.5) 1px, transparent 1px); 73 | background-size: 40px 40px; 74 | } 75 | -------------------------------------------------------------------------------- /api/src/server_fns/auth.rs: -------------------------------------------------------------------------------- 1 | use super::server_error; 2 | use crate::auth::{self, AuthResponse}; 3 | use crate::models; 4 | use dioxus::prelude::*; 5 | 6 | #[cfg(feature = "server")] 7 | use crate::AuthSession; 8 | 9 | #[cfg(feature = "server")] 10 | use tower_cookies::{ 11 | cookie::{time, SameSite}, 12 | Cookie, Cookies, 13 | }; 14 | 15 | pub const AUTH_COOKIE_NAME: &str = "auth_token"; 16 | 17 | /// Helper to configure the auth cookie consistently 18 | #[cfg(feature = "server")] 19 | fn build_auth_cookie(token: String) -> Cookie<'static> { 20 | use crate::auth::EXPIRATION_DAYS; 21 | 22 | let mut cookie = Cookie::new(AUTH_COOKIE_NAME, token); 23 | cookie.set_path("/"); 24 | cookie.set_http_only(true); 25 | cookie.set_same_site(SameSite::Lax); 26 | cookie.set_expires(time::OffsetDateTime::now_utc() + time::Duration::days(EXPIRATION_DAYS)); 27 | cookie 28 | } 29 | 30 | #[post("/api/auth/register")] 31 | pub async fn register(username: String, password: String) -> Result<(), ServerFnError> { 32 | models::user::User::create(&username, &password) 33 | .await 34 | .map_err(server_error) 35 | .map(|_| ()) 36 | } 37 | 38 | #[post("/api/auth/login", cookies: Cookies)] 39 | pub async fn login(username: String, password: String) -> Result { 40 | let user = match models::user::User::verify(&username, &password).await { 41 | Ok(user) => user, 42 | Err(e) => return Err(server_error(e)), 43 | }; 44 | 45 | let token = auth::create_token(user.id.clone(), user.username.clone()).map_err(server_error)?; 46 | 47 | cookies.add(build_auth_cookie(token)); 48 | 49 | Ok(AuthResponse { 50 | username: user.username, 51 | user_id: user.id, 52 | }) 53 | } 54 | 55 | #[post("/api/auth/refresh", auth: AuthSession, cookies: Cookies)] 56 | pub async fn refresh_token() -> Result<(), ServerFnError> { 57 | let claims = auth.0; 58 | 59 | let _ = models::user::User::get_by_id(&claims.sub) 60 | .await 61 | .map_err(server_error)?; 62 | 63 | let token = auth::create_token(claims.sub, claims.username).map_err(server_error)?; 64 | 65 | cookies.add(build_auth_cookie(token)); 66 | 67 | Ok(()) 68 | } 69 | 70 | #[post("/api/auth/logout", cookies: Cookies)] 71 | pub async fn logout() -> Result<(), ServerFnError> { 72 | let mut cookie = Cookie::new(AUTH_COOKIE_NAME, ""); 73 | cookie.set_path("/"); 74 | 75 | cookies.remove(cookie); 76 | 77 | Ok(()) 78 | } 79 | 80 | #[get("/api/auth/me", auth: AuthSession)] 81 | pub async fn get_current_user() -> Result, ServerFnError> { 82 | let claims = auth.0; 83 | 84 | Ok(Some(AuthResponse { 85 | username: claims.username, 86 | user_id: claims.sub, 87 | })) 88 | } 89 | -------------------------------------------------------------------------------- /ui/src/components/album/mod.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use shared::{ 3 | download::DownloadQuery, 4 | musicbrainz::{AlbumWithTracks, Track}, 5 | }; 6 | use std::collections::HashSet; 7 | 8 | use crate::album::{footer::AlbumFooter, track_list::TrackList}; 9 | 10 | mod footer; 11 | mod header; 12 | mod track_item; 13 | mod track_list; 14 | 15 | pub use header::AlbumHeader; 16 | 17 | #[derive(Props, PartialEq, Clone)] 18 | pub struct Props { 19 | /// The album and its tracks to display 20 | pub data: AlbumWithTracks, 21 | /// Callback for when the user confirms their selection 22 | #[props(into)] 23 | pub on_select: EventHandler, 24 | } 25 | 26 | #[component] 27 | pub fn Album(props: Props) -> Element { 28 | let mut selected_tracks = use_signal(HashSet::::new); 29 | let tracks = use_signal(|| props.data.tracks.clone()); 30 | 31 | let all_selected = 32 | selected_tracks.read().len() == tracks.read().len() && !tracks.read().is_empty(); 33 | 34 | let handle_select_all = move |_| { 35 | let mut selected = selected_tracks.write(); 36 | if all_selected { 37 | selected.clear(); 38 | } else { 39 | for track in tracks.read().iter() { 40 | selected.insert(track.id.clone()); 41 | } 42 | } 43 | }; 44 | 45 | let handle_track_toggle = move |track_id: String| { 46 | let mut selected = selected_tracks.write(); 47 | if selected.contains(&track_id) { 48 | selected.remove(&track_id); 49 | } else { 50 | selected.insert(track_id); 51 | } 52 | }; 53 | 54 | rsx! { 55 | TrackList { 56 | tracks, 57 | selected_tracks, 58 | on_toggle_select_all: handle_select_all, 59 | on_track_toggle: handle_track_toggle, 60 | all_selected, 61 | } 62 | AlbumFooter { 63 | is_selection_empty: selected_tracks.read().is_empty(), 64 | on_select: move |_| { 65 | let selected_ids = selected_tracks.read(); 66 | let tracks: Vec = tracks 67 | .read() 68 | .iter() 69 | .filter(|t| selected_ids.contains(&t.id)) 70 | .cloned() 71 | .collect(); 72 | let album = props.data.album.clone(); 73 | if props.data.tracks.len() == tracks.len() { 74 | props 75 | .on_select 76 | .call(DownloadQuery { 77 | album, 78 | tracks: props.data.tracks.clone(), 79 | }); 80 | } else { 81 | props.on_select.call(DownloadQuery { album, tracks }); 82 | } 83 | }, 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /api/src/models/user.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "server")] 2 | use super::folder::Folder; 3 | #[cfg(feature = "server")] 4 | use crate::db::DB; 5 | #[cfg(feature = "server")] 6 | use argon2::{ 7 | password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, 8 | Argon2, 9 | }; 10 | use serde::{Deserialize, Serialize}; 11 | #[cfg(feature = "server")] 12 | use uuid::Uuid; 13 | 14 | #[derive(Clone, Debug, Serialize, Deserialize)] 15 | #[cfg_attr(feature = "server", derive(sqlx::FromRow))] 16 | pub struct User { 17 | pub id: String, 18 | pub username: String, 19 | #[serde(skip)] 20 | pub password_hash: String, 21 | } 22 | 23 | #[cfg(feature = "server")] 24 | impl User { 25 | pub async fn create(username: &str, password: &str) -> Result { 26 | let salt = SaltString::generate(&mut OsRng); 27 | let argon2 = Argon2::default(); 28 | let password_hash = argon2 29 | .hash_password(password.as_bytes(), &salt) 30 | .map_err(|e| e.to_string())? 31 | .to_string(); 32 | 33 | let id = Uuid::new_v4().to_string(); 34 | 35 | let user = sqlx::query_as::<_, User>( 36 | "INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?) RETURNING id, username, password_hash" 37 | ) 38 | .bind(&id) 39 | .bind(username) 40 | .bind(password_hash) 41 | .fetch_one(&*DB) 42 | .await 43 | .map_err(|e| e.to_string())?; 44 | 45 | Ok(user) 46 | } 47 | 48 | pub async fn verify(username: &str, password: &str) -> Result { 49 | let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = ?") 50 | .bind(username) 51 | .fetch_optional(&*DB) 52 | .await 53 | .map_err(|e| e.to_string())? 54 | .ok_or("User not found")?; 55 | 56 | let parsed_hash = PasswordHash::new(&user.password_hash).map_err(|e| e.to_string())?; 57 | Argon2::default() 58 | .verify_password(password.as_bytes(), &parsed_hash) 59 | .map_err(|_| "Invalid password")?; 60 | 61 | Ok(user) 62 | } 63 | 64 | pub async fn get_folders(&self) -> Result, String> { 65 | sqlx::query_as::<_, Folder>("SELECT * FROM folders WHERE user_id = ?") 66 | .bind(&self.id) 67 | .fetch_all(&*DB) 68 | .await 69 | .map_err(|e| e.to_string()) 70 | } 71 | 72 | pub async fn get_all() -> Result, String> { 73 | sqlx::query_as::<_, User>("SELECT * FROM users") 74 | .fetch_all(&*DB) 75 | .await 76 | .map_err(|e| e.to_string()) 77 | } 78 | 79 | pub async fn get_by_id(id: &str) -> Result { 80 | let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?") 81 | .bind(id) 82 | .fetch_optional(&*DB) 83 | .await 84 | .map_err(|e| e.to_string())? 85 | .ok_or("User not found")?; 86 | 87 | Ok(user) 88 | } 89 | 90 | pub async fn update_password(id: &str, password: &str) -> Result<(), String> { 91 | let salt = SaltString::generate(&mut OsRng); 92 | let argon2 = Argon2::default(); 93 | let password_hash = argon2 94 | .hash_password(password.as_bytes(), &salt) 95 | .map_err(|e| e.to_string())? 96 | .to_string(); 97 | 98 | sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?") 99 | .bind(password_hash) 100 | .bind(id) 101 | .execute(&*DB) 102 | .await 103 | .map_err(|e| e.to_string())?; 104 | Ok(()) 105 | } 106 | 107 | pub async fn delete(id: &str) -> Result<(), String> { 108 | sqlx::query("DELETE FROM users WHERE id = ?") 109 | .bind(id) 110 | .execute(&*DB) 111 | .await 112 | .map_err(|e| e.to_string())?; 113 | Ok(()) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /ui/src/components/downloads/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use dioxus::prelude::*; 4 | use shared::slskd::{DownloadState, FileEntry}; 5 | 6 | mod item; 7 | use item::DownloadItem; 8 | 9 | #[derive(Props, Clone, PartialEq)] 10 | pub struct DownloadsProps { 11 | pub is_open: Signal, 12 | pub downloads: Signal>, 13 | } 14 | 15 | #[component] 16 | pub fn Downloads(mut props: DownloadsProps) -> Element { 17 | let mut active_downloads: Vec = props.downloads.read().values().cloned().collect(); 18 | active_downloads.sort_by(|a, b| b.enqueued_at.cmp(&a.enqueued_at)); 19 | active_downloads.sort_by(|a, b| { 20 | let a_state = a 21 | .state 22 | .first() 23 | .cloned() 24 | .unwrap_or(DownloadState::Unknown("".into())); 25 | let b_state = b 26 | .state 27 | .first() 28 | .cloned() 29 | .unwrap_or(DownloadState::Unknown("".into())); 30 | b_state 31 | .partial_cmp(&a_state) 32 | .unwrap_or(std::cmp::Ordering::Equal) 33 | }); 34 | 35 | // Count specific states for the header summary 36 | let processing_count = active_downloads 37 | .iter() 38 | .filter(|f| { 39 | let state = f 40 | .state 41 | .first() 42 | .cloned() 43 | .unwrap_or(DownloadState::Unknown("".into())); 44 | matches!( 45 | state, 46 | DownloadState::Queued 47 | | DownloadState::InProgress 48 | | DownloadState::Importing 49 | | DownloadState::Downloaded // Still needs to be imported 50 | ) 51 | }) 52 | .count(); 53 | 54 | let errored_count = active_downloads 55 | .iter() 56 | .filter(|f| { 57 | let state = f 58 | .state 59 | .first() 60 | .cloned() 61 | .unwrap_or(DownloadState::Unknown("".into())); 62 | matches!( 63 | state, 64 | DownloadState::Errored 65 | | DownloadState::Aborted 66 | | DownloadState::Cancelled 67 | | DownloadState::ImportFailed 68 | ) 69 | }) 70 | .count(); 71 | 72 | let clear_finished = move |_| { 73 | let mut map = props.downloads.write(); 74 | map.retain(|_, file| { 75 | let state = file 76 | .state 77 | .first() 78 | .cloned() 79 | .unwrap_or(DownloadState::Unknown("Unknown".into())); 80 | 81 | matches!( 82 | state, 83 | DownloadState::Queued 84 | | DownloadState::InProgress 85 | | DownloadState::Importing 86 | | DownloadState::Downloaded // Downloads that are completed but not yet imported 87 | ) 88 | }); 89 | }; 90 | 91 | let close_modal = move |_| props.is_open.set(false); 92 | 93 | let (modal_opacity, panel_translate, pointer_events) = if (*props.is_open)() { 94 | ("opacity-100", "translate-x-0", "pointer-events-auto") 95 | } else { 96 | ("opacity-0", "translate-x-full", "pointer-events-none") 97 | }; 98 | 99 | rsx! { 100 | div { class: "fixed inset-0 z-50 flex justify-end transition-opacity duration-200 ease-out {modal_opacity} {pointer_events}", 101 | // Backdrop 102 | div { 103 | class: "absolute inset-0 bg-black/60 backdrop-blur-sm cursor-pointer", 104 | onclick: close_modal, 105 | } 106 | 107 | // Panel 108 | div { class: "relative w-full max-w-md bg-beet-panel border-l border-white/10 h-full shadow-2xl transform transition-transform duration-200 ease-out flex flex-col {panel_translate}", 109 | //Header 110 | div { class: "p-6 border-b border-white/10 flex justify-between items-center bg-black/20", 111 | div { 112 | h3 { class: "text-xl font-bold text-white font-display", 113 | "Active Transfers" 114 | } 115 | p { class: "text-xs text-beet-leaf font-mono mt-1", 116 | ":: {processing_count} PROCESSING // {errored_count} ERRORED" 117 | } 118 | } 119 | button { 120 | class: "text-gray-400 hover:text-white transition-colors cursor-pointer", 121 | onclick: close_modal, 122 | svg { 123 | class: "w-6 h-6", 124 | fill: "none", 125 | stroke: "currentColor", 126 | view_box: "0 0 24 24", 127 | path { 128 | stroke_linecap: "round", 129 | stroke_linejoin: "round", 130 | stroke_width: "2", 131 | d: "M6 18L18 6M6 6l12 12", 132 | } 133 | } 134 | } 135 | } 136 | 137 | // Content 138 | div { class: "flex-1 overflow-y-auto p-6 no-scrollbar space-y-4", 139 | if active_downloads.is_empty() { 140 | div { class: "text-center text-gray-500 py-10 font-mono text-sm", 141 | "No active transfers in the queue." 142 | } 143 | } 144 | 145 | for file in active_downloads.iter() { 146 | DownloadItem { file: file.clone() } 147 | } 148 | } 149 | // Footer 150 | div { class: "p-4 border-t border-white/10 bg-black/20", 151 | button { 152 | class: "w-full py-2 text-xs font-mono uppercase tracking-widest text-center border border-white/10 hover:bg-white/5 text-gray-400 hover:text-white transition-colors cursor-pointer hover:border-red-500/30", 153 | onclick: clear_finished, 154 | "CLEAR COMPLETED" 155 | } 156 | } 157 | } 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /ui/src/components/login/mod.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | use std::pin::Pin; 3 | 4 | use dioxus::prelude::*; 5 | 6 | type LoginCallback = Callback<(String, String), Pin>>>>; 7 | 8 | #[derive(Props, PartialEq, Clone)] 9 | pub struct Props { 10 | login: LoginCallback, 11 | } 12 | 13 | #[component] 14 | pub fn Login(props: Props) -> Element { 15 | let mut username = use_signal(|| "".to_string()); 16 | let mut password = use_signal(|| "".to_string()); 17 | let mut error = use_signal(|| "".to_string()); 18 | 19 | let handle_login = move || { 20 | let user = username.read().to_string(); 21 | let pass = password.read().to_string(); 22 | spawn(async move { 23 | error.set("".to_string()); 24 | match props.login.call((user, pass)).await { 25 | Ok(_) => { 26 | // Login success logic usually handled by parent/router redirect 27 | } 28 | Err(e) => error.set(e), 29 | } 30 | }); 31 | }; 32 | 33 | rsx! { 34 | div { class: "flex flex-col items-center justify-center min-h-screen text-white font-display", 35 | // bg decorations 36 | div { class: "fixed top-1/4 -left-10 w-64 h-64 bg-beet-accent/10 rounded-full blur-[150px] pointer-events-none" } 37 | div { class: "fixed bottom-1/4 -right-10 w-64 h-64 bg-beet-leaf/10 rounded-full blur-[150px] pointer-events-none" } 38 | 39 | div { class: "p-8 bg-beet-panel border border-white/10 rounded-lg shadow-2xl w-full max-w-md relative z-10", 40 | // Header 41 | div { class: "flex flex-col items-center mb-8", 42 | div { class: "w-12 h-12 bg-beet-accent rounded-sm flex items-center justify-center shadow-[0_0_15px_rgba(217,70,239,0.5)] mb-4 rotate-3 hover:rotate-6 transition-transform", 43 | svg { 44 | class: "w-8 h-8 text-white", 45 | fill: "none", 46 | stroke: "currentColor", 47 | view_box: "0 0 24 24", 48 | path { 49 | stroke_linecap: "round", 50 | stroke_linejoin: "round", 51 | stroke_width: "2", 52 | d: "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3", 53 | } 54 | } 55 | } 56 | h1 { class: "text-2xl font-bold tracking-tighter uppercase text-transparent bg-clip-text bg-gradient-to-r from-white to-gray-400", 57 | "Soulbeet" 58 | } 59 | p { class: "text-sm text-beet-leaf font-mono mt-2 tracking-widest", 60 | "YOUR LIBRARY MANAGER" 61 | } 62 | } 63 | 64 | // Form 65 | div { class: "space-y-6", 66 | div { 67 | label { class: "block text-xs font-mono text-gray-400 mb-1 uppercase tracking-wider", 68 | "Username" 69 | } 70 | input { 71 | class: "w-full bg-beet-dark border border-white/10 rounded p-3 text-white focus:outline-none focus:border-beet-accent focus:shadow-[0_0_10px_rgba(217,70,239,0.3)] transition-all font-mono", 72 | value: "{username}", 73 | oninput: move |e| username.set(e.value()), 74 | "type": "text", 75 | placeholder: "Enter username", 76 | onkeydown: move |e| { 77 | if e.key() == Key::Enter { 78 | handle_login(); 79 | } 80 | }, 81 | } 82 | } 83 | div { 84 | label { class: "block text-xs font-mono text-gray-400 mb-1 uppercase tracking-wider", 85 | "Password" 86 | } 87 | input { 88 | class: "w-full bg-beet-dark border border-white/10 rounded p-3 text-white focus:outline-none focus:border-beet-accent focus:shadow-[0_0_10px_rgba(217,70,239,0.3)] transition-all font-mono", 89 | value: "{password}", 90 | oninput: move |e| password.set(e.value()), 91 | "type": "password", 92 | placeholder: "Enter password", 93 | onkeydown: move |e| { 94 | if e.key() == Key::Enter { 95 | handle_login(); 96 | } 97 | }, 98 | } 99 | } 100 | 101 | if !error().is_empty() { 102 | div { class: "p-3 bg-red-500/10 border border-red-500/50 rounded text-red-400 text-sm font-mono flex items-center gap-2", 103 | svg { 104 | class: "w-4 h-4", 105 | fill: "none", 106 | view_box: "0 0 24 24", 107 | stroke: "currentColor", 108 | path { 109 | stroke_linecap: "round", 110 | stroke_linejoin: "round", 111 | stroke_width: "2", 112 | d: "M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z", 113 | } 114 | } 115 | "{error}" 116 | } 117 | } 118 | 119 | button { 120 | class: "w-full retro-btn flex justify-center items-center gap-2 group", 121 | onclick: move |_| handle_login(), 122 | span { "AUTHENTICATE" } 123 | svg { 124 | class: "w-4 h-4 group-hover:translate-x-1 transition-transform", 125 | fill: "none", 126 | view_box: "0 0 24 24", 127 | stroke: "currentColor", 128 | path { 129 | stroke_linecap: "round", 130 | stroke_linejoin: "round", 131 | stroke_width: "2", 132 | d: "M14 5l7 7m0 0l-7 7m7-7H3", 133 | } 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /lib/soulbeet/src/slskd/processing.rs: -------------------------------------------------------------------------------- 1 | use super::utils; 2 | use crate::slskd::models::SearchResponse; 3 | use itertools::Itertools; 4 | use shared::slskd::{AlbumResult, MatchResult, SearchResult, TrackResult}; 5 | use std::collections::{HashMap, HashSet}; 6 | use std::path::Path; 7 | 8 | pub fn process_search_responses( 9 | responses: &[SearchResponse], 10 | searched_artist: &str, 11 | searched_album: &str, 12 | expected_tracks: &[&str], 13 | ) -> Vec { 14 | const MIN_SCORE_THRESHOLD: f64 = 0.6; 15 | let audio_extensions: HashSet<&str> = ["flac", "wav", "m4a", "ogg", "aac", "wma", "mp3"] 16 | .iter() 17 | .copied() 18 | .collect(); 19 | 20 | let scored_files: Vec<(MatchResult, SearchResult)> = responses 21 | .iter() 22 | .flat_map(|resp| { 23 | resp.files.iter().filter_map(|file| { 24 | let path = Path::new(&file.filename); 25 | let ext = path 26 | .extension() 27 | .and_then(|s| s.to_str()) 28 | .map(|s| s.to_lowercase()); 29 | 30 | if let Some(ext) = ext { 31 | if !audio_extensions.contains(ext.as_str()) { 32 | return None; 33 | } 34 | } 35 | 36 | let rank_result = utils::rank_match( 37 | &file.filename, 38 | Some(searched_artist), 39 | Some(searched_album), 40 | expected_tracks, 41 | ); 42 | 43 | if rank_result.total_score < MIN_SCORE_THRESHOLD { 44 | return None; 45 | } 46 | 47 | let search_result = SearchResult { 48 | username: resp.username.clone(), 49 | filename: file.filename.clone(), 50 | size: file.size, 51 | bitrate: file.bit_rate, 52 | duration: file.length, 53 | has_free_upload_slot: resp.has_free_upload_slot, 54 | upload_speed: resp.upload_speed, 55 | queue_length: resp.queue_length, 56 | }; 57 | Some((rank_result, search_result)) 58 | }) 59 | }) 60 | .collect(); 61 | 62 | find_best_albums(&scored_files, expected_tracks) 63 | } 64 | 65 | fn find_best_albums( 66 | scored_files: &[(MatchResult, SearchResult)], 67 | expected_tracks: &[&str], 68 | ) -> Vec { 69 | if expected_tracks.is_empty() { 70 | return vec![]; 71 | } 72 | 73 | let album_groups = scored_files.iter().into_group_map_by(|(rank, search)| { 74 | ( 75 | search.username.clone(), 76 | rank.guessed_artist.clone(), 77 | rank.guessed_album.clone(), 78 | ) 79 | }); 80 | 81 | album_groups 82 | .into_iter() 83 | .filter_map(|((username, artist, album_title), files_in_group)| { 84 | // Specific search: find the single best file for each expected track. 85 | let mut best_files_for_album = HashMap::new(); 86 | 87 | for expected_track_title in expected_tracks { 88 | if let Some(best_file_for_track) = files_in_group 89 | .iter() 90 | // Find all files that matched this specific track 91 | .filter(|(rank, _)| &rank.matched_track == expected_track_title) 92 | // Find the best one among them 93 | .max_by(|(r1, s1), (r2, s2)| { 94 | r1.total_score 95 | .partial_cmp(&r2.total_score) 96 | .unwrap_or(std::cmp::Ordering::Equal) 97 | .then_with(|| { 98 | s1.quality_score().partial_cmp(&s2.quality_score()).unwrap() 99 | }) 100 | }) 101 | { 102 | best_files_for_album.insert(*expected_track_title, best_file_for_track); 103 | } 104 | } 105 | 106 | let final_tracks: Vec<_> = expected_tracks 107 | .iter() 108 | .filter_map(|t| best_files_for_album.get(*t)) 109 | .map(|(mr, sr)| TrackResult::new(sr.clone(), mr.clone())) 110 | .collect(); 111 | 112 | if final_tracks.is_empty() { 113 | return None; 114 | } 115 | 116 | let completeness = if !expected_tracks.is_empty() { 117 | final_tracks.len() as f64 / expected_tracks.len() as f64 118 | } else { 119 | 1.0 120 | }; 121 | 122 | let total_size: i64 = final_tracks.iter().map(|t| t.base.size).sum(); 123 | let dominant_quality = final_tracks 124 | .iter() 125 | .map(|t| t.base.quality()) 126 | .counts() 127 | .into_iter() 128 | .max_by_key(|&(_, count)| count) 129 | .map(|(val, _)| val) 130 | .unwrap_or_default(); 131 | 132 | let first_track = final_tracks[0].base.clone(); 133 | let album_path = first_track.filename.clone(); 134 | 135 | let avg_score: f64 = 136 | final_tracks.iter().map(|t| t.match_score).sum::() / final_tracks.len() as f64; 137 | let avg_format_score = final_tracks 138 | .iter() 139 | .map(|t| t.base.quality_score()) 140 | .sum::() 141 | / final_tracks.len() as f64; 142 | 143 | let album_quality_score = 144 | (avg_score * 0.3) + (completeness * 0.3) + (avg_format_score * 0.4); 145 | 146 | Some(AlbumResult { 147 | username, 148 | album_path, 149 | album_title, 150 | artist: Some(artist), 151 | track_count: final_tracks.len(), 152 | total_size, 153 | tracks: final_tracks, 154 | dominant_quality, 155 | has_free_upload_slot: first_track.has_free_upload_slot, 156 | upload_speed: first_track.upload_speed, 157 | queue_length: first_track.queue_length, 158 | score: album_quality_score, 159 | }) 160 | }) 161 | .collect() 162 | } 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Soulbeet 2 | 3 | [![Docker Pulls](https://img.shields.io/docker/pulls/docccccc/soulbeet)](https://hub.docker.com/repository/docker/docccccc/soulbeet/general) 4 | [![Docker Image Size](https://img.shields.io/docker/image-size/docccccc/soulbeet)](https://hub.docker.com/repository/docker/docccccc/soulbeet/general) 5 | [![Docker Image Version](https://img.shields.io/docker/v/docccccc/soulbeet)](https://hub.docker.com/repository/docker/docccccc/soulbeet/general) 6 | 7 | [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/terry90/soulbeet/image-build-push.yml)](https://github.com/terry90/soulbeet/actions) 8 | [![GitHub License](https://img.shields.io/github/license/terry90/soulbeet)](https://github.com/terry90/soulbeet) 9 | [![GitHub Repo stars](https://img.shields.io/github/stars/terry90/soulbeet)](https://github.com/terry90/soulbeet) 10 | 11 | Soulbeet is a modern, self-hosted music downloader and manager. It bridges the gap between Soulseek (via `slskd`) and your music library (managed by `beets`), providing a seamless flow from search to streaming-ready library. 12 | 13 | Screenshots: [here](./screenshots) 14 | 15 | ## Features 16 | 17 | - **Unified Search**: Search for albums and tracks using MusicBrainz metadata and find sources on Soulseek. 18 | - **One-Click Download & Import**: Select an album (or just some tracks), choose your target folder, and Soulbeet handles the rest. 19 | - **Automated Importing**: Automatically monitors downloads and uses the `beets` CLI to tag, organize, and move files to your specified music folder. 20 | - **User Management**: Multi-user support with private folders. Each user can manage their own music library paths. Or have a common folder. 21 | 22 | ## Architecture 23 | 24 | 1. **Soulbeet Web**: The main interface (Dioxus Fullstack). 25 | 2. **Slskd**: The Soulseek client backend. Soulbeet communicates with `slskd` to initiate and monitor downloads. 26 | 3. **Beets**: The music library manager. Soulbeet executes `beet import` to process finished downloads. 27 | 4. **SQLite**: Stores user accounts and folder configurations. (PostgreSQL compat can be added easily, maybe in the future) 28 | 29 | ## Self-Hosting with Docker 30 | 31 | The recommended way to run Soulbeet is via Docker Compose. This ensures all dependencies (like `beets` and `python`) are correctly set up. 32 | 33 | ### Prerequisites 34 | 35 | - Docker & Docker Compose (or podman-compose) 36 | 37 | ### Quick Start 38 | 39 | 1. Create a `docker-compose.yml` file: 40 | 41 | ```yaml 42 | services: 43 | soulbeet: 44 | image: docker.io/docccccc/soulbeet:latest 45 | restart: unless-stopped 46 | ports: 47 | - 9765:9765 48 | environment: 49 | - DATABASE_URL=sqlite:/data/soulbeet.db 50 | - SLSKD_URL=http://slskd:5030 51 | - SLSKD_API_KEY=your_slskd_api_key_here 52 | # The path where slskd saves files (INSIDE the soulbeet container) 53 | - SLSKD_DOWNLOAD_PATH=/downloads 54 | # Optional: Beets configuration 55 | - BEETS_CONFIG=/config/config.yaml 56 | - SECRET_KEY=secret 57 | volumes: 58 | # Data persistence (DB) 59 | - ./data:/data 60 | # Map the SAME download folder slskd uses 61 | - /path/to/slskd/downloads:/downloads 62 | # Map your music libraries (where beets will move files to) 63 | - /path/to/music:/music 64 | # Optional 65 | depends_on: 66 | - slskd 67 | 68 | # Optional 69 | # Example slskd service if you don't have one running 70 | slskd: 71 | image: slskd/slskd 72 | environment: 73 | - SLSKD_REMOTE_CONFIGURATION=true 74 | volumes: 75 | - ./slskd-config:/app/slskd.conf.d 76 | - /path/to/slskd/downloads:/app/downloads 77 | ports: 78 | - "5030:5030" 79 | ``` 80 | 81 | 2. **Important**: The `/downloads` volume must match between `slskd` and `soulbeet` so Soulbeet can see the files `slskd` downloaded. 82 | 83 | 3. Build and Run: 84 | 85 | ```bash 86 | docker-compose up -d --build 87 | ``` 88 | 89 | ### Initial Setup 90 | 91 | 1. Open `http://localhost:9765` 92 | 2. Login with the default credentials: 93 | - Username: `admin` 94 | - Password: `admin` 95 | 3. Go to **Settings**. 96 | 4. **Change your password** (Create a new user if you prefer and delete the admin later, or just change the admin logic if you forked the code). 97 | 5. **Add Music Folders**: Add the paths where you want your music to be stored (e.g., `/music/Person1`, `/music/Person2`, `/music/Shared`). These must be paths accessible inside the Docker container. 98 | 99 | ## Configuration 100 | 101 | ### Environment Variables 102 | 103 | | Variable | Description | Default | 104 | |----------|-------------|---------| 105 | | `DATABASE_URL` | Connection string for SQLite | `sqlite:soulbeet.db` | 106 | | `SLSKD_URL` | URL of your Slskd instance | | 107 | | `SLSKD_API_KEY` | API Key for Slskd | | 108 | | `SLSKD_DOWNLOAD_PATH` | Path where Slskd downloads files | | 109 | | `BEETS_CONFIG` | Path to custom beets config file | `beets_config.yaml` | 110 | | `SECRET_KEY` | Used to encrypt tokens | | 111 | 112 | ### Beets Configuration 113 | 114 | Soulbeet uses `beets` to import music. You can mount a custom `config.yaml` to `/config/config.yaml` (or wherever you point `BEETS_CONFIG` to) to customize how beets behaves (plugins, naming formats, etc.). 115 | 116 | Default `beet import` flags used: 117 | - `-q`: Quiet mode (no user interaction) 118 | - `-s`: Singleton mode (Works best at the moment, may change in the future) 119 | - `-d [target_path]`: Import to the specific folder selected in the UI. 120 | 121 | ## Development 122 | 123 | 1. Install Rust and `dioxus_cli`. 124 | 2. Run the tailwind watcher: 125 | ```bash 126 | ./css.sh 127 | ``` 128 | 3. Run the app: 129 | ```bash 130 | dx serve --platform web 131 | 132 | ## TODO & Ideas 133 | 134 | - Mobile app (nothing much to do honestly) 135 | - Better scoring 136 | - Enhance the default beets configuration 137 | - Find a way to avoid album dups ? e.g `Clair Obscur_ Expedition 33 (Original Soundtrack)` & `Clair Obscur_ Expedition 33_ Original Soundtrack` - Rare but annoying 138 | - Add play preview on album track list 139 | - Improve slskd search. Currently: 140 | - Single track search, query: "{artist} {track_title}" -> more resilient 141 | - Multiple tracks search, query: "{artist} {album}" -> best for metadata and grouping tracks by album 142 | - Listenbrainz integration to autodownload suggestions 143 | - Complete library manager, removal of tracks 144 | - Synchronize a playlist (Spotify or other) 145 | -------------------------------------------------------------------------------- /ui/src/components/downloads/item.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use shared::slskd::{DownloadState, FileEntry}; 3 | 4 | #[component] 5 | pub fn DownloadItem(file: FileEntry) -> Element { 6 | let state = file 7 | .state 8 | .first() 9 | .cloned() 10 | .unwrap_or(DownloadState::Unknown("Unknown".into())); 11 | 12 | let (_status_text, border_class, badge_class, badge_text) = match &state { 13 | DownloadState::Queued => ( 14 | "Queued", 15 | "border-white/5 opacity-60", 16 | "border border-gray-600 text-gray-400", 17 | "QUEUED", 18 | ), 19 | DownloadState::InProgress => ( 20 | "Downloading", 21 | "border-beet-accent/50", 22 | "bg-blue-500/20 text-blue-300", 23 | "SLSK", 24 | ), 25 | DownloadState::Downloaded => ( 26 | "Completed", 27 | "border-beet-leaf/50", 28 | "bg-beet-leaf/20 text-beet-leaf", 29 | "DOWNLOADED", 30 | ), 31 | DownloadState::Importing => ( 32 | "Importing...", 33 | "border-beet-leaf/50", 34 | "bg-beet-leaf/20 text-beet-leaf", 35 | "BEETS", 36 | ), 37 | DownloadState::Imported => ( 38 | "Imported", 39 | "border-green-500/50", 40 | "bg-green-500/20 text-green-300", 41 | "LIB", 42 | ), 43 | DownloadState::ImportFailed => ( 44 | "Import Failed", 45 | "border-orange-500/50", 46 | "bg-orange-500/20 text-orange-300", 47 | "IMP ERR", 48 | ), 49 | DownloadState::Errored | DownloadState::Aborted | DownloadState::Cancelled => ( 50 | "Failed", 51 | "border-red-500/50", 52 | "bg-red-500/20 text-red-300", 53 | "ERR", 54 | ), 55 | _ => ( 56 | "Unknown", 57 | "border-white/5", 58 | "bg-gray-700 text-gray-400", 59 | "?", 60 | ), 61 | }; 62 | 63 | let percent = file.percent_complete as i32; 64 | 65 | // Clean up filename for display (remove path) 66 | let filename_str = file.filename.replace('\\', "/"); 67 | let path = std::path::Path::new(&filename_str); 68 | let components: Vec<_> = path.components().collect(); 69 | 70 | let display_name = match components.len() { 71 | 0 => "Unknown".to_string(), 72 | _ => components[components.len() - 1] 73 | .as_os_str() 74 | .to_string_lossy() 75 | .into_owned(), 76 | }; 77 | 78 | rsx! { 79 | div { class: "bg-white/5 border {border_class} p-4 rounded-lg hover:border-beet-accent/50 transition-colors group", 80 | div { class: "flex justify-between items-start mb-2", 81 | div { 82 | class: "text-sm font-bold text-white truncate w-3/4 pr-2", 83 | title: "{file.filename}", 84 | "{display_name}" 85 | } 86 | span { 87 | class: "text-[10px] font-mono {badge_class} px-1.5 py-0.5 rounded uppercase cursor-help", 88 | title: "{file.state_description}", 89 | "{badge_text}" 90 | } 91 | } 92 | div { class: "flex justify-between text-xs text-gray-400 font-mono mb-1", 93 | span { 94 | if matches!(state, DownloadState::InProgress) { 95 | if let Some(speed) = calculate_speed(&file) { 96 | "{speed}" 97 | } else { 98 | "{format_size(file.size)}" 99 | } 100 | } else { 101 | "{format_size(file.size)}" 102 | } 103 | } 104 | span { "{percent}%" } 105 | } 106 | // Progress Bar 107 | if matches!(state, DownloadState::InProgress | DownloadState::Importing) { 108 | div { class: "h-2 w-full bg-gray-800 rounded-full overflow-hidden relative", 109 | div { 110 | class: "h-full bg-beet-accent absolute top-0 left-0 transition-all duration-300", 111 | style: "width: {percent}%", 112 | } 113 | // Striped animation overlay (using inline SVG for pattern) 114 | div { 115 | class: "h-full w-full absolute top-0 left-0 opacity-30", 116 | style: "background-image: repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(255,255,255,0.5) 5px, rgba(255,255,255,0.5) 10px);", 117 | } 118 | } 119 | } else if matches!(state, DownloadState::Queued) { 120 | div { class: "h-1 w-full bg-gray-800 rounded-full mt-2" } 121 | } else if matches!(state, DownloadState::ImportFailed) { 122 | div { class: "text-xs text-orange-400 mt-1 break-words", "{file.state_description}" } 123 | } else if matches!(state, DownloadState::Errored) { 124 | div { class: "text-xs text-red-400 mt-1 break-words", "{file.state_description}" } 125 | } 126 | if matches!(state, DownloadState::Importing) { 127 | div { class: "flex items-center gap-2 text-xs text-gray-300 font-mono mt-2", 128 | svg { 129 | class: "w-3 h-3 animate-spin", 130 | fill: "none", 131 | view_box: "0 0 24 24", 132 | circle { 133 | class: "opacity-25", 134 | cx: "12", 135 | cy: "12", 136 | r: "10", 137 | stroke: "currentColor", 138 | stroke_width: "4", 139 | } 140 | path { 141 | class: "opacity-75", 142 | fill: "currentColor", 143 | d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z", 144 | } 145 | } 146 | "Moving and tagging..." 147 | } 148 | } 149 | } 150 | } 151 | } 152 | 153 | fn format_size(bytes: u64) -> String { 154 | const KB: u64 = 1024; 155 | const MB: u64 = KB * 1024; 156 | const GB: u64 = MB * 1024; 157 | 158 | if bytes >= GB { 159 | format!("{:.2} GB", bytes as f64 / GB as f64) 160 | } else if bytes >= MB { 161 | format!("{:.2} MB", bytes as f64 / MB as f64) 162 | } else if bytes >= KB { 163 | format!("{:.2} KB", bytes as f64 / KB as f64) 164 | } else { 165 | format!("{bytes} B") 166 | } 167 | } 168 | 169 | fn calculate_speed(file: &FileEntry) -> Option { 170 | if file.average_speed > 0.0 { 171 | Some(format!("{}/s", format_size(file.average_speed as u64))) 172 | } else { 173 | None 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /web/src/main.rs: -------------------------------------------------------------------------------- 1 | use auth::{use_auth, AuthProvider}; 2 | use dioxus::logger::tracing::warn; 3 | use dioxus::prelude::*; 4 | use shared::slskd::FileEntry; 5 | use std::collections::HashMap; 6 | 7 | use ui::{Downloads, Layout, Navbar, SearchReset}; 8 | use views::{LoginPage, SearchPage, SettingsPage}; 9 | 10 | mod auth; 11 | mod views; 12 | 13 | #[derive(Debug, Clone, Routable, PartialEq)] 14 | #[rustfmt::skip] 15 | pub enum Route { 16 | #[layout(AuthGuard)] 17 | #[route("/login")] 18 | LoginPage {}, 19 | 20 | #[layout(WebNavbar)] 21 | #[route("/")] 22 | SearchPage {}, 23 | #[route("/settings")] 24 | SettingsPage {}, 25 | } 26 | 27 | const FAVICON: Asset = asset!("/assets/favicon.ico"); 28 | const MAIN_CSS: Asset = asset!("/assets/tailwind.css"); 29 | 30 | fn main() { 31 | #[cfg(feature = "server")] 32 | { 33 | use tower_cookies::CookieManagerLayer; 34 | 35 | dioxus::serve(|| async move { 36 | Ok(dioxus::server::router(App).layer(CookieManagerLayer::new())) 37 | }); 38 | } 39 | 40 | #[cfg(not(feature = "server"))] 41 | dioxus::launch(App); 42 | } 43 | 44 | #[component] 45 | fn App() -> Element { 46 | rsx! { 47 | document::Link { rel: "icon", href: FAVICON } 48 | document::Link { rel: "stylesheet", href: MAIN_CSS } 49 | document::Meta { name: "viewport", content: "width=device-width, initial-scale=1" } 50 | document::Title { "SoulBeet" } 51 | 52 | AuthProvider { Router:: {} } 53 | } 54 | } 55 | 56 | #[component] 57 | fn AuthGuard() -> Element { 58 | let auth = use_auth(); 59 | let nav = use_navigator(); 60 | let current = use_route::(); 61 | 62 | use_effect(move || { 63 | let is_logged_in = auth.is_logged_in(); 64 | 65 | // If not logged in AND we're not already on /login -> go to login 66 | if !is_logged_in && !matches!(current, Route::LoginPage {}) { 67 | nav.replace(Route::LoginPage {}); 68 | } 69 | 70 | // If logged in and on /login -> go to home 71 | if is_logged_in && matches!(current, Route::LoginPage {}) { 72 | nav.replace(Route::SearchPage {}); 73 | } 74 | }); 75 | 76 | rsx! { 77 | Outlet:: {} 78 | } 79 | } 80 | 81 | #[component] 82 | fn WebNavbar() -> Element { 83 | let mut auth = use_auth(); 84 | let mut downloads_open = use_signal(|| false); 85 | let mut search_reset = use_signal(|| 0); 86 | let mut downloads = use_signal::>(HashMap::new); 87 | 88 | use_context_provider(|| SearchReset(search_reset)); 89 | 90 | use_future(move || async move { 91 | loop { 92 | let stream = auth.call(api::download_updates_stream()).await; 93 | 94 | match stream { 95 | Ok(mut s) => { 96 | while let Some(Ok(data)) = s.next().await { 97 | let mut map = downloads.write(); 98 | for file in data { 99 | map.insert(file.id.clone(), file); 100 | } 101 | } 102 | } 103 | Err(e) => { 104 | warn!("Failed to connect to download updates stream: {:?}", e); 105 | gloo_timers::future::TimeoutFuture::new(1_000).await; 106 | } 107 | } 108 | } 109 | }); 110 | 111 | let logout = move |_| { 112 | spawn(async move { 113 | auth.logout().await; 114 | }); 115 | }; 116 | 117 | rsx! { 118 | Layout { 119 | Navbar { 120 | Link { 121 | class: "nav-link text-white font-medium border-b-2 border-transparent hover:border-beet-accent pb-0.5", 122 | active_class: "border-beet-accent", 123 | to: Route::SearchPage {}, 124 | onclick: move |_| search_reset += 1, 125 | span { class: "hidden md:block", "Search" } 126 | svg { 127 | class: "md:hidden w-6 h-6", 128 | fill: "none", 129 | stroke: "currentColor", 130 | view_box: "0 0 24 24", 131 | path { 132 | stroke_linecap: "round", 133 | stroke_linejoin: "round", 134 | stroke_width: "2", 135 | d: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z", 136 | } 137 | } 138 | } 139 | Link { 140 | class: "nav-link text-white font-medium border-b-2 border-transparent hover:border-beet-accent pb-0.5", 141 | active_class: "border-beet-accent", 142 | to: Route::SettingsPage {}, 143 | span { class: "hidden md:block", "Settings" } 144 | svg { 145 | class: "md:hidden w-6 h-6", 146 | fill: "none", 147 | stroke: "currentColor", 148 | view_box: "0 0 24 24", 149 | path { 150 | stroke_linecap: "round", 151 | stroke_linejoin: "round", 152 | stroke_width: "2", 153 | d: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z", 154 | } 155 | path { 156 | stroke_linecap: "round", 157 | stroke_linejoin: "round", 158 | stroke_width: "2", 159 | d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z", 160 | } 161 | } 162 | } 163 | 164 | // Separator 165 | div { class: "h-4 w-px bg-white/10" } 166 | 167 | // Downloads Toggle 168 | button { 169 | class: "relative group p-2 hover:bg-white/5 rounded-lg transition-colors focus:outline-none cursor-pointer", 170 | onclick: move |_| downloads_open.set(!downloads_open()), 171 | svg { 172 | class: "w-5 h-5 text-gray-300 group-hover:text-beet-leaf transition-colors", 173 | fill: "none", 174 | stroke: "currentColor", 175 | view_box: "0 0 24 24", 176 | path { 177 | stroke_linecap: "round", 178 | stroke_linejoin: "round", 179 | stroke_width: "2", 180 | d: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4", 181 | } 182 | } 183 | 184 | if !downloads.read().is_empty() { 185 | span { class: "absolute top-1.5 right-1.5 flex h-2.5 w-2.5", 186 | span { class: "animate-ping absolute inline-flex h-full w-full rounded-full bg-beet-accent opacity-75" } 187 | span { class: "relative inline-flex rounded-full h-2.5 w-2.5 bg-beet-accent" } 188 | } 189 | } 190 | } 191 | 192 | button { 193 | class: "nav-link text-red-400 hover:text-red-300 text-xs uppercase tracking-widest font-mono cursor-pointer", 194 | onclick: logout, 195 | "Logout" 196 | } 197 | } 198 | 199 | main { class: "px-4 sm:px-6 lg:px-8 flex-grow flex flex-col relative overflow-y-auto w-full py-8 no-scrollbar", 200 | Outlet:: {} 201 | } 202 | Downloads { is_open: downloads_open, downloads } 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /lib/soulbeet/src/musicbrainz.rs: -------------------------------------------------------------------------------- 1 | use musicbrainz_rs::{ 2 | entity::{ 3 | artist_credit::ArtistCredit, 4 | recording::{Recording, RecordingSearchQuery}, 5 | release::{Release, ReleaseStatus}, 6 | release_group::{ReleaseGroup, ReleaseGroupPrimaryType, ReleaseGroupSearchQuery}, 7 | }, 8 | Fetch, MusicBrainzClient, Search, 9 | }; 10 | use shared::musicbrainz::{Album, AlbumWithTracks, SearchResult, Track}; 11 | use std::{collections::HashSet, sync::OnceLock}; 12 | use tracing::info; 13 | 14 | // This ensures the client is initialized only once with a proper user agent. 15 | fn musicbrainz_client() -> &'static MusicBrainzClient { 16 | static CLIENT: OnceLock = OnceLock::new(); 17 | CLIENT.get_or_init(|| { 18 | let version = env!("CARGO_PKG_VERSION"); 19 | MusicBrainzClient::new(&format!( 20 | "Soulbeet/{version} ( https://github.com/terry90/soulbeet )" 21 | )) 22 | .unwrap() 23 | }) 24 | } 25 | 26 | /// Formats the artist credits into a single, comma-separated string. 27 | fn format_artist_credit(credits: &Option>) -> String { 28 | credits 29 | .as_ref() 30 | .map(|credits| { 31 | credits 32 | .iter() 33 | .map(|credit| credit.name.clone()) 34 | .collect::>() 35 | .join(", ") 36 | }) 37 | .unwrap_or_else(|| "Unknown Artist".to_string()) 38 | } 39 | 40 | /// Formats a duration from milliseconds to a MM:SS string. 41 | fn format_duration(duration_ms: &Option) -> Option { 42 | duration_ms.map(|ms| { 43 | let total_seconds = ms / 1000; 44 | let minutes = total_seconds / 60; 45 | let seconds = total_seconds % 60; 46 | format!("{minutes:02}:{seconds:02}") 47 | }) 48 | } 49 | 50 | /// An enumeration to specify the type of search. 51 | #[derive(Debug)] 52 | pub enum SearchType { 53 | Track, 54 | Album, 55 | } 56 | 57 | /// Performs a refined search for music, prioritizing canonical releases. 58 | pub async fn search( 59 | artist: &Option, 60 | query: &str, 61 | search_type: SearchType, 62 | limit: u8, 63 | ) -> Result, musicbrainz_rs::Error> { 64 | let client = musicbrainz_client(); 65 | let mut results = Vec::new(); 66 | 67 | info!( 68 | "Starting {:?} search for query: '{}', artist: '{:?}'", 69 | search_type, query, artist 70 | ); 71 | 72 | match search_type { 73 | SearchType::Track => { 74 | let mut recording_query = RecordingSearchQuery::query_builder(); 75 | if let Some(ref artist) = artist { 76 | recording_query.artist_name(artist).and(); 77 | } 78 | let search_query = recording_query.recording(query).build(); 79 | 80 | let search_results = Recording::search(search_query) 81 | .limit(limit) 82 | .with_releases() 83 | .execute_with_client(client) 84 | .await?; 85 | 86 | let mut unique_tracks = HashSet::new(); 87 | 88 | for recording in search_results.entities { 89 | let artist_name = format_artist_credit(&recording.artist_credit); 90 | let album_title = recording 91 | .releases 92 | .as_ref() 93 | .and_then(|r| r.first()) 94 | .map(|r| r.title.clone()) 95 | .unwrap_or_default(); 96 | 97 | // Use a combination of title, artist, and album to define a unique track 98 | let key = ( 99 | recording.title.to_lowercase(), 100 | artist_name.to_lowercase(), 101 | album_title.to_lowercase(), 102 | ); 103 | 104 | if !unique_tracks.contains(&key) { 105 | let first_release = recording.releases.as_ref().and_then(|r| r.first()); 106 | let track = Track { 107 | id: recording.id, 108 | title: recording.title.clone(), 109 | artist: artist_name.clone(), 110 | album_id: first_release.map(|release| release.id.clone()), 111 | album_title: first_release.map(|r| r.title.clone()), 112 | release_date: first_release.and_then(|r| r.date.clone().map(|d| d.0)), 113 | duration: format_duration(&recording.length), 114 | }; 115 | unique_tracks.insert(key); 116 | results.push(SearchResult::Track(track)); 117 | } 118 | } 119 | } 120 | SearchType::Album => { 121 | let mut album_query = ReleaseGroupSearchQuery::query_builder(); 122 | if let Some(ref artist) = artist { 123 | album_query.artist(artist).and(); 124 | } 125 | let search_query = album_query.release_group(query).build(); 126 | 127 | let search_results = ReleaseGroup::search(search_query) 128 | .limit(limit) 129 | .with_releases() 130 | .execute_with_client(client) 131 | .await?; 132 | 133 | for release_group in search_results.entities { 134 | if release_group.primary_type != Some(ReleaseGroupPrimaryType::Album) { 135 | continue; 136 | } 137 | 138 | if let Some(best_release) = release_group.releases.as_ref().and_then(|releases| { 139 | releases 140 | .iter() 141 | .filter(|r| r.status == Some(ReleaseStatus::Official)) 142 | .min_by_key(|release| release.date.as_ref().map(|d| &d.0)) 143 | }) { 144 | // If no official release was found, take the first one available 145 | let final_release = best_release.clone(); 146 | 147 | results.push(SearchResult::Album(Album { 148 | id: final_release.id.clone(), 149 | title: release_group.title.clone(), 150 | artist: format_artist_credit(&release_group.artist_credit), 151 | release_date: final_release.date.as_ref().map(|d| d.0.clone()), 152 | })); 153 | } 154 | } 155 | } 156 | } 157 | 158 | Ok(results) 159 | } 160 | 161 | /// Fetches a release (album) by its ID and returns it with its full tracklist. 162 | pub async fn find_album(release_id: &str) -> Result { 163 | let client = musicbrainz_client(); 164 | 165 | // Fetch the release with recordings (tracks) and artist credits for the tracks. 166 | let release = Release::fetch() 167 | .id(release_id) 168 | .with_recordings() 169 | .with_artist_credits() 170 | .execute_with_client(client) 171 | .await?; 172 | 173 | let mut tracks = Vec::new(); 174 | 175 | // A release contains media (like CD 1, CD 2), and each medium has tracks. 176 | if let Some(media) = &release.media { 177 | for medium in media { 178 | if let Some(release_tracks) = &medium.tracks { 179 | for track in release_tracks { 180 | if let Some(recording) = &track.recording { 181 | tracks.push(Track { 182 | id: recording.id.clone(), 183 | title: recording.title.clone(), 184 | artist: format_artist_credit(&recording.artist_credit), 185 | album_id: Some(release.id.clone()), 186 | album_title: Some(release.title.clone()), 187 | release_date: release.date.as_ref().map(|d| d.0.clone()), 188 | duration: format_duration(&recording.length), 189 | }); 190 | } 191 | } 192 | } 193 | } 194 | } 195 | 196 | // First, create the standalone Album object. 197 | let album = Album { 198 | id: release.id, 199 | title: release.title, 200 | artist: format_artist_credit(&release.artist_credit), 201 | release_date: release.date.map(|d| d.0), 202 | }; 203 | 204 | // Then, package it into the new struct along with the tracks. 205 | let album_with_tracks = AlbumWithTracks { album, tracks }; 206 | 207 | Ok(album_with_tracks) 208 | } 209 | -------------------------------------------------------------------------------- /ui/src/components/settings/user_manager.rs: -------------------------------------------------------------------------------- 1 | use api::{delete_user, get_users, register, update_user_password}; 2 | use dioxus::prelude::*; 3 | 4 | use crate::auth::use_auth; 5 | 6 | #[component] 7 | pub fn UserManager() -> Element { 8 | let mut new_username = use_signal(|| "".to_string()); 9 | let mut new_password = use_signal(|| "".to_string()); 10 | let mut users = use_signal(Vec::new); 11 | 12 | let mut editing_user_id = use_signal(|| None::); 13 | let mut edit_user_password = use_signal(|| "".to_string()); 14 | 15 | let mut error = use_signal(|| "".to_string()); 16 | let mut success_msg = use_signal(|| "".to_string()); 17 | let auth = use_auth(); 18 | 19 | let fetch_users = move || async move { 20 | match auth.call(get_users()).await { 21 | Ok(fetched_users) => users.set(fetched_users), 22 | Err(e) => error.set(format!("Failed to fetch users: {e}")), 23 | } 24 | }; 25 | 26 | use_future(move || async move { 27 | fetch_users().await; 28 | }); 29 | 30 | let handle_create_user = move |_| async move { 31 | error.set("".to_string()); 32 | success_msg.set("".to_string()); 33 | 34 | if new_username().is_empty() || new_password().is_empty() { 35 | error.set("Username and Password are required".to_string()); 36 | return; 37 | } 38 | 39 | match auth.call(register(new_username(), new_password())).await { 40 | Ok(_) => { 41 | success_msg.set(format!("User '{}' created successfully", new_username())); 42 | new_username.set("".to_string()); 43 | new_password.set("".to_string()); 44 | fetch_users().await; 45 | } 46 | Err(e) => error.set(format!("Failed to create user: {e}")), 47 | } 48 | }; 49 | 50 | let handle_delete_user = move |id: String| async move { 51 | match auth.call(delete_user(id)).await { 52 | Ok(_) => { 53 | success_msg.set("User deleted successfully".to_string()); 54 | fetch_users().await; 55 | } 56 | Err(e) => error.set(format!("Failed to delete user: {e}")), 57 | } 58 | }; 59 | 60 | let handle_update_user = move |id: String| async move { 61 | if edit_user_password().is_empty() { 62 | error.set("Password cannot be empty".to_string()); 63 | return; 64 | } 65 | match auth 66 | .call(update_user_password(id, edit_user_password())) 67 | .await 68 | { 69 | Ok(_) => { 70 | success_msg.set("User updated successfully".to_string()); 71 | editing_user_id.set(None); 72 | edit_user_password.set("".to_string()); 73 | fetch_users().await; 74 | } 75 | Err(e) => error.set(format!("Failed to update user: {e}")), 76 | } 77 | }; 78 | 79 | rsx! { 80 | div { class: "bg-beet-panel border border-white/10 p-6 rounded-lg shadow-2xl relative z-10", 81 | h2 { class: "text-xl font-bold mb-4 text-beet-accent font-display", 82 | "User Management" 83 | } 84 | 85 | // Local Messages 86 | if !error().is_empty() { 87 | div { class: "mb-4 p-4 bg-red-900/20 border border-red-500/50 rounded text-red-400 font-mono text-sm", 88 | "{error}" 89 | } 90 | } 91 | if !success_msg().is_empty() { 92 | div { class: "mb-4 p-4 bg-green-900/20 border border-green-500/50 rounded text-green-400 font-mono text-sm", 93 | "{success_msg}" 94 | } 95 | } 96 | 97 | // Create User 98 | div { class: "grid grid-cols-1 md:grid-cols-2 gap-4 mb-4", 99 | div { 100 | label { class: "block text-xs font-mono text-gray-400 mb-1 uppercase tracking-wider", 101 | "New Username" 102 | } 103 | input { 104 | class: "w-full p-2 rounded bg-beet-dark border border-white/10 focus:border-beet-accent focus:outline-none text-white font-mono", 105 | value: "{new_username}", 106 | oninput: move |e| new_username.set(e.value()), 107 | placeholder: "Username", 108 | "type": "text", 109 | } 110 | } 111 | div { 112 | label { class: "block text-xs font-mono text-gray-400 mb-1 uppercase tracking-wider", 113 | "New Password" 114 | } 115 | input { 116 | class: "w-full p-2 rounded bg-beet-dark border border-white/10 focus:border-beet-accent focus:outline-none text-white font-mono", 117 | value: "{new_password}", 118 | oninput: move |e| new_password.set(e.value()), 119 | placeholder: "Password", 120 | "type": "password", 121 | } 122 | } 123 | } 124 | button { 125 | class: "retro-btn mb-6 rounded", 126 | onclick: handle_create_user, 127 | "Create User" 128 | } 129 | 130 | // User List 131 | h3 { class: "text-lg font-bold mb-2 text-white font-display border-b border-white/10 pb-2", 132 | "Existing Users" 133 | } 134 | if users.read().is_empty() { 135 | p { class: "text-gray-500 font-mono italic", "No users found." } 136 | } else { 137 | ul { class: "space-y-2", 138 | { 139 | users 140 | .read() 141 | .clone() 142 | .into_iter() 143 | .map(|user| { 144 | let id_update = user.id.clone(); 145 | let id_edit = user.id.clone(); 146 | let id_delete = user.id.clone(); 147 | rsx! { 148 | li { class: "bg-white/5 border border-white/5 p-3 rounded hover:border-beet-accent/30 transition-colors", 149 | if editing_user_id() == Some(user.id.clone()) { 150 | div { class: "flex flex-col gap-2", 151 | div { class: "font-bold text-white font-display", "{user.username}" } 152 | input { 153 | class: "p-2 rounded bg-beet-dark border border-white/10 focus:border-beet-accent text-white font-mono text-sm", 154 | value: "{edit_user_password}", 155 | oninput: move |e| edit_user_password.set(e.value()), 156 | placeholder: "New Password", 157 | "type": "password", 158 | } 159 | div { class: "flex gap-2 mt-2", 160 | button { 161 | class: "text-xs uppercase tracking-wider font-bold text-beet-leaf hover:text-white transition-colors", 162 | onclick: move |_| handle_update_user(id_update.clone()), 163 | "[ Save ]" 164 | } 165 | button { 166 | class: "text-xs uppercase tracking-wider font-bold text-gray-500 hover:text-white transition-colors", 167 | onclick: move |_| editing_user_id.set(None), 168 | "[ Cancel ]" 169 | } 170 | } 171 | } 172 | } else { 173 | div { class: "flex justify-between items-center", 174 | span { class: "font-bold text-white font-display", "{user.username}" } 175 | div { class: "flex gap-3", 176 | button { 177 | class: "text-xs font-mono text-gray-400 hover:text-beet-accent transition-colors underline decoration-dotted", 178 | onclick: move |_| { 179 | editing_user_id.set(Some(id_edit.clone())); 180 | edit_user_password.set("".to_string()); 181 | }, 182 | "Change Password" 183 | } 184 | button { 185 | class: "text-xs font-mono text-gray-400 hover:text-red-400 transition-colors underline decoration-dotted", 186 | onclick: move |_| handle_delete_user(id_delete.clone()), 187 | "Delete" 188 | } 189 | } 190 | } 191 | } 192 | } 193 | } 194 | }) 195 | } 196 | } 197 | } 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /lib/soulbeet/src/slskd/utils.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use shared::slskd::MatchResult; 3 | use std::{collections::HashSet, path::Path, sync::LazyLock}; 4 | 5 | static RE_NON_WORD: LazyLock = LazyLock::new(|| Regex::new(r"[^\w\s]").unwrap()); 6 | static RE_LEAD_TRACK_FIXED: LazyLock = 7 | LazyLock::new(|| Regex::new(r"^\s*(\d{1,3}|[A-D]\d{1,2})\s*[\.\-]\s*").unwrap()); 8 | static RE_TRAIL_BRACKET: LazyLock = 9 | LazyLock::new(|| Regex::new(r"\s*\[\s*[^\]]*\]\s*$").unwrap()); 10 | static RE_TRAIL_YEAR: LazyLock = 11 | LazyLock::new(|| Regex::new(r"\s*[-\(\[]?\d{4}[-\)\]]?\s*$").unwrap()); 12 | 13 | // A struct to hold pre-processed text for efficient comparisons. 14 | #[derive(Debug, Clone)] 15 | struct CleanedText { 16 | original: String, 17 | words: HashSet, 18 | } 19 | 20 | impl CleanedText { 21 | fn new(s: &str) -> Self { 22 | let original = s.to_string(); 23 | let s = s.replace('_', " "); 24 | let cleaned = RE_NON_WORD.replace_all(&s, " ").to_string(); 25 | let words = cleaned 26 | .to_lowercase() 27 | .split_whitespace() 28 | .filter(|w| !w.trim().is_empty()) 29 | .map(|w| w.to_string()) 30 | .collect(); 31 | CleanedText { original, words } 32 | } 33 | 34 | pub fn words(&self) -> &HashSet { 35 | &self.words 36 | } 37 | } 38 | 39 | fn jaccard_sim(a: &CleanedText, b: &CleanedText) -> f64 { 40 | let inter = a.words().intersection(b.words()).count(); 41 | let uni = a.words().len() + b.words().len() - inter; 42 | if uni == 0 { 43 | 0.0 44 | } else { 45 | inter as f64 / uni as f64 46 | } 47 | } 48 | 49 | fn containment_sim(candidate: &CleanedText, target: &CleanedText) -> f64 { 50 | if target.words().is_empty() { 51 | return 0.0; 52 | } 53 | let inter = candidate.words().intersection(target.words()).count(); 54 | inter as f64 / target.words().len() as f64 55 | } 56 | 57 | fn dice_sim(a: &CleanedText, b: &CleanedText) -> f64 { 58 | let inter = a.words().intersection(b.words()).count(); 59 | let total = a.words().len() + b.words().len(); 60 | if total == 0 { 61 | 1.0 62 | } else { 63 | (2.0 * inter as f64) / total as f64 64 | } 65 | } 66 | 67 | fn clean_name(name: &str) -> String { 68 | let name = name.replace('_', " "); 69 | let mut cleaned = RE_LEAD_TRACK_FIXED.replace(&name, "").to_string(); 70 | cleaned = RE_TRAIL_BRACKET.replace(&cleaned, "").to_string(); 71 | cleaned = RE_TRAIL_YEAR.replace(&cleaned, "").to_string(); 72 | cleaned.trim().to_string() 73 | } 74 | 75 | fn extract_track_title(stem: &str) -> String { 76 | let stem_clean = clean_name(stem); 77 | if let Some(pos) = stem_clean.rfind(" - ") { 78 | stem_clean[pos + 3..].trim().to_string() 79 | } else { 80 | stem_clean 81 | } 82 | } 83 | 84 | #[derive(Debug)] 85 | struct PathInfo { 86 | parent_folders: Vec, 87 | stem: String, 88 | } 89 | 90 | impl PathInfo { 91 | fn from_path(path_str: &str) -> Self { 92 | let normalized_path_str = path_str.replace('\\', "/"); 93 | let path = Path::new(&normalized_path_str); 94 | 95 | let parent_folders = path 96 | .ancestors() 97 | .skip(1) 98 | .filter_map(|p| p.file_name()) 99 | .filter_map(|s| s.to_str()) 100 | .map(|s| s.to_string()) 101 | .collect::>(); 102 | 103 | let stem = path 104 | .file_stem() 105 | .and_then(|s| s.to_str()) 106 | .unwrap_or("") 107 | .to_string(); 108 | 109 | let mut reversed_folders = parent_folders; 110 | reversed_folders.reverse(); 111 | 112 | Self { 113 | parent_folders: reversed_folders, 114 | stem, 115 | } 116 | } 117 | } 118 | 119 | fn score_album(folders: &[CleanedText], target_album: &CleanedText) -> (f64, CleanedText) { 120 | folders 121 | .iter() 122 | .map(|folder| { 123 | let score = 124 | (jaccard_sim(folder, target_album) + containment_sim(folder, target_album)) / 2.0; 125 | (score, folder.clone()) 126 | }) 127 | .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)) 128 | .unwrap_or((0.0, CleanedText::new(""))) 129 | } 130 | 131 | fn score_artist( 132 | folders: &[CleanedText], 133 | stem: &CleanedText, 134 | target_artist: &CleanedText, 135 | ) -> (f64, CleanedText) { 136 | let folder_candidate = folders 137 | .iter() 138 | .map(|folder| (containment_sim(folder, target_artist), folder.clone())) 139 | .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)) 140 | .unwrap_or((0.0, CleanedText::new(""))); 141 | 142 | let stem_artist_part = if let Some(pos) = stem.original.rfind(" - ") { 143 | clean_name(&stem.original[..pos]) 144 | } else { 145 | clean_name(&stem.original) 146 | }; 147 | let stem_candidate_c = CleanedText::new(&stem_artist_part); 148 | let stem_score = containment_sim(&stem_candidate_c, target_artist); 149 | 150 | if stem_score > folder_candidate.0 { 151 | return (stem_score, stem_candidate_c); 152 | } 153 | if folder_candidate.0 > stem_score { 154 | return folder_candidate; 155 | } 156 | 157 | if stem_score > 0.9 && stem_candidate_c.original.len() > folder_candidate.1.original.len() { 158 | return (stem_score, stem_candidate_c); 159 | } 160 | folder_candidate 161 | } 162 | 163 | fn score_track(stem: &CleanedText, expected_tracks: &[CleanedText]) -> (f64, CleanedText) { 164 | if expected_tracks.is_empty() { 165 | return (1.0, CleanedText::new(&extract_track_title(&stem.original))); 166 | } 167 | 168 | let track_title_from_stem = CleanedText::new(&extract_track_title(&stem.original)); 169 | 170 | expected_tracks 171 | .iter() 172 | .map(|expected| { 173 | let score = (dice_sim(&track_title_from_stem, expected) * 0.6) 174 | + (containment_sim(&track_title_from_stem, expected) * 0.4); 175 | (score, expected.clone()) 176 | }) 177 | .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)) 178 | .unwrap_or((0.0, CleanedText::new(""))) 179 | } 180 | 181 | pub fn rank_match( 182 | filename: &str, 183 | searched_artist: Option<&str>, 184 | searched_album: Option<&str>, 185 | expected_tracks: &[&str], 186 | ) -> MatchResult { 187 | const ALBUM_WEIGHT: f64 = 0.4; 188 | const TRACK_WEIGHT: f64 = 0.4; 189 | const ARTIST_WEIGHT: f64 = 0.2; 190 | // If the album score is below this, we assume the path has no useful album info 191 | // and we don't penalize the score for it. 192 | const ALBUM_INFO_THRESHOLD: f64 = 0.25; 193 | 194 | let path_info = PathInfo::from_path(filename); 195 | let path_folders_c: Vec<_> = path_info 196 | .parent_folders 197 | .iter() 198 | .map(|f| CleanedText::new(&clean_name(f))) 199 | .collect(); 200 | let stem_c = CleanedText::new(&path_info.stem); 201 | 202 | let (artist_score, best_artist_guess) = if let Some(artist_str) = searched_artist { 203 | let searched_artist_c = CleanedText::new(artist_str); 204 | score_artist(&path_folders_c, &stem_c, &searched_artist_c) 205 | } else { 206 | (0.0, CleanedText::new("")) 207 | }; 208 | 209 | let (album_score, best_album_folder) = if let Some(album_str) = searched_album { 210 | let searched_album_c = CleanedText::new(album_str); 211 | score_album(&path_folders_c, &searched_album_c) 212 | } else { 213 | (0.0, CleanedText::new("")) 214 | }; 215 | 216 | let (track_score, best_track_match) = if !expected_tracks.is_empty() { 217 | let expected_tracks_c: Vec<_> = expected_tracks 218 | .iter() 219 | .map(|t| CleanedText::new(t)) 220 | .collect(); 221 | score_track(&stem_c, &expected_tracks_c) 222 | } else { 223 | ( 224 | 0.0, 225 | CleanedText::new(&extract_track_title(&stem_c.original)), 226 | ) 227 | }; 228 | 229 | let mut weighted_sum = 0.0; 230 | let mut total_weight = 0.0; 231 | 232 | if searched_artist.is_some() { 233 | weighted_sum += artist_score * ARTIST_WEIGHT; 234 | total_weight += ARTIST_WEIGHT; 235 | } 236 | 237 | if !expected_tracks.is_empty() { 238 | weighted_sum += track_score * TRACK_WEIGHT; 239 | total_weight += TRACK_WEIGHT; 240 | } 241 | 242 | if searched_album.is_some() { 243 | weighted_sum += album_score * ALBUM_WEIGHT; 244 | if album_score > ALBUM_INFO_THRESHOLD { 245 | total_weight += ALBUM_WEIGHT; 246 | } 247 | } 248 | 249 | let total_score = if total_weight > 0.0 { 250 | weighted_sum / total_weight 251 | } else { 252 | 0.0 253 | }; 254 | 255 | MatchResult { 256 | guessed_artist: best_artist_guess.original, 257 | guessed_album: best_album_folder.original, 258 | matched_track: best_track_match.original, 259 | artist_score, 260 | album_score, 261 | track_score, 262 | total_score, 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /ui/src/components/search/download_results.rs: -------------------------------------------------------------------------------- 1 | use dioxus::logger::tracing::info; 2 | use dioxus::prelude::*; 3 | use shared::slskd::{AlbumResult, TrackResult}; 4 | use std::collections::HashSet; 5 | 6 | use crate::{use_auth, Checkbox}; 7 | 8 | #[derive(Props, PartialEq, Clone)] 9 | pub struct Props { 10 | pub results: Vec, 11 | pub is_searching: bool, 12 | #[props(into)] 13 | pub on_download: EventHandler<(Vec, String)>, 14 | } 15 | 16 | #[derive(Props, Clone, PartialEq)] 17 | struct AlbumResultItemProps { 18 | album: AlbumResult, 19 | selected_tracks: Signal>, 20 | on_album_select_all: EventHandler, 21 | on_track_toggle: EventHandler, 22 | } 23 | 24 | #[derive(Props, Clone, PartialEq)] 25 | struct TrackItemProps { 26 | track: TrackResult, 27 | is_selected: bool, 28 | on_toggle: EventHandler, 29 | } 30 | 31 | fn get_track_id(track: &TrackResult) -> String { 32 | format!("{}{}", track.base.filename, track.base.username) 33 | } 34 | 35 | #[component] 36 | fn TrackItem(props: TrackItemProps) -> Element { 37 | let unique_id = get_track_id(&props.track); 38 | 39 | rsx! { 40 | li { 41 | key: "{unique_id}", 42 | class: "flex items-center gap-2 p-1 rounded-md hover:bg-white/10 cursor-pointer", 43 | onclick: move |_| props.on_toggle.call(unique_id.clone()), 44 | 45 | Checkbox { is_selected: props.is_selected } 46 | 47 | label { class: "cursor-pointer text-gray-300 font-mono text-sm", "{props.track.title}" } 48 | } 49 | } 50 | } 51 | 52 | #[component] 53 | fn AlbumResultItem(props: AlbumResultItemProps) -> Element { 54 | let album = props.album.clone(); 55 | 56 | rsx! { 57 | div { 58 | key: "{album.album_path}", 59 | class: "bg-white/5 border border-white/5 p-4 rounded-md", 60 | div { class: "flex justify-between items-center mb-2", 61 | div { class: "flex-grow", 62 | h4 { class: "text-md font-bold text-beet-leaf", "{album.album_title}" } 63 | p { class: "text-sm text-gray-400 font-mono", 64 | "{album.artist.clone().unwrap_or_default()} - Quality: {album.dominant_quality}, Score: {album.score:.2}" 65 | } 66 | } 67 | button { 68 | class: "font-mono uppercase text-[10px] whitespace-nowrap tracking-widest px-3 py-1 border border-beet-leaf/30 text-beet-leaf hover:bg-beet-leaf hover:text-beet-dark transition-colors cursor-pointer rounded", 69 | onclick: move |_| props.on_album_select_all.call(album.clone()), 70 | "Select All" 71 | } 72 | } 73 | ul { class: "space-y-1", 74 | for track in props.album.tracks { 75 | TrackItem { 76 | is_selected: props.selected_tracks.read().contains(&get_track_id(&track)), 77 | track, 78 | on_toggle: props.on_track_toggle, 79 | } 80 | } 81 | 82 | } 83 | } 84 | } 85 | } 86 | 87 | /// Main component responsible for displaying all download options. 88 | #[component] 89 | pub fn DownloadResults(props: Props) -> Element { 90 | let mut selected_tracks = use_signal(HashSet::::new); 91 | let results = props.results.clone(); 92 | let mut folders = use_signal(std::vec::Vec::new); 93 | let mut selected_folder = use_signal(|| "".to_string()); 94 | let auth = use_auth(); 95 | 96 | use_future(move || async move { 97 | if let Ok(user_folders) = auth.call(api::get_user_folders()).await { 98 | info!("Fetched {} user folders", user_folders.len()); 99 | 100 | // Only select if the user has exactly one folder 101 | // It could be error prone to auto-select if there are multiple folders 102 | // (I failed multiple times because of this) 103 | if user_folders.len() == 1 { 104 | selected_folder.set(user_folders[0].path.clone()); 105 | } 106 | folders.set(user_folders); 107 | } 108 | }); 109 | 110 | let handle_album_select_all = move |album_result: AlbumResult| { 111 | let mut selected = selected_tracks.write(); 112 | let all_selected = album_result 113 | .tracks 114 | .iter() 115 | .all(|t| selected.contains(&get_track_id(t))); 116 | 117 | if all_selected { 118 | for track in &album_result.tracks { 119 | selected.remove(&get_track_id(track)); 120 | } 121 | } else { 122 | for track in &album_result.tracks { 123 | selected.insert(get_track_id(track)); 124 | } 125 | } 126 | }; 127 | 128 | let handle_track_toggle = move |filename: String| { 129 | info!("Toggle track selection: {}", filename); 130 | let mut selected = selected_tracks.write(); 131 | if selected.contains(&filename) { 132 | selected.remove(&filename); 133 | } else { 134 | selected.insert(filename); 135 | } 136 | }; 137 | 138 | let handle_download = move |_| { 139 | let selected_ids = selected_tracks.read(); 140 | 141 | let tracks_to_download: Vec = props 142 | .results 143 | .iter() 144 | .flat_map(|album_result| album_result.tracks.iter()) 145 | .filter(|track| selected_ids.contains(&get_track_id(track))) 146 | .cloned() 147 | .collect(); 148 | props 149 | .on_download 150 | .call((tracks_to_download, selected_folder())); 151 | }; 152 | 153 | rsx! { 154 | div { class: "bg-beet-panel border border-white/10 text-white p-6 sm:p-8 rounded-lg shadow-2xl w-full max-w-2xl mx-auto my-10 font-display relative", 155 | h3 { class: "text-2xl font-bold mb-6 text-center text-beet-accent", "Download Options" } 156 | div { class: "mb-4", 157 | label { 158 | r#for: "dl_folder", 159 | class: "block text-sm font-medium mb-1 text-gray-400 font-mono", 160 | "Select Target Folder" 161 | } 162 | select { 163 | name: "dl_folder", 164 | class: "w-full p-2 rounded bg-beet-dark border border-white/10 focus:border-beet-accent focus:outline-none text-white font-mono", 165 | value: "{selected_folder}", 166 | onchange: move |e| selected_folder.set(e.value()), 167 | option { value: "", disabled: true, "Select a folder" } 168 | for folder in folders.read().iter() { 169 | option { value: "{folder.path}", "{folder.name}" } 170 | } 171 | } 172 | } 173 | 174 | div { class: "space-y-4", 175 | if props.is_searching { 176 | div { class: "flex flex-col items-center justify-center p-4 bg-white/5 rounded-lg", 177 | div { class: "animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-beet-accent mb-2" } 178 | p { class: "text-sm text-gray-300 animate-pulse text-center font-mono", 179 | "Searching... The rarer your track is, the longer the search can take." 180 | } 181 | } 182 | } else if results.is_empty() { 183 | div { class: "text-center text-gray-500 py-8 font-mono", "No results found" } 184 | } 185 | for album in results { 186 | AlbumResultItem { 187 | album, 188 | selected_tracks, 189 | on_album_select_all: handle_album_select_all, 190 | on_track_toggle: handle_track_toggle, 191 | } 192 | } 193 | } 194 | div { class: "fixed bottom-8 right-8", 195 | button { 196 | class: "bg-beet-accent hover:bg-fuchsia-400 text-white font-bold p-4 rounded-full shadow-[0_0_15px_rgba(255,0,255,0.5)] transition-transform hover:scale-105 disabled:bg-gray-600 disabled:cursor-not-allowed disabled:shadow-none flex items-center justify-center cursor-pointer", 197 | disabled: selected_tracks.read().is_empty() || selected_folder.read().is_empty(), 198 | onclick: handle_download, 199 | svg { 200 | class: "w-6 h-6", 201 | fill: "none", 202 | stroke: "currentColor", 203 | view_box: "0 0 24 24", 204 | path { 205 | stroke_linecap: "round", 206 | stroke_linejoin: "round", 207 | stroke_width: "2", 208 | d: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4", 209 | } 210 | } 211 | } 212 | } 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /lib/shared/src/slskd.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::Path; 3 | 4 | use std::fmt; 5 | 6 | use serde::de::{self, SeqAccess, Visitor}; 7 | use serde::{Deserialize, Deserializer, Serialize}; 8 | use serde_json::Value; 9 | 10 | #[derive(Debug, Clone, Serialize, Deserialize)] 11 | pub struct DownloadRequest { 12 | pub username: String, 13 | pub filename: String, 14 | pub file_size: i64, 15 | } 16 | 17 | #[derive(Serialize, Deserialize, Clone, Debug)] 18 | pub struct DownloadResponse { 19 | pub username: String, 20 | pub filename: String, 21 | pub size: u64, 22 | pub error: Option, 23 | } 24 | 25 | #[derive(Debug, Clone, PartialEq, PartialOrd, Serialize, Deserialize)] 26 | pub enum DownloadState { 27 | InProgress, 28 | Importing, 29 | Queued, 30 | Downloaded, 31 | Imported, 32 | Unknown(String), 33 | Aborted, 34 | Cancelled, 35 | Errored, 36 | ImportSkipped, 37 | ImportFailed, 38 | } 39 | 40 | impl From for DownloadState { 41 | fn from(s: String) -> Self { 42 | match s.as_str() { 43 | "Queued" => DownloadState::Queued, 44 | "InProgress" => DownloadState::InProgress, 45 | "Completed" => DownloadState::Downloaded, 46 | "Aborted" => DownloadState::Aborted, 47 | "Cancelled" => DownloadState::Cancelled, 48 | "Errored" => DownloadState::Errored, 49 | "Importing" => DownloadState::Importing, 50 | "Imported" => DownloadState::Imported, 51 | "ImportSkipped" => DownloadState::ImportSkipped, 52 | "ImportFailed" => DownloadState::ImportFailed, 53 | _ => DownloadState::Unknown(s), 54 | } 55 | } 56 | } 57 | 58 | // The exact structure of a single file entry 59 | #[derive(Debug, Deserialize, Serialize, PartialEq, Clone)] 60 | #[serde(rename_all = "camelCase")] 61 | pub struct FileEntry { 62 | pub id: String, 63 | pub username: String, 64 | pub direction: String, 65 | pub filename: String, 66 | pub size: u64, 67 | #[serde(default)] 68 | pub start_offset: u64, 69 | #[serde(deserialize_with = "deserialize_download_state")] 70 | pub state: Vec, 71 | pub state_description: String, 72 | pub requested_at: String, 73 | pub enqueued_at: Option, 74 | #[serde(default)] 75 | pub started_at: Option, 76 | #[serde(default)] 77 | pub ended_at: Option, 78 | pub bytes_transferred: u64, 79 | #[serde(default)] 80 | pub average_speed: f64, 81 | pub bytes_remaining: u64, 82 | #[serde(default)] 83 | pub elapsed_time: Option, 84 | pub percent_complete: f64, 85 | #[serde(default)] 86 | pub remaining_time: Option, 87 | #[serde(default)] 88 | pub exception: Option, 89 | } 90 | 91 | impl FileEntry { 92 | pub fn get_state(&self) -> Vec { 93 | self.state.clone() 94 | } 95 | } 96 | 97 | fn deserialize_download_state<'de, D>(deserializer: D) -> Result, D::Error> 98 | where 99 | D: Deserializer<'de>, 100 | { 101 | struct StateVisitor; 102 | 103 | impl<'de> Visitor<'de> for StateVisitor { 104 | type Value = Vec; 105 | 106 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 107 | formatter.write_str("a comma-separated string or a sequence of DownloadStates") 108 | } 109 | 110 | fn visit_str(self, value: &str) -> Result 111 | where 112 | E: de::Error, 113 | { 114 | Ok(value 115 | .split(',') 116 | .map(|part| DownloadState::from(part.trim().to_string())) 117 | .collect()) 118 | } 119 | 120 | fn visit_seq(self, mut seq: A) -> Result 121 | where 122 | A: SeqAccess<'de>, 123 | { 124 | let mut vec = Vec::new(); 125 | while let Some(elem) = seq.next_element()? { 126 | vec.push(elem); 127 | } 128 | Ok(vec) 129 | } 130 | } 131 | 132 | deserializer.deserialize_any(StateVisitor) 133 | } 134 | 135 | // Custom deserializer that flattens everything into Vec 136 | fn deserialize_flattened_files<'de, D>(deserializer: D) -> Result, D::Error> 137 | where 138 | D: Deserializer<'de>, 139 | { 140 | // First deserialize as generic JSON to traverse it manually 141 | let v = Value::deserialize(deserializer)?; 142 | 143 | let mut files = Vec::new(); 144 | 145 | if let Value::Array(users) = v { 146 | for user in users { 147 | if let Some(directories) = user.get("directories").and_then(|d| d.as_array()) { 148 | for dir in directories { 149 | if let Some(dir_files) = dir.get("files").and_then(|f| f.as_array()) { 150 | for file in dir_files { 151 | let file_entry: FileEntry = serde_json::from_value(file.clone()) 152 | .map_err(serde::de::Error::custom)?; 153 | files.push(file_entry); 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | 161 | Ok(files) 162 | } 163 | 164 | // Final struct you actually care about 165 | #[derive(Debug, Deserialize)] 166 | pub struct DownloadHistory { 167 | #[serde(deserialize_with = "deserialize_flattened_files")] 168 | pub files: Vec, 169 | } 170 | 171 | pub struct FlattenedFiles(pub Vec); 172 | 173 | impl<'de> Deserialize<'de> for FlattenedFiles { 174 | fn deserialize(deserializer: D) -> Result 175 | where 176 | D: Deserializer<'de>, 177 | { 178 | deserialize_flattened_files(deserializer).map(FlattenedFiles) 179 | } 180 | } 181 | 182 | #[derive(Debug, Clone, Serialize)] 183 | pub struct MatchResult { 184 | pub guessed_artist: String, 185 | pub guessed_album: String, 186 | pub matched_track: String, 187 | pub artist_score: f64, 188 | pub album_score: f64, 189 | pub track_score: f64, 190 | pub total_score: f64, 191 | } 192 | 193 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 194 | pub struct TrackResult { 195 | #[serde(flatten)] 196 | pub base: SearchResult, 197 | pub artist: String, 198 | pub title: String, 199 | pub album: String, 200 | pub match_score: f64, 201 | } 202 | 203 | impl TrackResult { 204 | pub fn new(base: SearchResult, matched: MatchResult) -> Self { 205 | Self { 206 | base, 207 | artist: matched.guessed_artist, 208 | title: matched.matched_track, 209 | album: matched.guessed_album, 210 | match_score: matched.total_score, 211 | } 212 | } 213 | } 214 | 215 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 216 | #[serde(rename_all = "camelCase")] 217 | pub struct SearchResult { 218 | pub username: String, 219 | pub filename: String, 220 | pub size: i64, 221 | pub bitrate: Option, 222 | pub duration: Option, 223 | pub has_free_upload_slot: bool, 224 | pub upload_speed: i32, 225 | pub queue_length: i32, 226 | } 227 | 228 | impl SearchResult { 229 | pub fn quality(&self) -> String { 230 | Path::new(&self.filename) 231 | .extension() 232 | .and_then(|s| s.to_str()) 233 | .unwrap_or("unknown") 234 | .to_lowercase() 235 | } 236 | 237 | pub fn quality_score(&self) -> f64 { 238 | let quality_weights: HashMap<&str, f64> = [ 239 | ("flac", 1.0), 240 | ("wav", 0.85), 241 | ("m4a", 0.65), 242 | ("aac", 0.65), 243 | ("mp3", 0.55), 244 | ("ogg", 0.6), 245 | ("wma", 0.4), 246 | ] 247 | .iter() 248 | .cloned() 249 | .collect(); 250 | 251 | let mut base_score = *quality_weights.get(self.quality().as_str()).unwrap_or(&0.3); 252 | 253 | if let Some(br) = self.bitrate { 254 | if br >= 320 { 255 | base_score += 0.2; 256 | } else if br >= 256 { 257 | base_score += 0.1; 258 | } else if br < 128 { 259 | base_score -= 0.3; 260 | } 261 | } 262 | 263 | if self.has_free_upload_slot { 264 | base_score += 0.1; 265 | } 266 | if self.upload_speed > 100 { 267 | base_score += 0.05; 268 | } 269 | if self.queue_length > 10 { 270 | base_score -= 0.1; 271 | } 272 | 273 | base_score.min(1.0) 274 | } 275 | } 276 | 277 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 278 | pub struct AlbumResult { 279 | pub username: String, 280 | pub album_path: String, 281 | pub album_title: String, 282 | pub artist: Option, 283 | pub track_count: usize, 284 | pub total_size: i64, 285 | pub tracks: Vec, 286 | pub dominant_quality: String, 287 | pub has_free_upload_slot: bool, 288 | pub upload_speed: i32, 289 | pub queue_length: i32, 290 | pub score: f64, 291 | } 292 | 293 | impl AlbumResult { 294 | pub fn size_mb(&self) -> i64 { 295 | self.total_size / (1024 * 1024) 296 | } 297 | 298 | pub fn average_track_size_mb(&self) -> f64 { 299 | if self.track_count > 0 { 300 | self.size_mb() as f64 / self.track_count as f64 301 | } else { 302 | 0.0 303 | } 304 | } 305 | } 306 | 307 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 308 | pub enum SearchState { 309 | InProgress, 310 | Completed, 311 | NotFound, 312 | TimedOut, 313 | } 314 | 315 | #[derive(Debug, Clone, Serialize, Deserialize)] 316 | pub struct SearchResponse { 317 | pub search_id: String, 318 | pub results: Vec, 319 | pub has_more: bool, 320 | pub total_results: usize, 321 | pub state: SearchState, 322 | } 323 | -------------------------------------------------------------------------------- /ui/src/components/settings/folder_manager.rs: -------------------------------------------------------------------------------- 1 | use api::{create_user_folder, delete_folder, get_user_folders, update_folder}; 2 | use dioxus::prelude::*; 3 | 4 | use crate::auth::use_auth; 5 | 6 | #[component] 7 | pub fn FolderManager() -> Element { 8 | let mut folder_name = use_signal(|| "".to_string()); 9 | let mut folder_path = use_signal(|| "".to_string()); 10 | let mut folders = use_signal(Vec::new); 11 | 12 | let mut editing_folder_id = use_signal(|| None::); 13 | let mut edit_folder_name = use_signal(|| "".to_string()); 14 | let mut edit_folder_path = use_signal(|| "".to_string()); 15 | 16 | let mut error = use_signal(|| "".to_string()); 17 | let mut success_msg = use_signal(|| "".to_string()); 18 | let auth = use_auth(); 19 | 20 | let fetch_folders = move || async move { 21 | match auth.call(get_user_folders()).await { 22 | Ok(fetched_folders) => folders.set(fetched_folders), 23 | Err(e) => error.set(format!("Failed to fetch folders: {e}")), 24 | } 25 | }; 26 | 27 | use_future(move || async move { 28 | fetch_folders().await; 29 | }); 30 | 31 | let handle_add_folder = move |_| async move { 32 | error.set("".to_string()); 33 | success_msg.set("".to_string()); 34 | 35 | if folder_name().is_empty() || folder_path().is_empty() { 36 | error.set("Name and Path are required".to_string()); 37 | return; 38 | } 39 | 40 | match auth 41 | .call(create_user_folder(folder_name(), folder_path())) 42 | .await 43 | { 44 | Ok(_) => { 45 | success_msg.set("Folder added successfully".to_string()); 46 | folder_name.set("".to_string()); 47 | folder_path.set("".to_string()); 48 | fetch_folders().await; 49 | } 50 | Err(e) => error.set(format!("Failed to add folder: {e}")), 51 | } 52 | }; 53 | 54 | let handle_delete_folder = move |id: String| async move { 55 | match auth.call(delete_folder(id)).await { 56 | Ok(_) => { 57 | success_msg.set("Folder deleted successfully".to_string()); 58 | fetch_folders().await; 59 | } 60 | Err(e) => error.set(format!("Failed to delete folder: {e}")), 61 | } 62 | }; 63 | 64 | let handle_update_folder = move |id: String| async move { 65 | match auth 66 | .call(update_folder(id, edit_folder_name(), edit_folder_path())) 67 | .await 68 | { 69 | Ok(_) => { 70 | success_msg.set("Folder updated successfully".to_string()); 71 | editing_folder_id.set(None); 72 | fetch_folders().await; 73 | } 74 | Err(e) => error.set(format!("Failed to update folder: {e}")), 75 | } 76 | }; 77 | 78 | rsx! { 79 | div { class: "bg-beet-panel border border-white/10 p-6 rounded-lg shadow-2xl mb-8 relative z-10", 80 | h2 { class: "text-xl font-bold mb-4 text-beet-accent font-display", "Manage Music Folders" } 81 | 82 | // Local Messages 83 | if !error().is_empty() { 84 | div { class: "mb-4 p-4 bg-red-900/20 border border-red-500/50 rounded text-red-400 font-mono text-sm", 85 | "{error}" 86 | } 87 | } 88 | if !success_msg().is_empty() { 89 | div { class: "mb-4 p-4 bg-green-900/20 border border-green-500/50 rounded text-green-400 font-mono text-sm", 90 | "{success_msg}" 91 | } 92 | } 93 | 94 | div { class: "grid grid-cols-1 md:grid-cols-2 gap-4 mb-4", 95 | div { 96 | label { class: "block text-xs font-mono text-gray-400 mb-1 uppercase tracking-wider", 97 | "Folder Name (e.g., 'Music/Common')" 98 | } 99 | input { 100 | class: "w-full p-2 rounded bg-beet-dark border border-white/10 focus:border-beet-accent focus:outline-none text-white font-mono", 101 | value: "{folder_name}", 102 | oninput: move |e| folder_name.set(e.value()), 103 | placeholder: "My Music", 104 | "type": "text", 105 | } 106 | } 107 | div { 108 | label { class: "block text-xs font-mono text-gray-400 mb-1 uppercase tracking-wider", 109 | "Folder Path" 110 | } 111 | input { 112 | class: "w-full p-2 rounded bg-beet-dark border border-white/10 focus:border-beet-accent focus:outline-none text-white font-mono", 113 | value: "{folder_path}", 114 | oninput: move |e| folder_path.set(e.value()), 115 | placeholder: "/home/user/Music", 116 | "type": "text", 117 | } 118 | } 119 | } 120 | 121 | button { class: "retro-btn mb-6 rounded", onclick: handle_add_folder, "Add Folder" } 122 | 123 | // Existing Folders List 124 | h3 { class: "text-lg font-bold mb-2 text-white font-display border-b border-white/10 pb-2", 125 | "Existing Folders" 126 | } 127 | if folders.read().is_empty() { 128 | p { class: "text-gray-500 font-mono italic", "No folders added yet." } 129 | } else { 130 | ul { class: "space-y-2", 131 | { 132 | folders 133 | .read() 134 | .clone() 135 | .into_iter() 136 | .map(|folder| { 137 | let id_edit = folder.id.clone(); 138 | let id_delete = folder.id.clone(); 139 | let id_update = folder.id.clone(); 140 | rsx! { 141 | li { class: "bg-white/5 border border-white/5 p-3 rounded hover:border-beet-accent/30 transition-colors", 142 | if editing_folder_id() == Some(folder.id.clone()) { 143 | div { class: "flex flex-col gap-2", 144 | input { 145 | class: "p-2 rounded bg-beet-dark border border-white/10 focus:border-beet-accent text-white font-mono text-sm", 146 | value: "{edit_folder_name}", 147 | oninput: move |e| edit_folder_name.set(e.value()), 148 | placeholder: "Name", 149 | } 150 | input { 151 | class: "p-2 rounded bg-beet-dark border border-white/10 focus:border-beet-accent text-white font-mono text-sm", 152 | value: "{edit_folder_path}", 153 | oninput: move |e| edit_folder_path.set(e.value()), 154 | placeholder: "Path", 155 | } 156 | div { class: "flex gap-2 mt-2", 157 | button { 158 | class: "text-xs uppercase tracking-wider font-bold text-beet-leaf hover:text-white transition-colors", 159 | onclick: move |_| handle_update_folder(id_update.clone()), 160 | "[ Save ]" 161 | } 162 | button { 163 | class: "text-xs uppercase tracking-wider font-bold text-gray-500 hover:text-white transition-colors", 164 | onclick: move |_| editing_folder_id.set(None), 165 | "[ Cancel ]" 166 | } 167 | } 168 | } 169 | } else { 170 | div { class: "flex justify-between items-center", 171 | div { 172 | span { class: "font-bold text-white block font-display", "{folder.name}" } 173 | span { class: "text-gray-500 text-xs font-mono", "{folder.path}" } 174 | } 175 | div { class: "flex gap-3", 176 | button { 177 | class: "text-xs font-mono text-gray-400 hover:text-beet-accent transition-colors underline decoration-dotted", 178 | onclick: move |_| { 179 | edit_folder_name.set(folder.name.clone()); 180 | edit_folder_path.set(folder.path.clone()); 181 | editing_folder_id.set(Some(id_edit.clone())); 182 | }, 183 | "Edit" 184 | } 185 | button { 186 | class: "text-xs font-mono text-gray-400 hover:text-red-400 transition-colors underline decoration-dotted", 187 | onclick: move |_| handle_delete_folder(id_delete.clone()), 188 | "Delete" 189 | } 190 | } 191 | } 192 | } 193 | } 194 | } 195 | }) 196 | } 197 | } 198 | } 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /api/src/server_fns/download.rs: -------------------------------------------------------------------------------- 1 | use dioxus::fullstack::{CborEncoding, Streaming}; 2 | use dioxus::logger::tracing::{info, warn}; 3 | use dioxus::prelude::*; 4 | use shared::slskd::{DownloadResponse, DownloadState, FileEntry, TrackResult}; 5 | 6 | #[cfg(feature = "server")] 7 | use tokio::sync::broadcast; 8 | 9 | #[cfg(feature = "server")] 10 | use soulbeet::beets; 11 | 12 | use super::server_error; 13 | 14 | // Import the extractor we created in the previous step 15 | #[cfg(feature = "server")] 16 | use crate::AuthSession; 17 | 18 | #[cfg(feature = "server")] 19 | use crate::globals::{SLSKD_CLIENT, USER_CHANNELS}; 20 | 21 | #[cfg(feature = "server")] 22 | use chrono::Utc; 23 | 24 | #[cfg(feature = "server")] 25 | use uuid::Uuid; 26 | 27 | #[cfg(feature = "server")] 28 | fn resolve_download_path(filename: &str, download_base: &std::path::Path) -> Option { 29 | // Normalize path separators (win -> linux) 30 | let filename_str = filename.replace('\\', "/"); 31 | let path = std::path::Path::new(&filename_str); 32 | let components: Vec<_> = path.components().collect(); 33 | 34 | // Keep only the last directory and filename (d1/d2/d3/file -> d3/file) 35 | if components.len() >= 2 { 36 | let len = components.len(); 37 | let last_dir = components[len - 2].as_os_str(); 38 | let file_name = components[len - 1].as_os_str(); 39 | 40 | let relative_path = std::path::PathBuf::from(last_dir).join(file_name); 41 | let full_path = download_base.join(relative_path); 42 | 43 | Some(full_path.to_string_lossy().to_string()) 44 | } else { 45 | // Fallback 46 | let full_path = download_base.join(path); 47 | Some(full_path.to_string_lossy().to_string()) 48 | } 49 | } 50 | 51 | #[cfg(feature = "server")] 52 | async fn import_track( 53 | entry: FileEntry, 54 | target_path: std::path::PathBuf, 55 | tx: broadcast::Sender>, 56 | ) { 57 | let download_path_base = 58 | std::env::var("SLSKD_DOWNLOAD_PATH").unwrap_or_else(|_| "/downloads".to_string()); 59 | let download_path_buf = std::path::PathBuf::from(&download_path_base); 60 | 61 | if let Some(path) = resolve_download_path(&entry.filename, &download_path_buf) { 62 | info!("Importing path: {:?}", path); 63 | 64 | // Notify Importing 65 | let mut importing_entry = entry.clone(); 66 | importing_entry.state = vec![DownloadState::Importing]; 67 | let _ = tx.send(vec![importing_entry.clone()]); 68 | 69 | match beets::import(vec![path], &target_path).await { 70 | Ok(beets::ImportResult::Success) => { 71 | info!("Beet import successful"); 72 | let mut imported_entry = entry.clone(); 73 | imported_entry.state = vec![DownloadState::Imported]; 74 | let _ = tx.send(vec![imported_entry]); 75 | } 76 | Ok(beets::ImportResult::Skipped) => { 77 | info!("Beet import skipped item"); 78 | let mut skipped_entry = entry.clone(); 79 | skipped_entry.state = vec![DownloadState::ImportSkipped]; 80 | let _ = tx.send(vec![skipped_entry]); 81 | } 82 | Ok(beets::ImportResult::Failed(err)) => { 83 | info!("Beet import failed item"); 84 | let mut failed_entry = entry.clone(); 85 | failed_entry.state = vec![DownloadState::ImportFailed]; 86 | failed_entry.state_description = format!("Beet import failed: {err}"); 87 | let _ = tx.send(vec![failed_entry]); 88 | } 89 | Err(e) => { 90 | info!("Beet import failed or returned unknown status: {e}"); 91 | let mut failed_entry = entry.clone(); 92 | failed_entry.state = vec![DownloadState::ImportFailed]; 93 | failed_entry.state_description = format!("Import error: {}", e); 94 | let _ = tx.send(vec![failed_entry]); 95 | } 96 | } 97 | } else { 98 | warn!("Could not resolve path for file: {}", entry.filename); 99 | let mut failed_entry = entry.clone(); 100 | failed_entry.state = vec![DownloadState::ImportFailed]; 101 | failed_entry.state_description = "Could not resolve file path".to_string(); 102 | let _ = tx.send(vec![failed_entry]); 103 | } 104 | } 105 | 106 | #[cfg(feature = "server")] 107 | async fn slskd_download(tracks: Vec) -> Result, ServerFnError> { 108 | SLSKD_CLIENT.download(tracks).await.map_err(server_error) 109 | } 110 | 111 | #[get("/api/downloads/updates", auth: AuthSession)] 112 | pub async fn download_updates_stream( 113 | ) -> Result, CborEncoding>, ServerFnError> { 114 | let username = auth.0.username; 115 | 116 | let rx = { 117 | let mut map = USER_CHANNELS.write().await; 118 | let tx = map.entry(username.clone()).or_insert_with(|| { 119 | let (tx, _) = broadcast::channel(100); 120 | tx 121 | }); 122 | tx.subscribe() 123 | }; 124 | 125 | Ok(Streaming::spawn(move |tx_stream| async move { 126 | let mut rx = rx; 127 | loop { 128 | match rx.recv().await { 129 | Ok(downloads) => { 130 | if tx_stream.unbounded_send(downloads).is_err() { 131 | break; 132 | } 133 | } 134 | Err(e) => { 135 | // unexpected error or lag 136 | warn!("Broadcast receive error: {}", e); 137 | } 138 | } 139 | } 140 | })) 141 | } 142 | 143 | #[post("/api/downloads/queue", auth: AuthSession)] 144 | pub async fn download( 145 | tracks: Vec, 146 | target_folder: String, 147 | ) -> Result, ServerFnError> { 148 | let username = auth.0.username; 149 | 150 | let target_path_buf = std::path::Path::new(&target_folder).to_path_buf(); 151 | if let Err(e) = tokio::fs::create_dir_all(&target_path_buf).await { 152 | return Err(server_error(format!( 153 | "Failed to create target directory: {}", 154 | e 155 | ))); 156 | } 157 | 158 | let res = slskd_download(tracks).await?; 159 | 160 | let (failed, successful): (Vec<_>, Vec<_>) = 161 | res.iter().cloned().partition(|d| d.error.is_some()); 162 | 163 | let tx = { 164 | let mut map = USER_CHANNELS.write().await; 165 | map.entry(username.clone()) 166 | .or_insert_with(|| { 167 | let (tx, _) = broadcast::channel(100); 168 | tx 169 | }) 170 | .clone() 171 | }; 172 | 173 | if !failed.is_empty() { 174 | let failed_entries: Vec = failed 175 | .iter() 176 | .map(|d| FileEntry { 177 | id: Uuid::new_v4().to_string(), 178 | username: d.username.clone(), 179 | direction: "Download".to_string(), 180 | filename: d.filename.clone(), 181 | size: d.size, 182 | start_offset: 0, 183 | state: vec![DownloadState::Errored], 184 | state_description: d.error.clone().unwrap_or_default(), 185 | requested_at: Utc::now().to_rfc3339(), 186 | enqueued_at: None, 187 | started_at: None, 188 | ended_at: None, 189 | bytes_transferred: 0, 190 | average_speed: 0.0, 191 | bytes_remaining: d.size, 192 | elapsed_time: None, 193 | percent_complete: 0.0, 194 | remaining_time: None, 195 | exception: d.error.clone(), 196 | }) 197 | .collect(); 198 | 199 | let _ = tx.send(failed_entries); 200 | } 201 | 202 | let download_filenames: Vec = successful.iter().map(|d| d.filename.clone()).collect(); 203 | let target_path = target_path_buf; 204 | 205 | if download_filenames.is_empty() { 206 | return Ok(res); 207 | } 208 | 209 | info!("Started monitoring downloads: {:?}", download_filenames); 210 | 211 | tokio::spawn(async move { 212 | let mut interval = tokio::time::interval(std::time::Duration::from_secs(2)); 213 | let mut attempts = 0; 214 | const MAX_ATTEMPTS: usize = 600; // ~20 minutes timeout 215 | 216 | loop { 217 | interval.tick().await; 218 | attempts += 1; 219 | 220 | if attempts > MAX_ATTEMPTS { 221 | info!( 222 | "Download monitoring timed out for batch {:?}", 223 | download_filenames 224 | ); 225 | break; 226 | } 227 | 228 | match SLSKD_CLIENT.get_all_downloads().await { 229 | Ok(downloads) => { 230 | let batch_status: Vec<_> = downloads 231 | .iter() 232 | .filter(|file| download_filenames.contains(&file.filename)) 233 | .cloned() 234 | .collect(); 235 | 236 | if !batch_status.is_empty() { 237 | let _ = tx.send(batch_status.clone()); 238 | } 239 | 240 | // If we can't find any of our downloads, they might have been cleared or invalid 241 | if batch_status.is_empty() { 242 | info!("No active downloads found for batch, assuming completed or lost."); 243 | break; 244 | } 245 | 246 | // TODO: Parallelize imports, do not wait for all to finish downloading before starting imports 247 | let all_finished = batch_status.iter().all(|d| { 248 | d.state.iter().any(|s| { 249 | matches!( 250 | s, 251 | DownloadState::Downloaded 252 | | DownloadState::Aborted 253 | | DownloadState::Cancelled 254 | | DownloadState::Errored 255 | ) 256 | }) 257 | }); 258 | 259 | if all_finished { 260 | let successful_downloads: Vec<_> = batch_status 261 | .iter() 262 | .filter(|d| { 263 | d.state 264 | .iter() 265 | .any(|s| matches!(s, DownloadState::Downloaded)) 266 | }) 267 | .cloned() 268 | .collect(); 269 | 270 | if !successful_downloads.is_empty() { 271 | info!( 272 | "Downloads completed ({} successful). Starting granular import to {:?}", 273 | successful_downloads.len(), 274 | target_path 275 | ); 276 | 277 | for download in successful_downloads { 278 | import_track(download, target_path.clone(), tx.clone()).await; 279 | } 280 | } else { 281 | info!("Downloads finished but none succeeded. Skipping import."); 282 | } 283 | break; 284 | } 285 | } 286 | Err(e) => { 287 | info!("Error fetching download status: {}", e); 288 | } 289 | } 290 | } 291 | }); 292 | 293 | Ok(res) 294 | } 295 | -------------------------------------------------------------------------------- /ui/src/components/search/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod album; 2 | pub mod context; 3 | pub mod track; 4 | 5 | pub use context::SearchReset; 6 | 7 | use dioxus::logger::tracing::{info, warn}; 8 | use dioxus::prelude::*; 9 | use shared::download::DownloadQuery; 10 | use shared::musicbrainz::{AlbumWithTracks, SearchResult}; 11 | use shared::slskd::{ 12 | AlbumResult as SlskdAlbumResult, SearchState, TrackResult as SlskdTrackResult, 13 | }; 14 | use shared::system::SystemHealth; 15 | 16 | use track::TrackResult; 17 | 18 | use crate::search::album::AlbumResult; 19 | use crate::{use_auth, Album, AlbumHeader, Button, Modal, SystemStatus}; 20 | 21 | mod download_results; 22 | use download_results::DownloadResults; 23 | 24 | mod search_type_toggle; 25 | use search_type_toggle::{SearchType, SearchTypeToggle}; 26 | 27 | #[component] 28 | pub fn Search() -> Element { 29 | let auth = use_auth(); 30 | let mut response = use_signal::>>(|| None); 31 | let mut search = use_signal(String::new); 32 | let mut artist = use_signal::>(|| None); 33 | let mut search_type = use_signal(|| SearchType::Album); 34 | let mut loading = use_signal(|| false); 35 | let mut viewing_album = use_signal::>(|| None); 36 | let mut download_options = use_signal::>>(|| None); 37 | let search_reset = try_use_context::(); 38 | 39 | let mut system_status = use_signal(SystemHealth::default); 40 | 41 | use_future(move || async move { 42 | loop { 43 | if let Ok(health) = auth.call(api::get_system_health()).await { 44 | system_status.set(health); 45 | } 46 | gloo_timers::future::TimeoutFuture::new(10000).await; 47 | } 48 | }); 49 | 50 | use_effect(move || { 51 | if let Some(reset) = search_reset { 52 | if reset.0() > 0 { 53 | response.set(None); 54 | search.set(String::new()); 55 | artist.set(None); 56 | search_type.set(SearchType::Album); 57 | viewing_album.set(None); 58 | download_options.set(None); 59 | } 60 | } 61 | }); 62 | 63 | if !auth.is_logged_in() { 64 | info!("User not logged in"); 65 | return rsx! {}; 66 | } 67 | 68 | let download = move |query: DownloadQuery| async move { 69 | loading.set(true); 70 | viewing_album.set(None); 71 | download_options.set(Some(vec![])); 72 | 73 | let search_id = match auth.call(api::start_download_search(query)).await { 74 | Ok(id) => id, 75 | Err(e) => { 76 | warn!("Failed to start download search: {:?}", e); 77 | loading.set(false); 78 | return; 79 | } 80 | }; 81 | 82 | loop { 83 | match auth 84 | .call(api::poll_download_search(search_id.clone())) 85 | .await 86 | { 87 | Ok(response) => { 88 | download_options.with_mut(|current| { 89 | if let Some(list) = current { 90 | for new_album in response.results { 91 | if let Some(pos) = list.iter().position(|x| { 92 | x.username == new_album.username 93 | && x.album_path == new_album.album_path 94 | }) { 95 | // Safeguard against incomplete albums 96 | list[pos] = new_album; 97 | } else { 98 | list.push(new_album); 99 | } 100 | } 101 | 102 | // Resort new results by score 103 | list.sort_by(|a, b| { 104 | b.score 105 | .partial_cmp(&a.score) 106 | .unwrap_or(std::cmp::Ordering::Equal) 107 | }); 108 | } 109 | }); 110 | 111 | if response.state != SearchState::InProgress { 112 | break; 113 | } 114 | } 115 | Err(e) => { 116 | info!("Failed to poll search: {:?}", e); 117 | break; 118 | } 119 | } 120 | } 121 | loading.set(false); 122 | }; 123 | 124 | let download_tracks = move |(tracks, folder): (Vec, String)| async move { 125 | loading.set(true); 126 | download_options.set(None); 127 | if let Ok(_res) = auth.call(api::download(tracks, folder)).await { 128 | info!("Downloads started"); 129 | } 130 | loading.set(false); 131 | }; 132 | 133 | let perform_search = move || async move { 134 | loading.set(true); 135 | 136 | let query_data = api::SearchQuery { 137 | artist: artist(), 138 | query: search(), 139 | }; 140 | 141 | let result = match search_type() { 142 | SearchType::Album => auth.call(api::search_album(query_data)).await, 143 | SearchType::Track => auth.call(api::search_track(query_data)).await, 144 | }; 145 | 146 | if let Ok(data) = result { 147 | response.set(Some(data)); 148 | } 149 | loading.set(false); 150 | }; 151 | 152 | let view_full_album = move |album_id: String| async move { 153 | loading.set(true); 154 | 155 | if let Ok(album_data) = auth.call(api::find_album(album_id.clone())).await { 156 | viewing_album.set(Some(album_data)); 157 | } else { 158 | info!("Failed to fetch album details for {}", album_id); 159 | } 160 | loading.set(false); 161 | }; 162 | 163 | rsx! { 164 | if let Some(data) = viewing_album.read().clone() { 165 | Modal { 166 | on_close: move |_| viewing_album.set(None), 167 | header: rsx! { 168 | AlbumHeader { album: data.album.clone() } 169 | }, 170 | Album { 171 | data, 172 | on_select: move |data: DownloadQuery| { 173 | spawn(download(data)); 174 | }, 175 | } 176 | } 177 | } 178 | 179 | // bg decorations 180 | div { class: "fixed top-1/4 -left-10 w-64 h-64 bg-beet-accent/10 rounded-full blur-[150px] pointer-events-none" } 181 | div { class: "fixed bottom-1/4 -right-10 w-64 h-64 bg-beet-leaf/10 rounded-full blur-[150px] pointer-events-none" } 182 | 183 | div { class: "w-full max-w-3xl space-y-8 z-10 mx-auto flex flex-col items-center mt-20", 184 | 185 | // Title Area 186 | div { class: "text-center space-y-2", 187 | h2 { class: "text-4xl md:text-6xl font-bold tracking-tight", 188 | span { class: "text-white", "Harvest" } 189 | " " 190 | span { class: "text-beet-leaf font-light italic", "Music." } 191 | } 192 | p { class: "text-gray-400 font-mono text-sm", 193 | "Search & Download // Manage Your Library" 194 | } 195 | } 196 | 197 | // Search bar 198 | div { class: "w-full relative group", 199 | 200 | div { class: "absolute -inset-1 bg-gradient-to-r from-beet-accent to-beet-leaf rounded-t-lg rounded-b-0 md:rounded-b-lg blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200" } 201 | div { class: "relative flex items-center bg-beet-dark border border-white/10 rounded-t-lg rounded-b-0 md:rounded-b-lg p-2 shadow-2xl", 202 | div { class: "pl-4 pr-2 text-gray-500", 203 | svg { 204 | class: "w-6 h-6", 205 | fill: "none", 206 | stroke: "currentColor", 207 | view_box: "0 0 24 24", 208 | path { 209 | stroke_linecap: "round", 210 | stroke_linejoin: "round", 211 | stroke_width: "2", 212 | d: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z", 213 | } 214 | } 215 | } 216 | input { 217 | "type": "text", 218 | value: "{search}", 219 | class: "w-2/3 bg-transparent border-none focus:ring-0 text-white text-sm placeholder-gray-600 font-mono h-10 focus:outline-none", 220 | placeholder: "Search artist, album or track...", 221 | oninput: move |event| search.set(event.value()), 222 | onkeydown: move |event| { 223 | if event.key() == Key::Enter { 224 | spawn(perform_search()); 225 | } 226 | }, 227 | } 228 | 229 | div { class: "hidden md:flex h-8 w-px bg-white/10 mx-2" } 230 | input { 231 | "type": "text", 232 | value: "{artist.read().clone().unwrap_or_default()}", 233 | class: "hidden md:flex w-1/3 bg-transparent border-none focus:ring-0 text-gray-400 text-sm placeholder-gray-700 font-mono h-10 focus:outline-none", 234 | placeholder: "Artist (opt)", 235 | oninput: move |event| { 236 | let val = event.value(); 237 | if val.is_empty() { artist.set(None) } else { artist.set(Some(val)) } 238 | }, 239 | onkeydown: move |event| { 240 | if event.key() == Key::Enter { 241 | spawn(perform_search()); 242 | } 243 | }, 244 | } 245 | div { class: "hidden md:flex", 246 | SearchTypeToggle { search_type } 247 | Button { 248 | class: "rounded ml-2 whitespace-nowrap", 249 | disabled: loading() || search.read().is_empty(), 250 | onclick: move |_| { 251 | spawn(perform_search()); 252 | }, 253 | "SEARCH" 254 | } 255 | } 256 | } 257 | // Mobile search bar 258 | div { class: "md:hidden relative flex items-center bg-beet-dark border border-t-0 md:border-t border-white/10 rounded-b-lg rounded-t-0 md:rounded-t-lg p-2 shadow-2xl", 259 | input { 260 | "type": "text", 261 | value: "{artist.read().clone().unwrap_or_default()}", 262 | class: "w-full pl-4 bg-transparent border-none focus:ring-0 text-gray-400 text-sm placeholder-gray-700 font-mono h-10 focus:outline-none", 263 | placeholder: "Artist (opt)", 264 | oninput: move |event| { 265 | let val = event.value(); 266 | if val.is_empty() { artist.set(None) } else { artist.set(Some(val)) } 267 | }, 268 | onkeydown: move |event| { 269 | if event.key() == Key::Enter { 270 | spawn(perform_search()); 271 | } 272 | }, 273 | } 274 | 275 | SearchTypeToggle { search_type } 276 | 277 | Button { 278 | class: "rounded ml-2 whitespace-nowrap", 279 | disabled: loading() || search.read().is_empty(), 280 | onclick: move |_| { 281 | spawn(perform_search()); 282 | }, 283 | "SEARCH" 284 | } 285 | } 286 | } 287 | 288 | SystemStatus { health: system_status.read().clone() } 289 | 290 | // Results 291 | if let Some(results) = download_options.read().clone() { 292 | DownloadResults { 293 | results, 294 | is_searching: loading(), 295 | on_download: move |data| { 296 | spawn(download_tracks(data)); 297 | }, 298 | } 299 | } else if loading() { 300 | div { class: "flex flex-col justify-center items-center py-10", 301 | div { class: "animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-beet-accent" } 302 | } 303 | } else { 304 | match *response.read() { 305 | Some(ref items) if !items.is_empty() => rsx! { 306 | div { class: "w-full bg-beet-panel/50 border border-white/5 p-6 backdrop-blur-sm mt-8", 307 | h5 { class: "text-xl font-display font-bold mb-4 border-b border-white/10 pb-2 text-white", 308 | "Search Results" 309 | } 310 | ul { class: "list-none p-0 space-y-4", 311 | for item in items.iter() { 312 | match item { 313 | SearchResult::Track(ref track) => rsx! { 314 | li { key: "{track.id}", 315 | TrackResult { 316 | on_album_click: move |id| { 317 | spawn(view_full_album(id)); 318 | }, 319 | track: track.clone(), 320 | } 321 | } 322 | }, 323 | SearchResult::Album(album) => rsx! { 324 | li { key: "{album.id}", 325 | AlbumResult { 326 | on_click: move |id| { 327 | spawn(view_full_album(id)); 328 | }, 329 | album: album.clone(), 330 | } 331 | } 332 | }, 333 | } 334 | } 335 | } 336 | } 337 | }, 338 | Some(_) => rsx! { 339 | div { class: "text-center text-gray-500 py-10 font-mono", "No signals found in the ether." } 340 | }, 341 | None => rsx! {}, 342 | } 343 | } 344 | } 345 | } 346 | } 347 | --------------------------------------------------------------------------------