1
0
Fork 0
mirror of https://github.com/fiso64/slsk-batchdl.git synced 2024-12-22 22:42:41 +00:00
slsk-batchdl/slsk-batchdl/Program.cs
2024-05-23 21:30:47 +02:00

4684 lines
188 KiB
C#

using AngleSharp.Dom;
using Konsole;
using Soulseek;
using System.Collections.Concurrent;
using System.Data;
using System.Text.RegularExpressions;
using HtmlAgilityPack;
using System.Diagnostics;
using AngleSharp.Text;
using ProgressBar = Konsole.ProgressBar;
using SearchResponse = Soulseek.SearchResponse;
using SlResponse = Soulseek.SearchResponse;
using SlFile = Soulseek.File;
using File = System.IO.File;
using Directory = System.IO.Directory;
using SlDictionary = System.Collections.Concurrent.ConcurrentDictionary<string, (Soulseek.SearchResponse, Soulseek.File)>;
using System.Linq;
using System.IO;
// todo
// - Why does it use so much CPU and memory?
// - Very slow startup time on linux
// undocumented options
// --on-complete
// --artist-col, --title-col, --album-col, --length-col, --yt-desc-col, --yt-id-col
// --input-type, --login, --random-login, --no-modify-share-count --fast-search-delay,
// --fails-to-deprioritize (=1), --fails-to-ignore (=2)
// --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
static class Program
{
static FileConditions necessaryCond = new()
{
LengthTolerance = 3,
};
static FileConditions preferredCond = new()
{
Formats = new string[] { "mp3" },
LengthTolerance = 2,
MinBitrate = 200,
MaxBitrate = 2500,
MaxSampleRate = 95999,
StrictTitle = true,
StrictAlbum = true,
AcceptNoLength = false,
};
static SoulseekClient? client = null;
static TrackLists trackLists = new();
static ConcurrentDictionary<Track, SearchInfo> searches = new();
static ConcurrentDictionary<string, DownloadWrapper> downloads = new();
static ConcurrentDictionary<string, int> userSuccessCount = new();
static int deprioritizeOn = -1;
static int ignoreOn = -2;
static string outputFolder = "";
static string m3uFilePath = "";
static string musicDir = "";
static string parentFolder = Directory.GetCurrentDirectory();
static string folderName = "";
static string defaultFolderName = "";
static string ytUrl = "";
static string searchStr = "";
static string spotifyUrl = "";
static string spotifyId = "";
static string spotifySecret = "";
static string ytKey = "";
static string csvPath = "";
static string username = "";
static string password = "";
static string artistCol = "";
static string albumCol = "";
static string trackCol = "";
static string ytIdCol = "";
static string descCol = "";
static string lengthCol = "";
static bool aggregate = false;
static bool album = false;
static string albumArtOption = "";
static bool albumArtOnly = false;
static bool interactiveMode = false;
static bool albumIgnoreFails = false;
static int minAlbumTrackCount = -1;
static int maxAlbumTrackCount = -1;
static bool setAlbumMinTrackCount = true;
static bool setAlbumMaxTrackCount = false;
static string albumCommonPath = "";
static string regexReplacePattern = "";
static string regexPatternToReplace = "";
static string timeUnit = "s";
static string displayStyle = "single";
static string input = "";
static bool preciseSkip = true;
static string nameFormat = "";
static bool skipNotFound = false;
static bool desperateSearch = false;
static bool noRemoveSpecialChars = false;
static bool artistMaybeWrong = false;
static bool fastSearch = false;
static int fastSearchDelay = 300;
static double fastSearchMinUpSpeed = 1.0;
static bool ytParse = false;
static bool removeFt = false;
static bool removeBrackets = false;
static bool reverse = false;
static bool useYtdlp = false;
static string ytdlpArgument = "";
static bool skipExisting = false;
static string m3uOption = "fails";
static bool useTagsCheckExisting = false;
static bool removeTracksFromSource = false;
static bool getDeleted = false;
static bool removeSingleCharacterSearchTerms = false;
static int maxTracks = int.MaxValue;
static int minUsersAggregate = 2;
static bool relax = false;
static bool debugInfo = false;
static int offset = 0;
static string onComplete = "";
static string confPath = "";
static Spotify? spotifyClient = null;
static string playlistUri = "";
static int downloadMaxStaleTime = 50000;
static int updateDelay = 100;
static int searchTimeout = 5000;
static int maxConcurrentProcesses = 2;
static int maxRetriesPerTrack = 30;
static int listenPort = 50000;
static object consoleLock = new object();
static bool skipUpdate = false;
static bool debugDisableDownload = false;
static bool debugPrintTracks = false;
static bool noModifyShareCount = false;
static bool printResultsFull = false;
static bool debugPrintTracksFull = false;
static bool useRandomLogin = false;
static int searchesPerTime = 34;
static int searchResetTime = 220;
static RateLimitedSemaphore? searchSemaphore;
private static M3UEditor? m3uEditor;
private static CancellationTokenSource? mainLoopCts;
static string inputType = "";
static void PrintHelp()
{
Console.WriteLine("Usage: sldl <input> [OPTIONS]" +
"\n" +
"\n <input> <input> is one of the following:" +
"\n" +
"\n Spotify playlist url or 'spotify-likes': Download a spotify" +
"\n playlist or your liked songs. --spotify-id and" +
"\n --spotify-secret may be required in addition." +
"\n" +
"\n Youtube playlist url: Download songs from a youtube playlist." +
"\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 Title, Album, Length. Only the title or album column is" +
"\n required, but extra info may improve search results." +
"\n" +
"\n Name of the track, album, or artist to search for:" +
"\n Can either be any typical search string or a comma-separated" +
"\n list like 'title=Song Name,artist=Artist Name,length=215'" +
"\n Allowed properties are: title, artist, album, length (sec)" +
"\n Specify artist and album only to download an album." +
"\n" +
"\nOptions:" +
"\n --user <username> Soulseek username" +
"\n --pass <password> Soulseek password" +
"\n" +
"\n -p --path <path> Download directory" +
"\n -f --folder <name> Subfolder name. Set to '.' to output directly to the" +
"\n download folder (default: playlist/csv name)" +
"\n -n --number <maxtracks> Download the first n tracks of a playlist" +
"\n -o --offset <offset> Skip a specified number of tracks" +
"\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 --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" +
"\n 'all': Write successes + fails as comments" +
"\n" +
"\n --spotify-id <id> spotify client ID" +
"\n --spotify-secret <secret> spotify client secret" +
"\n --remove-from-playlist Remove downloaded tracks from playlist (spotify only)" +
"\n" +
"\n --youtube-key <key> Youtube data API key" +
"\n --get-deleted Attempt to retrieve titles of deleted videos from wayback" +
"\n machine. Requires yt-dlp." +
"\n" +
"\n --time-format <format> Time format in Length column of the csv file (e.g h:m:s.ms" +
"\n for durations like 1:04:35.123). Default: s" +
"\n --yt-parse Enable if the csv file contains YouTube video titles and" +
"\n channel names; attempt to parse them into title and artist" +
"\n names." +
"\n" +
"\n --format <format> Accepted file format(s), comma-separated" +
"\n --length-tol <sec> Length tolerance in seconds (default: 3)" +
"\n --min-bitrate <rate> Minimum file bitrate" +
"\n --max-bitrate <rate> Maximum file bitrate" +
"\n --min-samplerate <rate> Minimum file sample rate" +
"\n --max-samplerate <rate> Maximum file sample rate" +
"\n --min-bitdepth <depth> Minimum bit depth" +
"\n --max-bitdepth <depth> Maximum bit depth" +
"\n --banned-users <list> Comma-separated list of users to ignore" +
"\n" +
"\n --pref-format <format> Preferred file format(s), comma-separated (default: mp3)" +
"\n --pref-length-tol <sec> Preferred length tolerance in seconds (default: 2)" +
"\n --pref-min-bitrate <rate> Preferred minimum bitrate (default: 200)" +
"\n --pref-max-bitrate <rate> Preferred maximum bitrate (default: 2200)" +
"\n --pref-min-samplerate <rate> Preferred minimum sample rate" +
"\n --pref-max-samplerate <rate> Preferred maximum sample rate (default: 96000)" +
"\n --pref-min-bitdepth <depth> Preferred minimum bit depth" +
"\n --pref-max-bitdepth <depth> Preferred maximum bit depth" +
"\n --pref-banned-users <list> Comma-separated list of users to deprioritize" +
"\n" +
"\n --strict-conditions Skip files with missing properties instead of accepting by" +
"\n default; if --min-bitrate is set, ignores any files with" +
"\n unknown bitrate." +
"\n" +
"\n -a --album Album download mode" +
"\n -t --interactive When downloading albums: Allows to select the wanted album" +
"\n --album-track-count <num> Specify the exact number of tracks in the album. Folders" +
"\n with a different number of tracks will be ignored. Append" +
"\n a '+' or '-' after the number for the inequalities >= and <=" +
"\n --album-ignore-fails When downloading an album and one of the files fails, do not" +
"\n skip to the next source and do not delete all successfully" +
"\n downloaded files" +
"\n --album-art <option> Retrieve additional images after downloading the album:" +
"\n 'largest': Download from the folder with the largest image" +
"\n 'most': Download from the folder containing the most images" +
"\n --album-art-only Only download album art for the provided album" +
"\n" +
"\n -g --aggregate Instead of downloading a single track matching the input," +
"\n find and download all distinct songs associated with the" +
"\n provided artist, album, or track title." +
"\n --min-users-aggregate <num> Minimum number of users sharing a track before it is" +
"\n downloaded in aggregate mode. Setting it to higher values" +
"\n will significantly reduce false positives, but also cause it" +
"\n to ignore rarer songs. Default: 2" +
"\n --relax-filtering Slightly relax file filtering in aggregate mode to include" +
"\n more results" +
"\n" +
"\n -s --skip-existing Skip if a track matching file conditions is found in the" +
"\n output folder or your music library (if provided)" +
"\n --skip-mode <mode> 'name': Use only filenames to check if a track exists" +
"\n 'name-precise' (default): Use filenames and check conditions" +
"\n 'tag': Use file tags (slower)" +
"\n 'tag-precise': Use file tags and check file conditions" +
"\n --music-dir <path> Specify to also skip downloading tracks found in a music" +
"\n library. Use with --skip-existing" +
"\n --skip-not-found Skip searching for tracks that weren't found on Soulseek" +
"\n during the last run. Fails are read from the m3u file." +
"\n" +
"\n --no-remove-special-chars Do not remove special characters before searching" +
"\n --remove-ft Remove 'feat.' and everything after before searching" +
"\n --remove-brackets Remove square brackets and their contents before searching" +
"\n --regex <regex> Remove a regexp from all track titles and artist names." +
"\n Optionally specify the replacement regex after a semicolon" +
"\n --artist-maybe-wrong Performs an additional search without the artist name." +
"\n Useful for sources like SoundCloud where the \"artist\"" +
"\n could just be an uploader. Note that when downloading a" +
"\n YouTube playlist via url, this option is set automatically" +
"\n on a per-track basis, so it is best kept off in that case." +
"\n -d --desperate Tries harder to find the desired track by searching for the" +
"\n artist/album/title only, then filtering. (slower search)" +
"\n --yt-dlp Use yt-dlp to download tracks that weren't found on" +
"\n Soulseek. yt-dlp must be available from the command line." +
"\n --yt-dlp-argument <str> The command line arguments when running yt-dlp. Default:" +
"\n \"{id}\" -f bestaudio/best -cix -o \"{savepath}.%(ext)s\"" +
"\n Available vars are: {id}, {savedir}, {savepath} (w/o ext)." +
"\n Note that with -x, yt-dlp will download webms in case" +
"\n ffmpeg is unavailable." +
"\n" +
"\n -c --config <path> Set config file location" +
"\n --search-timeout <ms> Max search time in ms (default: 5000)" +
"\n --max-stale-time <ms> Max download time without progress in ms (default: 50000)" +
"\n --concurrent-downloads <num> Max concurrent downloads (default: 2)" +
"\n --searches-per-time <num> Max searches per time interval. Higher values may cause" +
"\n 30-minute bans. (default: 34)" +
"\n --searches-renew-time <sec> Controls how often available searches are replenished." +
"\n Lower values may cause 30-minute bans. (default: 220)" +
"\n --display-mode <option> Changes how searches and downloads are displayed:" +
"\n 'single' (default): Show transfer state and percentage" +
"\n 'double': Transfer state and a large progress bar " +
"\n 'simple': No download bars or changing percentages" +
"\n --listen-port <port> Port for incoming connections (default: 50000)" +
"\n" +
"\n --print <option> Print tracks or search results instead of downloading:" +
"\n 'tracks': Print all tracks to be downloaded" +
"\n 'tracks-full': Print extended information about all tracks" +
"\n 'results': Print search results satisfying file conditions" +
"\n 'results-full': Print search results including full paths" +
"\n --debug Print extra debug info" +
"\n" +
"\n Note: Acronyms of two- and --three-word-arguments are also accepted, e.g. --twa");
}
static async Task Main(string[] args)
{
Console.ResetColor();
Console.OutputEncoding = System.Text.Encoding.UTF8;
if (args.Contains("--help") || args.Contains("-h") || args.Length == 0)
{
PrintHelp();
return;
}
#if WINDOWS
try
{
if (Console.BufferHeight <= 50 && displayStyle != "simple")
WriteLine("Windows: Recommended to use the default command prompt to avoid printing issues.");
}
catch { }
#endif
confPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "sldl.conf");
string old = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "slsk-batchdl.conf");
if (!File.Exists(confPath) && File.Exists(old))
confPath = old;
args = args.SelectMany(arg =>
{
if (arg.Length > 3 && arg.StartsWith("--") && arg.Contains('='))
{
var parts = arg.Split('=', 2);
return new[] { parts[0], parts[1] };
}
return new[] { arg };
}).ToArray();
bool confPathChanged = false;
int idx = Array.LastIndexOf(args, "-c");
int idx2 = Array.LastIndexOf(args, "--config");
idx = idx > -1 ? idx : idx2;
if (idx != -1)
{
confPath = args[idx + 1];
confPathChanged = true;
}
if ((File.Exists(confPath) || confPathChanged) && confPath != "none")
{
if (File.Exists(Path.Join(AppDomain.CurrentDomain.BaseDirectory, confPath)))
confPath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, confPath);
var finalArgs = new List<string>(ParseConfig(confPath));
finalArgs.AddRange(args);
args = finalArgs.ToArray();
}
if (args.Contains("--strict") || args.Contains("--strict-conditions") || args.Contains("--sc"))
{
preferredCond.AcceptMissingProps = false;
necessaryCond.AcceptMissingProps = false;
preferredCond.UnsetClientSpecificFields();
necessaryCond.UnsetClientSpecificFields();
}
args = args.SelectMany(arg =>
{
if (arg.Length > 2 && arg[0] == '-' && arg[1] != '-' && !arg.Contains(' ') && arg.ToLower() == arg)
return arg.Substring(1).Select(c => $"-{c}");
return new[] { arg };
}).ToArray();
for (int i = 0; i < args.Length; i++)
{
if (args[i].StartsWith("-"))
{
switch (args[i])
{
case "-i":
case "--input":
input = args[++i];
break;
case "--it":
case "--input-type":
inputType = args[++i];
break;
case "-p":
case "--path":
parentFolder = args[++i];
break;
case "-c":
case "--config":
confPath = args[++i];
break;
case "-f":
case "--folder":
folderName = args[++i];
break;
case "-m":
case "--md":
case "--music-dir":
musicDir = args[++i];
break;
case "-g":
case "--aggregate":
aggregate = true;
break;
case "--mua":
case "--min-users-aggregate":
minUsersAggregate = int.Parse(args[++i]);
break;
case "--rf":
case "--relax":
case "--relax-filtering":
relax = true;
break;
case "--si":
case "--spotify-id":
spotifyId = args[++i];
break;
case "--ss":
case "--spotify-secret":
spotifySecret = args[++i];
break;
case "--yk":
case "--youtube-key":
ytKey = args[++i];
break;
case "-l":
case "--login":
var login = args[++i].Split(';',2);
username = login[0];
password = login[1];
break;
case "--user":
case "--username":
username = args[++i];
break;
case "--pass":
case "--password":
password = args[++i];
break;
case "--rl":
case "--random-login":
useRandomLogin = true;
break;
case "--artist-col":
artistCol = args[++i];
break;
case "--track-col":
trackCol = args[++i];
break;
case "--album-col":
albumCol = args[++i];
break;
case "--yt-desc-col":
descCol = args[++i];
break;
case "--yt-id-col":
ytIdCol = args[++i];
break;
case "-n":
case "--number":
maxTracks = int.Parse(args[++i]);
break;
case "-o":
case "--offset":
offset = int.Parse(args[++i]);
break;
case "--nf":
case "--name-format":
nameFormat = args[++i];
break;
case "--p":
case "--print":
string opt = args[++i];
if (opt == "tracks")
{
debugPrintTracks = true;
debugDisableDownload = true;
}
else if (opt == "tracks-full")
{
debugPrintTracks = true;
debugPrintTracksFull = true;
debugDisableDownload = true;
}
else if (opt == "results")
debugDisableDownload = true;
else if (opt == "results-full")
{
debugDisableDownload = true;
printResultsFull = true;
}
else
throw new ArgumentException($"Unknown print option {opt}");
break;
case "--pt":
case "--print-tracks":
debugPrintTracks = true;
debugDisableDownload = true;
break;
case "--ptf":
case "--print-tracks-full":
debugPrintTracks = true;
debugPrintTracksFull = true;
debugDisableDownload = true;
break;
case "--pr":
case "--print-results":
debugDisableDownload = true;
break;
case "--prf":
case "--print-results-full":
debugDisableDownload = true;
printResultsFull = true;
break;
case "--yp":
case "--yt-parse":
ytParse = true;
break;
case "--length-col":
lengthCol = args[++i];
break;
case "--tf":
case "--time-format":
timeUnit = args[++i];
break;
case "--yd":
case "--yt-dlp":
useYtdlp = true;
break;
case "-s":
case "--se":
case "--skip-existing":
skipExisting = true;
break;
case "--snf":
case "--skip-not-found":
skipNotFound = true;
break;
case "--rfp":
case "--remove-from-playlist":
removeTracksFromSource = true;
break;
case "--rft":
case "--remove-ft":
removeFt = true;
break;
case "--rb":
case "--remove-brackets":
removeBrackets = true;
break;
case "--gd":
case "--get-deleted":
getDeleted = true;
break;
case "--re":
case "--regex":
string s = args[++i].Replace("\\;", "<<semicol>>");
var parts = s.Split(";").ToArray();
regexPatternToReplace = parts[0];
if (parts.Length > 1)
regexReplacePattern = parts[1];
regexPatternToReplace = regexPatternToReplace.Replace("<<semicol>>", ";");
regexReplacePattern = regexReplacePattern.Replace("<<semicol>>", ";");
break;
case "-r":
case "--reverse":
reverse = true;
break;
case "--m3u":
case "--m3u8":
m3uOption = args[++i];
break;
case "--lp":
case "--port":
case "--listen-port":
listenPort = int.Parse(args[++i]);
break;
case "--st":
case "--timeout":
case "--search-timeout":
searchTimeout = int.Parse(args[++i]);
break;
case "--mst":
case "--stale-time":
case "--max-stale-time":
downloadMaxStaleTime = int.Parse(args[++i]);
break;
case "--cp":
case "--cd":
case "--processes":
case "--concurrent-processes":
case "--concurrent-downloads":
maxConcurrentProcesses = int.Parse(args[++i]);
break;
case "--spt":
case "--searches-per-time":
searchesPerTime = int.Parse(args[++i]);
break;
case "--srt":
case "--searches-renew-time":
searchResetTime = int.Parse(args[++i]);
break;
case "--mr":
case "--retries":
case "--max-retries":
maxRetriesPerTrack = int.Parse(args[++i]);
break;
case "--atc":
case "--album-track-count":
string a = args[++i];
if (a.Last() == '-')
maxAlbumTrackCount = int.Parse(a.Substring(0, a.Length - 1));
else if (a.Last() == '+')
minAlbumTrackCount = int.Parse(a.Substring(0, a.Length - 1));
else
{
minAlbumTrackCount = int.Parse(a);
maxAlbumTrackCount = minAlbumTrackCount;
}
break;
case "--matc":
case "--min-album-track-count":
minAlbumTrackCount = int.Parse(args[++i]);
break;
case "--Matc":
case "--max-album-track-count":
maxAlbumTrackCount = int.Parse(args[++i]);
break;
case "--eMtc":
case "--extract-max-track-count":
setAlbumMaxTrackCount = true;
break;
case "--aa":
case "--album-art":
switch (args[++i])
{
case "largest":
case "most":
albumArtOption = args[i];
break;
case "default":
albumArtOption = "";
break;
default:
throw new ArgumentException($"Invalid album art download mode \'{args[i]}\'");
}
break;
case "--aao":
case "--aa-only":
case "--album-art-only":
albumArtOnly = true;
if (albumArtOption == "")
{
albumArtOption = "largest";
}
preferredCond = new FileConditions();
necessaryCond = new FileConditions();
break;
case "--aif":
case "--album-ignore-fails":
albumIgnoreFails = true;
break;
case "-t":
case "--interactive":
interactiveMode = true;
break;
case "--pf":
case "--paf":
case "--pref-format":
preferredCond.Formats = args[++i].Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
break;
case "--plt":
case "--pref-length-tol":
preferredCond.LengthTolerance = int.Parse(args[++i]);
break;
case "--pmbr":
case "--pref-min-bitrate":
preferredCond.MinBitrate = int.Parse(args[++i]);
break;
case "--pMbr":
case "--pref-max-bitrate":
preferredCond.MaxBitrate = int.Parse(args[++i]);
break;
case "--pmsr":
case "--pref-min-samplerate":
preferredCond.MinSampleRate = int.Parse(args[++i]);
break;
case "--pMsr":
case "--pref-max-samplerate":
preferredCond.MaxSampleRate = int.Parse(args[++i]);
break;
case "--pmbd":
case "--pref-min-bitdepth":
preferredCond.MinBitDepth = int.Parse(args[++i]);
break;
case "--pMbd":
case "--pref-max-bitdepth":
preferredCond.MaxBitDepth = int.Parse(args[++i]);
break;
case "--pdw":
case "--pref-danger-words":
preferredCond.DangerWords = args[++i].Split(',');
break;
case "--pst":
case "--pstt":
case "--pref-strict-title":
preferredCond.StrictTitle = true;
break;
case "--psa":
case "--pref-strict-artist":
preferredCond.StrictArtist = true;
break;
case "--psal":
case "--pref-strict-album":
preferredCond.StrictAlbum = true;
break;
case "--pbu":
case "--pref-banned-users":
preferredCond.BannedUsers = args[++i].Split(',');
break;
case "--af":
case "--format":
necessaryCond.Formats = args[++i].Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
break;
case "--lt":
case "--length-tol":
necessaryCond.LengthTolerance = int.Parse(args[++i]);
break;
case "--mbr":
case "--min-bitrate":
necessaryCond.MinBitrate = int.Parse(args[++i]);
break;
case "--Mbr":
case "--max-bitrate":
necessaryCond.MaxBitrate = int.Parse(args[++i]);
break;
case "--msr":
case "--min-samplerate":
necessaryCond.MinSampleRate = int.Parse(args[++i]);
break;
case "--Msr":
case "--max-samplerate":
necessaryCond.MaxSampleRate = int.Parse(args[++i]);
break;
case "--mbd":
case "--min-bitdepth":
necessaryCond.MinBitDepth = int.Parse(args[++i]);
break;
case "--Mbd":
case "--max-bitdepth":
necessaryCond.MaxBitDepth = int.Parse(args[++i]);
break;
case "--dw":
case "--danger-words":
necessaryCond.DangerWords = args[++i].Split(',');
break;
case "--stt":
case "--strict-title":
necessaryCond.StrictTitle = true;
break;
case "--sa":
case "--strict-artist":
necessaryCond.StrictArtist = true;
break;
case "--sal":
case "--strict-album":
necessaryCond.StrictAlbum = true;
break;
case "--bu":
case "--banned-users":
necessaryCond.BannedUsers = args[++i].Split(',');
break;
case "--c":
case "--cond":
case "--conditions":
ParseConditions(necessaryCond, args[++i]);
break;
case "--pc":
case "--pref":
case "--preferred-conditions":
ParseConditions(preferredCond, args[++i]);
break;
case "--nmsc":
case "--no-modify-share-count":
noModifyShareCount = true;
break;
case "--seut":
case "--skip-existing-use-tags":
skipExisting = true;
useTagsCheckExisting = true;
break;
case "-d":
case "--desperate":
desperateSearch = true;
break;
case "--dm":
case "--display":
case "--display-mode":
switch (args[++i])
{
case "single":
case "double":
case "simple":
displayStyle = args[i];
break;
default:
throw new ArgumentException($"Invalid display style \"{args[i]}\"");
}
break;
case "--sm":
case "--skip-mode":
switch (args[++i])
{
case "name":
case "name-precise":
case "tag":
case "tag-precise":
useTagsCheckExisting = args[i].Contains("tag");
preciseSkip = args[i].Contains("-precise");
break;
default:
throw new ArgumentException($"Invalid skip mode \'{args[i]}\'");
}
break;
case "--nrsc":
case "--no-remove-special-chars":
noRemoveSpecialChars = true;
break;
case "--amw":
case "--artist-maybe-wrong":
artistMaybeWrong = true;
break;
case "--fs":
case "--fast-search":
fastSearch = true;
break;
case "--fsd":
case "--fast-search-delay":
fastSearchDelay = int.Parse(args[++i]);
break;
case "--fsmus":
case "--fast-search-min-up-speed":
fastSearchMinUpSpeed = double.Parse(args[++i]);
break;
case "--debug":
debugInfo = true;
break;
case "--sc":
case "--strict":
case "--strict-conditions":
preferredCond.AcceptMissingProps = false;
necessaryCond.AcceptMissingProps = false;
break;
case "--yda":
case "--yt-dlp-argument":
ytdlpArgument = args[++i];
break;
case "-a":
case "--album":
album = true;
break;
case "--oc":
case "--on-complete":
onComplete = args[++i];
break;
case "--ftd":
case "--fails-to-deprioritize":
deprioritizeOn = -int.Parse(args[++i]);
break;
case "--fti":
case "--fails-to-ignore":
ignoreOn = -int.Parse(args[++i]);
break;
default:
throw new ArgumentException($"Unknown argument: {args[i]}");
}
}
else
{
if (input == "")
input = args[i];
else
throw new ArgumentException($"Invalid argument \'{args[i]}\'. Input is already set to \'{input}\'");
}
}
if (input == "")
throw new ArgumentException($"No input provided");
if (!(new string[] { "", "youtube", "spotify", "csv", "string", "bandcamp" }).Contains(inputType))
throw new ArgumentException($"Invalid input type '{inputType}'");
if (ytKey != "")
YouTube.apiKey = ytKey;
if (debugDisableDownload)
maxConcurrentProcesses = 1;
ignoreOn = ignoreOn > deprioritizeOn ? deprioritizeOn : ignoreOn;
if (inputType == "youtube" || (inputType == "" && input.StartsWith("http") && input.Contains("youtu")))
{
WriteLine("Youtube download", debugOnly: true);
await YoutubeInput();
}
else if (inputType == "spotify" || (inputType == "" && (input.StartsWith("http") && input.Contains("spotify")) || input == "spotify-likes"))
{
WriteLine("Spotify download", debugOnly: true);
await SpotifyInput();
}
else if (inputType == "bandcamp" || (inputType == "" && input.StartsWith("http") && input.Contains("bandcamp")))
{
WriteLine("Bandcamp download", debugOnly: true);
await BandcampInput();
}
else if (inputType == "csv" || (inputType == "" && Path.GetExtension(input).Equals(".csv", StringComparison.OrdinalIgnoreCase)))
{
WriteLine("CSV download", debugOnly: true);
await CsvInput();
}
else
{
WriteLine("String download", debugOnly: true);
await StringInput();
}
WriteLine("Got tracks", debugOnly: true);
if (reverse)
{
trackLists.Reverse();
trackLists = TrackLists.FromFlatList(trackLists.Flattened().Skip(offset).Take(maxTracks).ToList(), aggregate, album);
}
PreprocessTrackList(trackLists);
if (folderName == "")
folderName = defaultFolderName;
if (folderName == ".")
folderName = "";
folderName = folderName.Replace("\\", "/");
folderName = String.Join('/', folderName.Split("/").Select(x => ReplaceInvalidChars(x, " ").Trim()));
folderName = folderName.Replace('/', Path.DirectorySeparatorChar);
outputFolder = Path.Combine(parentFolder, folderName);
m3uFilePath = Path.Combine((m3uFilePath != "" ? m3uFilePath : outputFolder), (folderName == "" ? "playlist" : folderName) + ".m3u8");
m3uOption = debugDisableDownload ? "none" : m3uOption;
m3uEditor = new M3UEditor(m3uFilePath, outputFolder, trackLists, offset, m3uOption);
bool needLogin = !(debugPrintTracks && trackLists.lists.All(x => x.type == TrackLists.ListType.Normal));
if (needLogin)
{
client = new SoulseekClient(new SoulseekClientOptions(listenPort: listenPort));
if (!useRandomLogin && (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)))
throw new ArgumentException("No soulseek username or password");
await Login(useRandomLogin);
}
bool needUpdate = needLogin;
if (needUpdate)
{
var UpdateTask = Task.Run(() => Update());
WriteLine("Update started", debugOnly: true);
}
searchSemaphore = new RateLimitedSemaphore(searchesPerTime, TimeSpan.FromSeconds(searchResetTime));
await MainLoop();
WriteLine("Mainloop done", debugOnly: true);
}
static async Task YoutubeInput()
{
int max = reverse ? int.MaxValue : maxTracks;
int off = reverse ? 0 : offset;
ytUrl = input;
inputType = "youtube";
string name;
List<Track>? deleted = null;
List<Track> tracks;
if (getDeleted)
{
Console.WriteLine("Getting deleted videos..");
var archive = new YouTube.YouTubeArchiveRetriever();
deleted = await archive.RetrieveDeleted(ytUrl);
}
if (YouTube.apiKey != "")
{
Console.WriteLine("Loading YouTube playlist (API)");
(name, tracks) = await YouTube.GetTracksApi(ytUrl, max, off);
}
else
{
Console.WriteLine("Loading YouTube playlist");
(name, tracks) = await YouTube.GetTracksYtExplode(ytUrl, max, off);
}
if (deleted != null)
{
tracks.InsertRange(0, deleted);
}
YouTube.StopService();
trackLists.AddEntry(tracks);
if (album || aggregate)
trackLists = TrackLists.FromFlatList(trackLists.Flattened().ToList(), aggregate, album);
defaultFolderName = ReplaceInvalidChars(name, " ");
}
static async Task SpotifyInput()
{
int max = reverse ? int.MaxValue : maxTracks;
int off = reverse ? 0 : offset;
spotifyUrl = input;
inputType = "spotify";
string? playlistName;
bool usedDefaultId = false;
bool needLogin = spotifyUrl == "spotify-likes" || removeTracksFromSource;
List<Track> tracks;
static void readSpotifyCreds()
{
Console.Write("Spotify client ID:");
spotifyId = Console.ReadLine();
Console.Write("Spotify client secret:");
spotifySecret = Console.ReadLine();
Console.WriteLine();
}
if (needLogin && (spotifyId == "" || spotifySecret == ""))
{
readSpotifyCreds();
}
spotifyClient = new Spotify(spotifyId, spotifySecret);
await spotifyClient.Authorize(needLogin, removeTracksFromSource);
if (spotifyUrl == "spotify-likes")
{
Console.WriteLine("Loading Spotify likes");
tracks = await spotifyClient.GetLikes(max, off);
playlistName = "Spotify Likes";
trackLists.AddEntry(tracks);
if (album || aggregate)
trackLists = TrackLists.FromFlatList(trackLists.Flattened().ToList(), aggregate, album);
}
else if (spotifyUrl.Contains("/album/"))
{
Console.WriteLine("Loading Spotify album");
(var source, tracks) = await spotifyClient.GetAlbum(spotifyUrl);
playlistName = source.ToString(noInfo: true);
trackLists.AddEntry(TrackLists.ListType.Album, source);
if (minAlbumTrackCount == -1 && setAlbumMinTrackCount && (maxAlbumTrackCount == -1 || maxAlbumTrackCount >= tracks.Count))
{
minAlbumTrackCount = tracks.Count;
}
if (maxAlbumTrackCount == -1 && setAlbumMaxTrackCount && (minAlbumTrackCount == -1 || minAlbumTrackCount <= tracks.Count))
{
maxAlbumTrackCount = tracks.Count;
}
}
else
{
try
{
Console.WriteLine("Loading Spotify playlist");
(playlistName, playlistUri, tracks) = await spotifyClient.GetPlaylist(spotifyUrl, max, off);
}
catch (SpotifyAPI.Web.APIException)
{
if (!needLogin)
{
Console.WriteLine("Spotify playlist not found. It may be set to private. Login? [Y/n]");
string answer = Console.ReadLine();
if (answer.ToLower() == "y")
{
if (usedDefaultId)
readSpotifyCreds();
await spotifyClient.Authorize(true);
Console.WriteLine("Loading Spotify tracks");
(playlistName, playlistUri, tracks) = await spotifyClient.GetPlaylist(spotifyUrl, max, off);
}
else
{
Environment.Exit(0);
return;
}
}
else throw;
}
trackLists.AddEntry(tracks);
if (album || aggregate)
trackLists = TrackLists.FromFlatList(trackLists.Flattened().ToList(), aggregate, album);
}
defaultFolderName = ReplaceInvalidChars(playlistName, " ");
}
static async Task BandcampInput()
{
inputType = "bandcamp";
bool isAlbum = !input.Contains("/track/");
var web = new HtmlWeb();
var doc = await web.LoadFromWebAsync(input);
var nameSection = doc.DocumentNode.SelectSingleNode("//div[@id='name-section']");
var name = nameSection.SelectSingleNode(".//h2[@class='trackTitle']").InnerText.UnHtmlString().Trim();
if (isAlbum)
{
var artist = nameSection.SelectSingleNode(".//h3/span/a").InnerText.UnHtmlString().Trim();
var track = new Track() { Artist=artist, Album=name, IsAlbum=true };
trackLists.AddEntry(TrackLists.ListType.Album, track);
if (minAlbumTrackCount == -1 && setAlbumMinTrackCount || maxAlbumTrackCount == -1 && setAlbumMaxTrackCount)
{
var trackTable = doc.DocumentNode.SelectSingleNode("//*[@id='track_table']");
int n = trackTable.SelectNodes(".//tr").Count;
if (minAlbumTrackCount == -1 && setAlbumMinTrackCount && (maxAlbumTrackCount == -1 || maxAlbumTrackCount >= n))
{
minAlbumTrackCount = n;
}
if (maxAlbumTrackCount == -1 && setAlbumMaxTrackCount && (minAlbumTrackCount == -1 || minAlbumTrackCount <= n))
{
maxAlbumTrackCount = n;
}
}
defaultFolderName = ReplaceInvalidChars(track.ToString(true), " ").Trim();
}
else
{
var album = nameSection.SelectSingleNode(".//h3[@class='albumTitle']/span/a").InnerText.UnHtmlString().Trim();
var artist = nameSection.SelectSingleNode(".//h3[@class='albumTitle']/span[last()]/a").InnerText.UnHtmlString().Trim();
//var timeParts = doc.DocumentNode.SelectSingleNode("//span[@class='time_total']").InnerText.Trim().Split(':');
var track = new Track() { Artist=artist, Title=name, Album=album };
trackLists.AddEntry(track);
defaultFolderName = ".";
}
}
static async Task CsvInput()
{
int max = reverse ? int.MaxValue : maxTracks;
int off = reverse ? 0 : offset;
csvPath = input;
inputType = "csv";
if (!File.Exists(csvPath))
throw new FileNotFoundException("CSV file not found");
var tracks = await ParseCsvIntoTrackInfo(csvPath, artistCol, trackCol, lengthCol, albumCol, descCol, ytIdCol, timeUnit, ytParse);
tracks = tracks.Skip(off).Take(max).ToList();
trackLists = TrackLists.FromFlatList(tracks, aggregate, album);
defaultFolderName = Path.GetFileNameWithoutExtension(csvPath);
}
static async Task StringInput()
{
searchStr = input;
inputType = "string";
var music = ParseTrackArg(searchStr, true, album);
bool isAlbum = false;
if (album)
{
trackLists.AddEntry(TrackLists.ListType.Album, new Track(music) { IsAlbum = true });
}
else if (!aggregate && music.Title != "")
{
trackLists.AddEntry(music);
}
else if (aggregate)
{
trackLists.AddEntry(TrackLists.ListType.Aggregate, music);
}
else if (music.Title == "" && music.Album != "")
{
isAlbum = true;
music.IsAlbum = true;
trackLists.AddEntry(TrackLists.ListType.Album, music);
}
else
{
throw new ArgumentException("Need track title or album");
}
if (aggregate || isAlbum || album)
defaultFolderName = ReplaceInvalidChars(music.ToString(true), " ").Trim();
else
defaultFolderName = ".";
}
static async Task MainLoop()
{
for (int i = 0; i < trackLists.lists.Count; i++)
{
var (list, type, source) = trackLists.lists[i];
List<Track> existing = new List<Track>();
List<Track> notFound = new List<Track>();
if (skipNotFound)
{
(notFound, source) = SkipNotFound(list[0], source);
trackLists.SetSource(source, i);
foreach (var tracks in list.Skip(1)) SkipNotFound(tracks, source);
}
if (trackLists.lists.Count > 1 || type != TrackLists.ListType.Normal)
{
string sourceStr = type == TrackLists.ListType.Normal ? "" : $": {source.ToString(noInfo: type == TrackLists.ListType.Album)}";
bool needSearchStr = type == TrackLists.ListType.Normal || skipNotFound && source.TrackState == Track.State.NotFoundLastTime;
string searchStr = needSearchStr ? "" : $", searching..";
Console.WriteLine($"{Enum.GetName(typeof(TrackLists.ListType), type)} download{sourceStr}{searchStr}");
}
if (!(skipNotFound && source.TrackState == Track.State.NotFoundLastTime))
{
if (type == TrackLists.ListType.Normal)
{
// list[0] should already contain the tracks
}
else if (type == TrackLists.ListType.Album)
{
list = await GetAlbumDownloads(source);
trackLists.SetList(list, i);
if (!debugDisableDownload && (list.Count == 0 || list[0].Count == 0))
{
source = new Track(source) { TrackState = Track.State.Failed, FailureReason = nameof(FailureReasons.NoSuitableFileFound) };
trackLists.SetSource(source, i);
}
}
else if (type == TrackLists.ListType.Aggregate)
{
list[0] = await GetUniqueRelatedTracks(source);
if (list[0].Count == 0)
{
source = new Track(source) { TrackState = Track.State.Failed, FailureReason = nameof(FailureReasons.NoSuitableFileFound) };
trackLists.SetSource(source, i);
}
}
}
if (skipExisting && list != null)
{
existing = DoSkipExisting(list[0], print: i==0, useCache: trackLists.lists.Count > 1);
foreach (var tracks in list.Skip(1)) DoSkipExisting(tracks, false, useCache: trackLists.lists.Count > 1);
}
m3uEditor.Update();
if (list != null && (!interactiveMode || debugPrintTracks))
{
PrintTracksTbd(list[0].Where(t => t.TrackState == Track.State.Initial).ToList(), existing, notFound, type);
}
if (debugPrintTracks || list?.Count == 0 || list?[0].Count == 0)
{
if (i < trackLists.lists.Count - 1) Console.WriteLine();
continue;
}
if (type == TrackLists.ListType.Normal)
{
await TracksDownloadNormal(list[0]);
}
else if (type == TrackLists.ListType.Album && list != null)
{
await TracksDownloadAlbum(list, albumArtOnly);
}
else if (type == TrackLists.ListType.Aggregate)
{
await TracksDownloadNormal(list[0]);
}
if (i < trackLists.lists.Count - 1)
{
Console.WriteLine();
}
}
if (!debugDisableDownload && trackLists.CombinedTrackList().Count > 1)
{
PrintComplete();
}
}
static void PreprocessTrackList(TrackLists trackLists)
{
for (int k = 0; k < trackLists.lists.Count; k++)
{
var (list, type, source) = trackLists.lists[k];
trackLists.lists[k] = (list, type, PreprocessTrack(source));
foreach (var ls in list)
{
for (int i = 0; i < ls.Count; i++)
{
ls[i] = PreprocessTrack(ls[i]);
}
}
}
}
static Track PreprocessTrack(Track track)
{
if (removeFt)
{
track.Title = track.Title.RemoveFt();
track.Artist = track.Artist.RemoveFt();
}
if (removeBrackets)
{
track.Title = track.Title.RemoveSquareBrackets();
}
if (regexPatternToReplace != "")
{
track.Title = Regex.Replace(track.Title, regexPatternToReplace, regexReplacePattern);
track.Artist = Regex.Replace(track.Artist, regexPatternToReplace, regexReplacePattern);
}
if (artistMaybeWrong)
{
track.ArtistMaybeWrong = true;
}
return track;
}
static void PrintComplete()
{
var ls = trackLists.CombinedTrackList();
int successes = 0, fails = 0;
foreach (var x in ls)
{
if (x.TrackState == Track.State.Downloaded)
successes++;
else if (x.TrackState == Track.State.Failed)
fails++;
}
if (successes + fails > 1)
Console.WriteLine($"\nCompleted: {successes} succeeded, {fails} failed.");
}
static void PrintTracksTbd(List<Track> tracks, List<Track> existing, List<Track> notFound, TrackLists.ListType type)
{
if (type == TrackLists.ListType.Normal && !debugPrintTracks && tracks.Count == 1 && existing.Count + notFound.Count == 0)
return;
string notFoundLastTime = notFound.Count > 0 ? $"{notFound.Count} not found" : "";
string alreadyExist = existing.Count > 0 ? $"{existing.Count} already exist" : "";
notFoundLastTime = alreadyExist != "" && notFoundLastTime != "" ? ", " + notFoundLastTime : notFoundLastTime;
string skippedTracks = alreadyExist + notFoundLastTime != "" ? $" ({alreadyExist}{notFoundLastTime})" : "";
Console.WriteLine($"Downloading {tracks.Count(x => !x.IsNotAudio)} tracks{skippedTracks}");
if (type != TrackLists.ListType.Album)
{
if (tracks.Count > 0)
{
bool showAll = type != TrackLists.ListType.Normal || debugPrintTracks;
PrintTracks(tracks, showAll ? int.MaxValue : 10, debugPrintTracksFull, infoFirst: debugPrintTracks);
if (debugPrintTracksFull && (existing.Count > 0 || notFound.Count > 0))
Console.WriteLine("\n-----------------------------------------------\n");
}
}
else if (!interactiveMode && tracks.Count > 0 && !tracks[0].Downloads.IsEmpty)
{
var response = tracks[0].Downloads.First().Value.Item1;
string userInfo = $"{response.Username} ({((float)response.UploadSpeed / (1024 * 1024)):F3}MB/s)";
var (parents, props) = FolderInfo(tracks.SelectMany(x => x.Downloads.Select(d => d.Value.Item2)));
Console.WriteLine();
WriteLine($"User : {userInfo}\nFolder: {parents}\nProps : {props}", ConsoleColor.White);
PrintTracks(tracks.Where(t => t.TrackState == Track.State.Initial).ToList(), pathsOnly: true, showAncestors: true, showUser: false);
Console.WriteLine();
}
if (debugPrintTracks)
{
if (existing.Count > 0)
{
Console.WriteLine($"\nThe following tracks already exist:");
PrintTracks(existing, fullInfo: debugPrintTracksFull, infoFirst: debugPrintTracks);
}
if (notFound.Count > 0)
{
Console.WriteLine($"\nThe following tracks were not found during the last run:");
PrintTracks(notFound, fullInfo: debugPrintTracksFull, infoFirst: debugPrintTracks);
}
}
}
static List<Track> DoSkipExisting(List<Track> tracks, bool print, bool useCache)
{
var existing = new Dictionary<Track, string>();
if (!(musicDir != "" && outputFolder.StartsWith(musicDir, StringComparison.OrdinalIgnoreCase)) && System.IO.Directory.Exists(outputFolder))
{
var d = SkipExisting(tracks, outputFolder, necessaryCond, useTagsCheckExisting, preciseSkip, useCache);
d.ToList().ForEach(x => existing.TryAdd(x.Key, x.Value));
}
if (musicDir != "" && System.IO.Directory.Exists(musicDir))
{
if (print) Console.WriteLine($"Checking if tracks exist in library..");
var d = SkipExisting(tracks, musicDir, necessaryCond, useTagsCheckExisting, preciseSkip, useCache);
d.ToList().ForEach(x => existing.TryAdd(x.Key, x.Value));
}
else if (musicDir != "" && !System.IO.Directory.Exists(musicDir))
if (print) Console.WriteLine($"Musid dir does not exist: {musicDir}");
return existing.Select(x => x.Key).ToList();
}
static (List<Track>, Track) SkipNotFound(List<Track> tracks, Track source)
{
List<Track> notFound = new List<Track>();
if (m3uEditor.HasFail(source, out string? reason) && reason == nameof(FailureReasons.NoSuitableFileFound))
{
notFound.Add(source);
source = new Track(source) { TrackState = Track.State.NotFoundLastTime };
}
for (int i = tracks.Count - 1; i >= 0; i--)
{
if (m3uEditor.HasFail(tracks[i], out reason) && reason == nameof(FailureReasons.NoSuitableFileFound))
{
notFound.Add(tracks[i]);
tracks[i] = new Track(tracks[i]) { TrackState = Track.State.NotFoundLastTime };
}
}
return (notFound, source);
}
static async Task TracksDownloadNormal(List<Track> tracks)
{
SemaphoreSlim semaphore = new SemaphoreSlim(maxConcurrentProcesses);
var copy = new List<Track>(tracks);
var downloadTasks = copy.Select(async (track, index) =>
{
if (track.TrackState == Track.State.Exists || track.TrackState == Track.State.NotFoundLastTime)
return;
await semaphore.WaitAsync();
int tries = 2;
retry:
await WaitForLogin();
try
{
WriteLine($"Search and download {track}", debugOnly: true);
var savedFilePath = await SearchAndDownload(track);
lock (trackLists) { tracks[index] = new Track(track) { TrackState=Track.State.Downloaded, DownloadPath=savedFilePath }; }
if (removeTracksFromSource && !string.IsNullOrEmpty(spotifyUrl))
spotifyClient.RemoveTrackFromPlaylist(playlistUri, track.URI);
}
catch (Exception ex)
{
WriteLine($"Exception thrown: {ex}", debugOnly: true);
if (!client.State.HasFlag(SoulseekClientStates.LoggedIn))
{
goto retry;
}
else if (ex is SearchAndDownloadException)
{
lock (trackLists) { tracks[index] = new Track(track) { TrackState = Track.State.Failed, FailureReason = ex.Message }; }
}
else
{
WriteLine($"\n{ex.Message}\n{ex.StackTrace}\n", ConsoleColor.DarkYellow, true);
if (tries-- > 0)
goto retry;
}
}
finally { semaphore.Release(); }
m3uEditor.Update();
if (onComplete != "")
{
OnComplete(onComplete, tracks[index]);
}
});
await Task.WhenAll(downloadTasks);
}
static async Task TracksDownloadAlbum(List<List<Track>> list, bool imagesOnly) // bad
{
var dlFiles = new ConcurrentDictionary<string, bool>();
var dlAdditionalImages = new ConcurrentDictionary<string, bool>();
var tracks = new List<Track>();
bool downloadingImages = false;
bool albumDlFailed = false;
var listRef = list;
void prepareImageDownload()
{
var albumArtList = list.Select(tracks => tracks.Where(t => Utils.IsImageFile(t.Downloads.First().Value.Item2.Filename))).Where(tracks => tracks.Any());
if (albumArtOption == "largest")
{
list = albumArtList // shitty shortcut
.OrderByDescending(tracks => tracks.Select(t => t.Downloads.First().Value.Item2.Size).Max() / 1024 / 100)
.ThenByDescending(tracks => tracks.First().Downloads.First().Value.Item1.UploadSpeed / 1024 / 300)
.ThenByDescending(tracks => tracks.Select(t => t.Downloads.First().Value.Item2.Size).Sum() / 1024 / 100)
.Select(x => x.ToList()).ToList();
}
else if (albumArtOption == "most")
{
list = albumArtList // shitty shortcut
.OrderByDescending(tracks => tracks.Count())
.ThenByDescending(tracks => tracks.First().Downloads.First().Value.Item1.UploadSpeed / 1024 / 300)
.ThenByDescending(tracks => tracks.Select(t => t.Downloads.First().Value.Item2.Size).Sum() / 1024 / 100)
.Select(x => x.ToList()).ToList();
}
downloadingImages = true;
}
if (imagesOnly)
{
prepareImageDownload();
}
int idx = -1;
while (list.Count > 0)
{
idx++;
albumDlFailed = false;
tracks = interactiveMode ? InteractiveModeAlbum(list) : list[0];
if (!downloadingImages && tracks.All(t => t.TrackState != Track.State.Initial || (!interactiveMode && t.IsNotAudio)))
goto imgDl;
if (list.Count <= 1 && tracks.All(t => t.TrackState != Track.State.Initial))
goto imgDl;
mainLoopCts = new CancellationTokenSource();
albumCommonPath = Utils.GreatestCommonPath(tracks.SelectMany(x => x.Downloads.Select(y => y.Value.Item2.Filename)), dirsep: '\\');
SemaphoreSlim semaphore = new SemaphoreSlim(maxConcurrentProcesses);
var copy = new List<Track>(tracks);
if (!interactiveMode && idx > 0 && tracks.Count > 0 && !tracks[0].Downloads.IsEmpty)
{
var response = tracks[0].Downloads.First().Value.Item1;
string userInfo = $"{response.Username} ({((float)response.UploadSpeed / (1024 * 1024)):F3}MB/s)";
var (parents, props) = FolderInfo(tracks.SelectMany(x => x.Downloads.Select(d => d.Value.Item2)));
Console.WriteLine();
WriteLine($"User : {userInfo}\nFolder: {parents}\nProps : {props}", ConsoleColor.White);
PrintTracks(tracks.Where(t => t.TrackState == Track.State.Initial).ToList(), pathsOnly: true, showAncestors: true, showUser: false);
Console.WriteLine();
}
try
{
var downloadTasks = copy.Select(async (track, index) =>
{
if (track.TrackState != Track.State.Initial)
return;
await semaphore.WaitAsync(mainLoopCts.Token);
int tries = 2;
retry:
await WaitForLogin();
mainLoopCts.Token.ThrowIfCancellationRequested();
try
{
var savedFilePath = await SearchAndDownload(track);
dlFiles.TryAdd(savedFilePath, true);
lock (trackLists)
{
tracks[index] = new Track(track) { TrackState = Track.State.Downloaded, DownloadPath = savedFilePath };
if (downloadingImages)
{
dlAdditionalImages.TryAdd(savedFilePath, true);
ReplaceTrack(listRef, track, tracks[index]); // shitty shortcut
}
}
}
catch (Exception ex)
{
if (!client.State.HasFlag(SoulseekClientStates.LoggedIn))
{
goto retry;
}
else if (ex is SearchAndDownloadException)
{
lock (trackLists)
{
tracks[index] = new Track(track) { TrackState = Track.State.Failed, FailureReason = ex.Message };
if (downloadingImages)
ReplaceTrack(listRef, track, tracks[index]); // shitty shortcut
}
}
else
{
WriteLine($"\n{ex.Message}\n{ex.StackTrace}\n", ConsoleColor.DarkYellow, true);
if (tries-- > 0)
goto retry;
}
if (!albumIgnoreFails)
{
mainLoopCts.Cancel();
lock (downloads)
{
foreach (var (key, dl) in downloads)
{
dl.cts.Cancel();
if (File.Exists(dl.savePath)) File.Delete(dl.savePath);
}
}
throw new OperationCanceledException();
}
}
finally { semaphore.Release(); }
if (onComplete != "")
{
OnComplete(onComplete, tracks[index]);
}
});
await Task.WhenAll(downloadTasks);
}
catch (OperationCanceledException)
{
if (!albumIgnoreFails)
{
if (!downloadingImages)
albumDlFailed = true;
var setToClear = downloadingImages ? dlAdditionalImages : dlFiles;
foreach (var path in setToClear.Keys)
if (File.Exists(path)) File.Delete(path);
setToClear.Clear();
list.RemoveAt(0);
continue;
}
}
imgDl:
if (!downloadingImages && !albumDlFailed && albumArtOption != "")
{
prepareImageDownload();
bool needImageDl = true;
if (!interactiveMode && list.Count > 0)
{
if (albumArtOption == "most")
{
needImageDl = dlFiles.Keys.Count(x => Utils.IsImageFile(x) && File.Exists(x)) < list[0].Count;
}
else if (albumArtOption == "largest")
{
long curMax = dlFiles.Keys.Where(x => Utils.IsImageFile(x) && File.Exists(x)).Max(x => new FileInfo(x).Length);
needImageDl = curMax < list[0].Max(t => t.Downloads.First().Value.Item2.Size) - 1024 * 50;
}
}
if (needImageDl)
{
continue;
}
}
break;
}
ApplyNamingFormatsNonAudio(listRef);
m3uEditor.Update();
albumCommonPath = "";
}
static void ReplaceTrack(List<List<Track>> list, Track oldTrack, Track newTrack) // shitty shortcut
{
foreach (var sublist in list)
{
for (int i = 0; i < sublist.Count; i++)
{
if (sublist[i].Equals(oldTrack))
{
sublist[i] = newTrack;
return;
}
}
}
}
static List<Track> InteractiveModeAlbum(List<List<Track>> list)
{
int aidx = 0;
static string interactiveModeLoop()
{
string userInput = "";
while (true)
{
var key = Console.ReadKey(false);
if (key.Key == ConsoleKey.DownArrow)
return "n";
else if (key.Key == ConsoleKey.UpArrow)
return "p";
else if (key.Key == ConsoleKey.Escape)
return "c";
else if (key.Key == ConsoleKey.Enter)
return userInput;
else
userInput += key.KeyChar;
}
}
Console.WriteLine($"\nPrev [Up/p] / Next [Down/n] / Accept [Enter] / Accept & Exit Interactive Mode [q] / Cancel [Esc/c]");
while (true)
{
Console.WriteLine();
var tracks = list[aidx];
var response = tracks[0].Downloads.First().Value.Item1;
string userInfo = $"{response.Username} ({((float)response.UploadSpeed / (1024 * 1024)):F3}MB/s)";
var (parents, props) = FolderInfo(tracks.SelectMany(x => x.Downloads.Select(d => d.Value.Item2)));
WriteLine($"[{aidx + 1} / {list.Count}]", ConsoleColor.DarkGray);
WriteLine($"User : {userInfo}\nFolder: {parents}\nProps : {props}", ConsoleColor.White);
PrintTracks(tracks.Where(t => t.TrackState == Track.State.Initial).ToList(), pathsOnly: true, showAncestors: true, showUser: false);
string userInput = interactiveModeLoop();
switch (userInput)
{
case "p":
aidx = (aidx + list.Count - 1) % list.Count;
break;
case "n":
aidx = (aidx + 1) % list.Count;
break;
case "c":
return new List<Track>();
case "q":
interactiveMode = false;
list.RemoveAt(aidx);
return tracks;
case "":
return tracks;
}
}
}
static (string parents, string props) FolderInfo(IEnumerable<SlFile> files)
{
string res = "";
int totalLengthInSeconds = files.Sum(f => f.Length ?? 0);
var sampleRates = files.Where(f => f.SampleRate.HasValue).Select(f => f.SampleRate.Value).OrderBy(r => r).ToList();
int? modeSampleRate = sampleRates.GroupBy(rate => rate).OrderByDescending(g => g.Count()).Select(g => (int?)g.Key).FirstOrDefault();
var bitRates = files.Where(f => f.BitRate.HasValue).Select(f => f.BitRate.Value).ToList();
double? meanBitrate = bitRates.Count > 0 ? (double?)bitRates.Average() : null;
double totalFileSizeInMB = files.Sum(f => f.Size) / (1024.0 * 1024.0);
TimeSpan totalTimeSpan = TimeSpan.FromSeconds(totalLengthInSeconds);
string totalLengthFormatted;
if (totalTimeSpan.TotalHours >= 1)
totalLengthFormatted = string.Format("{0}:{1:D2}:{2:D2}", (int)totalTimeSpan.TotalHours, totalTimeSpan.Minutes, totalTimeSpan.Seconds);
else
totalLengthFormatted = string.Format("{0:D2}:{1:D2}", totalTimeSpan.Minutes, totalTimeSpan.Seconds);
var mostCommonExtension = files.GroupBy(f => GetExtensionSlsk(f.Filename))
.OrderByDescending(g => Utils.IsMusicExtension(g.Key)).ThenByDescending(g => g.Count()).First().Key;
res = $"[{mostCommonExtension.ToUpper()} / {totalLengthFormatted}";
if (modeSampleRate.HasValue)
res += $" / {(modeSampleRate.Value/1000.0).Normalize()} kHz";
if (meanBitrate.HasValue)
res += $" / {(int)meanBitrate.Value} kbps";
res += $" / {totalFileSizeInMB:F2} MB]";
string gcp;
if (files.Count() > 1)
gcp = Utils.GreatestCommonPath(files.Select(x => x.Filename), '\\').TrimEnd('\\');
else
gcp = GetDirectoryNameSlsk(files.First().Filename);
var discPattern = new Regex(@"^(?i)(dis[c|k]|cd)\s*\d{1,2}$");
int lastIndex = gcp.LastIndexOf('\\');
if (lastIndex != -1)
{
int secondLastIndex = gcp.LastIndexOf('\\', lastIndex - 1);
gcp = secondLastIndex == -1 ? gcp.Substring(lastIndex + 1) : gcp.Substring(secondLastIndex + 1);
}
return (gcp, res);
}
static async Task Login(bool random = false, int tries = 3)
{
string user = username, pass = password;
if (random)
{
var r = new Random();
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
user = new string(Enumerable.Repeat(chars, 10).Select(s => s[r.Next(s.Length)]).ToArray());
pass = new string(Enumerable.Repeat(chars, 10).Select(s => s[r.Next(s.Length)]).ToArray());
}
WriteLine($"Login {user}", debugOnly: true);
while (true)
{
try
{
WriteLine($"Connecting {user}", debugOnly: true);
await client.ConnectAsync(user, pass);
if (!noModifyShareCount) {
WriteLine($"Setting share count", debugOnly: true);
await client.SetSharedCountsAsync(10, 50);
}
break;
}
catch (Exception e) {
WriteLine($"Exception while logging in: {e}", debugOnly: true);
if (!(e is Soulseek.AddressException || e is System.TimeoutException) && --tries == 0)
throw;
}
await Task.Delay(500);
WriteLine($"Retry login {user}", debugOnly: true);
}
WriteLine($"Logged in {user}", debugOnly: true);
}
static async Task<string> SearchAndDownload(Track track)
{
Console.ResetColor();
ProgressBar? progress = GetProgressBar(displayStyle);
var results = new SlDictionary();
var fsResults = new SlDictionary();
var cts = new CancellationTokenSource();
var saveFilePath = "";
Task? downloadTask = null;
var fsDownloadLock = new object();
int fsResultsStarted = 0;
int downloading = 0;
bool notFound = false;
bool searchEnded = false;
string fsUser = "";
string fsFile = "";
if (track.Downloads != null)
{
results = track.Downloads;
goto downloads;
}
RefreshOrPrint(progress, 0, $"Waiting: {track}", false);
string searchText = $"{track.Artist} {track.Title}".Trim();
var removeChars = new string[] { " ", "_", "-" };
searches.TryAdd(track, new SearchInfo(results, progress));
void fastSearchDownload()
{
lock (fsDownloadLock)
{
if (downloading == 0 && !searchEnded)
{
downloading = 1;
var (r, f) = fsResults.ArgMax(x => x.Value.Item1.UploadSpeed).Value;
saveFilePath = GetSavePath(f.Filename);
fsUser = r.Username;
fsFile = f.Filename;
downloadTask = DownloadFile(r, f, saveFilePath, track, progress, cts);
}
}
}
void responseHandler(SearchResponse r)
{
if (r.Files.Count > 0)
{
foreach (var file in r.Files)
results.TryAdd(r.Username + "\\" + file.Filename, (r, file));
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))
{
fsResults.TryAdd(r.Username + "\\" + f.Filename, (r, f));
if (Interlocked.Exchange(ref fsResultsStarted, 1) == 0)
{
Task.Delay(fastSearchDelay).ContinueWith(tt => fastSearchDownload());
}
}
}
}
}
SearchOptions getSearchOptions(int timeout, FileConditions necCond, FileConditions prfCond)
{
return new SearchOptions(
minimumResponseFileCount: 1,
minimumPeerUploadSpeed: 1,
searchTimeout: searchTimeout,
removeSingleCharacterSearchTerms: removeSingleCharacterSearchTerms,
responseFilter: (response) =>
{
return response.UploadSpeed > 0 && necCond.BannedUsersSatisfies(response);
},
fileFilter: (file) =>
{
return Utils.IsMusicFile(file.Filename) && (necCond.FileSatisfies(file, track, null) || printResultsFull);
});
}
void onSearch() => RefreshOrPrint(progress, 0, $"Searching: {track}", true);
await RunSearches(track, results, getSearchOptions, responseHandler, cts.Token, onSearch);
searches.TryRemove(track, out _);
searchEnded = true;
lock (fsDownloadLock) { }
if (downloading==0 && results.IsEmpty && !useYtdlp)
{
notFound = true;
}
else if (downloading==1)
{
try
{
if (downloadTask == null || downloadTask.IsFaulted || downloadTask.IsCanceled)
throw new TaskCanceledException();
await downloadTask;
userSuccessCount.AddOrUpdate(fsUser, 1, (k, v) => v + 1);
}
catch
{
saveFilePath = "";
downloading = 0;
results.TryRemove(fsUser + "\\" + fsFile, out _);
userSuccessCount.AddOrUpdate(fsUser, -1, (k, v) => v - 1);
}
}
cts.Dispose();
downloads:
if (debugDisableDownload && results.IsEmpty)
{
WriteLine($"No results", ConsoleColor.Yellow);
return "";
}
else if (downloading==0 && !results.IsEmpty)
{
var random = new Random();
var orderedResults = OrderedResults(results, track, true);
if (debugDisableDownload)
{
int count = 0;
foreach (var (response, file) in orderedResults) {
Console.WriteLine(DisplayString(track, file, response,
(printResultsFull ? necessaryCond : null), (printResultsFull ? preferredCond : null), printResultsFull, infoFirst: true));
count += 1;
}
WriteLine($"Total: {count}\n", ConsoleColor.Yellow);
return "";
}
async Task<bool> process(SlResponse response, SlFile file)
{
saveFilePath = GetSavePath(file.Filename);
try
{
downloading = 1;
await DownloadFile(response, file, saveFilePath, track, progress);
userSuccessCount.AddOrUpdate(response.Username, 1, (k, v) => v + 1);
return true;
}
catch (Exception e)
{
downloading = 0;
if (!client.State.HasFlag(SoulseekClientStates.LoggedIn))
throw;
userSuccessCount.AddOrUpdate(response.Username, -1, (k, v) => v - 1);
if (--maxRetriesPerTrack <= 0)
{
RefreshOrPrint(progress, 0, $"Out of download retries: {track}", true);
WriteLine("Last error was: " + e.Message, ConsoleColor.DarkYellow, true);
throw new SearchAndDownloadException(nameof(FailureReasons.OutOfDownloadRetries));
}
return false;
}
}
// the first result is usually fine, no need to sort the entire sequence
var fr = orderedResults.First();
bool success = await process(fr.response, fr.file);
if (!success)
{
fr = orderedResults.Skip(1).FirstOrDefault();
if (fr != default && userSuccessCount.GetValueOrDefault(fr.response.Username, 0) > ignoreOn)
{
success = await process(fr.response, fr.file);
}
if (!success && fr != default)
{
foreach (var (response, file) in orderedResults.Skip(2))
{
if (userSuccessCount.GetValueOrDefault(response.Username, 0) <= ignoreOn)
continue;
success = await process(response, file);
if (success) break;
}
}
}
}
if (downloading == 0 && useYtdlp)
{
notFound = false;
try
{
RefreshOrPrint(progress, 0, $"yt-dlp search: {track}", true);
var ytResults = await YouTube.YtdlpSearch(track);
if (ytResults.Count > 0)
{
foreach (var (length, id, title) in ytResults)
{
if (necessaryCond.LengthToleranceSatisfies(length, track.Length))
{
string saveFilePathNoExt = GetSavePathNoExt(title);
downloading = 1;
RefreshOrPrint(progress, 0, $"yt-dlp download: {track}", true);
saveFilePath = await YouTube.YtdlpDownload(id, saveFilePathNoExt, ytdlpArgument);
RefreshOrPrint(progress, 100, $"Succeded: yt-dlp completed download for {track}", true);
break;
}
}
}
}
catch (Exception e)
{
saveFilePath = "";
downloading = 0;
RefreshOrPrint(progress, 0, $"{e.Message}", true);
throw new SearchAndDownloadException(nameof(FailureReasons.NoSuitableFileFound));
}
}
if (downloading == 0)
{
if (notFound)
{
RefreshOrPrint(progress, 0, $"Not found: {track}", true);
throw new SearchAndDownloadException(nameof(FailureReasons.NoSuitableFileFound));
}
else
{
RefreshOrPrint(progress, 0, $"All downloads failed: {track}", true);
throw new SearchAndDownloadException(nameof(FailureReasons.AllDownloadsFailed));
}
}
if (nameFormat != "" && !useYtdlp)
saveFilePath = ApplyNamingFormat(saveFilePath);
return saveFilePath;
}
public class SearchAndDownloadException : Exception
{
public SearchAndDownloadException(string text = "") : base(text) { }
}
static async Task<List<List<Track>>> GetAlbumDownloads(Track track) // slow
{
var results = new ConcurrentDictionary<string, (SearchResponse, Soulseek.File)>();
SearchOptions getSearchOptions(int timeout, FileConditions nec, FileConditions prf) =>
new SearchOptions(
minimumResponseFileCount: 1,
minimumPeerUploadSpeed: 1,
removeSingleCharacterSearchTerms: removeSingleCharacterSearchTerms,
searchTimeout: timeout,
responseFilter: (response) =>
{
return response.UploadSpeed > 0 && nec.BannedUsersSatisfies(response);
}
//fileFilter: (file) => {
// return FileConditions.StrictString(GetDirectoryNameSlsk(file.Filename), track.ArtistName, ignoreCase: true)
// && FileConditions.StrictString(GetDirectoryNameSlsk(file.Filename), track.Album, ignoreCase: true);
//}
);
void handler(SlResponse r)
{
if (r.Files.Count > 0)
{
foreach (var file in r.Files)
results.TryAdd(r.Username + "\\" + file.Filename, (r, file));
}
}
var cts = new CancellationTokenSource();
await RunSearches(track, results, getSearchOptions, handler, cts.Token);
string fullPath((SearchResponse r, Soulseek.File f) x) { return x.r.Username + "\\" + x.f.Filename; }
var orderedResults = OrderedResults(results, track, false, false, albumMode: true);
if (debugDisableDownload && !debugPrintTracks)
{
foreach (var (response, file) in orderedResults)
{
Console.WriteLine(DisplayString(track, file, response,
(printResultsFull ? necessaryCond : null), (printResultsFull ? preferredCond : null), printResultsFull, infoFirst: true));
}
WriteLine($"Total: {orderedResults.Count()}\n", ConsoleColor.Yellow);
return default;
}
var groupedLists = orderedResults.GroupBy(x => fullPath(x).Substring(0, fullPath(x).LastIndexOf('\\')));
var musicFolders = new List<(string Key, List<(SlResponse response, SlFile file)>)>();
var nonMusicFolders = new List<IGrouping<string, (SlResponse response, SlFile file)>>();
foreach (var group in groupedLists)
{
if (group.Any(x => Utils.IsMusicFile(x.file.Filename)))
musicFolders.Add((group.Key, group.ToList()));
else
nonMusicFolders.Add(group);
}
var discPattern = new Regex(@"^(?i)(dis[c|k]|cd)\s*\d{1,2}$");
if (!discPattern.IsMatch(track.Album))
{
for (int i = 0; i < musicFolders.Count; i++)
{
var (folderKey, files) = musicFolders[i];
var parentFolderName = GetFileNameSlsk(folderKey);
if (discPattern.IsMatch(parentFolderName))
{
var parentFolderKey = GetDirectoryNameSlsk(folderKey);
var parentFolderItem = musicFolders.FirstOrDefault(x => x.Key == parentFolderKey);
if (parentFolderItem != default)
{
parentFolderItem.Item2.AddRange(files);
musicFolders.RemoveAt(i);
i--;
}
else
musicFolders[i] = (parentFolderKey, files);
}
}
}
foreach (var nonMusicFolder in nonMusicFolders)
{
foreach (var musicFolder in musicFolders)
{
string x = nonMusicFolder.Key.TrimEnd('\\') + '\\';
if (x.StartsWith(musicFolder.Key.TrimEnd('\\') + '\\'))
{
musicFolder.Item2.AddRange(nonMusicFolder);
break;
}
}
}
foreach (var (_, files) in musicFolders)
files.Sort((x, y) => x.file.Filename.CompareTo(y.file.Filename));
var fileCounts = musicFolders.Select(x =>
x.Item2.Count(x => Utils.IsMusicFile(x.file.Filename))
).ToList();
bool countIsGood(int count, int min, int max) => count >= min && (max == -1 || count <= max);
var result = musicFolders
.Where(x => countIsGood(x.Item2.Count(rf => Utils.IsMusicFile(rf.file.Filename)), minAlbumTrackCount, maxAlbumTrackCount))
.Select(ls => ls.Item2.Select(x => {
var t = new Track
{
Artist = track.Artist,
Album = track.Album,
IsNotAudio = !Utils.IsMusicFile(x.file.Filename),
Downloads = new ConcurrentDictionary<string, (SlResponse, SlFile file)>(
new Dictionary<string, (SlResponse response, SlFile file)> { { x.response.Username + "\\" + x.file.Filename, x } })
};
return skipExisting ? InferTrack(x.file.Filename, t) : t;
})
.OrderBy(t => t.IsNotAudio)
.ThenBy(t => t.Downloads.First().Value.Item2.Filename)
.ToList()).Where(ls => ls.Count > 0).ToList();
if (result.Count == 0)
result.Add(new List<Track>());
return result;
}
static async Task<List<Track>> GetUniqueRelatedTracks(Track track)
{
var results = new ConcurrentDictionary<string, (SearchResponse, Soulseek.File)>();
SearchOptions getSearchOptions(int timeout, FileConditions nec, FileConditions prf) =>
new SearchOptions(
minimumResponseFileCount: 1,
minimumPeerUploadSpeed: 1,
removeSingleCharacterSearchTerms: removeSingleCharacterSearchTerms,
searchTimeout: timeout,
responseFilter: (response) =>
{
return response.UploadSpeed > 0 && nec.BannedUsersSatisfies(response);
},
fileFilter: (file) =>
{
return Utils.IsMusicFile(file.Filename) && nec.FileSatisfies(file, track, null);
//&& FileConditions.StrictString(file.Filename, track.ArtistName, ignoreCase: true)
//&& FileConditions.StrictString(file.Filename, track.TrackTitle, ignoreCase: true)
//&& FileConditions.StrictString(file.Filename, track.Album, ignoreCase: true);
}
);
void handler(SlResponse r)
{
if (r.Files.Count > 0)
{
foreach (var file in r.Files)
results.TryAdd(r.Username + "\\" + file.Filename, (r, file));
}
}
var cts = new CancellationTokenSource();
await RunSearches(track, results, getSearchOptions, handler, cts.Token);
string artistName = track.Artist.Trim();
string trackName = track.Title.Trim();
string albumName = track.Album.Trim();
var fileResponses = results.Select(x => x.Value);
var equivalentFiles = EquivalentFiles(track, fileResponses).ToList();
if (!relax)
{
equivalentFiles = equivalentFiles
.Where(x => FileConditions.StrictString(x.Item1.Title, track.Title, ignoreCase: true)
&& (FileConditions.StrictString(x.Item1.Artist, track.Artist, ignoreCase: true, boundarySkipWs: false)
|| FileConditions.StrictString(x.Item1.Title, track.Artist, ignoreCase: true, boundarySkipWs: false)
&& x.Item1.Title.ContainsInBrackets(track.Artist, ignoreCase: true)))
.ToList();
}
var tracks = equivalentFiles
.Select(kvp => {
kvp.Item1.Downloads = new ConcurrentDictionary<string, (SearchResponse, Soulseek.File)>(
kvp.Item2.ToDictionary(item => { return item.response.Username + "\\" + item.file.Filename; }, item => item));
return kvp.Item1;
}).ToList();
return tracks;
}
static IOrderedEnumerable<(Track, IEnumerable<(SlResponse response, SlFile file)>)> EquivalentFiles(Track track,
IEnumerable<(SlResponse, SlFile)> fileResponses, int minShares=-1)
{
if (minShares == -1)
minShares = minUsersAggregate;
Track inferTrack((SearchResponse r, Soulseek.File f) x)
{
Track t = track;
t.Length = x.f.Length ?? -1;
return InferTrack(x.f.Filename, t);
}
var res = fileResponses
.GroupBy(inferTrack, new TrackStringComparer(ignoreCase: true))
.Where(group => group.Select(x => x.Item1.Username).Distinct().Count() >= minShares)
.SelectMany(group => {
var sortedTracks = group.OrderBy(t => t.Item2.Length).Where(x => x.Item2.Length != null).ToList();
var groups = new List<(Track, List<(SearchResponse, Soulseek.File)>)>();
var noLengthGroup = group.Where(x => x.Item2.Length == null);
for (int i = 0; i < sortedTracks.Count;)
{
var subGroup = new List<(SearchResponse, Soulseek.File)> { sortedTracks[i] };
int j = i + 1;
while (j < sortedTracks.Count)
{
int l1 = (int)sortedTracks[j].Item2.Length;
int l2 = (int)sortedTracks[i].Item2.Length;
if (Math.Abs(l1 - l2) <= necessaryCond.LengthTolerance)
{
subGroup.Add(sortedTracks[j]);
j++;
}
else break;
}
Track t = group.Key;
t.Length = (int)sortedTracks[i].Item2.Length;
groups.Add((t, subGroup));
i = j;
}
if (noLengthGroup.Any())
{
if (groups.Count > 0 && !preferredCond.AcceptNoLength)
groups.First().Item2.AddRange(noLengthGroup);
else
groups.Add((group.Key, noLengthGroup.ToList()));
}
return groups.Where(subGroup => subGroup.Item2.Select(x => x.Item1.Username).Distinct().Count() >= minShares)
.Select(subGroup => (subGroup.Item1, subGroup.Item2.AsEnumerable()));
}).OrderByDescending(x => x.Item2.Count());
return res;
}
static IOrderedEnumerable<(SlResponse response, SlFile file)> OrderedResults(IEnumerable<KeyValuePair<string, (SlResponse, SlFile)>> results,
Track track, bool useInfer=false, bool useLevenshtein=true, bool albumMode=false)
{
bool useBracketCheck = true;
if (albumMode)
{
useBracketCheck = false;
useLevenshtein = false;
useInfer = false;
}
Dictionary<string, (Track, int)>? result = null;
if (useInfer)
{
var equivalentFiles = EquivalentFiles(track, results.Select(x => x.Value), 1);
result = 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));
}
(Track, int) infTrack((SearchResponse response, Soulseek.File file) x)
{
string key = $"{x.response.Username}\\{x.file.Filename}";
if (result != null && result.ContainsKey(key))
return result[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();
return Utils.Levenshtein(t1, t2);
}
var random = new Random();
return results.Select(kvp => (response: kvp.Value.Item1, file: kvp.Value.Item2))
.Where(x => userSuccessCount.GetValueOrDefault(x.response.Username, 0) > ignoreOn)
.OrderByDescending(x => userSuccessCount.GetValueOrDefault(x.response.Username, 0) > deprioritizeOn)
.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 => preferredCond.StrictTitleSatisfies(x.file.Filename, track.Title))
.ThenByDescending(x => preferredCond.LengthToleranceSatisfies(x.file, track.Length))
.ThenByDescending(x => preferredCond.FormatSatisfies(x.file.Filename))
.ThenByDescending(x => preferredCond.StrictAlbumSatisfies(x.file.Filename, track.Album))
.ThenByDescending(x => preferredCond.BitrateSatisfies(x.file))
.ThenByDescending(x => preferredCond.FileSatisfies(x.file, track, x.response))
.ThenByDescending(x => x.response.HasFreeUploadSlot)
.ThenByDescending(x => x.response.UploadSpeed / 1024 / 650)
.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 => random.Next());
}
static async Task RunSearches(Track track, SlDictionary results, Func<int, FileConditions, FileConditions, SearchOptions> getSearchOptions,
Action<SearchResponse> responseHandler, CancellationToken ct, Action? onSearch = null)
{
bool artist = track.Artist != "";
bool title = track.Title != "";
bool album = track.Album != "";
string search = GetSearchString(track);
var searchTasks = new List<Task>();
var defaultSearchOpts = getSearchOptions(searchTimeout, necessaryCond, preferredCond);
searchTasks.Add(Search(search, defaultSearchOpts, responseHandler, ct, onSearch));
if (search.RemoveDiacriticsIfExist(out string noDiacrSearch) && !track.ArtistMaybeWrong)
searchTasks.Add(Search(noDiacrSearch, defaultSearchOpts, responseHandler, ct, onSearch));
await Task.WhenAll(searchTasks);
if (results.IsEmpty && track.ArtistMaybeWrong && title)
{
var cond = new FileConditions(necessaryCond);
var infTrack = InferTrack(track.Title, new Track());
cond.StrictTitle = infTrack.Title == track.Title;
cond.StrictArtist = false;
var opts = getSearchOptions(Math.Min(searchTimeout, 5000), cond, preferredCond);
searchTasks.Add(Search($"{infTrack.Artist} {infTrack.Title}", opts, responseHandler, ct, onSearch));
}
if (desperateSearch)
{
await Task.WhenAll(searchTasks);
if (results.IsEmpty && !track.ArtistMaybeWrong)
{
if (artist && album && title)
{
var cond = new FileConditions(necessaryCond)
{
StrictTitle = true,
StrictAlbum = true
};
var opts = getSearchOptions(Math.Min(searchTimeout, 5000), cond, preferredCond);
searchTasks.Add(Search($"{track.Artist} {track.Album}", opts, responseHandler, ct, onSearch));
}
if (artist && title && track.Length != -1 && necessaryCond.LengthTolerance != -1)
{
var cond = new FileConditions(necessaryCond)
{
LengthTolerance = -1,
StrictTitle = true,
StrictArtist = true
};
var opts = getSearchOptions(Math.Min(searchTimeout, 5000), cond, preferredCond);
searchTasks.Add(Search($"{track.Artist} {track.Title}", opts, responseHandler, ct, onSearch));
}
}
await Task.WhenAll(searchTasks);
if (results.IsEmpty)
{
var track2 = track.ArtistMaybeWrong ? InferTrack(track.Title, new Track()) : track;
if (track.Album.Length > 3 && album)
{
var cond = new FileConditions(necessaryCond)
{
StrictAlbum = true,
StrictTitle = !track.ArtistMaybeWrong,
StrictArtist = !track.ArtistMaybeWrong,
LengthTolerance = -1
};
var opts = getSearchOptions(Math.Min(searchTimeout, 5000), cond, preferredCond);
searchTasks.Add(Search($"{track.Album}", opts, responseHandler, ct, onSearch));
}
if (track2.Title.Length > 3 && artist)
{
var cond = new FileConditions(necessaryCond)
{
StrictTitle = !track.ArtistMaybeWrong,
StrictArtist = !track.ArtistMaybeWrong,
LengthTolerance = -1
};
var opts = getSearchOptions(Math.Min(searchTimeout, 5000), cond, preferredCond);
searchTasks.Add(Search($"{track2.Title}", opts, responseHandler, ct, onSearch));
}
if (track2.Artist.Length > 3 && title)
{
var cond = new FileConditions(necessaryCond)
{
StrictTitle = !track.ArtistMaybeWrong,
StrictArtist = !track.ArtistMaybeWrong,
LengthTolerance = -1
};
var opts = getSearchOptions(Math.Min(searchTimeout, 5000), cond, preferredCond);
searchTasks.Add(Search($"{track2.Artist}", opts, responseHandler, ct, onSearch));
}
}
}
await Task.WhenAll(searchTasks);
}
static async Task Search(string search, SearchOptions opts, Action<SearchResponse> rHandler, CancellationToken ct, Action? onSearch = null)
{
await searchSemaphore.WaitAsync();
try
{
search = CleanSearchString(search);
var q = SearchQuery.FromText(search);
var searchTasks = new List<Task>();
onSearch?.Invoke();
await client.SearchAsync(q, options: opts, cancellationToken: ct, responseHandler: rHandler);
}
catch (OperationCanceledException) { }
}
public static string GetSearchString(Track track)
{
if (track.Title != "")
return (track.Artist + " " + track.Title).Trim();
else if (track.Album != "")
return (track.Artist + " " + track.Album).Trim();
return track.Artist.Trim();
}
public static string CleanSearchString(string str)
{
string old;
if (regexPatternToReplace != "")
{
old = str;
str = Regex.Replace(str, regexPatternToReplace, regexReplacePattern).Trim();
if (str == "") str = old;
}
if (!noRemoveSpecialChars)
{
old = str;
str = str.ReplaceSpecialChars(" ").RemoveConsecutiveWs().Trim();
if (str == "") str = old;
}
foreach (var banned in bannedTerms)
{
string b1 = banned;
string b2 = banned.Replace(" ", "-");
string b3 = banned.Replace(" ", "_");
string b4 = banned.Replace(" ", "");
foreach (var s in new string[] { b1, b2, b3, b4 })
str = str.Replace(s, string.Concat("*", s.AsSpan(1)), StringComparison.OrdinalIgnoreCase);
}
return str.Trim();
}
public static Track InferTrack(string filename, Track defaultTrack)
{
Track t = new Track(defaultTrack);
filename = GetFileNameWithoutExtSlsk(filename).Replace(" — ", " - ").Replace("_", " ").RemoveConsecutiveWs().Trim();
var trackNumStart = new Regex(@"^(?:(?:[0-9][-\.])?\d{2,3}[. -]|\b\d\.\s|\b\d\s-\s)(?=.+\S)");
//var trackNumMiddle = new Regex(@"\s+-\s+(\d{2,3})(?: -|\.|)\s+|\s+-(\d{2,3})-\s+");
var trackNumMiddle = new Regex(@"(?<= - )((\d-)?\d{2,3}|\d{2,3}\.?)\s+");
var trackNumMiddleAlt = new Regex(@"\s+-(\d{2,3})-\s+");
if (trackNumStart.IsMatch(filename))
{
filename = trackNumStart.Replace(filename, "", 1).Trim();
if (filename.StartsWith("- "))
filename = filename.Substring(2).Trim();
}
else
{
var reg = trackNumMiddle.IsMatch(filename) ? trackNumMiddle : (trackNumMiddleAlt.IsMatch(filename) ? trackNumMiddleAlt : null);
if (reg != null && !reg.IsMatch(defaultTrack.ToString(noInfo: true)))
{
filename = reg.Replace(filename, "<<tracknum>>", 1).Trim();
filename = Regex.Replace(filename, @"-\s*<<tracknum>>\s*-", "-");
filename = filename.Replace("<<tracknum>>", "");
}
}
string aname = t.Artist.Trim();
string tname = t.Title.Trim();
string alname = t.Album.Trim();
string fname = filename;
fname = fname.Replace("—", "-").Replace("_", " ").Replace('[', '(').Replace(']', ')').ReplaceInvalidChars("", true).RemoveConsecutiveWs().Trim();
tname = tname.Replace("—", "-").Replace("_", " ").Replace('[', '(').Replace(']', ')').ReplaceInvalidChars("", true).RemoveFt().RemoveConsecutiveWs().Trim();
aname = aname.Replace("—", "-").Replace("_", " ").Replace('[', '(').Replace(']', ')').ReplaceInvalidChars("", true).RemoveFt().RemoveConsecutiveWs().Trim();
alname = alname.Replace("—", "-").Replace("_", " ").Replace('[', '(').Replace(']', ')').ReplaceInvalidChars("", true).RemoveFt().RemoveConsecutiveWs().Trim();
bool maybeRemix = aname != "" && Regex.IsMatch(fname, @$"\({Regex.Escape(aname)} .+\)", RegexOptions.IgnoreCase);
string[] parts = fname.Split(new string[] { " - " }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
string[] realParts = filename.Split(new string[] { " - " }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length != realParts.Length)
realParts = parts;
if (parts.Length == 1)
{
if (maybeRemix)
t.ArtistMaybeWrong = true;
t.Title = parts[0];
}
else if (parts.Length == 2)
{
t.Artist = realParts[0];
t.Title = realParts[1];
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)
{
bool hasTitle = tname != "" && parts[2].ContainsIgnoreCase(tname);
if (hasTitle)
t.Title = realParts[2];
int artistPos = -1;
if (aname != "")
{
if (parts[0].ContainsIgnoreCase(aname))
artistPos = 0;
else if (parts[1].ContainsIgnoreCase(aname))
artistPos = 1;
else
t.ArtistMaybeWrong = true;
}
int albumPos = -1;
if (alname != "")
{
if (parts[0].ContainsIgnoreCase(alname))
albumPos = 0;
else if (parts[1].ContainsIgnoreCase(alname))
albumPos = 1;
}
if (artistPos >= 0 && artistPos == albumPos)
{
artistPos = 0;
albumPos = 1;
}
if (artistPos == -1 && maybeRemix)
{
t.ArtistMaybeWrong = true;
artistPos = 0;
albumPos = 1;
}
if (artistPos == -1 && albumPos == -1)
{
t.ArtistMaybeWrong = true;
t.Artist = realParts[0] + " - " + realParts[1];
}
else if (artistPos >= 0)
{
t.Artist = parts[artistPos];
}
t.Title = parts[2];
}
if (t.Title == "")
{
t.Title = fname;
t.ArtistMaybeWrong = true;
}
t.Title = t.Title.RemoveFt();
t.Artist = t.Artist.RemoveFt();
return t;
}
static async Task DownloadFile(SearchResponse response, Soulseek.File file, string filePath, Track track, ProgressBar progress, CancellationTokenSource? searchCts=null)
{
if (debugDisableDownload)
throw new Exception();
await WaitForLogin();
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(filePath));
string origPath = filePath;
filePath += ".incomplete";
bool transferSet = false;
var transferOptions = new TransferOptions(
stateChanged: (state) =>
{
if (downloads.ContainsKey(file.Filename) && !transferSet)
downloads[file.Filename].transfer = state.Transfer;
},
progressUpdated: (progress) =>
{
if (downloads.ContainsKey(file.Filename))
downloads[file.Filename].bytesTransferred = progress.PreviousBytesTransferred;
}
);
try
{
using var cts = new CancellationTokenSource();
using var outputStream = new FileStream(filePath, FileMode.Create);
downloads.TryAdd(file.Filename, new DownloadWrapper(origPath, response, file, track, cts, progress));
await client.DownloadAsync(response.Username, file.Filename, () => Task.FromResult((Stream)outputStream), file.Size, options: transferOptions, cancellationToken: cts.Token);
}
catch
{
if (System.IO.File.Exists(filePath))
try { System.IO.File.Delete(filePath); } catch { }
if (downloads.ContainsKey(file.Filename))
downloads[file.Filename].UpdateText();
downloads.TryRemove(file.Filename, out _);
throw;
}
try { searchCts?.Cancel(); }
catch { }
try { System.IO.File.Move(filePath, origPath, true); }
catch (IOException) { WriteLine($"Failed to rename .incomplete file", ConsoleColor.DarkYellow, true); }
downloads[file.Filename].success = true;
downloads[file.Filename].UpdateText();
downloads.TryRemove(file.Filename, out _);
}
static async Task Update()
{
while (true)
{
if (!skipUpdate)
{
try
{
if (client.State.HasFlag(SoulseekClientStates.LoggedIn))
{
foreach (var (key, val) in searches) // shouldn't this give "collection was modified" errors? whatever..
{
if (val == null)
searches.TryRemove(key, out _);
}
foreach (var (key, val) in downloads)
{
if (val != null)
{
val.UpdateText();
if ((DateTime.Now - val.UpdateLastChangeTime()).TotalMilliseconds > downloadMaxStaleTime)
{
val.stalled = true;
val.UpdateText();
try { val.cts.Cancel(); } catch { }
downloads.TryRemove(key, out _);
}
}
else
{
downloads.TryRemove(key, out _);
}
}
}
else if (!client.State.HasFlag(SoulseekClientStates.LoggedIn | SoulseekClientStates.LoggingIn | SoulseekClientStates.Connecting))
{
WriteLine($"\nDisconnected, logging in\n", ConsoleColor.DarkYellow, true);
try { await Login(useRandomLogin); }
catch (Exception ex)
{
string banMsg = useRandomLogin ? "" : " (possibly a 30-minute ban caused by frequent searches)";
WriteLine($"{ex.Message}{banMsg}", ConsoleColor.DarkYellow, true);
}
}
}
catch (Exception ex)
{
WriteLine($"\n{ex.Message}\n", ConsoleColor.DarkYellow, true);
}
}
await Task.Delay(updateDelay);
}
}
static void OnComplete(string onComplete, Track track)
{
if (onComplete == "")
return;
else if (onComplete.Length > 2 && onComplete[0].IsDigit() && onComplete[1] == ':')
{
if ((int)track.TrackState != int.Parse(onComplete[0].ToString()))
return;
onComplete = onComplete.Substring(2);
}
Process process = new Process();
ProcessStartInfo startInfo = new ProcessStartInfo();
onComplete = onComplete.Replace("{title}", track.Title)
.Replace("{artist}", track.Artist)
.Replace("{album}", track.Album)
.Replace("{uri}", track.URI)
.Replace("{length}", track.Length.ToString())
.Replace("{artist-maybe-wrong}", track.ArtistMaybeWrong.ToString())
.Replace("{is-album}", track.IsAlbum.ToString())
.Replace("{is-not-audio}", track.IsNotAudio.ToString())
.Replace("{failure-reason}", track.FailureReason)
.Replace("{path}", track.DownloadPath)
.Replace("{state}", track.TrackState.ToString())
.Trim();
if (onComplete[0] == '"')
{
int e = onComplete.IndexOf('"', 1);
if (e > 1)
{
startInfo.FileName = onComplete.Substring(1, e - 1);
startInfo.Arguments = onComplete.Substring(e + 1, onComplete.Length - e - 1);
}
else
{
startInfo.FileName = onComplete.Trim('"');
}
}
else
{
string[] parts = onComplete.Split(' ', 2);
startInfo.FileName = parts[0];
startInfo.Arguments = parts.Length > 1 ? parts[1] : "";
}
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.UseShellExecute = false;
process.StartInfo = startInfo;
process.Start();
}
class DownloadWrapper
{
public string savePath;
public string displayText = "";
public int downloadRotatingBarState = 0;
public Soulseek.File file;
public Transfer? transfer;
public SearchResponse response;
public ProgressBar progress;
public Track track;
public long bytesTransferred = 0;
public bool stalled = false;
public bool queued = false;
public bool success = false;
public CancellationTokenSource cts;
public DateTime startTime = DateTime.Now;
public DateTime lastChangeTime = DateTime.Now;
private TransferStates? prevTransferState = null;
private long prevBytesTransferred = 0;
private bool updatedTextDownload = false;
private bool updatedTextSuccess = false;
public DownloadWrapper(string savePath, SearchResponse response, Soulseek.File file, Track track, CancellationTokenSource cts, ProgressBar progress)
{
this.savePath = savePath;
this.response = response;
this.file = file;
this.cts = cts;
this.track = track;
this.progress = progress;
this.displayText = DisplayString(track, file, response);
RefreshOrPrint(progress, 0, "Initialize: " + displayText, true);
RefreshOrPrint(progress, 0, displayText, false);
}
public void UpdateText()
{
char[] bars = { '/', '|', '\\', '―' };
downloadRotatingBarState++;
downloadRotatingBarState %= bars.Length;
string bar = success ? "" : bars[downloadRotatingBarState] + " ";
float? percentage = bytesTransferred / (float)file.Size;
queued = transfer?.State.ToString().Contains("Queued") ?? false;
string state = "NullState";
bool downloading = false;
if (stalled)
{
state = "Stalled";
bar = "";
}
else if (transfer != null)
{
state = transfer.State.ToString();
if (queued)
state = "Queued";
else if (state.Contains("Completed, "))
state = state.Replace("Completed, ", "");
else if (state.Contains("Initializing"))
state = "Initialize";
}
if (state == "Succeeded")
success = true;
if (state == "InProgress")
downloading = true;
string txt = $"{bar}{state}:".PadRight(14, ' ');
bool needSimplePrintUpdate = (downloading && !updatedTextDownload) || (success && !updatedTextSuccess);
updatedTextDownload |= downloading;
updatedTextSuccess |= success;
Console.ResetColor();
RefreshOrPrint(progress, (int)((percentage ?? 0) * 100), $"{txt} {displayText}", needSimplePrintUpdate, needSimplePrintUpdate);
}
public DateTime UpdateLastChangeTime(bool propagate=true, bool forceChanged=false)
{
bool changed = prevTransferState != transfer?.State || prevBytesTransferred != bytesTransferred;
if (changed || forceChanged)
{
lastChangeTime= DateTime.Now;
stalled = false;
if (propagate)
{
foreach (var (_, dl) in downloads)
{
if (dl != this && dl.response.Username == response.Username)
dl.UpdateLastChangeTime(propagate: false, forceChanged: true);
}
}
}
prevTransferState = transfer?.State;
prevBytesTransferred = bytesTransferred;
return lastChangeTime;
}
}
class SearchInfo
{
public ConcurrentDictionary<string, (SearchResponse, Soulseek.File)> results;
public ProgressBar progress;
public SearchInfo(ConcurrentDictionary<string, (SearchResponse, Soulseek.File)> results, ProgressBar progress)
{
this.results = results;
this.progress = progress;
}
}
class FileConditions
{
public int LengthTolerance = -1;
public int MinBitrate = -1;
public int MaxBitrate = -1;
public int MinSampleRate = -1;
public int MaxSampleRate = -1;
public int MinBitDepth = -1;
public int MaxBitDepth = -1;
public bool StrictTitle = false;
public bool StrictArtist = false;
public bool StrictAlbum = false;
public string[] DangerWords = Array.Empty<string>();
public string[] Formats = Array.Empty<string>();
public string[] BannedUsers = Array.Empty<string>();
public string StrictStringRegexRemove = "";
public bool StrictStringDiacrRemove = true;
public bool AcceptNoLength = true;
public bool AcceptMissingProps = true;
public FileConditions() { }
public FileConditions(FileConditions other)
{
LengthTolerance = other.LengthTolerance;
MinBitrate = other.MinBitrate;
MaxBitrate = other.MaxBitrate;
MinSampleRate = other.MinSampleRate;
MaxSampleRate = other.MaxSampleRate;
AcceptNoLength = other.AcceptNoLength;
StrictArtist = other.StrictArtist;
StrictTitle = other.StrictTitle;
MinBitDepth = other.MinBitDepth;
MaxBitDepth = other.MaxBitDepth;
Formats = other.Formats.ToArray();
DangerWords = other.DangerWords.ToArray();
BannedUsers = other.BannedUsers.ToArray();
}
public void UnsetClientSpecificFields()
{
MinBitrate = -1;
MaxBitrate = -1;
MinSampleRate = -1;
MaxSampleRate = -1;
MinBitDepth = -1;
MaxBitDepth = -1;
}
public bool FileSatisfies(Soulseek.File file, Track track, SearchResponse? response)
{
return DangerWordSatisfies(file.Filename, track.Title, track.Artist) && FormatSatisfies(file.Filename)
&& LengthToleranceSatisfies(file, track.Length) && BitrateSatisfies(file) && SampleRateSatisfies(file)
&& StrictTitleSatisfies(file.Filename, track.Title) && StrictArtistSatisfies(file.Filename, track.Artist)
&& StrictAlbumSatisfies(file.Filename, track.Album) && BannedUsersSatisfies(response) && BitDepthSatisfies(file);
}
public bool FileSatisfies(TagLib.File file, Track track)
{
return DangerWordSatisfies(file.Name, track.Title, track.Artist) && FormatSatisfies(file.Name)
&& LengthToleranceSatisfies(file, track.Length) && BitrateSatisfies(file) && SampleRateSatisfies(file)
&& StrictTitleSatisfies(file.Name, track.Title) && StrictArtistSatisfies(file.Name, track.Artist)
&& StrictAlbumSatisfies(file.Name, track.Album) && BitDepthSatisfies(file);
}
public bool DangerWordSatisfies(string fname, string tname, string aname)
{
if (tname == "")
return true;
fname = GetFileNameWithoutExtSlsk(fname).Replace(" — ", " - ");
tname = tname.Replace(" — ", " - ");
foreach (var word in DangerWords)
{
if (fname.ContainsIgnoreCase(word) ^ tname.ContainsIgnoreCase(word))
{
if (!(fname.Contains(" - ") && fname.ContainsIgnoreCase(word) && aname.ContainsIgnoreCase(word)))
{
if (word == "mix")
return fname.ContainsIgnoreCase("original mix") || tname.ContainsIgnoreCase("original mix");
else
return false;
}
}
}
return true;
}
public bool StrictTitleSatisfies(string fname, string tname, bool noPath = true)
{
if (!StrictTitle || tname == "")
return true;
fname = noPath ? GetFileNameWithoutExtSlsk(fname) : fname;
return StrictString(fname, tname, StrictStringRegexRemove, StrictStringDiacrRemove, ignoreCase: true);
}
public bool StrictArtistSatisfies(string fname, string aname)
{
if (!StrictArtist || aname == "")
return true;
return StrictString(fname, aname, StrictStringRegexRemove, StrictStringDiacrRemove, ignoreCase: true, boundarySkipWs: false);
}
public bool StrictAlbumSatisfies(string fname, string alname)
{
if (!StrictAlbum || alname == "")
return true;
return StrictString(GetDirectoryNameSlsk(fname), alname, StrictStringRegexRemove, StrictStringDiacrRemove, ignoreCase: true);
}
public static bool StrictString(string fname, string tname, string regexRemove = "", bool diacrRemove = true, bool ignoreCase = true, bool boundarySkipWs = true)
{
if (string.IsNullOrEmpty(tname))
return true;
fname = fname.Replace("_", " ").ReplaceInvalidChars(" ", true, false);
fname = regexRemove != "" ? Regex.Replace(fname, regexRemove, "") : fname;
fname = diacrRemove ? fname.RemoveDiacritics() : fname;
fname = fname.Trim();
tname = tname.Replace("_", " ").ReplaceInvalidChars(" ", true, false);
tname = regexRemove != "" ? Regex.Replace(tname, regexRemove, "") : tname;
tname = diacrRemove ? tname.RemoveDiacritics() : tname;
tname = tname.Trim();
if (boundarySkipWs)
return fname.ContainsWithBoundaryIgnoreWs(tname, ignoreCase, acceptLeftDigit: true);
else
return fname.ContainsWithBoundary(tname, ignoreCase);
}
public bool FormatSatisfies(string fname)
{
string ext = Path.GetExtension(fname).Trim('.').ToLower();
return Formats.Length == 0 || (ext != "" && Formats.Any(f => f == ext));
}
public bool LengthToleranceSatisfies(Soulseek.File file, int wantedLength)
{
return LengthToleranceSatisfies(file.Length, wantedLength);
}
public bool LengthToleranceSatisfies(TagLib.File file, int wantedLength)
{
return LengthToleranceSatisfies((int)file.Properties.Duration.TotalSeconds, wantedLength);
}
public bool LengthToleranceSatisfies(int? length, int wantedLength)
{
if (LengthTolerance < 0 || wantedLength < 0)
return true;
if (length == null || length < 0)
return AcceptNoLength && AcceptMissingProps;
return Math.Abs((int)length - wantedLength) <= LengthTolerance;
}
public bool BitrateSatisfies(Soulseek.File file)
{
return BitrateSatisfies(file.BitRate);
}
public bool BitrateSatisfies(TagLib.File file)
{
return BitrateSatisfies(file.Properties.AudioBitrate);
}
public bool BitrateSatisfies(int? bitrate)
{
return BoundCheck(bitrate, MinBitrate, MaxBitrate);
}
public bool SampleRateSatisfies(Soulseek.File file)
{
return SampleRateSatisfies(file.SampleRate);
}
public bool SampleRateSatisfies(TagLib.File file)
{
return SampleRateSatisfies(file.Properties.AudioSampleRate);
}
public bool SampleRateSatisfies(int? sampleRate)
{
return BoundCheck(sampleRate, MinSampleRate, MaxSampleRate);
}
public bool BitDepthSatisfies(Soulseek.File file)
{
return BitDepthSatisfies(file.BitDepth);
}
public bool BitDepthSatisfies(TagLib.File file)
{
return BitDepthSatisfies(file.Properties.BitsPerSample);
}
public bool BitDepthSatisfies(int? bitdepth)
{
return BoundCheck(bitdepth, MinBitDepth, MaxBitDepth);
}
public bool BoundCheck(int? num, int min, int max)
{
if (max < 0 && min < 0)
return true;
if (num == null || num < 0)
return AcceptMissingProps;
if (num < min || max != -1 && num > max)
return false;
return true;
}
public bool BannedUsersSatisfies(SearchResponse? response)
{
return response == null || !BannedUsers.Any(x => x == response.Username);
}
public string GetNotSatisfiedName(Soulseek.File file, Track track, SearchResponse? response)
{
if (!DangerWordSatisfies(file.Filename, track.Title, track.Artist))
return "DangerWord fails";
if (!FormatSatisfies(file.Filename))
return "Format fails";
if (!LengthToleranceSatisfies(file, track.Length))
return "Length fails";
if (!BitrateSatisfies(file))
return "Bitrate fails";
if (!SampleRateSatisfies(file))
return "SampleRate fails";
if (!StrictTitleSatisfies(file.Filename, track.Title))
return "StrictTitle fails";
if (!StrictArtistSatisfies(file.Filename, track.Artist))
return "StrictArtist fails";
if (!BitDepthSatisfies(file))
return "BitDepth fails";
if (!BannedUsersSatisfies(response))
return "BannedUsers fails";
return "Satisfied";
}
public string GetNotSatisfiedName(TagLib.File file, Track track)
{
if (!DangerWordSatisfies(file.Name, track.Title, track.Artist))
return "DangerWord fails";
if (!FormatSatisfies(file.Name))
return "Format fails";
if (!LengthToleranceSatisfies(file, track.Length))
return "Length fails";
if (!BitrateSatisfies(file))
return "Bitrate fails";
if (!SampleRateSatisfies(file))
return "SampleRate fails";
if (!StrictTitleSatisfies(file.Name, track.Title))
return "StrictTitle fails";
if (!StrictArtistSatisfies(file.Name, track.Artist))
return "StrictArtist fails";
if (!BitDepthSatisfies(file))
return "BitDepth fails";
return "Satisfied";
}
}
static async Task<List<Track>> ParseCsvIntoTrackInfo(string path, string artistCol = "", string trackCol = "",
string lengthCol = "", string albumCol = "", string descCol = "", string ytIdCol = "", string timeUnit = "s", bool ytParse = false)
{
var tracks = new List<Track>();
using var sr = new StreamReader(path, System.Text.Encoding.UTF8);
var parser = new SmallestCSV.SmallestCSVParser(sr);
var header = parser.ReadNextRow();
while (header == null || header.Count == 0 || !header.Any(t => t.Trim() != ""))
header = parser.ReadNextRow();
string[] cols = { artistCol, albumCol, trackCol, lengthCol, descCol, ytIdCol };
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" }
};
string usingColumns = "";
for (int i = 0; i < cols.Length; i++)
{
if (string.IsNullOrEmpty(cols[i]))
{
string? res = header.FirstOrDefault(h => Regex.Replace(h, @"\(.*?\)", "").Trim().EqualsAny(aliases[i], StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(res))
{
cols[i] = res;
usingColumns += $"{aliases[i][0]}:\"{res}\", ";
}
}
else
{
if (header.IndexOf(cols[i]) == -1)
throw new Exception($"Column \"{cols[i]}\" not found in CSV file");
usingColumns += $"{aliases[i][0]}:\"{cols[i]}\", ";
}
}
int foundCount = cols.Count(col => col != "");
if (!string.IsNullOrEmpty(usingColumns))
Console.WriteLine($"Using columns: {usingColumns.TrimEnd(' ', ',')}.");
else if (foundCount == 0)
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]);
while (true)
{
var values = parser.ReadNextRow();
if (values == null)
break;
if (!values.Any(t => t.Trim() != ""))
continue;
while (values.Count < foundCount)
values.Add("");
var desc = "";
var track = new Track();
if (artistIndex >= 0) track.Artist = values[artistIndex];
if (trackIndex >= 0) track.Title = values[trackIndex];
if (albumIndex >= 0) track.Album = values[albumIndex];
if (descIndex >= 0) desc = values[descIndex];
if (ytIdIndex >= 0) track.URI = values[ytIdIndex];
if (lengthIndex >= 0)
{
try
{
track.Length = (int)ParseTrackLength(values[lengthIndex], timeUnit);
}
catch
{
WriteLine($"Couldn't parse track length \"{values[lengthIndex]}\" with format \"{timeUnit}\" for \"{track}\"", ConsoleColor.DarkYellow);
}
}
if (ytParse)
track = await YouTube.ParseTrackInfo(track.Title, track.Artist, track.URI, track.Length, desc);
if (track.Title != "" || track.Artist != "" || track.Album != "")
tracks.Add(track);
}
if (ytParse)
YouTube.StopService();
return tracks;
}
static string GetSavePath(string sourceFname)
{
return $"{GetSavePathNoExt(sourceFname)}{Path.GetExtension(sourceFname)}";
}
static string GetSavePathNoExt(string sourceFname)
{
string outTo = outputFolder;
if (albumCommonPath != "")
{
string add = sourceFname.Replace(albumCommonPath, "").Replace(GetFileNameSlsk(sourceFname),"").Trim('\\').Trim();
if (add!="") outTo = Path.Join(outputFolder, add.Replace('\\', Path.DirectorySeparatorChar));
}
return Path.Combine(outTo, $"{GetSaveName(sourceFname)}");
}
static string GetSaveName(string sourceFname)
{
string name = GetFileNameWithoutExtSlsk(sourceFname);
return ReplaceInvalidChars(name, " ");
}
static string GetAsPathSlsk(string fname)
{
return fname.Replace('\\', Path.DirectorySeparatorChar);
}
public static string GetFileNameSlsk(string fname)
{
fname = fname.Replace('\\', Path.DirectorySeparatorChar);
return Path.GetFileName(fname);
}
static string GetFileNameWithoutExtSlsk(string fname)
{
fname = fname.Replace('\\', Path.DirectorySeparatorChar);
return Path.GetFileNameWithoutExtension(fname);
}
static string GetExtensionSlsk(string fname)
{
fname = fname.Replace('\\', Path.DirectorySeparatorChar);
return Path.GetExtension(fname).TrimStart('.');
}
static string GetDirectoryNameSlsk(string fname)
{
fname = fname.Replace('\\', Path.DirectorySeparatorChar);
return Path.GetDirectoryName(fname);
}
static void ApplyNamingFormatsNonAudio(List<List<Track>> list)
{
if (!nameFormat.Replace("\\", "/").Contains('/'))
return;
var downloadedTracks = list.SelectMany(x => x)
.Where(x => x.DownloadPath != "" && !x.IsNotAudio)
.Select(x => x.DownloadPath).Distinct().ToList();
if (downloadedTracks.Count == 0)
return;
for (int i = 0; i < list.Count; i++)
{
for (int j = 0; j < list[i].Count; j++)
{
var track = list[i][j];
if (!track.IsNotAudio || track.TrackState != Track.State.Downloaded)
continue;
string filepath = track.DownloadPath;
string add = Path.GetRelativePath(outputFolder, Path.GetDirectoryName(filepath));
string newFilePath = Path.Join(Utils.GreatestCommonPath(downloadedTracks), add, Path.GetFileName(filepath));
if (filepath != newFilePath)
{
Directory.CreateDirectory(Path.GetDirectoryName(newFilePath));
Utils.Move(filepath, newFilePath);
if (add != "" && add != "." && Utils.GetRecursiveFileCount(Path.Join(outputFolder, add)) == 0)
Directory.Delete(Path.Join(outputFolder, add), true);
list[i][j] = new Track(track) { DownloadPath = newFilePath };
}
}
}
}
static string ApplyNamingFormat(string filepath)
{
if (nameFormat == "" || !Utils.IsMusicFile(filepath))
return filepath;
string add = Path.GetRelativePath(outputFolder, Path.GetDirectoryName(filepath));
string newFilePath = NamingFormat(filepath, nameFormat);
if (filepath != newFilePath)
{
Directory.CreateDirectory(Path.GetDirectoryName(newFilePath));
Utils.Move(filepath, newFilePath);
if (add != "" && add != "." && Utils.GetRecursiveFileCount(Path.Join(outputFolder, add)) == 0)
Directory.Delete(Path.Join(outputFolder, add), true);
}
return newFilePath;
}
static string NamingFormat(string filepath, string format)
{
string newName = format;
TagLib.File? file = null;
try { file = TagLib.File.Create(filepath); }
catch { return filepath; }
Regex regex = new Regex(@"(\{(?:\{??[^\{]*?\}))");
MatchCollection matches = regex.Matches(newName);
while (matches.Count > 0)
{
foreach (Match match in matches.Cast<Match>())
{
string inner = match.Groups[1].Value.Trim('{').Trim('}');
var options = inner.Split('|');
string chosenOpt = "";
foreach (var opt in options)
{
string[] parts = Regex.Split(opt, @"\([^\)]*\)");
string[] result = parts.Where(part => !string.IsNullOrWhiteSpace(part)).ToArray();
if (result.All(x => GetTagValue(file, x) != "")) {
chosenOpt = opt;
break;
}
}
chosenOpt = Regex.Replace(chosenOpt, @"\([^()]*\)|[^()]+", match =>
{
if (match.Value.StartsWith("(") && match.Value.EndsWith(")"))
return match.Value.Substring(1, match.Value.Length-2);
else
return GetTagValue(file, match.Value);
});
string old = match.Groups[1].Value;
old = old.StartsWith("{{") ? old.Substring(1) : old;
newName = newName.Replace(old, chosenOpt);
}
matches = regex.Matches(newName);
}
if (newName != format)
{
string directory = Path.GetDirectoryName(filepath);
string dirsep = Path.DirectorySeparatorChar.ToString();
string extension = Path.GetExtension(filepath);
newName = newName.Replace(new string[] { "/", "\\" }, dirsep);
var x = newName.Split(dirsep, StringSplitOptions.RemoveEmptyEntries);
newName = string.Join(dirsep, x.Select(x => ReplaceInvalidChars(x, " ")));
string newFilePath = Path.Combine(directory, newName + extension);
return newFilePath;
}
return filepath;
}
static string GetTagValue(TagLib.File file, string tag)
{
switch (tag)
{
case "artist":
return (file.Tag.FirstPerformer ?? "").RemoveFt();
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();
case "title":
return file.Tag.Title ?? "";
case "album":
return file.Tag.Album ?? "";
case "year":
return file.Tag.Year.ToString() ?? "";
case "track":
return file.Tag.Track.ToString("D2") ?? "";
case "disc":
return file.Tag.Disc.ToString() ?? "";
case "filename":
return Path.GetFileNameWithoutExtension(file.Name);
case "default_foldername":
return defaultFolderName;
default:
return "";
}
}
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;
string searchName2 = "";
if (searchName.Length <= 3) {
searchName2 = track.Artist.Replace(ignore, "").ToLower();
searchName2 = searchName2.ReplaceInvalidChars("").RemoveFt().RemoveSquareBrackets();
searchName2 = searchName2 == "" ? track.Artist : searchName2;
}
string fullpath = filename;
filename = Path.GetFileNameWithoutExtension(filename);
filename = filename.ReplaceInvalidChars("");
filename = filename.Replace(ignore, "").ToLower();
if (filename.Contains(searchName) && FileConditions.StrictString(fullpath, searchName2, ignoreCase:true, boundarySkipWs:true))
{
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 != "")
{
if (filename.Contains(searchName))
return true;
}
}
return false;
}
static bool TrackExistsInCollection(Track track, FileConditions conditions, IEnumerable<string> collection, out string? foundPath, bool precise)
{
var matchingFiles = collection.Where(fileName => TrackMatchesFilename(track, fileName)).ToArray();
if (!precise && matchingFiles.Any())
{
foundPath = matchingFiles.First();
return true;
}
foreach (var p in matchingFiles)
{
TagLib.File f;
try { f = TagLib.File.Create(p); }
catch { continue; }
if (conditions.FileSatisfies(f, track))
{
foundPath = p;
return true;
}
}
foundPath = null;
return false;
}
static bool TrackExistsInCollection(Track track, FileConditions conditions, IEnumerable<TagLib.File> collection, out string? foundPath, bool precise)
{
string artist = track.Artist.ToLower().Replace(" ", "").RemoveFt();
string title = track.Title.ToLower().Replace(" ", "").RemoveFt().RemoveSquareBrackets();
foreach (var f in collection)
{
foundPath = f.Name;
if (precise && !conditions.FileSatisfies(f, track))
continue;
if (string.IsNullOrEmpty(f.Tag.Title) || string.IsNullOrEmpty(f.Tag.FirstPerformer))
{
if (TrackMatchesFilename(track, f.Name))
return true;
continue;
}
string fileArtist = f.Tag.FirstPerformer.ToLower().Replace(" ", "").RemoveFt();
string fileTitle = f.Tag.Title.ToLower().Replace(" ", "").RemoveFt().RemoveSquareBrackets();
bool durCheck = conditions.LengthToleranceSatisfies(f, track.Length);
bool check1 = (artist.Contains(fileArtist) || (track.ArtistMaybeWrong && title.Contains(fileArtist)));
bool check2 = !precise && fileTitle.Length >= 6 && durCheck;
if ((check1 || check2) && (precise || conditions.DangerWordSatisfies(fileTitle, title, artist)))
{
if (title.Contains(fileTitle))
return true;
}
}
foundPath = null;
return false;
}
static Dictionary<Track, string> SkipExisting(List<Track> tracks, string dir, FileConditions necessaryCond, bool useTags, bool precise, bool useCache)
{
var existing = new Dictionary<Track, string>();
List<string> musicFiles;
List<TagLib.File> musicIndex;
if (useCache && MusicCache.TryGetValue(dir, out var cached))
{
musicFiles = cached.musicFiles;
musicIndex = cached.musicIndex;
}
else
{
var files = System.IO.Directory.GetFiles(dir, "*", SearchOption.AllDirectories);
musicFiles = files.Where(filename => Utils.IsMusicFile(filename)).ToList();
musicIndex = useTags ? BuildMusicIndex(musicFiles) : new List<TagLib.File>();
if (useCache)
MusicCache[dir] = (musicFiles, musicIndex);
}
for (int i = 0; i < tracks.Count; i++)
{
if (tracks[i].IsNotAudio)
continue;
bool exists;
string? path;
if (useTags)
exists = TrackExistsInCollection(tracks[i], necessaryCond, musicIndex, out path, precise);
else
exists = TrackExistsInCollection(tracks[i], necessaryCond, musicFiles, out path, precise);
if (exists)
{
existing.TryAdd(tracks[i], path);
tracks[i] = new Track(tracks[i]) { TrackState = Track.State.Exists, DownloadPath = path };
}
}
return existing;
}
static List<TagLib.File> BuildMusicIndex(List<string> musicFiles)
{
var musicIndex = new List<TagLib.File>();
foreach (var p in musicFiles)
{
try { musicIndex.Add(TagLib.File.Create(p)); }
catch { continue; }
}
return musicIndex;
}
static Dictionary<string, (List<string> musicFiles, List<TagLib.File> musicIndex)> MusicCache = new();
static List<string> ParseConfig(string path)
{
var lines = File.ReadAllLines(path);
var res = new List<string>();
foreach (var line in lines)
{
string l = line.Trim();
if (l == "" || l.StartsWith('#'))
continue;
int i = l.IndexOfAny(new char[] { ' ', '=' });
if (i < 0) continue;
var x = l.Split(l[i], 2, StringSplitOptions.TrimEntries);
string opt = x[0];
string arg = x[1];
if (opt == "") continue;
if (arg.StartsWith('='))
arg = arg.Substring(1).TrimStart();
if (arg == "false") continue;
if (!opt.StartsWith('-'))
{
if (opt.Length == 1)
opt = '-' + opt;
else
opt = "--" + opt;
}
res.Add(opt);
if (arg.Length > 0 && arg != "true")
{
if (arg[0] == '"' && arg[arg.Length - 1] == '"')
arg = arg.Substring(1, arg.Length - 2);
res.Add(arg);
}
}
return res;
}
static void ParseConditions(FileConditions cond, string input)
{
var tr = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
string[] conditions = input.Split(';', tr);
foreach (string condition in conditions)
{
string[] parts = condition.Split(new string[] { ">=", "<=", "=", ">", "<" }, 2, tr);
string field = parts[0].Replace("-", "").Trim().ToLower();
string value = parts.Length > 1 ? parts[1].Trim() : "true";
switch (field)
{
case "sr":
case "samplerate":
UpdateMinMax(value, condition, ref cond.MinSampleRate, ref cond.MaxSampleRate);
break;
case "br":
case "bitrate":
UpdateMinMax(value, condition, ref cond.MinBitrate, ref cond.MaxBitrate);
break;
case "bd":
case "bitdepth":
UpdateMinMax(value, condition, ref cond.MinBitDepth, ref cond.MaxBitDepth);
break;
case "t":
case "tol":
case "lentol":
case "lengthtol":
case "tolerance":
case "lengthtolerance":
cond.LengthTolerance = int.Parse(value);
break;
case "f":
case "format":
case "formats":
cond.Formats = value.Split(',', tr);
break;
case "banned":
case "bannedusers":
cond.BannedUsers = value.Split(',', tr);
break;
case "dangerwords":
cond.DangerWords = value.Split(',', tr);
break;
case "stricttitle":
cond.StrictTitle = bool.Parse(value);
break;
case "strictartist":
cond.StrictArtist = bool.Parse(value);
break;
case "strictalbum":
cond.StrictAlbum = bool.Parse(value);
break;
case "acceptnolen":
case "acceptnolength":
cond.AcceptNoLength = bool.Parse(value);
break;
case "strict":
case "acceptmissing":
case "acceptmissingprops":
cond.AcceptMissingProps = bool.Parse(value);
break;
default:
throw new ArgumentException($"Unknown condition '{condition}'");
}
}
}
static void UpdateMinMax(string value, string condition, ref int min, ref int max)
{
if (condition.Contains(">="))
min = int.Parse(value);
else if (condition.Contains("<="))
max = int.Parse(value);
else if (condition.Contains('>'))
min = int.Parse(value) + 1;
else if (condition.Contains('<'))
max = int.Parse(value) - 1;
else if (condition.Contains('='))
min = max = int.Parse(value);
}
static Track ParseTrackArg(string input, bool parseSingleString, bool isAlbum) // more complicated than it needs to be
{
input = input.Trim();
Track track = new Track();
List<string> keys = new List<string> { "title", "artist", "duration", "length", "album", "artist-maybe-wrong" };
if (!keys.Any(p => input.Replace(" ", "").Contains(p + "=")))
{
input = input.Replace(" — ", " - ");
if (!parseSingleString || !input.Contains(" - "))
{
track.Title = input;
}
else
{
var parts = input.Split(" - ", 3, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
string artist = "", album = "", title = "";
if (parts.Length <= 1)
{
title = input;
album = input;
}
else if (parts.Length == 2)
{
artist = parts[0];
album = parts[1];
title = parts[1];
}
else
{
artist = parts[0];
album = parts[1];
title = parts[2];
}
track.Artist = artist;
if (isAlbum)
{
track.Album = album;
track.IsAlbum = true;
}
else
{
track.Title = title;
if (input.Length == 3)
track.Album = album;
}
}
}
else
{
(int, int) getNextKeyIndices(int start)
{
int commaIndex = start;
int equalsIndex = input.IndexOf('=', commaIndex);
if (equalsIndex == -1)
return (-1, -1);
if (start == 0)
return keys.Any(k => k == input.Substring(0, equalsIndex).Trim()) ? (0, equalsIndex) : (-1, -1);
while (start < input.Length)
{
commaIndex = input.IndexOf(',', start);
equalsIndex = commaIndex != -1 ? input.IndexOf('=', commaIndex) : -1;
if (commaIndex == -1 || equalsIndex == -1)
return (-1, -1);
if (keys.Any(k => k == input.Substring(commaIndex + 1, equalsIndex - commaIndex - 1).Trim()))
return (commaIndex + 1, equalsIndex);
start = commaIndex + 1;
}
return (-1, -1);
}
(int start, int end) = getNextKeyIndices(0);
(int prevStart, int prevEnd) = (0, 0);
while (true)
{
if (prevEnd != 0)
{
string key = input.Substring(prevStart, prevEnd - prevStart);
int valEnd = start != -1 ? start - 1 : input.Length;
string val = input.Substring(prevEnd + 1, valEnd - prevEnd - 1);
switch (key)
{
case "title":
track.Title = val;
break;
case "artist":
track.Artist = val;
break;
case "duration":
case "length":
track.Length = (int)ParseTrackLength(val, "s");
break;
case "album":
track.Album = val;
break;
case "artist-maybe-wrong":
if (val == "true")
track.ArtistMaybeWrong = true;
break;
}
}
if (end == -1)
break;
(prevStart, prevEnd) = (start, end);
(start, end) = getNextKeyIndices(end);
}
}
if (track.Title == "" && track.Album == "" && track.Artist == "")
throw new ArgumentException("Track string must contain title, album or artist.");
return track;
}
static double ParseTrackLength(string duration, string format)
{
if (string.IsNullOrEmpty(format))
throw new ArgumentException("Duration format string empty");
duration = Regex.Replace(duration, "[a-zA-Z]", "");
var formatParts = Regex.Split(format, @"\W+");
var durationParts = Regex.Split(duration, @"\W+").Where(s => !string.IsNullOrEmpty(s)).ToArray();
double totalSeconds = 0;
for (int i = 0; i < formatParts.Length; i++)
{
switch (formatParts[i])
{
case "h":
totalSeconds += double.Parse(durationParts[i]) * 3600;
break;
case "m":
totalSeconds += double.Parse(durationParts[i]) * 60;
break;
case "s":
totalSeconds += double.Parse(durationParts[i]);
break;
case "ms":
totalSeconds += double.Parse(durationParts[i]) / Math.Pow(10, durationParts[i].Length);
break;
}
}
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)
{
if (file == null)
return t.ToString();
string sampleRate = file.SampleRate.HasValue ? $"{(file.SampleRate.Value/1000.0).Normalize()}kHz" : "";
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 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}]";
}
else
{
string info = string.Join('/', new string[] { length.PadRight(4), (sampleRate+bitRate).PadRight(8), fileSize.PadLeft(6) });
displayText = $"[{info}] {user}{fname}";
}
string necStr = nec != null ? $"nec:{nec.GetNotSatisfiedName(file, t, response)}, " : "";
string prefStr = pref != null ? $"prf:{pref.GetNotSatisfiedName(file, t, response)}" : "";
string cond = "";
if (nec != null || pref != null)
cond = $" ({(necStr + prefStr).TrimEnd(' ', ',')})";
return displayText + cond;
}
static void PrintTracks(List<Track> tracks, int number = int.MaxValue, bool fullInfo=false, bool pathsOnly=false, bool showAncestors=false, bool infoFirst=false, bool showUser=true)
{
number = Math.Min(tracks.Count, number);
string ancestor = "";
if (showAncestors)
ancestor = Utils.GreatestCommonPath(tracks.SelectMany(x => x.Downloads.Select(y => y.Value.Item2.Filename)));
if (pathsOnly)
{
for (int i = 0; i < number; i++)
{
foreach (var x in tracks[i].Downloads)
{
if (ancestor == "")
Console.WriteLine(" " + DisplayString(tracks[i], x.Value.Item2, x.Value.Item1, infoFirst: infoFirst, showUser: showUser));
else
Console.WriteLine(" " + DisplayString(tracks[i], x.Value.Item2, x.Value.Item1, customPath: x.Value.Item2.Filename.Replace(ancestor, ""), infoFirst: infoFirst, showUser: showUser));
}
}
}
else if (!fullInfo)
{
for (int i = 0; i < number; i++)
{
Console.WriteLine($" {tracks[i]}");
}
}
else
{
for (int i = 0; i < number; i++)
{
if (!tracks[i].IsNotAudio)
{
Console.WriteLine($" Title: {tracks[i].Title}");
Console.WriteLine($" Artist: {tracks[i].Artist}");
if (!tracks[i].IsAlbum)
Console.WriteLine($" Length: {tracks[i].Length}s");
if (!string.IsNullOrEmpty(tracks[i].Album))
Console.WriteLine($" Album: {tracks[i].Album}");
if (!string.IsNullOrEmpty(tracks[i].URI))
Console.WriteLine($" URL/ID: {tracks[i].URI}");
if (tracks[i].ArtistMaybeWrong)
Console.WriteLine($" Artist maybe wrong: {tracks[i].ArtistMaybeWrong}");
if (tracks[i].Downloads != null) {
Console.WriteLine($" Shares: {tracks[i].Downloads.Count}");
foreach (var x in tracks[i].Downloads) {
if (ancestor == "")
Console.WriteLine(" " + DisplayString(tracks[i], x.Value.Item2, x.Value.Item1, infoFirst: infoFirst, showUser: showUser));
else
Console.WriteLine(" " + DisplayString(tracks[i], x.Value.Item2, x.Value.Item1, customPath: x.Value.Item2.Filename.Replace(ancestor, ""), infoFirst: infoFirst, showUser: showUser));
}
if (tracks[i].Downloads?.Count > 0) Console.WriteLine();
}
}
else
{
Console.WriteLine($" File: {GetFileNameSlsk(tracks[i].Downloads.First().Value.Item2.Filename)}");
Console.WriteLine($" Shares: {tracks[i].Downloads.Count}");
foreach (var x in tracks[i].Downloads) {
if (ancestor == "")
Console.WriteLine(" " + DisplayString(tracks[i], x.Value.Item2, x.Value.Item1, infoFirst: infoFirst, showUser: showUser));
else
Console.WriteLine(" " + DisplayString(tracks[i], x.Value.Item2, x.Value.Item1, customPath: x.Value.Item2.Filename.Replace(ancestor, ""), infoFirst: infoFirst, showUser: showUser));
}
Console.WriteLine();
}
Console.WriteLine();
}
}
if (number < tracks.Count)
Console.WriteLine($" ... (etc)");
}
static void RefreshOrPrint(ProgressBar? progress, int current, string item, bool print = false, bool refreshIfOffscreen = false)
{
if (progress != null && !Console.IsOutputRedirected && (refreshIfOffscreen || progress.Y >= Console.WindowTop))
{
try { progress.Refresh(current, item); }
catch { }
}
else if ((displayStyle == "simple" || Console.IsOutputRedirected) && print)
Console.WriteLine(item);
}
public static void WriteLine(string value, ConsoleColor color=ConsoleColor.Gray, bool safe=false, bool debugOnly=false)
{
if (debugOnly && !debugInfo)
return;
if (!safe)
{
Console.ForegroundColor = color;
Console.WriteLine(value);
Console.ResetColor();
}
else
{
skipUpdate = true;
lock (consoleLock)
{
Console.ForegroundColor = color;
Console.WriteLine(value);
Console.ResetColor();
}
skipUpdate = false;
}
}
private static ProgressBar? GetProgressBar(string style)
{
lock (consoleLock)
{
#if WINDOWS
if (!debugDisableDownload && debugPrintTracks)
{
try { Console.BufferHeight = Math.Max(Console.BufferHeight + 2, 4000); }
catch { }
}
#endif
ProgressBar? progress = null;
if (style == "double")
progress = new ProgressBar(PbStyle.DoubleLine, 100, Console.WindowWidth - 40, character: '―');
else if (style != "simple")
progress = new ProgressBar(PbStyle.SingleLine, 100, Console.WindowWidth - 10, character: ' ');
return progress;
}
}
public static async Task WaitForLogin()
{
while (true)
{
WriteLine($"Wait for login, state: {client.State}", debugOnly: true);
if (client.State.HasFlag(SoulseekClientStates.LoggedIn))
break;
await Task.Delay(500);
}
}
static List<string> bannedTerms = new List<string>()
{
"depeche mode", "beatles", "prince revolutions", "michael jackson", "coexist", "bob dylan", "enter shikari",
"village people", "lenny kravitz", "beyonce", "beyoncé", "lady gaga", "jay z", "kanye west", "rihanna",
"adele", "kendrick lamar", "bad romance", "born this way", "weeknd", "broken hearted", "highway 61 revisited",
"west gold digger", "west good life"
};
}
public enum FailureReasons
{
None,
InvalidSearchString,
OutOfDownloadRetries,
NoSuitableFileFound,
AllDownloadsFailed
}
public class TrackLists
{
public enum ListType
{
Normal,
Album,
Aggregate
}
public List<(List<List<Track>> list, ListType type, Track source)> lists = new();
public TrackLists() { }
public TrackLists(List<(List<List<Track>> list, ListType type, Track source)> lists)
{
foreach (var (list, type, source) in lists)
{
var newList = new List<List<Track>>();
foreach (var innerList in list)
{
var innerNewList = new List<Track>(innerList);
newList.Add(innerNewList);
}
this.lists.Add((newList, type, source));
}
}
public static TrackLists FromFlatList(List<Track> flatList, bool aggregate, bool album)
{
var res = new TrackLists();
for (int i = 0; i < flatList.Count; i++)
{
if (aggregate)
{
res.AddEntry(ListType.Aggregate, flatList[i]);
}
else if (album || (flatList[i].Album != "" && flatList[i].Title == ""))
{
res.AddEntry(ListType.Album, new Track(flatList[i]) { IsAlbum = true });
}
else
{
res.AddEntry(ListType.Normal);
while (i < flatList.Count && (flatList[i].Album == "" || flatList[i].Title != ""))
{
res.AddTrackToLast(flatList[i]);
i++;
}
if (i < flatList.Count)
i--;
}
}
return res;
}
public void AddEntry(List<List<Track>>? list=null, ListType? type=null, Track? source=null)
{
type ??= ListType.Normal;
source ??= new Track();
list ??= new List<List<Track>>();
lists.Add(((List<List<Track>> list, ListType type, Track source))(list, type, source));
}
public void AddEntry(List<Track> tracks, ListType? type = null, Track? source = null)
{
var list = new List<List<Track>>() { tracks };
AddEntry(list, type, source);
}
public void AddEntry(Track track, ListType? type = null, Track? source = null)
{
var list = new List<List<Track>>() { new List<Track>() { track } };
AddEntry(list, type, source);
}
public void AddEntry(ListType? type = null, Track? source = null)
{
var list = new List<List<Track>>() { new List<Track>() };
AddEntry(list, type, source);
}
public void AddTrackToLast(Track track)
{
int i = lists.Count - 1;
int j = lists[i].list.Count - 1;
lists[i].list[j].Add(track);
}
public void Reverse()
{
lists.Reverse();
foreach (var (list, type, source) in lists)
{
foreach (var ls in list)
{
ls.Reverse();
}
}
}
public List<Track> CombinedTrackList(bool addSourceTracks=false)
{
var res = new List<Track>();
foreach (var (list, type, source) in lists)
{
if (addSourceTracks)
res.Add(source);
foreach (var t in list[0])
res.Add(t);
}
return res;
}
public List<Track> Flattened()
{
var res = new List<Track>();
foreach (var (list, type, source) in lists)
{
if (type == ListType.Album || type == ListType.Aggregate)
{
res.Add(source);
}
else
{
foreach (var t in list[0])
res.Add(t);
}
}
return res;
}
public void SetList(List<List<Track>> list, int index)
{
var (_, type, source) = lists[index];
lists[index] = (list, type, source);
}
public void SetType(ListType type, int index)
{
var (list, _, source) = lists[index];
lists[index] = (list, type, source);
}
public void SetSource(Track source, int index)
{
var (list, type, _) = lists[index];
lists[index] = (list, type, source);
}
}
public struct Track
{
public string Title = "";
public string Artist = "";
public string Album = "";
public string URI = "";
public int Length = -1;
public bool ArtistMaybeWrong = false;
public bool IsAlbum = false;
public bool IsNotAudio = false;
public string FailureReason = "";
public string DownloadPath = "";
public State TrackState = State.Initial;
public SlDictionary? Downloads = null;
public enum State
{
Initial,
Downloaded,
Failed,
Exists,
NotFoundLastTime
};
public Track() { }
public Track(Track other)
{
Title = other.Title;
Artist = other.Artist;
Album = other.Album;
Length = other.Length;
URI = other.URI;
ArtistMaybeWrong = other.ArtistMaybeWrong;
Downloads = other.Downloads;
IsAlbum = other.IsAlbum;
IsNotAudio = other.IsNotAudio;
TrackState = other.TrackState;
FailureReason = other.FailureReason;
DownloadPath = other.DownloadPath;
}
public override readonly string ToString()
{
return ToString(false);
}
public readonly string ToString(bool noInfo = false)
{
if (IsNotAudio && Downloads != null && !Downloads.IsEmpty)
return $"{Program.GetFileNameSlsk(Downloads.First().Value.Item2.Filename)}";
string str = Artist;
if (!IsAlbum && Title == "" && Downloads != null && !Downloads.IsEmpty)
{
str = $"{Program.GetFileNameSlsk(Downloads.First().Value.Item2.Filename)}";
}
else if (Title != "" || Album != "")
{
if (str != "")
str += " - ";
if (Title != "")
str += Title;
else if (IsAlbum)
str += Album;
if (!noInfo)
{
if (Length > 0)
str += $" ({Length}s)";
if (IsAlbum)
str += " (album)";
}
}
else if (!noInfo)
{
str += " (artist)";
}
return str;
}
}
class TrackStringComparer : IEqualityComparer<Track>
{
private bool _ignoreCase = false;
public TrackStringComparer(bool ignoreCase = false) {
_ignoreCase = ignoreCase;
}
public bool Equals(Track a, Track b)
{
if (a.Equals(b))
return true;
var comparer = _ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
return string.Equals(a.Title, b.Title, comparer)
&& string.Equals(a.Artist, b.Artist, comparer)
&& string.Equals(a.Album, b.Album, comparer);
}
public int GetHashCode(Track a)
{
unchecked
{
int hash = 17;
string trackTitle = _ignoreCase ? a.Title.ToLower() : a.Title;
string artistName = _ignoreCase ? a.Artist.ToLower() : a.Artist;
string album = _ignoreCase ? a.Album.ToLower() : a.Album;
hash = hash * 23 + trackTitle.GetHashCode();
hash = hash * 23 + artistName.GetHashCode();
hash = hash * 23 + album.GetHashCode();
return hash;
}
}
}
public class M3UEditor
{
public TrackLists trackLists;
public string path;
public string outputFolder;
public int offset = 0;
public string option = "fails";
public bool m3uListLabels = false;
public Dictionary<string, string> fails;
public M3UEditor(string m3uPath, string outputFolder, TrackLists trackLists, int offset = 0, string option="fails")
{
this.trackLists = trackLists;
this.outputFolder = Path.GetFullPath(outputFolder);
this.offset = offset;
this.option = option;
path = Path.GetFullPath(m3uPath);
m3uListLabels = false;/*trackLists.lists.Any(x => x.type != TrackLists.ListType.Normal);*/
fails = ReadAllLines()
.Where(x => x.StartsWith("# Failed: "))
.Select(line =>
{
var lastBracketIndex = line.LastIndexOf('[');
lastBracketIndex = lastBracketIndex == -1 ? line.Length : lastBracketIndex;
var key = line.Substring("# Failed: ".Length, lastBracketIndex - "# Failed: ".Length).Trim();
var value = lastBracketIndex != line.Length ? line.Substring(lastBracketIndex + 1).Trim().TrimEnd(']') : "";
return new { Key = key, Value = value };
})
.ToSafeDictionary(pair => pair.Key, pair => pair.Value);
}
public void Update()
{
if (option != "fails" && option != "all")
return;
bool needUpdate = false;
lock (trackLists)
{
var lines = ReadAllLines().ToList();
int index = offset;
void updateLine(string newLine)
{
while (index >= lines.Count) lines.Add("");
if (newLine != lines[index]) needUpdate = true;
lines[index] = newLine;
}
foreach (var (list, type, source) in trackLists.lists)
{
if (source.TrackState == Track.State.Failed)
{
updateLine(TrackToLine(source, source.FailureReason));
fails.TryAdd(source.ToString().Trim(), source.FailureReason.Trim());
index++;
}
else
{
if (m3uListLabels)
{
string end = type == TrackLists.ListType.Normal ? "" : $" {source.ToString(noInfo: true)}";
updateLine($"# {Enum.GetName(typeof(TrackLists.ListType), type)} download{end}");
index++;
}
for (int k = 0; k < list.Count; k++)
{
for (int j = 0; j < list[k].Count; j++)
{
var track = list[k][j];
if (track.IsNotAudio)
{
continue;
}
else if (track.TrackState == Track.State.Failed || track.TrackState == Track.State.NotFoundLastTime ||
(option == "all" && (track.TrackState == Track.State.Downloaded || (track.TrackState == Track.State.Exists && k == 0))))
{
string reason = track.TrackState == Track.State.NotFoundLastTime ? nameof(FailureReasons.NoSuitableFileFound) : track.FailureReason;
updateLine(TrackToLine(track, reason));
if (track.TrackState == Track.State.Failed)
fails.TryAdd(track.ToString().Trim(), reason.Trim());
if (type != TrackLists.ListType.Normal)
index++;
}
if (type == TrackLists.ListType.Normal)
index++;
}
}
}
}
if (needUpdate)
{
if (!File.Exists(path))
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, string.Join("\n", lines).TrimEnd('\n') + "\n");
}
}
}
public string TrackToLine(Track track, string failureReason="")
{
if (failureReason != "")
return $"# Failed: {track} [{failureReason}]";
if (track.DownloadPath != "")
return Path.GetRelativePath(Path.GetDirectoryName(path), track.DownloadPath).Replace("\\", "/");
return $"# {track}";
}
public bool HasFail(Track track, out string? reason)
{
reason = null;
var key = track.ToString().Trim();
if (key == "")
return false;
return fails.TryGetValue(key, out reason);
}
public string ReadAllText()
{
if (!File.Exists(path))
return "";
using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var streamReader = new StreamReader(fileStream);
return streamReader.ReadToEnd();
}
public string[] ReadAllLines()
{
return ReadAllText().Split('\n');
}
}
class RateLimitedSemaphore
{
private readonly int maxCount;
private readonly TimeSpan resetTimeSpan;
private readonly SemaphoreSlim semaphore;
private long nextResetTimeTicks;
private readonly object resetTimeLock = new object();
public RateLimitedSemaphore(int maxCount, TimeSpan resetTimeSpan)
{
this.maxCount = maxCount;
this.resetTimeSpan = resetTimeSpan;
this.semaphore = new SemaphoreSlim(maxCount, maxCount);
this.nextResetTimeTicks = (DateTimeOffset.UtcNow + this.resetTimeSpan).UtcTicks;
}
private void TryResetSemaphore()
{
if (!(DateTimeOffset.UtcNow.UtcTicks > Interlocked.Read(ref this.nextResetTimeTicks)))
return;
lock (this.resetTimeLock)
{
var currentTime = DateTimeOffset.UtcNow;
if (currentTime.UtcTicks > Interlocked.Read(ref this.nextResetTimeTicks))
{
this.semaphore.Release(this.maxCount - this.semaphore.CurrentCount);
var newResetTimeTicks = (currentTime + this.resetTimeSpan).UtcTicks;
Interlocked.Exchange(ref this.nextResetTimeTicks, newResetTimeTicks);
}
}
}
public async Task WaitAsync()
{
TryResetSemaphore();
var semaphoreTask = this.semaphore.WaitAsync();
while (!semaphoreTask.IsCompleted)
{
var ticks = Interlocked.Read(ref this.nextResetTimeTicks);
var nextResetTime = new DateTimeOffset(new DateTime(ticks, DateTimeKind.Utc));
var delayTime = nextResetTime - DateTimeOffset.UtcNow;
var delayTask = delayTime >= TimeSpan.Zero ? Task.Delay(delayTime) : Task.CompletedTask;
await Task.WhenAny(semaphoreTask, delayTask);
TryResetSemaphore();
}
}
}