feat: ability to export tags from dbfile to directory structure

This commit is contained in:
Mark 2025-08-23 16:04:26 +02:00
parent a053b5ee5c
commit 1138365180

View File

@ -27,20 +27,31 @@ fn main() {
std::process::exit(1); std::process::exit(1);
}; };
let mut bad_arg = false; let mut bad_arg = false;
let mut dbdir = ".".to_owned();
let mut skip_duration = false; let mut skip_duration = false;
let mut custom_files = None; let mut custom_files = None;
let mut artist_img = false; let mut artist_img = false;
let mut export_custom_files = None;
loop { loop {
match args.next() { match args.next() {
None => break, None => break,
Some(arg) => match arg.as_str() { Some(arg) => match arg.as_str() {
"--help" => { "--help" => {
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!("--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."); eprintln!("--custom-files <path>: server will use <path> as its custom-files directory. Additional data is loaded from here.");
eprintln!("--cf-artist-txt: For each artist, check for an <artist>.txt file. If it exists, add each line as a tag to that artist.");
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!("--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>.");
return; return;
} }
"--dbdir" => {
if let Some(dir) = args.next() {
dbdir = dir;
} else {
bad_arg = true;
eprintln!("--dbdir <path> :: missing <path>!");
}
}
"--skip-duration" => skip_duration = true, "--skip-duration" => skip_duration = true,
"--custom-files" => { "--custom-files" => {
if let Some(path) = args.next() { if let Some(path) = args.next() {
@ -51,6 +62,14 @@ fn main() {
} }
} }
"--cf-artist-img" => artist_img = true, "--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 => { arg => {
bad_arg = true; bad_arg = true;
eprintln!("Unknown argument: {arg}"); eprintln!("Unknown argument: {arg}");
@ -58,9 +77,19 @@ fn main() {
}, },
} }
} }
if export_custom_files.is_some() {
if skip_duration || custom_files.is_some() || artist_img {
bad_arg = true;
eprintln!("--export-custom-files :: incompatible with other arguments except --dbdir!");
}
}
if bad_arg { if bad_arg {
return; 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'."); eprintln!("Library: {lib_dir}. press enter to start. result will be saved in 'dbfile'.");
std::io::stdin().read_line(&mut String::new()).unwrap(); std::io::stdin().read_line(&mut String::new()).unwrap();
// start // start
@ -89,7 +118,7 @@ fn main() {
} }
} }
eprintln!("\nloaded metadata of {} files.", songs.len()); eprintln!("\nloaded metadata of {} files.", songs.len());
let mut database = Database::new_empty_in_dir(PathBuf::from("."), PathBuf::from(&lib_dir)); let mut database = Database::new_empty_in_dir(PathBuf::from(dbdir), PathBuf::from(&lib_dir));
let unknown_artist = database.add_artist_new(Artist { let unknown_artist = database.add_artist_new(Artist {
id: 0, id: 0,
name: format!("<unknown>"), name: format!("<unknown>"),
@ -132,21 +161,21 @@ fn main() {
let mut general = GeneralData::default(); let mut general = GeneralData::default();
match (song_tags.track(), song_tags.total_tracks()) { match (song_tags.track(), song_tags.total_tracks()) {
(None, None) => {} (None, None) => {}
(Some(n), Some(t)) => general.tags.push(format!("TrackNr={n}/{t}")), (Some(n), Some(t)) => general.tags.push(format!("SRCFILE:TrackNr={n}/{t}")),
(Some(n), None) => general.tags.push(format!("TrackNr={n}")), (Some(n), None) => general.tags.push(format!("SRCFILE:TrackNr={n}")),
(None, Some(t)) => general.tags.push(format!("TrackNr=?/{t}")), (None, Some(t)) => general.tags.push(format!("SRCFILE:TrackNr=?/{t}")),
} }
match (song_tags.disc(), song_tags.total_discs()) { match (song_tags.disc(), song_tags.total_discs()) {
(None, None) => {} (None, None) => {}
(Some(n), Some(t)) => general.tags.push(format!("DiscNr={n}/{t}")), (Some(n), Some(t)) => general.tags.push(format!("SRCFILE:DiscNr={n}/{t}")),
(Some(n), None) => general.tags.push(format!("DiscNr={n}")), (Some(n), None) => general.tags.push(format!("SRCFILE:DiscNr={n}")),
(None, Some(t)) => general.tags.push(format!("DiscNr=?/{t}")), (None, Some(t)) => general.tags.push(format!("SRCFILE:DiscNr=?/{t}")),
} }
if let Some(year) = song_tags.year() { if let Some(year) = song_tags.year() {
general.tags.push(format!("Year={year}")); general.tags.push(format!("SRCFILE:Year={year}"));
} }
if let Some(genre) = song_tags.genre_parsed() { if let Some(genre) = song_tags.genre_parsed() {
general.tags.push(format!("Genre={genre}")); general.tags.push(format!("SRCFILE:Genre={genre}"));
} }
let (artist_id, album_id) = if let Some(artist) = song_tags let (artist_id, album_id) = if let Some(artist) = song_tags
.album_artist() .album_artist()
@ -278,6 +307,30 @@ fn main() {
general, 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..."); eprintln!("searching for covers...");
let mut multiple_cover_options = vec![]; let mut multiple_cover_options = vec![];
let mut single_images = HashMap::new(); let mut single_images = HashMap::new();
@ -333,95 +386,6 @@ fn main() {
} }
} }
if let Some(custom_files) = custom_files { if let Some(custom_files) = custom_files {
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();
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;
for line in info.lines() {
artist.general.tags.push(normalized_str_to_tag(line));
}
}
// <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) {
if let Ok(info) = fs::read_to_string(dir.join(format!(
"{}.tags",
normalize_to_file_path_component_for_custom_files(&song.title)
))) {
c += 1;
for line in info.lines() {
song.general.tags.push(normalized_str_to_tag(line));
}
}
}
}
}
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;
for line in info.lines() {
album.general.tags.push(normalized_str_to_tag(line));
}
}
// <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;
for line in info.lines() {
song.general.tags.push(normalized_str_to_tag(line));
}
}
}
}
}
}
} 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!();
if artist_img { if artist_img {
eprintln!("[info] Searching for <artist>.{{png,jpg,...}} files in custom-files dir..."); eprintln!("[info] Searching for <artist>.{{png,jpg,...}} files in custom-files dir...");
match fs::read_dir(&custom_files) { match fs::read_dir(&custom_files) {
@ -452,12 +416,102 @@ fn main() {
} }
for artist in database.artists_mut().values_mut() { for artist in database.artists_mut().values_mut() {
if let Some(ext) = files.get(&artist.name) { if let Some(ext) = files.get(&artist.name) {
artist.general.tags.push(format!("SRCFILE:ImageExt={ext}"));
artist.general.tags.push(format!("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) {
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);
}
}
}
}
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..."); eprintln!("saving dbfile...");
database.save_database(None).unwrap(); database.save_database(None).unwrap();
@ -563,3 +617,177 @@ fn normalized_str_to_tag(str: &str) -> String {
.replace("\\r", "\r") .replace("\\r", "\r")
.replace("\\S", "\\") .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 in database.artists().values() {
export_custom_files_tags(
&artist.general.tags,
&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 in artist.singles.iter() {
if let Some(song) = database.songs().get(song) {
export_custom_files_tags(
&song.general.tags,
&dir.join(format!(
"{}.tags",
normalize_to_file_path_component_for_custom_files(&song.title,)
)),
);
}
}
}
for album in artist.albums.iter() {
if let Some(album) = database.albums().get(album) {
export_custom_files_tags(
&album.general.tags,
&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 in album.songs.iter() {
if let Some(song) = database.songs().get(song) {
export_custom_files_tags(
&song.general.tags,
&dir.join(format!(
"{}.tags",
normalize_to_file_path_component_for_custom_files(&song.title,)
)),
);
}
}
}
}
}
}
fn export_custom_files_tags(tags: &Vec<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<Vec<String>>,
tags: &'_ Vec<String>,
) -> &'a Vec<String> {
&*normalized_tags.get_or_insert_with(|| {
let mut tags = tags.clone();
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
})
}
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.lines()
.map(|str| normalized_str_to_tag(str))
.collect::<Vec<String>>()
== *mk_normalized_tags(&mut normalized_tags, tags)
}) {
// 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 {
if !mk_normalized_tags(&mut normalized_tags, tags).is_empty() {
if let Some(p) = path.parent() {
if 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)
.iter()
.map(|tag| normalize_tag_to_str(tag) + "\n")
.collect::<String>(),
) {
eprintln!("Could not save {}: {e}", path.display());
}
}
}
} else {
eprintln!(
"[ERR] Somehow created a non-unicode path {path:?}! This should not have happened!"
);
}
}