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-05-17 20:58:36 +02:00
parent e4505a53c7
commit 4a3e8ddadb
3 changed files with 145 additions and 45 deletions

View file

@ -7,26 +7,41 @@ A batch downloader for Soulseek built with Soulseek.NET. Accepts CSV files or Sp
Download tracks from a csv file:
```
slsk-batchdl test.csv
```
```
<details>
<summary>CSV details</summary>
The names of the columns in the csv should be: `Artist`, `Title`, `Album`, `Length`. Some alternatives are also accepted. You can use `--print tracks` before downloading to check if everything has been parsed correctly. Only the title or album column is required, but additional info may improve search results.
</details>
<br>
Download spotify likes while skipping songs that already exist in the output folder:
```
slsk-batchdl spotify-likes --skip-existing
```
<details>
<summary>Spotify details</summary>
To download private playlists or liked songs you will need to provide a client id and secret, which you can get here https://developer.spotify.com/dashboard/applications. Create an app and add `http://localhost:48721/callback` as a redirect url in its settings.
</details>
<br>
Download from a youtube playlist with fallback to yt-dlp in case it is not found on soulseek, and retrieve deleted video titles from wayback machine:
```
slsk-batchdl --get-deleted --yt-dlp "https://www.youtube.com/playlist?list=PLI_eFW8NAFzYAXZ5DrU6E6mQ_XfhaLBUX"
```
<details>
<summary>YouTube details</summary>
Playlists are retrieved using the YoutubeExplode library which unfortunately doesn't always return all videos. You can use the official API by providing a key with `--youtube-key`. Get it here https://console.cloud.google.com. Create a new project, click "Enable Api" and search for "youtube data", then follow the prompts.
Also note that due the high number of music videos in the above example playlist, it may be better to remove all text in parentheses and disable song duration checking: `--regex "[\[\(].*?[\]\)]" --length-tol -1 --pref-length-tol -1`.
</details>
<br>
Search & download a specific song, preferring lossless:
@ -54,7 +69,7 @@ Depending on the provided input, the download behaviour changes:
- Normal download: When the song title is set (in the CSV row, or in the string input), the program will download a single file for every entry.
- Album download: When the album name is set and the song title is NOT set, the program will search for the album and download the entire folder.
- Aggregate download: With `--aggregate`, the program will first perform an ordinary search for the input, then attempt to group the results into distinct songs and download one of each kind. This can be used to download an artist's entire discography (or simply printing it, like in the example above).
- Aggregate download: With `--aggregate`, the program will first perform an ordinary search for the input, then attempt to group the results into distinct songs and download one of each kind. This can be used to download an artist's entire discography (or simply printing it, like in the example above), finding remixes of a song, etc. Note that it is not 100% reliable, which is why `--min-users-aggregate` is set to 2 by default, i.e. any song that is shared by only one person will be ignored. Enabling `--relax` will give even more results.
## Options
```
@ -90,8 +105,8 @@ Options:
-n --number <maxtracks> Download the first n tracks of a playlist
-o --offset <offset> Skip a specified number of tracks
-r --reverse Download tracks in reverse order
--name-format <format> Name format for downloaded tracks, e.g "{artist} - {title}"
--fast-search Begin downloading as soon as a file satisfying the preferred
--nf --name-format <format> Name format for downloaded tracks, e.g "{artist} - {title}"
--fs --fast-search Begin downloading as soon as a file satisfying the preferred
conditions is found. Increases chance to download bad files.
--m3u <option> Create an m3u8 playlist file
'none': Do not create a playlist file
@ -161,6 +176,7 @@ Options:
'default': Download from the same folder as the music
'largest': Download from the folder with the largest image
'most': Download from the folder containing the most images
--album-art-only Only download album art for the provided album
-s --skip-existing Skip if a track matching file conditions is found in the
output folder or your music library (if provided)
@ -212,6 +228,7 @@ Options:
### File conditions
Files not satisfying the conditions will not be downloaded. For example, `--length-tol` is set to 3 by default, meaning that files whose duration differs from the supplied duration by more than 3 seconds will not be downloaded (can be disabled by setting it to -1).
Files satisfying `pref-` conditions will be preferred; setting `--pref-format "flac,wav"` will make it download high quality files if they exist, and only download low quality files if there's nothing else. Conditions can also be supplied as a semicolon-delimited string to `--cond` and `--pref`, e.g `--cond "br>=320;f=mp3,ogg;sr<96000"`.\
**Important note**: Some info may be unavailable depending on the client used by the peer. For example, the default Soulseek client does not share the file bitrate. By default, if `--min-bitrate` is set, then files with unknown bitrate will still be downloaded. You can configure it to reject all files where one of the checked properties is unavailable by enabling `--strict`. (As a consequence, if `--strict` and `--min-bitrate` is set then any files shared by users with the default client will be ignored)
### Name format

View file

@ -116,7 +116,9 @@ static class Program
static string descCol = "";
static string lengthCol = "";
static bool aggregate = false;
static bool album = false;
static string albumArtOption = "";
static bool albumArtOnly = false;
static bool interactiveMode = false;
static bool albumIgnoreFails = false;
static int albumTrackCount = -1;
@ -187,7 +189,7 @@ static class Program
// undocumented options:
// --artist-col, --title-col, --album-col, --length-col, --yt-desc-col, --yt-id-col
// --remove-brackets, --spotify, --csv, --string, --youtube, --random-login
// --danger-words, --pref-danger-words, --no-modify-share-count, --yt-dlp-argument
// --danger-words, --pref-danger-words, --no-modify-share-count, --yt-dlp-argument, --album, --album-art-only
Console.WriteLine("Usage: slsk-batchdl <input> [OPTIONS]" +
"\n" +
"\n <input> <input> is one of the following:" +
@ -220,8 +222,8 @@ static class Program
"\n -n --number <maxtracks> Download the first n tracks of a playlist" +
"\n -o --offset <offset> Skip a specified number of tracks" +
"\n -r --reverse Download tracks in reverse order" +
"\n --name-format <format> Name format for downloaded tracks, e.g \"{artist} - {title}\"" +
"\n --fast-search Begin downloading as soon as a file satisfying the preferred" +
"\n --nf --name-format <format> Name format for downloaded tracks, e.g \"{artist} - {title}\"" +
"\n --fs --fast-search Begin downloading as soon as a file satisfying the preferred" +
"\n conditions is found. Increases chance to download bad files." +
"\n --m3u <option> Create an m3u8 playlist file" +
"\n 'none': Do not create a playlist file" +
@ -291,6 +293,7 @@ static class Program
"\n 'default': Download from the same folder as the music" +
"\n 'largest': Download from the folder with the largest image" +
"\n 'most': Download from the folder containing the most images" +
"\n --album-art-only Only download album art for the provided album" +
"\n" +
"\n -s --skip-existing Skip if a track matching file conditions is found in the" +
"\n output folder or your music library (if provided)" +
@ -422,6 +425,7 @@ static class Program
case "--folder":
folderName = args[++i];
break;
case "--md":
case "--music-dir":
musicDir = args[++i];
break;
@ -429,6 +433,7 @@ static class Program
case "--aggregate":
aggregate = true;
break;
case "--mua":
case "--min-users-aggregate":
minUsersAggregate = int.Parse(args[++i]);
break;
@ -452,6 +457,7 @@ static class Program
case "--password":
password = args[++i];
break;
case "--rl":
case "--random-login":
useRandomLogin = true;
break;
@ -478,6 +484,7 @@ static class Program
case "--offset":
offset = int.Parse(args[++i]);
break;
case "--nf":
case "--name-format":
nameFormat = args[++i];
break;
@ -517,21 +524,27 @@ static class Program
useYtdlp = true;
break;
case "-s":
case "--se":
case "--skip-existing":
skipExisting = true;
break;
case "--snf":
case "--skip-not-found":
skipNotFound = true;
break;
case "--rfp":
case "--remove-from-playlist":
removeTracksFromSource = true;
break;
case "--rft":
case "--remove-ft":
removeFt = true;
break;
case "--rb":
case "--remove-brackets":
removeBrackets = true;
break;
case "--gd":
case "--get-deleted":
getDeleted = true;
break;
@ -549,31 +562,43 @@ static class Program
reverse = true;
break;
case "--m3u":
case "--m3u8":
m3uOption = args[++i];
break;
case "--port":
case "--listen-port":
listenPort = int.Parse(args[++i]);
break;
case "--st":
case "--timeout":
case "--search-timeout":
searchTimeout = int.Parse(args[++i]);
break;
case "--mst":
case "--stale-time":
case "--max-stale-time":
downloadMaxStaleTime = int.Parse(args[++i]);
break;
case "--cp":
case "--processes":
case "--concurrent-processes":
case "--concurrent-downloads":
maxConcurrentProcesses = int.Parse(args[++i]);
break;
case "--spt":
case "--searches-per-time":
searchesPerTime = int.Parse(args[++i]);
break;
case "--srt":
case "--searches-renew-time":
searchResetTime = int.Parse(args[++i]);
break;
case "--mr":
case "--retries":
case "--max-retries":
maxRetriesPerTrack = int.Parse(args[++i]);
break;
case "--atc":
case "--album-track-count":
string a = args[++i];
if (a.Last() == '+' || a.Last() == '-')
@ -583,6 +608,7 @@ static class Program
}
albumTrackCount = int.Parse(a);
break;
case "--aa":
case "--album-art":
switch (args[++i])
{
@ -597,15 +623,31 @@ static class Program
throw new ArgumentException($"Invalid album art download mode \'{args[i]}\'");
}
break;
case "--aao":
case "--aa-only":
case "--album-art-only":
albumArtOnly = true;
if (albumArtOption == "")
{
albumArtOption = "largest";
}
preferredCond = new FileConditions();
necessaryCond = new FileConditions();
break;
case "--aif":
case "--album-ignore-fails":
albumIgnoreFails = true;
break;
case "--int":
case "--interactive":
interactiveMode = true;
break;
case "--pref-f":
case "--pref-af":
case "--pref-format":
preferredCond.Formats = args[++i].Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
break;
case "--pref-t":
case "--pref-tol":
case "--pref-length-tol":
preferredCond.LengthTolerance = int.Parse(args[++i]);
@ -647,6 +689,7 @@ static class Program
case "--pref-max-bitdepth":
preferredCond.MaxBitDepth = int.Parse(args[++i]);
break;
case "--af":
case "--format":
necessaryCond.Formats = args[++i].Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
break;
@ -700,9 +743,11 @@ static class Program
case "--preferred":
ParseConditions(preferredCond, args[++i]);
break;
case "--nmsc":
case "--no-modify-share-count":
noModifyShareCount = true;
break;
case "--seut":
case "--skip-existing-use-tags":
skipExisting = true;
useTagsCheckExisting = true;
@ -723,6 +768,7 @@ static class Program
throw new ArgumentException($"Invalid display style \"{args[i]}\"");
}
break;
case "--sm":
case "--skip-mode":
switch (args[++i])
{
@ -737,12 +783,15 @@ static class Program
throw new ArgumentException($"Invalid skip mode \'{args[i]}\'");
}
break;
case "--nrsc":
case "--no-remove-special-chars":
noRemoveSpecialChars = true;
break;
case "--amw":
case "--artist-maybe-wrong":
artistMaybeWrong = true;
break;
case "--fs":
case "--fast-search":
fastSearch = true;
break;
@ -753,9 +802,14 @@ static class Program
preferredCond.AcceptMissingProps = false;
necessaryCond.AcceptMissingProps = false;
break;
case "--yda":
case "--yt-dlp-argument":
ytdlpArgument = args[++i];
break;
case "--al":
case "--album":
album = true;
break;
default:
throw new ArgumentException($"Unknown argument: {args[i]}");
}
@ -804,7 +858,7 @@ static class Program
if (reverse)
{
trackLists.Reverse();
trackLists = TrackLists.FromFlatList(trackLists.Flattened().Skip(offset).Take(maxTracks).ToList(), aggregate);
trackLists = TrackLists.FromFlatList(trackLists.Flattened().Skip(offset).Take(maxTracks).ToList(), aggregate, album);
}
PreprocessTrackList(trackLists);
@ -877,10 +931,13 @@ static class Program
tracks.InsertRange(0, deleted);
}
trackLists.AddEntry(tracks);
defaultFolderName = ReplaceInvalidChars(name, " ");
YouTube.StopService();
trackLists.AddEntry(tracks);
if (album || aggregate)
trackLists = TrackLists.FromFlatList(trackLists.Flattened().ToList(), aggregate, album);
defaultFolderName = ReplaceInvalidChars(name, " ");
}
@ -955,6 +1012,9 @@ static class Program
}
trackLists.AddEntry(tracks);
if (album || aggregate)
trackLists = TrackLists.FromFlatList(trackLists.Flattened().ToList(), aggregate, album);
defaultFolderName = ReplaceInvalidChars(playlistName, " ");
}
@ -972,7 +1032,7 @@ static class Program
var tracks = await ParseCsvIntoTrackInfo(csvPath, artistCol, trackCol, lengthCol, albumCol, descCol, ytIdCol, timeUnit, ytParse);
tracks = tracks.Skip(off).Take(max).ToList();
trackLists = TrackLists.FromFlatList(tracks, aggregate);
trackLists = TrackLists.FromFlatList(tracks, aggregate, album);
defaultFolderName = Path.GetFileNameWithoutExtension(csvPath);
}
@ -984,7 +1044,11 @@ static class Program
var music = ParseTrackArg(searchStr);
bool isAlbum = false;
if (!aggregate && music.TrackTitle != "")
if (album)
{
trackLists.AddEntry(TrackLists.ListType.Album, new Track(music) { TrackIsAlbum = true });
}
else if (!aggregate && music.TrackTitle != "")
{
trackLists.AddEntry(music);
}
@ -1003,7 +1067,7 @@ static class Program
throw new ArgumentException("Need track title or album");
}
if (aggregate || isAlbum)
if (aggregate || isAlbum || album)
defaultFolderName = ReplaceInvalidChars(music.ToString(true), " ").Trim();
else
defaultFolderName = ".";
@ -1028,9 +1092,9 @@ static class Program
if (trackLists.lists.Count > 1 || type != TrackLists.ListType.Normal)
{
string sourceStr = type == TrackLists.ListType.Normal ? "" : $" {source.ToString(noInfo: type == TrackLists.ListType.Album)}";
string sourceStr = type == TrackLists.ListType.Normal ? "" : $": {source.ToString(noInfo: type == TrackLists.ListType.Album)}";
bool needSearchStr = type == TrackLists.ListType.Normal || skipNotFound && source.TrackState == Track.State.NotFoundLastTime;
string searchStr = needSearchStr ? "" : $", searching...";
string searchStr = needSearchStr ? "" : $", searching..";
Console.WriteLine($"{Enum.GetName(typeof(TrackLists.ListType), type)} download{sourceStr}{searchStr}");
}
@ -1068,7 +1132,11 @@ static class Program
}
m3uEditor.Update();
PrintTracksTbd(list[0].Where(t => t.TrackState == Track.State.Initial).ToList(), existing, notFound, type);
if (!interactiveMode)
{
PrintTracksTbd(list[0].Where(t => t.TrackState == Track.State.Initial).ToList(), existing, notFound, type);
}
if (debugPrintTracks || list.Count == 0 || list[0].Count == 0)
{
@ -1082,7 +1150,7 @@ static class Program
}
else if (type == TrackLists.ListType.Album)
{
await TracksDownloadAlbum(list);
await TracksDownloadAlbum(list, albumArtOnly);
}
else if (type == TrackLists.ListType.Aggregate)
{
@ -1285,7 +1353,7 @@ static class Program
}
static async Task TracksDownloadAlbum(List<List<Track>> list) // bad
static async Task TracksDownloadAlbum(List<List<Track>> list, bool imagesOnly) // bad
{
var dlFiles = new ConcurrentDictionary<string, char>();
var dlAdditionalImages = new ConcurrentDictionary<string, char>();
@ -1294,6 +1362,33 @@ static class Program
bool albumDlFailed = false;
var listRef = list;
void prepareImageDownload()
{
var albumArtList = list.Select(tracks => tracks.Where(t => Utils.IsImageFile(t.Downloads.First().Value.Item2.Filename))).Where(tracks => tracks.Any());
if (albumArtOption == "largest")
{
list = albumArtList // shitty shortcut
.OrderByDescending(tracks => tracks.Select(t => t.Downloads.First().Value.Item2.Size).Max() / 1024 / 100)
.ThenByDescending(tracks => tracks.First().Downloads.First().Value.Item1.UploadSpeed / 1024 / 300)
.ThenByDescending(tracks => tracks.Select(t => t.Downloads.First().Value.Item2.Size).Sum() / 1024 / 100)
.Select(x => x.ToList()).ToList();
}
else if (albumArtOption == "most")
{
list = albumArtList // shitty shortcut
.OrderByDescending(tracks => tracks.Count())
.ThenByDescending(tracks => tracks.First().Downloads.First().Value.Item1.UploadSpeed / 1024 / 300)
.ThenByDescending(tracks => tracks.Select(t => t.Downloads.First().Value.Item2.Size).Sum() / 1024 / 100)
.Select(x => x.ToList()).ToList();
}
downloadingImages = true;
}
if (imagesOnly)
{
prepareImageDownload();
}
while (list.Count > 0)
{
albumDlFailed = false;
@ -1386,24 +1481,7 @@ static class Program
if (!downloadingImages && !albumDlFailed && albumArtOption != "")
{
var albumArtList = list.Select(tracks => tracks.Where(t => Utils.IsImageFile(t.Downloads.First().Value.Item2.Filename))).Where(tracks => tracks.Any());
if (albumArtOption == "largest")
{
list = albumArtList // shitty shortcut
.OrderByDescending(tracks => tracks.Select(t => t.Downloads.First().Value.Item2.Size).Max() / 1024 / 100)
.ThenByDescending(tracks => tracks.First().Downloads.First().Value.Item1.UploadSpeed / 1024 / 300)
.ThenByDescending(tracks => tracks.Select(t => t.Downloads.First().Value.Item2.Size).Sum() / 1024 / 100)
.Select(x => x.ToList()).ToList();
}
else if (albumArtOption == "most")
{
list = albumArtList // shitty shortcut
.OrderByDescending(tracks => tracks.Count())
.ThenByDescending(tracks => tracks.First().Downloads.First().Value.Item1.UploadSpeed / 1024 / 300)
.ThenByDescending(tracks => tracks.Select(t => t.Downloads.First().Value.Item2.Size).Sum() / 1024 / 100)
.Select(x => x.ToList()).ToList();
}
downloadingImages = true;
prepareImageDownload();
continue;
}
@ -1764,7 +1842,7 @@ static class Program
string fullPath((SearchResponse r, Soulseek.File f) x) { return x.r.Username + "\\" + x.f.Filename; }
var groupedLists = OrderedResults(results, track, albumMode: false)
var groupedLists = OrderedResults(results, track, albumMode: true)
.GroupBy(x => fullPath(x).Substring(0, fullPath(x).LastIndexOf('\\')));
var musicFolders = groupedLists
@ -1982,6 +2060,8 @@ static class Program
useBracketCheck = false;
useLevenshtein = false;
useInfer = false;
preferredCond.StrictTitle = false; // bad!
necessaryCond.StrictTitle = false; // bad!
}
Dictionary<string, (Track, int)>? result = null;
@ -3689,7 +3769,7 @@ public class TrackLists
}
}
public static TrackLists FromFlatList(List<Track> flatList, bool aggregate)
public static TrackLists FromFlatList(List<Track> flatList, bool aggregate, bool album)
{
var res = new TrackLists();
for (int i = 0; i < flatList.Count; i++)
@ -3698,9 +3778,9 @@ public class TrackLists
{
res.AddEntry(ListType.Aggregate, flatList[i]);
}
else if (flatList[i].Album != "" && flatList[i].TrackTitle == "")
else if (album || (flatList[i].Album != "" && flatList[i].TrackTitle == ""))
{
res.AddEntry(ListType.Album, new Track(flatList[i]) { TrackIsAlbum=true });
res.AddEntry(ListType.Album, new Track(flatList[i]) { TrackIsAlbum = true });
}
else
{
@ -3955,7 +4035,7 @@ public class M3UEditor
this.offset = offset;
this.option = option;
path = Path.GetFullPath(m3uPath);
m3uListLabels = trackLists.lists.Any(x => x.type != TrackLists.ListType.Normal);
m3uListLabels = false;/*trackLists.lists.Any(x => x.type != TrackLists.ListType.Normal);*/
fails = ReadAllLines()
.Where(x => x.StartsWith("# Failed: "))
.Select(line =>

View file

@ -480,14 +480,17 @@ public static class YouTube
ytdlpArgument = "\"{id}\" -f bestaudio/best -ci -o \"{savepath-noext}.%(ext)s\" -x";
startInfo.FileName = "yt-dlp";
startInfo.Arguments = ytdlpArgument.Replace("{id}", id).Replace("{savepath-noext}", savePathNoExt);
startInfo.Arguments = ytdlpArgument
.Replace("{id}", id)
.Replace("{savepath-noext}", savePathNoExt)
.Replace("{savedir}", Path.GetDirectoryName(savePathNoExt));
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.UseShellExecute = false;
process.StartInfo = startInfo;
process.OutputDataReceived += (sender, e) => { Console.WriteLine(e.Data); };
process.ErrorDataReceived += (sender, e) => { Console.WriteLine(e.Data); };
//process.OutputDataReceived += (sender, e) => { Console.WriteLine(e.Data); };
//process.ErrorDataReceived += (sender, e) => { Console.WriteLine(e.Data); };
process.Start();
process.WaitForExit();