diff --git a/README.md b/README.md index fbb0fd4..7a2d91e 100644 --- a/README.md +++ b/README.md @@ -250,14 +250,9 @@ Files satisfying `pref-` conditions will be preferred; setting `--pref-format "f **Important note**: Some info may be unavailable depending on the client used by the peer. For example, the default Soulseek client does not share the file bitrate. By default, if `--min-bitrate` is set, then files with unknown bitrate will still be downloaded. You can configure it to reject all files where one of the checked properties is unavailable by enabling `--strict-conditions`. (As a consequence, if `--min-bitrate` is also set then any files shared by users with the default client will be ignored) ### Name format -Available tags are: artist, artists, album_artist, album_artists, title, album, year, track, disc, filename, default_foldername. Name format supports subdirectories as well as conditional expressions: `{str1|str2}` – If any tags in str1 are null, choose str2. String literals enclosed in parentheses are ignored in the null check. -``` -{artist( - )title|album_artist( - )title|filename} -``` -``` -{album(/)}{track(. )}{artist|(unknown artist)} - {title|(unknown title)} -``` -Here `{album(/)}` will conditionally put the download into a subfolder; if the album tag is null, then the slash won't be added to the path. An alternative is `{album|(missing album)}/` which will save all songs with unknown album under `missing album`. +Variables enclosed in {} will be replaced by the corresponding file tag value. Available variables are: artist, sartist, artists, albumartist, albumartists, title, stitle, album, salbum, year, track, disc, filename, foldername. The variables sartist, stitle and salbum will be replaced by the source artist, title and album respectively (i.e what is shown in spotify/youtube/csv file) instead of the tag values of the downloaded file. Name format supports subdirectories as well as conditional expressions like `{tag1|tag2}` – If tag1 is null, choose tag2. String literals enclosed in parentheses are ignored in the null check. Examples: +- `{artist} - {title}`: Always name it 'Artist - Title'. Because some files on Soulseek do not have tags, the second example is preferred: +- `{artist( - )title|filename}`: If artist and title is not null, name it 'Artist - Title', otherwise use the original filename. ### Quality vs Speed The following options will make it go faster, but may decrease search result quality or cause instability: diff --git a/slsk-batchdl/Program.cs b/slsk-batchdl/Program.cs index 428e7bc..43d180f 100644 --- a/slsk-batchdl/Program.cs +++ b/slsk-batchdl/Program.cs @@ -23,7 +23,7 @@ using SlDictionary = System.Collections.Concurrent.ConcurrentDictionary deprioritizeOn ? deprioritizeOn : ignoreOn; + ignoreOn = Math.Min(ignoreOn, deprioritizeOn); if (inputType == "youtube" || (inputType == "" && input.StartsWith("http") && input.Contains("youtu"))) { @@ -1192,7 +1205,7 @@ static class Program if (!File.Exists(csvPath)) throw new FileNotFoundException("CSV file not found"); - var tracks = await ParseCsvIntoTrackInfo(csvPath, artistCol, trackCol, lengthCol, albumCol, descCol, ytIdCol, timeUnit, ytParse); + var tracks = await ParseCsvIntoTrackInfo(csvPath, artistCol, trackCol, lengthCol, albumCol, descCol, ytIdCol, trackCountCol, timeUnit, ytParse); tracks = tracks.Skip(off).Take(max).ToList(); trackLists = TrackLists.FromFlatList(tracks, aggregate, album); defaultFolderName = Path.GetFileNameWithoutExtension(csvPath); @@ -2125,7 +2138,7 @@ static class Program } if (nameFormat != "" && !useYtdlp) - saveFilePath = ApplyNamingFormat(saveFilePath); + saveFilePath = ApplyNamingFormat(saveFilePath, track); return saveFilePath; } @@ -2238,10 +2251,22 @@ static class Program x.Item2.Count(x => Utils.IsMusicFile(x.file.Filename)) ).ToList(); - bool countIsGood(int count, int min, int max) => count >= min && (max == -1 || count <= max); + int min, max; + if (track.MinAlbumTrackCount != -1 || track.MaxAlbumTrackCount != -1) + { + min = track.MinAlbumTrackCount; + max = track.MaxAlbumTrackCount; + } + else + { + min = minAlbumTrackCount; + max = maxAlbumTrackCount; + } + + bool countIsGood(int count) => count >= min && (max == -1 || count <= max); var result = musicFolders - .Where(x => countIsGood(x.Item2.Count(rf => Utils.IsMusicFile(rf.file.Filename)), minAlbumTrackCount, maxAlbumTrackCount)) + .Where(x => countIsGood(x.Item2.Count(rf => Utils.IsMusicFile(rf.file.Filename)))) .Select(ls => ls.Item2.Select(x => { var t = new Track { @@ -3291,7 +3316,7 @@ static class Program static async Task> ParseCsvIntoTrackInfo(string path, string artistCol = "", string trackCol = "", - string lengthCol = "", string albumCol = "", string descCol = "", string ytIdCol = "", string timeUnit = "s", bool ytParse = false) + string lengthCol = "", string albumCol = "", string descCol = "", string ytIdCol = "", string trackCountCol = "", string timeUnit = "s", bool ytParse = false) { var tracks = new List(); using var sr = new StreamReader(path, System.Text.Encoding.UTF8); @@ -3301,14 +3326,15 @@ static class Program while (header == null || header.Count == 0 || !header.Any(t => t.Trim() != "")) header = parser.ReadNextRow(); - string[] cols = { artistCol, albumCol, trackCol, lengthCol, descCol, ytIdCol }; + string[] cols = { artistCol, albumCol, trackCol, lengthCol, descCol, ytIdCol, trackCountCol }; string[][] aliases = { new[] { "artist", "artist name", "artists", "artist names" }, new[] { "album", "album name", "album title" }, new[] { "title", "song", "track title", "track name", "song name", "track" }, new[] { "length", "duration", "track length", "track duration", "song length", "song duration" }, new[] { "description", "youtube description" }, - new[] { "id", "youtube id", "url" } + new[] { "url", "id", "youtube id" }, + new[] { "track count", "album track count" } }; string usingColumns = ""; @@ -3338,8 +3364,8 @@ static class Program throw new Exception("No columns specified and couldn't determine automatically"); int[] indices = cols.Select(col => col == "" ? -1 : header.IndexOf(col)).ToArray(); - int artistIndex, albumIndex, trackIndex, lengthIndex, descIndex, ytIdIndex; - (artistIndex, albumIndex, trackIndex, lengthIndex, descIndex, ytIdIndex) = (indices[0], indices[1], indices[2], indices[3], indices[4], indices[5]); + int artistIndex, albumIndex, trackIndex, lengthIndex, descIndex, ytIdIndex, trackCountIndex; + (artistIndex, albumIndex, trackIndex, lengthIndex, descIndex, ytIdIndex, trackCountIndex) = (indices[0], indices[1], indices[2], indices[3], indices[4], indices[5], indices[6]); while (true) { @@ -3359,6 +3385,28 @@ static class Program if (albumIndex >= 0) track.Album = values[albumIndex]; if (descIndex >= 0) desc = values[descIndex]; if (ytIdIndex >= 0) track.URI = values[ytIdIndex]; + if (trackCountIndex >= 0) + { + string a = values[trackCountIndex].Trim(); + if (a == "-1") + { + track.MinAlbumTrackCount = -1; + track.MaxAlbumTrackCount = -1; + } + else if (a.Last() == '-' && int.TryParse(a.AsSpan(0, a.Length - 1), out int n)) + { + track.MaxAlbumTrackCount = n; + } + else if (a.Last() == '+' && int.TryParse(a.AsSpan(0, a.Length - 1), out n)) + { + track.MinAlbumTrackCount = n; + } + else if (int.TryParse(a, out n)) + { + track.MinAlbumTrackCount = n; + track.MaxAlbumTrackCount = n; + } + } if (lengthIndex >= 0) { try @@ -3469,13 +3517,13 @@ static class Program } } - static string ApplyNamingFormat(string filepath) + static string ApplyNamingFormat(string filepath, Track track) { if (nameFormat == "" || !Utils.IsMusicFile(filepath)) return filepath; string add = Path.GetRelativePath(outputFolder, Path.GetDirectoryName(filepath)); - string newFilePath = NamingFormat(filepath, nameFormat); + string newFilePath = NamingFormat(filepath, nameFormat, track); if (filepath != newFilePath) { Directory.CreateDirectory(Path.GetDirectoryName(newFilePath)); @@ -3487,7 +3535,7 @@ static class Program return newFilePath; } - static string NamingFormat(string filepath, string format) + static string NamingFormat(string filepath, string format, Track track) { string newName = format; TagLib.File? file = null; @@ -3511,7 +3559,7 @@ static class Program { string[] parts = Regex.Split(opt, @"\([^\)]*\)"); string[] result = parts.Where(part => !string.IsNullOrWhiteSpace(part)).ToArray(); - if (result.All(x => GetTagValue(file, x) != "")) { + if (result.All(x => GetVarValue(x, file, track) != "")) { chosenOpt = opt; break; } @@ -3522,7 +3570,7 @@ static class Program if (match.Value.StartsWith("(") && match.Value.EndsWith(")")) return match.Value.Substring(1, match.Value.Length-2); else - return GetTagValue(file, match.Value); + return GetVarValue(match.Value, file, track); }); string old = match.Groups[1].Value; old = old.StartsWith("{{") ? old.Substring(1) : old; @@ -3548,22 +3596,29 @@ static class Program return filepath; } - static string GetTagValue(TagLib.File file, string tag) + static string GetVarValue(string x, TagLib.File file, Track track) { - switch (tag) + switch (x) { case "artist": - return (file.Tag.FirstPerformer ?? "").RemoveFt(); + return file.Tag.FirstPerformer ?? ""; case "artists": - return string.Join(" & ", file.Tag.Performers).RemoveFt(); - case "album_artist": - return (file.Tag.FirstAlbumArtist ?? "").RemoveFt(); - case "album_artists": - return string.Join(" & ", file.Tag.AlbumArtists).RemoveFt(); + return string.Join(" & ", file.Tag.Performers); + case "albumartist": + return file.Tag.FirstAlbumArtist ?? ""; + case "albumartists": + return string.Join(" & ", file.Tag.AlbumArtists); case "title": return file.Tag.Title ?? ""; case "album": return file.Tag.Album ?? ""; + case "sartist": + case "sartists": + return track.Artist; + case "stitle": + return track.Title; + case "salbum": + return track.Album; case "year": return file.Tag.Year.ToString() ?? ""; case "track": @@ -3572,7 +3627,7 @@ static class Program return file.Tag.Disc.ToString() ?? ""; case "filename": return Path.GetFileNameWithoutExtension(file.Name); - case "default_foldername": + case "foldername": return defaultFolderName; default: return ""; @@ -3581,34 +3636,66 @@ static class Program static bool TrackMatchesFilename(Track track, string filename) { - string[] ignore = new string[] { " ", "_", "-", ".", "(", ")" }; - string searchName = track.Title.Replace(ignore, "").ToLower(); - searchName = searchName.ReplaceInvalidChars("").RemoveFt().RemoveSquareBrackets(); - searchName = searchName == "" ? track.Title : searchName; + if (track.Title.Trim() == "" || filename.Trim() == "") + return false; - string searchName2 = ""; - if (searchName.Length <= 3) { - searchName2 = track.Artist.Replace(ignore, "").ToLower(); - searchName2 = searchName2.ReplaceInvalidChars("").RemoveFt().RemoveSquareBrackets(); - searchName2 = searchName2 == "" ? track.Artist : searchName2; + string[] ignore = new string[] { " ", "_", "-", ".", "(", ")", "[", "]" }; + + string preprocess1(string s, bool removeSlash = true) + { + s = s.ReplaceInvalidChars("", false, removeSlash).Replace(ignore, "").ToLower(); + s = s.RemoveFt().RemoveDiacritics(); + return s; } - string fullpath = filename; - filename = Path.GetFileNameWithoutExtension(filename); - filename = filename.ReplaceInvalidChars(""); - filename = filename.Replace(ignore, "").ToLower(); + string preprocess2(string s, bool removeSlash = true) + { + s = s.ReplaceInvalidChars("", false, removeSlash).ToLower().RemoveDiacritics(); + return s; + } - if (filename.Contains(searchName) && FileConditions.StrictString(fullpath, searchName2, ignoreCase:true, boundarySkipWs:true)) + string preprocess3(string s) + { + s = s.ToLower().RemoveDiacritics(); + return s; + } + + string title = preprocess1(track.Title); + string artist = preprocess1(track.Artist); + string fname = preprocess1(Path.GetFileNameWithoutExtension(filename)); + string path = preprocess1(filename, false); + + if (title == "" || fname == "") + { + title = preprocess2(track.Title); + artist = preprocess2(track.Artist); + fname = preprocess2(Path.GetFileNameWithoutExtension(filename)); + path = preprocess2(filename, false); + + if (title == "" || fname == "") + { + title = preprocess3(track.Title); + artist = preprocess3(track.Artist); + fname = preprocess3(Path.GetFileNameWithoutExtension(filename)); + path = preprocess3(filename); + + if (title == "" || fname == "") + { + return false; + } + } + } + + if (fname.Contains(title) && path.Contains(artist)) { return true; } else if ((track.ArtistMaybeWrong || track.Artist == "") && track.Title.Contains(" - ")) { - searchName = track.Title.Substring(track.Title.IndexOf(" - ") + 3).Replace(ignore, "").ToLower(); - searchName = searchName.ReplaceInvalidChars("").RemoveFt().RemoveSquareBrackets(); - if (searchName != "") + title = preprocess1(track.Title.Substring(track.Title.IndexOf(" - ") + 3)); + if (title != "") { - if (filename.Contains(searchName)) + if (preprocess1(filename, false).Contains(title)) return true; } } @@ -4372,6 +4459,8 @@ public struct Track public int Length = -1; public bool ArtistMaybeWrong = false; public bool IsAlbum = false; + public int MinAlbumTrackCount = -1; + public int MaxAlbumTrackCount = -1; public bool IsNotAudio = false; public string FailureReason = ""; public string DownloadPath = ""; @@ -4406,6 +4495,8 @@ public struct Track FailureReason = other.FailureReason; DownloadPath = other.DownloadPath; Other = other.Other; + MinAlbumTrackCount = other.MinAlbumTrackCount; + MaxAlbumTrackCount = other.MaxAlbumTrackCount; } public override readonly string ToString()