use std::{ ffi::OsStr, fs::{self, read_dir, DirEntry, File}, io::{stdout, Write}, path::{Path, PathBuf}, process::Command, string::FromUtf8Error, }; mod utils; use clap::Parser; use deunicode::AsciiChars; use thiserror::Error; use zerocopy::IntoBytes; use crate::utils::{Ansi, Codec, Header, Song, SongGroup, Soundtrack}; #[derive(Error, Debug)] enum Errors { #[error("Couldn't find your input folder. {}", .0.kind())] UnknownFolder(#[source] std::io::Error), #[error(transparent)] UnknownIO(#[from] std::io::Error), #[error(transparent)] FromUtf8(#[from] FromUtf8Error), #[error("Skill issue on the programmer part ngl, report this to dev pls")] SkillIssue(), #[error("Didn't find any file to convert, is your input folder structured correctly?")] NoFileToConvert(), #[error("You are missing ffprobe in your PATH")] MissingFfprobe(#[source] std::io::Error), #[error("You are missing ffmpeg in your PATH")] MissingFfmpeg(#[source] std::io::Error), } #[derive(Parser, Debug)] #[command(version)] struct Args { /// Input folder of your musics #[arg(default_value = "./music")] input: String, /// Output folder for the database and converted musics #[arg(default_value = "./output")] output: String, /// Bitrate for the output #[arg(short, long, default_value_t = 128)] bitrate: i16, /// Codec to use for conversion #[clap(value_enum)] #[arg(short, long, default_value_t = Codec::Wmav2)] codec: Codec, } fn main() { let args = Args::parse(); println!( " ⠀⠀⠀⠂⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠈⠲⣥⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⠖⠁⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠘⠿⣿⣷⣦⣀⡀⠀⠀⢀⣠⣴⣾⣿⠟⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢼⣿⣿⣿⣿⣿⣷⡋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⢀⢾⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⢠⣺⣿⣽⣿⠟⠁⠈⠻⣿⣷⣾⣆⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⢀⢴⣾⣿⡿⠈⠀⠀⠀⠀⠀⠀⠑⢿⣿⣷⡄⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⣠⣾⣿⠟⠉⠀⠀⠀XBST⠀⠀⠀⠈⠻⣿⣦⡀⠀⠀⠀⠀ ⠀⠀⢠⣪⠟⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⣔⠀⠀⠀ ⢀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠑⠢⡀ " ); match process(&args) { Ok(_) => (), Err(e) => eprintln!("\r\x1B[K\x1b[0;31m{}\x1b[0;20m", e), } } fn process(args: &Args) -> Result<(), Errors> { let mut soundtrack_count = 0; let input_path = PathBuf::from(&args.input); let music_directory = read_dir(input_path).map_err(Errors::UnknownFolder)?; let mut soundtracks: Vec = Default::default(); let mut song_groups: Vec> = Default::default(); let mut total_songs = 0; let mut total_song_groups = 0; let mut files_to_convert: Vec = Default::default(); // Loop through each folders for the soundtrack struct for (i, soundtrack_dirs) in music_directory.enumerate() { let soundtrack = soundtrack_dirs.map_err(Errors::UnknownFolder)?.path(); // Ignore non folders for soundtracks if !soundtrack.is_dir() { continue; } soundtrack_count += 1; let st; let song_group; let mut unprocessed_files; ( st, song_group, total_songs, total_song_groups, unprocessed_files, ) = process_soundtrack( soundtrack, i, soundtrack_count, total_songs, total_song_groups, )?; soundtracks.push(st); song_groups.push(song_group); files_to_convert.append(&mut unprocessed_files); } let mut soundtrack_ids: [i32; 100] = [0; 100]; for i in 0..soundtrack_count { soundtrack_ids[i] = i as i32; } let header = Header { magic: 1, num_soundtracks: soundtrack_count as i32, next_soundtrack_id: (soundtrack_count + 1) as i32, soundtrack_ids, next_song_id: (total_songs + 1) as i32, padding: [char::MIN; 24], }; if files_to_convert.is_empty() { return Err(Errors::NoFileToConvert()); } write_database(&args.output, header, soundtracks, song_groups)?; for (i, f) in files_to_convert.iter().enumerate() { let percentage: f64 = ((i + 1) as f64 / total_songs as f64) * 100.0; print!( "{}{}\r{:3}% [{}{}] {:3}/{}", Ansi::CursorUp, Ansi::ClearLine, percentage as usize, { let mut bar = "=".repeat(percentage as usize / 3); if percentage < 100.0 { bar += ">" } bar }, " ".repeat(100 / 3 - percentage as usize / 3), i + 1, total_songs ); print!( "{}\r{}Processing {} - {}", Ansi::CursorDown, Ansi::ClearLine, f.parent() .and_then(|f| f.file_stem()) .unwrap_or_else(|| OsStr::new("Unknown soundtrack")) .to_string_lossy(), f.file_stem() .unwrap_or_else(|| OsStr::new("Unknown track")) .to_string_lossy() ); stdout().flush().map_err(Errors::UnknownIO)?; convert_to_wma(f, &args.output, args.bitrate, &args.codec, i)?; } print!("\x1B[1A\x1B[K\r\x1B[K Done."); Ok(()) } fn build_songs_group( chunks: &[DirEntry], song_group_id: usize, soundtrack_id: usize, total_songs: usize, ) -> Result<(SongGroup, Vec), Errors> { let mut files_to_convert: Vec = Default::default(); let mut songs: Vec = Default::default(); let mut song_group: SongGroup = SongGroup::default(); // Loop through each files in chunk of 6 (max songs allowed in a song group) chunks.iter().enumerate().for_each(|(i, song)| { let song_path = song.path(); // Ignore non files for song groups if !song_path.is_file() { return; } let song_id: i32 = (total_songs - chunks.len() + i) as i32; let song_time_miliseconds = match get_duration(song_path) { Ok(s) => s, Err(e) => { eprintln!("\x1b[0;31mFailed to get duration: {}\x1b[0;20m", e); 0 } }; let filepath = song.path(); let filename = filepath .file_stem() .unwrap_or_else(|| OsStr::new("Unknown track")) .to_string_lossy() .ascii_chars() .to_string(); let mut name = filename.trim().bytes().collect::>(); name.resize(32, 0); let mut song_name: [[u8; 2]; 32] = [[0; 2]; 32]; for (char_count, b) in name.iter().enumerate() { song_name[char_count] = [*b, 0]; } let s = Song { song_id, song_time_miliseconds, song_name, }; songs.push(s); files_to_convert.push(song.path()); }); for s in songs.chunks(6) { let empty_song = Song::default(); let mut empty_song_groups = s.to_vec(); empty_song_groups.resize(6, empty_song); song_group = SongGroup { magic: 200819, soundtrack_id: soundtrack_id as i32, id: song_group_id as i32, ipadding: 0, songs: empty_song_groups.try_into().unwrap(), cpadding: [char::MIN; 16], }; } Ok((song_group, files_to_convert)) } fn process_soundtrack( soundtrack: PathBuf, soundtrack_index: usize, soundtrack_count: usize, mut total_songs: usize, total_song_groups: usize, ) -> Result<(Soundtrack, Vec, usize, usize, Vec), Errors> { let mut files_to_convert: Vec = Default::default(); let mut songs_count = 0; let mut song_groups: Vec = Default::default(); let mut song_groups_ids: Vec = Vec::with_capacity(84); let mut song_group_id = total_song_groups; let soundtrack_name_str = soundtrack .file_name() .unwrap_or_else(|| OsStr::new("Unknown soundtrack")) .to_string_lossy() .trim() .ascii_chars() .to_string(); // Convert the folder name into 2 bytes let mut soundtrack_name = soundtrack_name_str .bytes() .map(|b| [b, 0]) .collect::>(); // Max value of 32 soundtrack_name.resize(32, [0; 2]); let files = read_dir(soundtrack)? .filter_map(|x| x.ok()) .collect::>(); let mut total_time_miliseconds: i32 = 0; for chunk in files.chunks(6) { total_songs += chunk.len(); songs_count += chunk.len(); let (songs, mut unconverted_files) = build_songs_group(chunk, song_group_id, soundtrack_index, total_songs)?; songs.songs.iter().for_each(|x| { if songs.soundtrack_id == soundtrack_index as i32 { total_time_miliseconds += x.song_time_miliseconds; } }); song_groups.push(songs); song_group_id += 1; files_to_convert.append(&mut unconverted_files); } for s in &song_groups { song_groups_ids.push(s.id); } song_groups_ids.resize(84, 0); let song_groups_ids: [i32; 84] = song_groups_ids .clone() .try_into() .map_err(|_| Errors::SkillIssue())?; let st = Soundtrack { magic: 136049, id: soundtrack_index as i32, num_songs: songs_count as u32, song_groups_ids, total_time_miliseconds, name: soundtrack_name .try_into() .map_err(|_| Errors::SkillIssue())?, padding: [char::MIN; 24], }; let mut soundtrack_ids: [i32; 100] = [0; 100]; for i in 0..soundtrack_count { soundtrack_ids[i] = i as i32; } Ok(( st, song_groups, total_songs, song_group_id, files_to_convert, )) } fn get_duration(path: PathBuf) -> Result { let output = Command::new("ffprobe") .args([ "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", ]) .arg(path.into_os_string()) .output() .map_err(Errors::MissingFfprobe)?; let binding = String::from_utf8(output.stdout).map_err(Errors::FromUtf8)?; let stdout = binding.trim(); Ok((stdout.parse::().unwrap() * 1000.0) as i32) } fn convert_to_wma( input: &Path, output: &String, bitrate: i16, codec: &Codec, song_index: usize, ) -> Result<(), Errors> { let binding = input.as_os_str(); let input = binding.to_str().unwrap(); fs::create_dir_all(format!("{}/0000", output)).map_err(Errors::UnknownIO)?; Command::new("ffmpeg") .args([ "-i", input, "-acodec", &codec.to_string(), "-ac", "2", "-ar", "44100", "-b:a", &format!("{}k", bitrate), "-map_metadata", "-1", "-map", "0:a", "-y", &format!("{}/0000/{:0>8x}.wma", output, song_index), ]) .output() .map_err(Errors::MissingFfmpeg)?; Ok(()) } fn write_database( output: &String, header: Header, soundtracks: Vec, songs: Vec>, ) -> Result<(), Errors> { fs::create_dir_all(format!("{}/", &output)).map_err(Errors::UnknownIO)?; let mut database = File::create(format!("{}/ST.DB", &output)).map_err(Errors::UnknownIO)?; database.write_all(header.as_bytes())?; database.write_all(soundtracks.as_bytes())?; for _ in 0..100 - &soundtracks.len() { database.write_all(&[0_u8; 512])?; } for s in songs { for s in s { database.write_all(s.magic.as_bytes())?; database.write_all(s.soundtrack_id.as_bytes())?; database.write_all(s.id.as_bytes())?; database.write_all(s.ipadding.as_bytes())?; for x in s.songs.chunks(6) { for chunk in x { database.write_all(chunk.song_id.as_bytes())?; } for chunk in x { database.write_all(chunk.song_time_miliseconds.as_bytes())?; } for chunk in x { database.write_all(chunk.song_name.as_bytes())?; } } database.write_all(s.cpadding.as_bytes())?; } } Ok(()) }