1
0
Fork 0
mirror of https://github.com/fiso64/slsk-batchdl.git synced 2024-12-22 22:42:41 +00:00

c to cancel album download

This commit is contained in:
fiso64 2024-12-22 15:10:42 +01:00
parent 4a34ec0cbd
commit 529f009c48
11 changed files with 216 additions and 80 deletions

View file

@ -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:

View file

@ -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");

View file

@ -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;

View file

@ -8,6 +8,7 @@ namespace Enums
OutOfDownloadRetries = 2,
NoSuitableFileFound = 3,
AllDownloadsFailed = 4,
Other = 5,
}
public enum TrackState

View file

@ -17,7 +17,7 @@ namespace Extractors
public async Task<TrackLists> 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");

View file

@ -16,7 +16,7 @@ namespace Extractors
public async Task<TrackLists> 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");

View file

@ -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))

View file

@ -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);

View file

@ -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<ConsoleKey>? 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<Track> 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<Track>? additionalImages = null;
@ -704,40 +763,9 @@ static partial class Program
}
static async Task RunAlbumDownloads(Config config, TrackListEntry tle, FileManager organizer, List<Track> tracks, SemaphoreSlim semaphore, CancellationTokenSource cts)
static void OnAlbumFail(List<Track>? 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<Track>? 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<Track>? 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);

View file

@ -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;

View file

@ -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;