From 6dd66b582b6dd495e5bb22c0663a219e43ac85f0 Mon Sep 17 00:00:00 2001 From: shinya Date: Thu, 29 Jan 2026 15:33:44 +0100 Subject: [PATCH] Initial commit --- Cargo.toml | 15 +++++++ flake.nix | 20 +++++++++ src/cli.rs | 1 + src/json_parsing.rs | 15 +++++++ src/lrclib.rs | 31 ++++++++++++++ src/main.rs | 43 ++++++++++++++++++++ src/music.rs | 99 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 224 insertions(+) create mode 100644 Cargo.toml create mode 100644 flake.nix create mode 100644 src/cli.rs create mode 100644 src/json_parsing.rs create mode 100644 src/lrclib.rs create mode 100644 src/main.rs create mode 100644 src/music.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..297c5b1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lrclib-downloader" +version = "0.1.0" +edition = "2024" + +[dependencies] +thiserror = "1.0" +audiotags = "0.5.0" +http = "1.4.0" +mp3-duration = "0.1.10" +urlencoding = "2.1.3" +reqwest = { version = "0.11", features = ["blocking", "rustls-tls"] } +serde_json = "1.0.149" +serde = "1.0.228" +clap = { version = "4.5.55", features = ["derive"] } diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d5179e4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,20 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + }; + outputs = { self, nixpkgs }: + let pkgs = nixpkgs.legacyPackages."x86_64-linux"; in { + devShells."x86_64-linux".default = pkgs.mkShell { + buildInputs = with pkgs; [ + cargo + rustc + rustfmt + clippy + rust-analyzer + openssl + pkg-config + ]; + env.RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}"; + }; + }; +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1 @@ + diff --git a/src/json_parsing.rs b/src/json_parsing.rs new file mode 100644 index 0000000..8d5b340 --- /dev/null +++ b/src/json_parsing.rs @@ -0,0 +1,15 @@ +use crate::lrclib; +use std::time::Duration; + +pub fn get_lyrics_text(artist_name: &str, track_name: &str, album_name: &str, duration: Duration) { + match lrclib::get_lyrics(artist_name, track_name, album_name, duration) { + Some(lyrics_json) => match serde_json::from_str::(&lyrics_json) { + Ok(parsed) => { + let sync_lyrics = &parsed["syncedLyrics"]; + println!("{sync_lyrics}\n"); + } + Err(e) => eprintln!("Failed to parse lyrics JSON: {e}"), + }, + None => eprintln!("Failed to fetch lyrics for {track_name}"), + } +} diff --git a/src/lrclib.rs b/src/lrclib.rs new file mode 100644 index 0000000..14c6dfc --- /dev/null +++ b/src/lrclib.rs @@ -0,0 +1,31 @@ +use std::time::Duration; +use urlencoding::encode; + +pub fn get_lyrics( + artist_name: &str, + track_name: &str, + album_name: &str, + duration: Duration, +) -> Option { + let duration_secs = duration.as_secs(); + + let url = format!( + "https://lrclib.net/api/get?artist_name={}&track_name={}&album_name={}&duration={}", + encode(artist_name), + encode(track_name), + encode(album_name), + duration_secs + ); + + match reqwest::blocking::get(&url) { + Ok(response) if response.status().is_success() => response.text().ok(), + Ok(response) => { + eprintln!("Request failed with status: {}", response.status()); + None + } + Err(e) => { + eprintln!("Request error: {}", e); + None + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..02227cc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,43 @@ +use clap::Parser; +use std::path::PathBuf; + +mod json_parsing; +mod lrclib; +mod music; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + #[arg(short, long, num_args = 1..)] + file: Option>, + + #[arg(short, long)] + directory: Option, + + #[arg(short, long)] + watch: bool, +} + +fn main() { + let args = Args::parse(); + + if let Some(files) = &args.file { + for file in files { + if file.is_file() { + if let Err(e) = music::get_song_file(file) { + eprintln!("Failed to process {}: {e}", file.display()); + } + } else { + println!("Not a file: {:?}", file) + } + } + } else if let Some(directory) = &args.directory { + if !args.watch { + if let Err(e) = music::get_all_music_files(directory) { + eprintln!("Failed to process directory {}: {e}", directory.display()); + } + } else { + println!("Watch directory for new files") + } + } +} diff --git a/src/music.rs b/src/music.rs new file mode 100644 index 0000000..a1e285e --- /dev/null +++ b/src/music.rs @@ -0,0 +1,99 @@ +use crate::json_parsing; +use audiotags::Tag; +use std::fs; +use std::path::Path; +use std::time::Duration; +use thiserror::Error; + +#[derive(Debug)] +pub struct TrackInfo { + pub track_name: String, + pub artist_name: String, + pub album_name: String, + pub duration: Duration, +} + +#[derive(Debug, Error)] +pub enum MusicError { + #[error("failed to read directory: {0}")] + ReadDir(#[from] std::io::Error), + + #[error("failed to read audio tags from {0}")] + TagRead(String), + + #[error("failed to get song duration for {0}")] + DurationRead(String), +} + +fn is_mp3(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("mp3")) + .unwrap_or(false) +} + +pub fn get_song_file(path: &Path) -> Result<(), MusicError> { + if !is_mp3(path) { + return Ok(()); + } + + let info = get_song_info(path)?; + + println!("Track: {}", info.track_name); + println!("Artist: {}", info.artist_name); + println!("Album: {}", info.album_name); + println!("Duration: {:?}\n", info.duration); + + json_parsing::get_lyrics_text( + &info.artist_name, + &info.track_name, + &info.album_name, + info.duration, + ); + + Ok(()) +} + +pub fn get_all_music_files(directory: &Path) -> Result<(), MusicError> { + if !directory.is_dir() { + return Ok(()); + } + + for entry in fs::read_dir(directory)? { + let entry = entry?; + let path = entry.path(); + + if entry.file_type()?.is_dir() { + get_all_music_files(&path)?; + } else if let Err(e) = get_song_file(&path) { + eprintln!("Skipping file {}: {}", path.display(), e); + } + } + + Ok(()) +} + +pub fn get_song_info(path: &Path) -> Result { + let tag = Tag::new() + .read_from_path(path) + .map_err(|_| MusicError::TagRead(path.display().to_string()))?; + + let track_name = tag.title().unwrap_or("Unknown Track").to_string(); + let artist_name = tag.artist().unwrap_or("Unknown Artist").to_string(); + let album_name = tag + .album() + .map_or("Unknown Album".to_string(), |a| a.title.to_string()); + + let duration = get_song_length(path)?; + + Ok(TrackInfo { + track_name, + artist_name, + album_name, + duration, + }) +} + +pub fn get_song_length(path: &Path) -> Result { + mp3_duration::from_path(path).map_err(|_| MusicError::DurationRead(path.display().to_string())) +}