From 529f009c4829d9f756134baa496746ad227c7bd8 Mon Sep 17 00:00:00 2001 From: fiso64 Date: Sun, 22 Dec 2024 15:10:42 +0100 Subject: [PATCH] c to cancel album download --- README.md | 33 +++- slsk-batchdl/Config.cs | 14 +- slsk-batchdl/Download.cs | 4 +- slsk-batchdl/Enums.cs | 1 + slsk-batchdl/Extractors/Csv.cs | 2 +- slsk-batchdl/Extractors/List.cs | 2 +- slsk-batchdl/Help.cs | 25 +++- slsk-batchdl/Models/DownloadWrapper.cs | 4 +- slsk-batchdl/Program.cs | 200 ++++++++++++++++++------- slsk-batchdl/Search.cs | 9 +- slsk-batchdl/Utilities/Utils.cs | 2 +- 11 files changed, 216 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 645f5c3..ecfd519 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ See the [usage examples](#examples-1). - [File conditions](#file-conditions) - [Name format](#name-format) - [Configuration](#configuration) + - [Shortcuts \& interactive mode](#shortcuts--interactive-mode) - [Examples](#examples-1) - [Notes](#notes) - [Docker](#docker) @@ -407,7 +408,7 @@ Variables enclosed in {} will be replaced by the corresponding file tag value. Name format supports subdirectories as well as conditional expressions like {tag1|tag2} - If tag1 is null, use tag2. String literals enclosed in parentheses are ignored in the null check. -### Examples: +### Examples - `{artist} - {title}` Always name it 'Artist - Title'. Because some files on Soulseek are untagged, the following is generally preferred: @@ -418,7 +419,7 @@ tag1 is null, use tag2. String literals enclosed in parentheses are ignored in t Sort files into artist/album folders if all tags are present, otherwise put them in the 'missing-tags' folder. -### Available variables: +### Available variables ``` artist First artist (from the file tags) sartist Source artist (as on CSV/Spotify/YouTube/etc) @@ -439,7 +440,7 @@ default-folder Default sldl folder name (usually the playlist n ``` ## Configuration -### Config Location: +### Config Location sldl will look for a file named sldl.conf in the following locations: ``` ~/AppData/Roaming/sldl/sldl.conf @@ -447,7 +448,7 @@ default-folder Default sldl folder name (usually the playlist n ``` as well as in the directory of the executable. -### Syntax: +### Syntax Example config file: ``` username = your-username @@ -458,7 +459,7 @@ fast-search = true Lines starting with hashtags (#) will be ignored. Tildes in paths are expanded as the user directory. The path variable `{bindir}` stores the directory of the sldl binary. -### Configuration profiles: +### Configuration profiles Profiles are supported: ``` [lossless] @@ -485,6 +486,28 @@ input-type ("youtube"|"csv"|"string"|"bandcamp"|"spotify") download-mode ("normal"|"aggregate"|"album"|"album-aggregate") interactive (bool) ``` + +## Shortcuts & interactive mode + +### Shortcuts +To cancel a running album download, press `C`. + +### Interactive mode +Interactive mode for albums can be enabled with `-t`/`--interactive`. It enables users to choose the desired folder or download specific files from it. + +Key bindings: +``` +Up/p previous folder +Down/n next folder +Enter/d download selected folder +q download folder and disable interactive mode +r retrieve all files in the folder +Esc/s skip current album + +d:1,2,3 download specific files +d:start:end download a range of files +``` + ## Examples Download tracks from a csv file: diff --git a/slsk-batchdl/Config.cs b/slsk-batchdl/Config.cs index 56d374a..7d82a51 100644 --- a/slsk-batchdl/Config.cs +++ b/slsk-batchdl/Config.cs @@ -202,7 +202,7 @@ public class Config if (confPath == "none") return; - confPath = Utils.ExpandUser(args[idx + 1]); + confPath = Utils.ExpandVariables(args[idx + 1]); if(File.Exists(Path.Join(AppDomain.CurrentDomain.BaseDirectory, confPath))) confPath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, confPath); } @@ -249,12 +249,12 @@ public class Config nameFormat = nameFormat.Trim(); - confPath = Utils.GetFullPath(Utils.ExpandUser(confPath)); - parentDir = Utils.GetFullPath(Utils.ExpandUser(parentDir)); - m3uFilePath = Utils.GetFullPath(Utils.ExpandUser(m3uFilePath)); - indexFilePath = Utils.GetFullPath(Utils.ExpandUser(indexFilePath)); - skipMusicDir = Utils.GetFullPath(Utils.ExpandUser(skipMusicDir)); - failedAlbumPath = Utils.GetFullPath(Utils.ExpandUser(failedAlbumPath)); + confPath = Utils.GetFullPath(Utils.ExpandVariables(confPath)); + parentDir = Utils.GetFullPath(Utils.ExpandVariables(parentDir)); + m3uFilePath = Utils.GetFullPath(Utils.ExpandVariables(m3uFilePath)); + indexFilePath = Utils.GetFullPath(Utils.ExpandVariables(indexFilePath)); + skipMusicDir = Utils.GetFullPath(Utils.ExpandVariables(skipMusicDir)); + failedAlbumPath = Utils.GetFullPath(Utils.ExpandVariables(failedAlbumPath)); if (failedAlbumPath.Length == 0) failedAlbumPath = Path.Join(parentDir, "failed"); diff --git a/slsk-batchdl/Download.cs b/slsk-batchdl/Download.cs index d6b664a..763df6e 100644 --- a/slsk-batchdl/Download.cs +++ b/slsk-batchdl/Download.cs @@ -11,7 +11,7 @@ using SearchResponse = Soulseek.SearchResponse; static class Download { - public static async Task DownloadFile(SearchResponse response, Soulseek.File file, string filePath, Track track, ProgressBar progress, Config config, CancellationToken? ct = null, CancellationTokenSource? searchCts = null) + public static async Task DownloadFile(SearchResponse response, Soulseek.File file, string filePath, Track track, ProgressBar progress, TrackListEntry tle, Config config, CancellationToken? ct = null, CancellationTokenSource? searchCts = null) { await Program.WaitForLogin(config); Directory.CreateDirectory(Path.GetDirectoryName(filePath)); @@ -40,7 +40,7 @@ static class Download new CancellationTokenSource(); using var outputStream = new FileStream(filePath, FileMode.Create); - var wrapper = new DownloadWrapper(origPath, response, file, track, downloadCts, progress); + var wrapper = new DownloadWrapper(origPath, response, file, track, downloadCts, progress, tle); downloads.TryAdd(file.Filename, wrapper); int maxRetries = 3; diff --git a/slsk-batchdl/Enums.cs b/slsk-batchdl/Enums.cs index ec722a9..ae10b7b 100644 --- a/slsk-batchdl/Enums.cs +++ b/slsk-batchdl/Enums.cs @@ -8,6 +8,7 @@ namespace Enums OutOfDownloadRetries = 2, NoSuitableFileFound = 3, AllDownloadsFailed = 4, + Other = 5, } public enum TrackState diff --git a/slsk-batchdl/Extractors/Csv.cs b/slsk-batchdl/Extractors/Csv.cs index 1223b44..4453848 100644 --- a/slsk-batchdl/Extractors/Csv.cs +++ b/slsk-batchdl/Extractors/Csv.cs @@ -17,7 +17,7 @@ namespace Extractors public async Task GetTracks(string input, int maxTracks, int offset, bool reverse, Config config) { - csvFilePath = Utils.ExpandUser(input); + csvFilePath = Utils.ExpandVariables(input); if (!File.Exists(csvFilePath)) throw new FileNotFoundException($"CSV file '{csvFilePath}' not found"); diff --git a/slsk-batchdl/Extractors/List.cs b/slsk-batchdl/Extractors/List.cs index b1c7a00..93ab004 100644 --- a/slsk-batchdl/Extractors/List.cs +++ b/slsk-batchdl/Extractors/List.cs @@ -16,7 +16,7 @@ namespace Extractors public async Task GetTracks(string input, int maxTracks, int offset, bool reverse, Config config) { - listFilePath = Utils.ExpandUser(input); + listFilePath = Utils.ExpandVariables(input); if (!File.Exists(listFilePath)) throw new FileNotFoundException($"List file '{listFilePath}' not found"); diff --git a/slsk-batchdl/Help.cs b/slsk-batchdl/Help.cs index 6b82da0..127d68d 100644 --- a/slsk-batchdl/Help.cs +++ b/slsk-batchdl/Help.cs @@ -183,7 +183,7 @@ public static class Help Help -h, --help [option] [all|input|download-modes|search|name-format| - file-conditions|config] + file-conditions|config|shortcuts] Notes Acronyms of two- and --three-word-flags are also accepted, e.g. --twf. If the option @@ -473,6 +473,28 @@ public static class Help interactive (bool) "; + const string shortcutsHelp = @" + Shortcuts & interactive mode + Shortcuts + To cancel a running album download, press `C`. + + Interactive mode + Interactive mode for albums can be enabled with `-t`/`--interactive`. It enables users + to choose the desired folder or download specific files from it. + + Key bindings: + + Up/p previous folder + Down/n next folder + Enter/d download selected folder + q download folder and disable interactive mode + r retrieve all files in the folder + Esc/s skip current album + + d:1,2,3 download specific files + d:start:end download a range of files + "; + public static void PrintHelp(string? option = null) { string text = helpText; @@ -485,6 +507,7 @@ public static class Help { "file-conditions", fileConditionsHelp }, { "name-format", nameFormatHelp }, { "config", configHelp }, + { "shortcuts", shortcutsHelp }, }; if (option != null && dict.ContainsKey(option)) diff --git a/slsk-batchdl/Models/DownloadWrapper.cs b/slsk-batchdl/Models/DownloadWrapper.cs index af9e50b..afb86b7 100644 --- a/slsk-batchdl/Models/DownloadWrapper.cs +++ b/slsk-batchdl/Models/DownloadWrapper.cs @@ -15,6 +15,7 @@ namespace Models public SearchResponse response; public ProgressBar progress; public Track track; + public TrackListEntry tle; public long bytesTransferred = 0; public bool stalled = false; public bool queued = false; @@ -29,12 +30,13 @@ namespace Models bool updatedTextSuccess = false; readonly char[] bars = { '|', '/', '—', '\\' }; - public DownloadWrapper(string savePath, SearchResponse response, Soulseek.File file, Track track, CancellationTokenSource cts, ProgressBar progress) + public DownloadWrapper(string savePath, SearchResponse response, Soulseek.File file, Track track, CancellationTokenSource cts, ProgressBar progress, TrackListEntry tle) { this.savePath = savePath; this.response = response; this.file = file; this.cts = cts; + this.tle = tle; this.track = track; this.progress = progress; this.displayText = Printing.DisplayString(track, file, response); diff --git a/slsk-batchdl/Program.cs b/slsk-batchdl/Program.cs index ab644f7..1ffcb62 100644 --- a/slsk-batchdl/Program.cs +++ b/slsk-batchdl/Program.cs @@ -22,6 +22,8 @@ static partial class Program const int updateInterval = 100; private static bool initialized = false; public static bool skipUpdate = false; + public static bool interceptKeys = false; + public static event EventHandler? keyPressed; public static IExtractor extractor = null!; public static TrackLists trackLists = null!; @@ -615,6 +617,15 @@ static partial class Program string? soulseekDir = null; int index = 0; + async Task runAlbumDownloads(List tracks, SemaphoreSlim semaphore, CancellationTokenSource cts) + { + var downloadTasks = tracks.Select(async track => + { + await DownloadTask(config, tle, track, semaphore, organizer, cts, true, true, true); + }); + await Task.WhenAll(downloadTasks); + } + while (tle.list.Count > 0 && !config.albumArtOnly) { bool wasInteractive = config.interactiveMode; @@ -641,12 +652,24 @@ static partial class Program PrintAlbum(tracks); } - var semaphore = new SemaphoreSlim(999); // Needs to be uncapped due to a bug that causes album downloads to fail after some time + using var semaphore = new SemaphoreSlim(999); // Needs to be uncapped due to a bug that causes album downloads to fail after some time using var cts = new CancellationTokenSource(); + bool userCancelled = false; + void onKeyPressed(object? sender, ConsoleKey key) + { + if (key == ConsoleKey.C) + { + userCancelled = true; + cts.Cancel(); + } + } + interceptKeys = true; + keyPressed += onKeyPressed; + try { - await RunAlbumDownloads(config, tle, organizer, tracks, semaphore, cts); + await runAlbumDownloads(tracks, semaphore, cts); if (!config.noBrowseFolder && retrieveCurrent && !retrievedFolders.Contains(soulseekDir)) { @@ -657,8 +680,8 @@ static partial class Program if (newFilesFound > 0) { - Console.WriteLine($"Found {newFilesFound} more files in the directory, downloading:"); - await RunAlbumDownloads(config, tle, organizer, tracks, semaphore, cts); + Console.WriteLine($"Found {newFilesFound} more files, downloading:"); + await runAlbumDownloads(tracks, semaphore, cts); } else { @@ -671,16 +694,52 @@ static partial class Program } catch (OperationCanceledException) { - OnAlbumFail(config, tracks); + if (userCancelled) + { + Console.Write("\nDownload cancelled."); + if (tracks.Any(t => t.State == TrackState.Downloaded && t.DownloadPath.Length > 0)) + { + Console.WriteLine("Delete files? [Y/n] (default: album fail action): "); + var res = Console.ReadLine().Trim().ToLower(); + if (res == "y") + OnAlbumFail(tracks, true, config); + else if (res == "" && !config.IgnoreAlbumFail) + OnAlbumFail(tracks, config.DeleteAlbumOnFail, config); + } + } + else + { + if (!config.IgnoreAlbumFail) + OnAlbumFail(tracks, config.DeleteAlbumOnFail, config); + } + } + finally + { + organizer.SetRemoteCommonDir(null); + tle.list.RemoveAt(index); + interceptKeys = false; + keyPressed -= onKeyPressed; } - - organizer.SetRemoteCommonDir(null); - tle.list.RemoveAt(index); } if (succeeded) { - await OnAlbumSuccess(config, tle, tracks); + tle.source.State = TrackState.Downloaded; + + var downloadedAudio = tracks.Where(t => !t.IsNotAudio && t.State == TrackState.Downloaded && t.DownloadPath.Length > 0); + if (downloadedAudio.Any()) + { + tle.source.DownloadPath = Utils.GreatestCommonDirectory(downloadedAudio.Select(t => t.DownloadPath)); + + if (config.removeTracksFromSource) + { + await extractor.RemoveTrackFromSource(tle.source); + } + } + } + else if (index != -1) + { + tle.source.State = TrackState.Failed; } List? additionalImages = null; @@ -704,40 +763,9 @@ static partial class Program } - static async Task RunAlbumDownloads(Config config, TrackListEntry tle, FileManager organizer, List tracks, SemaphoreSlim semaphore, CancellationTokenSource cts) + static void OnAlbumFail(List? tracks, bool deleteDownloaded, Config config) { - var downloadTasks = tracks.Select(async track => - { - await DownloadTask(config, tle, track, semaphore, organizer, cts, true, true, true); - }); - await Task.WhenAll(downloadTasks); - } - - - static async Task OnAlbumSuccess(Config config, TrackListEntry tle, List? tracks) - { - if (tracks == null) - return; - - var downloadedAudio = tracks.Where(t => !t.IsNotAudio && t.State == TrackState.Downloaded && t.DownloadPath.Length > 0); - - if (downloadedAudio.Any()) - { - tle.source.State = TrackState.Downloaded; - tle.source.DownloadPath = Utils.GreatestCommonDirectory(downloadedAudio.Select(t => t.DownloadPath)); - - if (config.removeTracksFromSource) - { - await extractor.RemoveTrackFromSource(tle.source); - } - } - } - - - static void OnAlbumFail(Config config, List? tracks) - { - if (tracks == null || config.IgnoreAlbumFail) - return; + if (tracks == null) return; foreach (var track in tracks) { @@ -745,7 +773,7 @@ static partial class Program { try { - if (config.DeleteAlbumOnFail) + if (deleteDownloaded || track.DownloadPath.EndsWith(".incomplete")) { File.Delete(track.DownloadPath); } @@ -881,17 +909,54 @@ static partial class Program fileManager.SetRemoteCommonDir(Utils.GreatestCommonDirectorySlsk(tracks.Select(t => t.FirstDownload.Filename))); bool allSucceeded = true; - var semaphore = new SemaphoreSlim(1); + using var semaphore = new SemaphoreSlim(1); + using var cts = new CancellationTokenSource(); - foreach (var track in tracks) + bool userCancelled = false; + void onKeyPressed(object? sender, ConsoleKey key) { - using var cts = new CancellationTokenSource(); - await DownloadTask(config, tle, track, semaphore, fileManager, cts, false, false, false); + if (key == ConsoleKey.C) + { + userCancelled = true; + cts.Cancel(); + } + } + interceptKeys = true; + keyPressed += onKeyPressed; - if (track.State == TrackState.Downloaded) - downloadedImages.Add(track); + try + { + foreach (var track in tracks) + { + await DownloadTask(config, tle, track, semaphore, fileManager, cts, false, false, false); + + if (track.State == TrackState.Downloaded) + downloadedImages.Add(track); + else + allSucceeded = false; + } + } + catch (OperationCanceledException) + { + if (userCancelled) + { + Console.Write("\nDownload cancelled."); + if (tracks.Any(t => t.State == TrackState.Downloaded && t.DownloadPath.Length > 0)) + { + Console.WriteLine("Delete files? [Y/n]: "); + if (Console.ReadLine().Trim().ToLower() == "y") + OnAlbumFail(tracks, true, config); + } + } else - allSucceeded = false; + { + throw; + } + } + finally + { + interceptKeys = false; + keyPressed -= onKeyPressed; } if (allSucceeded) @@ -902,7 +967,7 @@ static partial class Program } - static async Task DownloadTask(Config config, TrackListEntry? tle, Track track, SemaphoreSlim semaphore, FileManager organizer, CancellationTokenSource? cts, bool cancelOnFail, bool removeFromSource, bool organize) + static async Task DownloadTask(Config config, TrackListEntry tle, Track track, SemaphoreSlim semaphore, FileManager organizer, CancellationTokenSource cts, bool cancelOnFail, bool removeFromSource, bool organize) { if (track.State != TrackState.Initial) return; @@ -921,7 +986,7 @@ static partial class Program try { - (savedFilePath, chosenFile) = await Search.SearchAndDownload(track, organizer, config, cts); + (savedFilePath, chosenFile) = await Search.SearchAndDownload(track, organizer, tle, config, cts); } catch (Exception ex) { @@ -944,6 +1009,15 @@ static partial class Program throw new OperationCanceledException(); } } + else if (ex is OperationCanceledException && cts.IsCancellationRequested) + { + lock (trackLists) + { + track.State = TrackState.Failed; + track.FailureReason = FailureReason.Other; + } + throw; + } else { tries--; @@ -956,6 +1030,12 @@ static partial class Program if (tries == 0 && cancelOnFail) { + lock (trackLists) + { + track.State = TrackState.Failed; + track.FailureReason = FailureReason.Other; + } + cts.Cancel(); throw new OperationCanceledException(); } @@ -1027,8 +1107,6 @@ static partial class Program WriteLine($" [Up/p] | [Down/n] | [Enter] | [q] {retrieveAll1}| [Esc/s]", ConsoleColor.Green); WriteLine($" Prev | Next | Accept | Accept & Quit Interactive {retrieveAll2}| Skip", ConsoleColor.Green); Console.WriteLine(); - WriteLine($" d:1,2,3 or d:start:end to download individual files", ConsoleColor.Green); - Console.WriteLine(); } writeHelp(); @@ -1123,7 +1201,7 @@ static partial class Program } - static async Task Update(Config config) + static async Task Update(Config startConfig) { while (true) { @@ -1145,7 +1223,7 @@ static partial class Program { lock (val) { - if ((DateTime.Now - val.UpdateLastChangeTime()).TotalMilliseconds > config.maxStaleTime) + if ((DateTime.Now - val.UpdateLastChangeTime()).TotalMilliseconds > val.tle.config.maxStaleTime) { val.stalled = true; val.UpdateText(); @@ -1172,10 +1250,10 @@ static partial class Program && !client.State.HasFlag(SoulseekClientStates.Connecting)) { WriteLine($"\nDisconnected, logging in\n", ConsoleColor.DarkYellow, true); - try { await Login(config, config.useRandomLogin); } + try { await Login(startConfig, startConfig.useRandomLogin); } catch (Exception ex) { - string banMsg = config.useRandomLogin ? "" : " (possibly a 30-minute ban caused by frequent searches)"; + string banMsg = startConfig.useRandomLogin ? "" : " (possibly a 30-minute ban caused by frequent searches)"; WriteLine($"{ex.Message}{banMsg}", ConsoleColor.DarkYellow, true); } } @@ -1193,6 +1271,12 @@ static partial class Program { WriteLine($"\n{ex.Message}\n", ConsoleColor.DarkYellow, true); } + + if (interceptKeys && Console.KeyAvailable) + { + var key = Console.ReadKey(intercept: true).Key; + keyPressed?.Invoke(null, key); + } } await Task.Delay(updateInterval); diff --git a/slsk-batchdl/Search.cs b/slsk-batchdl/Search.cs index e670ccc..6c2bc2b 100644 --- a/slsk-batchdl/Search.cs +++ b/slsk-batchdl/Search.cs @@ -16,7 +16,7 @@ static class Search public static RateLimitedSemaphore? searchSemaphore; // very messy function that does everything - public static async Task<(string, SlFile?)> SearchAndDownload(Track track, FileManager organizer, Config config, CancellationTokenSource? cts = null) + public static async Task<(string, SlFile?)> SearchAndDownload(Track track, FileManager organizer, TrackListEntry tle, Config config, CancellationTokenSource? cts = null) { if (config.DoNotDownload) throw new Exception(); @@ -58,7 +58,7 @@ static class Search saveFilePath = organizer.GetSavePath(f.Filename); fsUser = r.Username; chosenFile = f; - downloadTask = Download.DownloadFile(r, f, saveFilePath, track, progress, config, cts?.Token, searchCts); + downloadTask = Download.DownloadFile(r, f, saveFilePath, track, progress, tle, config, cts?.Token, searchCts); } } } @@ -155,12 +155,15 @@ static class Search try { downloading = 1; - await Download.DownloadFile(response, file, saveFilePath, track, progress, config, cts?.Token); + await Download.DownloadFile(response, file, saveFilePath, track, progress, tle, config, cts?.Token); userSuccessCounts.AddOrUpdate(response.Username, 1, (k, v) => v + 1); return true; } catch (Exception e) { + if (e is OperationCanceledException && cts != null && cts.IsCancellationRequested) + throw; + Printing.WriteLineIf($"Error: Download Error: {e}", config.debugInfo, ConsoleColor.DarkYellow); chosenFile = null; diff --git a/slsk-batchdl/Utilities/Utils.cs b/slsk-batchdl/Utilities/Utils.cs index 092e636..e533539 100644 --- a/slsk-batchdl/Utilities/Utils.cs +++ b/slsk-batchdl/Utilities/Utils.cs @@ -101,7 +101,7 @@ public static class Utils return Path.GetDirectoryName(fname); } - public static string ExpandUser(string path) + public static string ExpandVariables(string path) { if (string.IsNullOrWhiteSpace(path)) return path;