mirror of
https://github.com/fiso64/slsk-batchdl.git
synced 2024-12-22 22:42:41 +00:00
commit
This commit is contained in:
parent
4e3ed2ec46
commit
82591b6569
15 changed files with 1428 additions and 1282 deletions
32
README.md
32
README.md
|
@ -191,7 +191,6 @@ Usage: sldl <input> [OPTIONS]
|
||||||
'default': No additional images
|
'default': No additional images
|
||||||
'largest': Download from the folder with the largest image
|
'largest': Download from the folder with the largest image
|
||||||
'most': Download from the folder containing the most images
|
'most': Download from the folder containing the most images
|
||||||
'most-largest': Do most, then largest
|
|
||||||
--album-art-only Only download album art for the provided album
|
--album-art-only Only download album art for the provided album
|
||||||
--no-browse-folder Do not automatically browse user shares to get all files in
|
--no-browse-folder Do not automatically browse user shares to get all files in
|
||||||
in the folder
|
in the folder
|
||||||
|
@ -250,7 +249,7 @@ The id and secret can be obtained at https://developer.spotify.com/dashboard/app
|
||||||
Create an app and add http://localhost:48721/callback as a redirect url in its settings.
|
Create an app and add http://localhost:48721/callback as a redirect url in its settings.
|
||||||
|
|
||||||
### Bandcamp
|
### Bandcamp
|
||||||
A bandcamp url: Download a single track, and album, or an artist's entire discography.
|
A bandcamp url: Download a single track, an album, or an artist's entire discography.
|
||||||
Extracts the artist name, album name and sets --album-track-count="n+", where n is the
|
Extracts the artist name, album name and sets --album-track-count="n+", where n is the
|
||||||
number of visible tracks on the bandcamp page.
|
number of visible tracks on the bandcamp page.
|
||||||
|
|
||||||
|
@ -296,8 +295,7 @@ Two files are considered equal if their inferred track title and artist name are
|
||||||
(ignoring case and some special characters), and their lengths are within --length-tol of each
|
(ignoring case and some special characters), and their lengths are within --length-tol of each
|
||||||
other.
|
other.
|
||||||
Note that this mode is not 100% reliable, which is why --min-shares-aggregate is set to 2 by
|
Note that this mode is not 100% reliable, which is why --min-shares-aggregate is set to 2 by
|
||||||
default, i.e. any song that is shared only once will be ignored. Enable --relax-filtering to
|
default, i.e. any song that is shared only once will be ignored.
|
||||||
make the file filtering less aggressive.
|
|
||||||
|
|
||||||
### Album Aggregate
|
### Album Aggregate
|
||||||
Activated when --album and --aggregate are enabled, in this mode sldl searches for the query
|
Activated when --album and --aggregate are enabled, in this mode sldl searches for the query
|
||||||
|
@ -356,14 +354,14 @@ files if available, and only download lossy files if there's nothing else.
|
||||||
|
|
||||||
There are no default required conditions. The default preferred conditions are:
|
There are no default required conditions. The default preferred conditions are:
|
||||||
```
|
```
|
||||||
format = mp3
|
pref-format = mp3
|
||||||
length-tol = 3
|
pref-length-tol = 3
|
||||||
min-bitrate = 200
|
pref-min-bitrate = 200
|
||||||
max-bitrate = 2500
|
pref-max-bitrate = 2500
|
||||||
max-samplerate = 48000
|
pref-max-samplerate = 48000
|
||||||
strict-title = true
|
pref-strict-title = true
|
||||||
strict-album = true
|
pref-strict-album = true
|
||||||
accept-no-length = false
|
pref-accept-no-length = false
|
||||||
```
|
```
|
||||||
sldl will therefore prefer mp3 files with bitrate between 200 and 2500 kbps, and whose length
|
sldl will therefore prefer mp3 files with bitrate between 200 and 2500 kbps, and whose length
|
||||||
differs from the supplied length by no more than 3 seconds. It will also prefer files whose
|
differs from the supplied length by no more than 3 seconds. It will also prefer files whose
|
||||||
|
@ -419,7 +417,7 @@ year Track year or date
|
||||||
track Track number
|
track Track number
|
||||||
disc Disc number
|
disc Disc number
|
||||||
filename Soulseek filename without extension
|
filename Soulseek filename without extension
|
||||||
foldername Soulseek folder name (only available for album downloads)
|
foldername Soulseek folder name
|
||||||
default-foldername Default sldl folder name
|
default-foldername Default sldl folder name
|
||||||
extractor Name of the extractor used (CSV/Spotify/YouTube/etc)
|
extractor Name of the extractor used (CSV/Spotify/YouTube/etc)
|
||||||
```
|
```
|
||||||
|
@ -494,11 +492,11 @@ profile-cond = input-type == "youtube"
|
||||||
path = ~/downloads/sldl-youtube
|
path = ~/downloads/sldl-youtube
|
||||||
# download to another location for youtube
|
# download to another location for youtube
|
||||||
```
|
```
|
||||||
The following operators are supported: &&, ||, ==, !=, ! (negation for bools).
|
The following operators are supported for use in profile-cond: &&, ||, ==, !=, !{bool}.
|
||||||
The following variables are available for use in profile-cond:
|
The following variables are available:
|
||||||
```
|
```
|
||||||
input-type ( = "youtube"|"csv"|"string"|"bandcamp"|"spotify")
|
input-type ("youtube"|"csv"|"string"|"bandcamp"|"spotify")
|
||||||
download-mode ( = "normal"|"aggregate"|"album"|"album-aggregate")
|
download-mode ("normal"|"aggregate"|"album"|"album-aggregate")
|
||||||
interactive (bool)
|
interactive (bool)
|
||||||
```
|
```
|
||||||
## Examples
|
## Examples
|
||||||
|
|
|
@ -236,6 +236,9 @@ static class Config
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (albumArtOnly && albumArtOption == AlbumArtOption.Default)
|
||||||
|
albumArtOption = AlbumArtOption.Largest;
|
||||||
|
|
||||||
parentFolder = Utils.ExpandUser(parentFolder);
|
parentFolder = Utils.ExpandUser(parentFolder);
|
||||||
m3uFilePath = Utils.ExpandUser(m3uFilePath);
|
m3uFilePath = Utils.ExpandUser(m3uFilePath);
|
||||||
musicDir = Utils.ExpandUser(musicDir);
|
musicDir = Utils.ExpandUser(musicDir);
|
||||||
|
@ -250,6 +253,7 @@ static class Config
|
||||||
folderName = folderName.Replace('/', Path.DirectorySeparatorChar);
|
folderName = folderName.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
outputFolder = Path.Join(parentFolder, folderName);
|
outputFolder = Path.Join(parentFolder, folderName);
|
||||||
|
nameFormat = nameFormat.Trim();
|
||||||
|
|
||||||
if (m3uFilePath.Length == 0)
|
if (m3uFilePath.Length == 0)
|
||||||
m3uFilePath = Path.Join(outputFolder, (folderName.Length == 0 ? "playlist" : folderName) + ".m3u8");
|
m3uFilePath = Path.Join(outputFolder, (folderName.Length == 0 ? "playlist" : folderName) + ".m3u8");
|
||||||
|
@ -319,7 +323,7 @@ static class Config
|
||||||
if (newProfiles.Count > 0)
|
if (newProfiles.Count > 0)
|
||||||
{
|
{
|
||||||
//appliedProfiles.Clear();
|
//appliedProfiles.Clear();
|
||||||
appliedProfiles.Union(newProfiles);
|
appliedProfiles.UnionWith(newProfiles);
|
||||||
ApplyProfile(profile);
|
ApplyProfile(profile);
|
||||||
ProcessArgs(arguments);
|
ProcessArgs(arguments);
|
||||||
PostProcessArgs();
|
PostProcessArgs();
|
||||||
|
@ -551,6 +555,7 @@ static class Config
|
||||||
cond.AcceptNoLength = bool.Parse(value);
|
cond.AcceptNoLength = bool.Parse(value);
|
||||||
break;
|
break;
|
||||||
case "strict":
|
case "strict":
|
||||||
|
case "strictconditions":
|
||||||
case "acceptmissing":
|
case "acceptmissing":
|
||||||
case "acceptmissingprops":
|
case "acceptmissingprops":
|
||||||
cond.AcceptMissingProps = bool.Parse(value);
|
cond.AcceptMissingProps = bool.Parse(value);
|
||||||
|
@ -908,7 +913,6 @@ static class Config
|
||||||
"default" => AlbumArtOption.Default,
|
"default" => AlbumArtOption.Default,
|
||||||
"largest" => AlbumArtOption.Largest,
|
"largest" => AlbumArtOption.Largest,
|
||||||
"most" => AlbumArtOption.Most,
|
"most" => AlbumArtOption.Most,
|
||||||
"most-largest" => AlbumArtOption.MostLargest,
|
|
||||||
_ => throw new ArgumentException($"Invalid album art download mode '{args[i]}'"),
|
_ => throw new ArgumentException($"Invalid album art download mode '{args[i]}'"),
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
@ -977,6 +981,10 @@ static class Config
|
||||||
case "--pref-strict-album":
|
case "--pref-strict-album":
|
||||||
setFlag(ref preferredCond.StrictAlbum, ref i);
|
setFlag(ref preferredCond.StrictAlbum, ref i);
|
||||||
break;
|
break;
|
||||||
|
case "--panl":
|
||||||
|
case "--pref-accept-no-length":
|
||||||
|
setFlag(ref preferredCond.AcceptNoLength, ref i);
|
||||||
|
break;
|
||||||
case "--pbu":
|
case "--pbu":
|
||||||
case "--pref-banned-users":
|
case "--pref-banned-users":
|
||||||
preferredCond.BannedUsers = args[++i].Split(',');
|
preferredCond.BannedUsers = args[++i].Split(',');
|
||||||
|
@ -1031,6 +1039,10 @@ static class Config
|
||||||
case "--banned-users":
|
case "--banned-users":
|
||||||
necessaryCond.BannedUsers = args[++i].Split(',');
|
necessaryCond.BannedUsers = args[++i].Split(',');
|
||||||
break;
|
break;
|
||||||
|
case "--anl":
|
||||||
|
case "--accept-no-length":
|
||||||
|
setFlag(ref necessaryCond.AcceptNoLength, ref i);
|
||||||
|
break;
|
||||||
case "--c":
|
case "--c":
|
||||||
case "--cond":
|
case "--cond":
|
||||||
case "--conditions":
|
case "--conditions":
|
||||||
|
|
|
@ -25,6 +25,7 @@ namespace Data
|
||||||
|
|
||||||
public bool OutputsDirectory => Type != TrackType.Normal;
|
public bool OutputsDirectory => Type != TrackType.Normal;
|
||||||
public Soulseek.File? FirstDownload => Downloads?.FirstOrDefault().Item2;
|
public Soulseek.File? FirstDownload => Downloads?.FirstOrDefault().Item2;
|
||||||
|
public SearchResponse? FirstResponse => Downloads?.FirstOrDefault().Item1;
|
||||||
public string? FirstUsername => Downloads?.FirstOrDefault().Item1?.Username;
|
public string? FirstUsername => Downloads?.FirstOrDefault().Item1?.Username;
|
||||||
|
|
||||||
public Track() { }
|
public Track() { }
|
||||||
|
@ -106,7 +107,6 @@ namespace Data
|
||||||
public bool needSkipExistingAfterSearch = false;
|
public bool needSkipExistingAfterSearch = false;
|
||||||
public bool gotoNextAfterSearch = false;
|
public bool gotoNextAfterSearch = false;
|
||||||
public bool placeInSubdir = false;
|
public bool placeInSubdir = false;
|
||||||
public bool useRemoteDirname = false;
|
|
||||||
|
|
||||||
public TrackListEntry()
|
public TrackListEntry()
|
||||||
{
|
{
|
||||||
|
@ -141,14 +141,13 @@ namespace Data
|
||||||
}
|
}
|
||||||
|
|
||||||
public TrackListEntry(List<List<Track>> list, Track source, bool needSearch, bool placeInSubdir,
|
public TrackListEntry(List<List<Track>> list, Track source, bool needSearch, bool placeInSubdir,
|
||||||
bool useRemoteDirname, bool canBeSkipped, bool needSkipExistingAfterSearch, bool gotoNextAfterSearch)
|
bool sourceCanBeSkipped, bool needSkipExistingAfterSearch, bool gotoNextAfterSearch)
|
||||||
{
|
{
|
||||||
this.list = list;
|
this.list = list;
|
||||||
this.source = source;
|
this.source = source;
|
||||||
this.needSourceSearch = needSearch;
|
this.needSourceSearch = needSearch;
|
||||||
this.placeInSubdir = placeInSubdir;
|
this.placeInSubdir = placeInSubdir;
|
||||||
this.useRemoteDirname = useRemoteDirname;
|
this.sourceCanBeSkipped = sourceCanBeSkipped;
|
||||||
this.sourceCanBeSkipped = canBeSkipped;
|
|
||||||
this.needSkipExistingAfterSearch = needSkipExistingAfterSearch;
|
this.needSkipExistingAfterSearch = needSkipExistingAfterSearch;
|
||||||
this.gotoNextAfterSearch = gotoNextAfterSearch;
|
this.gotoNextAfterSearch = gotoNextAfterSearch;
|
||||||
}
|
}
|
||||||
|
|
221
slsk-batchdl/Download.cs
Normal file
221
slsk-batchdl/Download.cs
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
using Soulseek;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
using Data;
|
||||||
|
using Enums;
|
||||||
|
using static Program;
|
||||||
|
|
||||||
|
using File = System.IO.File;
|
||||||
|
using Directory = System.IO.Directory;
|
||||||
|
using ProgressBar = Konsole.ProgressBar;
|
||||||
|
using SearchResponse = Soulseek.SearchResponse;
|
||||||
|
using SlResponse = Soulseek.SearchResponse;
|
||||||
|
using SlFile = Soulseek.File;
|
||||||
|
using SlDictionary = System.Collections.Concurrent.ConcurrentDictionary<string, (Soulseek.SearchResponse, Soulseek.File)>;
|
||||||
|
|
||||||
|
|
||||||
|
static class Download
|
||||||
|
{
|
||||||
|
public static async Task DownloadFile(SearchResponse response, Soulseek.File file, string filePath, Track track, ProgressBar progress, CancellationTokenSource? searchCts = null)
|
||||||
|
{
|
||||||
|
if (Config.DoNotDownload)
|
||||||
|
throw new Exception();
|
||||||
|
|
||||||
|
await Program.WaitForLogin();
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(filePath));
|
||||||
|
string origPath = filePath;
|
||||||
|
filePath += ".incomplete";
|
||||||
|
|
||||||
|
var transferOptions = new TransferOptions(
|
||||||
|
stateChanged: (state) =>
|
||||||
|
{
|
||||||
|
if (Program.downloads.TryGetValue(file.Filename, out var x))
|
||||||
|
x.transfer = state.Transfer;
|
||||||
|
},
|
||||||
|
progressUpdated: (progress) =>
|
||||||
|
{
|
||||||
|
if (downloads.TryGetValue(file.Filename, out var x))
|
||||||
|
x.bytesTransferred = progress.PreviousBytesTransferred;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
using var outputStream = new FileStream(filePath, FileMode.Create);
|
||||||
|
var wrapper = new DownloadWrapper(origPath, response, file, track, cts, progress);
|
||||||
|
downloads.TryAdd(file.Filename, wrapper);
|
||||||
|
|
||||||
|
// Attempt to make it resume downloads after a network interruption.
|
||||||
|
// Does not work: The resumed download will be queued until it goes stale.
|
||||||
|
// The host (slskd) reports that "Another upload to {user} is already in progress"
|
||||||
|
// when attempting to resume. Must wait until timeout, which can take minutes.
|
||||||
|
|
||||||
|
int maxRetries = 3;
|
||||||
|
int retryCount = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.DownloadAsync(response.Username, file.Filename,
|
||||||
|
() => Task.FromResult((Stream)outputStream),
|
||||||
|
file.Size, startOffset: outputStream.Position,
|
||||||
|
options: transferOptions, cancellationToken: cts.Token);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (SoulseekClientException)
|
||||||
|
{
|
||||||
|
retryCount++;
|
||||||
|
|
||||||
|
if (retryCount >= maxRetries || IsConnectedAndLoggedIn())
|
||||||
|
throw;
|
||||||
|
|
||||||
|
await WaitForLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
try { File.Delete(filePath); } catch { }
|
||||||
|
downloads.TryRemove(file.Filename, out var d);
|
||||||
|
if (d != null)
|
||||||
|
lock (d) { d.UpdateText(); }
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
try { searchCts?.Cancel(); }
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
try { Utils.Move(filePath, origPath); }
|
||||||
|
catch (IOException) { Printing.WriteLine($"Failed to rename .incomplete file", ConsoleColor.DarkYellow, true); }
|
||||||
|
|
||||||
|
downloads.TryRemove(file.Filename, out var x);
|
||||||
|
if (x != null)
|
||||||
|
{
|
||||||
|
lock (x)
|
||||||
|
{
|
||||||
|
x.success = true;
|
||||||
|
x.UpdateText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public 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;
|
||||||
|
|
||||||
|
TransferStates? prevTransferState = null;
|
||||||
|
long prevBytesTransferred = 0;
|
||||||
|
bool updatedTextDownload = false;
|
||||||
|
bool updatedTextSuccess = false;
|
||||||
|
readonly char[] bars = { '|', '/', '—', '\\' };
|
||||||
|
|
||||||
|
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 = Printing.DisplayString(track, file, response);
|
||||||
|
|
||||||
|
Printing.RefreshOrPrint(progress, 0, "Initialize: " + displayText, true);
|
||||||
|
Printing.RefreshOrPrint(progress, 0, displayText, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateText()
|
||||||
|
{
|
||||||
|
downloadRotatingBarState++;
|
||||||
|
downloadRotatingBarState %= bars.Length;
|
||||||
|
float? percentage = bytesTransferred / (float)file.Size;
|
||||||
|
queued = (transfer?.State & TransferStates.Queued) != 0;
|
||||||
|
string bar;
|
||||||
|
string state;
|
||||||
|
bool downloading = false;
|
||||||
|
|
||||||
|
if (stalled)
|
||||||
|
{
|
||||||
|
state = "Stalled";
|
||||||
|
bar = "";
|
||||||
|
}
|
||||||
|
else if (transfer != null)
|
||||||
|
{
|
||||||
|
if (queued)
|
||||||
|
state = "Queued";
|
||||||
|
else if ((transfer.State & TransferStates.Initializing) != 0)
|
||||||
|
state = "Initialize";
|
||||||
|
else if ((transfer.State & TransferStates.Completed) != 0)
|
||||||
|
{
|
||||||
|
var flag = transfer.State & (TransferStates.Succeeded | TransferStates.Cancelled
|
||||||
|
| TransferStates.TimedOut | TransferStates.Errored | TransferStates.Rejected
|
||||||
|
| TransferStates.Aborted);
|
||||||
|
state = flag.ToString();
|
||||||
|
|
||||||
|
if (flag == TransferStates.Succeeded)
|
||||||
|
success = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
state = transfer.State.ToString();
|
||||||
|
if ((transfer.State & TransferStates.InProgress) != 0)
|
||||||
|
downloading = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bar = success ? "" : bars[downloadRotatingBarState] + " ";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
state = "NullState";
|
||||||
|
bar = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
string txt = $"{bar}{state}:".PadRight(14) + $" {displayText}";
|
||||||
|
bool needSimplePrintUpdate = (downloading && !updatedTextDownload) || (success && !updatedTextSuccess);
|
||||||
|
updatedTextDownload |= downloading;
|
||||||
|
updatedTextSuccess |= success;
|
||||||
|
|
||||||
|
Console.ResetColor();
|
||||||
|
Printing.RefreshOrPrint(progress, (int)((percentage ?? 0) * 100), txt, needSimplePrintUpdate, needSimplePrintUpdate);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTime UpdateLastChangeTime(bool updateAllFromThisUser = true, bool forceChanged = false)
|
||||||
|
{
|
||||||
|
bool changed = prevTransferState != transfer?.State || prevBytesTransferred != bytesTransferred;
|
||||||
|
if (changed || forceChanged)
|
||||||
|
{
|
||||||
|
lastChangeTime = DateTime.Now;
|
||||||
|
stalled = false;
|
||||||
|
if (updateAllFromThisUser)
|
||||||
|
{
|
||||||
|
foreach (var (_, dl) in downloads)
|
||||||
|
{
|
||||||
|
if (dl != this && dl.response.Username == response.Username)
|
||||||
|
dl.UpdateLastChangeTime(updateAllFromThisUser: false, forceChanged: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevTransferState = transfer?.State;
|
||||||
|
prevBytesTransferred = bytesTransferred;
|
||||||
|
return lastChangeTime;
|
||||||
|
}
|
||||||
|
}
|
|
@ -68,7 +68,6 @@ namespace Enums
|
||||||
Default,
|
Default,
|
||||||
Most,
|
Most,
|
||||||
Largest,
|
Largest,
|
||||||
MostLargest,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum DisplayMode
|
public enum DisplayMode
|
||||||
|
|
|
@ -166,7 +166,7 @@ namespace Extractors
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
Program.WriteLine($"Couldn't parse track length \"{values[lengthIndex]}\" with format \"{timeUnit}\" for \"{track}\"", ConsoleColor.DarkYellow);
|
Printing.WriteLine($"Couldn't parse track length \"{values[lengthIndex]}\" with format \"{timeUnit}\" for \"{track}\"", ConsoleColor.DarkYellow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
271
slsk-batchdl/FileManager.cs
Normal file
271
slsk-batchdl/FileManager.cs
Normal file
|
@ -0,0 +1,271 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
using Data;
|
||||||
|
using Enums;
|
||||||
|
|
||||||
|
|
||||||
|
public class FileManager
|
||||||
|
{
|
||||||
|
readonly TrackListEntry? tle;
|
||||||
|
readonly HashSet<Track> organized = new();
|
||||||
|
string? remoteCommonDir;
|
||||||
|
|
||||||
|
public FileManager() { }
|
||||||
|
|
||||||
|
public FileManager(TrackListEntry tle)
|
||||||
|
{
|
||||||
|
this.tle = tle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetSavePath(string sourceFname)
|
||||||
|
{
|
||||||
|
return $"{GetSavePathNoExt(sourceFname)}{Path.GetExtension(sourceFname)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetSavePathNoExt(string sourceFname)
|
||||||
|
{
|
||||||
|
string name = Utils.GetFileNameWithoutExtSlsk(sourceFname);
|
||||||
|
name = name.ReplaceInvalidChars(Config.invalidReplaceStr);
|
||||||
|
|
||||||
|
string parent = Config.outputFolder;
|
||||||
|
|
||||||
|
if (tle != null && tle.placeInSubdir && remoteCommonDir != null)
|
||||||
|
{
|
||||||
|
string dirname = Path.GetFileName(remoteCommonDir);
|
||||||
|
string relpath = Path.GetRelativePath(remoteCommonDir, Utils.NormalizedPath(sourceFname));
|
||||||
|
parent = Path.Join(parent, dirname, Path.GetDirectoryName(relpath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Join(parent, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetRemoteCommonDir(string? remoteCommonDir)
|
||||||
|
{
|
||||||
|
this.remoteCommonDir = remoteCommonDir != null ? Utils.NormalizedPath(remoteCommonDir) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OrganizeAlbum(List<Track> tracks, List<Track>? additionalImages, bool remainingOnly = true)
|
||||||
|
{
|
||||||
|
if (tle == null)
|
||||||
|
throw new NullReferenceException("TrackListEntry should not be null.");
|
||||||
|
|
||||||
|
string outputFolder = Config.outputFolder;
|
||||||
|
|
||||||
|
foreach (var track in tracks.Where(t => !t.IsNotAudio))
|
||||||
|
{
|
||||||
|
if (remainingOnly && organized.Contains(track))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
OrganizeAudio(tle, track, track.FirstDownload);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool onlyAdditionalImages = Config.nameFormat.Length == 0;
|
||||||
|
|
||||||
|
var nonAudioToOrganize = onlyAdditionalImages ? additionalImages : tracks.Where(t => t.IsNotAudio);
|
||||||
|
|
||||||
|
if (nonAudioToOrganize == null || !nonAudioToOrganize.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
string parent = Utils.GreatestCommonDirectory(
|
||||||
|
tracks.Where(t => !t.IsNotAudio && t.State == TrackState.Downloaded && t.DownloadPath.Length > 0).Select(t => t.DownloadPath));
|
||||||
|
|
||||||
|
foreach (var track in nonAudioToOrganize)
|
||||||
|
{
|
||||||
|
if (remainingOnly && organized.Contains(track))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
OrganizeNonAudio(track, parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OrganizeAudio(TrackListEntry tle, Track track, Soulseek.File? file)
|
||||||
|
{
|
||||||
|
if (track.DownloadPath.Length == 0 || !Utils.IsMusicFile(track.DownloadPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (Config.nameFormat.Length == 0)
|
||||||
|
{
|
||||||
|
organized.Add(track);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string pathPart = SubstituteValues(Config.nameFormat, track, file);
|
||||||
|
string newFilePath = Path.Join(Config.outputFolder, pathPart + Path.GetExtension(track.DownloadPath));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MoveAndDeleteParent(track.DownloadPath, newFilePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Printing.WriteLine($"\nFailed to move: {ex.Message}\n", ConsoleColor.DarkYellow, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
track.DownloadPath = newFilePath;
|
||||||
|
|
||||||
|
organized.Add(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OrganizeNonAudio(Track track, string parent)
|
||||||
|
{
|
||||||
|
if (track.DownloadPath.Length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string newFilePath = Path.Join(parent, Path.GetFileName(track.DownloadPath));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MoveAndDeleteParent(track.DownloadPath, newFilePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Printing.WriteLine($"\nFailed to move: {ex.Message}\n", ConsoleColor.DarkYellow, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
track.DownloadPath = newFilePath;
|
||||||
|
|
||||||
|
organized.Add(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void MoveAndDeleteParent(string oldPath, string newPath)
|
||||||
|
{
|
||||||
|
if (Utils.NormalizedPath(oldPath) != Utils.NormalizedPath(newPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(newPath));
|
||||||
|
Utils.Move(oldPath, newPath);
|
||||||
|
|
||||||
|
string x = Utils.NormalizedPath(Path.GetFullPath(Config.outputFolder));
|
||||||
|
string y = Utils.NormalizedPath(Path.GetDirectoryName(oldPath));
|
||||||
|
|
||||||
|
while (x.Length > 0 && y.StartsWith(x + '/') && Utils.FileCountRecursive(y) == 0)
|
||||||
|
{
|
||||||
|
Directory.Delete(y, true); // hopefully this is fine
|
||||||
|
y = Utils.NormalizedPath(Path.GetDirectoryName(y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string SubstituteValues(string format, Track track, Soulseek.File? slfile)
|
||||||
|
{
|
||||||
|
string newName = format;
|
||||||
|
TagLib.File? file = null;
|
||||||
|
|
||||||
|
try { file = TagLib.File.Create(track.DownloadPath); }
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
var regex = new Regex(@"(\{(?:\{??[^\{]*?\}))");
|
||||||
|
var matches = regex.Matches(newName);
|
||||||
|
|
||||||
|
while (matches.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var 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, slfile, 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, slfile, track);
|
||||||
|
});
|
||||||
|
|
||||||
|
string old = match.Groups[1].Value;
|
||||||
|
old = old.StartsWith("{{") ? old[1..] : old;
|
||||||
|
newName = newName.Replace(old, chosenOpt);
|
||||||
|
}
|
||||||
|
|
||||||
|
matches = regex.Matches(newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newName != format)
|
||||||
|
{
|
||||||
|
char dirsep = Path.DirectorySeparatorChar;
|
||||||
|
newName = newName.Replace('/', dirsep).Replace('\\', dirsep);
|
||||||
|
var x = newName.Split(dirsep, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
newName = string.Join(dirsep, x.Select(x => x.ReplaceInvalidChars(Config.invalidReplaceStr).Trim(' ', '.')));
|
||||||
|
return newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return format;
|
||||||
|
}
|
||||||
|
|
||||||
|
string GetVarValue(string x, TagLib.File? file, Soulseek.File? slfile, Track track)
|
||||||
|
{
|
||||||
|
string res;
|
||||||
|
|
||||||
|
switch (x)
|
||||||
|
{
|
||||||
|
case "artist":
|
||||||
|
res = file?.Tag.FirstPerformer ?? ""; break;
|
||||||
|
case "artists":
|
||||||
|
res = file != null ? string.Join(" & ", file.Tag.Performers) : ""; break;
|
||||||
|
case "albumartist":
|
||||||
|
res = file?.Tag.FirstAlbumArtist ?? ""; break;
|
||||||
|
case "albumartists":
|
||||||
|
res = file != null ? string.Join(" & ", file.Tag.AlbumArtists) : ""; break;
|
||||||
|
case "title":
|
||||||
|
res = file?.Tag.Title ?? ""; break;
|
||||||
|
case "album":
|
||||||
|
res = file?.Tag.Album ?? ""; break;
|
||||||
|
case "sartist":
|
||||||
|
case "sartists":
|
||||||
|
res = track.Artist; break;
|
||||||
|
case "stitle":
|
||||||
|
res = track.Title; break;
|
||||||
|
case "salbum":
|
||||||
|
res = track.Album; break;
|
||||||
|
case "year":
|
||||||
|
res = file?.Tag.Year.ToString() ?? ""; break;
|
||||||
|
case "track":
|
||||||
|
res = file?.Tag.Track.ToString("D2") ?? ""; break;
|
||||||
|
case "disc":
|
||||||
|
res = file?.Tag.Disc.ToString() ?? ""; break;
|
||||||
|
case "filename":
|
||||||
|
res = Utils.GetFileNameWithoutExtSlsk(slfile?.Filename ?? ""); break;
|
||||||
|
case "foldername":
|
||||||
|
if (remoteCommonDir == null || slfile == null)
|
||||||
|
{
|
||||||
|
return Utils.GetBaseNameSlsk(Utils.GetDirectoryNameSlsk(slfile?.Filename ?? ""));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string d = Path.GetDirectoryName(Utils.NormalizedPath(slfile.Filename));
|
||||||
|
string r = Path.GetFileName(remoteCommonDir);
|
||||||
|
return Path.Join(r, Path.GetRelativePath(remoteCommonDir, d));
|
||||||
|
}
|
||||||
|
case "default-foldername":
|
||||||
|
res = Config.defaultFolderName; break;
|
||||||
|
case "extractor":
|
||||||
|
res = Config.inputType.ToString(); break;
|
||||||
|
default:
|
||||||
|
res = ""; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.ReplaceInvalidChars(Config.invalidReplaceStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,39 +3,39 @@ using Data;
|
||||||
using Enums;
|
using Enums;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
|
||||||
namespace ExistingCheckers
|
namespace FileSkippers
|
||||||
{
|
{
|
||||||
public static class ExistingCheckerRegistry
|
public static class FileSkipperRegistry
|
||||||
{
|
{
|
||||||
public static ExistingChecker GetChecker(SkipMode mode, string dir, FileConditions conditions, M3uEditor m3uEditor)
|
public static FileSkipper GetChecker(SkipMode mode, string dir, FileConditions conditions, M3uEditor m3uEditor)
|
||||||
{
|
{
|
||||||
bool noConditions = conditions.Equals(new FileConditions());
|
bool noConditions = conditions.Equals(new FileConditions());
|
||||||
return mode switch
|
return mode switch
|
||||||
{
|
{
|
||||||
SkipMode.Name => new NameExistingChecker(dir),
|
SkipMode.Name => new NameSkipper(dir),
|
||||||
SkipMode.NameCond => noConditions ? new NameExistingChecker(dir) : new NameConditionExistingChecker(dir, conditions),
|
SkipMode.NameCond => noConditions ? new NameSkipper(dir) : new NameConditionalSkipper(dir, conditions),
|
||||||
SkipMode.Tag => new TagExistingChecker(dir),
|
SkipMode.Tag => new TagSkipper(dir),
|
||||||
SkipMode.TagCond => noConditions ? new TagExistingChecker(dir) : new TagConditionExistingChecker(dir, conditions),
|
SkipMode.TagCond => noConditions ? new TagSkipper(dir) : new TagConditionalSkipper(dir, conditions),
|
||||||
SkipMode.M3u => new M3uExistingChecker(m3uEditor, false),
|
SkipMode.M3u => new M3uSkipper(m3uEditor, false),
|
||||||
SkipMode.M3uCond => noConditions ? new M3uExistingChecker(m3uEditor, true) : new M3uConditionExistingChecker(m3uEditor, conditions),
|
SkipMode.M3uCond => noConditions ? new M3uSkipper(m3uEditor, true) : new M3uConditionalSkipper(m3uEditor, conditions),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract class ExistingChecker
|
public abstract class FileSkipper
|
||||||
{
|
{
|
||||||
public abstract bool TrackExists(Track track, out string? foundPath);
|
public abstract bool TrackExists(Track track, out string? foundPath);
|
||||||
public virtual void BuildIndex() { IndexIsBuilt = true; }
|
public virtual void BuildIndex() { IndexIsBuilt = true; }
|
||||||
public bool IndexIsBuilt { get; protected set; } = false;
|
public bool IndexIsBuilt { get; protected set; } = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class NameExistingChecker : ExistingChecker
|
public class NameSkipper : FileSkipper
|
||||||
{
|
{
|
||||||
readonly string[] ignore = new string[] { "_", "-", ".", "(", ")", "[", "]" };
|
readonly string[] ignore = new string[] { "_", "-", ".", "(", ")", "[", "]" };
|
||||||
readonly string dir;
|
readonly string dir;
|
||||||
readonly List<(string, string, string)> index = new(); // (Path, PreprocessedPath, PreprocessedName)
|
readonly List<(string, string, string)> index = new(); // (Path, PreprocessedPath, PreprocessedName)
|
||||||
|
|
||||||
public NameExistingChecker(string dir)
|
public NameSkipper(string dir)
|
||||||
{
|
{
|
||||||
this.dir = dir;
|
this.dir = dir;
|
||||||
}
|
}
|
||||||
|
@ -99,14 +99,14 @@ namespace ExistingCheckers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class NameConditionExistingChecker : ExistingChecker
|
public class NameConditionalSkipper : FileSkipper
|
||||||
{
|
{
|
||||||
readonly string[] ignore = new string[] { "_", "-", ".", "(", ")", "[", "]" };
|
readonly string[] ignore = new string[] { "_", "-", ".", "(", ")", "[", "]" };
|
||||||
readonly string dir;
|
readonly string dir;
|
||||||
readonly List<(string, string, SimpleFile)> index = new(); // (PreprocessedPath, PreprocessedName, file)
|
readonly List<(string, string, SimpleFile)> index = new(); // (PreprocessedPath, PreprocessedName, file)
|
||||||
FileConditions conditions;
|
FileConditions conditions;
|
||||||
|
|
||||||
public NameConditionExistingChecker(string dir, FileConditions conditions)
|
public NameConditionalSkipper(string dir, FileConditions conditions)
|
||||||
{
|
{
|
||||||
this.dir = dir;
|
this.dir = dir;
|
||||||
this.conditions = conditions;
|
this.conditions = conditions;
|
||||||
|
@ -175,12 +175,12 @@ namespace ExistingCheckers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TagExistingChecker : ExistingChecker
|
public class TagSkipper : FileSkipper
|
||||||
{
|
{
|
||||||
readonly string dir;
|
readonly string dir;
|
||||||
readonly List<(string, string, string)> index = new(); // (Path, PreprocessedArtist, PreprocessedTitle)
|
readonly List<(string, string, string)> index = new(); // (Path, PreprocessedArtist, PreprocessedTitle)
|
||||||
|
|
||||||
public TagExistingChecker(string dir)
|
public TagSkipper(string dir)
|
||||||
{
|
{
|
||||||
this.dir = dir;
|
this.dir = dir;
|
||||||
}
|
}
|
||||||
|
@ -240,13 +240,13 @@ namespace ExistingCheckers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TagConditionExistingChecker : ExistingChecker
|
public class TagConditionalSkipper : FileSkipper
|
||||||
{
|
{
|
||||||
readonly string dir;
|
readonly string dir;
|
||||||
readonly List<(string, string, SimpleFile)> index = new(); // (PreprocessedArtist, PreprocessedTitle, file)
|
readonly List<(string, string, SimpleFile)> index = new(); // (PreprocessedArtist, PreprocessedTitle, file)
|
||||||
FileConditions conditions;
|
FileConditions conditions;
|
||||||
|
|
||||||
public TagConditionExistingChecker(string dir, FileConditions conditions)
|
public TagConditionalSkipper(string dir, FileConditions conditions)
|
||||||
{
|
{
|
||||||
this.dir = dir;
|
this.dir = dir;
|
||||||
this.conditions = conditions;
|
this.conditions = conditions;
|
||||||
|
@ -307,12 +307,12 @@ namespace ExistingCheckers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class M3uExistingChecker : ExistingChecker
|
public class M3uSkipper : FileSkipper
|
||||||
{
|
{
|
||||||
M3uEditor m3uEditor;
|
M3uEditor m3uEditor;
|
||||||
bool checkFileExists;
|
bool checkFileExists;
|
||||||
|
|
||||||
public M3uExistingChecker(M3uEditor m3UEditor, bool checkFileExists)
|
public M3uSkipper(M3uEditor m3UEditor, bool checkFileExists)
|
||||||
{
|
{
|
||||||
this.m3uEditor = m3UEditor;
|
this.m3uEditor = m3UEditor;
|
||||||
this.checkFileExists = checkFileExists;
|
this.checkFileExists = checkFileExists;
|
||||||
|
@ -349,12 +349,12 @@ namespace ExistingCheckers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class M3uConditionExistingChecker : ExistingChecker
|
public class M3uConditionalSkipper : FileSkipper
|
||||||
{
|
{
|
||||||
M3uEditor m3uEditor;
|
M3uEditor m3uEditor;
|
||||||
FileConditions conditions;
|
FileConditions conditions;
|
||||||
|
|
||||||
public M3uConditionExistingChecker(M3uEditor m3UEditor, FileConditions conditions)
|
public M3uConditionalSkipper(M3uEditor m3UEditor, FileConditions conditions)
|
||||||
{
|
{
|
||||||
this.m3uEditor = m3UEditor;
|
this.m3uEditor = m3UEditor;
|
||||||
this.conditions = conditions;
|
this.conditions = conditions;
|
|
@ -164,7 +164,6 @@ public static class Help
|
||||||
'default': No additional images
|
'default': No additional images
|
||||||
'largest': Download from the folder with the largest image
|
'largest': Download from the folder with the largest image
|
||||||
'most': Download from the folder containing the most images
|
'most': Download from the folder containing the most images
|
||||||
'most-largest': Do most, then largest
|
|
||||||
--album-art-only Only download album art for the provided album
|
--album-art-only Only download album art for the provided album
|
||||||
--no-browse-folder Do not automatically browse user shares to get all files in
|
--no-browse-folder Do not automatically browse user shares to get all files in
|
||||||
in the folder
|
in the folder
|
||||||
|
@ -221,7 +220,7 @@ public static class Help
|
||||||
Create an app and add http://localhost:48721/callback as a redirect url in its settings.
|
Create an app and add http://localhost:48721/callback as a redirect url in its settings.
|
||||||
|
|
||||||
Bandcamp
|
Bandcamp
|
||||||
A bandcamp url: Download a single track, and album, or an artist's entire discography.
|
A bandcamp url: Download a single track, an album, or an artist's entire discography.
|
||||||
Extracts the artist name, album name and sets --album-track-count=""n+"", where n is the
|
Extracts the artist name, album name and sets --album-track-count=""n+"", where n is the
|
||||||
number of visible tracks on the bandcamp page.
|
number of visible tracks on the bandcamp page.
|
||||||
|
|
||||||
|
@ -266,8 +265,7 @@ public static class Help
|
||||||
(ignoring case and some special characters), and their lengths are within --length-tol of each
|
(ignoring case and some special characters), and their lengths are within --length-tol of each
|
||||||
other.
|
other.
|
||||||
Note that this mode is not 100% reliable, which is why --min-shares-aggregate is set to 2 by
|
Note that this mode is not 100% reliable, which is why --min-shares-aggregate is set to 2 by
|
||||||
default, i.e. any song that is shared only once will be ignored. Enable --relax-filtering to
|
default, i.e. any song that is shared only once will be ignored.
|
||||||
make the file filtering less aggressive.
|
|
||||||
|
|
||||||
Album Aggregate
|
Album Aggregate
|
||||||
Activated when --album and --aggregate are enabled, in this mode sldl searches for the query
|
Activated when --album and --aggregate are enabled, in this mode sldl searches for the query
|
||||||
|
@ -328,14 +326,14 @@ public static class Help
|
||||||
|
|
||||||
There are no default required conditions. The default preferred conditions are:
|
There are no default required conditions. The default preferred conditions are:
|
||||||
|
|
||||||
format = mp3
|
pref-format = mp3
|
||||||
length-tol = 3
|
pref-length-tol = 3
|
||||||
min-bitrate = 200
|
pref-min-bitrate = 200
|
||||||
max-bitrate = 2500
|
pref-max-bitrate = 2500
|
||||||
max-samplerate = 48000
|
pref-max-samplerate = 48000
|
||||||
strict-title = true
|
pref-strict-title = true
|
||||||
strict-album = true
|
pref-strict-album = true
|
||||||
accept-no-length = false
|
pref-accept-no-length = false
|
||||||
|
|
||||||
sldl will therefore prefer mp3 files with bitrate between 200 and 2500 kbps, and whose length
|
sldl will therefore prefer mp3 files with bitrate between 200 and 2500 kbps, and whose length
|
||||||
differs from the supplied length by no more than 3 seconds. It will also prefer files whose
|
differs from the supplied length by no more than 3 seconds. It will also prefer files whose
|
||||||
|
@ -391,7 +389,7 @@ public static class Help
|
||||||
track Track number
|
track Track number
|
||||||
disc Disc number
|
disc Disc number
|
||||||
filename Soulseek filename without extension
|
filename Soulseek filename without extension
|
||||||
foldername Soulseek folder name (only available for album downloads)
|
foldername Soulseek folder name
|
||||||
default-foldername Default sldl folder name
|
default-foldername Default sldl folder name
|
||||||
extractor Name of the extractor used (CSV/Spotify/YouTube/etc)
|
extractor Name of the extractor used (CSV/Spotify/YouTube/etc)
|
||||||
";
|
";
|
||||||
|
@ -469,11 +467,11 @@ public static class Help
|
||||||
path = ~/downloads/sldl-youtube
|
path = ~/downloads/sldl-youtube
|
||||||
# download to another location for youtube
|
# download to another location for youtube
|
||||||
|
|
||||||
The following operators are supported: &&, ||, ==, !=, ! (negation for bools).
|
The following operators are supported for use in profile-cond: &&, ||, ==, !=, !{bool}.
|
||||||
The following variables are available for use in profile-cond:
|
The following variables are available for use in profile-cond:
|
||||||
|
|
||||||
input-type ( = ""youtube""|""csv""|""string""|""bandcamp""|""spotify"")
|
input-type (""youtube""|""csv""|""string""|""bandcamp""|""spotify"")
|
||||||
download-mode ( = ""normal""|""aggregate""|""album""|""album-aggregate"")
|
download-mode (""normal""|""aggregate""|""album""|""album-aggregate"")
|
||||||
interactive (bool)
|
interactive (bool)
|
||||||
";
|
";
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ public class M3uEditor
|
||||||
this.offset = offset;
|
this.offset = offset;
|
||||||
this.option = option;
|
this.option = option;
|
||||||
this.path = Path.GetFullPath(m3uPath);
|
this.path = Path.GetFullPath(m3uPath);
|
||||||
this.parent = Path.GetDirectoryName(path);
|
this.parent = Utils.NormalizedPath(Path.GetDirectoryName(path));
|
||||||
this.lines = ReadAllLines().ToList();
|
this.lines = ReadAllLines().ToList();
|
||||||
this.needFirstUpdate = option == M3uOption.All;
|
this.needFirstUpdate = option == M3uOption.All;
|
||||||
|
|
||||||
|
@ -133,7 +133,7 @@ public class M3uEditor
|
||||||
return indexTrack == null
|
return indexTrack == null
|
||||||
|| indexTrack.State != track.State
|
|| indexTrack.State != track.State
|
||||||
|| indexTrack.FailureReason != track.FailureReason
|
|| indexTrack.FailureReason != track.FailureReason
|
||||||
|| indexTrack.DownloadPath != track.DownloadPath;
|
|| Utils.NormalizedPath(indexTrack.DownloadPath) != Utils.NormalizedPath(track.DownloadPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateTrackIfNeeded(Track track)
|
void updateTrackIfNeeded(Track track)
|
||||||
|
@ -245,7 +245,7 @@ public class M3uEditor
|
||||||
foreach (var val in previousRunData.Values)
|
foreach (var val in previousRunData.Values)
|
||||||
{
|
{
|
||||||
string p = val.DownloadPath;
|
string p = val.DownloadPath;
|
||||||
if (p.StartsWith(parent))
|
if (Utils.NormalizedPath(p).StartsWith(parent))
|
||||||
p = "./" + Path.GetRelativePath(parent, p); // prepend ./ for LoadPreviousResults to recognize that a rel. path is used
|
p = "./" + Path.GetRelativePath(parent, p); // prepend ./ for LoadPreviousResults to recognize that a rel. path is used
|
||||||
|
|
||||||
var items = new string[]
|
var items = new string[]
|
||||||
|
@ -274,7 +274,7 @@ public class M3uEditor
|
||||||
failureReason = nameof(FailureReason.NoSuitableFileFound);
|
failureReason = nameof(FailureReason.NoSuitableFileFound);
|
||||||
|
|
||||||
if (failureReason != null)
|
if (failureReason != null)
|
||||||
return $"# Failed: {track} [{failureReason}]";
|
return $"#FAIL: {track} [{failureReason}]";
|
||||||
|
|
||||||
if (track.DownloadPath.Length > 0)
|
if (track.DownloadPath.Length > 0)
|
||||||
{
|
{
|
||||||
|
|
362
slsk-batchdl/Printing.cs
Normal file
362
slsk-batchdl/Printing.cs
Normal file
|
@ -0,0 +1,362 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Konsole;
|
||||||
|
using Soulseek;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
using Data;
|
||||||
|
using Enums;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
public static class Printing
|
||||||
|
{
|
||||||
|
static readonly object consoleLock = new();
|
||||||
|
|
||||||
|
public 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static void PrintTracks(List<Track> tracks, int number = int.MaxValue, bool fullInfo = false, bool pathsOnly = false, bool showAncestors = true, bool infoFirst = false, bool showUser = true)
|
||||||
|
{
|
||||||
|
if (tracks.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
number = Math.Min(tracks.Count, number);
|
||||||
|
|
||||||
|
string ancestor = "";
|
||||||
|
|
||||||
|
if (!showAncestors)
|
||||||
|
ancestor = Utils.GreatestCommonDirectorySlsk(tracks.SelectMany(x => x.Downloads.Select(y => y.Item2.Filename)));
|
||||||
|
|
||||||
|
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.Item2, x.Item1, infoFirst: infoFirst, showUser: showUser));
|
||||||
|
else
|
||||||
|
Console.WriteLine(" " + DisplayString(tracks[i], x.Item2, x.Item1, customPath: x.Item2.Filename.Replace(ancestor, "").TrimStart('\\'), 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.Item2, x.Item1, infoFirst: infoFirst, showUser: showUser));
|
||||||
|
else
|
||||||
|
Console.WriteLine(" " + DisplayString(tracks[i], x.Item2, x.Item1, customPath: x.Item2.Filename.Replace(ancestor, "").TrimStart('\\'), infoFirst: infoFirst, showUser: showUser));
|
||||||
|
}
|
||||||
|
if (tracks[i].Downloads?.Count > 0) Console.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($" File: {Utils.GetFileNameSlsk(tracks[i].Downloads[0].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.Item2, x.Item1, infoFirst: infoFirst, showUser: showUser));
|
||||||
|
else
|
||||||
|
Console.WriteLine(" " + DisplayString(tracks[i], x.Item2, x.Item1, customPath: x.Item2.Filename.Replace(ancestor, "").TrimStart('\\'), infoFirst: infoFirst, showUser: showUser));
|
||||||
|
}
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (number < tracks.Count)
|
||||||
|
Console.WriteLine($" ... (etc)");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static async Task PrintResults(TrackListEntry tle, List<Track> existing, List<Track> notFound)
|
||||||
|
{
|
||||||
|
await Program.InitClientAndUpdateIfNeeded();
|
||||||
|
|
||||||
|
if (tle.source.Type == TrackType.Normal)
|
||||||
|
{
|
||||||
|
await Search.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));
|
||||||
|
|
||||||
|
if (!Config.printOption.HasFlag(PrintOption.Full))
|
||||||
|
Console.WriteLine($"Result 1 of {tle.list.Count} for album {tle.source.ToString(true)}:");
|
||||||
|
else
|
||||||
|
Console.WriteLine($"Results ({tle.list.Count}) for album {tle.source.ToString(true)}:");
|
||||||
|
|
||||||
|
if (tle.list.Count > 0 && tle.list[0].Count > 0)
|
||||||
|
{
|
||||||
|
if (!Config.noBrowseFolder)
|
||||||
|
Console.WriteLine("[Skipping full folder retrieval]");
|
||||||
|
|
||||||
|
foreach (var ls in tle.list)
|
||||||
|
{
|
||||||
|
PrintAlbum(ls);
|
||||||
|
|
||||||
|
if (!Config.printOption.HasFlag(PrintOption.Full))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("No results.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static void PrintComplete(TrackLists trackLists)
|
||||||
|
{
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static void PrintTracksTbd(List<Track> toBeDownloaded, List<Track> existing, List<Track> notFound, TrackType type, bool summary = true)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
bool allSkipped = existing.Count + notFound.Count > toBeDownloaded.Count;
|
||||||
|
|
||||||
|
if (summary && (type == TrackType.Normal || skippedTracks.Length > 0))
|
||||||
|
Console.WriteLine($"Downloading {toBeDownloaded.Count(x => !x.IsNotAudio)} tracks{skippedTracks}{(allSkipped ? '.' : ':')}");
|
||||||
|
|
||||||
|
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 || Config.PrintResults)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static void PrintAlbum(List<Track> albumTracks)
|
||||||
|
{
|
||||||
|
if (albumTracks.Count == 0 && albumTracks[0].Downloads.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var response = albumTracks[0].FirstResponse;
|
||||||
|
string userInfo = $"{response.Username} ({((float)response.UploadSpeed / (1024 * 1024)):F3}MB/s)";
|
||||||
|
var (parents, props) = FolderInfo(albumTracks.Select(x => x.FirstDownload));
|
||||||
|
|
||||||
|
WriteLine($"User : {userInfo}\nFolder: {parents}\nProps : {props}", ConsoleColor.White);
|
||||||
|
PrintTracks(albumTracks.ToList(), pathsOnly: true, showAncestors: false, showUser: false);
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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.TrimStart('.');
|
||||||
|
|
||||||
|
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 = Utils.GreatestCommonDirectory(files.Select(x => x.Filename)).TrimEnd('\\');
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public 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
|
||||||
|
{
|
||||||
|
Program.skipUpdate = true;
|
||||||
|
lock (consoleLock)
|
||||||
|
{
|
||||||
|
Console.ForegroundColor = color;
|
||||||
|
Console.WriteLine(value);
|
||||||
|
Console.ResetColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
Program.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -4,6 +4,7 @@ using System.Text.RegularExpressions;
|
||||||
|
|
||||||
using Data;
|
using Data;
|
||||||
using Enums;
|
using Enums;
|
||||||
|
using static Program;
|
||||||
|
|
||||||
using File = System.IO.File;
|
using File = System.IO.File;
|
||||||
using Directory = System.IO.Directory;
|
using Directory = System.IO.Directory;
|
||||||
|
@ -14,28 +15,31 @@ using SlFile = Soulseek.File;
|
||||||
using SlDictionary = System.Collections.Concurrent.ConcurrentDictionary<string, (Soulseek.SearchResponse, Soulseek.File)>;
|
using SlDictionary = System.Collections.Concurrent.ConcurrentDictionary<string, (Soulseek.SearchResponse, Soulseek.File)>;
|
||||||
|
|
||||||
|
|
||||||
static partial class Program
|
static class Search
|
||||||
{
|
{
|
||||||
static async Task<string> SearchAndDownload(Track track, ResponseData? responseData = null)
|
public static RateLimitedSemaphore? searchSemaphore;
|
||||||
|
|
||||||
|
// very messy function that does everything
|
||||||
|
public static async Task<(string, SlFile?)> SearchAndDownload(Track track, FileManager organizer)
|
||||||
{
|
{
|
||||||
if (Config.DoNotDownload)
|
if (Config.DoNotDownload)
|
||||||
throw new Exception();
|
throw new Exception();
|
||||||
|
|
||||||
responseData ??= new ResponseData();
|
var responseData = new ResponseData();
|
||||||
IEnumerable<(SlResponse response, SlFile file)>? orderedResults = null;
|
IEnumerable<(SlResponse response, SlFile file)>? orderedResults = null;
|
||||||
var progress = GetProgressBar(Config.displayMode);
|
var progress = Printing.GetProgressBar(Config.displayMode);
|
||||||
var results = new SlDictionary();
|
var results = new SlDictionary();
|
||||||
var fsResults = new SlDictionary();
|
var fsResults = new SlDictionary();
|
||||||
var cts = new CancellationTokenSource();
|
var cts = new CancellationTokenSource();
|
||||||
var saveFilePath = "";
|
var saveFilePath = "";
|
||||||
|
SlFile? chosenFile = null;
|
||||||
Task? downloadTask = null;
|
Task? downloadTask = null;
|
||||||
var fsDownloadLock = new object();
|
var fsDownloadLock = new object();
|
||||||
int fsResultsStarted = 0;
|
int fsResultsStarted = 0;
|
||||||
int downloading = 0;
|
int downloading = 0;
|
||||||
bool notFound = false;
|
bool notFound = false;
|
||||||
bool searchEnded = false;
|
bool searchEnded = false;
|
||||||
string fsUser = "";
|
string? fsUser = null;
|
||||||
string fsFile = "";
|
|
||||||
|
|
||||||
if (track.Downloads != null)
|
if (track.Downloads != null)
|
||||||
{
|
{
|
||||||
|
@ -43,10 +47,7 @@ static partial class Program
|
||||||
goto downloads;
|
goto downloads;
|
||||||
}
|
}
|
||||||
|
|
||||||
RefreshOrPrint(progress, 0, $"Waiting: {track}", false);
|
Printing.RefreshOrPrint(progress, 0, $"Waiting: {track}", false);
|
||||||
|
|
||||||
string searchText = $"{track.Artist} {track.Title}".Trim();
|
|
||||||
var removeChars = new string[] { " ", "_", "-" };
|
|
||||||
|
|
||||||
searches.TryAdd(track, new SearchInfo(results, progress));
|
searches.TryAdd(track, new SearchInfo(results, progress));
|
||||||
|
|
||||||
|
@ -58,10 +59,10 @@ static partial class Program
|
||||||
{
|
{
|
||||||
downloading = 1;
|
downloading = 1;
|
||||||
var (r, f) = fsResults.MaxBy(x => x.Value.Item1.UploadSpeed).Value;
|
var (r, f) = fsResults.MaxBy(x => x.Value.Item1.UploadSpeed).Value;
|
||||||
saveFilePath = GetSavePath(f.Filename);
|
saveFilePath = organizer.GetSavePath(f.Filename);
|
||||||
fsUser = r.Username;
|
fsUser = r.Username;
|
||||||
fsFile = f.Filename;
|
chosenFile = f;
|
||||||
downloadTask = DownloadFile(r, f, saveFilePath, track, progress, cts);
|
downloadTask = Download.DownloadFile(r, f, saveFilePath, track, progress, cts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -109,7 +110,7 @@ static partial class Program
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void onSearch() => RefreshOrPrint(progress, 0, $"Searching: {track}", true);
|
void onSearch() => Printing.RefreshOrPrint(progress, 0, $"Searching: {track}", true);
|
||||||
await RunSearches(track, results, getSearchOptions, responseHandler, cts.Token, onSearch);
|
await RunSearches(track, results, getSearchOptions, responseHandler, cts.Token, onSearch);
|
||||||
|
|
||||||
searches.TryRemove(track, out _);
|
searches.TryRemove(track, out _);
|
||||||
|
@ -133,10 +134,13 @@ static partial class Program
|
||||||
{
|
{
|
||||||
saveFilePath = "";
|
saveFilePath = "";
|
||||||
downloading = 0;
|
downloading = 0;
|
||||||
results.TryRemove(fsUser + "\\" + fsFile, out _);
|
if (chosenFile != null && fsUser != null)
|
||||||
|
{
|
||||||
|
results.TryRemove(fsUser + '\\' + chosenFile.Filename, out _);
|
||||||
userSuccessCount.AddOrUpdate(fsUser, -1, (k, v) => v - 1);
|
userSuccessCount.AddOrUpdate(fsUser, -1, (k, v) => v - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cts.Dispose();
|
cts.Dispose();
|
||||||
|
|
||||||
|
@ -150,24 +154,27 @@ static partial class Program
|
||||||
int trackTries = Config.maxRetriesPerTrack;
|
int trackTries = Config.maxRetriesPerTrack;
|
||||||
async Task<bool> process(SlResponse response, SlFile file)
|
async Task<bool> process(SlResponse response, SlFile file)
|
||||||
{
|
{
|
||||||
saveFilePath = GetSavePath(file.Filename);
|
saveFilePath = organizer.GetSavePath(file.Filename);
|
||||||
|
chosenFile = file;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
downloading = 1;
|
downloading = 1;
|
||||||
await DownloadFile(response, file, saveFilePath, track, progress);
|
await Download.DownloadFile(response, file, saveFilePath, track, progress);
|
||||||
userSuccessCount.AddOrUpdate(response.Username, 1, (k, v) => v + 1);
|
userSuccessCount.AddOrUpdate(response.Username, 1, (k, v) => v + 1);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
chosenFile = null;
|
||||||
|
saveFilePath = "";
|
||||||
downloading = 0;
|
downloading = 0;
|
||||||
if (!IsConnectedAndLoggedIn())
|
if (!IsConnectedAndLoggedIn())
|
||||||
throw;
|
throw;
|
||||||
userSuccessCount.AddOrUpdate(response.Username, -1, (k, v) => v - 1);
|
userSuccessCount.AddOrUpdate(response.Username, -1, (k, v) => v - 1);
|
||||||
if (--trackTries <= 0)
|
if (--trackTries <= 0)
|
||||||
{
|
{
|
||||||
RefreshOrPrint(progress, 0, $"Out of download retries: {track}", true);
|
Printing.RefreshOrPrint(progress, 0, $"Out of download retries: {track}", true);
|
||||||
WriteLine("Last error was: " + e.Message, ConsoleColor.DarkYellow, true);
|
Printing.WriteLine("Last error was: " + e.Message, ConsoleColor.DarkYellow, true);
|
||||||
throw new SearchAndDownloadException(FailureReason.OutOfDownloadRetries);
|
throw new SearchAndDownloadException(FailureReason.OutOfDownloadRetries);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
@ -206,7 +213,7 @@ static partial class Program
|
||||||
notFound = false;
|
notFound = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
RefreshOrPrint(progress, 0, $"yt-dlp search: {track}", true);
|
Printing.RefreshOrPrint(progress, 0, $"yt-dlp search: {track}", true);
|
||||||
var ytResults = await Extractors.YouTube.YtdlpSearch(track);
|
var ytResults = await Extractors.YouTube.YtdlpSearch(track);
|
||||||
|
|
||||||
if (ytResults.Count > 0)
|
if (ytResults.Count > 0)
|
||||||
|
@ -215,11 +222,11 @@ static partial class Program
|
||||||
{
|
{
|
||||||
if (Config.necessaryCond.LengthToleranceSatisfies(length, track.Length))
|
if (Config.necessaryCond.LengthToleranceSatisfies(length, track.Length))
|
||||||
{
|
{
|
||||||
string saveFilePathNoExt = GetSavePathNoExt(title);
|
string saveFilePathNoExt = organizer.GetSavePathNoExt(title);
|
||||||
downloading = 1;
|
downloading = 1;
|
||||||
RefreshOrPrint(progress, 0, $"yt-dlp download: {track}", true);
|
Printing.RefreshOrPrint(progress, 0, $"yt-dlp download: {track}", true);
|
||||||
saveFilePath = await Extractors.YouTube.YtdlpDownload(id, saveFilePathNoExt, Config.ytdlpArgument);
|
saveFilePath = await Extractors.YouTube.YtdlpDownload(id, saveFilePathNoExt, Config.ytdlpArgument);
|
||||||
RefreshOrPrint(progress, 100, $"Succeded: yt-dlp completed download for {track}", true);
|
Printing.RefreshOrPrint(progress, 100, $"Succeded: yt-dlp completed download for {track}", true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -229,7 +236,7 @@ static partial class Program
|
||||||
{
|
{
|
||||||
saveFilePath = "";
|
saveFilePath = "";
|
||||||
downloading = 0;
|
downloading = 0;
|
||||||
RefreshOrPrint(progress, 0, $"{e.Message}", true);
|
Printing.RefreshOrPrint(progress, 0, $"{e.Message}", true);
|
||||||
throw new SearchAndDownloadException(FailureReason.NoSuitableFileFound);
|
throw new SearchAndDownloadException(FailureReason.NoSuitableFileFound);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -239,24 +246,21 @@ static partial class Program
|
||||||
if (notFound)
|
if (notFound)
|
||||||
{
|
{
|
||||||
string lockedFilesStr = responseData.lockedFilesCount > 0 ? $" (Found {responseData.lockedFilesCount} locked files)" : "";
|
string lockedFilesStr = responseData.lockedFilesCount > 0 ? $" (Found {responseData.lockedFilesCount} locked files)" : "";
|
||||||
RefreshOrPrint(progress, 0, $"Not found: {track}{lockedFilesStr}", true);
|
Printing.RefreshOrPrint(progress, 0, $"Not found: {track}{lockedFilesStr}", true);
|
||||||
throw new SearchAndDownloadException(FailureReason.NoSuitableFileFound);
|
throw new SearchAndDownloadException(FailureReason.NoSuitableFileFound);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
RefreshOrPrint(progress, 0, $"All downloads failed: {track}", true);
|
Printing.RefreshOrPrint(progress, 0, $"All downloads failed: {track}", true);
|
||||||
throw new SearchAndDownloadException(FailureReason.AllDownloadsFailed);
|
throw new SearchAndDownloadException(FailureReason.AllDownloadsFailed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Config.nameFormat.Length > 0)
|
return (Path.GetFullPath(saveFilePath), chosenFile);
|
||||||
saveFilePath = ApplyNamingFormat(saveFilePath, track);
|
|
||||||
|
|
||||||
return Path.GetFullPath(saveFilePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static async Task<List<List<Track>>> GetAlbumDownloads(Track track, ResponseData responseData)
|
public static async Task<List<List<Track>>> GetAlbumDownloads(Track track, ResponseData responseData)
|
||||||
{
|
{
|
||||||
var results = new ConcurrentDictionary<string, (SearchResponse, Soulseek.File)>();
|
var results = new ConcurrentDictionary<string, (SearchResponse, Soulseek.File)>();
|
||||||
SearchOptions getSearchOptions(int timeout, FileConditions nec, FileConditions prf) =>
|
SearchOptions getSearchOptions(int timeout, FileConditions nec, FileConditions prf) =>
|
||||||
|
@ -395,11 +399,11 @@ static partial class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static async Task<List<Track>> GetAggregateTracks(Track track, ResponseData responseData)
|
public static async Task<List<Track>> GetAggregateTracks(Track track, ResponseData responseData)
|
||||||
{
|
{
|
||||||
var results = new SlDictionary();
|
var results = new SlDictionary();
|
||||||
SearchOptions getSearchOptions(int timeout, FileConditions nec, FileConditions prf) =>
|
SearchOptions getSearchOptions(int timeout, FileConditions nec, FileConditions prf) =>
|
||||||
new (
|
new(
|
||||||
minimumResponseFileCount: 1,
|
minimumResponseFileCount: 1,
|
||||||
minimumPeerUploadSpeed: 1,
|
minimumPeerUploadSpeed: 1,
|
||||||
removeSingleCharacterSearchTerms: Config.removeSingleCharacterSearchTerms,
|
removeSingleCharacterSearchTerms: Config.removeSingleCharacterSearchTerms,
|
||||||
|
@ -455,7 +459,7 @@ static partial class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static async Task<List<List<List<Track>>>> GetAggregateAlbums(Track track, ResponseData responseData)
|
public static async Task<List<List<List<Track>>>> GetAggregateAlbums(Track track, ResponseData responseData)
|
||||||
{
|
{
|
||||||
int maxDiff = Config.necessaryCond.LengthTolerance;
|
int maxDiff = Config.necessaryCond.LengthTolerance;
|
||||||
|
|
||||||
|
@ -551,7 +555,7 @@ static partial class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static async Task<List<(string dir, SlFile file)>> GetAllFilesInFolder(string user, string folderPrefix)
|
public static async Task<List<(string dir, SlFile file)>> GetAllFilesInFolder(string user, string folderPrefix)
|
||||||
{
|
{
|
||||||
var browseOptions = new BrowseOptions();
|
var browseOptions = new BrowseOptions();
|
||||||
var res = new List<(string dir, SlFile file)>();
|
var res = new List<(string dir, SlFile file)>();
|
||||||
|
@ -579,7 +583,7 @@ static partial class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static async Task CompleteFolder(List<Track> tracks, SearchResponse response, string folder)
|
public static async Task CompleteFolder(List<Track> tracks, SearchResponse response, string folder)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
@ -610,12 +614,12 @@ static partial class Program
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
WriteLine($"Error getting complete list of files: {ex}", ConsoleColor.DarkYellow);
|
Printing.WriteLine($"Error getting complete list of files: {ex}", ConsoleColor.DarkYellow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static IEnumerable<(Track, IEnumerable<(SlResponse response, SlFile file)>)> EquivalentFiles(Track track,
|
public static IEnumerable<(Track, IEnumerable<(SlResponse response, SlFile file)>)> EquivalentFiles(Track track,
|
||||||
IEnumerable<(SlResponse, SlFile)> fileResponses, int minShares = -1)
|
IEnumerable<(SlResponse, SlFile)> fileResponses, int minShares = -1)
|
||||||
{
|
{
|
||||||
if (minShares == -1)
|
if (minShares == -1)
|
||||||
|
@ -645,14 +649,14 @@ static partial class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static IOrderedEnumerable<(SlResponse response, SlFile file)> OrderedResults(IEnumerable<KeyValuePair<string, (SlResponse, SlFile)>> results,
|
public static IOrderedEnumerable<(SlResponse response, SlFile file)> OrderedResults(IEnumerable<KeyValuePair<string, (SlResponse, SlFile)>> results,
|
||||||
Track track, bool useInfer = false, bool useLevenshtein = true, bool albumMode = false)
|
Track track, bool useInfer = false, bool useLevenshtein = true, bool albumMode = false)
|
||||||
{
|
{
|
||||||
return OrderedResults(results.Select(x => x.Value), track, useInfer, useLevenshtein, albumMode);
|
return OrderedResults(results.Select(x => x.Value), track, useInfer, useLevenshtein, albumMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static IOrderedEnumerable<(SlResponse response, SlFile file)> OrderedResults(IEnumerable<(SlResponse, SlFile)> results,
|
public static IOrderedEnumerable<(SlResponse response, SlFile file)> OrderedResults(IEnumerable<(SlResponse, SlFile)> results,
|
||||||
Track track, bool useInfer = false, bool useLevenshtein = true, bool albumMode = false)
|
Track track, bool useInfer = false, bool useLevenshtein = true, bool albumMode = false)
|
||||||
{
|
{
|
||||||
bool useBracketCheck = true;
|
bool useBracketCheck = true;
|
||||||
|
@ -720,7 +724,7 @@ static partial class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static async Task RunSearches(Track track, SlDictionary results, Func<int, FileConditions, FileConditions, SearchOptions> getSearchOptions,
|
public static async Task RunSearches(Track track, SlDictionary results, Func<int, FileConditions, FileConditions, SearchOptions> getSearchOptions,
|
||||||
Action<SearchResponse> responseHandler, CancellationToken? ct = null, Action? onSearch = null)
|
Action<SearchResponse> responseHandler, CancellationToken? ct = null, Action? onSearch = null)
|
||||||
{
|
{
|
||||||
bool artist = track.Artist.Length > 0;
|
bool artist = track.Artist.Length > 0;
|
||||||
|
@ -731,11 +735,11 @@ static partial class Program
|
||||||
var searchTasks = new List<Task>();
|
var searchTasks = new List<Task>();
|
||||||
|
|
||||||
var defaultSearchOpts = getSearchOptions(Config.searchTimeout, Config.necessaryCond, Config.preferredCond);
|
var defaultSearchOpts = getSearchOptions(Config.searchTimeout, Config.necessaryCond, Config.preferredCond);
|
||||||
searchTasks.Add(Search(search, defaultSearchOpts, responseHandler, ct, onSearch));
|
searchTasks.Add(DoSearch(search, defaultSearchOpts, responseHandler, ct, onSearch));
|
||||||
|
|
||||||
if (search.RemoveDiacriticsIfExist(out string noDiacrSearch) && !track.ArtistMaybeWrong)
|
if (search.RemoveDiacriticsIfExist(out string noDiacrSearch) && !track.ArtistMaybeWrong)
|
||||||
{
|
{
|
||||||
searchTasks.Add(Search(noDiacrSearch, defaultSearchOpts, responseHandler, ct, onSearch));
|
searchTasks.Add(DoSearch(noDiacrSearch, defaultSearchOpts, responseHandler, ct, onSearch));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.WhenAll(searchTasks);
|
await Task.WhenAll(searchTasks);
|
||||||
|
@ -747,7 +751,7 @@ static partial class Program
|
||||||
cond.StrictTitle = infTrack.Title == track.Title;
|
cond.StrictTitle = infTrack.Title == track.Title;
|
||||||
cond.StrictArtist = false;
|
cond.StrictArtist = false;
|
||||||
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
||||||
searchTasks.Add(Search($"{infTrack.Artist} {infTrack.Title}", opts, responseHandler, ct, onSearch));
|
searchTasks.Add(DoSearch($"{infTrack.Artist} {infTrack.Title}", opts, responseHandler, ct, onSearch));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Config.desperateSearch)
|
if (Config.desperateSearch)
|
||||||
|
@ -764,7 +768,7 @@ static partial class Program
|
||||||
StrictAlbum = true
|
StrictAlbum = true
|
||||||
};
|
};
|
||||||
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
||||||
searchTasks.Add(Search($"{track.Artist} {track.Album}", opts, responseHandler, ct, onSearch));
|
searchTasks.Add(DoSearch($"{track.Artist} {track.Album}", opts, responseHandler, ct, onSearch));
|
||||||
}
|
}
|
||||||
if (artist && title && track.Length != -1 && Config.necessaryCond.LengthTolerance != -1)
|
if (artist && title && track.Length != -1 && Config.necessaryCond.LengthTolerance != -1)
|
||||||
{
|
{
|
||||||
|
@ -775,7 +779,7 @@ static partial class Program
|
||||||
StrictArtist = true
|
StrictArtist = true
|
||||||
};
|
};
|
||||||
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
||||||
searchTasks.Add(Search($"{track.Artist} {track.Title}", opts, responseHandler, ct, onSearch));
|
searchTasks.Add(DoSearch($"{track.Artist} {track.Title}", opts, responseHandler, ct, onSearch));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -795,7 +799,7 @@ static partial class Program
|
||||||
LengthTolerance = -1
|
LengthTolerance = -1
|
||||||
};
|
};
|
||||||
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
||||||
searchTasks.Add(Search($"{track.Album}", opts, responseHandler, ct, onSearch));
|
searchTasks.Add(DoSearch($"{track.Album}", opts, responseHandler, ct, onSearch));
|
||||||
}
|
}
|
||||||
if (track2.Title.Length > 3 && artist)
|
if (track2.Title.Length > 3 && artist)
|
||||||
{
|
{
|
||||||
|
@ -806,7 +810,7 @@ static partial class Program
|
||||||
LengthTolerance = -1
|
LengthTolerance = -1
|
||||||
};
|
};
|
||||||
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
||||||
searchTasks.Add(Search($"{track2.Title}", opts, responseHandler, ct, onSearch));
|
searchTasks.Add(DoSearch($"{track2.Title}", opts, responseHandler, ct, onSearch));
|
||||||
}
|
}
|
||||||
if (track2.Artist.Length > 3 && title)
|
if (track2.Artist.Length > 3 && title)
|
||||||
{
|
{
|
||||||
|
@ -817,7 +821,7 @@ static partial class Program
|
||||||
LengthTolerance = -1
|
LengthTolerance = -1
|
||||||
};
|
};
|
||||||
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
var opts = getSearchOptions(Math.Min(Config.searchTimeout, 5000), cond, Config.preferredCond);
|
||||||
searchTasks.Add(Search($"{track2.Artist}", opts, responseHandler, ct, onSearch));
|
searchTasks.Add(DoSearch($"{track2.Artist}", opts, responseHandler, ct, onSearch));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -826,7 +830,7 @@ static partial class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static async Task Search(string search, SearchOptions opts, Action<SearchResponse> rHandler, CancellationToken? ct = null, Action? onSearch = null)
|
static async Task DoSearch(string search, SearchOptions opts, Action<SearchResponse> rHandler, CancellationToken? ct = null, Action? onSearch = null)
|
||||||
{
|
{
|
||||||
await searchSemaphore.WaitAsync();
|
await searchSemaphore.WaitAsync();
|
||||||
try
|
try
|
||||||
|
@ -840,7 +844,7 @@ static partial class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static async Task SearchAndPrintResults(List<Track> tracks)
|
public static async Task SearchAndPrintResults(List<Track> tracks)
|
||||||
{
|
{
|
||||||
foreach (var track in tracks)
|
foreach (var track in tracks)
|
||||||
{
|
{
|
||||||
|
@ -878,7 +882,7 @@ static partial class Program
|
||||||
|
|
||||||
if (Config.DoNotDownload && results.IsEmpty)
|
if (Config.DoNotDownload && results.IsEmpty)
|
||||||
{
|
{
|
||||||
WriteLine($"No results", ConsoleColor.Yellow);
|
Printing.WriteLine($"No results", ConsoleColor.Yellow);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
@ -887,12 +891,12 @@ static partial class Program
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
foreach (var (response, file) in orderedResults)
|
foreach (var (response, file) in orderedResults)
|
||||||
{
|
{
|
||||||
Console.WriteLine(DisplayString(track, file, response,
|
Console.WriteLine(Printing.DisplayString(track, file, response,
|
||||||
Config.PrintResultsFull ? Config.necessaryCond : null, Config.PrintResultsFull ? Config.preferredCond : null,
|
Config.PrintResultsFull ? Config.necessaryCond : null, Config.PrintResultsFull ? Config.preferredCond : null,
|
||||||
fullpath: Config.PrintResultsFull, infoFirst: true, showSpeed: Config.PrintResultsFull));
|
fullpath: Config.PrintResultsFull, infoFirst: true, showSpeed: Config.PrintResultsFull));
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
WriteLine($"Total: {count}\n", ConsoleColor.Yellow);
|
Printing.WriteLine($"Total: {count}\n", ConsoleColor.Yellow);
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
|
@ -944,7 +948,7 @@ static partial class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static Track InferTrack(string filename, Track defaultTrack, TrackType type = TrackType.Normal)
|
public static Track InferTrack(string filename, Track defaultTrack, TrackType type = TrackType.Normal)
|
||||||
{
|
{
|
||||||
var t = new Track(defaultTrack);
|
var t = new Track(defaultTrack);
|
||||||
t.Type = type;
|
t.Type = type;
|
||||||
|
@ -1111,219 +1115,45 @@ static partial class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static async Task DownloadFile(SearchResponse response, Soulseek.File file, string filePath, Track track, ProgressBar progress, CancellationTokenSource? searchCts = null)
|
public static bool AlbumsAreSimilar(List<Track> album1, List<Track> album2, int[]? album1SortedLengths = null, int tolerance = 3)
|
||||||
{
|
{
|
||||||
if (Config.DoNotDownload)
|
if (album1SortedLengths != null && album1SortedLengths.Length != album2.Count(t => !t.IsNotAudio))
|
||||||
throw new Exception();
|
return false;
|
||||||
|
else if (album1.Count(t => !t.IsNotAudio) != album2.Count(t => !t.IsNotAudio))
|
||||||
|
return false;
|
||||||
|
|
||||||
await WaitForLogin();
|
if (album1SortedLengths == null)
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath));
|
album1SortedLengths = album1.Where(t => !t.IsNotAudio).Select(t => t.Length).OrderBy(x => x).ToArray();
|
||||||
string origPath = filePath;
|
|
||||||
filePath += ".incomplete";
|
|
||||||
|
|
||||||
var transferOptions = new TransferOptions(
|
var album2SortedLengths = album2.Where(t => !t.IsNotAudio).Select(t => t.Length).OrderBy(x => x).ToArray();
|
||||||
stateChanged: (state) =>
|
|
||||||
|
for (int i = 0; i < album1SortedLengths.Length; i++)
|
||||||
{
|
{
|
||||||
if (downloads.TryGetValue(file.Filename, out var x))
|
if (Math.Abs(album1SortedLengths[i] - album2SortedLengths[i]) > tolerance)
|
||||||
x.transfer = state.Transfer;
|
return false;
|
||||||
},
|
|
||||||
progressUpdated: (progress) =>
|
|
||||||
{
|
|
||||||
if (downloads.TryGetValue(file.Filename, out var x))
|
|
||||||
x.bytesTransferred = progress.PreviousBytesTransferred;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
using var outputStream = new FileStream(filePath, FileMode.Create);
|
|
||||||
var wrapper = new DownloadWrapper(origPath, response, file, track, cts, progress);
|
|
||||||
downloads.TryAdd(file.Filename, wrapper);
|
|
||||||
|
|
||||||
// Attempt to make it resume downloads after a network interruption.
|
|
||||||
// Does not work: The resumed download will be queued until it goes stale.
|
|
||||||
// The host (slskd) reports that "Another upload to {user} is already in progress"
|
|
||||||
// when attempting to resume. Must wait until timeout, which can take minutes.
|
|
||||||
|
|
||||||
int maxRetries = 3;
|
|
||||||
int retryCount = 0;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await client.DownloadAsync(response.Username, file.Filename,
|
|
||||||
() => Task.FromResult((Stream)outputStream),
|
|
||||||
file.Size, startOffset: outputStream.Position,
|
|
||||||
options: transferOptions, cancellationToken: cts.Token);
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (SoulseekClientException)
|
|
||||||
{
|
|
||||||
retryCount++;
|
|
||||||
|
|
||||||
if (retryCount >= maxRetries || IsConnectedAndLoggedIn())
|
|
||||||
throw;
|
|
||||||
|
|
||||||
await WaitForLogin();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
if (File.Exists(filePath))
|
|
||||||
try { File.Delete(filePath); } catch { }
|
|
||||||
downloads.TryRemove(file.Filename, out var d);
|
|
||||||
if (d != null)
|
|
||||||
lock (d) { d.UpdateText(); }
|
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try { searchCts?.Cancel(); }
|
return true;
|
||||||
catch { }
|
|
||||||
|
|
||||||
try { Utils.Move(filePath, origPath); }
|
|
||||||
catch (IOException) { WriteLine($"Failed to rename .incomplete file", ConsoleColor.DarkYellow, true); }
|
|
||||||
|
|
||||||
downloads.TryRemove(file.Filename, out var x);
|
|
||||||
if (x != null)
|
|
||||||
{
|
|
||||||
lock (x)
|
|
||||||
{
|
|
||||||
x.success = true;
|
|
||||||
x.UpdateText();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public class SearchAndDownloadException : Exception
|
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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SearchAndDownloadException : Exception
|
||||||
|
{
|
||||||
public FailureReason reason;
|
public FailureReason reason;
|
||||||
public SearchAndDownloadException(FailureReason reason, string text = "") : base(text) { this.reason = reason; }
|
public SearchAndDownloadException(FailureReason reason, string text = "") : base(text) { this.reason = reason; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class SearchInfo
|
||||||
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;
|
|
||||||
|
|
||||||
TransferStates? prevTransferState = null;
|
|
||||||
long prevBytesTransferred = 0;
|
|
||||||
bool updatedTextDownload = false;
|
|
||||||
bool updatedTextSuccess = false;
|
|
||||||
readonly char[] bars = { '|', '/', '—', '\\' };
|
|
||||||
|
|
||||||
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()
|
|
||||||
{
|
|
||||||
downloadRotatingBarState++;
|
|
||||||
downloadRotatingBarState %= bars.Length;
|
|
||||||
float? percentage = bytesTransferred / (float)file.Size;
|
|
||||||
queued = (transfer?.State & TransferStates.Queued) != 0;
|
|
||||||
string bar;
|
|
||||||
string state;
|
|
||||||
bool downloading = false;
|
|
||||||
|
|
||||||
if (stalled)
|
|
||||||
{
|
|
||||||
state = "Stalled";
|
|
||||||
bar = "";
|
|
||||||
}
|
|
||||||
else if (transfer != null)
|
|
||||||
{
|
|
||||||
if (queued)
|
|
||||||
state = "Queued";
|
|
||||||
else if ((transfer.State & TransferStates.Initializing) != 0)
|
|
||||||
state = "Initialize";
|
|
||||||
else if ((transfer.State & TransferStates.Completed) != 0)
|
|
||||||
{
|
|
||||||
var flag = transfer.State & (TransferStates.Succeeded | TransferStates.Cancelled
|
|
||||||
| TransferStates.TimedOut | TransferStates.Errored | TransferStates.Rejected
|
|
||||||
| TransferStates.Aborted);
|
|
||||||
state = flag.ToString();
|
|
||||||
|
|
||||||
if (flag == TransferStates.Succeeded)
|
|
||||||
success = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
state = transfer.State.ToString();
|
|
||||||
if ((transfer.State & TransferStates.InProgress) != 0)
|
|
||||||
downloading = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bar = success ? "" : bars[downloadRotatingBarState] + " ";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
state = "NullState";
|
|
||||||
bar = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
string txt = $"{bar}{state}:".PadRight(14) + $" {displayText}";
|
|
||||||
bool needSimplePrintUpdate = (downloading && !updatedTextDownload) || (success && !updatedTextSuccess);
|
|
||||||
updatedTextDownload |= downloading;
|
|
||||||
updatedTextSuccess |= success;
|
|
||||||
|
|
||||||
Console.ResetColor();
|
|
||||||
RefreshOrPrint(progress, (int)((percentage ?? 0) * 100), txt, needSimplePrintUpdate, needSimplePrintUpdate);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public DateTime UpdateLastChangeTime(bool updateAllFromThisUser = true, bool forceChanged = false)
|
|
||||||
{
|
|
||||||
bool changed = prevTransferState != transfer?.State || prevBytesTransferred != bytesTransferred;
|
|
||||||
if (changed || forceChanged)
|
|
||||||
{
|
|
||||||
lastChangeTime = DateTime.Now;
|
|
||||||
stalled = false;
|
|
||||||
if (updateAllFromThisUser)
|
|
||||||
{
|
|
||||||
foreach (var (_, dl) in downloads)
|
|
||||||
{
|
|
||||||
if (dl != this && dl.response.Username == response.Username)
|
|
||||||
dl.UpdateLastChangeTime(updateAllFromThisUser: false, forceChanged: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevTransferState = transfer?.State;
|
|
||||||
prevBytesTransferred = bytesTransferred;
|
|
||||||
return lastChangeTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SearchInfo
|
|
||||||
{
|
|
||||||
public ConcurrentDictionary<string, (SearchResponse, Soulseek.File)> results;
|
public ConcurrentDictionary<string, (SearchResponse, Soulseek.File)> results;
|
||||||
public ProgressBar progress;
|
public ProgressBar progress;
|
||||||
|
|
||||||
|
@ -1332,8 +1162,6 @@ static partial class Program
|
||||||
this.results = results;
|
this.results = results;
|
||||||
this.progress = progress;
|
this.progress = progress;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
using Data;
|
using Data;
|
||||||
using Enums;
|
using Enums;
|
||||||
using ExistingCheckers;
|
using FileSkippers;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
|
@ -62,12 +62,12 @@ namespace Test
|
||||||
"/home/user/docs/report.pdf",
|
"/home/user/docs/report.pdf",
|
||||||
"/home/user/docs/",
|
"/home/user/docs/",
|
||||||
};
|
};
|
||||||
Assert(Utils.GreatestCommonPath(paths, dirsep: '/') == "/home/user/docs/");
|
Assert(Utils.GreatestCommonPath(paths) == "/home/user/docs/");
|
||||||
Assert(Utils.GreatestCommonPath(new string[] { "/path/file", "" }, dirsep: '/') == "");
|
Assert(Utils.GreatestCommonPath(new string[] { "/path/file", "" }) == "");
|
||||||
Assert(Utils.GreatestCommonPath(new string[] { "/path/file", "/" }, dirsep: '/') == "/");
|
Assert(Utils.GreatestCommonPath(new string[] { "/path/file", "/" }) == "/");
|
||||||
Assert(Utils.GreatestCommonPath(new string[] { "/path/dir1", "/path/dir2" }, dirsep: '/') == "/path/");
|
Assert(Utils.GreatestCommonPath(new string[] { "/path/dir1", "/path/dir2" }) == "/path/");
|
||||||
Assert(Utils.GreatestCommonPath(new string[] { "/path/dir1", "/path/dir2" }, dirsep: '\\') == "");
|
Assert(Utils.GreatestCommonPath(new string[] { "/path\\dir1/blah", "/path/dir2\\blah" }) == "/path\\");
|
||||||
Assert(Utils.GreatestCommonPath(new string[] { "dir1", "dir2" }, dirsep: '/') == "");
|
Assert(Utils.GreatestCommonPath(new string[] { "dir1", "dir2" }) == "");
|
||||||
|
|
||||||
// RemoveDiacritics
|
// RemoveDiacritics
|
||||||
Assert(" Café Crème à la mode Ü".RemoveDiacritics() == " Cafe Creme a la mode U");
|
Assert(" Café Crème à la mode Ü".RemoveDiacritics() == " Cafe Creme a la mode U");
|
||||||
|
@ -327,7 +327,7 @@ namespace Test
|
||||||
|
|
||||||
Program.m3uEditor = new M3uEditor(path, trackLists, Config.m3uOption);
|
Program.m3uEditor = new M3uEditor(path, trackLists, Config.m3uOption);
|
||||||
|
|
||||||
Program.outputExistingChecker = new M3uExistingChecker(Program.m3uEditor, false);
|
Program.outputDirSkipper = new M3uSkipper(Program.m3uEditor, false);
|
||||||
|
|
||||||
var notFound = (List<Track>)ProgramInvoke("DoSkipNotFound", new object[] { trackLists[0].list[0] });
|
var notFound = (List<Track>)ProgramInvoke("DoSkipNotFound", new object[] { trackLists[0].list[0] });
|
||||||
var existing = (List<Track>)ProgramInvoke("DoSkipExisting", new object[] { trackLists[0].list[0] });
|
var existing = (List<Track>)ProgramInvoke("DoSkipExisting", new object[] { trackLists[0].list[0] });
|
||||||
|
|
|
@ -140,14 +140,14 @@ public static class Utils
|
||||||
return ((decimal)value) / 1.000000000000000000000000000000000m;
|
return ((decimal)value) / 1.000000000000000000000000000000000m;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int GetRecursiveFileCount(string directory)
|
public static int FileCountRecursive(string directory)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(directory))
|
if (!Directory.Exists(directory))
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
int count = Directory.GetFiles(directory).Length;
|
int count = Directory.GetFiles(directory).Length;
|
||||||
foreach (string subDirectory in Directory.GetDirectories(directory))
|
foreach (string subDirectory in Directory.GetDirectories(directory))
|
||||||
count += GetRecursiveFileCount(subDirectory);
|
count += FileCountRecursive(subDirectory);
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
@ -512,23 +512,23 @@ public static class Utils
|
||||||
return distance[source.Length, target.Length];
|
return distance[source.Length, target.Length];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string GreatestCommonPath(IEnumerable<string> paths, char dirsep)
|
public static string GreatestCommonPath(IEnumerable<string> paths)
|
||||||
{
|
{
|
||||||
string? path = paths.FirstOrDefault();
|
string? path = paths.FirstOrDefault();
|
||||||
|
|
||||||
if (path == null || path.Length == 0)
|
if (path == null || path.Length == 0)
|
||||||
return "";
|
return "";
|
||||||
|
|
||||||
int commonPathIndex(string path1, string path2, int maxIndex)
|
static int commonPathIndex(string path1, string path2, int maxIndex)
|
||||||
{
|
{
|
||||||
var minLength = Math.Min(path1.Length, Math.Min(path2.Length, maxIndex));
|
var minLength = Math.Min(path1.Length, Math.Min(path2.Length, maxIndex));
|
||||||
var commonPathLength = 0;
|
var commonPathLength = 0;
|
||||||
for (int i = 0; i < minLength; i++)
|
for (int i = 0; i < minLength; i++)
|
||||||
{
|
{
|
||||||
if (path1[i] != path2[i])
|
if ((path1[i] == '/' || path1[i] == '\\') && (path2[i] == '/' || path2[i] == '\\'))
|
||||||
break;
|
|
||||||
if (path1[i] == dirsep)
|
|
||||||
commonPathLength = i + 1;
|
commonPathLength = i + 1;
|
||||||
|
else if (path1[i] != path2[i])
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return commonPathLength;
|
return commonPathLength;
|
||||||
}
|
}
|
||||||
|
@ -541,6 +541,27 @@ public static class Utils
|
||||||
return path[..index];
|
return path[..index];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string GreatestCommonDirectory(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
if (paths.Skip(1).Any())
|
||||||
|
return NormalizedPath(GreatestCommonPath(paths));
|
||||||
|
else
|
||||||
|
return NormalizedPath(Path.GetDirectoryName(paths.First().TrimEnd('/').TrimEnd('\\')) ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GreatestCommonDirectorySlsk(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
if (paths.Skip(1).Any())
|
||||||
|
return Utils.GreatestCommonPath(paths).Replace('/', '\\').TrimEnd('\\');
|
||||||
|
else
|
||||||
|
return Utils.GetDirectoryNameSlsk(paths.First()).Replace('/', '\\').TrimEnd('\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizedPath(string path)
|
||||||
|
{
|
||||||
|
return path.Replace('\\', '/').TrimEnd('/').Trim();
|
||||||
|
}
|
||||||
|
|
||||||
public static bool SequenceEqualUpToPermutation<T>(this IEnumerable<T> list1, IEnumerable<T> list2)
|
public static bool SequenceEqualUpToPermutation<T>(this IEnumerable<T> list1, IEnumerable<T> list2)
|
||||||
{
|
{
|
||||||
var cnt = new Dictionary<T, int>();
|
var cnt = new Dictionary<T, int>();
|
||||||
|
|
Loading…
Reference in a new issue