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
fiso64 d5c42ad8b8 fix spotify and csv retrieval
place files in subdirs for multi-album downloads if name-format is empty
2024-08-27 22:24:40 +02:00

1551 lines
58 KiB
C#

using AngleSharp.Text;
using Konsole;
using Soulseek;
using System.Collections.Concurrent;
using System.Data;
using System.Diagnostics;
using System.Text.RegularExpressions;
using Data;
using Enums;
using ExistingCheckers;
using Directory = System.IO.Directory;
using File = System.IO.File;
using ProgressBar = Konsole.ProgressBar;
using SearchResponse = Soulseek.SearchResponse;
using SlFile = Soulseek.File;
using SlResponse = Soulseek.SearchResponse;
static partial class Program
{
public static Extractors.IExtractor? extractor;
public static ExistingChecker? outputExistingChecker;
public static ExistingChecker? musicDirExistingChecker;
public static SoulseekClient? client;
public static TrackLists? trackLists;
public static M3uEditor? m3uEditor;
static RateLimitedSemaphore? searchSemaphore;
static CancellationTokenSource? mainLoopCts;
static readonly ConcurrentDictionary<Track, SearchInfo> searches = new();
static readonly ConcurrentDictionary<string, DownloadWrapper> downloads = new();
static readonly ConcurrentDictionary<string, int> userSuccessCount = new();
static bool skipUpdate = false;
static bool initialized = false;
static string? soulseekFolderPathPrefix;
static readonly object consoleLock = new();
static async Task Main(string[] args)
{
Console.ResetColor();
Console.OutputEncoding = System.Text.Encoding.UTF8;
int helpIdx = Array.FindIndex(args, x => x == "--help" || x == "-h");
if (args.Length == 0 || helpIdx >= 0)
{
string option = helpIdx + 1 < args.Length ? args[helpIdx + 1] : "";
Help.PrintHelp(option);
return;
}
bool doContinue = Config.ParseArgsAndReadConfig(args);
if (!doContinue)
return;
if (Config.input.Length == 0)
throw new ArgumentException($"No input provided");
(Config.inputType, extractor) = Extractors.ExtractorRegistry.GetMatchingExtractor(Config.input);
if (Config.inputType == InputType.None)
throw new ArgumentException($"No matching extractor for input '{Config.input}'");
WriteLine($"Using extractor: {Config.inputType}", debugOnly: true);
trackLists = await extractor.GetTracks(Config.maxTracks, Config.offset, Config.reverse);
WriteLine("Got tracks", debugOnly: true);
trackLists.UpgradeListTypes(Config.aggregate, Config.album);
trackLists.SetListEntryOptions();
Config.PostProcessArgs();
m3uEditor = new M3uEditor(Config.m3uFilePath, trackLists, Config.m3uOption, Config.offset);
InitExistingChecker();
await MainLoop();
WriteLine("Mainloop done", debugOnly: true);
}
static async Task InitClientAndUpdateIfNeeded()
{
if (initialized)
return;
bool needLogin = !Config.PrintTracks;
if (needLogin)
{
client = new SoulseekClient(new SoulseekClientOptions(listenPort: Config.listenPort));
if (!Config.useRandomLogin && (string.IsNullOrEmpty(Config.username) || string.IsNullOrEmpty(Config.password)))
throw new ArgumentException("No soulseek username or password");
await Login(Config.useRandomLogin);
}
bool needUpdate = needLogin;
if (needUpdate)
{
var UpdateTask = Task.Run(() => Update());
WriteLine("Update started", debugOnly: true);
}
searchSemaphore = new RateLimitedSemaphore(Config.searchesPerTime, TimeSpan.FromSeconds(Config.searchRenewTime));
initialized = true;
}
static void InitExistingChecker()
{
if (Config.skipExisting)
{
var cond = Config.skipExistingPrefCond ? Config.preferredCond : Config.necessaryCond;
if (Config.musicDir.Length == 0 || !Config.outputFolder.StartsWith(Config.musicDir, StringComparison.OrdinalIgnoreCase))
outputExistingChecker = ExistingCheckerRegistry.GetChecker(Config.skipMode, Config.outputFolder, cond, m3uEditor);
if (Config.musicDir.Length > 0)
{
if (!Directory.Exists(Config.musicDir))
Console.WriteLine("Error: Music directory does not exist");
else
musicDirExistingChecker = ExistingCheckerRegistry.GetChecker(Config.skipModeMusicDir, Config.outputFolder, cond, m3uEditor);
}
}
}
static void PreprocessTracks(TrackListEntry tle)
{
for (int k = 0; k < tle.list.Count; k++)
{
PreprocessTrack(tle.source);
foreach (var ls in tle.list)
{
for (int i = 0; i < ls.Count; i++)
{
PreprocessTrack(ls[i]);
}
}
}
}
static void PreprocessTrack(Track track)
{
if (Config.removeFt)
{
track.Title = track.Title.RemoveFt();
track.Artist = track.Artist.RemoveFt();
}
if (Config.removeBrackets)
{
track.Title = track.Title.RemoveSquareBrackets();
}
if (Config.regexToReplace.Title.Length + Config.regexToReplace.Artist.Length + Config.regexToReplace.Album.Length > 0)
{
track.Title = Regex.Replace(track.Title, Config.regexToReplace.Title, Config.regexReplaceBy.Title);
track.Artist = Regex.Replace(track.Artist, Config.regexToReplace.Artist, Config.regexReplaceBy.Artist);
track.Album = Regex.Replace(track.Album, Config.regexToReplace.Album, Config.regexReplaceBy.Album);
}
if (Config.artistMaybeWrong)
{
track.ArtistMaybeWrong = true;
}
track.Artist = track.Artist.Trim();
track.Album = track.Album.Trim();
track.Title = track.Title.Trim();
}
static async Task MainLoop()
{
for (int i = 0; i < trackLists.lists.Count; i++)
{
if (i > 0) Console.WriteLine();
var tle = trackLists[i];
Config.UpdateArgs(tle);
PreprocessTracks(tle);
var existing = new List<Track>();
var notFound = new List<Track>();
var responseData = new ResponseData();
if (Config.skipNotFound && !Config.PrintResults)
{
if (tle.sourceCanBeSkipped && SetNotFoundLastTime(tle.source))
notFound.Add(tle.source);
if (tle.source.State != TrackState.NotFoundLastTime && !tle.needSourceSearch)
{
foreach (var tracks in tle.list)
notFound.AddRange(DoSkipNotFound(tracks));
}
}
if (Config.skipExisting && !Config.PrintResults && tle.source.State != TrackState.NotFoundLastTime)
{
if (tle.sourceCanBeSkipped && SetExisting(tle.source))
existing.Add(tle.source);
if (tle.source.State != TrackState.AlreadyExists && !tle.needSourceSearch)
{
foreach (var tracks in tle.list)
existing.AddRange(DoSkipExisting(tracks));
}
}
if (Config.PrintTracks)
{
if (tle.source.Type == TrackType.Normal)
{
PrintTracksTbd(tle.list[0].Where(t => t.State == TrackState.Initial).ToList(), existing, notFound, tle.source.Type);
}
else
{
var tl = new List<Track>();
if (tle.source.State == TrackState.Initial) tl.Add(tle.source);
PrintTracksTbd(tl, existing, notFound, tle.source.Type);
}
continue;
}
if (tle.sourceCanBeSkipped)
{
if (tle.source.State == TrackState.AlreadyExists)
{
Console.WriteLine($"{tle.source.Type} download '{tle.source.ToString(true)}' already exists at {tle.source.DownloadPath}, skipping");
continue;
}
if (tle.source.State == TrackState.NotFoundLastTime)
{
Console.WriteLine($"{tle.source.Type} download '{tle.source.ToString(true)}' was not found during a prior run, skipping");
continue;
}
}
if (tle.needSourceSearch)
{
await InitClientAndUpdateIfNeeded();
Console.WriteLine($"{tle.source.Type} download: {tle.source.ToString(true)}, searching..");
if (tle.source.Type == TrackType.Album)
{
tle.list = await GetAlbumDownloads(tle.source, responseData);
}
else if (tle.source.Type == TrackType.Aggregate)
{
tle.list[0] = await GetAggregateTracks(tle.source, responseData);
}
else if (tle.source.Type == TrackType.AlbumAggregate)
{
var res = await GetAggregateAlbums(tle.source, responseData);
foreach (var item in res)
{
var newSource = new Track(tle.source) { Type = TrackType.Album };
trackLists.AddEntry(new TrackListEntry(item, newSource, false, true, true, true, false, false));
}
}
if (Config.skipExisting && tle.needSkipExistingAfterSearch)
{
foreach (var tracks in tle.list)
notFound.AddRange(DoSkipExisting(tracks));
}
if (tle.gotoNextAfterSearch)
{
continue;
}
}
if (Config.PrintResults)
{
await PrintResults(tle, existing, notFound);
continue;
}
if (tle.needSourceSearch && (tle.list.Count == 0 || !tle.list.Any(x => x.Count > 0)))
{
string lockedFilesStr = responseData.lockedFilesCount > 0 ? $" (Found {responseData.lockedFilesCount} locked files)" : "";
Console.WriteLine($"No results.{lockedFilesStr}");
tle.source.State = TrackState.Failed;
tle.source.FailureReason = FailureReason.NoSuitableFileFound;
m3uEditor.Update();
continue;
}
m3uEditor.Update();
if (tle.source.Type != TrackType.Album)
{
PrintTracksTbd(tle.list[0].Where(t => t.State == TrackState.Initial).ToList(), existing, notFound, tle.source.Type);
}
if (notFound.Count + existing.Count >= tle.list.Sum(x => x.Count))
{
continue;
}
await InitClientAndUpdateIfNeeded();
if (tle.source.Type == TrackType.Normal)
{
await TracksDownloadNormal(tle);
}
else if (tle.source.Type == TrackType.Album)
{
await TracksDownloadAlbum(tle);
}
else if (tle.source.Type == TrackType.Aggregate)
{
await TracksDownloadNormal(tle);
}
}
if (!Config.DoNotDownload && (trackLists.lists.Count > 0 || trackLists.Flattened(false, false).Skip(1).Any()))
{
PrintComplete();
}
}
static async Task PrintResults(TrackListEntry tle, List<Track> existing, List<Track> notFound)
{
await InitClientAndUpdateIfNeeded();
if (tle.source.Type == TrackType.Normal)
{
await SearchAndPrintResults(tle.list[0]);
}
else if (tle.source.Type == TrackType.Aggregate)
{
Console.WriteLine(new string('-', 60));
Console.WriteLine($"Results for aggregate {tle.source.ToString(true)}:");
PrintTracksTbd(tle.list[0].Where(t => t.State == TrackState.Initial).ToList(), existing, notFound, tle.source.Type);
}
else if (tle.source.Type == TrackType.Album)
{
Console.WriteLine(new string('-', 60));
Console.WriteLine($"Results for album {tle.source.ToString(true)}:");
if (tle.list.Count > 0 && tle.list[0].Count > 0)
{
if (!Config.noBrowseFolder)
Console.WriteLine("[Skipped full folder retrieval]");
foreach (var ls in tle.list)
{
PrintAlbum(ls);
if (!Config.printOption.HasFlag(PrintOption.Full))
break;
}
}
else
{
Console.WriteLine("No results.");
}
}
}
static void PrintComplete()
{
var ls = trackLists.Flattened(true, true);
int successes = 0, fails = 0;
foreach (var x in ls)
{
if (x.State == TrackState.Downloaded)
successes++;
else if (x.State == TrackState.Failed)
fails++;
}
if (successes + fails > 1)
Console.WriteLine($"\nCompleted: {successes} succeeded, {fails} failed.");
}
static void PrintTracksTbd(List<Track> toBeDownloaded, List<Track> existing, List<Track> notFound, TrackType type)
{
if (type == TrackType.Normal && !Config.PrintTracks && toBeDownloaded.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.Length > 0 && notFoundLastTime.Length > 0 ? ", " + notFoundLastTime : notFoundLastTime;
string skippedTracks = alreadyExist.Length + notFoundLastTime.Length > 0 ? $" ({alreadyExist}{notFoundLastTime})" : "";
bool full = Config.printOption.HasFlag(PrintOption.Full);
if (type == TrackType.Normal || skippedTracks.Length > 0)
Console.WriteLine($"Downloading {toBeDownloaded.Count(x => !x.IsNotAudio)} tracks{skippedTracks}");
if (toBeDownloaded.Count > 0)
{
bool showAll = type != TrackType.Normal || Config.PrintTracks || Config.PrintResults;
PrintTracks(toBeDownloaded, showAll ? int.MaxValue : 10, full, infoFirst: Config.PrintTracks);
if (full && (existing.Count > 0 || notFound.Count > 0))
Console.WriteLine("\n-----------------------------------------------\n");
}
if (Config.PrintTracks)
{
if (existing.Count > 0)
{
Console.WriteLine($"\nThe following tracks already exist:");
PrintTracks(existing, fullInfo: full, infoFirst: Config.PrintTracks);
}
if (notFound.Count > 0)
{
Console.WriteLine($"\nThe following tracks were not found during a prior run:");
PrintTracks(notFound, fullInfo: full, infoFirst: Config.PrintTracks);
}
}
}
static void PrintAlbum(List<Track> albumTracks, bool retrieveAll = false)
{
if (albumTracks.Count == 0 && albumTracks[0].Downloads.IsEmpty)
return;
var response = albumTracks[0].Downloads.First().Value.Item1;
string userInfo = $"{response.Username} ({((float)response.UploadSpeed / (1024 * 1024)):F3}MB/s)";
var (parents, props) = FolderInfo(albumTracks.SelectMany(x => x.Downloads.Select(d => d.Value.Item2)));
Console.WriteLine();
WriteLine($"User : {userInfo}\nFolder: {parents}\nProps : {props}", ConsoleColor.White);
PrintTracks(albumTracks.Where(t => t.State == TrackState.Initial).ToList(), pathsOnly: true, showAncestors: true, showUser: false);
Console.WriteLine();
}
static List<Track> DoSkipExisting(List<Track> tracks)
{
var existing = new List<Track>();
foreach (var track in tracks)
{
if (SetExisting(track))
{
existing.Add(track);
}
}
return existing;
}
static bool SetExisting(Track track)
{
string? path = null;
if (outputExistingChecker != null)
{
if (!outputExistingChecker.IndexIsBuilt)
outputExistingChecker.BuildIndex();
outputExistingChecker.TrackExists(track, out path);
}
if (path == null && musicDirExistingChecker != null)
{
if (!musicDirExistingChecker.IndexIsBuilt)
{
Console.WriteLine($"Building music directory index..");
musicDirExistingChecker.BuildIndex();
}
musicDirExistingChecker.TrackExists(track, out path);
}
if (path != null)
{
track.State = TrackState.AlreadyExists;
track.DownloadPath = path;
}
return path != null;
}
static List<Track> DoSkipNotFound(List<Track> tracks)
{
var notFound = new List<Track>();
foreach (var track in tracks)
{
if (SetNotFoundLastTime(track))
{
notFound.Add(track);
}
}
return notFound;
}
static bool SetNotFoundLastTime(Track track)
{
if (m3uEditor.TryGetPreviousRunResult(track, out var prevTrack))
{
if (prevTrack.FailureReason == FailureReason.NoSuitableFileFound || prevTrack.State == TrackState.NotFoundLastTime)
{
track.State = TrackState.NotFoundLastTime;
return true;
}
}
return false;
}
static async Task TracksDownloadNormal(TrackListEntry tle)
{
var tracks = tle.list[0];
var semaphore = new SemaphoreSlim(Config.concurrentProcesses);
var copy = new List<Track>(tracks);
var downloadTasks = copy.Select(async (track, index) =>
{
if (track.State == TrackState.AlreadyExists || (track.State == TrackState.NotFoundLastTime && Config.skipNotFound))
return;
await semaphore.WaitAsync();
int tries = Config.unknownErrorRetries;
string savedFilePath = "";
while (tries > 0)
{
await WaitForLogin();
try
{
WriteLine($"Search and download {track}", debugOnly: true);
savedFilePath = await SearchAndDownload(track);
}
catch (Exception ex)
{
WriteLine($"Exception thrown: {ex}", debugOnly: true);
if (!IsConnectedAndLoggedIn())
{
continue;
}
else if (ex is SearchAndDownloadException sdEx)
{
lock (trackLists)
{
tracks[index].State = TrackState.Failed;
tracks[index].FailureReason = sdEx.reason;
}
}
else
{
WriteLine($"\n{ex.Message}\n{ex.StackTrace}\n", ConsoleColor.DarkYellow, true);
tries--;
continue;
}
}
break;
}
if (savedFilePath.Length > 0)
{
lock (trackLists)
{
tracks[index].State = TrackState.Downloaded;
tracks[index].DownloadPath = savedFilePath;
}
if (Config.removeTracksFromSource)
{
try
{
await extractor.RemoveTrackFromSource(track);
}
catch (Exception ex)
{
WriteLine($"\n{ex.Message}\n{ex.StackTrace}\n", ConsoleColor.DarkYellow, true);
}
}
}
m3uEditor.Update();
if (Config.onComplete.Length > 0)
{
OnComplete(Config.onComplete, tracks[index]);
}
semaphore.Release();
});
await Task.WhenAll(downloadTasks);
}
static async Task TracksDownloadAlbum(TrackListEntry tle) // this is shit
{
var list = tle.list;
var dlFiles = new ConcurrentDictionary<string, bool>();
var dlAdditionalImages = new ConcurrentDictionary<string, bool>();
var retrievedFolders = new HashSet<string>();
var tracks = new List<Track>();
bool downloadingImages = false;
bool albumDlFailed = false;
string savedOutputFolder = Config.outputFolder;
var curAlbumArtOption = Config.albumArtOption == AlbumArtOption.MostLargest ? AlbumArtOption.Most : Config.albumArtOption;
void prepareImageDownload(AlbumArtOption option)
{
var albumArtList = list
//.Where(tracks => tracks)
.Select(tracks => tracks.Where(t => Utils.IsImageFile(t.Downloads.First().Value.Item2.Filename)))
.Where(tracks => tracks.Any());
if (option == AlbumArtOption.Largest)
{
list = albumArtList
.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 (option == AlbumArtOption.Most)
{
list = albumArtList
.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();
}
}
bool needImageDownload(AlbumArtOption option)
{
bool need = true;
if (option == AlbumArtOption.Most)
{
need = dlFiles.Keys.Count(x => Utils.IsImageFile(x) && File.Exists(x)) < list[0].Count;
}
else if (option == AlbumArtOption.Largest)
{
long curMax = dlFiles.Keys.Where(x => Utils.IsImageFile(x) && File.Exists(x)).Max(x => new FileInfo(x).Length);
need = curMax < list[0].Max(t => t.Downloads.First().Value.Item2.Size) - 1024 * 50;
}
return need;
}
if (Config.albumArtOnly)
{
prepareImageDownload(curAlbumArtOption);
downloadingImages = true;
}
int idx = -1;
while (list.Count > 0)
{
idx++;
mainLoopCts = new CancellationTokenSource();
albumDlFailed = false;
if (Config.interactiveMode)
tracks = await InteractiveModeAlbum(list, !downloadingImages, retrievedFolders);
else
tracks = list[0];
soulseekFolderPathPrefix = GetCommonPathPrefix(tracks);
if (tle.placeInSubdir && Config.nameFormat.Length == 0 && (idx == 0 || !downloadingImages))
{
string name = tle.useRemoteDirname ? Utils.GetBaseNameSlsk(soulseekFolderPathPrefix) : tle.source.ToString(true);
Config.outputFolder = Path.Join(savedOutputFolder, name);
}
if (!downloadingImages)
{
if (!Config.noBrowseFolder && !Config.interactiveMode && !retrievedFolders.Contains(soulseekFolderPathPrefix))
{
Console.WriteLine("Getting all files in folder...");
var response = tracks[0].Downloads.First().Value.Item1;
await CompleteFolder(tracks, response, soulseekFolderPathPrefix);
retrievedFolders.Add(soulseekFolderPathPrefix);
}
}
if (!Config.interactiveMode)
PrintAlbum(tracks);
if (!downloadingImages)
{
if (tracks.All(t => t.State != TrackState.Initial || (!Config.interactiveMode && t.IsNotAudio)))
goto imgDl;
if (list.Count <= 1 && tracks.All(t => t.State != TrackState.Initial))
goto imgDl;
}
var semaphore = new SemaphoreSlim(Config.concurrentProcesses);
var copy = new List<Track>(tracks);
try
{
var downloadTasks = copy.Select(async (track, index) =>
{
if (track.State != TrackState.Initial)
return;
await semaphore.WaitAsync(mainLoopCts.Token);
int tries = Config.unknownErrorRetries;
string savedFilePath = "";
while (tries > 0)
{
await WaitForLogin();
mainLoopCts.Token.ThrowIfCancellationRequested();
try
{
savedFilePath = await SearchAndDownload(track);
}
catch (Exception ex)
{
if (!IsConnectedAndLoggedIn())
{
continue;
}
else if (ex is SearchAndDownloadException sdEx)
{
lock (trackLists)
{
tracks[index].State = TrackState.Failed;
tracks[index].FailureReason = sdEx.reason;
}
if (!Config.albumIgnoreFails)
{
mainLoopCts.Cancel();
foreach (var (key, dl) in downloads)
{
lock (dl)
{
dl.cts.Cancel();
if (File.Exists(dl.savePath)) File.Delete(dl.savePath);
downloads.TryRemove(key, out _);
}
}
throw new OperationCanceledException();
}
}
else
{
WriteLine($"\n{ex.Message}\n{ex.StackTrace}\n", ConsoleColor.DarkYellow, true);
tries--;
continue;
}
}
break;
}
if (savedFilePath.Length > 0)
{
dlFiles.TryAdd(savedFilePath, true);
lock (trackLists)
{
tracks[index].State = TrackState.Downloaded;
tracks[index].DownloadPath = savedFilePath;
if (downloadingImages)
{
dlAdditionalImages.TryAdd(savedFilePath, true);
}
}
}
if (Config.onComplete.Length > 0)
{
OnComplete(Config.onComplete, tracks[index]);
}
semaphore.Release();
});
await Task.WhenAll(downloadTasks);
}
catch (OperationCanceledException)
{
if (!Config.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:
bool needDownloadAgain = Config.albumArtOption == AlbumArtOption.MostLargest && curAlbumArtOption == AlbumArtOption.Most;
if ((!downloadingImages || needDownloadAgain) && !albumDlFailed && Config.albumArtOption != AlbumArtOption.Default)
{
if (curAlbumArtOption == AlbumArtOption.Most && downloadingImages && Config.albumArtOption == AlbumArtOption.MostLargest)
{
curAlbumArtOption = AlbumArtOption.Largest;
}
prepareImageDownload(curAlbumArtOption);
if (Config.interactiveMode || needImageDownload(curAlbumArtOption))
{
downloadingImages = true;
continue;
}
else if (Config.albumArtOption == AlbumArtOption.MostLargest && curAlbumArtOption == AlbumArtOption.Most)
{
curAlbumArtOption = AlbumArtOption.Largest;
prepareImageDownload(curAlbumArtOption);
if (Config.interactiveMode || needImageDownload(curAlbumArtOption))
{
downloadingImages = true;
continue;
}
}
}
break;
}
bool success = tracks.All(t => t.State == TrackState.Downloaded || t.State == TrackState.AlreadyExists);
ApplyNamingFormatsNonAudio(tracks);
if (!Config.albumArtOnly && success)
{
tle.source.State = TrackState.Downloaded;
tle.source.DownloadPath = Utils.GreatestCommonPath(tracks.Where(x => x.DownloadPath.Length > 0).Select(x => x.DownloadPath), Path.DirectorySeparatorChar);
}
m3uEditor.Update();
soulseekFolderPathPrefix = "";
Config.outputFolder = savedOutputFolder;
}
static string GetCommonPathPrefix(List<Track> tracks)
{
if (tracks.Count == 1)
return Utils.GetDirectoryNameSlsk(tracks.First().Downloads.First().Value.Item2.Filename);
else
return Utils.GreatestCommonPath(tracks.SelectMany(x => x.Downloads.Select(y => y.Value.Item2.Filename)), dirsep: '\\');
}
static async Task<List<Track>> InteractiveModeAlbum(List<List<Track>> list, bool retrieveFolder, HashSet<string> retrievedFolders)
{
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;
var folder = GetCommonPathPrefix(tracks);
if (retrieveFolder && !Config.noBrowseFolder && !retrievedFolders.Contains(folder))
{
Console.WriteLine("Getting all files in folder...");
await CompleteFolder(tracks, response, folder);
retrievedFolders.Add(folder);
}
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, 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":
Config.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 => Utils.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.Skip(1).Any())
gcp = Utils.GreatestCommonPath(files.Select(x => x.Filename), '\\').TrimEnd('\\');
else
gcp = Utils.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[(lastIndex + 1)..] : gcp[(secondLastIndex + 1)..];
}
return (gcp, res);
}
static async Task Login(bool random = false, int tries = 3)
{
string user = Config.username, pass = Config.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}");
while (true)
{
try
{
WriteLine($"Connecting {user}", debugOnly: true);
await client.ConnectAsync(user, pass);
if (!Config.noModifyShareCount)
{
WriteLine($"Setting share count", debugOnly: true);
await client.SetSharedCountsAsync(20, 100);
}
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 Update()
{
while (true)
{
if (!skipUpdate)
{
try
{
if (IsConnectedAndLoggedIn())
{
foreach (var (key, val) in searches)
{
if (val == null)
searches.TryRemove(key, out _); // reminder: removing from a dict in a foreach is allowed in newer .net versions
}
foreach (var (key, val) in downloads)
{
if (val != null)
{
lock (val)
{
if ((DateTime.Now - val.UpdateLastChangeTime()).TotalMilliseconds > Config.maxStaleTime)
{
val.stalled = true;
val.UpdateText();
try { val.cts.Cancel(); } catch { }
downloads.TryRemove(key, out _);
}
else
{
val.UpdateText();
}
}
}
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(Config.useRandomLogin); }
catch (Exception ex)
{
string banMsg = Config.useRandomLogin ? "" : " (possibly a 30-minute ban caused by frequent searches)";
WriteLine($"{ex.Message}{banMsg}", ConsoleColor.DarkYellow, true);
}
}
foreach (var (key, val) in downloads)
{
if (val != null)
lock (val) { val.UpdateLastChangeTime(updateAllFromThisUser: false, forceChanged: true); }
else
downloads.TryRemove(key, out _);
}
}
}
catch (Exception ex)
{
WriteLine($"\n{ex.Message}\n", ConsoleColor.DarkYellow, true);
}
}
await Task.Delay(Config.updateDelay);
}
}
static void OnComplete(string onComplete, Track track)
{
if (onComplete.Length == 0)
return;
else if (onComplete.Length > 2 && onComplete[0].IsDigit() && onComplete[1] == ':')
{
if ((int)track.State != int.Parse(onComplete[0].ToString()))
return;
onComplete = onComplete[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("{type}", track.Type.ToString())
.Replace("{is-not-audio}", track.IsNotAudio.ToString())
.Replace("{failure-reason}", track.FailureReason.ToString())
.Replace("{path}", track.DownloadPath)
.Replace("{state}", track.State.ToString())
.Replace("{extractor}", Config.inputType.ToString())
.Trim();
if (onComplete[0] == '"')
{
int e = onComplete.IndexOf('"', 1);
if (e > 1)
{
startInfo.FileName = onComplete[1..e];
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;
WriteLine($"on-complete: FileName={startInfo.FileName}, Arguments={startInfo.Arguments}", debugOnly: true);
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
process.WaitForExit();
}
static string GetSavePath(string sourceFname)
{
return $"{GetSavePathNoExt(sourceFname)}{Path.GetExtension(sourceFname)}";
}
static string GetSavePathNoExt(string sourceFname)
{
string outTo = Config.outputFolder;
if (!string.IsNullOrEmpty(soulseekFolderPathPrefix))
{
string add = sourceFname.Replace(soulseekFolderPathPrefix, "").Replace(Utils.GetFileNameSlsk(sourceFname), "").Trim('\\').Trim();
if (add.Length > 0) outTo = Path.Join(Config.outputFolder, add.Replace('\\', Path.DirectorySeparatorChar));
}
return Path.Combine(outTo, $"{GetSaveName(sourceFname)}");
}
static string GetSaveName(string sourceFname)
{
string name = Utils.GetFileNameWithoutExtSlsk(sourceFname);
return name.ReplaceInvalidChars(Config.invalidReplaceStr);
}
static void ApplyNamingFormatsNonAudio(List<Track> tracks)
{
if (!Config.nameFormat.Replace('\\', '/').Contains('/'))
return;
var audioFilePaths = tracks.Where(t => t.DownloadPath.Length > 0 && !t.IsNotAudio).Select(t => t.DownloadPath);
string outputFolder = Utils.GreatestCommonPath(audioFilePaths, Path.DirectorySeparatorChar);
foreach (var track in tracks)
{
if (!track.IsNotAudio || track.State != TrackState.Downloaded)
continue;
string newFilePath = Path.Join(outputFolder, Path.GetFileName(track.DownloadPath));
if (track.DownloadPath != newFilePath)
{
Directory.CreateDirectory(Path.GetDirectoryName(newFilePath));
Utils.Move(track.DownloadPath, newFilePath);
string prevParent = Path.GetDirectoryName(track.DownloadPath);
if (prevParent != Config.outputFolder && Utils.GetRecursiveFileCount(prevParent) == 0)
Directory.Delete(prevParent, true);
track.DownloadPath = newFilePath;
}
}
}
static string ApplyNamingFormat(string filepath, Track track)
{
if (Config.nameFormat.Length == 0 || !Utils.IsMusicFile(filepath))
return filepath;
string dir = Path.GetDirectoryName(filepath) ?? "";
string add = dir.Length > 0 ? Path.GetRelativePath(Config.outputFolder, dir) : "";
string newFilePath = NamingFormat(filepath, Config.nameFormat, track);
if (filepath != newFilePath)
{
dir = Path.GetDirectoryName(newFilePath) ?? "";
if (dir.Length > 0) Directory.CreateDirectory(dir);
try
{
Utils.Move(filepath, newFilePath);
}
catch (Exception ex)
{
WriteLine($"\nFailed to move: {ex.Message}\n", ConsoleColor.DarkYellow, true);
return filepath;
}
if (add.Length > 0 && add != "." && Utils.GetRecursiveFileCount(Path.Join(Config.outputFolder, add)) == 0)
try { Directory.Delete(Path.Join(Config.outputFolder, add), true); } catch { }
}
return newFilePath;
}
static string NamingFormat(string filepath, string format, Track track)
{
string newName = format;
TagLib.File? file = null;
try { file = TagLib.File.Create(filepath); }
catch { }
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;
inner = inner[1..^1];
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 => GetVarValue(x, file, filepath, track).Length > 0))
{
chosenOpt = opt;
break;
}
}
chosenOpt = Regex.Replace(chosenOpt, @"\([^()]*\)|[^()]+", match =>
{
if (match.Value.StartsWith("(") && match.Value.EndsWith(")"))
return match.Value[1..^1].ReplaceInvalidChars(Config.invalidReplaceStr, removeSlash: false);
else
return GetVarValue(match.Value, file, filepath, track).ReplaceInvalidChars(Config.invalidReplaceStr);
});
string old = match.Groups[1].Value;
old = old.StartsWith("{{") ? old[1..] : old;
newName = newName.Replace(old, chosenOpt);
}
matches = regex.Matches(newName);
}
if (newName != format)
{
string directory = Path.GetDirectoryName(filepath) ?? "";
string extension = Path.GetExtension(filepath);
char dirsep = Path.DirectorySeparatorChar;
newName = newName.Replace('/', dirsep);
var x = newName.Split(dirsep, StringSplitOptions.RemoveEmptyEntries);
newName = string.Join(dirsep, x.Select(x => x.ReplaceInvalidChars(Config.invalidReplaceStr).Trim(' ', '.')));
string newFilePath = Path.Combine(directory, newName + extension);
return newFilePath;
}
return filepath;
}
static string GetVarValue(string x, TagLib.File? file, string filepath, Track track)
{
switch (x)
{
case "artist":
return file?.Tag.FirstPerformer ?? "";
case "artists":
return file != null ? string.Join(" & ", file.Tag.Performers) : "";
case "albumartist":
return file?.Tag.FirstAlbumArtist ?? "";
case "albumartists":
return file != null ? string.Join(" & ", file.Tag.AlbumArtists) : "";
case "title":
return file?.Tag.Title ?? "";
case "album":
return file?.Tag.Album ?? "";
case "sartist":
case "sartists":
return track.Artist;
case "stitle":
return track.Title;
case "salbum":
return track.Album;
case "year":
return file?.Tag.Year.ToString() ?? "";
case "track":
return file?.Tag.Track.ToString("D2") ?? "";
case "disc":
return file?.Tag.Disc.ToString() ?? "";
case "filename":
return Path.GetFileNameWithoutExtension(filepath);
case "foldername":
return track.FirstDownload != null ?
Utils.GetBaseNameSlsk(Utils.GetDirectoryNameSlsk(track.FirstDownload.Filename)) : Config.defaultFolderName;
case "default-foldername":
return Config.defaultFolderName;
case "extractor":
return Config.inputType.ToString();
default:
return "";
}
}
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, bool showSpeed = false)
{
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 speed = showSpeed && response?.Username != null ? $"({response.UploadSpeed / 1024.0 / 1024.0:F2}MB/s) " : "";
string fname = fullpath ? file.Filename : (showUser ? "..\\" : "") + (customPath.Length == 0 ? Utils.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.Length > 0));
displayText = $"{speed}{user}{fname} [{info}]";
}
else
{
string info = string.Join('/', new string[] { length.PadRight(4), (sampleRate + bitRate).PadRight(8), fileSize.PadLeft(6) });
displayText = $"[{info}] {speed}{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)), Path.DirectorySeparatorChar);
if (pathsOnly)
{
for (int i = 0; i < number; i++)
{
foreach (var x in tracks[i].Downloads)
{
if (ancestor.Length == 0)
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($" Artist: {tracks[i].Artist}");
if (!string.IsNullOrEmpty(tracks[i].Title) || tracks[i].Type == TrackType.Normal)
Console.WriteLine($" Title: {tracks[i].Title}");
if (!string.IsNullOrEmpty(tracks[i].Album) || tracks[i].Type == TrackType.Album)
Console.WriteLine($" Album: {tracks[i].Album}");
if (tracks[i].Length > -1 || tracks[i].Type == TrackType.Normal)
Console.WriteLine($" Length: {tracks[i].Length}s");
if (!string.IsNullOrEmpty(tracks[i].DownloadPath))
Console.WriteLine($" Local path: {tracks[i].DownloadPath}");
if (!string.IsNullOrEmpty(tracks[i].URI))
Console.WriteLine($" URL/ID: {tracks[i].URI}");
if (tracks[i].Type != TrackType.Normal)
Console.WriteLine($" Type: {tracks[i].Type}");
if (!string.IsNullOrEmpty(tracks[i].Other))
Console.WriteLine($" Other: {tracks[i].Other}");
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.Length == 0)
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: {Utils.GetFileNameSlsk(tracks[i].Downloads.First().Value.Item2.Filename)}");
Console.WriteLine($" Shares: {tracks[i].Downloads.Count}");
foreach (var x in tracks[i].Downloads)
{
if (ancestor.Length == 0)
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 ((Config.displayMode == DisplayMode.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 && !Config.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;
}
}
public static ProgressBar? GetProgressBar(DisplayMode style)
{
lock (consoleLock)
{
ProgressBar? progress = null;
if (style == DisplayMode.Double)
progress = new ProgressBar(PbStyle.DoubleLine, 100, Console.WindowWidth - 40, character: '―');
else if (style != DisplayMode.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 (IsConnectedAndLoggedIn())
break;
await Task.Delay(1000);
}
}
public static bool IsConnectedAndLoggedIn()
{
return client != null && (client.State & (SoulseekClientStates.Connected | SoulseekClientStates.LoggedIn)) != 0;
}
static readonly List<string> bannedTerms = new()
{
"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"
};
}