1
0
Fork 0
mirror of https://github.com/fiso64/slsk-batchdl.git synced 2024-12-22 06:22:41 +00:00
This commit is contained in:
fiso64 2024-12-20 23:56:44 +01:00
parent 85b94de641
commit c639daabce
17 changed files with 781 additions and 801 deletions

View file

@ -3,7 +3,7 @@
An automatic downloader for Soulseek built with Soulseek.NET. Accepts CSV files as well as Spotify and YouTube urls.
Supports playlist and album downloads; selects the best files according to user-configured file conditions and heuristics.
See the usage [examples](#examples-1).
See the [usage examples](#examples-1).
## Index
- [Options](#options)
@ -68,14 +68,15 @@ Usage: sldl <input> [OPTIONS]
--listen-port <port> Port for incoming connections (default: 49998)
--on-complete <command> Run a command whenever a file is downloaded.
Available placeholders: {path} (local save path), {title},
Available placeholders: {path} (local path),{title},{row}
{artist},{album},{uri},{length},{failure-reason},{state}.
Prepend a state number to only run in specific cases:
1:, 2:, 3:, 4: for the Downloaded, Failed, Exists, and
NotFoundLastTime states respectively.
E.g: '1:<cmd>' will only run the command if the file is
downloaded successfully. Prepend 's:' to use the system
shell to execute the command.
shell to execute the command. Prepend 'a:' to run it only
whenever an album downloads or fails.
--print <option> Print tracks or search results instead of downloading:
'tracks': Print all tracks to be downloaded
@ -198,7 +199,7 @@ Usage: sldl <input> [OPTIONS]
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
--album-parallel-search Run album searches in parallel
--album-parallel-search Run album searches in parallel, then download sequentially.
```
#### Aggregate Download Options
```
@ -274,33 +275,22 @@ Name of the track, album, or artist to search for: Can either be any typical sea
(like what you would enter into the soulseek search bar), or a comma-separated list of
properties like 'title=Song Name, artist=Artist Name, length=215'.
The following properties are accepted:
```
title
artist
album
length (in seconds)
artist-maybe-wrong
album-track-count
```
The following properties are accepted: title, artist, album, length (in seconds),
artist-maybe-wrong, album-track-count.
Example inputs and their interpretations:
```
Input String | Artist | Title | Album | Length
---------------------------------------------------------------------------------
'Foo Bar' (without any hyphens) | | Foo Bar | |
'Foo - Bar' | Foo | Bar | |
'Foo - Bar' (with --album enabled) | Foo | | Bar |
'Artist - Title, length=42' | Artist | Title | | 42
'artist=AR, title=T, album=AL' | AR | T | AL |
```
| Input String | Artist | Title | Album | Length |
|-----------------------------------------|----------|----------|----------|--------|
| 'Foo Bar' (without any hyphens) | | Foo Bar | | |
| 'Foo - Bar' | Foo | Bar | | |
| '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"
```
e.g:
```
```bash
# input conditions pref. conditions
"artist=Artist, 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
@ -389,7 +379,7 @@ a file that only satisfies strict-title (if enabled) will always be preferred ov
only satisfies the format condition. Run with --print "results-full" to reveal the sorting logic.
Conditions can also be supplied as a semicolon-delimited string with --cond and --pref, e.g
--cond "br >= 320; format = mp3,ogg; sr < 96000".
`--cond "br >= 320; format = mp3,ogg; sr < 96000"`.
### Filtering irrelevant results
The options --strict-title, --strict-artist and --strict-album will filter any file that
@ -418,13 +408,13 @@ Name format supports subdirectories as well as conditional expressions like {tag
tag1 is null, use tag2. String literals enclosed in parentheses are ignored in the null check.
### Examples:
- "{artist} - {title}"
- `{artist} - {title}`
Always name it 'Artist - Title'. Because some files on Soulseek are untagged, the
following is generally preferred:
- "{artist( - )title|filename}"
- `{artist( - )title|filename}`
If artist and title are not null, name it 'Artist - Title', otherwise use the original
filename.
- "{albumartist(/)album(/)track(. )title|(missing-tags/)foldername(/)filename}"
- `{albumartist(/)album(/)track(. )title|(missing-tags/)foldername(/)filename}`
Sort files into artist/album folders if all tags are present, otherwise put them in
the 'missing-tags' folder.
@ -466,7 +456,7 @@ pref-format = flac
fast-search = true
```
Lines starting with hashtags (#) will be ignored. Tildes in paths are expanded as the user
directory.
directory. The path variable `{bindir}` stores the directory of the sldl binary.
### Configuration profiles:
Profiles are supported:
@ -547,12 +537,12 @@ sldl "artist=MC MENTAL" -a -g -t
#### Advanced example: Automatic wishlist downloader
Create a file named `wishlist.txt`, and add some items as detailed in [Input types: List](#list):
```bash
"Artist - My Favorite Song"
a:"Artist - Some Album, album-track-count=5" "format=flac"
```
"Artist - My Favorite Song" "format=flac"
a:"Artist - Some Album, album-track-count=5"
```
Add a profile to your `sldl.conf`:
```
```bash
[wishlist]
input = ~/sldl/wishlist.txt
input-type = list
@ -618,7 +608,7 @@ Example => Run `sldl` every Sunday at 1am, search for missing tracks from the sp
```
# min hour day month weekday command
0 1 * * 0 sldl https://open.spotify.com/playlist/6sf1WR5grXGJ6dET -c /config -p /data --skip-existing --m3u-path /data/index.sldl"
0 1 * * 0 sldl https://open.spotify.com/playlist/6sf1WR5grXGJ6dET -c /config -p /data --index-path /data/index.sldl
```
[crontab.guru](https://crontab.guru/) could be used to help with the scheduling expression.

View file

@ -1,33 +1,22 @@
@echo off
setlocal
set DOTNET_CLI_TELEMETRY_OPTOUT=1
set FRAMEWORK=net6.0
if not exist slsk-batchdl\bin\zips mkdir slsk-batchdl\bin\zips
REM win-x86
dotnet publish -c Release -r win-x86 -p:PublishSingleFile=true -p:DefineConstants=WINDOWS --self-contained false
if exist slsk-batchdl\bin\Release\net6.0\win-x86\publish\*.pdb del /F /Q slsk-batchdl\bin\Release\net6.0\win-x86\publish\*.pdb
if exist slsk-batchdl\bin\zips\sldl_win-x86.zip del /F /Q slsk-batchdl\bin\zips\sldl_win-x86.zip
powershell.exe -nologo -noprofile -command "& { Add-Type -A 'System.IO.Compression.FileSystem'; [IO.Compression.ZipFile]::CreateFromDirectory('slsk-batchdl\bin\Release\net6.0\win-x86\publish', 'slsk-batchdl\bin\zips\sldl_win-x86.zip'); }"
REM win-x86 self-contained
dotnet publish -c Release -r win-x86 -p:PublishSingleFile=true -p:PublishTrimmed=true -p:DefineConstants=WINDOWS --self-contained true
if exist slsk-batchdl\bin\Release\net6.0\win-x86\publish\*.pdb del /F /Q slsk-batchdl\bin\Release\net6.0\win-x86\publish\*.pdb
if exist slsk-batchdl\bin\zips\sldl_win-x86_self-contained.zip del /F /Q slsk-batchdl\bin\zips\sldl_win-x86_self-contained.zip
powershell.exe -nologo -noprofile -command "& { Add-Type -A 'System.IO.Compression.FileSystem'; [IO.Compression.ZipFile]::CreateFromDirectory('slsk-batchdl\bin\Release\net6.0\win-x86\publish', 'slsk-batchdl\bin\zips\sldl_win-x86_self-contained.zip'); }"
REM linux-x64
dotnet publish -c Release -r linux-x64 -p:PublishSingleFile=true -p:PublishTrimmed=true --self-contained true
if exist slsk-batchdl\bin\Release\net6.0\linux-x64\publish\*.pdb del /F /Q slsk-batchdl\bin\Release\net6.0\linux-x64\publish\*.pdb
if exist slsk-batchdl\bin\zips\sldl_linux-x64.zip del /F /Q slsk-batchdl\bin\zips\sldl_linux-x64.zip
powershell.exe -nologo -noprofile -command "& { Add-Type -A 'System.IO.Compression.FileSystem'; [IO.Compression.ZipFile]::CreateFromDirectory('slsk-batchdl\bin\Release\net6.0\linux-x64\publish', 'slsk-batchdl\bin\zips\sldl_linux-x64.zip'); }"
REM linux-arm
dotnet publish -c Release -r linux-arm -p:PublishSingleFile=true -p:PublishTrimmed=true --self-contained true
if exist slsk-batchdl\bin\Release\net6.0\linux-arm\publish\*.pdb del /F /Q slsk-batchdl\bin\Release\net6.0\linux-arm\publish\*.pdb
if exist slsk-batchdl\bin\zips\sldl_linux-arm.zip del /F /Q slsk-batchdl\bin\zips\sldl_linux-arm.zip
powershell.exe -nologo -noprofile -command "& { Add-Type -A 'System.IO.Compression.FileSystem'; [IO.Compression.ZipFile]::CreateFromDirectory('slsk-batchdl\bin\Release\net6.0\linux-arm\publish', 'slsk-batchdl\bin\zips\sldl_linux-arm.zip'); }"
call :publish_and_zip win-x86 false sldl_win-x86.zip
call :publish_and_zip win-x86 true sldl_win-x86_self-contained.zip
call :publish_and_zip linux-x64 true sldl_linux-x64.zip
call :publish_and_zip linux-arm true sldl_linux-arm.zip
endlocal
exit /b
:publish_and_zip
dotnet publish -c Release -r %1 -p:PublishSingleFile=true -p:PublishTrimmed=%2 -p:DefineConstants=WINDOWS --self-contained=%2
if exist slsk-batchdl\bin\Release\%FRAMEWORK%\%1\publish\*.pdb del /F /Q slsk-batchdl\bin\Release\%FRAMEWORK%\%1\publish\*.pdb
if exist slsk-batchdl\bin\zips\%3 del /F /Q slsk-batchdl\bin\zips\%3
powershell.exe -nologo -noprofile -command "& { Add-Type -A 'System.IO.Compression.FileSystem'; [IO.Compression.ZipFile]::CreateFromDirectory('slsk-batchdl\bin\Release\%FRAMEWORK%\%1\publish', 'slsk-batchdl\bin\zips\%3'); }"
exit /b

View file

@ -9,7 +9,7 @@ public class Config
{
public FileConditions necessaryCond = new()
{
Formats = new string[] { ".mp3", ".flac", ".ogg", ".m4a", ".opus", ".wav", ".aac", ".alac" },
Formats = new string[] { "mp3", "flac", "ogg", "m4a", "opus", "wav", "aac", "alac" },
};
public FileConditions preferredCond = new()
@ -175,11 +175,6 @@ public class Config
ProcessArgs(arguments);
}
public Config()
{
}
public Config Copy() // deep copies all fields except configProfiles and arguments
{
var copy = (Config)this.MemberwiseClone();
@ -209,9 +204,12 @@ public class Config
if (idx != -1)
{
confPath = Utils.ExpandUser(args[idx + 1]);
confPathChanged = true;
if (confPath == "none")
return;
confPath = Utils.ExpandUser(args[idx + 1]);
if(File.Exists(Path.Join(AppDomain.CurrentDomain.BaseDirectory, confPath)))
confPath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, confPath);
}
@ -389,7 +387,7 @@ public class Config
foreach (var (name, args) in toApply)
{
Console.WriteLine($"Applying auto profile: {name}");
tle.AddPrintLine($"Applying auto profile: {name}");
ProcessArgs(args);
appliedProfiles.Add(name);
}
@ -421,7 +419,7 @@ public class Config
appliedProfiles.Add(name);
}
else
Console.WriteLine($"Error: No profile '{name}' found in config");
Console.WriteLine($"Warning: No profile '{name}' found in config");
}
}
}
@ -563,7 +561,7 @@ public class Config
}
public static FileConditions ParseConditions(string input)
public static FileConditions ParseConditions(string input, Track? track = null)
{
static void UpdateMinMax(string value, string condition, ref int? min, ref int? max)
{
@ -579,6 +577,15 @@ public class Config
min = max = int.Parse(value);
}
static void UpdateMinMax2(string value, string condition, ref int min, ref int max)
{
int? nullableMin = min;
int? nullableMax = max;
UpdateMinMax(value, condition, ref nullableMin, ref nullableMax);
min = nullableMin ?? min;
max = nullableMax ?? max;
}
var cond = new FileConditions();
var tr = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
@ -639,6 +646,10 @@ public class Config
case "acceptmissingprops":
cond.AcceptMissingProps = bool.Parse(value);
break;
case "albumtrackcount":
if (track != null)
UpdateMinMax2(value, condition, ref track.MinAlbumTrackCount, ref track.MaxAlbumTrackCount);
break;
default:
throw new ArgumentException($"Unknown condition '{condition}'");
}
@ -722,6 +733,10 @@ public class Config
case "--config":
confPath = args[++i];
break;
case "--nc":
case "--no-config":
confPath = "none";
break;
case "--smd":
case "--skip-music-dir":
skipMusicDir = args[++i];

View file

@ -50,9 +50,9 @@ namespace Extractors
{
string[] lines = File.ReadAllLines(csvFilePath, System.Text.Encoding.UTF8);
if (track.CsvRow > -1 && track.CsvRow < lines.Length)
if (track.CsvOrListRow > -1 && track.CsvOrListRow < lines.Length)
{
lines[track.CsvRow] = new string(',', Math.Max(0, csvColumnCount - 1));
lines[track.CsvOrListRow] = new string(',', Math.Max(0, csvColumnCount - 1));
Utils.WriteAllLines(csvFilePath, lines, '\n');
}
}
@ -135,7 +135,7 @@ namespace Extractors
csvColumnCount = values.Count;
var desc = "";
var track = new Track() { CsvRow = index };
var track = new Track() { CsvOrListRow = index };
if (artistIndex >= 0) track.Artist = values[artistIndex];
if (trackIndex >= 0) track.Title = values[trackIndex];

View file

@ -66,16 +66,20 @@ namespace Extractors
foreach (var tle in tl.lists)
{
if (fields.Count >= 2)
tle.extractorCond = Config.ParseConditions(fields[1]);
{
tle.extractorCond = Config.ParseConditions(fields[1], tle.source);
}
if (fields.Count >= 3)
{
tle.extractorPrefCond = Config.ParseConditions(fields[2]);
}
tle.defaultFolderName = foldername;
tle.enablesIndexByDefault = true;
}
if (tl.lists.Count == 1)
tl[0].source.CsvRow = i;
tl[0].source.CsvOrListRow = i;
trackLists.lists.AddRange(tl.lists);
@ -138,9 +142,9 @@ namespace Extractors
{
string[] lines = File.ReadAllLines(listFilePath, Encoding.UTF8);
if (track.CsvRow > -1 && track.CsvRow < lines.Length)
if (track.CsvOrListRow > -1 && track.CsvOrListRow < lines.Length)
{
lines[track.CsvRow] = "";
lines[track.CsvOrListRow] = "";
Utils.WriteAllLines(listFilePath, lines, '\n');
}
}

View file

@ -648,8 +648,8 @@ namespace Extractors
public static async Task<string> YtdlpDownload(string id, string savePathNoExt, string ytdlpArgument = "", bool printCommand = false)
{
Process process = new Process();
ProcessStartInfo startInfo = new ProcessStartInfo();
var process = new Process();
var startInfo = new ProcessStartInfo();
if (ytdlpArgument.Length == 0)
ytdlpArgument = "\"{id}\" -f bestaudio/best -ci -o \"{savepath-noext}.%(ext)s\" -x";

View file

@ -54,7 +54,7 @@ public class FileManager
this.defaultFolderName = defaultFolderName != null ? Utils.NormalizedPath(defaultFolderName) : null;
}
public void OrganizeAlbum(List<Track> tracks, List<Track>? additionalImages, bool remainingOnly = true)
public void OrganizeAlbum(Track source, List<Track> tracks, List<Track>? additionalImages, bool remainingOnly = true)
{
foreach (var track in tracks.Where(t => !t.IsNotAudio))
{
@ -64,6 +64,8 @@ public class FileManager
OrganizeAudio(track, track.FirstDownload);
}
source.DownloadPath = Utils.GreatestCommonDirectory(tracks.Where(t => !t.IsNotAudio).Select(t => t.DownloadPath));
bool onlyAdditionalImages = config.nameFormat.Length == 0;
var nonAudioToOrganize = onlyAdditionalImages ? additionalImages : tracks.Where(t => t.IsNotAudio);

View file

@ -6,22 +6,46 @@ namespace FileSkippers
{
public static class FileSkipperRegistry
{
public static FileSkipper GetSkipper(SkipMode mode, string dir, FileConditions? conditions, M3uEditor indexEditor)
public static FileSkipper GetSkipper(SkipMode mode, string dir, bool useConditions)
{
bool useConditions = conditions != null && !conditions.Equals(new FileConditions());
return mode switch
{
SkipMode.Name => useConditions ? new NameConditionalSkipper(dir, conditions) : new NameSkipper(dir),
SkipMode.Tag => useConditions ? new TagConditionalSkipper(dir, conditions) : new TagSkipper(dir),
SkipMode.Index => useConditions ? new IndexConditionalSkipper(indexEditor, conditions) : new IndexSkipper(indexEditor, conditions != null),
SkipMode.Name => useConditions ? new NameConditionalSkipper(dir) : new NameSkipper(dir),
SkipMode.Tag => useConditions ? new TagConditionalSkipper(dir) : new TagSkipper(dir),
SkipMode.Index => useConditions ? new IndexConditionalSkipper() : new IndexSkipper(),
_ => throw new ArgumentException("Invalid SkipMode")
};
}
}
public struct FileSkipperContext
{
public FileConditions? conditions;
public M3uEditor? indexEditor;
public bool checkFileExists;
public static FileSkipperContext FromTrackListEntry(TrackListEntry tle)
{
FileConditions? cond = null;
if (tle.config.skipCheckPrefCond)
cond = tle.config.necessaryCond.With(tle.config.preferredCond);
else if (tle.config.skipCheckCond)
cond = tle.config.necessaryCond;
var context = new FileSkipperContext
{
checkFileExists = cond != null,
indexEditor = tle.indexEditor,
conditions = cond,
};
return context;
}
}
public abstract class FileSkipper
{
public abstract bool TrackExists(Track track, out string? foundPath);
public abstract bool TrackExists(Track track, FileSkipperContext context, out string? foundPath);
public virtual void BuildIndex() { IndexIsBuilt = true; }
public bool IndexIsBuilt { get; protected set; } = false;
}
@ -72,7 +96,7 @@ namespace FileSkippers
IndexIsBuilt = true;
}
public override bool TrackExists(Track track, out string? foundPath)
public override bool TrackExists(Track track, FileSkipperContext context, out string? foundPath)
{
foundPath = null;
@ -101,12 +125,10 @@ namespace FileSkippers
readonly string[] ignore = new string[] { "_", "-", ".", "(", ")", "[", "]" };
readonly string dir;
readonly List<(string, string, SimpleFile)> index = new(); // (PreprocessedPath, PreprocessedName, file)
FileConditions conditions;
public NameConditionalSkipper(string dir, FileConditions conditions)
public NameConditionalSkipper(string dir)
{
this.dir = dir;
this.conditions = conditions;
}
private string Preprocess(string s, bool removeSlash)
@ -148,7 +170,7 @@ namespace FileSkippers
IndexIsBuilt = true;
}
public override bool TrackExists(Track track, out string? foundPath)
public override bool TrackExists(Track track, FileSkipperContext context, out string? foundPath)
{
foundPath = null;
@ -160,7 +182,7 @@ namespace FileSkippers
foreach ((var ppath, var pname, var musicFile) in index)
{
if (pname.ContainsWithBoundary(title) && ppath.ContainsWithBoundary(artist) && conditions.FileSatisfies(musicFile, track))
if (pname.ContainsWithBoundary(title) && ppath.ContainsWithBoundary(artist) && context.conditions.FileSatisfies(musicFile, track))
{
foundPath = musicFile.Path;
return true;
@ -214,7 +236,7 @@ namespace FileSkippers
IndexIsBuilt = true;
}
public override bool TrackExists(Track track, out string? foundPath)
public override bool TrackExists(Track track, FileSkipperContext context, out string? foundPath)
{
foundPath = null;
@ -241,12 +263,10 @@ namespace FileSkippers
{
readonly string dir;
readonly List<(string, string, SimpleFile)> index = new(); // (PreprocessedArtist, PreprocessedTitle, file)
FileConditions conditions;
public TagConditionalSkipper(string dir, FileConditions conditions)
public TagConditionalSkipper(string dir)
{
this.dir = dir;
this.conditions = conditions;
}
private string Preprocess(string s)
@ -281,7 +301,7 @@ namespace FileSkippers
IndexIsBuilt = true;
}
public override bool TrackExists(Track track, out string? foundPath)
public override bool TrackExists(Track track, FileSkipperContext context, out string? foundPath)
{
foundPath = null;
@ -293,7 +313,7 @@ namespace FileSkippers
foreach ((var partist, var ptitle, var musicFile) in index)
{
if (title == ptitle && partist.Contains(artist) && conditions.FileSatisfies(musicFile, track))
if (title == ptitle && partist.Contains(artist) && context.conditions.FileSatisfies(musicFile, track))
{
foundPath = musicFile.Path;
return true;
@ -306,23 +326,18 @@ namespace FileSkippers
public class IndexSkipper : FileSkipper
{
M3uEditor indexEditor;
bool checkFileExists;
public IndexSkipper(M3uEditor m3UEditor, bool checkFileExists)
public IndexSkipper()
{
this.indexEditor = m3UEditor;
this.checkFileExists = checkFileExists;
IndexIsBuilt = true;
}
public override bool TrackExists(Track track, out string? foundPath)
public override bool TrackExists(Track track, FileSkipperContext context, out string? foundPath)
{
foundPath = null;
var t = indexEditor.PreviousRunResult(track);
var t = context.indexEditor.PreviousRunResult(track);
if (t != null && (t.State == TrackState.Downloaded || t.State == TrackState.AlreadyExists))
{
if (checkFileExists)
if (context.checkFileExists)
{
if (t.DownloadPath.Length == 0)
return false;
@ -348,20 +363,15 @@ namespace FileSkippers
public class IndexConditionalSkipper : FileSkipper
{
M3uEditor indexEditor;
FileConditions conditions;
public IndexConditionalSkipper(M3uEditor m3UEditor, FileConditions conditions)
public IndexConditionalSkipper()
{
this.indexEditor = m3UEditor;
this.conditions = conditions;
IndexIsBuilt = true;
}
public override bool TrackExists(Track track, out string? foundPath)
public override bool TrackExists(Track track, FileSkipperContext context, out string? foundPath)
{
foundPath = null;
var t = indexEditor.PreviousRunResult(track);
var t = context.indexEditor.PreviousRunResult(track);
if (t == null || t.DownloadPath.Length == 0)
return false;
@ -375,7 +385,7 @@ namespace FileSkippers
try
{
musicFile = TagLib.File.Create(t.DownloadPath);
if (conditions.FileSatisfies(musicFile, track, false))
if (context.conditions.FileSatisfies(musicFile, track, false))
{
foundPath = t.DownloadPath;
return true;
@ -415,7 +425,7 @@ namespace FileSkippers
try { musicFile = TagLib.File.Create(path); }
catch { return false; }
if (!conditions.FileSatisfies(musicFile, track))
if (!context.conditions.FileSatisfies(musicFile, track))
return false;
}
}

View file

@ -44,14 +44,15 @@ public static class Help
--listen-port <port> Port for incoming connections (default: 49998)
--on-complete <command> Run a command whenever a file is downloaded.
Available placeholders: {path} (local save path), {title},
Available placeholders: {path} (local path),{title},{row}
{artist},{album},{uri},{length},{failure-reason},{state}.
Prepend a state number to only run in specific cases:
1:, 2:, 3:, 4: for the Downloaded, Failed, Exists, and
NotFoundLastTime states respectively.
E.g: '1:<cmd>' will only run the command if the file is
downloaded successfully. Prepend 's:' to use the system
shell to execute the command.
shell to execute the command. Prepend 'a:' to run it only
whenever an album downloads or fails.
--print <option> Print tracks or search results instead of downloading:
'tracks': Print all tracks to be downloaded
@ -168,7 +169,7 @@ public static class Help
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
--album-parallel-search Run album searches in parallel
--album-parallel-search Run album searches in parallel, then download sequentially.
Aggregate Download
-g, --aggregate Aggregate download mode: Find and download all distinct
@ -249,13 +250,8 @@ public static class Help
(like what you would enter into the soulseek search bar), or a comma-separated list of
properties like 'title=Song Name, artist=Artist Name, length=215'.
The following properties are accepted:
title
artist
album
length (in seconds)
artist-maybe-wrong
album-track-count
The following properties are accepted: title, artist, album, length (in seconds),
artist-maybe-wrong, album-track-count.
Example inputs and their interpretations:
Input String | Artist | Title | Album | Length
@ -271,7 +267,7 @@ public static class Help
""some input"" ""conditions"" ""preferred conditions""
e.g:
for example:
""artist=Artist, album=Album"" ""format=mp3; br > 128"" ""br >= 320""
@ -447,7 +443,7 @@ public static class Help
fast-search = true
Lines starting with hashtags (#) will be ignored. Tildes in paths are expanded as the user
directory.
directory. The path variable {bindir} stores the directory of the sldl binary.
Configuration profiles:
Profiles are supported:

View file

@ -17,7 +17,7 @@ public class M3uEditor // todo: separate into M3uEditor and IndexEditor
private readonly object locker = new();
public M3uEditor(TrackLists trackLists, M3uOption option, int offset = 0)
private M3uEditor(TrackLists trackLists, M3uOption option, int offset = 0)
{
this.trackLists = trackLists;
this.option = option;
@ -25,12 +25,12 @@ public class M3uEditor // todo: separate into M3uEditor and IndexEditor
this.needFirstUpdate = option == M3uOption.All || option == M3uOption.Playlist;
}
public M3uEditor(string path, TrackLists trackLists, M3uOption option) : this(trackLists, option)
public M3uEditor(string path, TrackLists trackLists, M3uOption option, bool loadPreviousResults) : this(trackLists, option)
{
SetPathAndLoad(path);
SetPathAndLoad(path, loadPreviousResults);
}
public void SetPathAndLoad(string path)
private void SetPathAndLoad(string path, bool loadPreviousResults)
{
if (string.IsNullOrEmpty(path))
return;
@ -42,23 +42,30 @@ public class M3uEditor // todo: separate into M3uEditor and IndexEditor
parent = Utils.NormalizedPath(Path.GetDirectoryName(this.path));
lines = ReadAllLines().ToList();
if (loadPreviousResults)
LoadPreviousResults();
}
private void LoadPreviousResults()
{
// Format:
// #SLDL:<trackinfo>;<trackinfo>; ...
// where <trackinfo> = filepath,artist,album,title,length(int),tracktype(int),state(int),failurereason(int)
if (lines.Count == 0 || !lines[0].StartsWith("#SLDL:"))
if (lines.Count == 0 || !lines.Any(x => x.Trim() != ""))
return;
string sldlLine = lines[0];
lines = lines.Skip(1).ToList();
bool useOldFormat = lines[0].StartsWith("#SLDL:");
int k = "#SLDL:".Length;
var indexLines = useOldFormat ? new string[] { lines[0] } : lines.Skip(1);
var currentItem = new StringBuilder();
if (useOldFormat) lines = lines.Skip(1).ToList();
int offset = useOldFormat ? "#SLDL:".Length : 0;
foreach (var sldlLine in indexLines)
{
if (string.IsNullOrWhiteSpace(sldlLine))
continue;
int k = offset;
bool inQuotes = false;
for (; k < sldlLine.Length && sldlLine[k] == ' '; k++);
@ -109,7 +116,7 @@ public class M3uEditor // todo: separate into M3uEditor and IndexEditor
currentItem.Clear();
field++;
}
else if (field == 7 && c == ';')
else if (field == 7 && c == ';' && useOldFormat)
{
track.FailureReason = (FailureReason)int.Parse(currentItem.ToString());
currentItem.Clear();
@ -122,7 +129,17 @@ public class M3uEditor // todo: separate into M3uEditor and IndexEditor
}
}
if (!useOldFormat)
{
track.FailureReason = (FailureReason)int.Parse(currentItem.ToString());
currentItem.Clear();
}
previousRunData[track.ToKey()] = track;
if (!useOldFormat)
break;
}
}
}
@ -261,10 +278,6 @@ public class M3uEditor // todo: separate into M3uEditor and IndexEditor
private void WriteSldlLine(Writer writer)
{
// Format:
// #SLDL:<trackinfo>;<trackinfo>; ...
// where <trackinfo> = filepath,artist,album,title,length(int),tracktype(int),state(int),failurereason(int)
void writeCsvLine(string[] items)
{
bool comma = false;
@ -288,7 +301,8 @@ public class M3uEditor // todo: separate into M3uEditor and IndexEditor
}
}
writer.Write("#SLDL:");
//writer.Write("#SLDL:");
writer.Write("filepath,artist,album,title,length,tracktype,state,failurereason\n");
foreach (var val in previousRunData.Values)
{
@ -309,7 +323,8 @@ public class M3uEditor // todo: separate into M3uEditor and IndexEditor
};
writeCsvLine(items);
writer.Write(';');
//writer.Write(';');
writer.Write('\n');
}
writer.Write('\n');

View file

@ -16,7 +16,7 @@ namespace Models
public bool IsNotAudio = false;
public string DownloadPath = "";
public string Other = "";
public int CsvRow = -1;
public int CsvOrListRow = -1;
public TrackType Type = TrackType.Normal;
public FailureReason FailureReason = FailureReason.None;
public TrackState State = TrackState.Initial;

View file

@ -24,6 +24,8 @@ namespace Models
public bool CanParallelSearch => source.Type == TrackType.Album || source.Type == TrackType.Aggregate;
private List<string>? printLines = null;
public TrackListEntry(TrackType trackType)
{
list = new List<List<Track>>();
@ -76,5 +78,20 @@ namespace Models
else
list[0].Add(track);
}
public void AddPrintLine(string line)
{
if (printLines == null)
printLines = new List<string>();
printLines.Add(line);
}
public void PrintLines()
{
if (printLines == null) return;
foreach (var line in printLines)
Console.WriteLine(line);
printLines = null;
}
}
}

View file

@ -55,7 +55,9 @@ static partial class Program
trackLists.UpgradeListTypes(config.aggregate, config.album);
trackLists.SetListEntryOptions();
await MainLoop(config);
PrepareListEntries(config);
await MainLoop();
WriteLineIf("Mainloop done", config.debugInfo);
}
@ -104,140 +106,9 @@ static partial class Program
}
static void InitEditors(TrackListEntry tle, Config config)
static void PreprocessTracks(TrackListEntry tle)
{
tle.playlistEditor = new M3uEditor(trackLists, config.writePlaylist ? M3uOption.Playlist : M3uOption.None, config.offset);
tle.indexEditor = new M3uEditor(trackLists, config.writeIndex ? M3uOption.Index : M3uOption.None);
}
static void InitFileSkippers(TrackListEntry tle, Config config)
{
if (config.skipExisting)
{
FileConditions? cond = null;
if (config.skipCheckPrefCond)
{
cond = config.necessaryCond.With(config.preferredCond);
}
else if (config.skipCheckCond)
{
cond = config.necessaryCond;
}
tle.outputDirSkipper = FileSkipperRegistry.GetSkipper(config.skipMode, config.parentDir, cond, tle.indexEditor);
if (config.skipMusicDir.Length > 0)
{
if (!Directory.Exists(config.skipMusicDir))
Console.WriteLine("Error: Music directory does not exist");
else
tle.musicDirSkipper = FileSkipperRegistry.GetSkipper(config.skipModeMusicDir, config.skipMusicDir, cond, tle.indexEditor);
}
}
}
static void InitConfigs(Config defaultConfig)
{
//if (trackLists.Count == 0)
// return;
//foreach (var tle in trackLists.lists)
//{
// tle.config = defaultConfig.Copy();
// tle.config.UpdateProfiles(tle);
// if (tle.extractorCond != null)
// {
// tle.config.necessaryCond = tle.config.necessaryCond.With(tle.extractorCond);
// tle.extractorCond = null;
// }
// if (tle.extractorPrefCond != null)
// {
// tle.config.preferredCond = tle.config.preferredCond.With(tle.extractorPrefCond);
// tle.extractorPrefCond = null;
// }
// initEditors(tle, tle.config);
// initFileSkippers(tle, tle.config);
//}
//defaultConfig.UpdateProfiles(trackLists[0]);
//trackLists[0].config = defaultConfig;
//initEditors(trackLists[0], defaultConfig);
//initFileSkippers(trackLists[0], defaultConfig);
//var configs = new Dictionary<Config, TrackListEntry?>() { { defaultConfig, trackLists[0] } };
//// configs, skippers, and editors are assigned to every individual tle (since they may change based
//// on auto-profiles). This loop re-uses existing configs/skippers/editors whenever autoprofiles
//// don't change. Otherwise, a new file skipper would be created for every tle, and would require
//// indexing every time, even if the directory to be indexed is unchanged.
//foreach (var tle in trackLists.lists.Skip(1))
//{
// bool needUpdate = true;
// foreach (var (config, exampleTle) in configs)
// {
// if (!config.NeedUpdateProfiles(tle))
// {
// tle.config = config;
// if (exampleTle == null)
// {
// initEditors(tle, config);
// initFileSkippers(tle, config);
// configs[config] = tle;
// }
// else
// {
// tle.playlistEditor = exampleTle.playlistEditor;
// tle.indexEditor = exampleTle.indexEditor;
// tle.outputDirSkipper = exampleTle.outputDirSkipper;
// tle.musicDirSkipper = exampleTle.musicDirSkipper;
// }
// needUpdate = false;
// break;
// }
// }
// bool hasExtractorConditions = tle.extractorCond != null || tle.extractorPrefCond != null;
// if (!needUpdate)
// continue;
// var newConfig = defaultConfig.Copy();
// newConfig.UpdateProfiles(tle);
// configs[newConfig] = tle;
// tle.config = newConfig;
// // todo: only create new instances if a relevant config item has changed
// initEditors(tle, newConfig);
// initFileSkippers(tle, newConfig);
//}
}
static void PreprocessTracks(Config config, TrackListEntry tle)
{
PreprocessTrack(config, tle.source);
for (int k = 0; k < tle.list.Count; k++)
{
foreach (var ls in tle.list)
{
for (int i = 0; i < ls.Count; i++)
{
PreprocessTrack(config, ls[i]);
}
}
}
}
static void PreprocessTrack(Config config, Track track)
static void preprocessTrack(Config config, Track track)
{
if (config.removeFt)
{
@ -264,67 +135,135 @@ static partial class Program
track.Title = track.Title.Trim();
}
preprocessTrack(tle.config, tle.source);
static void PrepareListEntry(Config prevConfig, TrackListEntry tle)
for (int k = 0; k < tle.list.Count; k++)
{
tle.config = prevConfig.Copy();
foreach (var ls in tle.list)
{
for (int i = 0; i < ls.Count; i++)
{
preprocessTrack(tle.config, ls[i]);
}
}
}
}
static void PrepareListEntries(Config startConfig)
{
var editors = new Dictionary<(string path, M3uOption option), M3uEditor>();
var skippers = new Dictionary<(string dir, SkipMode mode, bool checkCond), FileSkipper>();
foreach (var tle in trackLists.lists)
{
tle.config = startConfig.Copy();
tle.config.UpdateProfiles(tle);
startConfig = tle.config;
if (tle.extractorCond != null)
{
tle.config.necessaryCond = tle.config.necessaryCond.With(tle.extractorCond);
tle.config.necessaryCond.AddConditions(tle.extractorCond);
tle.extractorCond = null;
}
if (tle.extractorPrefCond != null)
{
tle.config.preferredCond = tle.config.preferredCond.With(tle.extractorPrefCond);
tle.config.preferredCond.AddConditions(tle.extractorPrefCond);
tle.extractorPrefCond = null;
}
InitEditors(tle, tle.config);
InitFileSkippers(tle, tle.config);
string m3uPath, indexPath;
if (tle.config.m3uFilePath.Length > 0)
m3uPath = tle.config.m3uFilePath;
else
m3uPath = Path.Join(tle.config.parentDir, tle.defaultFolderName, "_playlist.m3u8");
var indexOption = tle.config.writeIndex ? M3uOption.Index : M3uOption.None;
if (indexOption != M3uOption.None || (tle.config.skipExisting && tle.config.skipMode == SkipMode.Index) || tle.config.skipNotFound)
{
string indexPath;
if (tle.config.indexFilePath.Length > 0)
indexPath = tle.config.indexFilePath;
else
indexPath = Path.Join(tle.config.parentDir, tle.defaultFolderName, "_index.sldl");
if (tle.config.writePlaylist)
tle.playlistEditor?.SetPathAndLoad(m3uPath);
if (tle.config.writeIndex)
tle.indexEditor?.SetPathAndLoad(indexPath);
if (editors.TryGetValue((indexPath, indexOption), out var indexEditor))
{
tle.indexEditor = indexEditor;
}
else
{
tle.indexEditor = new M3uEditor(indexPath, trackLists, indexOption, true);
editors.Add((indexPath, indexOption), tle.indexEditor);
}
}
PreprocessTracks(tle.config, tle);
var playlistOption = tle.config.writePlaylist ? M3uOption.Playlist : M3uOption.None;
if (playlistOption != M3uOption.None)
{
string m3uPath;
if (tle.config.m3uFilePath.Length > 0)
m3uPath = tle.config.m3uFilePath;
else
m3uPath = Path.Join(tle.config.parentDir, tle.defaultFolderName, "_playlist.m3u8");
if (editors.TryGetValue((m3uPath, playlistOption), out var playlistEditor))
{
tle.playlistEditor = playlistEditor;
}
else
{
tle.playlistEditor = new M3uEditor(m3uPath, trackLists, playlistOption, false);
editors.Add((m3uPath, playlistOption), tle.playlistEditor);
}
}
if (tle.config.skipExisting)
{
bool checkCond = tle.config.skipCheckCond || tle.config.skipCheckPrefCond;
if (skippers.TryGetValue((tle.config.parentDir, tle.config.skipMode, checkCond), out var outputDirSkipper))
{
tle.outputDirSkipper = outputDirSkipper;
}
else
{
tle.outputDirSkipper = FileSkipperRegistry.GetSkipper(tle.config.skipMode, tle.config.parentDir, checkCond);
skippers.Add((tle.config.parentDir, tle.config.skipMode, checkCond), tle.outputDirSkipper);
}
if (tle.config.skipMusicDir.Length > 0)
{
if (skippers.TryGetValue((tle.config.skipMusicDir, tle.config.skipModeMusicDir, checkCond), out var musicDirSkipper))
{
tle.musicDirSkipper = musicDirSkipper;
}
else
{
tle.musicDirSkipper = FileSkipperRegistry.GetSkipper(tle.config.skipModeMusicDir, tle.config.skipMusicDir, checkCond);
skippers.Add((tle.config.skipMusicDir, tle.config.skipModeMusicDir, checkCond), tle.musicDirSkipper);
}
}
}
}
}
static async Task MainLoop(Config defaultConfig)
static async Task MainLoop()
{
if (trackLists.Count == 0) return;
PrepareListEntry(defaultConfig, trackLists[0]);
var firstConfig = trackLists.lists[0].config;
bool enableParallelSearch = firstConfig.parallelAlbumSearch && !firstConfig.PrintResults && !firstConfig.PrintTracks && trackLists.lists.Any(x => x.CanParallelSearch);
var tle0 = trackLists.lists[0];
bool enableParallelSearch = tle0.config.parallelAlbumSearch && !tle0.config.PrintResults && !tle0.config.PrintTracks && trackLists.lists.Any(x => x.CanParallelSearch);
var parallelSearches = new List<(TrackListEntry tle, Task<(bool, ResponseData)> task)>();
var parallelSearchSemaphore = new SemaphoreSlim(firstConfig.parallelAlbumSearchProcesses);
var parallelSearchSemaphore = new SemaphoreSlim(tle0.config.parallelAlbumSearchProcesses);
tle0.PrintLines();
for (int i = 0; i < trackLists.lists.Count; i++)
{
if (!enableParallelSearch) Console.WriteLine();
if (i > 0) PrepareListEntry(trackLists[i-1].config, trackLists[i]);
var tle = trackLists[i];
var config = tle.config;
PreprocessTracks(tle);
if (!enableParallelSearch) tle.PrintLines();
var existing = new List<Track>();
var notFound = new List<Track>();
@ -342,13 +281,13 @@ static partial class Program
if (config.skipExisting && !config.PrintResults && tle.source.State != TrackState.NotFoundLastTime)
{
if (tle.sourceCanBeSkipped && SetExisting(tle, config, tle.source))
if (tle.sourceCanBeSkipped && SetExisting(tle, FileSkipperContext.FromTrackListEntry(tle), tle.source))
existing.Add(tle.source);
if (tle.source.State != TrackState.AlreadyExists && !tle.needSourceSearch)
{
foreach (var tracks in tle.list)
existing.AddRange(DoSkipExisting(tle, config, tracks));
existing.AddRange(DoSkipExisting(tle, tracks));
}
}
@ -393,7 +332,8 @@ static partial class Program
await parallelSearchSemaphore.WaitAsync();
progress = enableParallelSearch ? Printing.GetProgressBar(config) : null;
Printing.RefreshOrPrint(progress, 0, $" {tle.source.Type} download: {tle.source.ToString(true)}, searching..", print: true);
var part = progress == null ? "" : " ";
Printing.RefreshOrPrint(progress, 0, $"{part}{tle.source.Type} download: {tle.source.ToString(true)}, searching..", print: true);
bool foundSomething = false;
var responseData = new ResponseData();
@ -460,7 +400,7 @@ static partial class Program
if (config.skipExisting && tle.needSkipExistingAfterSearch)
{
foreach (var tracks in tle.list)
existing.AddRange(DoSkipExisting(tle, config, tracks));
existing.AddRange(DoSkipExisting(tle, tracks));
}
if (tle.gotoNextAfterSearch)
@ -557,7 +497,7 @@ static partial class Program
if (tle.config.skipExisting && tle.needSkipExistingAfterSearch)
{
foreach (var tracks in tle.list)
DoSkipExisting(tle, tle.config, tracks);
DoSkipExisting(tle, tracks);
}
}
}
@ -567,12 +507,13 @@ static partial class Program
}
static List<Track> DoSkipExisting(TrackListEntry tle, Config config, List<Track> tracks)
static List<Track> DoSkipExisting(TrackListEntry tle, List<Track> tracks)
{
var context = FileSkipperContext.FromTrackListEntry(tle);
var existing = new List<Track>();
foreach (var track in tracks)
{
if (SetExisting(tle, config, track))
if (SetExisting(tle, context, track))
{
existing.Add(track);
}
@ -581,7 +522,7 @@ static partial class Program
}
static bool SetExisting(TrackListEntry tle, Config config, Track track)
static bool SetExisting(TrackListEntry tle, FileSkipperContext context, Track track)
{
string? path = null;
@ -590,7 +531,7 @@ static partial class Program
if (!tle.outputDirSkipper.IndexIsBuilt)
tle.outputDirSkipper.BuildIndex();
tle.outputDirSkipper.TrackExists(track, out path);
tle.outputDirSkipper.TrackExists(track, context, out path);
}
if (path == null && tle.musicDirSkipper != null)
@ -601,7 +542,7 @@ static partial class Program
tle.musicDirSkipper.BuildIndex();
}
tle.musicDirSkipper.TrackExists(track, out path);
tle.musicDirSkipper.TrackExists(track, context, out path);
}
if (path != null)
@ -753,11 +694,13 @@ static partial class Program
if (tracks != null && tle.source.DownloadPath.Length > 0)
{
organizer.OrganizeAlbum(tracks, additionalImages);
organizer.OrganizeAlbum(tle.source, tracks, additionalImages);
}
tle.indexEditor?.Update();
tle.playlistEditor?.Update();
OnComplete(config, config.onComplete, tle.source, true);
}
@ -1046,10 +989,7 @@ static partial class Program
}
}
if (config.onComplete.Length > 0)
{
OnComplete(config, config.onComplete, track);
}
OnComplete(config, config.onComplete, track, false);
semaphore.Release();
}
@ -1300,47 +1240,62 @@ static partial class Program
}
static void OnComplete(Config config, string onComplete, Track track)
static void OnComplete(Config config, string onComplete, Track track, bool isAlbumOnComplete)
{
if (onComplete.Length == 0)
return;
bool useShellExecute = false;
bool createNoWindow = false;
int count = 0;
while (onComplete.Length > 2 && count++ < 2)
while (onComplete.Length > 2 && count++ < 4)
{
if (onComplete[0] == 's' && onComplete[1] == ':')
if (onComplete[1] == ':')
{
if (onComplete[0] == 's')
{
useShellExecute = true;
}
else if (onComplete[0].IsDigit() && onComplete[1] == ':')
else if (onComplete[0] == 'a')
{
if ((int)track.State != int.Parse(onComplete[0].ToString()))
return;
if (!isAlbumOnComplete) return;
}
else if (onComplete[0] == 'w')
{
createNoWindow = true;
}
else if (onComplete[0].IsDigit())
{
if ((int)track.State != int.Parse(onComplete[0].ToString())) return;
}
onComplete = onComplete[2..];
}
else
{
break;
}
onComplete = onComplete[2..];
}
var process = new Process();
var startInfo = new ProcessStartInfo();
onComplete = onComplete.Replace("{title}", track.Title)
onComplete = onComplete
.Replace("{title}", track.Title)
.Replace("{artist}", track.Artist)
.Replace("{album}", track.Album)
.Replace("{uri}", track.URI)
.Replace("{length}", track.Length.ToString())
.Replace("{row}", (track.CsvOrListRow == -1 ? -1 : track.CsvOrListRow + 1).ToString())
.Replace("{artist-maybe-wrong}", track.ArtistMaybeWrong.ToString())
.Replace("{type}", track.Type.ToString())
.Replace("{is-not-audio}", track.IsNotAudio.ToString())
.Replace("{failure-reason}", track.FailureReason.ToString())
.Replace("{path}", track.DownloadPath)
.Replace("{path}", track.DownloadPath.TrimEnd('/').TrimEnd('\\'))
.Replace("{state}", track.State.ToString())
.Replace("{extractor}", config.inputType.ToString())
.Replace("{bindir}", AppDomain.CurrentDomain.BaseDirectory.TrimEnd('/').TrimEnd('\\'))
.Trim();
if (onComplete[0] == '"')
@ -1367,6 +1322,8 @@ static partial class Program
{
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
if (!createNoWindow)
startInfo.CreateNoWindow = false;
}
startInfo.UseShellExecute = useShellExecute;

View file

@ -224,7 +224,7 @@ static class Search
{
string saveFilePathNoExt = organizer.GetSavePathNoExt(title);
downloading = 1;
Printing.RefreshOrPrint(progress, 0, $"yt-dlp download: {track}, filename: {saveFilePathNoExt}", true);
Printing.RefreshOrPrint(progress, 0, $"yt-dlp download: {track}", true);
saveFilePath = await Extractors.YouTube.YtdlpDownload(id, saveFilePathNoExt, config.ytdlpArgument, printCommand: config.debugInfo);
Printing.RefreshOrPrint(progress, 100, $"Succeded: yt-dlp completed download for {track}", true);
break;

View file

@ -13,9 +13,9 @@ namespace Tests
public static async Task RunAllTests()
{
TestStringUtils();
//TestAutoProfiles();
//TestProfileConditions();
//await TestStringExtractor();
TestAutoProfiles();
TestProfileConditions();
await TestStringExtractor();
//TestM3uEditor();
Console.WriteLine('\n' + new string('#', 50) + '\n' + "All tests passed.");
@ -75,213 +75,203 @@ namespace Tests
Passed();
}
//public static void TestAutoProfiles()
//{
// SetCurrentTest("TestAutoProfiles");
public static void TestAutoProfiles()
{
SetCurrentTest("TestAutoProfiles");
// var config = new Config();
// config.inputType = InputType.YouTube;
// config.interactiveMode = true;
// config.aggregate = false;
// config.maxStaleTime = 50000;
string path = Path.Join(Directory.GetCurrentDirectory(), "test_conf.conf");
// string path = Path.Join(Directory.GetCurrentDirectory(), "test_conf.conf");
string content =
"max-stale-time = 5" +
"\nfast-search = true" +
"\nformat = flac" +
// string content =
// "max-stale-time = 5" +
// "\nfast-search = true" +
// "\nformat = flac" +
"\n[profile-true-1]" +
"\nprofile-cond = input-type == \"youtube\" && download-mode == \"album\"" +
"\nmax-stale-time = 10" +
// "\n[profile-true-1]" +
// "\nprofile-cond = input-type == \"youtube\" && download-mode == \"album\"" +
// "\nmax-stale-time = 10" +
"\n[profile-true-2]" +
"\nprofile-cond = !aggregate" +
"\nfast-search = false" +
// "\n[profile-true-2]" +
// "\nprofile-cond = !aggregate" +
// "\nfast-search = false" +
"\n[profile-false-1]" +
"\nprofile-cond = input-type == \"string\"" +
"\nformat = mp3" +
// "\n[profile-false-1]" +
// "\nprofile-cond = input-type == \"string\"" +
// "\nformat = mp3" +
"\n[profile-no-cond]" +
"\nformat = opus";
File.WriteAllText(path, content);
var config = new Config(new string[] { "-c", path });
config.inputType = InputType.YouTube;
config.interactiveMode = true;
config.aggregate = false;
config.maxStaleTime = 50000;
var tle = new TrackListEntry(TrackType.Album);
config.UpdateProfiles(tle);
Assert(config.maxStaleTime == 10 && !config.fastSearch && config.necessaryCond.Formats[0] == "flac");
// "\n[profile-no-cond]" +
// "\nformat = opus";
content =
"\n[no-stale]" +
"\nprofile-cond = interactive && download-mode == \"album\"" +
"\nmax-stale-time = 999999" +
"\n[youtube]" +
"\nprofile-cond = input-type == \"youtube\"" +
"\nyt-dlp = true";
File.WriteAllText(path, content);
config = new Config(new string[] { "-c", path });
config.inputType = InputType.CSV;
config.album = true;
config.interactiveMode = true;
config.useYtdlp = false;
config.maxStaleTime = 50000;
config.UpdateProfiles(tle);
Assert(config.maxStaleTime == 999999 && !config.useYtdlp);
// File.WriteAllText(path, content);
content =
"\n[no-stale]" +
"\nprofile-cond = interactive && download-mode == \"album\"" +
"\nmax-stale-time = 999999" +
"\n[youtube]" +
"\nprofile-cond = input-type == \"youtube\"" +
"\nyt-dlp = true";
File.WriteAllText(path, content);
config = new Config(new string[] { "-c", path });
config.inputType = InputType.YouTube;
config.album = false;
config.interactiveMode = true;
config.useYtdlp = false;
config.maxStaleTime = 50000;
config.UpdateProfiles(new TrackListEntry(TrackType.Normal));
Assert(config.maxStaleTime == 50000 && config.useYtdlp);
// config.LoadAndParse(new string[] { "-c", path });
if (File.Exists(path)) File.Delete(path);
// var tle = new TrackListEntry(TrackType.Album);
// Config.UpdateProfiles(tle);
Passed();
}
// Assert(config.maxStaleTime == 10 && !config.fastSearch && config.necessaryCond.Formats[0] == "flac");
public static void TestProfileConditions()
{
SetCurrentTest("TestProfileConditions");
// ResetConfig();
// config.inputType = InputType.CSV;
// config.album = true;
// config.interactiveMode = true;
// config.useYtdlp = false;
// config.maxStaleTime = 50000;
// content =
// "\n[no-stale]" +
// "\nprofile-cond = interactive && download-mode == \"album\"" +
// "\nmax-stale-time = 999999" +
// "\n[youtube]" +
// "\nprofile-cond = input-type == \"youtube\"" +
// "\nyt-dlp = true";
var config = new Config(new string[] { });
config.inputType = InputType.YouTube;
config.interactiveMode = true;
config.album = true;
config.aggregate = false;
// File.WriteAllText(path, content);
var conds = new (bool, string)[]
{
(true, "input-type == \"youtube\""),
(true, "download-mode == \"album\""),
(false, "aggregate"),
(true, "interactive"),
(true, "album"),
(false, "!interactive"),
(true, "album && input-type == \"youtube\""),
(false, "album && input-type != \"youtube\""),
(false, "(interactive && aggregate)"),
(true, "album && (interactive || aggregate)"),
(true, "input-type == \"spotify\" || aggregate || input-type == \"csv\" || interactive && album"),
(true, " input-type!=\"youtube\"||(album&&!interactive ||(aggregate || interactive ) )"),
(false, " input-type!=\"youtube\"||(album&&!interactive ||(aggregate || !interactive ) )"),
};
foreach ((var b, var c) in conds)
{
Console.WriteLine(c);
Assert(b == config.ProfileConditionSatisfied(c));
}
// config.LoadAndParse(new string[] { "-c", path });
// Config.UpdateProfiles(tle);
// Assert(config.maxStaleTime == 999999 && !config.useYtdlp);
Passed();
}
// ResetConfig();
// config.inputType = InputType.YouTube;
// config.album = false;
// config.interactiveMode = true;
// config.useYtdlp = false;
// config.maxStaleTime = 50000;
// content =
// "\n[no-stale]" +
// "\nprofile-cond = interactive && download-mode == \"album\"" +
// "\nmax-stale-time = 999999" +
// "\n[youtube]" +
// "\nprofile-cond = input-type == \"youtube\"" +
// "\nyt-dlp = true";
public static async Task TestStringExtractor()
{
SetCurrentTest("TestStringExtractor");
// File.WriteAllText(path, content);
// config.LoadAndParse(new string[] { "-c", path });
// Config.UpdateProfiles(new TrackListEntry(TrackType.Normal));
var strings = new List<string>()
{
"Some Title",
"Some, Title",
"artist = Some artist, title = some title",
"Artist - Title, length = 42",
"title=Some, Title, artist=Some, Artist, album = Some, Album, length= 42",
"Some, Artist = a - Some, Title = b, album = Some, Album, length = 42",
// Assert(config.maxStaleTime == 50000 && config.useYtdlp);
"Foo Bar",
"Foo - Bar",
"Artist - Title, length=42",
"title=Title, artist=Artist, length=42",
};
// if (File.Exists(path))
// File.Delete(path);
var tracks = new List<Track>()
{
new Track() { Title="Some Title" },
new Track() { Title="Some, Title" },
new Track() { Title = "some title", Artist = "Some artist" },
new Track() { Title = "Title", Artist = "Artist", Length = 42 },
new Track() { Title="Some, Title", Artist = "Some, Artist", Album = "Some, Album", Length = 42 },
new Track() { Title="Some, Title = b", Artist = "Some, Artist = a", Album = "Some, Album", Length = 42 },
// Passed();
//}
new Track() { Title = "Foo Bar" },
new Track() { Title = "Bar", Artist = "Foo" },
new Track() { Title = "Title", Artist = "Artist", Length = 42 },
new Track() { Title = "Title", Artist = "Artist", Length = 42 },
};
//public static void TestProfileConditions()
//{
// SetCurrentTest("TestProfileConditions");
var albums = new List<Track>()
{
new Track() { Album="Some Title", Type = TrackType.Album },
new Track() { Album="Some, Title", Type = TrackType.Album },
new Track() { Title = "some title", Artist = "Some artist", Type = TrackType.Album },
new Track() { Album = "Title", Artist = "Artist", Length = 42, Type = TrackType.Album },
new Track() { Title="Some, Title", Artist = "Some, Artist", Album = "Some, Album", Length = 42, Type = TrackType.Album },
new Track() { Artist = "Some, Artist = a", Album = "Some, Album", Length = 42, Type = TrackType.Album },
// config.inputType = InputType.YouTube;
// config.interactiveMode = true;
// config.album = true;
// config.aggregate = false;
new Track() { Album = "Foo Bar", Type = TrackType.Album },
new Track() { Album = "Bar", Artist = "Foo", Type = TrackType.Album },
new Track() { Album = "Title", Artist = "Artist", Length = 42, Type = TrackType.Album },
new Track() { Title = "Title", Artist = "Artist", Length = 42, Type = TrackType.Album },
};
// var conds = new (bool, string)[]
// {
// (true, "input-type == \"youtube\""),
// (true, "download-mode == \"album\""),
// (false, "aggregate"),
// (true, "interactive"),
// (true, "album"),
// (false, "!interactive"),
// (true, "album && input-type == \"youtube\""),
// (false, "album && input-type != \"youtube\""),
// (false, "(interactive && aggregate)"),
// (true, "album && (interactive || aggregate)"),
// (true, "input-type == \"spotify\" || aggregate || input-type == \"csv\" || interactive && album"),
// (true, " input-type!=\"youtube\"||(album&&!interactive ||(aggregate || interactive ) )"),
// (false, " input-type!=\"youtube\"||(album&&!interactive ||(aggregate || !interactive ) )"),
// };
var extractor = new Extractors.StringExtractor();
// foreach ((var b, var c) in conds)
// {
// Console.WriteLine(c);
// Assert(b == config.ProfileConditionSatisfied(c));
// }
var config = new Config(new string[] { });
config.aggregate = false;
config.album = false;
// Passed();
//}
Console.WriteLine("Testing songs: ");
for (int i = 0; i < strings.Count; i++)
{
config.input = strings[i];
Console.WriteLine(config.input);
var res = await extractor.GetTracks(config.input, 0, 0, false, config);
var t = res[0].list[0][0];
Assert(Extractors.StringExtractor.InputMatches(config.input));
Assert(t.ToKey() == tracks[i].ToKey());
}
//public static async Task TestStringExtractor()
//{
// SetCurrentTest("TestStringExtractor");
Console.WriteLine();
Console.WriteLine("Testing albums");
config.album = true;
for (int i = 0; i < strings.Count; i++)
{
config.input = strings[i];
Console.WriteLine(config.input);
var t = (await extractor.GetTracks(config.input, 0, 0, false, config))[0].source;
Assert(Extractors.StringExtractor.InputMatches(config.input));
Assert(t.ToKey() == albums[i].ToKey());
}
// var strings = new List<string>()
// {
// "Some Title",
// "Some, Title",
// "artist = Some artist, title = some title",
// "Artist - Title, length = 42",
// "title=Some, Title, artist=Some, Artist, album = Some, Album, length= 42",
// "Some, Artist = a - Some, Title = b, album = Some, Album, length = 42",
Passed();
}
// "Foo Bar",
// "Foo - Bar",
// "Artist - Title, length=42",
// "title=Title, artist=Artist, length=42",
// };
// var tracks = new List<Track>()
// {
// new Track() { Title="Some Title" },
// new Track() { Title="Some, Title" },
// new Track() { Title = "some title", Artist = "Some artist" },
// new Track() { Title = "Title", Artist = "Artist", Length = 42 },
// new Track() { Title="Some, Title", Artist = "Some, Artist", Album = "Some, Album", Length = 42 },
// new Track() { Title="Some, Title = b", Artist = "Some, Artist = a", Album = "Some, Album", Length = 42 },
// new Track() { Title = "Foo Bar" },
// new Track() { Title = "Bar", Artist = "Foo" },
// new Track() { Title = "Title", Artist = "Artist", Length = 42 },
// new Track() { Title = "Title", Artist = "Artist", Length = 42 },
// };
// var albums = new List<Track>()
// {
// new Track() { Album="Some Title", Type = TrackType.Album },
// new Track() { Album="Some, Title", Type = TrackType.Album },
// new Track() { Title = "some title", Artist = "Some artist", Type = TrackType.Album },
// new Track() { Album = "Title", Artist = "Artist", Length = 42, Type = TrackType.Album },
// new Track() { Title="Some, Title", Artist = "Some, Artist", Album = "Some, Album", Length = 42, Type = TrackType.Album },
// new Track() { Artist = "Some, Artist = a", Album = "Some, Album", Length = 42, Type = TrackType.Album },
// new Track() { Album = "Foo Bar", Type = TrackType.Album },
// new Track() { Album = "Bar", Artist = "Foo", Type = TrackType.Album },
// new Track() { Album = "Title", Artist = "Artist", Length = 42, Type = TrackType.Album },
// new Track() { Title = "Title", Artist = "Artist", Length = 42, Type = TrackType.Album },
// };
// var extractor = new Extractors.StringExtractor();
// config.aggregate = false;
// config.album = false;
// Console.WriteLine("Testing songs: ");
// for (int i = 0; i < strings.Count; i++)
// {
// config.input = strings[i];
// Console.WriteLine(config.input);
// 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());
// }
// Console.WriteLine();
// Console.WriteLine("Testing albums");
// config.album = true;
// for (int i = 0; i < strings.Count; i++)
// {
// config.input = strings[i];
// Console.WriteLine(config.input);
// var t = (await extractor.GetTracks(config.input, 0, 0, false))[0].source;
// Assert(Extractors.StringExtractor.InputMatches(config.input));
// Assert(t.ToKey() == albums[i].ToKey());
// }
// Passed();
//}
//public static void TestM3uEditor()
//{
public static void TestM3uEditor()
{
throw new NotImplementedException();
//SetCurrentTest("TestM3uEditor");
//var config = new Config(new string[] { });
//config.skipMode = SkipMode.Index;
//config.skipMusicDir = "";
//config.printOption = PrintOption.Tracks | PrintOption.Full;
@ -326,9 +316,10 @@ namespace Tests
//foreach (var t in toBeDownloadedInitial)
// trackLists.AddTrackToLast(t);
// Program.indexEditor = new M3uEditor(path, trackLists, M3uOption.All);
//var editor = new M3uEditor(path, trackLists, M3uOption.All);
//trackLists[0].indexEditor = editor;
// Program.outputDirSkipper = new IndexSkipper(Program.indexEditor, false);
//trackLists[0].outputDirSkipper = new IndexSkipper();
//var notFound = (List<Track>)ProgramInvoke("DoSkipNotFound", new object[] { trackLists[0].list[0] });
//var existing = (List<Track>)ProgramInvoke("DoSkipExisting", new object[] { trackLists[0].list[0] });
@ -338,9 +329,9 @@ namespace Tests
//Assert(existing.SequenceEqualUpToPermutation(existingInitial));
//Assert(toBeDownloaded.SequenceEqualUpToPermutation(toBeDownloadedInitial));
// Printing.PrintTracksTbd(toBeDownloaded, existing, notFound, TrackType.Normal);
//Printing.PrintTracksTbd(toBeDownloaded, existing, notFound, TrackType.Normal, config);
// Program.indexEditor.Update();
//editor.Update();
//string output = File.ReadAllText(path);
//string need =
// "#SLDL:./file1.5,\"Artist, 1.5\",,\"Title, , 1.5\",-1,0,3,0;path/to/file1,\"Artist, 1\",,\"Title, , 1\",-1,0,3,0;path/to/file2,\"Artist, 2\",,Title2,-1,0,3,0;,\"Artist; ,3\",,Title3 ;a,-1,0,4,0;,\"Artist,,, ;4\",,Title4,-1,0,4,3;,,,,-1,0,0,0;" +
@ -359,7 +350,7 @@ namespace Tests
//toBeDownloaded[1].FailureReason = FailureReason.NoSuitableFileFound;
//existing[1].DownloadPath = "/other/new/file/path";
// Program.indexEditor.Update();
//editor.Update();
//output = File.ReadAllText(path);
//need =
// "#SLDL:/other/new/file/path,\"Artist, 1.5\",,\"Title, , 1.5\",-1,0,3,0;path/to/file1,\"Artist, 1\",,\"Title, , 1\",-1,0,3,0;path/to/file2,\"Artist, 2\",,Title2,-1,0,3,0;,\"Artist; ,3\",,Title3 ;a,-1,0,4,0;,\"Artist,,, ;4\",,Title4,-1,0,4,3;" +
@ -378,11 +369,11 @@ namespace Tests
//Console.WriteLine();
//Console.WriteLine(output);
// Program.indexEditor = new M3uEditor(path, trackLists, M3uOption.All);
//editor = new M3uEditor(path, trackLists, M3uOption.All);
//foreach (var t in trackLists.Flattened(false, false))
//{
// Program.indexEditor.TryGetPreviousRunResult(t, out var prev);
// editor.TryGetPreviousRunResult(t, out var prev);
// Assert(prev != null);
// Assert(prev.ToKey() == t.ToKey());
// Assert(prev.DownloadPath == t.DownloadPath);
@ -390,7 +381,7 @@ namespace Tests
// Assert(prev.FailureReason == t.FailureReason);
//}
// Program.indexEditor.Update();
//editor.Update();
//output = File.ReadAllText(path);
//Assert(output == need);
@ -407,8 +398,8 @@ namespace Tests
// trackLists.AddEntry(new TrackListEntry(t));
//File.WriteAllText(path, "");
// Program.indexEditor = new M3uEditor(path, trackLists, M3uOption.Index);
// Program.indexEditor.Update();
//editor = new M3uEditor(path, trackLists, M3uOption.Index);
//editor.Update();
//Assert(File.ReadAllText(path) == "");
@ -418,13 +409,13 @@ namespace Tests
//test[1].FailureReason = FailureReason.NoSuitableFileFound;
//test[2].State = TrackState.AlreadyExists;
// Program.indexEditor.Update();
//editor.Update();
// Program.indexEditor = new M3uEditor(path, trackLists, M3uOption.Index);
//editor = new M3uEditor(path, trackLists, M3uOption.Index);
//foreach (var t in test)
//{
// Program.indexEditor.TryGetPreviousRunResult(t, out var tt);
// editor.TryGetPreviousRunResult(t, out var tt);
// Assert(tt != null);
// Assert(tt.ToKey() == t.ToKey());
// t.DownloadPath = "this should not change tt.DownloadPath";
@ -434,7 +425,7 @@ namespace Tests
//File.Delete(path);
//Passed();
//}
}
}
static class Helpers
@ -470,15 +461,6 @@ namespace Tests
}
}
public static void ResetConfig()
{
var singletonType = typeof(Config);
var instanceField = singletonType.GetField("Instance", BindingFlags.Static | BindingFlags.NonPublic);
var constructor = singletonType.GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null);
var newInstance = constructor.Invoke(null);
instanceField.SetValue(null, newInstance);
}
public static void Passed()
{
Console.WriteLine($"{currentTest} passed");

View file

@ -187,7 +187,7 @@ public static class Printing
public static void PrintComplete(TrackLists trackLists)
{
var ls = trackLists.Flattened(true, true);
var ls = trackLists.Flattened(true, false);
int successes = 0, fails = 0;
foreach (var x in ls)
{

View file

@ -103,17 +103,20 @@ public static class Utils
public static string ExpandUser(string path)
{
if (string.IsNullOrEmpty(path))
{
if (string.IsNullOrWhiteSpace(path))
return path;
}
path = path.Trim();
path = NormalizedPath(path);
if (path.Length > 0 && path[0] == '~' && (path.Length == 1 || path[1] == '\\' || path[1] == '/'))
if (path[0] == '~' && (path.Length == 1 || path[1] == '/'))
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
path = Path.Join(home, path[1..].TrimStart('/').TrimStart('\\'));
path = Path.Join(home, path[1..].TrimStart('/'));
}
else if (path.StartsWith("{bindir}") && (path.Length == 8 || path[8] == '/'))
{
string bindir = AppDomain.CurrentDomain.BaseDirectory;
path = Path.Join(bindir, path[8..].TrimStart('/'));
}
return path;