Clippy fixes
This commit is contained in:
127
Cargo.lock
generated
127
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
10
src/db.rs
10
src/db.rs
@@ -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],
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
203
src/main.rs
203
src/main.rs
@@ -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);
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user