mod disc_id; mod musicbrainz; use clap::{ Parser, Subcommand, builder::styling::{AnsiColor, Effects, Styles}, }; use lofty::{file::TaggedFileExt, probe::Probe, tag::Accessor}; use sha2::{Digest, Sha256}; use std::{ fs::{self, File, create_dir_all, remove_dir}, io::{BufReader, Read}, path::{Path, PathBuf}, }; const CLAP_STYLING: Styles = Styles::styled() .header(AnsiColor::Green.on_default().effects(Effects::BOLD)) .usage(AnsiColor::Green.on_default().effects(Effects::BOLD)) .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) .placeholder(AnsiColor::Cyan.on_default()); #[cfg(not(target_os = "linux"))] compile_error!("kitty like linux more"); fn check_cat_hash(cat_path: &Path) -> bool { let file = File::open(cat_path).expect("how dare u! i want my cat back :(\n"); let mut cat = BufReader::new(file); let mut hasher = Sha256::new(); let mut buffer = [0u8; 4096]; loop { let bytes_read = cat.read(&mut buffer).expect("what happened to my cat!?\n"); if bytes_read == 0 { break; } hasher.update(&buffer[..bytes_read]); } let file_hash = hex::encode(hasher.finalize()); file_hash .eq_ignore_ascii_case("45d4cf2fb11999c39ec5cd0cbedb436767421c8b8dab575c76f0b6ad9265d640") } fn hashcat(cat_path: &Path) { if !check_cat_hash(cat_path) { panic!("this is not my cat. where is my cat. what did you do!? KITTYYYYYYYYYY") } } #[derive(Parser)] #[command(name = "timbre")] #[command(about = "a music manager to finally solve this huge mess of music management")] #[command(styles=CLAP_STYLING)] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand, PartialEq)] enum Commands { /// give timbre a home so it can help you Init, /// look through your library and see what's there Scan, /// attempt to repair timbre's home, this won't restore lost tracks Repair, /// import music files Import, } fn is_audio(path: &Path) -> bool { match path.extension().and_then(|e| e.to_str()) { Some(ext) => { if matches!( ext.to_lowercase().as_str(), "flac" | "mp3" | "m4a" | "ogg" | "opus" | "wav" ) { true } else { println!( "while looking through each of your songs, i found one that i don't understand.\nits named {}, and i don't understand its file extension.\nyou can try to help teach me by opening a git issue if it's a music file! :)\ni will continue, but ignore this file :(", path.display() ); false } } None => false, } } fn scan(dir: &Path, out: &mut Vec) { for entry in fs::read_dir(dir) .expect("i want to help you, but i had trouble reading my music folder :( sorry.\ntry running timbre repair!\n") { let path=entry.expect( "i wanna help you, but, i can't do everything. i couldn't read a file, to the point where i can't tell you which :(\n").path(); if path.is_dir() { scan(&path, out); } else if is_audio(&path) { out.push(path); } } } enum SafeName { Ok(String), Empty, Malicious, } fn sanitize(name: &str) -> SafeName { let trimmed = name.trim(); if trimmed == ".." || trimmed == "." { return SafeName::Malicious; } let cleaned = trimmed.replace(['/', '\\'], "-"); let cleaned = cleaned.trim(); if cleaned.is_empty() { SafeName::Empty } else { SafeName::Ok(cleaned.to_string()) } } fn remove_empty_dirs(dir: &Path) { if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { remove_empty_dirs(&path); let _ = fs::remove_dir(&path); } } } } fn main() { musicbrainz::get_disc_info("http://catgpt-server:5000"); let cat = include_bytes!("../cat_01.jpg"); // my kitty! let cli = Cli::parse(); let timbre_dir = Path::new(&std::env::var("HOME").expect("NERD!!!")) .join("Music") .join("Timbre"); if cli.command != Commands::Init && cli.command != Commands::Repair { if timbre_dir.exists() { hashcat(&timbre_dir.join("cat_01.jpg")); // meow } else { println!( "i can't help you since i don't have a home :(\nyou can create one with the command timbre init." ); return; } } match cli.command { Commands::Init => { if !timbre_dir.exists() { println!("making a home..."); create_dir_all(&timbre_dir) .expect("i couldn't create my house :( i don't know what to do :(\n"); fs::write(timbre_dir.join("cat_01.jpg"), cat) .expect("my cat! he can't come! what did you do!?!\n"); for dir in ["Albums", "Singles", "Unidentified"] { create_dir_all(timbre_dir.join(dir)) .expect("sorry, but i couldn't create a folder that i need.\n"); } println!("i now have a home!"); } else { println!("i already has a home!"); } } Commands::Scan => { let mut tracks = Vec::new(); scan(&timbre_dir.join("Singles"), &mut tracks); scan(&timbre_dir.join("Albums"), &mut tracks); if tracks.is_empty() { println!("i couldn't find any audio files :("); } else { println!("i found {} audio files :)", tracks.len()); } } Commands::Import => { let import_dir = timbre_dir.join("Import"); if !import_dir.exists() { create_dir_all(&import_dir) .expect("i can't make the folder for you to put your music in :(\n"); println!("i made an Import folder! put your music there and run this again."); return; } let mut to_import = Vec::new(); scan(&import_dir, &mut to_import); if to_import.is_empty() { println!("the Import folder is empty :( put some music in it first!"); return; } println!("found {} files to import!", to_import.len()); for file in to_import { let tagged = Probe::open(&file) .expect("i couldn't open this file :(\n") .read() .expect("i couldn't read this file's tags :(\n"); match tagged.primary_tag() { Some(tag) => { let track = tag.track(); let artist = match tag.artist() { None => SafeName::Empty, Some(a) => sanitize(&a), }; let album = match tag.album() { None => None, Some(a) => Some(sanitize(&a)), }; let title = match tag.title() { None => SafeName::Empty, Some(t) => sanitize(&t), }; if matches!(artist, SafeName::Malicious) || matches!(title, SafeName::Malicious) || matches!(album, Some(SafeName::Malicious)) { println!( "hey, help! i just found this file: {}\nit seems to be trying to do something bad and hurt your computer!\ni'm moving it to a Quarantine folder for you to check\nyou should probably check the source of it.\ntip: upload it to https://virustotal.com\n", file.display() ); create_dir_all(&timbre_dir.join("Quarantine")).expect("i can't create the Quarantine folder, could be something normal, potentially malware :(\nbe careful with the file! it's still in the Import folder.\ntip: upload it to https://virustotal.com\n"); fs::rename(&file, &timbre_dir.join("Quarantine").join(file.file_name().expect("no filename??\n"))) .expect("i couldn't move this file :(, still in the Import folder, be careful!\ntip: upload it to https://virustotal.com\n"); continue; } let (SafeName::Ok(artist), SafeName::Ok(title)) = (artist, title) else { let unid = timbre_dir.join("Unidentified"); fs::rename(&file, unid.join(file.file_name().unwrap())) .expect("i couldn't move an Unidentified file to a folder :(\nis the folder missing? try timbre repair!\n"); continue; }; let ext = file.extension().and_then(|e| e.to_str()).unwrap_or(""); let dest = match album { Some(SafeName::Ok(album)) => { let filename = match track { Some(n) => format!("{:02} {}.{}", n, title, ext), None => format!("{}.{}", title, ext), }; timbre_dir .join("Albums") .join(&artist) .join(&album) .join(filename) } _ => timbre_dir .join("Singles") .join(&artist) .join(format!("{}.{}", title, ext)), }; if !dest.starts_with(&timbre_dir) { println!( "hey, help! i just found this file: {}\nit seems to be trying to do something bad and hurt your computer!\ni'm moving it to a Quarantine folder for you to check\nyou should probably check the source of it.\ntip: upload it to https://virustotal.com\n", file.display() ); create_dir_all(&timbre_dir.join("Quarantine")).expect("i can't create the Quarantine folder, could be something normal, potentially malware :(\nbe careful with the file! it's still in the Import folder.\ntip: upload it to https://virustotal.com\n"); fs::rename(&file, &timbre_dir.join("Quarantine").join(file.file_name().expect("no filename??\n"))) .expect("i couldn't move this file :(, still in the Import folder, be careful!\ntip: upload it to https://virustotal.com\n"); continue; } if dest.exists() { println!("already there, skipping {} :(", file.display()); continue; } create_dir_all(dest.parent().unwrap()) .expect("can't create directory to put a file in :(\n"); fs::rename(&file, &dest).expect("can't move file to directory :(\n"); } None => { println!("no tags on {} :(", file.display()); } } } remove_empty_dirs(&import_dir); let _ = remove_dir(&import_dir); println!("all done! cleaned up the Import folder if it's empty :)"); } Commands::Repair => { println!("repairing my home..."); let mut fixed_something = false; if !timbre_dir.exists() { println!( "it appears that my home is gone. your songs may have been deleted :(\nrebuilding my home..." ); create_dir_all(&timbre_dir) .expect("i couldn't create my house :( i don't know what to do :(\n"); fs::write(timbre_dir.join("cat_01.jpg"), cat) .expect("my cat! he can't come! what did you do!?!\n"); for dir in ["Albums", "Singles", "Unidentified"] { create_dir_all(timbre_dir.join(dir)) .expect("sorry, but i couldn't create a folder that i need.\n"); } println!("i rebuilt my home! your audio won't be there though :("); fixed_something = true; } if !timbre_dir.join("Albums").exists() || !timbre_dir.join("Singles").exists() || !timbre_dir.join("Unidentified").exists() { println!("i found folders that don't exist! recreating them..."); for dir in ["Albums", "Singles", "Unidentified"] { create_dir_all(timbre_dir.join(dir)) .expect("sorry, but i couldn't create a folder that i need.\n"); } println!("done!"); fixed_something = true; } if !timbre_dir.join("cat_01.jpg").exists() { println!("where did my cat go!?!"); fs::write(timbre_dir.join("cat_01.jpg"), cat) .expect("my cat! he can't come! what did you do!?!\n"); println!("he's back :)"); fixed_something = true; } if !check_cat_hash(&timbre_dir.join("cat_01.jpg")) { println!("what happened to my cat :( let me bring my kitty back."); fs::write(timbre_dir.join("cat_01.jpg"), cat) .expect("what did you do! i can't bring my cat back :(\n"); println!("yay he's back!"); fixed_something = true; } if !fixed_something { println!( "i wasn't able to fix anything :(\neither there is no problem, or i couldn't fix it :(" ); } } } }