Initial commit

This commit is contained in:
shinya 2026-01-29 15:33:44 +01:00
commit 6dd66b582b
7 changed files with 224 additions and 0 deletions

15
Cargo.toml Normal file
View File

@ -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"] }

20
flake.nix Normal file
View File

@ -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}";
};
};
}

1
src/cli.rs Normal file
View File

@ -0,0 +1 @@

15
src/json_parsing.rs Normal file
View File

@ -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::<serde_json::Value>(&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}"),
}
}

31
src/lrclib.rs Normal file
View File

@ -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<String> {
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
}
}
}

43
src/main.rs Normal file
View File

@ -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<Vec<PathBuf>>,
#[arg(short, long)]
directory: Option<PathBuf>,
#[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")
}
}
}

99
src/music.rs Normal file
View File

@ -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<TrackInfo, MusicError> {
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<Duration, MusicError> {
mp3_duration::from_path(path).map_err(|_| MusicError::DurationRead(path.display().to_string()))
}