345 lines
14 KiB
Rust
345 lines
14 KiB
Rust
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<PathBuf>) {
|
|
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 :("
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|