xbst/src/main.rs

456 lines
13 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Soundtrack> = Default::default();
let mut song_groups: Vec<Vec<SongGroup>> = Default::default();
let mut total_songs = 0;
let mut total_song_groups = 0;
let mut files_to_convert: Vec<PathBuf> = 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<PathBuf>), Errors> {
let mut files_to_convert: Vec<PathBuf> = Default::default();
let mut songs: Vec<Song> = 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::<Vec<u8>>();
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<SongGroup>, usize, usize, Vec<PathBuf>), Errors> {
let mut files_to_convert: Vec<PathBuf> = Default::default();
let mut songs_count = 0;
let mut song_groups: Vec<SongGroup> = Default::default();
let mut song_groups_ids: Vec<i32> = 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::<Vec<[u8; 2]>>();
// Max value of 32
soundtrack_name.resize(32, [0; 2]);
let files = read_dir(soundtrack)?
.filter_map(|x| x.ok())
.collect::<Vec<_>>();
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<i32, Errors> {
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::<f32>().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<Soundtrack>,
songs: Vec<Vec<SongGroup>>,
) -> 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(())
}