From 2c4ee4309dd23dbc13b94836294d49e65f92d637 Mon Sep 17 00:00:00 2001
From: fiso64 <sokfilix@gmail.com>
Date: Tue, 4 Jun 2024 14:12:56 +0200
Subject: [PATCH] commit

---
 README.md               |   2 +-
 slsk-batchdl/Program.cs | 234 ++++++++++++++++++++++++----------------
 slsk-batchdl/Utils.cs   |  59 +++++-----
 3 files changed, 170 insertions(+), 125 deletions(-)

diff --git a/README.md b/README.md
index 7a2d91e..d19cbe2 100644
--- a/README.md
+++ b/README.md
@@ -121,7 +121,7 @@ Options:
   -r --reverse                   Download tracks in reverse order
   --name-format <format>         Name format for downloaded tracks, e.g "{artist} - {title}"
   --fast-search                  Begin downloading as soon as a file satisfying the preferred
-                                 conditions is found. Increases chance to download bad files.
+                                 conditions is found. Higher chance to download wrong files.
   --m3u <option>                 Create an m3u8 playlist file
                                  'none': Do not create a playlist file
                                  'fails' (default): Write only failed downloads to the m3u
diff --git a/slsk-batchdl/Program.cs b/slsk-batchdl/Program.cs
index 2dc936d..917ed4f 100644
--- a/slsk-batchdl/Program.cs
+++ b/slsk-batchdl/Program.cs
@@ -25,7 +25,7 @@ using SlDictionary = System.Collections.Concurrent.ConcurrentDictionary<string,
 // --on-complete
 // --artist-col, --title-col, --album-col, --length-col, --yt-desc-col, --yt-id-col, --album-track-count-col
 // --input-type, --login, --random-login, --no-modify-share-count --fast-search-delay,
-// --fails-to-deprioritize (=1), --fails-to-ignore (=2)
+// --fails-to-deprioritize (=1), --fails-to-ignore (=2), --invalid-replace-str
 // --cond, --pref, --danger-words, --pref-danger-words, --strict-title, --strict-artist, --strict-album
 // --fast-search-delay, --fast-search-min-up-speed
 // --min-album-track-count, --max-album-track-count, --extract-max-track-count
@@ -97,6 +97,7 @@ static class Program
     static string input = "";
     static bool preciseSkip = true;
     static string nameFormat = "";
+    static string invalidReplaceStr = " ";
     static bool skipNotFound = false;
     static bool desperateSearch = false;
     static bool noRemoveSpecialChars = false;
@@ -168,7 +169,7 @@ static class Program
                             "\n                                 Provide a --youtube-key to include unavailabe uploads." +
                             "\n" +
                             "\n                                 Path to a local CSV file: Use a csv file containing track" +
-                            "\n                                 info to download. The names of the columns should be Artist, " +
+                            "\n                                 info to download. The names of the columns should be Artist," +
                             "\n                                 Title, Album, Length. Only the title or album column is" +
                             "\n                                 required, but extra info may improve search results." +
                             "\n" +
@@ -190,7 +191,7 @@ static class Program
                             "\n  -r --reverse                   Download tracks in reverse order" +
                             "\n  --name-format <format>         Name format for downloaded tracks, e.g \"{artist} - {title}\"" +
                             "\n  --fast-search                  Begin downloading as soon as a file satisfying the preferred" +
-                            "\n                                 conditions is found. Increases chance to download bad files." +
+                            "\n                                 conditions is found. Higher chance to download wrong files." +
                             "\n  --m3u <option>                 Create an m3u8 playlist file" +
                             "\n                                 'none': Do not create a playlist file" +
                             "\n                                 'fails' (default): Write only failed downloads to the m3u" +
@@ -489,6 +490,9 @@ static class Program
                     case "--name-format":
                         nameFormat = args[++i];
                         break;
+                    case "--invalid-replace-str":
+                        invalidReplaceStr = args[++i];
+                        break;
                     case "--p":
                     case "--print":
                         string opt = args[++i];
@@ -705,7 +709,9 @@ static class Program
                         preferredCond.Formats = args[++i].Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
                         break;
                     case "--plt":
+                    case "--pref-tolerance":
                     case "--pref-length-tol":
+                    case "--pref-length-tolerance":
                         preferredCond.LengthTolerance = int.Parse(args[++i]);
                         break;
                     case "--pmbr":
@@ -758,7 +764,9 @@ static class Program
                         necessaryCond.Formats = args[++i].Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
                         break;
                     case "--lt":
+                    case "--tolerance":
                     case "--length-tol":
+                    case "--length-tolerance":
                         necessaryCond.LengthTolerance = int.Parse(args[++i]);
                         break;
                     case "--mbr":
@@ -973,7 +981,7 @@ static class Program
         if (folderName == ".")
             folderName = "";
         folderName = folderName.Replace("\\", "/");
-        folderName = String.Join('/', folderName.Split("/").Select(x => ReplaceInvalidChars(x, " ").Trim()));
+        folderName = String.Join('/', folderName.Split("/").Select(x => x.ReplaceInvalidChars(invalidReplaceStr).Trim()));
         folderName = folderName.Replace('/', Path.DirectorySeparatorChar);
 
         outputFolder = Path.Combine(parentFolder, folderName);
@@ -1049,7 +1057,7 @@ static class Program
         if (album || aggregate)
             trackLists = TrackLists.FromFlatList(trackLists.Flattened().ToList(), aggregate, album);
 
-        defaultFolderName = ReplaceInvalidChars(name, " ");
+        defaultFolderName = name.ReplaceInvalidChars(invalidReplaceStr);
     }
 
 
@@ -1144,7 +1152,7 @@ static class Program
         }
 
 
-        defaultFolderName = ReplaceInvalidChars(playlistName, " ");
+        defaultFolderName = playlistName.ReplaceInvalidChars(invalidReplaceStr);
     }
 
 
@@ -1179,7 +1187,7 @@ static class Program
                 }
             }
 
-            defaultFolderName = ReplaceInvalidChars(track.ToString(true), " ").Trim();
+            defaultFolderName = track.ToString(true).ReplaceInvalidChars(invalidReplaceStr).Trim();
         }
         else
         {
@@ -1243,7 +1251,7 @@ static class Program
         }
 
         if (aggregate || isAlbum || album)
-            defaultFolderName = ReplaceInvalidChars(music.ToString(true), " ").Trim();
+            defaultFolderName = music.ToString(true).ReplaceInvalidChars(invalidReplaceStr).Trim();
         else
             defaultFolderName = ".";
     }
@@ -1255,8 +1263,8 @@ static class Program
         {
             var (list, type, source) = trackLists.lists[i];
 
-            List<Track> existing = new List<Track>();
-            List<Track> notFound = new List<Track>();
+            var existing = new List<Track>();
+            var notFound = new List<Track>();
 
             if (skipNotFound)
             { 
@@ -1382,6 +1390,11 @@ static class Program
         {
             track.ArtistMaybeWrong = true;
         }
+
+        track.Artist = track.Artist.Trim();
+        track.Album = track.Album.Trim();
+        track.Title = track.Title.Trim();
+
         return track;
     }
 
@@ -1936,7 +1949,7 @@ static class Program
                 if (downloading == 0 && !searchEnded)
                 {
                     downloading = 1;
-                    var (r, f) = fsResults.ArgMax(x => x.Value.Item1.UploadSpeed).Value;
+                    var (r, f) = fsResults.MaxBy(x => x.Value.Item1.UploadSpeed).Value;
                     saveFilePath = GetSavePath(f.Filename);
                     fsUser = r.Username;
                     fsFile = f.Filename;
@@ -1955,7 +1968,9 @@ static class Program
                 if (fastSearch && !debugDisableDownload && userSuccessCount.GetValueOrDefault(r.Username, 0) > deprioritizeOn)
                 {
                     var f = r.Files.First();
-                    if (r.HasFreeUploadSlot && r.UploadSpeed/1024.0/1024.0 >= fastSearchMinUpSpeed && preferredCond.FileSatisfies(f, track, r))
+
+                    if (r.HasFreeUploadSlot && r.UploadSpeed/1024.0/1024.0 >= fastSearchMinUpSpeed 
+                        && BracketCheck(track, InferTrack(f.Filename, track)) && preferredCond.FileSatisfies(f, track, r))
                     {
                         fsResults.TryAdd(r.Username + "\\" + f.Filename, (r, f));
                         if (Interlocked.Exchange(ref fsResultsStarted, 1) == 0)
@@ -2030,9 +2045,11 @@ static class Program
             if (debugDisableDownload)
             {
                 int count = 0;
+                Console.WriteLine();
                 foreach (var (response, file) in orderedResults) {
                     Console.WriteLine(DisplayString(track, file, response,
-                        (printResultsFull ? necessaryCond : null), (printResultsFull ? preferredCond : null), printResultsFull, infoFirst: true));
+                        printResultsFull ? necessaryCond : null, printResultsFull ? preferredCond : null, 
+                        fullpath: printResultsFull, infoFirst: true, showSpeed: printResultsFull));
                     count += 1;
                 }
                 WriteLine($"Total: {count}\n", ConsoleColor.Yellow);
@@ -2137,7 +2154,7 @@ static class Program
             }
         }
 
-        if (nameFormat != "" && !useYtdlp)
+        if (nameFormat != "")
             saveFilePath = ApplyNamingFormat(saveFilePath, track);
 
         return saveFilePath;
@@ -2186,10 +2203,12 @@ static class Program
 
         if (debugDisableDownload && !debugPrintTracks)
         {
+            Console.WriteLine();
             foreach (var (response, file) in orderedResults)
             {
                 Console.WriteLine(DisplayString(track, file, response,
-                    (printResultsFull ? necessaryCond : null), (printResultsFull ? preferredCond : null), printResultsFull, infoFirst: true));
+                        printResultsFull ? necessaryCond : null, printResultsFull ? preferredCond : null,
+                        fullpath: printResultsFull, infoFirst: true, showSpeed: printResultsFull));
             }
             WriteLine($"Total: {orderedResults.Count()}\n", ConsoleColor.Yellow);
             return default;
@@ -2419,38 +2438,28 @@ static class Program
             useInfer = false;
         }
 
-        Dictionary<string, (Track, int)>? result = null;
+        Dictionary<string, (Track, int)>? infTracksAndCounts = null;
         if (useInfer)
         {
             var equivalentFiles = EquivalentFiles(track, results.Select(x => x.Value), 1);
-            result = equivalentFiles
+            infTracksAndCounts = equivalentFiles
                 .SelectMany(t => t.Item2, (t, f) => new { t.Item1, f.response.Username, f.file.Filename, Count = t.Item2.Count() })
-                .ToSafeDictionary(
-                    x => $"{x.Username}\\{x.Filename}",
-                    x => (x.Item1, x.Count));
+                .ToSafeDictionary(x => $"{x.Username}\\{x.Filename}", y => (y.Item1, y.Count));
         }
 
-        (Track, int) infTrack((SearchResponse response, Soulseek.File file) x)
+        (Track, int) inferredTrack((SearchResponse response, Soulseek.File file) x)
         {
             string key = $"{x.response.Username}\\{x.file.Filename}";
-            if (result != null && result.ContainsKey(key))
-                return result[key];
+            if (infTracksAndCounts != null && infTracksAndCounts.ContainsKey(key))
+                return infTracksAndCounts[key];
             return (new Track(), 0);
         }
 
-        bool bracketCheck((SearchResponse response, Soulseek.File file) x)
-        {
-            Track inferredTrack = infTrack(x).Item1;
-            string t1 = track.Title.RemoveFt().Replace('[', '(');
-            string t2 = inferredTrack.Title.RemoveFt().Replace('[', '(');
-            return track.ArtistMaybeWrong || t1.Contains('(') || !t2.Contains('(');
-        }
-
         int levenshtein((SearchResponse response, Soulseek.File file) x)
         {
-            Track inferredTrack = infTrack(x).Item1;
-            string t1 = track.Title.ReplaceInvalidChars("").Replace(" ", "").Replace("_", "").RemoveFt().ToLower();
-            string t2 = inferredTrack.Title.ReplaceInvalidChars("").Replace(" ", "").Replace("_", "").RemoveFt().ToLower();
+            Track t = inferredTrack(x).Item1;
+            string t1 = track.Title.RemoveFt().ReplaceSpecialChars("").Replace(" ", "").Replace("_", "").ToLower();
+            string t2 = t.Title.RemoveFt().ReplaceSpecialChars("").Replace(" ", "").Replace("_", "").ToLower();
             return Utils.Levenshtein(t1, t2);
         }
 
@@ -2461,7 +2470,7 @@ static class Program
                 .ThenByDescending(x => necessaryCond.FileSatisfies(x.file, track, x.response))
                 .ThenByDescending(x => preferredCond.BannedUsersSatisfies(x.response))
                 .ThenByDescending(x => (x.file.Length != null && x.file.Length > 0) || preferredCond.AcceptNoLength)
-                .ThenByDescending(x => !useBracketCheck || bracketCheck(x)) // deprioritize result if it contains '(' or '[' and the title does not (avoid remixes)
+                .ThenByDescending(x => !useBracketCheck || BracketCheck(track, inferredTrack(x).Item1)) // deprioritize result if it contains '(' or '[' and the title does not (avoid remixes)
                 .ThenByDescending(x => preferredCond.StrictTitleSatisfies(x.file.Filename, track.Title))
                 .ThenByDescending(x => preferredCond.LengthToleranceSatisfies(x.file, track.Length))
                 .ThenByDescending(x => preferredCond.FormatSatisfies(x.file.Filename))
@@ -2473,14 +2482,36 @@ static class Program
                 .ThenByDescending(x => albumMode || FileConditions.StrictString(x.file.Filename, track.Title))
                 .ThenByDescending(x => !albumMode || FileConditions.StrictString(GetDirectoryNameSlsk(x.file.Filename), track.Album))
                 .ThenByDescending(x => FileConditions.StrictString(x.file.Filename, track.Artist, boundarySkipWs: false))
-                .ThenByDescending(x => !useLevenshtein || levenshtein(x) <= 5) // sorts by the distance between the track title and the inferred title of the search result
-                .ThenByDescending(x => x.response.UploadSpeed / 1024 / 300)
-                .ThenByDescending(x => (x.file.BitRate ?? 0) / 70)
-                .ThenByDescending(x => useInfer ? infTrack(x).Item2 : 0) // sorts by the number of occurences of this track
+                .ThenByDescending(x => useInfer ? inferredTrack(x).Item2 : 0) // sorts by the number of occurences of this track
+                .ThenByDescending(x => x.response.UploadSpeed / 1024 / 350)
+                .ThenByDescending(x => (x.file.BitRate ?? 0) / 80)
+                .ThenByDescending(x => useLevenshtein ? levenshtein(x) / 5 : 0) // sorts by the distance between the track title and the inferred title of the search result
                 .ThenByDescending(x => random.Next());
     }
 
 
+    static bool BracketCheck(Track track, Track other)
+    {
+        string t1 = track.Title.RemoveFt().Replace('[', '(');
+        if (t1.Contains('('))
+            return true;
+
+        string t2 = other.Title.RemoveFt().Replace('[', '(');
+        if (!t2.Contains('('))
+            return true;
+
+        string ar = track.Artist.Replace('[', '(');
+        if (ar.Contains('(') && !t2.Replace(ar, "").Contains('('))
+            return true;
+
+        string al = track.Album.Replace('[', '(');
+        if (al.Contains('(') && !t2.Replace(al, "").Contains('('))
+            return true;
+
+        return false;
+    }
+
+
     static async Task RunSearches(Track track, SlDictionary results, Func<int, FileConditions, FileConditions, SearchOptions> getSearchOptions, 
         Action<SearchResponse> responseHandler, CancellationToken ct, Action? onSearch = null)
     {
@@ -2697,13 +2728,7 @@ static class Program
             if (!parts[0].ContainsIgnoreCase(aname) || !parts[1].ContainsIgnoreCase(tname))
             {
                 t.ArtistMaybeWrong = true;
-                //if (!maybeRemix && parts[0].ContainsIgnoreCase(tname) && parts[1].ContainsIgnoreCase(aname))
-                //{
-                //    t.ArtistName = realParts[1];
-                //    t.TrackTitle = realParts[0];
-                //}
             }
-            
         }
         else if (parts.Length == 3)
         {
@@ -2752,12 +2777,57 @@ static class Program
 
             t.Title = parts[2];
         }
+        else
+        {
+            int artistPos = -1, titlePos = -1;
+
+            if (aname != "")
+            {
+                var s = parts.Select((p, i) => (p, i)).Where(x => x.p.ContainsIgnoreCase(aname));
+                if (s.Any())
+                {
+                    artistPos = s.MinBy(x => Math.Abs(x.p.Length - aname.Length)).i;
+                    if (artistPos != -1)
+                        t.Artist = parts[artistPos];
+                }
+            }
+            if (tname != "")
+            {
+                var ss = parts.Select((p, i) => (p, i)).Where(x => x.i != artistPos && x.p.ContainsIgnoreCase(tname));
+                if (ss.Any())
+                {
+                    titlePos = ss.MinBy(x => Math.Abs(x.p.Length - tname.Length)).i;
+                    if (titlePos != -1)
+                        t.Title = parts[titlePos];
+                }
+            }
+        }
 
         if (t.Title == "")
         {
             t.Title = fname;
             t.ArtistMaybeWrong = true;
         }
+        else if (t.Artist != "" && !t.Title.ContainsIgnoreCase(defaultTrack.Title) && !t.Artist.ContainsIgnoreCase(defaultTrack.Artist))
+        {
+            string[] x = { t.Artist, t.Album, t.Title };
+
+            var perm = (0, 1, 2);
+            (int, int, int)[] permutations = { (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0) };
+
+            foreach (var p in permutations)
+            {
+                if (x[p.Item1].ContainsIgnoreCase(defaultTrack.Artist) && x[p.Item3].ContainsIgnoreCase(defaultTrack.Title))
+                {
+                    perm = p;
+                    break;
+                }
+            }
+
+            t.Artist = x[perm.Item1];
+            t.Album = x[perm.Item2];
+            t.Title = x[perm.Item3];
+        }
 
         t.Title = t.Title.RemoveFt();
         t.Artist = t.Artist.RemoveFt();
@@ -3172,7 +3242,7 @@ static class Program
             fname = regexRemove != "" ? Regex.Replace(fname, regexRemove, "") : fname;
             fname = diacrRemove ? fname.RemoveDiacritics() : fname;
             fname = fname.Trim().RemoveConsecutiveWs();
-            tname = tname.Replace("_", " ").ReplaceInvalidChars(" ", true, false);
+            tname = tname.Replace("_", " ").ReplaceInvalidChars(" ", true, true);
             tname = regexRemove != "" ? Regex.Replace(tname, regexRemove, "") : tname;
             tname = diacrRemove ? tname.RemoveDiacritics() : tname;
             tname = tname.Trim().RemoveConsecutiveWs();
@@ -3451,7 +3521,7 @@ static class Program
     static string GetSaveName(string sourceFname)
     {
         string name = GetFileNameWithoutExtSlsk(sourceFname);
-        return ReplaceInvalidChars(name, " ");
+        return name.ReplaceInvalidChars(invalidReplaceStr);
     }
 
     static string GetAsPathSlsk(string fname)
@@ -3541,7 +3611,7 @@ static class Program
         TagLib.File? file = null;
 
         try { file = TagLib.File.Create(filepath); }
-        catch { return filepath; }
+        catch { }
 
         Regex regex = new Regex(@"(\{(?:\{??[^\{]*?\}))");
         MatchCollection matches = regex.Matches(newName);
@@ -3550,7 +3620,8 @@ static class Program
         {
             foreach (Match match in matches.Cast<Match>())
             {
-                string inner = match.Groups[1].Value.Trim('{').Trim('}');
+                string inner = match.Groups[1].Value;
+                inner = inner.Substring(1, inner.Length - 2);
 
                 var options = inner.Split('|');
                 string chosenOpt = "";
@@ -3559,7 +3630,7 @@ static class Program
                 {
                     string[] parts = Regex.Split(opt, @"\([^\)]*\)");
                     string[] result = parts.Where(part => !string.IsNullOrWhiteSpace(part)).ToArray();
-                    if (result.All(x => GetVarValue(x, file, track) != "")) {
+                    if (result.All(x => GetVarValue(x, file, filepath, track) != "")) {
                         chosenOpt = opt;
                         break;
                     }
@@ -3568,10 +3639,11 @@ static class Program
                 chosenOpt = Regex.Replace(chosenOpt, @"\([^()]*\)|[^()]+", match =>
                 {
                     if (match.Value.StartsWith("(") && match.Value.EndsWith(")"))
-                        return match.Value.Substring(1, match.Value.Length-2);
+                        return match.Value.Substring(1, match.Value.Length-2).ReplaceInvalidChars(invalidReplaceStr, removeSlash: false);
                     else
-                        return GetVarValue(match.Value, file, track);
+                        return GetVarValue(match.Value, file, filepath, track).ReplaceInvalidChars(invalidReplaceStr);
                 });
+
                 string old = match.Groups[1].Value;
                 old = old.StartsWith("{{") ? old.Substring(1) : old;
                 newName = newName.Replace(old, chosenOpt);
@@ -3580,15 +3652,14 @@ static class Program
             matches = regex.Matches(newName);
         }
 
-
         if (newName != format)
         {
-            string directory = Path.GetDirectoryName(filepath);
-            string dirsep = Path.DirectorySeparatorChar.ToString();
+            string directory = Path.GetDirectoryName(filepath) ?? "";
             string extension = Path.GetExtension(filepath);
-            newName = newName.Replace(new string[] { "/", "\\" }, dirsep);
+            char dirsep = Path.DirectorySeparatorChar;
+            newName = newName.Replace('/', dirsep);
             var x = newName.Split(dirsep, StringSplitOptions.RemoveEmptyEntries);
-            newName = string.Join(dirsep, x.Select(x => ReplaceInvalidChars(x, " ")));
+            newName = string.Join(dirsep, x.Select(x => x.ReplaceInvalidChars(invalidReplaceStr)));
             string newFilePath = Path.Combine(directory, newName + extension);
             return newFilePath;
         }
@@ -3596,22 +3667,22 @@ static class Program
         return filepath;
     }
 
-    static string GetVarValue(string x, TagLib.File file, Track track)
+    static string GetVarValue(string x, TagLib.File? file, string filepath, Track track)
     {
         switch (x)
         {
             case "artist":
-                return file.Tag.FirstPerformer ?? "";
+                return file?.Tag.FirstPerformer ?? "";
             case "artists":
-                return string.Join(" & ", file.Tag.Performers);
+                return file != null ? string.Join(" & ", file.Tag.Performers) : "";
             case "albumartist":
-                return file.Tag.FirstAlbumArtist ?? "";
+                return file?.Tag.FirstAlbumArtist ?? "";
             case "albumartists":
-                return string.Join(" & ", file.Tag.AlbumArtists);
+                return file != null ? string.Join(" & ", file.Tag.AlbumArtists) : "";
             case "title":
-                return file.Tag.Title ?? "";
+                return file?.Tag.Title ?? "";
             case "album":
-                return file.Tag.Album ?? "";
+                return file?.Tag.Album ?? "";
             case "sartist":
             case "sartists":
                 return track.Artist;
@@ -3620,13 +3691,13 @@ static class Program
             case "salbum":
                 return track.Album;
             case "year":
-                return file.Tag.Year.ToString() ?? "";
+                return file?.Tag.Year.ToString() ?? "";
             case "track":
-                return file.Tag.Track.ToString("D2") ?? "";
+                return file?.Tag.Track.ToString("D2") ?? "";
             case "disc":
-                return file.Tag.Disc.ToString() ?? "";
+                return file?.Tag.Disc.ToString() ?? "";
             case "filename":
-                return Path.GetFileNameWithoutExtension(file.Name);
+                return Path.GetFileNameWithoutExtension(filepath);
             case "foldername":
                 return defaultFolderName;
             default:
@@ -4078,28 +4149,8 @@ static class Program
         return totalSeconds;
     }
 
-    static string ReplaceInvalidChars(this string str, string replaceStr, bool windows = false, bool removeSlash = true)
-    {
-        char[] invalidChars = Path.GetInvalidFileNameChars();
-        if (windows)
-            invalidChars = new char[] { ':', '|', '?', '>', '<', '*', '"', '/', '\\' };
-        if (!removeSlash)
-            invalidChars = invalidChars.Where(c => c != '/' && c != '\\').ToArray();
-        foreach (char c in invalidChars)
-            str = str.Replace(c.ToString(), replaceStr);
-        return str;
-    }
-
-    static string ReplaceSpecialChars(this string str, string replaceStr)
-    {
-        string special = ";:'\"|?!<>*/\\[]{}()-–—&%^$#@+=`~_";
-        foreach (char c in special)
-            str = str.Replace(c.ToString(), replaceStr);
-        return str;
-    }
-
     static string DisplayString(Track t, Soulseek.File? file=null, SearchResponse? response=null, FileConditions? nec=null, 
-        FileConditions? pref=null, bool fullpath=false, string customPath="", bool infoFirst=false, bool showUser=true)
+        FileConditions? pref=null, bool fullpath=false, string customPath="", bool infoFirst=false, bool showUser=true, bool showSpeed=false)
     {
         if (file == null)
             return t.ToString();
@@ -4108,18 +4159,19 @@ static class Program
         string bitRate = file.BitRate.HasValue ? $"{file.BitRate}kbps" : "";
         string fileSize = $"{file.Size / (float)(1024 * 1024):F1}MB";
         string user = showUser && response?.Username != null ? response.Username + "\\" : "";
+        string speed = showSpeed && response?.Username != null ? $"({response.UploadSpeed / 1024.0 / 1024.0:F2}MB/s) " : "";
         string fname = fullpath ? file.Filename : (showUser ? "..\\" : "") + (customPath == "" ? GetFileNameSlsk(file.Filename) : customPath);
         string length = Utils.IsMusicFile(file.Filename) ? (file.Length ?? -1).ToString() + "s" : "";
         string displayText;
         if (!infoFirst)
         {
             string info = string.Join('/', new string[] { length, sampleRate+bitRate, fileSize }.Where(value => value!=""));
-            displayText = $"{user}{fname} [{info}]";
+            displayText = $"{speed}{user}{fname} [{info}]";
         }
         else
         {
             string info = string.Join('/', new string[] { length.PadRight(4), (sampleRate+bitRate).PadRight(8), fileSize.PadLeft(6) });
-            displayText = $"[{info}] {user}{fname}";
+            displayText = $"[{info}] {speed}{user}{fname}";
         }
 
         string necStr = nec != null ? $"nec:{nec.GetNotSatisfiedName(file, t, response)}, " : "";
diff --git a/slsk-batchdl/Utils.cs b/slsk-batchdl/Utils.cs
index 1f508f7..3ee90e3 100644
--- a/slsk-batchdl/Utils.cs
+++ b/slsk-batchdl/Utils.cs
@@ -76,6 +76,32 @@ public static class Utils
         return s;
     }
 
+    public static string ReplaceInvalidChars(this string str, string replaceStr, bool windows = false, bool removeSlash = true, bool alwaysRemoveSlash = false)
+    {
+        char[] invalidChars = Path.GetInvalidFileNameChars();
+        if (windows)
+            invalidChars = new char[] { ':', '|', '?', '>', '<', '*', '"', '/', '\\' };
+        if (!removeSlash && !alwaysRemoveSlash)
+            invalidChars = invalidChars.Where(c => c != '/' && c != '\\').ToArray();
+        if (alwaysRemoveSlash)
+        {
+            var x = invalidChars.ToList();
+            x.AddRange(new char[] { '/', '\\' });
+            invalidChars = x.ToArray();
+        }
+        foreach (char c in invalidChars)
+            str = str.Replace(c.ToString(), replaceStr);
+        return str;
+    }
+
+    public static string ReplaceSpecialChars(this string str, string replaceStr)
+    {
+        string special = ";:'\"|?!<>*/\\[]{}()-–—&%^$#@+=`~_";
+        foreach (char c in special)
+            str = str.Replace(c.ToString(), replaceStr);
+        return str;
+    }
+
     public static string RemoveFt(this string str, bool removeParentheses = true, bool onlyIfNonempty = true)
     {
         string[] ftStrings = { "feat.", "ft." };
@@ -201,39 +227,6 @@ public static class Utils
         return d;
     }
 
-    // https://stackoverflow.com/a/35462350/13157140
-    public static T ArgMax<T, K>(this IEnumerable<T> source, Func<T, K> map, IComparer<K> comparer = null)
-    {
-        if (Object.ReferenceEquals(null, source))
-            throw new ArgumentNullException("source");
-        else if (Object.ReferenceEquals(null, map))
-            throw new ArgumentNullException("map");
-
-        T result = default(T);
-        K maxKey = default(K);
-        Boolean first = true;
-
-        if (null == comparer)
-            comparer = Comparer<K>.Default;
-
-        foreach (var item in source)
-        {
-            K key = map(item);
-
-            if (first || comparer.Compare(key, maxKey) > 0)
-            {
-                first = false;
-                maxKey = key;
-                result = item;
-            }
-        }
-
-        if (!first)
-            return result;
-        else
-            throw new ArgumentException("Can't compute ArgMax on empty sequence.", "source");
-    }
-
     public static int Levenshtein(string source, string target)
     {
         if (source.Length == 0)