Clippy fixes

This commit is contained in:
Connor Johnstone
2026-03-04 23:13:40 -05:00
parent 8eb6bb950e
commit d59235707d
6 changed files with 236 additions and 157 deletions

127
Cargo.lock generated
View File

@@ -8,6 +8,56 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -48,6 +98,52 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -179,6 +275,12 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -195,6 +297,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.17"
@@ -308,6 +416,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -353,6 +467,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
name = "playlists" name = "playlists"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap",
"crossterm", "crossterm",
"dotenvy", "dotenvy",
"lofty", "lofty",
@@ -619,6 +734,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -689,6 +810,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"

View File

@@ -14,3 +14,4 @@ rand = "0.9"
walkdir = "2.5" walkdir = "2.5"
crossterm = "0.28" crossterm = "0.28"
urlencoding = "2.1.3" urlencoding = "2.1.3"
clap = { version = "4", features = ["derive"] }

View File

@@ -2,6 +2,12 @@ use rusqlite::Connection;
use crate::lastfm::{SimilarArtist, TopTrack}; use crate::lastfm::{SimilarArtist, TopTrack};
/// A local track: (path, recording_mbid, title).
pub type LocalTrack = (String, Option<String>, Option<String>);
/// Track metadata: (recording_mbid, title).
pub type TrackMetadata = (Option<String>, Option<String>);
pub fn open(path: &std::path::Path) -> Result<Connection, rusqlite::Error> { pub fn open(path: &std::path::Path) -> Result<Connection, rusqlite::Error> {
let conn = Connection::open(path)?; let conn = Connection::open(path)?;
conn.execute_batch( conn.execute_batch(
@@ -62,7 +68,7 @@ pub fn get_available_similar_artists(
pub fn get_local_tracks_for_artist( pub fn get_local_tracks_for_artist(
conn: &Connection, conn: &Connection,
artist_mbid: &str, artist_mbid: &str,
) -> Result<Vec<(String, Option<String>, Option<String>)>, rusqlite::Error> { ) -> Result<Vec<LocalTrack>, rusqlite::Error> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT path, recording_mbid, title FROM tracks WHERE artist_mbid = ?1", "SELECT path, recording_mbid, title FROM tracks WHERE artist_mbid = ?1",
)?; )?;
@@ -114,7 +120,7 @@ pub fn get_top_tracks_by_name(
rows.collect() rows.collect()
} }
pub fn get_track_metadata(conn: &Connection, path: &str) -> Result<Option<(Option<String>, Option<String>)>, rusqlite::Error> { pub fn get_track_metadata(conn: &Connection, path: &str) -> Result<Option<TrackMetadata>, rusqlite::Error> {
conn.query_row( conn.query_row(
"SELECT recording_mbid, title FROM tracks WHERE path = ?1", "SELECT recording_mbid, title FROM tracks WHERE path = ?1",
[path], [path],

View File

@@ -16,15 +16,14 @@ pub struct TopTrack {
pub name: String, pub name: String,
pub mbid: Option<String>, pub mbid: Option<String>,
pub playcount: u64, pub playcount: u64,
pub listeners: u64,
} }
// Last.fm returns {"error": N, "message": "..."} on failure // Last.fm returns {"error": N, "message": "..."} on failure.
// Only used to detect error responses via serde — fields aren't read directly.
#[derive(Deserialize)] #[derive(Deserialize)]
struct ApiError { struct ApiError {
#[allow(dead_code)] #[allow(dead_code)]
error: u32, error: u32,
message: String,
} }
// Deserialization structs for the Last.fm API responses // Deserialization structs for the Last.fm API responses
@@ -62,7 +61,6 @@ struct TrackEntry {
name: String, name: String,
mbid: Option<String>, mbid: Option<String>,
playcount: String, playcount: String,
listeners: String,
} }
impl LastfmClient { impl LastfmClient {
@@ -87,14 +85,9 @@ impl LastfmClient {
extra_params: &str, extra_params: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> { ) -> Result<Option<String>, Box<dyn std::error::Error>> {
if let Some(name) = artist_name { if let Some(name) = artist_name {
let name = name.replace('\u{2010}', "-") let name = name
.replace('\u{2011}', "-") .replace(['\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}'], "-")
.replace('\u{2012}', "-") .replace(['\u{2018}', '\u{2019}'], "'");
.replace('\u{2013}', "-")
.replace('\u{2014}', "-")
.replace('\u{2015}', "-")
.replace('\u{2018}', "'")
.replace('\u{2019}', "'");
let encoded = urlencoding::encode(&name); let encoded = urlencoding::encode(&name);
let url = format!( let url = format!(
"{}?method={}&artist={}&api_key={}{}&format=json", "{}?method={}&artist={}&api_key={}{}&format=json",
@@ -163,7 +156,6 @@ impl LastfmClient {
name: t.name, name: t.name,
mbid: t.mbid.filter(|s| !s.is_empty()), mbid: t.mbid.filter(|s| !s.is_empty()),
playcount: t.playcount.parse().unwrap_or(0), playcount: t.playcount.parse().unwrap_or(0),
listeners: t.listeners.parse().unwrap_or(0),
}) })
.collect()) .collect())
} }

View File

@@ -10,9 +10,76 @@ use std::collections::HashMap;
use std::env; use std::env;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand};
use rand::distr::weighted::WeightedIndex; use rand::distr::weighted::WeightedIndex;
use rand::prelude::*; use rand::prelude::*;
#[derive(Parser)]
#[command(name = "playlists")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Index a music directory
Index {
/// Print progress
#[arg(short)]
verbose: bool,
/// Re-index already indexed artists
#[arg(short)]
force: bool,
/// Music directory to index
directory: String,
},
/// Build a playlist from similar artists
Build {
/// Print track scores
#[arg(short)]
verbose: bool,
/// Queue in MPD
#[arg(short, conflicts_with = "airsonic")]
mpd: bool,
/// Create Airsonic playlist
#[arg(short, conflicts_with = "mpd")]
airsonic: bool,
/// Interleave artists evenly
#[arg(short, conflicts_with = "random")]
shuffle: bool,
/// Fully randomize order
#[arg(short, conflicts_with = "shuffle")]
random: bool,
/// Number of tracks
#[arg(short = 'n', default_value_t = 20, value_parser = parse_positive_usize)]
count: usize,
/// Popularity bias (0=no preference, 10=heavy popular bias)
#[arg(short, default_value_t = 5, value_parser = clap::value_parser!(u8).range(0..=10))]
popularity: u8,
/// Seed file (or pick interactively)
file: Option<String>,
},
}
struct BuildOptions {
verbose: bool,
mpd: bool,
airsonic: bool,
shuffle: bool,
random: bool,
count: usize,
popularity_bias: u8,
}
fn parse_positive_usize(s: &str) -> Result<usize, String> {
let n: usize = s.parse().map_err(|e| format!("{e}"))?;
if n == 0 {
return Err("value must be greater than 0".to_string());
}
Ok(n)
}
fn db_path() -> PathBuf { fn db_path() -> PathBuf {
let data_dir = env::var("XDG_DATA_HOME") let data_dir = env::var("XDG_DATA_HOME")
.map(PathBuf::from) .map(PathBuf::from)
@@ -25,37 +92,23 @@ fn db_path() -> PathBuf {
dir.join("playlists.db") dir.join("playlists.db")
} }
fn usage(program: &str) -> ! {
eprintln!("Usage:");
eprintln!(" {program} index [-v] [-f] <directory>");
eprintln!(" {program} build [-v] [-m|-a] [-s|-r] [-n COUNT] [-p LEVEL] [file]");
std::process::exit(1);
}
fn main() { fn main() {
let args: Vec<String> = env::args().collect(); let cli = Cli::parse();
if args.len() < 2 { match cli.command {
usage(&args[0]); Command::Index { verbose, force, directory } => {
} cmd_index(verbose, force, &directory);
}
match args[1].as_str() { Command::Build { verbose, mpd, airsonic, shuffle, random, count, popularity, file } => {
"index" => cmd_index(&args), let opts = BuildOptions {
"build" => cmd_build(&args), verbose, mpd, airsonic, shuffle, random, count, popularity_bias: popularity,
_ => usage(&args[0]), };
cmd_build(opts, file.as_deref());
}
} }
} }
fn cmd_index(args: &[String]) { fn cmd_index(verbose: bool, force: bool, directory: &str) {
let verbose = args.iter().any(|a| a == "-v");
let force = args.iter().any(|a| a == "-f");
let rest: Vec<&String> = args.iter().skip(2).filter(|a| *a != "-v" && *a != "-f").collect();
if rest.len() != 1 {
eprintln!("Usage: {} index [-v] [-f] <directory>", args[0]);
std::process::exit(1);
}
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let api_key = env::var("LASTFM_API_KEY").unwrap_or_default(); let api_key = env::var("LASTFM_API_KEY").unwrap_or_default();
@@ -66,7 +119,7 @@ fn cmd_index(args: &[String]) {
let conn = db::open(&db_path()).expect("failed to open database"); let conn = db::open(&db_path()).expect("failed to open database");
let lastfm = lastfm::LastfmClient::new(api_key); let lastfm = lastfm::LastfmClient::new(api_key);
let dir = Path::new(rest[0].as_str()); let dir = Path::new(directory);
for path in filesystem::walk_music_files(dir) { for path in filesystem::walk_music_files(dir) {
let artist_mbid = match metadata::read_artist_mbid(&path) { let artist_mbid = match metadata::read_artist_mbid(&path) {
@@ -137,74 +190,12 @@ fn cmd_index(args: &[String]) {
} }
} }
fn cmd_build(args: &[String]) { fn cmd_build(opts: BuildOptions, file: Option<&str>) {
let verbose = args.iter().any(|a| a == "-v");
let mpd = args.iter().any(|a| a == "-m");
let airsonic = args.iter().any(|a| a == "-a");
let shuffle = args.iter().any(|a| a == "-s");
let random = args.iter().any(|a| a == "-r");
if shuffle && random {
eprintln!("Error: -s and -r are mutually exclusive");
std::process::exit(1);
}
if mpd && airsonic {
eprintln!("Error: -m and -a are mutually exclusive");
std::process::exit(1);
}
// Parse -n COUNT and -p LEVEL
let mut count: usize = 20;
let mut popularity_bias: u8 = 5;
let mut rest: Vec<&String> = Vec::new();
let mut iter = args.iter().skip(2);
while let Some(arg) = iter.next() {
if arg == "-v" || arg == "-m" || arg == "-a" || arg == "-s" || arg == "-r" {
continue;
} else if arg == "-n" {
match iter.next() {
Some(val) => match val.parse::<usize>() {
Ok(n) if n > 0 => count = n,
_ => {
eprintln!("Error: -n requires a positive integer");
std::process::exit(1);
}
},
None => {
eprintln!("Error: -n requires a value");
std::process::exit(1);
}
}
} else if arg == "-p" {
match iter.next() {
Some(val) => match val.parse::<u8>() {
Ok(n) if n <= 10 => popularity_bias = n,
_ => {
eprintln!("Error: -p requires an integer 0-10");
std::process::exit(1);
}
},
None => {
eprintln!("Error: -p requires a value");
std::process::exit(1);
}
}
} else {
rest.push(arg);
}
}
if rest.len() > 1 {
eprintln!("Usage: {} build [-v] [-m|-a] [-s|-r] [-n COUNT] [-p LEVEL] [file]", args[0]);
std::process::exit(1);
}
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let conn = db::open(&db_path()).expect("failed to open database"); let conn = db::open(&db_path()).expect("failed to open database");
let (artist_mbid, seed_name) = if let Some(file_arg) = rest.first() { let (artist_mbid, seed_name) = if let Some(file_arg) = file {
let path = Path::new(file_arg.as_str()); let path = Path::new(file_arg);
let mbid = match metadata::read_artist_mbid(path) { let mbid = match metadata::read_artist_mbid(path) {
Ok(Some(mbid)) => mbid, Ok(Some(mbid)) => mbid,
Ok(None) => { Ok(None) => {
@@ -239,7 +230,7 @@ fn cmd_build(args: &[String]) {
} }
}; };
build_playlist(&conn, &artist_mbid, &seed_name, count, verbose, mpd, airsonic, shuffle, random, popularity_bias); build_playlist(&conn, &artist_mbid, &seed_name, &opts);
} }
const POPULARITY_EXPONENTS: [f64; 11] = [ const POPULARITY_EXPONENTS: [f64; 11] = [
@@ -250,13 +241,7 @@ fn build_playlist(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
artist_mbid: &str, artist_mbid: &str,
seed_name: &str, seed_name: &str,
count: usize, opts: &BuildOptions,
verbose: bool,
mpd: bool,
airsonic: bool,
shuffle: bool,
random: bool,
popularity_bias: u8,
) { ) {
let similar = match db::get_available_similar_artists(conn, artist_mbid) { let similar = match db::get_available_similar_artists(conn, artist_mbid) {
Ok(a) => a, Ok(a) => a,
@@ -322,7 +307,7 @@ fn build_playlist(
let Some(playcount) = playcount else { continue }; let Some(playcount) = playcount else { continue };
let popularity = if playcount > 0 { let popularity = if playcount > 0 {
(playcount as f64 / max_playcount as f64).powf(POPULARITY_EXPONENTS[popularity_bias as usize]) (playcount as f64 / max_playcount as f64).powf(POPULARITY_EXPONENTS[opts.popularity_bias as usize])
} else { } else {
0.0 0.0
}; };
@@ -333,7 +318,7 @@ fn build_playlist(
} }
} }
if verbose { if opts.verbose {
let mut sorted = playlist.clone(); let mut sorted = playlist.clone();
sorted.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); sorted.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
for (total, popularity, similarity, artist, track_path) in &sorted { for (total, popularity, similarity, artist, track_path) in &sorted {
@@ -347,17 +332,17 @@ fn build_playlist(
.map(|(total, _, _, artist, path)| (total, artist, path)) .map(|(total, _, _, artist, path)| (total, artist, path))
.collect(); .collect();
let mut selected = generate_playlist(&candidates, count, seed_name); let mut selected = generate_playlist(&candidates, opts.count, seed_name);
if random { if opts.random {
selected.shuffle(&mut rand::rng()); selected.shuffle(&mut rand::rng());
} else if shuffle { } else if opts.shuffle {
selected = interleave_artists(selected); selected = interleave_artists(selected);
} }
let tracks: Vec<String> = selected.iter().map(|(_, _, p)| p.clone()).collect(); let tracks: Vec<String> = selected.iter().map(|(_, _, p)| p.clone()).collect();
if mpd { if opts.mpd {
let music_dir = env::var("MPD_MUSIC_DIR").unwrap_or_default(); let music_dir = env::var("MPD_MUSIC_DIR").unwrap_or_default();
if music_dir.is_empty() { if music_dir.is_empty() {
eprintln!("Error: MPD_MUSIC_DIR not set"); eprintln!("Error: MPD_MUSIC_DIR not set");
@@ -371,7 +356,7 @@ fn build_playlist(
} }
}; };
client.queue_playlist(&tracks, &music_dir); client.queue_playlist(&tracks, &music_dir);
} else if airsonic { } else if opts.airsonic {
let client = match airsonic::AirsonicClient::new() { let client = match airsonic::AirsonicClient::new() {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@@ -379,7 +364,7 @@ fn build_playlist(
std::process::exit(1); std::process::exit(1);
} }
}; };
if let Err(e) = client.create_playlist(seed_name, &tracks, conn, verbose) { if let Err(e) = client.create_playlist(seed_name, &tracks, conn, opts.verbose) {
eprintln!("Airsonic error: {e}"); eprintln!("Airsonic error: {e}");
std::process::exit(1); std::process::exit(1);
} }
@@ -422,7 +407,7 @@ fn generate_playlist(
5 => 4, 5 => 4,
_ => 5, _ => 5,
}; };
let artist_cap = ((n + divisor - 1) / divisor).max(1); let artist_cap = n.div_ceil(divisor).max(1);
while result.len() < n && !pool.is_empty() { while result.len() < n && !pool.is_empty() {
let seed_count = *artist_counts.get(seed_name).unwrap_or(&0); let seed_count = *artist_counts.get(seed_name).unwrap_or(&0);

View File

@@ -1,39 +1,7 @@
use std::path::Path; use std::path::Path;
use lofty::file::TaggedFileExt; use lofty::file::TaggedFileExt;
use lofty::tag::{ItemKey, ItemValue}; use lofty::tag::ItemKey;
/// A single key-value metadata item from a tag.
pub struct TagEntry {
pub key: String,
pub value: String,
}
/// Read all metadata items from a music file.
/// Returns `None` if no tags are present, otherwise a list of all tag entries.
pub fn read_all_metadata(path: &Path) -> Result<Option<Vec<TagEntry>>, lofty::error::LoftyError> {
let tagged_file = lofty::read_from_path(path)?;
let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else {
return Ok(None);
};
let entries = tag
.items()
.filter_map(|item| {
let value = match item.value() {
ItemValue::Text(t) | ItemValue::Locator(t) => t.clone(),
ItemValue::Binary(b) => format!("<{} bytes>", b.len()),
};
Some(TagEntry {
key: format!("{:?}", item.key()),
value,
})
})
.collect();
Ok(Some(entries))
}
/// Extract the artist name from a music file. /// Extract the artist name from a music file.
pub fn read_artist_name(path: &Path) -> Result<Option<String>, lofty::error::LoftyError> { pub fn read_artist_name(path: &Path) -> Result<Option<String>, lofty::error::LoftyError> {