mirror of
https://github.com/fiso64/slsk-batchdl.git
synced 2024-12-22 14:32:40 +00:00
commit
This commit is contained in:
parent
d56d68356a
commit
7cd634e54f
20 changed files with 849 additions and 390 deletions
55
README.md
55
README.md
|
@ -1,6 +1,6 @@
|
|||
# slsk-batchdl
|
||||
|
||||
A batch downloader for Soulseek built with Soulseek.NET. Accepts CSV files as well as Spotify and YouTube urls.
|
||||
An automatic downloader for Soulseek built with Soulseek.NET. Accepts CSV files as well as Spotify and YouTube urls.
|
||||
See the [examples](#examples-1).
|
||||
|
||||
## Index
|
||||
|
@ -41,7 +41,6 @@ Usage: sldl <input> [OPTIONS]
|
|||
```
|
||||
General Options
|
||||
-p, --path <path> Download directory
|
||||
-f, --folder <name> Subfolder name. Set to '.' to output directly to --path
|
||||
--input-type <type> Force set input type, [csv|youtube|spotify|bandcamp|string]
|
||||
--name-format <format> Name format for downloaded tracks. See --help name-format
|
||||
|
||||
|
@ -189,9 +188,6 @@ Usage: sldl <input> [OPTIONS]
|
|||
-t, --interactive Interactive mode, allows to select the folder and images
|
||||
--album-track-count <num> Specify the exact number of tracks in the album. Add a + or
|
||||
- for inequalities, e.g '5+' for five or more tracks.
|
||||
--album-ignore-fails Do not skip to the next source and do not delete all
|
||||
successfully downloaded files if one of the files in the
|
||||
folder fails to download
|
||||
--album-art <option> Retrieve additional images after downloading the album:
|
||||
'default': No additional images
|
||||
'largest': Download from the folder with the largest image
|
||||
|
@ -199,11 +195,17 @@ Usage: sldl <input> [OPTIONS]
|
|||
--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
|
||||
in the folder
|
||||
--failed-album-path Path to move all album files to when one of the items from
|
||||
the directory fails to download. Set to 'delete' to delete
|
||||
the files instead. Set to 'disable' keep it where it is.
|
||||
Default: {configured output dir}/failed
|
||||
```
|
||||
```
|
||||
Aggregate Download
|
||||
-g, --aggregate Aggregate download mode: Find and download all distinct
|
||||
songs associated with the provided artist, album, or title.
|
||||
--aggregate-length-tol <tol> Max length tolerance in seconds to consider two tracks or
|
||||
albums equal. (Default: 3)
|
||||
--min-shares-aggregate <num> Minimum number of shares of a track or album for it to be
|
||||
downloaded in aggregate mode. (Default: 2)
|
||||
--relax-filtering Slightly relax file filtering in aggregate mode to include
|
||||
|
@ -226,7 +228,7 @@ Usage: sldl <input> [OPTIONS]
|
|||
## Input types
|
||||
|
||||
The input type is usually determined automatically. To force a specific input type, set
|
||||
--input-type [spotify|youtube|csv|string|bandcamp]. The following input types are available:
|
||||
--input-type [spotify|youtube|csv|string|bandcamp|list]. The following input types are available:
|
||||
|
||||
### CSV file
|
||||
Path to a local CSV file: Use a csv file containing track info of the songs to download.
|
||||
|
@ -290,6 +292,7 @@ artist
|
|||
album
|
||||
length (in seconds)
|
||||
artist-maybe-wrong
|
||||
album-track-count
|
||||
```
|
||||
Example inputs and their interpretations:
|
||||
```
|
||||
|
@ -302,6 +305,17 @@ Input String | Artist | Title | Album | Lengt
|
|||
'artist=AR, title=T, album=AL' | AR | T | AL |
|
||||
```
|
||||
|
||||
### List
|
||||
A path to a text file where each line has the following form:
|
||||
```
|
||||
"some input" "conditions" "preferred conditions"
|
||||
"album=Album" "format=mp3; br > 128" "br >= 320"
|
||||
```
|
||||
Where "some input" is any of the above input types. The quotes can be omitted if the field
|
||||
contains no spaces. The conditions and preferred conditions fields are added on top of the
|
||||
configured conditions and can also be omitted. List input must be manually activated with
|
||||
--input-type=list.
|
||||
|
||||
## Download modes
|
||||
|
||||
### Normal
|
||||
|
@ -313,24 +327,18 @@ Input String | Artist | Title | Album | Lengt
|
|||
string or csv row has no track title, or when -a/--album is enabled.
|
||||
|
||||
### Aggregate
|
||||
With -g/--aggregate, sldl will first perform an ordinary search for the input, then attempt to
|
||||
group the results into distinct songs and download one of each kind. A common use case is
|
||||
finding all remixes of a song or printing all songs by an artist that are not your music dir.
|
||||
Two files are considered equal if their inferred track title and artist name are equal
|
||||
(ignoring case and some special characters), and their lengths are within --length-tol of each
|
||||
other.
|
||||
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.
|
||||
With -g/--aggregate, sldl performs an ordinary search for the input then attempts to
|
||||
group the results into distinct songs and download one of each kind, starting with the one
|
||||
which is shared by the most users.
|
||||
Note that --min-shares-aggregate is 2 by default, which means that songs shared by only
|
||||
one user will be ignored.
|
||||
|
||||
### Album Aggregate
|
||||
Activated when --album and --aggregate are enabled, in this mode sldl searches for the query
|
||||
and groups results into distinct albums. Two folders are considered same if they have the
|
||||
same number of audio files, and the durations of the files are within --length-tol of each
|
||||
other (or within 3 seconds if length-tol is not configured). If both folders have exactly one
|
||||
audio file with similar lengths, also checks if the inferred title and artist name coincide.
|
||||
More reliable than normal aggregate due to much simpler grouping logic.
|
||||
Note that --min-shares-aggregate is 2 by default, which means that folders shared only once
|
||||
will be ignored.
|
||||
Activated when both --album and --aggregate are enabled. sldl will group shares and download
|
||||
one of each distinct album, starting with the one shared by the most users. It's
|
||||
recommended to pair this with --interactive.
|
||||
Note that --min-shares-aggregate is 2 by default, which means that albums shared by only
|
||||
one user will be ignored.
|
||||
|
||||
|
||||
## Searching
|
||||
|
@ -407,7 +415,7 @@ only satisfies the format condition. Run with --print "results-full" to reveal t
|
|||
ranking with this option due to the bitrate and samplerate checks.
|
||||
|
||||
Conditions can also be supplied as a semicolon-delimited string with --cond and --pref, e.g
|
||||
--cond "br>=320;f=mp3,ogg;sr<96000"
|
||||
--cond "br >= 320; format = mp3,ogg; sr < 96000".
|
||||
|
||||
|
||||
## Name format
|
||||
|
@ -443,7 +451,6 @@ track Track number
|
|||
disc Disc number
|
||||
filename Soulseek filename without extension
|
||||
foldername Soulseek folder name
|
||||
default-foldername Default sldl folder name
|
||||
extractor Name of the extractor used (CSV/Spotify/YouTube/etc)
|
||||
```
|
||||
|
||||
|
|
|
@ -21,13 +21,10 @@ static class Config
|
|||
AcceptNoLength = false,
|
||||
};
|
||||
|
||||
public static string parentFolder = Directory.GetCurrentDirectory();
|
||||
public static string parentDir = Directory.GetCurrentDirectory();
|
||||
public static string input = "";
|
||||
public static string outputFolder = "";
|
||||
public static string m3uFilePath = "";
|
||||
public static string musicDir = "";
|
||||
public static string folderName = "";
|
||||
public static string defaultFolderName = "";
|
||||
public static string spotifyId = "";
|
||||
public static string spotifySecret = "";
|
||||
public static string spotifyToken = "";
|
||||
|
@ -49,11 +46,11 @@ static class Config
|
|||
public static string onComplete = "";
|
||||
public static string confPath = "";
|
||||
public static string profile = "";
|
||||
public static string failedAlbumPath = "";
|
||||
public static bool aggregate = false;
|
||||
public static bool album = false;
|
||||
public static bool albumArtOnly = false;
|
||||
public static bool interactiveMode = false;
|
||||
public static bool albumIgnoreFails = false;
|
||||
public static bool setAlbumMinTrackCount = true;
|
||||
public static bool setAlbumMaxTrackCount = false;
|
||||
public static bool skipNotFound = false;
|
||||
|
@ -94,6 +91,7 @@ static class Config
|
|||
public static int listenPort = 49998;
|
||||
public static int searchesPerTime = 34;
|
||||
public static int searchRenewTime = 220;
|
||||
public static int aggregateLengthTol = 3;
|
||||
public static double fastSearchMinUpSpeed = 1.0;
|
||||
public static Track regexToReplace = new();
|
||||
public static Track regexReplaceBy = new();
|
||||
|
@ -105,19 +103,22 @@ static class Config
|
|||
public static SkipMode skipModeMusicDir = SkipMode.Name;
|
||||
public static PrintOption printOption = PrintOption.None;
|
||||
|
||||
static readonly Dictionary<string, (List<string> args, string? cond)> profiles = new();
|
||||
static readonly HashSet<string> appliedProfiles = new();
|
||||
static bool hasConfiguredM3uMode = false;
|
||||
static bool confPathChanged = false;
|
||||
static string[] arguments;
|
||||
|
||||
public static bool HasAutoProfiles { get; private set; } = false;
|
||||
public static bool DoNotDownload => (printOption & (PrintOption.Results | PrintOption.Tracks)) != 0;
|
||||
public static bool PrintTracks => (printOption & PrintOption.Tracks) != 0;
|
||||
public static bool PrintResults => (printOption & PrintOption.Results) != 0;
|
||||
public static bool PrintTracksFull => (printOption & PrintOption.Tracks) != 0 && (printOption & PrintOption.Full) != 0;
|
||||
public static bool PrintResultsFull => (printOption & PrintOption.Results) != 0 && (printOption & PrintOption.Full) != 0;
|
||||
public static bool DeleteAlbumOnFail => failedAlbumPath == "delete";
|
||||
public static bool IgnoreAlbumFail => failedAlbumPath == "disable";
|
||||
|
||||
static readonly Dictionary<string, (List<string> args, string? cond)> profiles = new();
|
||||
static readonly HashSet<string> appliedProfiles = new();
|
||||
static bool hasConfiguredM3uMode = false;
|
||||
static bool confPathChanged = false;
|
||||
static string[] arguments;
|
||||
static FileConditions? prevConds = null;
|
||||
static FileConditions? prevPrefConds = null;
|
||||
|
||||
public static bool ParseArgsAndReadConfig(string[] args)
|
||||
{
|
||||
|
@ -218,7 +219,7 @@ static class Config
|
|||
}
|
||||
|
||||
|
||||
public static void PostProcessArgs() // must be run after Program.trackLists has been assigned
|
||||
public static void PostProcessArgs()
|
||||
{
|
||||
if (DoNotDownload || debugInfo)
|
||||
concurrentProcesses = 1;
|
||||
|
@ -227,38 +228,21 @@ static class Config
|
|||
|
||||
if (DoNotDownload)
|
||||
m3uOption = M3uOption.None;
|
||||
else if (!hasConfiguredM3uMode)
|
||||
{
|
||||
if (inputType == InputType.String)
|
||||
else if (!hasConfiguredM3uMode && inputType == InputType.String)
|
||||
m3uOption = M3uOption.None;
|
||||
else if (!aggregate && !(skipExisting && (skipMode == SkipMode.M3u || skipMode == SkipMode.M3uCond))
|
||||
&& Program.trackLists != null && !Program.trackLists.Flattened(true, false, true).Skip(1).Any())
|
||||
{
|
||||
m3uOption = M3uOption.None;
|
||||
}
|
||||
}
|
||||
|
||||
if (albumArtOnly && albumArtOption == AlbumArtOption.Default)
|
||||
albumArtOption = AlbumArtOption.Largest;
|
||||
|
||||
parentFolder = Utils.ExpandUser(parentFolder);
|
||||
m3uFilePath = Utils.ExpandUser(m3uFilePath);
|
||||
musicDir = Utils.ExpandUser(musicDir);
|
||||
|
||||
if (folderName.Length == 0)
|
||||
folderName = defaultFolderName;
|
||||
if (folderName == ".")
|
||||
folderName = "";
|
||||
|
||||
folderName = folderName.Replace('\\', '/');
|
||||
folderName = string.Join('/', folderName.Split('/').Select(x => x.ReplaceInvalidChars(invalidReplaceStr).Trim()));
|
||||
folderName = folderName.Replace('/', Path.DirectorySeparatorChar);
|
||||
|
||||
outputFolder = Path.Join(parentFolder, folderName);
|
||||
nameFormat = nameFormat.Trim();
|
||||
|
||||
if (m3uFilePath.Length == 0)
|
||||
m3uFilePath = Path.Join(outputFolder, (folderName.Length == 0 ? "playlist" : folderName) + ".m3u8");
|
||||
parentDir = Utils.ExpandUser(parentDir);
|
||||
m3uFilePath = Utils.ExpandUser(m3uFilePath);
|
||||
musicDir = Utils.ExpandUser(musicDir);
|
||||
failedAlbumPath = Utils.ExpandUser(failedAlbumPath);
|
||||
|
||||
if (failedAlbumPath.Length == 0)
|
||||
failedAlbumPath = Path.Join(parentDir, "failed");
|
||||
}
|
||||
|
||||
|
||||
|
@ -313,7 +297,7 @@ static class Config
|
|||
}
|
||||
|
||||
|
||||
public static void UpdateArgs(TrackListEntry tle)
|
||||
public static void UpdateProfiles(TrackListEntry tle)
|
||||
{
|
||||
if (DoNotDownload)
|
||||
return;
|
||||
|
@ -491,9 +475,32 @@ static class Config
|
|||
}
|
||||
|
||||
|
||||
static void ParseConditions(FileConditions cond, string input)
|
||||
public static void AddTemporaryConditions(FileConditionsPatch? cond, FileConditionsPatch? prefCond)
|
||||
{
|
||||
static void UpdateMinMax(string value, string condition, ref int min, ref int max)
|
||||
if (cond != null)
|
||||
{
|
||||
prevConds = necessaryCond;
|
||||
necessaryCond = necessaryCond.With(cond);
|
||||
}
|
||||
if (prefCond != null)
|
||||
{
|
||||
prevPrefConds = preferredCond;
|
||||
preferredCond = preferredCond.With(prefCond);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RestoreConditions()
|
||||
{
|
||||
if (prevConds != null)
|
||||
necessaryCond = prevConds;
|
||||
if (prevPrefConds != null)
|
||||
preferredCond = prevPrefConds;
|
||||
}
|
||||
|
||||
|
||||
public static FileConditionsPatch ParseConditions(string input)
|
||||
{
|
||||
static void UpdateMinMax(string value, string condition, ref int? min, ref int? max)
|
||||
{
|
||||
if (condition.Contains(">="))
|
||||
min = int.Parse(value);
|
||||
|
@ -507,6 +514,8 @@ static class Config
|
|||
min = max = int.Parse(value);
|
||||
}
|
||||
|
||||
var cond = new FileConditionsPatch();
|
||||
|
||||
var tr = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
|
||||
string[] conditions = input.Split(';', tr);
|
||||
foreach (string condition in conditions)
|
||||
|
@ -569,6 +578,8 @@ static class Config
|
|||
throw new ArgumentException($"Unknown condition '{condition}'");
|
||||
}
|
||||
}
|
||||
|
||||
return cond;
|
||||
}
|
||||
|
||||
|
||||
|
@ -614,21 +625,18 @@ static class Config
|
|||
"spotify" => InputType.Spotify,
|
||||
"bandcamp" => InputType.Bandcamp,
|
||||
"string" => InputType.String,
|
||||
"list" => InputType.List,
|
||||
_ => throw new ArgumentException($"Invalid input type '{args[i]}'"),
|
||||
};
|
||||
break;
|
||||
case "-p":
|
||||
case "--path":
|
||||
parentFolder = args[++i];
|
||||
parentDir = args[++i];
|
||||
break;
|
||||
case "-c":
|
||||
case "--config":
|
||||
confPath = args[++i];
|
||||
break;
|
||||
case "-f":
|
||||
case "--folder":
|
||||
folderName = args[++i];
|
||||
break;
|
||||
case "-m":
|
||||
case "--md":
|
||||
case "--music-dir":
|
||||
|
@ -942,9 +950,9 @@ static class Config
|
|||
preferredCond = new FileConditions();
|
||||
necessaryCond = new FileConditions();
|
||||
break;
|
||||
case "--aif":
|
||||
case "--album-ignore-fails":
|
||||
setFlag(ref albumIgnoreFails, ref i);
|
||||
case "--fap":
|
||||
case "--failed-album-path":
|
||||
failedAlbumPath = args[++i];
|
||||
break;
|
||||
case "-t":
|
||||
case "--interactive":
|
||||
|
@ -1063,12 +1071,12 @@ static class Config
|
|||
case "--c":
|
||||
case "--cond":
|
||||
case "--conditions":
|
||||
ParseConditions(necessaryCond, args[++i]);
|
||||
necessaryCond.With(ParseConditions(args[++i]));
|
||||
break;
|
||||
case "--pc":
|
||||
case "--pref":
|
||||
case "--preferred-conditions":
|
||||
ParseConditions(preferredCond, args[++i]);
|
||||
preferredCond.With(ParseConditions(args[++i]));
|
||||
break;
|
||||
case "--nmsc":
|
||||
case "--no-modify-share-count":
|
||||
|
@ -1177,6 +1185,10 @@ static class Config
|
|||
case "--skip-existing-pref-cond":
|
||||
setFlag(ref skipExistingPrefCond, ref i);
|
||||
break;
|
||||
case "--alt":
|
||||
case "--aggregate-length-tol":
|
||||
aggregateLengthTol = int.Parse(args[++i]);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException($"Unknown argument: {args[i]}");
|
||||
}
|
||||
|
@ -1193,4 +1205,41 @@ static class Config
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static string[] GetArgsArray(string commandLine)
|
||||
{
|
||||
var args = new List<string>();
|
||||
var currentArg = new StringBuilder();
|
||||
bool inQuotes = false;
|
||||
|
||||
for (int i = 0; i < commandLine.Length; i++)
|
||||
{
|
||||
char c = commandLine[i];
|
||||
|
||||
if (c == '\"')
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
else if (c == ' ' && !inQuotes)
|
||||
{
|
||||
if (currentArg.Length > 0)
|
||||
{
|
||||
args.Add(currentArg.ToString());
|
||||
currentArg.Clear();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
currentArg.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentArg.Length > 0)
|
||||
{
|
||||
args.Add(currentArg.ToString());
|
||||
}
|
||||
|
||||
return args.ToArray();
|
||||
}
|
||||
}
|
|
@ -100,38 +100,51 @@ namespace Data
|
|||
|
||||
public class TrackListEntry
|
||||
{
|
||||
public List<List<Track>> list;
|
||||
public List<List<Track>>? list;
|
||||
public Track source;
|
||||
public bool needSourceSearch = false;
|
||||
public bool sourceCanBeSkipped = false;
|
||||
public bool needSkipExistingAfterSearch = false;
|
||||
public bool gotoNextAfterSearch = false;
|
||||
public bool placeInSubdir = false;
|
||||
public string? defaultFolderName = null;
|
||||
public FileConditionsPatch? additionalConds = null;
|
||||
public FileConditionsPatch? additionalPrefConds = null;
|
||||
|
||||
public TrackListEntry()
|
||||
public TrackListEntry(TrackType trackType)
|
||||
{
|
||||
list = new List<List<Track>>();
|
||||
source = new Track();
|
||||
this.source = new Track() { Type = trackType };
|
||||
SetDefaults();
|
||||
}
|
||||
|
||||
public TrackListEntry(Track source)
|
||||
{
|
||||
list = new List<List<Track>>();
|
||||
this.source = source;
|
||||
|
||||
needSourceSearch = source.Type != TrackType.Normal;
|
||||
needSkipExistingAfterSearch = source.Type == TrackType.Aggregate;
|
||||
gotoNextAfterSearch = source.Type == TrackType.AlbumAggregate;
|
||||
sourceCanBeSkipped = source.Type != TrackType.Normal
|
||||
&& source.Type != TrackType.Aggregate
|
||||
&& source.Type != TrackType.AlbumAggregate;
|
||||
SetDefaults();
|
||||
}
|
||||
|
||||
public TrackListEntry(List<List<Track>> list, Track source)
|
||||
{
|
||||
this.list = list;
|
||||
this.source = source;
|
||||
SetDefaults();
|
||||
}
|
||||
|
||||
public TrackListEntry(List<List<Track>> list, Track source, bool needSourceSearch = false, bool sourceCanBeSkipped = false,
|
||||
bool needSkipExistingAfterSearch = false, bool gotoNextAfterSearch = false, string? defaultFoldername = null)
|
||||
{
|
||||
this.list = list;
|
||||
this.source = source;
|
||||
this.needSourceSearch = needSourceSearch;
|
||||
this.sourceCanBeSkipped = sourceCanBeSkipped;
|
||||
this.needSkipExistingAfterSearch = needSkipExistingAfterSearch;
|
||||
this.gotoNextAfterSearch = gotoNextAfterSearch;
|
||||
this.defaultFolderName = defaultFoldername;
|
||||
}
|
||||
|
||||
public void SetDefaults()
|
||||
{
|
||||
needSourceSearch = source.Type != TrackType.Normal;
|
||||
needSkipExistingAfterSearch = source.Type == TrackType.Aggregate;
|
||||
gotoNextAfterSearch = source.Type == TrackType.AlbumAggregate;
|
||||
|
@ -140,16 +153,14 @@ namespace Data
|
|||
&& source.Type != TrackType.AlbumAggregate;
|
||||
}
|
||||
|
||||
public TrackListEntry(List<List<Track>> list, Track source, bool needSearch, bool placeInSubdir,
|
||||
bool sourceCanBeSkipped, bool needSkipExistingAfterSearch, bool gotoNextAfterSearch)
|
||||
public void AddTrack(Track track)
|
||||
{
|
||||
this.list = list;
|
||||
this.source = source;
|
||||
this.needSourceSearch = needSearch;
|
||||
this.placeInSubdir = placeInSubdir;
|
||||
this.sourceCanBeSkipped = sourceCanBeSkipped;
|
||||
this.needSkipExistingAfterSearch = needSkipExistingAfterSearch;
|
||||
this.gotoNextAfterSearch = gotoNextAfterSearch;
|
||||
if (list == null)
|
||||
list = new List<List<Track>>() { new List<Track>() { track } };
|
||||
else if (list.Count == 0)
|
||||
list.Add(new List<Track>() { track });
|
||||
else
|
||||
list[0].Add(track);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -174,7 +185,7 @@ namespace Data
|
|||
}
|
||||
else
|
||||
{
|
||||
res.AddEntry(new TrackListEntry());
|
||||
res.AddEntry(new TrackListEntry(TrackType.Normal));
|
||||
res.AddTrackToLast(track);
|
||||
|
||||
bool hasNext;
|
||||
|
@ -252,11 +263,13 @@ namespace Data
|
|||
if (tle.source.Type == TrackType.Album && aggregate)
|
||||
{
|
||||
tle.source.Type = TrackType.AlbumAggregate;
|
||||
tle.SetDefaults();
|
||||
newLists.Add(tle);
|
||||
}
|
||||
else if (tle.source.Type == TrackType.Aggregate && album)
|
||||
{
|
||||
tle.source.Type = TrackType.AlbumAggregate;
|
||||
tle.SetDefaults();
|
||||
newLists.Add(tle);
|
||||
}
|
||||
else if (tle.source.Type == TrackType.Normal && (album || aggregate))
|
||||
|
@ -270,7 +283,9 @@ namespace Data
|
|||
else if (aggregate)
|
||||
track.Type = TrackType.Aggregate;
|
||||
|
||||
newLists.Add(new TrackListEntry(track));
|
||||
var newTle = new TrackListEntry(track);
|
||||
newTle.defaultFolderName = tle.defaultFolderName;
|
||||
newLists.Add(newTle);
|
||||
}
|
||||
}
|
||||
else
|
||||
|
@ -284,16 +299,11 @@ namespace Data
|
|||
|
||||
public void SetListEntryOptions()
|
||||
{
|
||||
// place downloads in subdirs if there is more than one special (album/aggregate) download
|
||||
bool placeInSubdirs = Flattened(true, false, true).Skip(1).Any();
|
||||
|
||||
if (placeInSubdirs)
|
||||
// aggregate downloads will be placed in subfolders by default
|
||||
foreach (var tle in lists)
|
||||
{
|
||||
foreach(var tle in lists)
|
||||
{
|
||||
if (tle.source.Type != TrackType.Normal)
|
||||
tle.placeInSubdir = true;
|
||||
}
|
||||
if (tle.source.Type == TrackType.Aggregate || tle.source.Type == TrackType.AlbumAggregate)
|
||||
tle.defaultFolderName = Path.Join(tle.defaultFolderName, tle.source.ToString(true));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ using SlDictionary = System.Collections.Concurrent.ConcurrentDictionary<string,
|
|||
|
||||
static class Download
|
||||
{
|
||||
public static async Task DownloadFile(SearchResponse response, Soulseek.File file, string filePath, Track track, ProgressBar progress, CancellationTokenSource? searchCts = null)
|
||||
public static async Task DownloadFile(SearchResponse response, Soulseek.File file, string filePath, Track track, ProgressBar progress, CancellationTokenSource cts, CancellationTokenSource? searchCts = null)
|
||||
{
|
||||
if (Config.DoNotDownload)
|
||||
throw new Exception();
|
||||
|
@ -42,16 +42,10 @@ static class Download
|
|||
|
||||
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)
|
||||
|
@ -65,10 +59,12 @@ static class Download
|
|||
|
||||
break;
|
||||
}
|
||||
catch (SoulseekClientException)
|
||||
catch (SoulseekClientException e)
|
||||
{
|
||||
retryCount++;
|
||||
|
||||
Printing.WriteLine($"Error while downloading: {e}", ConsoleColor.DarkYellow, debugOnly: true);
|
||||
|
||||
if (retryCount >= maxRetries || IsConnectedAndLoggedIn())
|
||||
throw;
|
||||
|
||||
|
@ -86,13 +82,13 @@ static class Download
|
|||
throw;
|
||||
}
|
||||
|
||||
try { searchCts?.Cancel(); }
|
||||
catch { }
|
||||
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)
|
||||
|
|
|
@ -37,6 +37,7 @@ namespace Enums
|
|||
Spotify,
|
||||
Bandcamp,
|
||||
String,
|
||||
List,
|
||||
None,
|
||||
}
|
||||
|
||||
|
@ -85,4 +86,11 @@ namespace Enums
|
|||
Normal,
|
||||
Verbose
|
||||
}
|
||||
|
||||
public enum AlbumFailOption
|
||||
{
|
||||
Ignore,
|
||||
Keep,
|
||||
Delete,
|
||||
}
|
||||
}
|
|
@ -16,17 +16,17 @@ namespace Extractors
|
|||
return input.IsInternetUrl() && input.Contains("bandcamp.com");
|
||||
}
|
||||
|
||||
public async Task<TrackLists> GetTracks(int maxTracks, int offset, bool reverse)
|
||||
public async Task<TrackLists> GetTracks(string input, int maxTracks, int offset, bool reverse)
|
||||
{
|
||||
var trackLists = new TrackLists();
|
||||
bool isTrack = Config.input.Contains("/track/");
|
||||
bool isAlbum = !isTrack && Config.input.Contains("/album/");
|
||||
bool isTrack = input.Contains("/track/");
|
||||
bool isAlbum = !isTrack && input.Contains("/album/");
|
||||
bool isArtist =!isTrack && !isAlbum;
|
||||
|
||||
if (isArtist)
|
||||
{
|
||||
Console.WriteLine("Retrieving bandcamp artist discography..");
|
||||
string artistUrl = Config.input.TrimEnd('/');
|
||||
string artistUrl = input.TrimEnd('/');
|
||||
|
||||
if (!artistUrl.EndsWith("/music"))
|
||||
artistUrl += "/music";
|
||||
|
@ -51,17 +51,14 @@ namespace Extractors
|
|||
foreach (var item in root.GetProperty("discography").EnumerateArray())
|
||||
{
|
||||
//ItemType = item.GetProperty("item_type").GetString(),
|
||||
var t = new Track()
|
||||
var track = new Track()
|
||||
{
|
||||
Album = item.GetProperty("title").GetString(),
|
||||
Artist = item.GetProperty("artist_name").GetString() ?? item.GetProperty("band_name").GetString(),
|
||||
Type = TrackType.Album,
|
||||
};
|
||||
var tle = new TrackListEntry()
|
||||
{
|
||||
source = t,
|
||||
placeInSubdir = true,
|
||||
};
|
||||
var tle = new TrackListEntry(track);
|
||||
tle.defaultFolderName = track.Artist;
|
||||
trackLists.AddEntry(tle);
|
||||
}
|
||||
}
|
||||
|
@ -69,7 +66,7 @@ namespace Extractors
|
|||
{
|
||||
Console.WriteLine("Retrieving bandcamp item..");
|
||||
var web = new HtmlWeb();
|
||||
var doc = await web.LoadFromWebAsync(Config.input);
|
||||
var doc = await web.LoadFromWebAsync(input);
|
||||
|
||||
var nameSection = doc.DocumentNode.SelectSingleNode("//div[@id='name-section']");
|
||||
var name = nameSection.SelectSingleNode(".//h2[contains(@class, 'trackTitle')]").InnerText.UnHtmlString().Trim();
|
||||
|
@ -91,8 +88,6 @@ namespace Extractors
|
|||
if (Config.setAlbumMaxTrackCount)
|
||||
track.MaxAlbumTrackCount = n;
|
||||
}
|
||||
|
||||
Config.defaultFolderName = track.ToString(true).ReplaceInvalidChars(Config.invalidReplaceStr).Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -101,10 +96,8 @@ namespace Extractors
|
|||
//var timeParts = doc.DocumentNode.SelectSingleNode("//span[@class='time_total']").InnerText.Trim().Split(':');
|
||||
|
||||
var track = new Track() { Artist = artist, Title = name, Album = album };
|
||||
trackLists.AddEntry(new());
|
||||
trackLists.AddEntry(new TrackListEntry(TrackType.Normal));
|
||||
trackLists.AddTrackToLast(track);
|
||||
|
||||
Config.defaultFolderName = ".";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,8 @@ namespace Extractors
|
|||
{
|
||||
public class CsvExtractor : IExtractor
|
||||
{
|
||||
object csvLock = new();
|
||||
string? csvFilePath = null;
|
||||
readonly object csvLock = new();
|
||||
int csvColumnCount = -1;
|
||||
|
||||
public static bool InputMatches(string input)
|
||||
|
@ -15,28 +16,26 @@ namespace Extractors
|
|||
return !input.IsInternetUrl() && input.EndsWith(".csv");
|
||||
}
|
||||
|
||||
public async Task<TrackLists> GetTracks(int maxTracks, int offset, bool reverse)
|
||||
public async Task<TrackLists> GetTracks(string input, int maxTracks, int offset, bool reverse)
|
||||
{
|
||||
if (!File.Exists(Config.input))
|
||||
if (!File.Exists(input))
|
||||
throw new FileNotFoundException("CSV file not found");
|
||||
|
||||
var tracks = await ParseCsvIntoTrackInfo(Config.input, Config.artistCol, Config.trackCol, Config.lengthCol,
|
||||
csvFilePath = input;
|
||||
|
||||
var tracks = await ParseCsvIntoTrackInfo(input, Config.artistCol, Config.trackCol, Config.lengthCol,
|
||||
Config.albumCol, Config.descCol, Config.ytIdCol, Config.trackCountCol, Config.timeUnit, Config.ytParse);
|
||||
|
||||
if (reverse)
|
||||
tracks.Reverse();
|
||||
|
||||
var trackLists = TrackLists.FromFlattened(tracks.Skip(offset).Take(maxTracks));
|
||||
var csvName = Path.GetFileNameWithoutExtension(input);
|
||||
|
||||
foreach (var tle in trackLists.lists)
|
||||
{
|
||||
if (tle.source.Type != TrackType.Normal)
|
||||
{
|
||||
tle.placeInSubdir = true;
|
||||
tle.defaultFolderName = csvName;
|
||||
}
|
||||
}
|
||||
|
||||
Config.defaultFolderName = Path.GetFileNameWithoutExtension(Config.input);
|
||||
|
||||
return trackLists;
|
||||
}
|
||||
|
@ -45,16 +44,16 @@ namespace Extractors
|
|||
{
|
||||
lock (csvLock)
|
||||
{
|
||||
if (File.Exists(Config.input))
|
||||
if (File.Exists(csvFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
string[] lines = File.ReadAllLines(Config.input, System.Text.Encoding.UTF8);
|
||||
string[] lines = File.ReadAllLines(csvFilePath, System.Text.Encoding.UTF8);
|
||||
|
||||
if (track.CsvRow > -1 && track.CsvRow < lines.Length)
|
||||
{
|
||||
lines[track.CsvRow] = new string(',', Math.Max(0, csvColumnCount - 1));
|
||||
Utils.WriteAllLines(Config.input, lines, '\n');
|
||||
Utils.WriteAllLines(csvFilePath, lines, '\n');
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
|
@ -193,7 +192,7 @@ namespace Extractors
|
|||
return tracks;
|
||||
}
|
||||
|
||||
double ParseTrackLength(string duration, string format)
|
||||
static double ParseTrackLength(string duration, string format)
|
||||
{
|
||||
if (string.IsNullOrEmpty(format))
|
||||
throw new ArgumentException("Duration format string empty");
|
||||
|
@ -224,6 +223,5 @@ namespace Extractors
|
|||
|
||||
return totalSeconds;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
146
slsk-batchdl/Extractors/List.cs
Normal file
146
slsk-batchdl/Extractors/List.cs
Normal file
|
@ -0,0 +1,146 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Data;
|
||||
using Enums;
|
||||
|
||||
namespace Extractors
|
||||
{
|
||||
public class ListExtractor : IExtractor
|
||||
{
|
||||
string? listFilePath = null;
|
||||
readonly object fileLock = new object();
|
||||
|
||||
public static bool InputMatches(string input)
|
||||
{
|
||||
return !input.IsInternetUrl();
|
||||
}
|
||||
|
||||
public async Task<TrackLists> GetTracks(string input, int maxTracks, int offset, bool reverse)
|
||||
{
|
||||
if (!File.Exists(input))
|
||||
throw new FileNotFoundException("List file not found");
|
||||
|
||||
listFilePath = input;
|
||||
|
||||
var lines = File.ReadAllLines(input);
|
||||
|
||||
var trackLists = new TrackLists();
|
||||
|
||||
int step = reverse ? -1 : 1;
|
||||
int start = reverse ? lines.Length - 1 : 0;
|
||||
int count = 0;
|
||||
int added = 0;
|
||||
|
||||
string foldername = Path.GetFileNameWithoutExtension(input);
|
||||
|
||||
for (int i = start; i < lines.Length && i >= 0; i += step)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
|
||||
if (line.Length == 0 || line.StartsWith('#')) continue;
|
||||
|
||||
if (count++ < offset)
|
||||
continue;
|
||||
|
||||
if (added >= maxTracks)
|
||||
break;
|
||||
|
||||
var fields = ParseLine(line);
|
||||
|
||||
var (_, ex) = ExtractorRegistry.GetMatchingExtractor(fields[0]);
|
||||
|
||||
var tl = await ex.GetTracks(fields[0], int.MaxValue, 0, false);
|
||||
|
||||
foreach (var tle in tl.lists)
|
||||
{
|
||||
if (fields.Count >= 2)
|
||||
tle.additionalConds = Config.ParseConditions(fields[1]);
|
||||
if (fields.Count >= 3)
|
||||
tle.additionalPrefConds = Config.ParseConditions(fields[2]);
|
||||
|
||||
tle.defaultFolderName = foldername;
|
||||
}
|
||||
|
||||
if (tl.lists.Count == 1)
|
||||
tl[0].source.CsvRow = i;
|
||||
|
||||
trackLists.lists.AddRange(tl.lists);
|
||||
|
||||
added++;
|
||||
}
|
||||
|
||||
return trackLists;
|
||||
}
|
||||
|
||||
static List<string> ParseLine(string input)
|
||||
{
|
||||
var fields = new List<string>();
|
||||
|
||||
bool inQuotes = false;
|
||||
var currentField = new StringBuilder();
|
||||
input = input.Replace('\t', ' ');
|
||||
|
||||
for (int i = 0; i < input.Length; i++)
|
||||
{
|
||||
char c = input[i];
|
||||
|
||||
if (c == '"')
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
else if (c == ' ' && !inQuotes)
|
||||
{
|
||||
if (currentField.Length > 0)
|
||||
{
|
||||
fields.Add(currentField.ToString());
|
||||
currentField.Clear();
|
||||
}
|
||||
|
||||
while (i < input.Length - 1 && input[i + 1] == ' ')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
currentField.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentField.Length > 0)
|
||||
{
|
||||
fields.Add(currentField.ToString());
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
public async Task RemoveTrackFromSource(Track track)
|
||||
{
|
||||
lock (fileLock)
|
||||
{
|
||||
if (File.Exists(listFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
string[] lines = File.ReadAllLines(listFilePath, Encoding.UTF8);
|
||||
|
||||
if (track.CsvRow > -1 && track.CsvRow < lines.Length)
|
||||
{
|
||||
lines[track.CsvRow] = "";
|
||||
Utils.WriteAllLines(listFilePath, lines, '\n');
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Printing.WriteLine($"Error removing from source: {e}", debugOnly: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,37 +19,35 @@ namespace Extractors
|
|||
return input == "spotify-likes" || input.IsInternetUrl() && input.Contains("spotify.com");
|
||||
}
|
||||
|
||||
public async Task<TrackLists> GetTracks(int maxTracks, int offset, bool reverse)
|
||||
public async Task<TrackLists> GetTracks(string input, int maxTracks, int offset, bool reverse)
|
||||
{
|
||||
var trackLists = new TrackLists();
|
||||
int max = reverse ? int.MaxValue : maxTracks;
|
||||
int off = reverse ? 0 : offset;
|
||||
|
||||
string playlistName = "";
|
||||
bool needLogin = Config.input == "spotify-likes" || Config.removeTracksFromSource;
|
||||
var tle = new TrackListEntry();
|
||||
bool needLogin = input == "spotify-likes" || Config.removeTracksFromSource;
|
||||
var tle = new TrackListEntry(TrackType.Normal);
|
||||
|
||||
if (needLogin && Config.spotifyToken.Length == 0 && (Config.spotifyId.Length == 0 || Config.spotifySecret.Length == 0))
|
||||
{
|
||||
Console.WriteLine("Error: Credentials are required when downloading liked music or removing from source playlists.");
|
||||
Environment.Exit(0);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
spotifyClient = new Spotify(Config.spotifyId, Config.spotifySecret, Config.spotifyToken, Config.spotifyRefresh);
|
||||
await spotifyClient.Authorize(needLogin, Config.removeTracksFromSource);
|
||||
|
||||
if (Config.input == "spotify-likes")
|
||||
if (input == "spotify-likes")
|
||||
{
|
||||
Console.WriteLine("Loading Spotify likes..");
|
||||
var tracks = await spotifyClient.GetLikes(max, off);
|
||||
playlistName = "Spotify Likes";
|
||||
tle.defaultFolderName = "Spotify Likes";
|
||||
tle.list.Add(tracks);
|
||||
}
|
||||
else if (Config.input.Contains("/album/"))
|
||||
else if (input.Contains("/album/"))
|
||||
{
|
||||
Console.WriteLine("Loading Spotify album..");
|
||||
(var source, var tracks) = await spotifyClient.GetAlbum(Config.input);
|
||||
playlistName = source.ToString(noInfo: true);
|
||||
(var source, var tracks) = await spotifyClient.GetAlbum(input);
|
||||
tle.source = source;
|
||||
|
||||
if (Config.setAlbumMinTrackCount)
|
||||
|
@ -58,11 +56,11 @@ namespace Extractors
|
|||
if (Config.setAlbumMaxTrackCount)
|
||||
source.MaxAlbumTrackCount = tracks.Count;
|
||||
}
|
||||
else if (Config.input.Contains("/artist/"))
|
||||
else if (input.Contains("/artist/"))
|
||||
{
|
||||
Console.WriteLine("Loading spotify artist..");
|
||||
Console.WriteLine("Error: Spotify artist download currently not supported.");
|
||||
Environment.Exit(0);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -71,19 +69,21 @@ namespace Extractors
|
|||
try
|
||||
{
|
||||
Console.WriteLine("Loading Spotify playlist");
|
||||
(playlistName, playlistUri, tracks) = await spotifyClient.GetPlaylist(Config.input, max, off);
|
||||
(var playlistName, playlistUri, tracks) = await spotifyClient.GetPlaylist(input, max, off);
|
||||
tle.defaultFolderName = playlistName;
|
||||
}
|
||||
catch (SpotifyAPI.Web.APIException)
|
||||
{
|
||||
if (!needLogin && !spotifyClient.UsedDefaultCredentials)
|
||||
{
|
||||
await spotifyClient.Authorize(true, Config.removeTracksFromSource);
|
||||
(playlistName, playlistUri, tracks) = await spotifyClient.GetPlaylist(Config.input, max, off);
|
||||
(var playlistName, playlistUri, tracks) = await spotifyClient.GetPlaylist(input, max, off);
|
||||
tle.defaultFolderName = playlistName;
|
||||
}
|
||||
else if (!needLogin)
|
||||
{
|
||||
Console.WriteLine("Spotify playlist not found (it may be set to private, but no credentials have been provided).");
|
||||
Environment.Exit(0);
|
||||
Console.WriteLine("Error: Spotify playlist not found (it may be set to private, but no credentials have been provided).");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
else throw;
|
||||
}
|
||||
|
@ -91,8 +91,6 @@ namespace Extractors
|
|||
tle.list.Add(tracks);
|
||||
}
|
||||
|
||||
Config.defaultFolderName = playlistName.ReplaceInvalidChars(Config.invalidReplaceStr);
|
||||
|
||||
trackLists.AddEntry(tle);
|
||||
|
||||
if (reverse)
|
||||
|
|
|
@ -11,35 +11,33 @@ namespace Extractors
|
|||
return !input.IsInternetUrl();
|
||||
}
|
||||
|
||||
public async Task<TrackLists> GetTracks(int maxTracks, int offset, bool reverse)
|
||||
public async Task<TrackLists> GetTracks(string input, int maxTracks, int offset, bool reverse)
|
||||
{
|
||||
var trackLists = new TrackLists();
|
||||
var music = ParseTrackArg(Config.input, Config.album);
|
||||
var music = ParseTrackArg(input, Config.album);
|
||||
TrackListEntry tle;
|
||||
|
||||
if (Config.album || (music.Title.Length == 0 && music.Album.Length > 0))
|
||||
{
|
||||
music.Type = TrackType.Album;
|
||||
trackLists.AddEntry(new TrackListEntry(music));
|
||||
tle = new TrackListEntry(music);
|
||||
}
|
||||
else
|
||||
{
|
||||
trackLists.AddEntry(new TrackListEntry());
|
||||
trackLists.AddTrackToLast(music);
|
||||
tle = new TrackListEntry(TrackType.Normal);
|
||||
tle.AddTrack(music);
|
||||
}
|
||||
|
||||
if (Config.aggregate || Config.album || music.Type != TrackType.Normal)
|
||||
Config.defaultFolderName = music.ToString(true).ReplaceInvalidChars(Config.invalidReplaceStr).Trim();
|
||||
else
|
||||
Config.defaultFolderName = ".";
|
||||
trackLists.AddEntry(tle);
|
||||
|
||||
return trackLists;
|
||||
}
|
||||
|
||||
static public Track ParseTrackArg(string input, bool isAlbum)
|
||||
public static Track ParseTrackArg(string input, bool isAlbum)
|
||||
{
|
||||
input = input.Trim();
|
||||
var track = new Track();
|
||||
var keys = new string[] { "title", "artist", "length", "album", "artist-maybe-wrong" };
|
||||
var keys = new string[] { "title", "artist", "length", "album", "artist-maybe-wrong", "album-track-count" };
|
||||
|
||||
void setProperty(string key, string value)
|
||||
{
|
||||
|
@ -60,6 +58,26 @@ namespace Extractors
|
|||
case "artist-maybe-wrong":
|
||||
if (value == "true") track.ArtistMaybeWrong = true;
|
||||
break;
|
||||
case "album-track-count":
|
||||
if (value == "-1")
|
||||
{
|
||||
track.MinAlbumTrackCount = -1;
|
||||
track.MaxAlbumTrackCount = -1;
|
||||
}
|
||||
else if (value.Last() == '-')
|
||||
{
|
||||
track.MaxAlbumTrackCount = int.Parse(value[..^1]);
|
||||
}
|
||||
else if (value.Last() == '+')
|
||||
{
|
||||
track.MinAlbumTrackCount = int.Parse(value[..^1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
track.MinAlbumTrackCount = int.Parse(value);
|
||||
track.MaxAlbumTrackCount = track.MinAlbumTrackCount;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ using HtmlAgilityPack;
|
|||
using System.Collections.Concurrent;
|
||||
|
||||
using Data;
|
||||
using Enums;
|
||||
|
||||
namespace Extractors
|
||||
{
|
||||
|
@ -20,7 +21,7 @@ namespace Extractors
|
|||
return input.IsInternetUrl() && (input.Contains("youtu.be") || input.Contains("youtube.com"));
|
||||
}
|
||||
|
||||
public async Task<TrackLists> GetTracks(int maxTracks, int offset, bool reverse)
|
||||
public async Task<TrackLists> GetTracks(string input, int maxTracks, int offset, bool reverse)
|
||||
{
|
||||
var trackLists = new TrackLists();
|
||||
int max = reverse ? int.MaxValue : maxTracks;
|
||||
|
@ -35,24 +36,24 @@ namespace Extractors
|
|||
{
|
||||
Console.WriteLine("Getting deleted videos..");
|
||||
var archive = new YouTube.YouTubeArchiveRetriever();
|
||||
deleted = await archive.RetrieveDeleted(Config.input, printFailed: Config.deletedOnly);
|
||||
deleted = await archive.RetrieveDeleted(input, printFailed: Config.deletedOnly);
|
||||
}
|
||||
if (!Config.deletedOnly)
|
||||
{
|
||||
if (YouTube.apiKey.Length > 0)
|
||||
{
|
||||
Console.WriteLine("Loading YouTube playlist (API)");
|
||||
(name, tracks) = await YouTube.GetTracksApi(Config.input, max, off);
|
||||
(name, tracks) = await YouTube.GetTracksApi(input, max, off);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Loading YouTube playlist");
|
||||
(name, tracks) = await YouTube.GetTracksYtExplode(Config.input, max, off);
|
||||
(name, tracks) = await YouTube.GetTracksYtExplode(input, max, off);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
name = await YouTube.GetPlaylistTitle(Config.input);
|
||||
name = await YouTube.GetPlaylistTitle(input);
|
||||
}
|
||||
if (deleted != null)
|
||||
{
|
||||
|
@ -61,11 +62,12 @@ namespace Extractors
|
|||
|
||||
YouTube.StopService();
|
||||
|
||||
var tle = new TrackListEntry();
|
||||
tle.list.Add(tracks);
|
||||
trackLists.AddEntry(tle);
|
||||
var tle = new TrackListEntry(TrackType.Normal);
|
||||
|
||||
Config.defaultFolderName = name.ReplaceInvalidChars(Config.invalidReplaceStr);
|
||||
tle.defaultFolderName = name;
|
||||
tle.list.Add(tracks);
|
||||
|
||||
trackLists.AddEntry(tle);
|
||||
|
||||
if (reverse)
|
||||
{
|
||||
|
|
|
@ -6,7 +6,7 @@ namespace Extractors
|
|||
{
|
||||
public interface IExtractor
|
||||
{
|
||||
Task<TrackLists> GetTracks(int maxTracks, int offset, bool reverse);
|
||||
Task<TrackLists> GetTracks(string input, int maxTracks, int offset, bool reverse);
|
||||
Task RemoveTrackFromSource(Track track) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
@ -19,18 +19,29 @@ namespace Extractors
|
|||
(InputType.Spotify, SpotifyExtractor.InputMatches, () => new SpotifyExtractor()),
|
||||
(InputType.Bandcamp, BandcampExtractor.InputMatches, () => new BandcampExtractor()),
|
||||
(InputType.String, StringExtractor.InputMatches, () => new StringExtractor()),
|
||||
(InputType.List, ListExtractor.InputMatches, () => new ListExtractor()),
|
||||
};
|
||||
|
||||
public static (InputType, IExtractor?) GetMatchingExtractor(string input)
|
||||
public static (InputType, IExtractor) GetMatchingExtractor(string input, InputType inputType = InputType.None)
|
||||
{
|
||||
foreach ((var inputType, var inputMatches, var extractor) in extractors)
|
||||
if (string.IsNullOrEmpty(input))
|
||||
throw new ArgumentException("Input string can not be null or empty.");
|
||||
|
||||
if (inputType != InputType.None)
|
||||
{
|
||||
var (t, _, e) = extractors.First(x => x.Item1 == inputType);
|
||||
return (t, e());
|
||||
}
|
||||
|
||||
foreach ((var type, var inputMatches, var extractor) in extractors)
|
||||
{
|
||||
if (inputMatches(input))
|
||||
{
|
||||
return (inputType, extractor());
|
||||
return (type, extractor());
|
||||
}
|
||||
}
|
||||
return (InputType.None, null);
|
||||
|
||||
throw new ArgumentException($"No matching extractor for input '{input}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,6 +41,58 @@ public class FileConditions
|
|||
BannedUsers = other.BannedUsers.ToArray();
|
||||
}
|
||||
|
||||
public FileConditions With(FileConditionsPatch patch)
|
||||
{
|
||||
var cond = new FileConditions(this);
|
||||
|
||||
if (patch.LengthTolerance != null)
|
||||
cond.LengthTolerance = patch.LengthTolerance.Value;
|
||||
|
||||
if (patch.MinBitrate != null)
|
||||
cond.MinBitrate = patch.MinBitrate.Value;
|
||||
|
||||
if (patch.MaxBitrate != null)
|
||||
cond.MaxBitrate = patch.MaxBitrate.Value;
|
||||
|
||||
if (patch.MinSampleRate != null)
|
||||
cond.MinSampleRate = patch.MinSampleRate.Value;
|
||||
|
||||
if (patch.MaxSampleRate != null)
|
||||
cond.MaxSampleRate = patch.MaxSampleRate.Value;
|
||||
|
||||
if (patch.MinBitDepth != null)
|
||||
cond.MinBitDepth = patch.MinBitDepth.Value;
|
||||
|
||||
if (patch.MaxBitDepth != null)
|
||||
cond.MaxBitDepth = patch.MaxBitDepth.Value;
|
||||
|
||||
if (patch.StrictTitle != null)
|
||||
cond.StrictTitle = patch.StrictTitle.Value;
|
||||
|
||||
if (patch.StrictArtist != null)
|
||||
cond.StrictArtist = patch.StrictArtist.Value;
|
||||
|
||||
if (patch.StrictAlbum != null)
|
||||
cond.StrictAlbum = patch.StrictAlbum.Value;
|
||||
|
||||
if (patch.Formats != null)
|
||||
cond.Formats = patch.Formats;
|
||||
|
||||
if (patch.BannedUsers != null)
|
||||
cond.BannedUsers = patch.BannedUsers;
|
||||
|
||||
if (patch.StrictStringDiacrRemove != null)
|
||||
cond.StrictStringDiacrRemove = patch.StrictStringDiacrRemove.Value;
|
||||
|
||||
if (patch.AcceptNoLength != null)
|
||||
cond.AcceptNoLength = patch.AcceptNoLength.Value;
|
||||
|
||||
if (patch.AcceptMissingProps != null)
|
||||
cond.AcceptMissingProps = patch.AcceptMissingProps.Value;
|
||||
|
||||
return cond;
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
if (obj is FileConditions other)
|
||||
|
@ -244,3 +296,24 @@ public class FileConditions
|
|||
return "Satisfied";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public class FileConditionsPatch
|
||||
{
|
||||
public int? LengthTolerance = null;
|
||||
public int? MinBitrate = null;
|
||||
public int? MaxBitrate = null;
|
||||
public int? MinSampleRate = null;
|
||||
public int? MaxSampleRate = null;
|
||||
public int? MinBitDepth = null;
|
||||
public int? MaxBitDepth = null;
|
||||
public bool? StrictTitle = null;
|
||||
public bool? StrictArtist = null;
|
||||
public bool? StrictAlbum = null;
|
||||
public string[]? Formats = null;
|
||||
public string[]? BannedUsers = null;
|
||||
public bool? StrictStringDiacrRemove = null;
|
||||
public bool? AcceptNoLength = null;
|
||||
public bool? AcceptMissingProps = null;
|
||||
}
|
||||
|
||||
|
|
|
@ -7,16 +7,15 @@ using System.Text.RegularExpressions;
|
|||
|
||||
using Data;
|
||||
using Enums;
|
||||
using System.ComponentModel;
|
||||
|
||||
|
||||
public class FileManager
|
||||
{
|
||||
readonly TrackListEntry? tle;
|
||||
readonly TrackListEntry tle;
|
||||
readonly HashSet<Track> organized = new();
|
||||
string? remoteCommonDir;
|
||||
|
||||
public FileManager() { }
|
||||
|
||||
public FileManager(TrackListEntry tle)
|
||||
{
|
||||
this.tle = tle;
|
||||
|
@ -24,18 +23,24 @@ public class FileManager
|
|||
|
||||
public string GetSavePath(string sourceFname)
|
||||
{
|
||||
return $"{GetSavePathNoExt(sourceFname)}{Path.GetExtension(sourceFname)}";
|
||||
return GetSavePathNoExt(sourceFname) + Path.GetExtension(sourceFname);
|
||||
}
|
||||
|
||||
public string GetSavePathNoExt(string sourceFname)
|
||||
{
|
||||
string parent = Config.parentDir;
|
||||
string name = Utils.GetFileNameWithoutExtSlsk(sourceFname);
|
||||
name = name.ReplaceInvalidChars(Config.invalidReplaceStr);
|
||||
|
||||
string parent = Config.outputFolder;
|
||||
|
||||
if (tle != null && tle.placeInSubdir && remoteCommonDir != null)
|
||||
if (tle.defaultFolderName != null)
|
||||
{
|
||||
parent = Path.Join(parent, tle.defaultFolderName.ReplaceInvalidChars(Config.invalidReplaceStr, removeSlash: false));
|
||||
}
|
||||
|
||||
if (tle.source.Type == TrackType.Album)
|
||||
{
|
||||
if (remoteCommonDir == null)
|
||||
throw new NullReferenceException("Remote common dir needs to be configured to organize album files");
|
||||
|
||||
string dirname = Path.GetFileName(remoteCommonDir);
|
||||
string relpath = Path.GetRelativePath(remoteCommonDir, Utils.NormalizedPath(sourceFname));
|
||||
parent = Path.Join(parent, dirname, Path.GetDirectoryName(relpath));
|
||||
|
@ -51,17 +56,12 @@ public class FileManager
|
|||
|
||||
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);
|
||||
OrganizeAudio(track, track.FirstDownload);
|
||||
}
|
||||
|
||||
bool onlyAdditionalImages = Config.nameFormat.Length == 0;
|
||||
|
@ -83,20 +83,19 @@ public class FileManager
|
|||
}
|
||||
}
|
||||
|
||||
public void OrganizeAudio(TrackListEntry tle, Track track, Soulseek.File? file)
|
||||
public void OrganizeAudio(Track track, Soulseek.File? file)
|
||||
{
|
||||
if (track.DownloadPath.Length == 0 || !Utils.IsMusicFile(track.DownloadPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
else if (Config.nameFormat.Length == 0)
|
||||
|
||||
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));
|
||||
string newFilePath = Path.Join(Config.parentDir, pathPart + Path.GetExtension(track.DownloadPath));
|
||||
|
||||
try
|
||||
{
|
||||
|
@ -141,15 +140,7 @@ public class FileManager
|
|||
{
|
||||
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));
|
||||
}
|
||||
Utils.DeleteAncestorsIfEmpty(Path.GetDirectoryName(oldPath), Config.parentDir);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -172,25 +163,33 @@ public class FileManager
|
|||
inner = inner[1..^1];
|
||||
|
||||
var options = inner.Split('|');
|
||||
string chosenOpt = "";
|
||||
string? chosenOpt = null;
|
||||
|
||||
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))
|
||||
if (result.All(x => TryGetVarValue(x, file, slfile, track, out string res) && res.Length > 0))
|
||||
{
|
||||
chosenOpt = opt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (chosenOpt == null)
|
||||
{
|
||||
chosenOpt = options[^1];
|
||||
}
|
||||
|
||||
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);
|
||||
{
|
||||
TryGetVarValue(match.Value, file, slfile, track, out string res);
|
||||
return res;
|
||||
}
|
||||
});
|
||||
|
||||
string old = match.Groups[1].Value;
|
||||
|
@ -213,10 +212,8 @@ public class FileManager
|
|||
return format;
|
||||
}
|
||||
|
||||
string GetVarValue(string x, TagLib.File? file, Soulseek.File? slfile, Track track)
|
||||
bool TryGetVarValue(string x, TagLib.File? file, Soulseek.File? slfile, Track track, out string res)
|
||||
{
|
||||
string res;
|
||||
|
||||
switch (x)
|
||||
{
|
||||
case "artist":
|
||||
|
@ -249,23 +246,24 @@ public class FileManager
|
|||
case "foldername":
|
||||
if (remoteCommonDir == null || slfile == null)
|
||||
{
|
||||
return Utils.GetBaseNameSlsk(Utils.GetDirectoryNameSlsk(slfile?.Filename ?? ""));
|
||||
res = Utils.GetBaseNameSlsk(Utils.GetDirectoryNameSlsk(slfile?.Filename ?? ""));
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
string d = Path.GetDirectoryName(Utils.NormalizedPath(slfile.Filename));
|
||||
string r = Path.GetFileName(remoteCommonDir);
|
||||
return Path.Join(r, Path.GetRelativePath(remoteCommonDir, d));
|
||||
res = Path.Join(r, Path.GetRelativePath(remoteCommonDir, d));
|
||||
return true;
|
||||
}
|
||||
case "default-foldername":
|
||||
res = Config.defaultFolderName; break;
|
||||
case "extractor":
|
||||
res = Config.inputType.ToString(); break;
|
||||
default:
|
||||
res = ""; break;
|
||||
res = x; return false;
|
||||
}
|
||||
|
||||
return res.ReplaceInvalidChars(Config.invalidReplaceStr);
|
||||
res = res.ReplaceInvalidChars(Config.invalidReplaceStr);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,8 +19,7 @@ public static class Help
|
|||
|
||||
General Options
|
||||
-p, --path <path> Download directory
|
||||
-f, --folder <name> Subfolder name. Set to '.' to output directly to --path
|
||||
--input-type <type> Force set input type, [csv|youtube|spotify|bandcamp|string]
|
||||
--input-type <type> [csv|youtube|spotify|bandcamp|string|list]
|
||||
--name-format <format> Name format for downloaded tracks. See --help name-format
|
||||
|
||||
-n, --number <maxtracks> Download the first n tracks of a playlist
|
||||
|
@ -161,9 +160,6 @@ public static class Help
|
|||
-t, --interactive Interactive mode, allows to select the folder and images
|
||||
--album-track-count <num> Specify the exact number of tracks in the album. Add a + or
|
||||
- for inequalities, e.g '5+' for five or more tracks.
|
||||
--album-ignore-fails Do not skip to the next source and do not delete all
|
||||
successfully downloaded files if one of the files in the
|
||||
folder fails to download
|
||||
--album-art <option> Retrieve additional images after downloading the album:
|
||||
'default': No additional images
|
||||
'largest': Download from the folder with the largest image
|
||||
|
@ -171,10 +167,16 @@ public static class Help
|
|||
--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
|
||||
in the folder
|
||||
--failed-album-path Path to move all album files to when one of the items from
|
||||
the directory fails to download. Set to 'delete' to delete
|
||||
the files instead. Set to the empty string """" to disable.
|
||||
Default: {configured output dir}/failed
|
||||
|
||||
Aggregate Download
|
||||
-g, --aggregate Aggregate download mode: Find and download all distinct
|
||||
songs associated with the provided artist, album, or title.
|
||||
--aggregate-length-tol <tol> Max length tolerance in seconds to consider two tracks or
|
||||
albums equal. (Default: 3)
|
||||
--min-shares-aggregate <num> Minimum number of shares of a track or album for it to be
|
||||
downloaded in aggregate mode. (Default: 2)
|
||||
--relax-filtering Slightly relax file filtering in aggregate mode to include
|
||||
|
@ -196,7 +198,7 @@ public static class Help
|
|||
Input types
|
||||
|
||||
The input type is usually determined automatically. To force a specific input type, set
|
||||
--input-type [spotify|youtube|csv|string|bandcamp]. The following input types are available:
|
||||
--input-type [spotify|youtube|csv|string|bandcamp|list]. The following input types are available:
|
||||
|
||||
CSV file
|
||||
Path to a local CSV file: Use a csv file containing track info of the songs to download.
|
||||
|
@ -259,6 +261,7 @@ public static class Help
|
|||
album
|
||||
length (in seconds)
|
||||
artist-maybe-wrong
|
||||
album-track-count
|
||||
|
||||
Example inputs and their interpretations:
|
||||
Input String | Artist | Title | Album | Length
|
||||
|
@ -268,6 +271,17 @@ public static class Help
|
|||
'Foo - Bar' (with --album enabled) | Foo | | Bar |
|
||||
'Artist - Title, length=42' | Artist | Title | | 42
|
||||
'artist=AR, title=T, album=AL' | AR | T | AL |
|
||||
|
||||
List
|
||||
A path to a text file where each line has the following form:
|
||||
|
||||
""some input"" ""conditions"" ""preferred conditions""
|
||||
""album=Album"" ""format=mp3; br > 128"" ""br >= 320""
|
||||
|
||||
Where ""some input"" is any of the above input types. The quotes can be omitted if the field
|
||||
contains no spaces. The conditions and preferred conditions fields are added on top of the
|
||||
configured conditions and can also be omitted. List input must be manually activated with
|
||||
--input-type=list.
|
||||
";
|
||||
|
||||
const string downloadModesHelp = @"
|
||||
|
@ -282,24 +296,18 @@ public static class Help
|
|||
or csv row has no track title, or when -a/--album is enabled.
|
||||
|
||||
Aggregate
|
||||
With -g/--aggregate, sldl will first perform an ordinary search for the input, then attempt to
|
||||
group the results into distinct songs and download one of each kind. A common use case is
|
||||
finding all remixes of a song or printing all songs by an artist that are not your music dir.
|
||||
Two files are considered equal if their inferred track title and artist name are equal
|
||||
(ignoring case and some special characters), and their lengths are within --length-tol of each
|
||||
other.
|
||||
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.
|
||||
With -g/--aggregate, sldl performs an ordinary search for the input then attempts to
|
||||
group the results into distinct songs and download one of each kind, starting with the one
|
||||
which is shared by the most users.
|
||||
Note that --min-shares-aggregate is 2 by default, which means that songs shared by only
|
||||
one user will be ignored.
|
||||
|
||||
Album Aggregate
|
||||
Activated when --album and --aggregate are enabled, in this mode sldl searches for the query
|
||||
and groups results into distinct albums. Two folders are considered same if they have the
|
||||
same number of audio files, and the durations of the files are within --length-tol of each
|
||||
other (or within 3 seconds if length-tol is not configured). If both folders have exactly one
|
||||
audio file with similar lengths, also checks if the inferred title and artist name coincide.
|
||||
More reliable than normal aggregate due to much simpler grouping logic.
|
||||
Note that --min-shares-aggregate is 2 by default, which means that folders shared only once
|
||||
will be ignored.
|
||||
Activated when both --album and --aggregate are enabled. sldl will group shares and download
|
||||
one of each distinct album, starting with the one shared by the most users. It's
|
||||
recommended to pair this with --interactive.
|
||||
Note that --min-shares-aggregate is 2 by default, which means that albums shared by only
|
||||
one user will be ignored.
|
||||
";
|
||||
|
||||
const string searchHelp = @"
|
||||
|
@ -378,7 +386,7 @@ public static class Help
|
|||
ranking with this option due to the bitrate and samplerate checks.
|
||||
|
||||
Conditions can also be supplied as a semicolon-delimited string with --cond and --pref, e.g
|
||||
--cond ""br>=320;f=mp3,ogg;sr<96000""
|
||||
--cond ""br >= 320; format = mp3,ogg; sr < 96000"".
|
||||
";
|
||||
|
||||
const string nameFormatHelp = @"
|
||||
|
@ -414,7 +422,6 @@ public static class Help
|
|||
disc Disc number
|
||||
filename Soulseek filename without extension
|
||||
foldername Soulseek folder name
|
||||
default-foldername Default sldl folder name
|
||||
extractor Name of the extractor used (CSV/Spotify/YouTube/etc)
|
||||
";
|
||||
|
||||
|
|
|
@ -5,25 +5,34 @@ using System.Text;
|
|||
|
||||
public class M3uEditor
|
||||
{
|
||||
public string path { get; private set; }
|
||||
string parent;
|
||||
List<string> lines;
|
||||
bool needFirstUpdate = false;
|
||||
readonly TrackLists trackLists;
|
||||
readonly string path;
|
||||
readonly string parent;
|
||||
readonly int offset = 0;
|
||||
readonly M3uOption option = M3uOption.Index;
|
||||
readonly Dictionary<string, Track> previousRunData = new(); // { track.ToKey(), track }
|
||||
|
||||
public M3uEditor(string m3uPath, TrackLists trackLists, M3uOption option, int offset = 0)
|
||||
public M3uEditor(TrackLists trackLists, M3uOption option)
|
||||
{
|
||||
this.trackLists = trackLists;
|
||||
this.offset = offset;
|
||||
this.option = option;
|
||||
this.path = Path.GetFullPath(m3uPath);
|
||||
this.parent = Utils.NormalizedPath(Path.GetDirectoryName(path));
|
||||
this.lines = ReadAllLines().ToList();
|
||||
this.needFirstUpdate = option == M3uOption.All;
|
||||
}
|
||||
|
||||
public M3uEditor(string path, TrackLists trackLists, M3uOption option) : this(trackLists, option)
|
||||
{
|
||||
SetPathAndLoad(path);
|
||||
}
|
||||
|
||||
public void SetPathAndLoad(string path)
|
||||
{
|
||||
if (this.path == path)
|
||||
return;
|
||||
|
||||
this.path = Path.GetFullPath(path);
|
||||
parent = Utils.NormalizedPath(Path.GetDirectoryName(this.path));
|
||||
lines = ReadAllLines().ToList();
|
||||
LoadPreviousResults();
|
||||
}
|
||||
|
||||
|
@ -72,7 +81,7 @@ public class M3uEditor
|
|||
if (field == 0)
|
||||
{
|
||||
if (x.StartsWith("./"))
|
||||
x = Path.Join(parent, x[2..]);
|
||||
x = System.IO.Path.Join(parent, x[2..]);
|
||||
track.DownloadPath = x;
|
||||
}
|
||||
else if (field == 1)
|
||||
|
@ -116,7 +125,7 @@ public class M3uEditor
|
|||
lock (trackLists)
|
||||
{
|
||||
bool needUpdate = false;
|
||||
int index = 1 + offset;
|
||||
int index = 1;
|
||||
|
||||
bool updateLine(string newLine)
|
||||
{
|
||||
|
@ -246,7 +255,7 @@ public class M3uEditor
|
|||
{
|
||||
string p = val.DownloadPath;
|
||||
if (Utils.NormalizedPath(p).StartsWith(parent))
|
||||
p = "./" + Path.GetRelativePath(parent, p); // prepend ./ for LoadPreviousResults to recognize that a rel. path is used
|
||||
p = "./" + System.IO.Path.GetRelativePath(parent, p); // prepend ./ for LoadPreviousResults to recognize that a rel. path is used
|
||||
|
||||
var items = new string[]
|
||||
{
|
||||
|
@ -278,7 +287,7 @@ public class M3uEditor
|
|||
|
||||
if (track.DownloadPath.Length > 0)
|
||||
{
|
||||
if (track.DownloadPath.StartsWith(parent))
|
||||
if (Utils.NormalizedPath(track.DownloadPath).StartsWith(parent))
|
||||
return Path.GetRelativePath(parent, track.DownloadPath);
|
||||
else
|
||||
return track.DownloadPath;
|
||||
|
|
|
@ -5,10 +5,12 @@ using System.Collections.Concurrent;
|
|||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Net.Sockets;
|
||||
|
||||
using Data;
|
||||
using Enums;
|
||||
using FileSkippers;
|
||||
using Extractors;
|
||||
using static Printing;
|
||||
|
||||
using Directory = System.IO.Directory;
|
||||
|
@ -54,14 +56,11 @@ static partial class Program
|
|||
if (Config.input.Length == 0)
|
||||
throw new ArgumentException($"No input provided");
|
||||
|
||||
(Config.inputType, extractor) = Extractors.ExtractorRegistry.GetMatchingExtractor(Config.input);
|
||||
|
||||
if (Config.inputType == InputType.None)
|
||||
throw new ArgumentException($"No matching extractor for input '{Config.input}'");
|
||||
(Config.inputType, extractor) = ExtractorRegistry.GetMatchingExtractor(Config.input, Config.inputType);
|
||||
|
||||
WriteLine($"Using extractor: {Config.inputType}", debugOnly: true);
|
||||
|
||||
trackLists = await extractor.GetTracks(Config.maxTracks, Config.offset, Config.reverse);
|
||||
trackLists = await extractor.GetTracks(Config.input, Config.maxTracks, Config.offset, Config.reverse);
|
||||
|
||||
WriteLine("Got tracks", debugOnly: true);
|
||||
|
||||
|
@ -71,7 +70,7 @@ static partial class Program
|
|||
|
||||
Config.PostProcessArgs();
|
||||
|
||||
m3uEditor = new M3uEditor(Config.m3uFilePath, trackLists, Config.m3uOption, Config.offset);
|
||||
m3uEditor = new M3uEditor(trackLists, Config.m3uOption);
|
||||
|
||||
InitFileSkippers();
|
||||
|
||||
|
@ -88,9 +87,25 @@ static partial class Program
|
|||
bool needLogin = !Config.PrintTracks;
|
||||
if (needLogin)
|
||||
{
|
||||
client = new SoulseekClient(new SoulseekClientOptions(listenPort: Config.listenPort));
|
||||
var connectionOptions = new ConnectionOptions(configureSocket: (socket) =>
|
||||
{
|
||||
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 15);
|
||||
});
|
||||
|
||||
var clientOptions = new SoulseekClientOptions(
|
||||
transferConnectionOptions: connectionOptions,
|
||||
serverConnectionOptions: connectionOptions,
|
||||
listenPort: Config.listenPort
|
||||
);
|
||||
|
||||
client = new SoulseekClient(clientOptions);
|
||||
|
||||
if (!Config.useRandomLogin && (string.IsNullOrEmpty(Config.username) || string.IsNullOrEmpty(Config.password)))
|
||||
throw new ArgumentException("No soulseek username or password");
|
||||
|
||||
await Login(Config.useRandomLogin);
|
||||
|
||||
Search.searchSemaphore = new RateLimitedSemaphore(Config.searchesPerTime, TimeSpan.FromSeconds(Config.searchRenewTime));
|
||||
|
@ -113,8 +128,8 @@ static partial class Program
|
|||
{
|
||||
var cond = Config.skipExistingPrefCond ? Config.preferredCond : Config.necessaryCond;
|
||||
|
||||
if (Config.musicDir.Length == 0 || !Config.outputFolder.StartsWith(Config.musicDir, StringComparison.OrdinalIgnoreCase))
|
||||
outputDirSkipper = FileSkipperRegistry.GetChecker(Config.skipMode, Config.outputFolder, cond, m3uEditor);
|
||||
if (Config.musicDir.Length == 0 || !Config.parentDir.StartsWith(Config.musicDir, StringComparison.OrdinalIgnoreCase))
|
||||
outputDirSkipper = FileSkipperRegistry.GetChecker(Config.skipMode, Config.parentDir, cond, m3uEditor);
|
||||
|
||||
if (Config.musicDir.Length > 0)
|
||||
{
|
||||
|
@ -171,6 +186,27 @@ static partial class Program
|
|||
}
|
||||
|
||||
|
||||
static void PrepareListEntry(TrackListEntry tle)
|
||||
{
|
||||
Config.RestoreConditions();
|
||||
|
||||
Config.UpdateProfiles(tle);
|
||||
|
||||
Config.AddTemporaryConditions(tle.additionalConds, tle.additionalPrefConds);
|
||||
|
||||
string m3uPath;
|
||||
|
||||
if (Config.m3uFilePath.Length > 0)
|
||||
m3uPath = Config.m3uFilePath;
|
||||
else
|
||||
m3uPath = Path.Join(Config.parentDir, tle.defaultFolderName, "sldl.m3u");
|
||||
|
||||
m3uEditor.SetPathAndLoad(m3uPath);
|
||||
|
||||
PreprocessTracks(tle);
|
||||
}
|
||||
|
||||
|
||||
static async Task MainLoop()
|
||||
{
|
||||
for (int i = 0; i < trackLists.lists.Count; i++)
|
||||
|
@ -179,13 +215,10 @@ static partial class Program
|
|||
|
||||
var tle = trackLists[i];
|
||||
|
||||
Config.UpdateArgs(tle);
|
||||
|
||||
PreprocessTracks(tle);
|
||||
PrepareListEntry(tle);
|
||||
|
||||
var existing = new List<Track>();
|
||||
var notFound = new List<Track>();
|
||||
var responseData = new ResponseData();
|
||||
|
||||
if (Config.skipNotFound && !Config.PrintResults)
|
||||
{
|
||||
|
@ -247,13 +280,18 @@ static partial class Program
|
|||
|
||||
Console.WriteLine($"{tle.source.Type} download: {tle.source.ToString(true)}, searching..");
|
||||
|
||||
bool foundSomething = false;
|
||||
var responseData = new ResponseData();
|
||||
|
||||
if (tle.source.Type == TrackType.Album)
|
||||
{
|
||||
tle.list = await Search.GetAlbumDownloads(tle.source, responseData);
|
||||
foundSomething = tle.list.Count > 0;
|
||||
}
|
||||
else if (tle.source.Type == TrackType.Aggregate)
|
||||
{
|
||||
tle.list.Insert(0, await Search.GetAggregateTracks(tle.source, responseData));
|
||||
foundSomething = tle.list.Count > 0;
|
||||
}
|
||||
else if (tle.source.Type == TrackType.AlbumAggregate)
|
||||
{
|
||||
|
@ -262,8 +300,27 @@ static partial class Program
|
|||
foreach (var item in res)
|
||||
{
|
||||
var newSource = new Track(tle.source) { Type = TrackType.Album };
|
||||
trackLists.AddEntry(new TrackListEntry(item, newSource, false, true, true, false, false));
|
||||
var albumTle = new TrackListEntry(item, newSource, needSourceSearch: false, sourceCanBeSkipped: true);
|
||||
albumTle.defaultFolderName = tle.defaultFolderName;
|
||||
trackLists.AddEntry(albumTle);
|
||||
}
|
||||
|
||||
foundSomething = res.Count > 0;
|
||||
}
|
||||
|
||||
if (!foundSomething)
|
||||
{
|
||||
var lockedFiles = responseData.lockedFilesCount > 0 ? $" (Found {responseData.lockedFilesCount} locked files)" : "";
|
||||
Console.WriteLine($"No results.{lockedFiles}");
|
||||
|
||||
if (!Config.PrintResults)
|
||||
{
|
||||
tle.source.State = TrackState.Failed;
|
||||
tle.source.FailureReason = FailureReason.NoSuitableFileFound;
|
||||
m3uEditor.Update();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Config.skipExisting && tle.needSkipExistingAfterSearch)
|
||||
|
@ -284,18 +341,6 @@ static partial class Program
|
|||
continue;
|
||||
}
|
||||
|
||||
if (tle.needSourceSearch && (tle.list.Count == 0 || !tle.list.Any(x => x.Count > 0)))
|
||||
{
|
||||
string lockedFilesStr = responseData.lockedFilesCount > 0 ? $" (Found {responseData.lockedFilesCount} locked files)" : "";
|
||||
Console.WriteLine($"No results.{lockedFilesStr}");
|
||||
|
||||
tle.source.State = TrackState.Failed;
|
||||
tle.source.FailureReason = FailureReason.NoSuitableFileFound;
|
||||
|
||||
m3uEditor.Update();
|
||||
continue;
|
||||
}
|
||||
|
||||
m3uEditor.Update();
|
||||
|
||||
if (tle.source.Type != TrackType.Album)
|
||||
|
@ -416,11 +461,15 @@ static partial class Program
|
|||
|
||||
var downloadTasks = tracks.Select(async (track, index) =>
|
||||
{
|
||||
await DownloadTask(tle, track, semaphore, organizer, null, false, true);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await DownloadTask(tle, track, semaphore, organizer, cts, false, true, true);
|
||||
m3uEditor.Update();
|
||||
});
|
||||
|
||||
await Task.WhenAll(downloadTasks);
|
||||
|
||||
if (Config.removeTracksFromSource && tracks.All(t => t.State == TrackState.Downloaded || t.State == TrackState.AlreadyExists))
|
||||
await extractor.RemoveTrackFromSource(tle.source);
|
||||
}
|
||||
|
||||
|
||||
|
@ -449,13 +498,6 @@ static partial class Program
|
|||
|
||||
organizer.SetRemoteCommonDir(soulseekDir);
|
||||
|
||||
if (!Config.noBrowseFolder && !Config.interactiveMode && !retrievedFolders.Contains(soulseekDir))
|
||||
{
|
||||
Console.WriteLine("Getting all files in folder...");
|
||||
await Search.CompleteFolder(tracks, tracks[0].FirstResponse, soulseekDir);
|
||||
retrievedFolders.Add(tracks[0].FirstUsername + '\\' + soulseekDir);
|
||||
}
|
||||
|
||||
if (!Config.interactiveMode && !wasInteractive)
|
||||
{
|
||||
Console.WriteLine();
|
||||
|
@ -463,36 +505,80 @@ static partial class Program
|
|||
}
|
||||
|
||||
var semaphore = new SemaphoreSlim(Config.concurrentProcesses);
|
||||
var cts = new CancellationTokenSource();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
var downloadTasks = tracks.Select(async track =>
|
||||
await RunAlbumDownloads(tle, organizer, tracks, semaphore, cts);
|
||||
|
||||
if (!Config.noBrowseFolder && !retrievedFolders.Contains(soulseekDir))
|
||||
{
|
||||
await DownloadTask(tle, track, semaphore, organizer, cts, cancelOnFail: !Config.albumIgnoreFails, true);
|
||||
});
|
||||
await Task.WhenAll(downloadTasks);
|
||||
Console.WriteLine("Getting all files in folder...");
|
||||
|
||||
int newFilesFound = await Search.CompleteFolder(tracks, tracks[0].FirstResponse, soulseekDir);
|
||||
retrievedFolders.Add(tracks[0].FirstUsername + '\\' + soulseekDir);
|
||||
|
||||
if (newFilesFound > 0)
|
||||
{
|
||||
Console.WriteLine($"Found {newFilesFound} more files in the directory, downloading:");
|
||||
await RunAlbumDownloads(tle, organizer, tracks, semaphore, cts);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("No more files found.");
|
||||
}
|
||||
}
|
||||
|
||||
succeeded = true;
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException) when (!Config.albumIgnoreFails)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
if (track.State == TrackState.Downloaded && File.Exists(track.DownloadPath))
|
||||
{
|
||||
try { File.Delete(track.DownloadPath); } catch { }
|
||||
}
|
||||
}
|
||||
OnAlbumFail(tracks);
|
||||
}
|
||||
|
||||
organizer.SetRemoteCommonDir(null);
|
||||
tle.list.RemoveAt(index);
|
||||
}
|
||||
|
||||
if (tracks != null && succeeded)
|
||||
if (succeeded)
|
||||
{
|
||||
await OnAlbumSuccess(tle, tracks);
|
||||
}
|
||||
|
||||
List<Track>? additionalImages = null;
|
||||
|
||||
if (Config.albumArtOnly || succeeded && Config.albumArtOption != AlbumArtOption.Default)
|
||||
{
|
||||
Console.WriteLine($"\nDownloading additional images:");
|
||||
additionalImages = await DownloadImages(tle.list, Config.albumArtOption, tracks, organizer);
|
||||
tracks?.AddRange(additionalImages);
|
||||
}
|
||||
|
||||
if (tracks != null && tle.source.DownloadPath.Length > 0)
|
||||
{
|
||||
organizer.OrganizeAlbum(tracks, additionalImages);
|
||||
}
|
||||
|
||||
m3uEditor.Update();
|
||||
}
|
||||
|
||||
|
||||
static async Task RunAlbumDownloads(TrackListEntry tle, FileManager organizer, List<Track> tracks, SemaphoreSlim semaphore, CancellationTokenSource cts)
|
||||
{
|
||||
var downloadTasks = tracks.Select(async track =>
|
||||
{
|
||||
await DownloadTask(tle, track, semaphore, organizer, cts, true, true, true);
|
||||
});
|
||||
await Task.WhenAll(downloadTasks);
|
||||
}
|
||||
|
||||
|
||||
static async Task OnAlbumSuccess(TrackListEntry tle, List<Track>? tracks)
|
||||
{
|
||||
if (tracks == null)
|
||||
return;
|
||||
|
||||
var downloadedAudio = tracks.Where(t => !t.IsNotAudio && t.State == TrackState.Downloaded && t.DownloadPath.Length > 0);
|
||||
|
||||
if (downloadedAudio.Any())
|
||||
|
@ -507,25 +593,41 @@ static partial class Program
|
|||
}
|
||||
}
|
||||
|
||||
List<Track>? additionalImages = null;
|
||||
|
||||
if (Config.albumArtOnly || succeeded && Config.albumArtOption != AlbumArtOption.Default)
|
||||
static void OnAlbumFail(List<Track>? tracks)
|
||||
{
|
||||
Console.WriteLine($"\nDownloading additional images:");
|
||||
additionalImages = await DownloadImages(tle.list, Config.albumArtOption, tracks);
|
||||
tracks?.AddRange(additionalImages);
|
||||
}
|
||||
if (tracks == null || Config.IgnoreAlbumFail)
|
||||
return;
|
||||
|
||||
if (tracks != null && tle.source.DownloadPath.Length > 0)
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
organizer.OrganizeAlbum(tracks, additionalImages);
|
||||
if (track.DownloadPath.Length > 0 && File.Exists(track.DownloadPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Config.DeleteAlbumOnFail)
|
||||
{
|
||||
File.Delete(track.DownloadPath);
|
||||
}
|
||||
else if (Config.failedAlbumPath.Length > 0)
|
||||
{
|
||||
var newPath = Path.Join(Config.failedAlbumPath, Path.GetRelativePath(Config.parentDir, track.DownloadPath));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(newPath));
|
||||
Utils.Move(track.DownloadPath, newPath);
|
||||
}
|
||||
|
||||
m3uEditor.Update();
|
||||
Utils.DeleteAncestorsIfEmpty(Path.GetDirectoryName(track.DownloadPath), Config.parentDir);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Printing.WriteLine($"Error: Unable to move or delete file '{track.DownloadPath}' after album fail: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static async Task<List<Track>> DownloadImages(List<List<Track>> downloads, AlbumArtOption option, List<Track>? chosenAlbum)
|
||||
static async Task<List<Track>> DownloadImages(List<List<Track>> downloads, AlbumArtOption option, List<Track>? chosenAlbum, FileManager fileManager)
|
||||
{
|
||||
var downloadedImages = new List<Track>();
|
||||
long mSize = 0;
|
||||
|
@ -621,11 +723,11 @@ static partial class Program
|
|||
|
||||
bool allSucceeded = true;
|
||||
var semaphore = new SemaphoreSlim(1);
|
||||
var organizer = new FileManager();
|
||||
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
await DownloadTask(null, track, semaphore, organizer, null, false, false);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await DownloadTask(null, track, semaphore, fileManager, cts, false, false, false);
|
||||
|
||||
if (track.State == TrackState.Downloaded)
|
||||
downloadedImages.Add(track);
|
||||
|
@ -641,15 +743,12 @@ static partial class Program
|
|||
}
|
||||
|
||||
|
||||
static async Task DownloadTask(TrackListEntry? tle, Track track, SemaphoreSlim semaphore, FileManager organizer, CancellationTokenSource? cts, bool cancelOnFail, bool removeFromSource)
|
||||
static async Task DownloadTask(TrackListEntry? tle, Track track, SemaphoreSlim semaphore, FileManager organizer, CancellationTokenSource cts, bool cancelOnFail, bool removeFromSource, bool organize)
|
||||
{
|
||||
if (track.State != TrackState.Initial)
|
||||
return;
|
||||
|
||||
if (cts != null)
|
||||
await semaphore.WaitAsync(cts.Token);
|
||||
else
|
||||
await semaphore.WaitAsync();
|
||||
|
||||
int tries = Config.unknownErrorRetries;
|
||||
string savedFilePath = "";
|
||||
|
@ -659,15 +758,15 @@ static partial class Program
|
|||
{
|
||||
await WaitForLogin();
|
||||
|
||||
cts?.Token.ThrowIfCancellationRequested();
|
||||
cts.Token.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
(savedFilePath, chosenFile) = await Search.SearchAndDownload(track, organizer);
|
||||
(savedFilePath, chosenFile) = await Search.SearchAndDownload(track, organizer, cts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteLine($"Exception thrown: {ex}", debugOnly: true);
|
||||
WriteLine($"Error: {ex}", debugOnly: true);
|
||||
if (!IsConnectedAndLoggedIn())
|
||||
{
|
||||
continue;
|
||||
|
@ -682,13 +781,12 @@ static partial class Program
|
|||
|
||||
if (cancelOnFail)
|
||||
{
|
||||
cts?.Cancel();
|
||||
cts.Cancel();
|
||||
throw new OperationCanceledException();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteLine($"\n{ex.Message}\n{ex.StackTrace}\n", ConsoleColor.DarkYellow, true);
|
||||
tries--;
|
||||
continue;
|
||||
}
|
||||
|
@ -699,7 +797,7 @@ static partial class Program
|
|||
|
||||
if (tries == 0 && cancelOnFail)
|
||||
{
|
||||
cts?.Cancel();
|
||||
cts.Cancel();
|
||||
throw new OperationCanceledException();
|
||||
}
|
||||
|
||||
|
@ -724,9 +822,9 @@ static partial class Program
|
|||
}
|
||||
}
|
||||
|
||||
if (track.State == TrackState.Downloaded && tle != null)
|
||||
if (track.State == TrackState.Downloaded && organize)
|
||||
{
|
||||
organizer?.OrganizeAudio(tle, track, chosenFile);
|
||||
organizer?.OrganizeAudio(track, chosenFile);
|
||||
}
|
||||
|
||||
if (Config.onComplete.Length > 0)
|
||||
|
@ -760,7 +858,13 @@ static partial class Program
|
|||
}
|
||||
}
|
||||
|
||||
WriteLine($"\nPrev [Up/p] / Next [Down/n] / Accept [Enter] / Accept & Exit Interactive [q] / Skip [Esc/s]\n", ConsoleColor.Green);
|
||||
string retrieveAll1 = retrieveFolder ? "| [r] " : "";
|
||||
string retrieveAll2 = retrieveFolder ? "| Load All Files " : "";
|
||||
|
||||
Console.WriteLine();
|
||||
WriteLine($" [Up/p] | [Down/n] | [Enter] | [q] {retrieveAll1}| [Esc/s]", ConsoleColor.Green);
|
||||
WriteLine($" Prev | Next | Accept | Accept & Quit Interactive {retrieveAll2}| Skip", ConsoleColor.Green);
|
||||
Console.WriteLine();
|
||||
|
||||
while (true)
|
||||
{
|
||||
|
@ -770,17 +874,10 @@ static partial class Program
|
|||
|
||||
WriteLine($"[{aidx + 1} / {list.Count}]", ConsoleColor.DarkGray);
|
||||
|
||||
var folder = Utils.GreatestCommonDirectorySlsk(tracks.Select(t => t.FirstDownload.Filename));
|
||||
if (retrieveFolder && !retrievedFolders.Contains(username + '\\' + folder))
|
||||
{
|
||||
Console.WriteLine("Getting all files in folder...");
|
||||
await Search.CompleteFolder(tracks, response, folder);
|
||||
retrievedFolders.Add(username + '\\' + folder);
|
||||
}
|
||||
|
||||
PrintAlbum(tracks);
|
||||
Console.WriteLine();
|
||||
|
||||
Loop:
|
||||
string userInput = interactiveModeLoop().Trim();
|
||||
switch (userInput)
|
||||
{
|
||||
|
@ -795,6 +892,24 @@ static partial class Program
|
|||
case "q":
|
||||
Config.interactiveMode = false;
|
||||
return aidx;
|
||||
case "r":
|
||||
var folder = Utils.GreatestCommonDirectorySlsk(tracks.Select(t => t.FirstDownload.Filename));
|
||||
if (retrieveFolder && !retrievedFolders.Contains(username + '\\' + folder))
|
||||
{
|
||||
Console.WriteLine("Getting all files in folder...");
|
||||
int newFiles = await Search.CompleteFolder(tracks, response, folder);
|
||||
retrievedFolders.Add(username + '\\' + folder);
|
||||
if (newFiles == 0)
|
||||
{
|
||||
Console.WriteLine("No more files found.");
|
||||
goto Loop;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Found {newFiles} more files in the folder:");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "":
|
||||
return aidx;
|
||||
}
|
||||
|
@ -846,7 +961,9 @@ static partial class Program
|
|||
}
|
||||
else
|
||||
{
|
||||
if (!client.State.HasFlag(SoulseekClientStates.LoggedIn | SoulseekClientStates.LoggingIn | SoulseekClientStates.Connecting))
|
||||
if (!client.State.HasFlag(SoulseekClientStates.LoggedIn)
|
||||
&& !client.State.HasFlag(SoulseekClientStates.LoggingIn)
|
||||
&& !client.State.HasFlag(SoulseekClientStates.Connecting))
|
||||
{
|
||||
WriteLine($"\nDisconnected, logging in\n", ConsoleColor.DarkYellow, true);
|
||||
try { await Login(Config.useRandomLogin); }
|
||||
|
@ -1017,7 +1134,7 @@ static partial class Program
|
|||
|
||||
public static bool IsConnectedAndLoggedIn()
|
||||
{
|
||||
return client != null && (client.State & (SoulseekClientStates.Connected | SoulseekClientStates.LoggedIn)) != 0;
|
||||
return client != null && client.State.HasFlag(SoulseekClientStates.Connected) && client.State.HasFlag(SoulseekClientStates.LoggedIn);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,17 +20,17 @@ static class Search
|
|||
public static RateLimitedSemaphore? searchSemaphore;
|
||||
|
||||
// very messy function that does everything
|
||||
public static async Task<(string, SlFile?)> SearchAndDownload(Track track, FileManager organizer)
|
||||
public static async Task<(string, SlFile?)> SearchAndDownload(Track track, FileManager organizer, CancellationTokenSource cts)
|
||||
{
|
||||
if (Config.DoNotDownload)
|
||||
throw new Exception();
|
||||
|
||||
var responseData = new ResponseData();
|
||||
IEnumerable<(SlResponse response, SlFile file)>? orderedResults = null;
|
||||
var responseData = new ResponseData();
|
||||
var progress = Printing.GetProgressBar(Config.displayMode);
|
||||
var results = new SlDictionary();
|
||||
var fsResults = new SlDictionary();
|
||||
var cts = new CancellationTokenSource();
|
||||
using var searchCts = new CancellationTokenSource();
|
||||
var saveFilePath = "";
|
||||
SlFile? chosenFile = null;
|
||||
Task? downloadTask = null;
|
||||
|
@ -62,7 +62,7 @@ static class Search
|
|||
saveFilePath = organizer.GetSavePath(f.Filename);
|
||||
fsUser = r.Username;
|
||||
chosenFile = f;
|
||||
downloadTask = Download.DownloadFile(r, f, saveFilePath, track, progress, cts);
|
||||
downloadTask = Download.DownloadFile(r, f, saveFilePath, track, progress, cts, searchCts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -111,7 +111,7 @@ static class Search
|
|||
}
|
||||
|
||||
void onSearch() => Printing.RefreshOrPrint(progress, 0, $"Searching: {track}", true);
|
||||
await RunSearches(track, results, getSearchOptions, responseHandler, cts.Token, onSearch);
|
||||
await RunSearches(track, results, getSearchOptions, responseHandler, searchCts.Token, onSearch);
|
||||
|
||||
searches.TryRemove(track, out _);
|
||||
searchEnded = true;
|
||||
|
@ -142,7 +142,7 @@ static class Search
|
|||
}
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
searchCts.Dispose();
|
||||
|
||||
downloads:
|
||||
|
||||
|
@ -159,7 +159,7 @@ static class Search
|
|||
try
|
||||
{
|
||||
downloading = 1;
|
||||
await Download.DownloadFile(response, file, saveFilePath, track, progress);
|
||||
await Download.DownloadFile(response, file, saveFilePath, track, progress, cts);
|
||||
userSuccessCount.AddOrUpdate(response.Username, 1, (k, v) => v + 1);
|
||||
return true;
|
||||
}
|
||||
|
@ -168,13 +168,17 @@ static class Search
|
|||
chosenFile = null;
|
||||
saveFilePath = "";
|
||||
downloading = 0;
|
||||
|
||||
if (!IsConnectedAndLoggedIn())
|
||||
throw;
|
||||
|
||||
Printing.WriteLine("Error: " + e.Message, ConsoleColor.DarkYellow, true);
|
||||
|
||||
userSuccessCount.AddOrUpdate(response.Username, -1, (k, v) => v - 1);
|
||||
if (--trackTries <= 0)
|
||||
{
|
||||
Printing.RefreshOrPrint(progress, 0, $"Out of download retries: {track}", true);
|
||||
Printing.WriteLine("Last error was: " + e.Message, ConsoleColor.DarkYellow, true);
|
||||
Printing.WriteLine("Last error was: " + e.Message, ConsoleColor.DarkYellow);
|
||||
throw new SearchAndDownloadException(FailureReason.OutOfDownloadRetries);
|
||||
}
|
||||
return false;
|
||||
|
@ -284,7 +288,7 @@ static class Search
|
|||
results.TryAdd(r.Username + "\\" + file.Filename, (r, file));
|
||||
}
|
||||
}
|
||||
var cts = new CancellationTokenSource();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await RunSearches(track, results, getSearchOptions, handler, cts.Token);
|
||||
|
||||
|
@ -427,7 +431,7 @@ static class Search
|
|||
results.TryAdd(r.Username + "\\" + file.Filename, (r, file));
|
||||
}
|
||||
}
|
||||
var cts = new CancellationTokenSource();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await RunSearches(track, results, getSearchOptions, handler, cts.Token);
|
||||
|
||||
|
@ -461,10 +465,7 @@ static class Search
|
|||
|
||||
public static async Task<List<List<List<Track>>>> GetAggregateAlbums(Track track, ResponseData responseData)
|
||||
{
|
||||
int maxDiff = Config.necessaryCond.LengthTolerance;
|
||||
|
||||
if (maxDiff < 0)
|
||||
maxDiff = 3;
|
||||
int maxDiff = Config.aggregateLengthTol;
|
||||
|
||||
bool lengthsAreSimilar(int[] sorted1, int[] sorted2)
|
||||
{
|
||||
|
@ -555,13 +556,13 @@ static class Search
|
|||
}
|
||||
|
||||
|
||||
public 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, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var browseOptions = new BrowseOptions();
|
||||
var res = new List<(string dir, SlFile file)>();
|
||||
|
||||
folderPrefix = folderPrefix.TrimEnd('\\') + '\\';
|
||||
var userFileList = await client.BrowseAsync(user, browseOptions);
|
||||
var userFileList = await client.BrowseAsync(user, browseOptions, cancellationToken);
|
||||
|
||||
foreach (var dir in userFileList.Directories)
|
||||
{
|
||||
|
@ -572,22 +573,15 @@ static class Search
|
|||
}
|
||||
}
|
||||
return res;
|
||||
|
||||
// It would be much better to use GetDirectoryContentsAsync. Unfortunately it only returns the file
|
||||
// names without full paths, and DownloadAsync needs full paths in order to download files.
|
||||
// Therefore it would not be possible to download any files that are in a subdirectory of the folder.
|
||||
|
||||
// var dir = await client.GetDirectoryContentsAsync(user, folderPrefix);
|
||||
// var res = dir.Files.Select(x => (folderPrefix, x)).ToList();
|
||||
// return res;
|
||||
}
|
||||
|
||||
|
||||
public static async Task CompleteFolder(List<Track> tracks, SearchResponse response, string folder)
|
||||
public static async Task<int> CompleteFolder(List<Track> tracks, SearchResponse response, string folder, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
int newFiles = 0;
|
||||
try
|
||||
{
|
||||
var allFiles = await GetAllFilesInFolder(response.Username, folder);
|
||||
var allFiles = await GetAllFilesInFolder(response.Username, folder, cancellationToken);
|
||||
|
||||
if (allFiles.Count > tracks.Count)
|
||||
{
|
||||
|
@ -599,6 +593,7 @@ static class Search
|
|||
var fullPath = dir + '\\' + file.Filename;
|
||||
if (!paths.Contains(fullPath))
|
||||
{
|
||||
newFiles++;
|
||||
var newFile = new SlFile(file.Code, fullPath, file.Size, file.Extension, file.Attributes);
|
||||
var t = new Track
|
||||
{
|
||||
|
@ -616,6 +611,7 @@ static class Search
|
|||
{
|
||||
Printing.WriteLine($"Error getting complete list of files: {ex}", ConsoleColor.DarkYellow);
|
||||
}
|
||||
return newFiles;
|
||||
}
|
||||
|
||||
|
||||
|
@ -633,7 +629,7 @@ static class Search
|
|||
}
|
||||
|
||||
var groups = fileResponses
|
||||
.GroupBy(inferTrack, new TrackComparer(ignoreCase: true, Config.necessaryCond.LengthTolerance))
|
||||
.GroupBy(inferTrack, new TrackComparer(ignoreCase: true, Config.aggregateLengthTol))
|
||||
.Select(x => (x, x.Select(y => y.Item1.Username).Distinct().Count()))
|
||||
.Where(x => x.Item2 >= minShares)
|
||||
.OrderByDescending(x => x.Item2)
|
||||
|
|
|
@ -255,7 +255,7 @@ namespace Test
|
|||
{
|
||||
Config.input = strings[i];
|
||||
Console.WriteLine(Config.input);
|
||||
var res = await extractor.GetTracks(0, 0, false);
|
||||
var res = await extractor.GetTracks(Config.input, 0, 0, false);
|
||||
var t = res[0].list[0][0];
|
||||
Assert(Extractors.StringExtractor.InputMatches(Config.input));
|
||||
Assert(t.ToKey() == tracks[i].ToKey());
|
||||
|
@ -268,7 +268,7 @@ namespace Test
|
|||
{
|
||||
Config.input = strings[i];
|
||||
Console.WriteLine(Config.input);
|
||||
var t = (await extractor.GetTracks(0, 0, false))[0].source;
|
||||
var t = (await extractor.GetTracks(Config.input, 0, 0, false))[0].source;
|
||||
Assert(Extractors.StringExtractor.InputMatches(Config.input));
|
||||
Assert(t.ToKey() == albums[i].ToKey());
|
||||
}
|
||||
|
@ -317,7 +317,7 @@ namespace Test
|
|||
};
|
||||
|
||||
var trackLists = new TrackLists();
|
||||
trackLists.AddEntry(new TrackListEntry());
|
||||
trackLists.AddEntry(new TrackListEntry(TrackType.Normal));
|
||||
foreach (var t in notFoundInitial)
|
||||
trackLists.AddTrackToLast(t);
|
||||
foreach (var t in existingInitial)
|
||||
|
|
|
@ -89,10 +89,13 @@ public static class Utils
|
|||
return path;
|
||||
}
|
||||
|
||||
if (path.StartsWith("~"))
|
||||
if (path.StartsWith('~'))
|
||||
{
|
||||
string homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return Path.Combine(homeDirectory, path.Substring(1).TrimStart('/').TrimStart('\\'));
|
||||
path = Path.Join(homeDirectory, path.Substring(1).TrimStart('/').TrimStart('\\'));
|
||||
|
||||
if (path.Length > 0)
|
||||
path = Path.GetFullPath(path);
|
||||
}
|
||||
|
||||
return path;
|
||||
|
@ -162,6 +165,26 @@ public static class Utils
|
|||
}
|
||||
}
|
||||
|
||||
public static void DeleteAncestorsIfEmpty(string startDir, string root)
|
||||
{
|
||||
string x = NormalizedPath(Path.GetFullPath(root));
|
||||
string y = NormalizedPath(startDir);
|
||||
|
||||
if (x.Length == 0)
|
||||
return;
|
||||
|
||||
while (y.StartsWith(x + '/') && FileCountRecursive(y) == 0)
|
||||
{
|
||||
Directory.Delete(y, true);
|
||||
|
||||
string prev = y;
|
||||
y = NormalizedPath(Path.GetDirectoryName(y) ?? "");
|
||||
|
||||
if (prev.Length == y.Length)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool EqualsAny(this string input, string[] values, StringComparison comparison = StringComparison.Ordinal)
|
||||
{
|
||||
foreach (var value in values)
|
||||
|
|
Loading…
Reference in a new issue