mirror of
https://github.com/Dummi26/musicdb.git
synced 2026-04-28 17:49:59 +02:00
903 lines
35 KiB
Rust
Executable File
903 lines
35 KiB
Rust
Executable File
use std::{
|
|
cmp::Ordering,
|
|
collections::{BTreeMap, HashMap},
|
|
fs,
|
|
io::Write,
|
|
path::{Path, PathBuf},
|
|
sync::{Arc, Mutex},
|
|
time::SystemTime,
|
|
};
|
|
|
|
use id3::TagLike;
|
|
use musicdb_lib::data::{
|
|
CoverId, DatabaseLocation, GeneralData,
|
|
album::Album,
|
|
artist::Artist,
|
|
database::{Cover, Database},
|
|
song::Song,
|
|
};
|
|
|
|
fn main() {
|
|
// arg parsing
|
|
let mut args = std::env::args().skip(1);
|
|
let lib_dir = if let Some(arg) = args.next() {
|
|
arg
|
|
} else {
|
|
eprintln!(
|
|
"usage: musicdb-filldb <library root> [--help] [--skip-duration] [--custom-files <path>] [... (see --help)]"
|
|
);
|
|
std::process::exit(1);
|
|
};
|
|
let mut bad_arg = false;
|
|
let mut dbdir = ".".to_owned();
|
|
let mut skip_duration = false;
|
|
let mut verbosity = 0;
|
|
let mut custom_files = None;
|
|
let mut artist_img = false;
|
|
let mut export_custom_files = None;
|
|
|
|
let mut stats = false;
|
|
|
|
let mut year_counts = BTreeMap::new();
|
|
let mut genre_counts = BTreeMap::new();
|
|
|
|
loop {
|
|
match args.next() {
|
|
None => break,
|
|
Some(arg) => match arg.as_str() {
|
|
"-v" | "--verbose" => verbosity += 1,
|
|
"--help" => {
|
|
eprintln!(
|
|
"-v, --verbose: Generate more warnings (can be specified multiple times)"
|
|
);
|
|
eprintln!("--dbdir <path>: Save dbfile in the <path> directory (default: `.`)");
|
|
eprintln!(
|
|
"--skip-duration: Don't try to figure out the songs duration from file contents. This means mp3 files with the Duration field unset will have a duration of 0."
|
|
);
|
|
eprintln!(
|
|
"--custom-files <path>: Server will use <path> as its custom-files directory. Additional data is loaded from here."
|
|
);
|
|
eprintln!(
|
|
"--cf-artist-img: For each artist, check for an <artist>.{{jpg,png,...}} file. If it exists, add ImageExt=<extension> tag to the artist, so the image can be loaded by clients later."
|
|
);
|
|
eprintln!(
|
|
"--export-custom-files <path>: Create <path> as a directory containing metadata from the *existing* dbfile, so that it can be loaded again using --custom-files <same-path>."
|
|
);
|
|
eprintln!("--stats, --statistics: Output statistics before exiting.");
|
|
return;
|
|
}
|
|
"--stats" | "--statistics" => stats = true,
|
|
"--dbdir" => {
|
|
if let Some(dir) = args.next() {
|
|
dbdir = dir;
|
|
} else {
|
|
bad_arg = true;
|
|
eprintln!("--dbdir <path> :: missing <path>!");
|
|
}
|
|
}
|
|
"--skip-duration" => skip_duration = true,
|
|
"--custom-files" => {
|
|
if let Some(path) = args.next() {
|
|
custom_files = Some(PathBuf::from(path));
|
|
} else {
|
|
bad_arg = true;
|
|
eprintln!("--custom-files <path> :: missing <path>!");
|
|
}
|
|
}
|
|
"--cf-artist-img" => artist_img = true,
|
|
"--export-custom-files" => {
|
|
if let Some(path) = args.next() {
|
|
export_custom_files = Some(PathBuf::from(path));
|
|
} else {
|
|
bad_arg = true;
|
|
eprintln!("--export-custom-files <path> :: missing <path>!");
|
|
}
|
|
}
|
|
arg => {
|
|
bad_arg = true;
|
|
eprintln!("Unknown argument: {arg}");
|
|
}
|
|
},
|
|
}
|
|
}
|
|
if export_custom_files.is_some() && (skip_duration || custom_files.is_some() || artist_img) {
|
|
bad_arg = true;
|
|
eprintln!("--export-custom-files :: incompatible with other arguments except --dbdir!");
|
|
}
|
|
if bad_arg {
|
|
return;
|
|
}
|
|
if let Some(path) = export_custom_files {
|
|
export_to_custom_files_dir(dbdir, path);
|
|
return;
|
|
}
|
|
eprintln!("Library: {lib_dir}. press enter to start. result will be saved in 'dbfile'.");
|
|
std::io::stdin().read_line(&mut String::new()).unwrap();
|
|
// start
|
|
eprintln!("finding files...");
|
|
let files = get_all_files_in_dir(&lib_dir);
|
|
let files_count = files.len();
|
|
eprintln!("found {files_count} files, reading metadata...");
|
|
let mut songs = Vec::new();
|
|
for (i, file) in files.into_iter().enumerate() {
|
|
let mut newline = OnceNewline::new();
|
|
eprint!("\r{}/{}", i + 1, files_count);
|
|
if let Ok(metadata) = file.metadata() {
|
|
_ = std::io::stderr().flush();
|
|
if let Some("mp3") = file.extension().and_then(|ext_os| ext_os.to_str()) {
|
|
match id3::Tag::read_from_path(&file) {
|
|
Err(e) => {
|
|
newline.now();
|
|
eprintln!("[{file:?}] error reading id3 tag: {e}");
|
|
}
|
|
Ok(tag) => songs.push((file, metadata, tag)),
|
|
}
|
|
}
|
|
} else {
|
|
newline.now();
|
|
eprintln!("[err] couldn't get metadata of file {:?}, skipping", file);
|
|
}
|
|
}
|
|
eprintln!("\nloaded metadata of {} files.", songs.len());
|
|
let mut database = Database::new_empty_in_dir(PathBuf::from(dbdir), PathBuf::from(&lib_dir));
|
|
let unknown_artist = database.add_artist_new(Artist {
|
|
id: 0,
|
|
name: "<unknown>".to_owned(),
|
|
cover: None,
|
|
albums: vec![],
|
|
singles: vec![],
|
|
general: GeneralData::default(),
|
|
});
|
|
eprintln!(
|
|
"searching for artists and adding songs... (this will be much faster with --skip-duration because it avoids loading and decoding all the mp3 files)"
|
|
);
|
|
let mut artists = HashMap::new();
|
|
let len = songs.len();
|
|
let mut prev_perc = 999;
|
|
songs.sort_by(|(path1, _, tags1), (path2, _, tags2)| {
|
|
// Sort by Disc->Track->Path
|
|
match (tags1.disc(), tags2.disc()) {
|
|
(Some(d1), Some(d2)) => d1.cmp(&d2),
|
|
(Some(_), None) => Ordering::Greater,
|
|
(None, Some(_)) => Ordering::Less,
|
|
(None, None) => Ordering::Equal,
|
|
}
|
|
.then_with(|| {
|
|
match (tags1.track(), tags2.track()) {
|
|
(Some(t1), Some(t2)) => t1.cmp(&t2),
|
|
(Some(_), None) => Ordering::Greater,
|
|
(None, Some(_)) => Ordering::Less,
|
|
(None, None) => Ordering::Equal,
|
|
}
|
|
.then_with(|| path1.cmp(path2))
|
|
})
|
|
});
|
|
for (i, (song_path, song_file_metadata, song_tags)) in songs.into_iter().enumerate() {
|
|
let perc = i * 100 / len;
|
|
if perc != prev_perc {
|
|
eprint!("{perc: >2}%\r");
|
|
_ = std::io::stderr().lock().flush();
|
|
prev_perc = perc;
|
|
}
|
|
let mut general = GeneralData::default();
|
|
match (song_tags.track(), song_tags.total_tracks()) {
|
|
(None, None) => {}
|
|
(Some(n), Some(t)) => general.tags.push(format!("SRCFILE:TrackNr={n}/{t}")),
|
|
(Some(n), None) => general.tags.push(format!("SRCFILE:TrackNr={n}")),
|
|
(None, Some(t)) => general.tags.push(format!("SRCFILE:TrackNr=?/{t}")),
|
|
}
|
|
match (song_tags.disc(), song_tags.total_discs()) {
|
|
(None, None) => {}
|
|
(Some(n), Some(t)) => general.tags.push(format!("SRCFILE:DiscNr={n}/{t}")),
|
|
(Some(n), None) => general.tags.push(format!("SRCFILE:DiscNr={n}")),
|
|
(None, Some(t)) => general.tags.push(format!("SRCFILE:DiscNr=?/{t}")),
|
|
}
|
|
if let Some(year) = song_tags.year() {
|
|
general.tags.push(format!("SRCFILE:Year={year}"));
|
|
if stats {
|
|
*year_counts.entry(year).or_insert(0) += 1;
|
|
}
|
|
} else if verbosity > 0 {
|
|
eprintln!("Missing year tag for file {}.", song_path.display());
|
|
}
|
|
if let Some(genre) = song_tags.genre_parsed() {
|
|
general.tags.push(format!("SRCFILE:Genre={genre}"));
|
|
if stats {
|
|
*genre_counts.entry(genre.into_owned()).or_insert(0) += 1;
|
|
}
|
|
} else if verbosity > 0 {
|
|
eprintln!("Missing genre tag for file {}.", song_path.display());
|
|
}
|
|
let (artist_id, album_id) = if let Some(artist) = song_tags
|
|
.album_artist()
|
|
.filter(|v| !v.trim().is_empty())
|
|
.or_else(|| song_tags.artist().filter(|v| !v.trim().is_empty()))
|
|
{
|
|
let artist_id = if !artists.contains_key(artist) {
|
|
let artist_id = database.add_artist_new(Artist {
|
|
id: 0,
|
|
name: artist.to_string(),
|
|
cover: None,
|
|
albums: vec![],
|
|
singles: vec![],
|
|
general: GeneralData::default(),
|
|
});
|
|
artists.insert(artist.to_string(), (artist_id, HashMap::new()));
|
|
artist_id
|
|
} else {
|
|
artists.get(artist).unwrap().0
|
|
};
|
|
if let Some(album) = song_tags.album().filter(|a| !a.trim().is_empty()) {
|
|
let (_, albums) = artists.get_mut(artist).unwrap();
|
|
let album_id = if !albums.contains_key(album) {
|
|
let album_id = database.add_album_new(Album {
|
|
id: 0,
|
|
artist: artist_id,
|
|
name: album.to_string(),
|
|
cover: None,
|
|
songs: vec![],
|
|
general: GeneralData::default(),
|
|
});
|
|
albums.insert(
|
|
album.to_string(),
|
|
(album_id, song_path.parent().map(|dir| dir.to_path_buf())),
|
|
);
|
|
album_id
|
|
} else {
|
|
let album = albums.get_mut(album).unwrap();
|
|
if album
|
|
.1
|
|
.as_ref()
|
|
.is_some_and(|dir| Some(dir.as_path()) != song_path.parent())
|
|
{
|
|
// album directory is inconsistent
|
|
album.1 = None;
|
|
}
|
|
album.0
|
|
};
|
|
(artist_id, Some(album_id))
|
|
} else {
|
|
(artist_id, None)
|
|
}
|
|
} else {
|
|
(unknown_artist, None)
|
|
};
|
|
let path = song_path.strip_prefix(&lib_dir).unwrap();
|
|
let title = song_tags
|
|
.title()
|
|
.and_then(|title| {
|
|
if title.trim().is_empty() {
|
|
if verbosity > 0 {
|
|
eprintln!(
|
|
"Title of song {:?} not found in tags, using {} (from filename) instead!",
|
|
song_path.display(), song_path.file_stem().unwrap().display(),
|
|
);
|
|
}
|
|
None
|
|
} else {
|
|
Some(title.to_string())
|
|
}
|
|
})
|
|
.unwrap_or_else(|| {
|
|
song_path
|
|
.file_stem()
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
});
|
|
database.add_song_new(Song::new(
|
|
DatabaseLocation {
|
|
rel_path: path.to_path_buf(),
|
|
},
|
|
match song_path.metadata() {
|
|
Ok(v) => match v.modified() {
|
|
Ok(v) => if let Ok(time) = v.duration_since(SystemTime::UNIX_EPOCH) {
|
|
Some(time.as_secs())
|
|
} else {
|
|
eprintln!(
|
|
"LastModified time of song {:?} is before the UNIX-EPOCH, setting `None`.",
|
|
song_path
|
|
);
|
|
None
|
|
},
|
|
Err(e) => {
|
|
eprintln!(
|
|
"LastModified time of song {:?} not available: {e}.",
|
|
song_path
|
|
);
|
|
None
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!(
|
|
"LastModified time of song {:?} could not be read: {e}.",
|
|
song_path
|
|
);
|
|
None
|
|
}
|
|
},
|
|
title.clone(),
|
|
album_id,
|
|
artist_id,
|
|
vec![],
|
|
None,
|
|
song_file_metadata.len(),
|
|
if let Some(dur) = song_tags.duration() {
|
|
dur as u64
|
|
} else {
|
|
if skip_duration {
|
|
if verbosity > 0 {
|
|
eprintln!(
|
|
"Duration of song {:?} not found in tags, using 0 instead!",
|
|
song_path
|
|
);
|
|
}
|
|
0
|
|
} else {
|
|
match mp3_duration::from_path(&song_path) {
|
|
Ok(dur) => dur.as_millis().min(u64::MAX as _) as u64,
|
|
Err(e) => {
|
|
eprintln!("Duration of song {song_path:?} not found in tags and can't be determined from the file contents either ({e}). Using duration 0 instead.");
|
|
0
|
|
}
|
|
}
|
|
}
|
|
},
|
|
general,
|
|
));
|
|
}
|
|
{
|
|
let (artists, albums, songs) = database.artists_albums_songs_mut();
|
|
fn unsrcfile(tags: &mut Vec<String>) {
|
|
let srcfile_tags = tags
|
|
.iter()
|
|
.filter_map(|tag| tag.strip_prefix("SRCFILE:"))
|
|
.map(|tag| tag.to_owned())
|
|
.collect::<Vec<_>>();
|
|
for tag in srcfile_tags {
|
|
if !tags.contains(&tag) {
|
|
tags.push(tag.to_owned());
|
|
}
|
|
}
|
|
}
|
|
for v in artists.values_mut() {
|
|
unsrcfile(&mut v.general.tags);
|
|
}
|
|
for v in albums.values_mut() {
|
|
unsrcfile(&mut v.general.tags);
|
|
}
|
|
for v in songs.values_mut() {
|
|
unsrcfile(&mut v.general.tags);
|
|
}
|
|
}
|
|
eprintln!("searching for covers...");
|
|
let mut multiple_cover_options = vec![];
|
|
let mut single_images = HashMap::new();
|
|
for (i1, (_artist, (artist_id, albums))) in artists.iter().enumerate() {
|
|
eprint!("\rartist {}/{}", i1 + 1, artists.len());
|
|
for (album_id, album_dir) in albums.values() {
|
|
if let Some(album_dir) = album_dir
|
|
&& let Some(cover_id) = get_cover(
|
|
&mut database,
|
|
&lib_dir,
|
|
album_dir,
|
|
&mut multiple_cover_options,
|
|
)
|
|
{
|
|
database.albums_mut().get_mut(album_id).unwrap().cover = Some(cover_id);
|
|
}
|
|
}
|
|
if let Some(artist) = database.artists().get(artist_id) {
|
|
for song in artist.singles.clone() {
|
|
if let Some(dir) = AsRef::<Path>::as_ref(&lib_dir)
|
|
.join(&database.songs().get(&song).unwrap().location.rel_path)
|
|
.parent()
|
|
{
|
|
let cover_id = if let Some(cover_id) = single_images.get(dir) {
|
|
Some(*cover_id)
|
|
} else if let Some(cover_id) =
|
|
get_cover(&mut database, &lib_dir, dir, &mut multiple_cover_options)
|
|
{
|
|
single_images.insert(dir.to_owned(), cover_id);
|
|
Some(cover_id)
|
|
} else {
|
|
None
|
|
};
|
|
let song = database.songs_mut().get_mut(&song).unwrap();
|
|
song.cover = cover_id;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
eprintln!();
|
|
if !multiple_cover_options.is_empty() {
|
|
eprintln!("> Found more than one cover in the following directories: ");
|
|
for dir in multiple_cover_options {
|
|
eprintln!(">> {}", dir.to_string_lossy());
|
|
}
|
|
eprintln!("> Default behavior is using the largest image file found.");
|
|
}
|
|
if let Some(uka) = database.artists().get(&unknown_artist) {
|
|
if uka.albums.is_empty() && uka.singles.is_empty() {
|
|
database.artists_mut().remove(&unknown_artist);
|
|
} else {
|
|
eprintln!("Added the <unknown> artist as a fallback!");
|
|
}
|
|
}
|
|
if let Some(custom_files) = custom_files {
|
|
if artist_img {
|
|
eprintln!("[info] Searching for <artist>.{{png,jpg,...}} files in custom-files dir...");
|
|
match fs::read_dir(&custom_files) {
|
|
Err(e) => {
|
|
eprintln!("Can't read custom-files dir {custom_files:?}: {e}");
|
|
}
|
|
Ok(ls) => {
|
|
let mut files = HashMap::new();
|
|
for entry in ls.flatten() {
|
|
let p = entry.path();
|
|
if let Some(base) = p.file_stem().and_then(|v| v.to_str())
|
|
&& let Some(ext) = entry
|
|
.path()
|
|
.extension()
|
|
.and_then(|v| v.to_str())
|
|
.filter(|v| {
|
|
matches!(v.to_lowercase().as_str(), "png" | "jpg" | "jpeg")
|
|
})
|
|
&& let Some(old) = files.insert(base.to_owned(), ext.to_owned())
|
|
{
|
|
eprintln!(
|
|
"[warn] Not using file {base}.{old}, because {base}.{ext} was found."
|
|
);
|
|
}
|
|
}
|
|
for artist in database.artists_mut().values_mut() {
|
|
if let Some(ext) = files.get(&artist.name) {
|
|
artist.general.tags.push(format!("SRCFILE:ImageExt={ext}"));
|
|
artist.general.tags.push(format!("ImageExt={ext}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
eprintln!(
|
|
"[info] Searching for <artist>.tags, <artist>.d/<album>.tags, <artist>.d/singles.d/<song>.tags, <artist>.d/<album>.d/<song>.tags in custom-files dir..."
|
|
);
|
|
let l = database.artists().len() + database.albums().len() + database.songs().len();
|
|
let mut cc = 0;
|
|
let mut c = 0;
|
|
let (artists, albums, songs) = database.artists_albums_songs_mut();
|
|
fn push_tags(info: &str, tags: &mut Vec<String>) {
|
|
for line in info.lines() {
|
|
let tag = normalized_str_to_tag(line);
|
|
if !tags.contains(&tag) {
|
|
tags.push(tag);
|
|
}
|
|
}
|
|
}
|
|
for artist in artists.values_mut() {
|
|
// <artist>.tags
|
|
cc += 1;
|
|
if let Ok(info) = fs::read_to_string(custom_files.join(format!(
|
|
"{}.tags",
|
|
normalize_to_file_path_component_for_custom_files(&artist.name)
|
|
))) {
|
|
c += 1;
|
|
push_tags(&info, &mut artist.general.tags);
|
|
}
|
|
// <artist>.d/
|
|
let dir = custom_files.join(format!(
|
|
"{}.d",
|
|
normalize_to_file_path_component_for_custom_files(&artist.name)
|
|
));
|
|
if fs::metadata(&dir).is_ok_and(|meta| meta.is_dir()) {
|
|
// <artist>.d/singles/
|
|
{
|
|
let dir = dir.join("singles");
|
|
for song in artist.singles.iter() {
|
|
// <artist>.d/singles/<song>.tags
|
|
cc += 1;
|
|
if let Some(song) = songs.get_mut(song)
|
|
&& let Ok(info) = fs::read_to_string(dir.join(format!(
|
|
"{}.tags",
|
|
normalize_to_file_path_component_for_custom_files(&song.title)
|
|
)))
|
|
{
|
|
c += 1;
|
|
push_tags(&info, &mut song.general.tags);
|
|
}
|
|
}
|
|
}
|
|
for album in artist.albums.iter() {
|
|
eprint!(" {cc}/{l} ({c})\r");
|
|
cc += 1;
|
|
if let Some(album) = albums.get_mut(album) {
|
|
// <artist>.d/<album>.tags
|
|
if let Ok(info) = fs::read_to_string(dir.join(format!(
|
|
"{}.tags",
|
|
normalize_to_file_path_component_for_custom_files(&album.name)
|
|
))) {
|
|
c += 1;
|
|
push_tags(&info, &mut album.general.tags);
|
|
}
|
|
// <artist>.d/<album>.d/
|
|
let dir = dir.join(format!(
|
|
"{}.d",
|
|
normalize_to_file_path_component_for_custom_files(&album.name)
|
|
));
|
|
for song in album.songs.iter() {
|
|
cc += 1;
|
|
if let Some(song) = songs.get_mut(song) {
|
|
// <artist>.d/<album>.d/<song>.tags
|
|
if let Ok(info) = fs::read_to_string(dir.join(format!(
|
|
"{}.tags",
|
|
normalize_to_file_path_component_for_custom_files(&song.title)
|
|
))) {
|
|
c += 1;
|
|
push_tags(&info, &mut song.general.tags);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
cc += artist.albums.len();
|
|
for album in artist.albums.iter() {
|
|
if let Some(album) = albums.get(album) {
|
|
cc += album.songs.len();
|
|
}
|
|
}
|
|
}
|
|
eprint!(" {cc}/{l} ({c})\r");
|
|
}
|
|
eprintln!();
|
|
}
|
|
eprintln!("saving dbfile...");
|
|
database.save_database(None).unwrap();
|
|
eprintln!("done!");
|
|
if stats {
|
|
eprintln!();
|
|
eprintln!("=== Genre Statistics ===");
|
|
let mut genre_counts = genre_counts
|
|
.iter()
|
|
.map(|(genre, count)| (genre, *count))
|
|
.collect::<Vec<_>>();
|
|
genre_counts.sort_by(|(_, count_1), (_, count_2)| count_2.cmp(count_1));
|
|
for (genre, count) in genre_counts {
|
|
eprintln!("{genre}: {count}");
|
|
}
|
|
eprintln!();
|
|
eprintln!("=== Year Statistics ===");
|
|
if let Some((&min_year, _)) = year_counts.first_key_value()
|
|
&& let Some((&max_year, _)) = year_counts.last_key_value()
|
|
{
|
|
let width = year_counts
|
|
.values()
|
|
.copied()
|
|
.max()
|
|
.unwrap_or(0)
|
|
.to_string()
|
|
.len();
|
|
for i in min_year / 10..=max_year / 10 {
|
|
let start = 10 * i;
|
|
let end = 10 * (1 + i);
|
|
let total = year_counts
|
|
.range(start..end)
|
|
.map(|(_, c)| *c)
|
|
.sum::<usize>()
|
|
.to_string();
|
|
eprint!(
|
|
"{}-{} | ∑: {}{}",
|
|
start,
|
|
end - 1,
|
|
" ".repeat(width + 1 - total.len()),
|
|
total,
|
|
);
|
|
for y in start..end {
|
|
let count = year_counts.get(&y).copied().unwrap_or(0);
|
|
let (pre, post) = if count == 0 {
|
|
("\x1b[90m", "\x1b[0m")
|
|
} else {
|
|
("", "")
|
|
};
|
|
let count = count.to_string();
|
|
eprint!(
|
|
", {}{y}: {}{}{}",
|
|
pre,
|
|
" ".repeat(width - count.len()),
|
|
count,
|
|
post
|
|
);
|
|
}
|
|
eprintln!();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_all_files_in_dir(dir: impl AsRef<Path>) -> Vec<PathBuf> {
|
|
let mut files = Vec::new();
|
|
_ = all_files_in_dir(&dir, &mut files);
|
|
files
|
|
}
|
|
fn all_files_in_dir(dir: impl AsRef<Path>, vec: &mut Vec<PathBuf>) -> Result<(), std::io::Error> {
|
|
for path in fs::read_dir(dir)?
|
|
.filter_map(|possible_entry| possible_entry.ok())
|
|
.map(|entry| entry.path())
|
|
{
|
|
if all_files_in_dir(&path, vec).is_err() {
|
|
vec.push(path);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
struct OnceNewline(bool);
|
|
impl OnceNewline {
|
|
pub fn new() -> Self {
|
|
Self(true)
|
|
}
|
|
pub fn now(&mut self) {
|
|
if std::mem::replace(&mut self.0, false) {
|
|
eprintln!();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_cover(
|
|
database: &mut Database,
|
|
lib_dir: &str,
|
|
abs_dir: impl AsRef<Path>,
|
|
multiple_options_list: &mut Vec<PathBuf>,
|
|
) -> Option<CoverId> {
|
|
let mut multiple = false;
|
|
let mut cover = None;
|
|
if let Ok(files) = fs::read_dir(&abs_dir) {
|
|
for file in files.flatten() {
|
|
let path = file.path();
|
|
if let Ok(metadata) = path.metadata()
|
|
&& metadata.is_file()
|
|
&& path
|
|
.extension()
|
|
.and_then(|v| v.to_str())
|
|
.is_some_and(|v| matches!(v.to_lowercase().as_str(), "png" | "jpg" | "jpeg"))
|
|
&& (cover.is_none()
|
|
|| cover
|
|
.as_ref()
|
|
.is_some_and(|(_, size)| *size < metadata.len()))
|
|
{
|
|
if cover.is_some() {
|
|
multiple = true;
|
|
}
|
|
cover = Some((path, metadata.len()));
|
|
}
|
|
}
|
|
}
|
|
if multiple {
|
|
multiple_options_list.push(abs_dir.as_ref().to_path_buf());
|
|
}
|
|
if let Some((path, _)) = cover {
|
|
let rel_path = path.strip_prefix(lib_dir).unwrap().to_path_buf();
|
|
Some(database.add_cover_new(Cover {
|
|
location: DatabaseLocation {
|
|
rel_path: rel_path.clone(),
|
|
},
|
|
data: Arc::new(Mutex::new((false, None))),
|
|
}))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn normalize_to_file_path_component_for_custom_files(str: &str) -> String {
|
|
str.replace('%', "%p")
|
|
.replace('\0', "%0")
|
|
.replace('/', "%s")
|
|
.replace('\\', "%S")
|
|
.replace('\t', "%t")
|
|
.replace('\r', "%r")
|
|
.replace('\n', "%n")
|
|
}
|
|
|
|
// may NOT set \! to a valid escape sequence, as this is used to
|
|
// identify db-internal "tags" such as the Song-/Album-/Artist-ID
|
|
fn normalize_tag_to_str(str: &str) -> String {
|
|
str.replace('\\', "\\S")
|
|
.replace('\n', "\\n")
|
|
.replace('\r', "\\r")
|
|
}
|
|
fn normalized_str_to_tag(str: &str) -> String {
|
|
str.replace(['\n', '\r'], "")
|
|
.replace("\\n", "\n")
|
|
.replace("\\r", "\r")
|
|
.replace("\\S", "\\")
|
|
}
|
|
|
|
fn export_to_custom_files_dir(dbdir: String, path: PathBuf) {
|
|
let database = Database::load_database_from_dir(dbdir.into(), PathBuf::new()).unwrap();
|
|
for (artist_id, artist) in database.artists().iter() {
|
|
export_custom_files_tags(
|
|
&artist.general.tags,
|
|
&gen_internals(Some(*artist_id), artist.cover),
|
|
&path.join(format!(
|
|
"{}.tags",
|
|
normalize_to_file_path_component_for_custom_files(&artist.name)
|
|
)),
|
|
);
|
|
let dir = path.join(format!(
|
|
"{}.d",
|
|
normalize_to_file_path_component_for_custom_files(&artist.name)
|
|
));
|
|
{
|
|
let dir = dir.join("singles");
|
|
for song_id in artist.singles.iter() {
|
|
if let Some(song) = database.songs().get(song_id) {
|
|
export_custom_files_tags(
|
|
&song.general.tags,
|
|
&gen_internals(Some(*song_id), song.cover),
|
|
&dir.join(format!(
|
|
"{}.tags",
|
|
normalize_to_file_path_component_for_custom_files(&song.title,)
|
|
)),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
for album_id in artist.albums.iter() {
|
|
if let Some(album) = database.albums().get(album_id) {
|
|
export_custom_files_tags(
|
|
&album.general.tags,
|
|
&gen_internals(Some(*album_id), album.cover),
|
|
&dir.join(format!(
|
|
"{}.tags",
|
|
normalize_to_file_path_component_for_custom_files(&album.name,)
|
|
)),
|
|
);
|
|
let dir = dir.join(format!(
|
|
"{}.d",
|
|
normalize_to_file_path_component_for_custom_files(&album.name,)
|
|
));
|
|
for song_id in album.songs.iter() {
|
|
if let Some(song) = database.songs().get(song_id) {
|
|
export_custom_files_tags(
|
|
&song.general.tags,
|
|
&gen_internals(Some(*song_id), song.cover),
|
|
&dir.join(format!(
|
|
"{}.tags",
|
|
normalize_to_file_path_component_for_custom_files(&song.title,)
|
|
)),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fn export_custom_files_tags(tags: &[String], internals: &[String], path: &Path) {
|
|
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
|
let mut normalized_tags = None;
|
|
fn mk_normalized_tags<'a>(
|
|
normalized_tags: &'a mut Option<String>,
|
|
tags: &[String],
|
|
internals: &[String],
|
|
) -> &'a String {
|
|
&*normalized_tags.get_or_insert_with(|| {
|
|
let mut tags = Vec::from(tags);
|
|
let mut rm = Vec::new();
|
|
for (i, srcfile_tag_stripped) in tags
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(i, tag)| Some((i, tag.strip_prefix("SRCFILE:")?)))
|
|
{
|
|
match rm.binary_search(&i) {
|
|
Ok(_) => {}
|
|
Err(v) => rm.insert(v, i),
|
|
}
|
|
if let Some(i) = tags.iter().position(|tag| tag == srcfile_tag_stripped) {
|
|
// There is a tag which just repeats the information
|
|
// which is already present in the source (audio) file.
|
|
// We do not want to save this information, so that,
|
|
// if the audio file is replaced in the future, its new
|
|
// information is used by musicdb, and musicdb-internal
|
|
// information is only used if it was changed to be different
|
|
// from the source file by the user.
|
|
match rm.binary_search(&i) {
|
|
Ok(_) => {}
|
|
Err(v) => rm.insert(v, i),
|
|
}
|
|
}
|
|
}
|
|
for i in rm.into_iter().rev() {
|
|
tags.remove(i);
|
|
}
|
|
tags.iter()
|
|
.map(|tag| normalize_tag_to_str(tag) + "\n")
|
|
.chain(
|
|
internals
|
|
.iter()
|
|
.map(|tag| format!("\\!{}\n", normalize_tag_to_str(tag))),
|
|
)
|
|
.collect::<String>()
|
|
})
|
|
}
|
|
let allow_write = match fs::exists(path) {
|
|
Err(e) => {
|
|
eprintln!("Cannot check for {}, skipping. Error: {e}", path.display());
|
|
false
|
|
}
|
|
Ok(false) => true,
|
|
Ok(true) => {
|
|
if fs::read_to_string(path).is_ok_and(|file| {
|
|
file == *mk_normalized_tags(&mut normalized_tags, tags, internals)
|
|
}) {
|
|
// file contains the same tags as database, don't write,
|
|
// but don't create backup either
|
|
false
|
|
} else {
|
|
let backup_path = path.with_file_name(format!("{file_name}.backup"));
|
|
match fs::exists(&backup_path) {
|
|
Err(e) => {
|
|
eprintln!(
|
|
"Cannot check for {}, skipping {}. Error: {e}",
|
|
backup_path.display(),
|
|
path.display()
|
|
);
|
|
false
|
|
}
|
|
Ok(true) => {
|
|
eprintln!(
|
|
"Backup {} exists, skipping {}.",
|
|
backup_path.display(),
|
|
path.display()
|
|
);
|
|
false
|
|
}
|
|
Ok(false) => {
|
|
if let Err(e) = fs::rename(path, &backup_path) {
|
|
eprintln!(
|
|
"Failed to move previous file/dir {} to {}: {e}",
|
|
path.display(),
|
|
backup_path.display()
|
|
);
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
if allow_write && !mk_normalized_tags(&mut normalized_tags, tags, internals).is_empty() {
|
|
if let Some(p) = path.parent()
|
|
&& let Err(e) = fs::create_dir_all(p)
|
|
{
|
|
eprintln!(
|
|
"Could not create directory to contain {}: {e}",
|
|
path.display()
|
|
);
|
|
}
|
|
if let Err(e) = fs::write(
|
|
path,
|
|
mk_normalized_tags(&mut normalized_tags, tags, internals),
|
|
) {
|
|
eprintln!("Could not save {}: {e}", path.display());
|
|
}
|
|
}
|
|
} else {
|
|
eprintln!(
|
|
"[ERR] Somehow created a non-unicode path {path:?}! This should not have happened!"
|
|
);
|
|
}
|
|
}
|
|
|
|
// TODO: load these tags, infer album and artist id from parent directories in the structure (will probably happen with no further changes required)
|
|
fn gen_internals(id: Option<u64>, cover: Option<CoverId>) -> Vec<String> {
|
|
[
|
|
id.map(|id| format!("id={id}")),
|
|
cover.map(|id| format!("cover={id}")),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
.collect()
|
|
}
|