tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

commit efbc80c6bd270370108e9f918e82b3fef8421ebf
parent 3cafe8063709b35562005390c1a35e90798a7ff0
Author: mtmn <miro@haravara.org>
Date:   Thu,  7 May 2026 22:07:13 +0200

chore: remove hakunadata

Diffstat:
MCargo.lock | 263+++++++------------------------------------------------------------------------
MCargo.toml | 1-
MMODULE.bazel | 1-
Dhakunadata/BUILD | 7-------
Dhakunadata/Cargo.toml | 16----------------
Dhakunadata/README.md | 51---------------------------------------------------
Dhakunadata/src/context.rs | 7-------
Dhakunadata/src/fetchers/discogs.rs | 82-------------------------------------------------------------------------------
Dhakunadata/src/fetchers/mod.rs | 2--
Dhakunadata/src/fetchers/musicbrainz.rs | 134-------------------------------------------------------------------------------
Dhakunadata/src/main.rs | 121-------------------------------------------------------------------------------
Dhakunadata/src/metadata/fetch.rs | 74--------------------------------------------------------------------------
Dhakunadata/src/metadata/genres.rs | 16----------------
Dhakunadata/src/metadata/labels.rs | 16----------------
Dhakunadata/src/metadata/mod.rs | 4----
Dhakunadata/src/metadata/subgenres.rs | 16----------------
Dhakunadata/src/tagging.rs | 204-------------------------------------------------------------------------------
17 files changed, 21 insertions(+), 994 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -473,21 +473,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -605,9 +590,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -623,22 +608,6 @@ dependencies = [ ] [[package]] -name = "hakunadata" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "lofty", - "reqwest", - "serde", - "serde_json", - "tempfile", - "tokio", - "url", - "walkdir", -] - -[[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -735,22 +704,6 @@ dependencies = [ ] [[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -903,16 +856,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - -[[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -985,9 +928,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -1041,15 +984,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] name = "lofty" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1136,23 +1070,6 @@ dependencies = [ ] [[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] name = "natord" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1180,73 +1097,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "openssl" -version = "0.10.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "openssl-sys" -version = "0.9.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1265,12 +1121,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] name = "potential_utf" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1434,15 +1284,6 @@ dependencies = [ ] [[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] name = "regex" version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1489,12 +1330,10 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -1503,10 +1342,8 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -1665,12 +1502,6 @@ dependencies = [ ] [[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1743,34 +1574,12 @@ dependencies = [ ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] name = "simd-adler32" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1990,43 +1799,19 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", - "tokio-macros", "windows-sys 0.61.2", ] [[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2066,20 +1851,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2168,12 +1953,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2224,9 +2003,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -2237,9 +2016,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -2247,9 +2026,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2257,9 +2036,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -2270,9 +2049,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -2313,9 +2092,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml @@ -2,7 +2,6 @@ resolver = "2" members = [ "diffamer", - "hakunadata", "lastfm_rs", "mack", ] diff --git a/MODULE.bazel b/MODULE.bazel @@ -32,7 +32,6 @@ crate.from_cargo( cargo_lockfile = "//:Cargo.lock", manifests = [ "//:diffamer/Cargo.toml", - "//:hakunadata/Cargo.toml", "//:mack/Cargo.toml", "//:lastfm_rs/Cargo.toml", ], diff --git a/hakunadata/BUILD b/hakunadata/BUILD @@ -1,7 +0,0 @@ -load("@crates//:defs.bzl", "all_crate_deps") -load("//bazel:rust.bzl", "rust_app") - -rust_app( - name = "hakunadata", - deps = all_crate_deps(), -) diff --git a/hakunadata/Cargo.toml b/hakunadata/Cargo.toml @@ -1,16 +0,0 @@ -[package] -name = "hakunadata" -version = "0.1.0" -edition = "2024" - -[dependencies] -anyhow = "1.0" -clap = { version = "4.4", features = ["derive"] } -lofty = "0.24.0" -reqwest = { version = "0.13", features = ["json", "native-tls", "query"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -tempfile = "3.8" -tokio = { version = "1.0", features = ["full", "macros", "rt-multi-thread"] } -url = "2.5" -walkdir = "2" diff --git a/hakunadata/README.md b/hakunadata/README.md @@ -1,51 +0,0 @@ -# hakunadata - -`hakunadata` is an opinionated tool that fetches music metadata (genres and labels) from Discogs or MusicBrainz and writes them to audio files. - -## Usage - -### Fetching metadata for an artist and album (proposal only) - -```bash -hakunadata --artist 'The Beatles' --album 'Abbey Road' -``` - -### Reading tags from a file - -This will fetch metadata based on the artist and album tags in the file and show you what tags would be written, without actually changing the file. - -```bash -hakunadata --read file.flac -``` - -### Writing tags to a file - -This will fetch metadata and write it to the file's tags. - -```bash -hakunadata --write file.flac -``` - -### Processing a directory recursively - -You can also provide a directory path to process all supported audio files (`.flac`, `.mp3`, `.ogg`, `.m4a`) within that directory and its subdirectories. - -```bash -# Show proposed tags for all files in a directory -hakunadata --read /path/to/music - -# Write tags for all files in a directory -hakunadata --write /path/to/music -``` - -## API Credentials - -The tool can use either Discogs or MusicBrainz. - -- **Discogs (Default, recommended):** For better results, especially for genre and label information, a Discogs token is recommended. You can get one from your Discogs developer settings. - - ```bash - export DISCOGS_TOKEN="your_discogs_token_here" - ``` - -- **MusicBrainz (Fallback):** If the `DISCOGS_TOKEN` environment variable is not set, the tool will fall back to using the MusicBrainz API, which does not require authentication. Results might be less detailed. diff --git a/hakunadata/src/context.rs b/hakunadata/src/context.rs @@ -1,7 +0,0 @@ -use crate::fetchers::discogs::DiscogsClient; -use crate::fetchers::musicbrainz::MusicBrainzClient; - -pub struct AppContext { - pub mb_client: Option<MusicBrainzClient>, - pub discogs_client: Option<DiscogsClient>, -} diff --git a/hakunadata/src/fetchers/discogs.rs b/hakunadata/src/fetchers/discogs.rs @@ -1,82 +0,0 @@ -use anyhow::{Context, Result}; -use reqwest::header; -use serde::Deserialize; - -const USER_AGENT: &str = "hakunadata/0.1.0 ( miro@haravara.org )"; - -pub struct DiscogsClient { - client: reqwest::Client, - token: Option<String>, -} - -impl DiscogsClient { - pub fn new() -> Result<Self> { - let mut headers = header::HeaderMap::new(); - headers.insert( - header::USER_AGENT, - header::HeaderValue::from_static(USER_AGENT), - ); - - let client = reqwest::Client::builder() - .default_headers(headers) - .build()?; - - let token = std::env::var("DISCOGS_TOKEN").ok(); - - Ok(Self { client, token }) - } - - pub async fn fetch_metadata( - &self, - artist: &str, - release: &str, - ) -> Result<Option<DiscogsResult>> { - let url = "https://api.discogs.com/database/search"; - let mut query = vec![ - ("type", "release"), - ("artist", artist), - ("release_title", release), - ]; - - let token_string; - if let Some(t) = &self.token { - token_string = t.clone(); - query.push(("token", &token_string)); - } - - let response = self - .client - .get(url) - .query(&query) - .send() - .await - .context("Failed to send Discogs request")?; - - if !response.status().is_success() { - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - use std::sync::atomic::{AtomicBool, Ordering}; - static WARNED: AtomicBool = AtomicBool::new(false); - if !WARNED.swap(true, Ordering::Relaxed) { - eprintln!("DISCOGS_TOKEN is required"); - } - } - return Ok(None); - } - - let search_result: DiscogsSearchResponse = response.json().await?; - - Ok(search_result.results.into_iter().next()) - } -} - -#[derive(Deserialize, Debug, Clone)] -pub struct DiscogsSearchResponse { - pub results: Vec<DiscogsResult>, -} - -#[derive(Deserialize, Debug, Clone)] -pub struct DiscogsResult { - pub genre: Option<Vec<String>>, - pub style: Option<Vec<String>>, - pub label: Option<Vec<String>>, -} diff --git a/hakunadata/src/fetchers/mod.rs b/hakunadata/src/fetchers/mod.rs @@ -1,2 +0,0 @@ -pub mod discogs; -pub mod musicbrainz; diff --git a/hakunadata/src/fetchers/musicbrainz.rs b/hakunadata/src/fetchers/musicbrainz.rs @@ -1,134 +0,0 @@ -use anyhow::{Context, Result}; -use reqwest::header; -use serde::Deserialize; - -const USER_AGENT: &str = "hakunadata/0.1.0 ( miro@haravara.org )"; - -pub struct MusicBrainzClient { - client: reqwest::Client, -} - -impl MusicBrainzClient { - pub fn new() -> Result<Self> { - let mut headers = header::HeaderMap::new(); - headers.insert( - header::USER_AGENT, - header::HeaderValue::from_static(USER_AGENT), - ); - - let client = reqwest::Client::builder() - .default_headers(headers) - .build()?; - - Ok(Self { client }) - } - - pub async fn fetch_genres(&self, artist: &str, release: &str) -> Result<Vec<String>> { - // First search for the release group to get a broader set of tags, or specific release. - // Let's try searching for "release" first as it is more specific, but release-group often has the tags. - // Actually, searching for release-group is usually better for genres as they adhere to the abstract album. - - let query = format!("artist:\"{artist}\" AND release:\"{release}\""); - let url = "https://musicbrainz.org/ws/2/release"; - - let response = self - .client - .get(url) - .query(&[ - ("query", query.as_str()), - ("fmt", "json"), - ("limit", "1"), // Just take the best match - ]) - .send() - .await - .context("Failed to send MusicBrainz request")?; - - if !response.status().is_success() { - // It's okay if we don't find it, but we should log it? - // For now just return empty. - return Ok(vec![]); - } - - let search_result: MbSearchResponse = response.json().await?; - - // If we found a release, let's fetch its group to get tags, OR if the release itself has tags (usually not in search result). - // The search result usually contains some info. But to get tags we might need to look up. - // Wait, the search result for 'release' DOES return a list of releases. - // If we want tags, we often need to include 'tags' in an 'inc' parameter for a direct lookup, but for search we get what we get. - // Actually, MB search API returns tags if present? Usually not. - // Better flow: - // 1. Search for release-group (or release). - // 2. Get ID. - // 3. Lookup release-group with inc=tags. - - if let Some(release_match) = search_result.releases.first() { - // If we have a release-group ID, use that. - if let Some(rg) = &release_match.release_group { - return self.lookup_release_group_tags(&rg.id).await; - } - } - - Ok(vec![]) - } - - async fn lookup_release_group_tags(&self, id: &str) -> Result<Vec<String>> { - let url = format!("https://musicbrainz.org/ws/2/release-group/{id}"); - let response = self - .client - .get(&url) - .query(&[("fmt", "json"), ("inc", "tags+genres")]) - .send() - .await?; - - if !response.status().is_success() { - return Ok(vec![]); - } - - let rg: MbReleaseGroup = response.json().await?; - let mut genres = Vec::new(); - - if let Some(tags) = rg.tags { - for tag in tags { - // Filter out garbage tags? maybe later. - genres.push(tag.name); - } - } - if let Some(genres_list) = rg.genres { - for g in genres_list { - genres.push(g.name); - } - } - - Ok(genres) - } -} - -// --- Serde Structs --- - -#[derive(Deserialize, Debug)] -struct MbSearchResponse { - #[serde(default)] - releases: Vec<MbRelease>, -} - -#[derive(Deserialize, Debug)] -struct MbRelease { - #[serde(rename = "release-group")] - release_group: Option<MbReleaseGroupRef>, -} - -#[derive(Deserialize, Debug)] -struct MbReleaseGroupRef { - id: String, -} - -#[derive(Deserialize, Debug)] -struct MbReleaseGroup { - tags: Option<Vec<MbTag>>, - genres: Option<Vec<MbTag>>, -} - -#[derive(Deserialize, Debug)] -struct MbTag { - name: String, -} diff --git a/hakunadata/src/main.rs b/hakunadata/src/main.rs @@ -1,121 +0,0 @@ -mod context; -mod fetchers; -mod metadata; -mod tagging; - -use anyhow::{Context, Result}; -use clap::Parser; -use context::AppContext; -use fetchers::discogs::DiscogsClient; -use fetchers::musicbrainz::MusicBrainzClient; -use metadata::fetch::process_query; -use std::path::Path; -use tagging::{print_metadata, process_file}; -use walkdir::WalkDir; - -static EXAMPLES: &str = r"EXAMPLES: - Fetch metadata for an artist and album: - hakunadata --artist 'Djrum' --album 'Under Tangled Silence' - - Preview tags for a single file: - hakunadata --read file.mp3 - - Write tags to a single file: - hakunadata --write file.mp3 - - Show proposed tags for all files in a directory: - hakunadata --read /path/to/music - - Write tags to all files in a directory: - hakunadata --write /path/to/music"; - -#[derive(Parser, Debug)] -#[command( - author, - version, - about, - long_about = None, - after_help = EXAMPLES -)] -struct Args { - /// Artist name - #[arg(long)] - artist: Option<String>, - - /// Album name - #[arg(long)] - album: Option<String>, - - /// File(s) to process - #[arg(required_unless_present_any = ["artist", "album"])] - files: Vec<String>, - - /// Write tags to file - #[arg(short, long, default_value_t = false)] - write: bool, - - /// Read tags from file and show what would be written - #[arg(short, long, default_value_t = true)] - read: bool, -} - -#[tokio::main] -async fn main() -> Result<()> { - let args = Args::parse(); - - let (discogs_client, mb_client) = if std::env::var("DISCOGS_TOKEN").is_ok() { - ( - Some(DiscogsClient::new().context("Failed to init Discogs client")?), - None, - ) - } else { - ( - None, - Some(MusicBrainzClient::new().context("Failed to init MusicBrainz client")?), - ) - }; - - let ctx = AppContext { - mb_client, - discogs_client, - }; - - if !args.files.is_empty() { - for path_str in args.files { - let path = Path::new(&path_str); - if !path.exists() { - eprintln!("Path does not exist: {}", path.display()); - continue; - } - - let mut files_to_process = Vec::new(); - if path.is_dir() { - // Fix redundant_closure: |e| e.ok() -> Result::ok - for entry in WalkDir::new(path).into_iter().filter_map(Result::ok) { - if entry.file_type().is_file() - && entry - .path() - .extension() - .and_then(|s| s.to_str()) - .is_some_and(|ext| matches!(ext, "flac" | "mp3" | "ogg" | "m4a")) - { - files_to_process.push(entry.path().to_path_buf()); - } - } - } else { - files_to_process.push(path.to_path_buf()); - } - - for file_path in files_to_process { - if let Err(e) = process_file(&ctx, &file_path, args.read, args.write).await { - eprintln!("Failed to process file {}: {e:?}", file_path.display()); - } - } - } - } else if let (Some(artist), Some(album)) = (args.artist, args.album) { - let result = process_query(&ctx, &artist, &album).await?; - print_metadata(&result); - } - - Ok(()) -} diff --git a/hakunadata/src/metadata/fetch.rs b/hakunadata/src/metadata/fetch.rs @@ -1,74 +0,0 @@ -use crate::context::AppContext; -use crate::metadata::{genres, labels, subgenres}; -use anyhow::Result; - -pub struct FetchedMetadata { - pub genres: Vec<String>, - pub subgenres: Vec<String>, - pub labels: Vec<String>, -} - -pub async fn process_query(ctx: &AppContext, artist: &str, album: &str) -> Result<FetchedMetadata> { - // Run fetchers concurrently (or rather, run whichever is enabled) - let discogs_future = async { - if let Some(client) = &ctx.discogs_client { - client.fetch_metadata(artist, album).await - } else { - Ok(None) - } - }; - - let mb_future = async { - if let Some(client) = &ctx.mb_client { - client.fetch_genres(artist, album).await - } else { - Ok(vec![]) - } - }; - - let (discogs_res, mb_res) = tokio::join!(discogs_future, mb_future); - - let mut genres = std::collections::HashSet::new(); - let mut subgenres = std::collections::HashSet::new(); - let mut labels = std::collections::HashSet::new(); - - // Process Discogs - if let Ok(Some(data)) = discogs_res { - let g = genres::process(&data); - for item in g { - genres.insert(item); - } - - let s = subgenres::process(&data); - for item in s { - subgenres.insert(item); - } - - let l = labels::process(&data); - for item in l { - labels.insert(item); - } - } - - // Process MusicBrainz - if let Ok(mb_genres) = mb_res { - for g in mb_genres { - genres.insert(g); - } - } - - let mut sorted_genres: Vec<_> = genres.into_iter().collect(); - sorted_genres.sort(); - - let mut sorted_subgenres: Vec<_> = subgenres.into_iter().collect(); - sorted_subgenres.sort(); - - let mut sorted_labels: Vec<_> = labels.into_iter().collect(); - sorted_labels.sort(); - - Ok(FetchedMetadata { - genres: sorted_genres, - subgenres: sorted_subgenres, - labels: sorted_labels, - }) -} diff --git a/hakunadata/src/metadata/genres.rs b/hakunadata/src/metadata/genres.rs @@ -1,16 +0,0 @@ -use crate::fetchers::discogs::DiscogsResult; -use std::collections::HashSet; - -pub fn process(result: &DiscogsResult) -> Vec<String> { - let mut genres = HashSet::<String>::new(); - - if let Some(g) = &result.genre { - for g_item in g { - genres.insert(g_item.clone()); - } - } - - let mut sorted_genres: Vec<_> = genres.into_iter().collect(); - sorted_genres.sort(); - sorted_genres -} diff --git a/hakunadata/src/metadata/labels.rs b/hakunadata/src/metadata/labels.rs @@ -1,16 +0,0 @@ -use crate::fetchers::discogs::DiscogsResult; -use std::collections::HashSet; - -pub fn process(result: &DiscogsResult) -> Vec<String> { - let mut labels = HashSet::<String>::new(); - - if let Some(l) = &result.label { - for label in l { - labels.insert(label.clone()); - } - } - - let mut sorted_labels: Vec<_> = labels.into_iter().collect(); - sorted_labels.sort(); - sorted_labels -} diff --git a/hakunadata/src/metadata/mod.rs b/hakunadata/src/metadata/mod.rs @@ -1,4 +0,0 @@ -pub mod fetch; -pub mod genres; -pub mod labels; -pub mod subgenres; diff --git a/hakunadata/src/metadata/subgenres.rs b/hakunadata/src/metadata/subgenres.rs @@ -1,16 +0,0 @@ -use crate::fetchers::discogs::DiscogsResult; -use std::collections::HashSet; - -pub fn process(result: &DiscogsResult) -> Vec<String> { - let mut subgenres = HashSet::<String>::new(); - - if let Some(s) = &result.style { - for style in s { - subgenres.insert(style.clone()); - } - } - - let mut sorted_subgenres: Vec<_> = subgenres.into_iter().collect(); - sorted_subgenres.sort(); - sorted_subgenres -} diff --git a/hakunadata/src/tagging.rs b/hakunadata/src/tagging.rs @@ -1,204 +0,0 @@ -use crate::context::AppContext; -use crate::metadata::fetch::{process_query, FetchedMetadata}; -use anyhow::{Context, Result}; -use lofty::config::WriteOptions; -use lofty::prelude::*; -use lofty::probe::Probe; -use lofty::tag::{ItemKey, Tag}; -use std::fs::File; -use std::path::{Component, Path}; - -/// Process a music file to read or write metadata tags -pub async fn process_file(ctx: &AppContext, path: &Path, read: bool, write: bool) -> Result<()> { - if !read && !write { - return Ok(()); - } - - // Normalize the path to remove relative components like './' and '../' - let normalized_path = normalize_path(path); - let abs_path = std::env::current_dir().map_or_else( - |_| normalized_path.clone(), - |cwd| cwd.join(&normalized_path), - ); - - let path_display = abs_path.display(); - - // Check if file exists - if !abs_path.exists() { - return Err(anyhow::anyhow!("File does not exist: {path_display}")); - } - - // Check if file is empty - let metadata = abs_path - .metadata() - .with_context(|| format!("Failed to read metadata for: {path_display}"))?; - - if metadata.len() == 0 { - eprintln!("Warning: File is empty, skipping: {path_display}"); - return Ok(()); - } - - // Read the file and extract current metadata - let (mut tagged_file, artist, album) = { - // Use the Probe API which can read from readers - let file = File::open(&abs_path) - .with_context(|| format!("Failed to open file: {path_display}"))?; - let mut probe = Probe::new(file); - - // Hint the file type based on extension if possible - if let Some(file_type) = path - .extension() - .and_then(|e| e.to_str()) - .and_then(lofty::file::FileType::from_ext) - { - probe = probe.set_file_type(file_type); - } - - let mut tagged_file = probe - .read() - .with_context(|| format!("Failed to read tags from {path_display}"))?; - - let tag = tagged_file - .primary_tag_mut() - .context("No primary tag found")?; - - let artist = tag.artist().context("Artist not found")?.to_string(); - let album = tag.album().context("Album not found")?.to_string(); - - (tagged_file, artist, album) - }; // file handle goes out of scope here - - println!("Processing: {artist} - {album}"); - - let result = process_query(ctx, &artist, &album).await?; - - if read { - print_proposed_tags(&result); - } - - if write { - // Get mutable reference to tag for writing - let tag = tagged_file - .primary_tag_mut() - .context("No primary tag found")?; - write_tags(tag, &result); - - // Create a backup of the original file before modifying it to prevent data loss on failure - let backup_path = abs_path.with_extension(format!( - "backup.{}", - abs_path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or("bak") - )); - - // Copy the original file to backup - std::fs::copy(&abs_path, &backup_path) - .with_context(|| format!("Failed to create backup file: {}", backup_path.display()))?; - - // Attempt to save the modified file to the original location - let save_result = tagged_file.save_to_path(&abs_path, WriteOptions::default()); - - match save_result { - Ok(()) => { - // Success: remove the backup file - let _ = std::fs::remove_file(&backup_path); // Ignore errors when removing backup - } - Err(e) => { - // Failure: restore from backup before returning the error - if std::path::Path::exists(&backup_path) { - if let Err(restore_err) = std::fs::copy(&backup_path, &abs_path) { - eprintln!( - "ERROR: Failed to restore from backup after write failure: {restore_err}", - ); - eprintln!( - "WARNING: Original file may be corrupted. Backup preserved at: {}", - backup_path.display() - ); - return Err(anyhow::anyhow!( - "Also failed to restore from backup: {restore_err}" - ) - .context(e)); - } - eprintln!("Restored file from backup after write failure"); - let _ = std::fs::remove_file(&backup_path); // Clean up backup after successful restore - } - return Err(anyhow::anyhow!("Failed to write tags to file").context(e)); - } - } - - println!("Tags written to {path_display}"); - } - - Ok(()) -} - -/// Helper function to normalize a path by removing relative components -fn normalize_path(path: &Path) -> std::path::PathBuf { - let mut result = std::path::PathBuf::new(); - - for component in path.components() { - match component { - Component::Prefix(prefix_component) => result.push(prefix_component.as_os_str()), - Component::RootDir => result.push("/"), - Component::CurDir => {} // Skip './' - Component::ParentDir => { - result.pop(); // Handle '../' by popping the last component - } - Component::Normal(os_string) => result.push(os_string), - } - } - - result -} - -/// Print the proposed tags to stdout -fn print_proposed_tags(metadata: &FetchedMetadata) { - println!("Proposed tags:"); - let genre_str = metadata.genres.join("/"); - println!(" Genre: {genre_str}"); - - if let Some(label) = metadata.labels.first() { - println!(" Label: {label}"); - } -} - -/// Write metadata to the tag -fn write_tags(tag: &mut Tag, metadata: &FetchedMetadata) { - let genre_str = metadata.genres.join("/"); - tag.insert_text(ItemKey::Genre, genre_str); - - if let Some(label) = metadata.labels.first() { - tag.insert_text(ItemKey::Label, label.clone()); - } -} - -/// Print metadata to stdout -pub fn print_metadata(result: &FetchedMetadata) { - if result.genres.is_empty() { - println!("Genres: (none)"); - } else { - println!("Genres:"); - for genre in &result.genres { - println!(" {genre}"); - } - } - - if result.subgenres.is_empty() { - println!("Subgenres: (none)"); - } else { - println!("Subgenres:"); - for subgenre in &result.subgenres { - println!(" {subgenre}"); - } - } - - if result.labels.is_empty() { - println!("Label: (none)"); - } else { - println!("Label:"); - for label in &result.labels { - println!(" {label}"); - } - } -}