1
0
Fork 0
mirror of https://github.com/fiso64/slsk-batchdl.git synced 2025-01-10 23:42:42 +00:00

remove spotify limit, liked songs download,

reverse download order option,
youtube download
This commit is contained in:
fiso64 2023-03-28 23:16:10 +02:00
parent c37287d926
commit 5c49877040
6 changed files with 299 additions and 120 deletions

View file

@ -1,6 +1,6 @@
# slsk-batchdl
A batch downloader for Soulseek using Soulseek.NET. Accepts csv files and spotify playlist urls.
A batch downloader for Soulseek using Soulseek.NET. Accepts CSV files, Spotify & YouTube urls.
```
Usage: slsk-batchdl.exe [OPTIONS]
@ -10,14 +10,16 @@ Options:
--username <username> Soulseek username
--password <password> Soulseek password
--spotify <url> Download a spotify playlist
--spotify <url> Download a spotify playlist. "likes" to download all your liked music.
--spotify-id <id> Your spotify client id (use if the default fails or if playlist private)
--spotify-secret <sec> Your spotify client secret (use if the default fails or if playlist private)
--youtube <url> Download YouTube playlist
--csv <path> Use a csv file containing track info to download
--artist-col <column> Specify if the csv file contains an artist name column
--track-col <column> Specify if if the csv file contains an track name column
--album-col <unit> CSV album column name. Optional, may improve searching
--album-col <unit> CSV album column name. Optional, may improve searching, slower
--full-title-col <column> Specify only if there are no separate artist and track name columns in the csv
--uploader-col <column> Specify when using full title col if there is also an uploader column in the csv (fallback in case artist name cannot be extracted from title)
--length-col <column> CSV duration column name. Recommended, will improve accuracy
@ -34,8 +36,10 @@ Options:
--nec-max-bitrate <rate> Necessary maximum bitrate
--nec-max-sample-rate <rate> Necessary maximum sample rate
--album-search Also search for "[Album name] [track name]". Occasionally helps to find more
--skip-existing Skip if a track matching the conditions is found in the output folder or your music library (if provided)
--music-dir <path> Specify to also skip downloading tracks which are in your library, use with --skip-existing
--reverse Download tracks in reverse order
--skip-if-pref-failed Skip if preferred versions of a track exist but failed to download. If no pref. versions were found, download as normal.
--create-m3u Create an m3u playlist file
--m3u-only Only create an m3u playlist file with existing tracks and exit
@ -62,5 +66,6 @@ slsk-batchdl.exe --spotify <url> -p "C:\Users\fiso64\Music\Playlists" --m3u "C:\
You might need to provide an id and secret when using spotify (which you can get here https://developer.spotify.com/dashboard/applications, under "Create an app").
## Notes:
- YouTube playlist downloading is bad: There is no way of reliably parsing video information into artist & track name. Also, many videos may be unavailable (or unavailable specifically through the api, for reasons that are beyond me), in which case they won't be downloaded.
- The console output tends to break after a while
- Much of the code was written by ChatGPT

View file

@ -13,7 +13,6 @@ using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using Soulseek;
using Spotify;
using TagLib.Matroska;
using static System.Formats.Asn1.AsnWriter;
using static System.Net.WebRequestMethods;
@ -45,14 +44,16 @@ class Program
Console.WriteLine(" --username <username> Soulseek username");
Console.WriteLine(" --password <password> Soulseek password");
Console.WriteLine();
Console.WriteLine(" --spotify <url> Download a spotify playlist");
Console.WriteLine(" --spotify <url> Download a spotify playlist. \"likes\" to download all your liked music.");
Console.WriteLine(" --spotify-id <id> Your spotify client id (use if the default fails or if playlist private)");
Console.WriteLine(" --spotify-secret <sec> Your spotify client secret (use if the default fails or if playlist private)");
Console.WriteLine();
Console.WriteLine(" --youtube <url> Download YouTube playlist");
Console.WriteLine();
Console.WriteLine(" --csv <path> Use a csv file containing track info to download");
Console.WriteLine(" --artist-col <column> Specify if the csv file contains an artist name column");
Console.WriteLine(" --track-col <column> Specify if if the csv file contains an track name column");
Console.WriteLine(" --album-col <unit> CSV album column name. Optional, may improve searching");
Console.WriteLine(" --album-col <unit> CSV album column name. Optional, may improve searching, slower");
Console.WriteLine(" --full-title-col <column> Specify only if there are no separate artist and track name columns in the csv");
Console.WriteLine(" --uploader-col <column> Specify when using full title col if there is also an uploader column in the csv (fallback in case artist name cannot be extracted from title)");
Console.WriteLine(" --length-col <column> CSV duration column name. Recommended, will improve accuracy");
@ -69,8 +70,10 @@ class Program
Console.WriteLine(" --nec-max-bitrate <rate> Necessary maximum bitrate");
Console.WriteLine(" --nec-max-sample-rate <rate> Necessary maximum sample rate");
Console.WriteLine();
Console.WriteLine(" --album-search Also search for \"[Album name] [track name]\". Occasionally helps to find more");
Console.WriteLine(" --skip-existing Skip if a track matching the conditions is found in the output folder or your music library (if provided)");
Console.WriteLine(" --music-dir <path> Specify to also skip downloading tracks which are in your library, use with --skip-existing");
Console.WriteLine(" --reverse Download tracks in reverse order");
Console.WriteLine(" --skip-if-pref-failed Skip if preferred versions of a track exist but failed to download. If no pref. versions were found, download as normal.");
Console.WriteLine(" --create-m3u Create an m3u playlist file");
Console.WriteLine(" --m3u-only Only create an m3u playlist file with existing tracks and exit");
@ -101,6 +104,7 @@ class Program
musicDir = "";
string parentFolder = "";
string folderName = "";
string ytUrl = "";
string spotifyUrl = "";
string spotifyId = "";
string spotifySecret = "";
@ -117,9 +121,11 @@ class Program
string lengthCol = "";
string timeUnit = "s";
ytdlpFormat = "bestaudio/best";
bool reverse = false;
bool useYtdlp = false;
bool skipExisting = false;
bool skipIfPrefFailed = false;
bool albumSearch = false;
bool createM3u = false;
bool m3uOnly = false;
int searchTimeout = 15000;
@ -161,6 +167,9 @@ class Program
case "--csv":
tracksCsv = args[++i];
break;
case "--youtube":
ytUrl = args[++i];
break;
case "--spotify":
spotifyUrl = args[++i];
break;
@ -185,6 +194,9 @@ class Program
case "--album-col":
albumCol = args[++i];
break;
case "--album-search":
albumSearch = true;
break;
case "--full-title-col":
fullTitleCol = args[++i];
break;
@ -206,6 +218,9 @@ class Program
case "--skip-existing":
skipExisting = true;
break;
case "--reverse":
reverse = true;
break;
case "--skip-if-pref-failed":
skipIfPrefFailed = true;
break;
@ -279,6 +294,15 @@ class Program
if (spotifyUrl == "likes")
{
playlistName = "Spotify Likes";
if (usedDefaultId)
{
WriteLastLine("");
Console.Write("Spotify client ID:");
spotifyId = Console.ReadLine();
WriteLastLine("");
Console.Write("Spotify client secret:");
spotifySecret = Console.ReadLine();
}
tracks = await GetSpotifyLikes(spotifyId, spotifySecret);
}
else
@ -310,7 +334,15 @@ class Program
}
}
if (folderName == "")
folderName = playlistName;
folderName = RemoveInvalidChars(playlistName, " ");
}
else if (ytUrl != "")
{
WriteLastLine("Loading youtube playlist...");
(string name, tracks) = await YouTube.GetTracks(ytUrl);
if (folderName == "")
folderName = RemoveInvalidChars(name, " ");
}
else if (tracksCsv != "")
{
@ -327,7 +359,7 @@ class Program
folderName = Path.GetFileNameWithoutExtension(tracksCsv);
}
else
throw new Exception("No csv or spotify url provided");
throw new Exception("No csv, spotify or youtube url provided");
folderName = RemoveInvalidChars(folderName, " ");
@ -423,7 +455,10 @@ class Program
}
}
albumSearch |= albumCol != "";
int tracksRemaining = tracks.Count;
if (reverse)
tracks.Reverse();
//foreach (var track in tracks)
// WriteLastLine($"{track.Title}, {track.ArtistName} - {track.TackTitle} ({track.Length}s)");
@ -441,7 +476,7 @@ class Program
await semaphore.WaitAsync();
try
{
var savedFilePath = await SearchAndDownload(track, preferredCond, necessaryCond, skipIfPrefFailed, maxRetriesPerFile, searchTimeout, albumSearchTimeout: 5000, useYtdlp);
var savedFilePath = await SearchAndDownload(track, preferredCond, necessaryCond, skipIfPrefFailed, maxRetriesPerFile, searchTimeout, albumSearch, useYtdlp);
if (savedFilePath != "")
{
tracksRemaining--;
@ -471,7 +506,7 @@ class Program
WriteLastLine($"Failed to download:\n{System.IO.File.ReadAllText(failsFilePath)}");
}
static async Task<string> SearchAndDownload(Track track, FileConditions preferredCond, FileConditions necessaryCond, bool skipIfPrefFailed, int maxRetriesPerFile, int searchTimeout, int albumSearchTimeout, bool useYtdlp)
static async Task<string> SearchAndDownload(Track track, FileConditions preferredCond, FileConditions necessaryCond, bool skipIfPrefFailed, int maxRetriesPerFile, int searchTimeout, bool albumSearch, bool useYtdlp)
{
var title = track.TrackTitle == "" ? $"{track.UnparsedTitle}" : $"{track.ArtistName} - {track.TrackTitle}";
if (track.TrackTitle == "")
@ -544,13 +579,13 @@ class Program
}
catch (OperationCanceledException ex) { }
if (responses.Count == 0 && track.Album != "" && track.TrackTitle != "")
if (albumSearch && responses.Count == 0 && track.Album != "" && track.TrackTitle != "")
{
Debug.WriteLine("\"Artist - Track\" not found, trying \"Album Track\"");
string searchText = $"{track.Album} {track.TrackTitle}";
searchOptions = new SearchOptions
(
minimumPeerUploadSpeed: 1, searchTimeout: albumSearchTimeout,
minimumPeerUploadSpeed: 1, searchTimeout: 5000,
responseFilter: (response) =>
{
return response.UploadSpeed > 0;
@ -647,7 +682,7 @@ class Program
try {
downloading = true;
string fname = GetSaveName(track);
YtdlpDownload(track, necessaryCond, Path.Combine(outputFolder, fname));
YtdlpSearchAndDownload(track, necessaryCond, Path.Combine(outputFolder, fname));
string[] files = System.IO.Directory.GetFiles(outputFolder, fname + ".*");
foreach (string file in files)
{
@ -799,14 +834,20 @@ class Program
return RemoveInvalidChars(name, " ");
}
static void YtdlpDownload(Track track, FileConditions conditions, string savePath)
static void YtdlpSearchAndDownload(Track track, FileConditions conditions, string savePathNoExt)
{
if (track.YtID != "")
{
YtdlpDownload(track.YtID, savePathNoExt);
return;
}
Process process = new Process();
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.FileName = "yt-dlp";
string search = track.TrackTitle == "" ? track.UnparsedTitle : $"{track.ArtistName} - {track.TrackTitle}";
startInfo.Arguments = $"\"ytsearch3:{search}\" --print \"%(duration>%H:%M:%S)s - %(id)s - %(title)s\"";
startInfo.Arguments = $"\"ytsearch3:{search}\" --print \"%(duration>%H:%M:%S)s ¦¦ %(id)s ¦¦ %(title)s\"";
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
@ -822,7 +863,7 @@ class Program
List<(int, string, string)> results = new List<(int, string, string)>();
string output;
Regex regex = new Regex(@"^(\d+):(\d+):(\d+) - ([\w-]+) - (.+)$"); // I LOVE CHATGPT !!!!
Regex regex = new Regex(@"^(\d+):(\d+):(\d+) ¦¦ ([\w-]+) ¦¦ (.+)$"); // I LOVE CHATGPT !!!!
while ((output = process.StandardOutput.ReadLine()) != null)
{
Match match = regex.Match(output);
@ -842,14 +883,25 @@ class Program
foreach (var res in results)
{
bool possibleMatch = false;
if (conditions.LengthToleranceSatisfies(track, res.Item1))
{
WriteLastLine($"[yt-dlp] Downloading: {res.Item3} ({res.Item1}s)");
process = new Process();
startInfo = new ProcessStartInfo();
YtdlpDownload(res.Item2, savePathNoExt);
return;
}
}
throw new Exception($"[yt-dlp] No matching files found");
}
static void YtdlpDownload(string id, string savePathNoExt)
{
Process process = new Process();
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.FileName = "yt-dlp";
startInfo.Arguments = $"{res.Item2} -f {ytdlpFormat} -ci -o \"{savePath}.%(ext)s\" --extract-audio";
startInfo.Arguments = $"{id} -f {ytdlpFormat} -ci -o \"{savePathNoExt}.%(ext)s\" --extract-audio";
WriteLastLine($"{startInfo.FileName} {startInfo.Arguments}");
startInfo.RedirectStandardOutput = true;
@ -861,11 +913,6 @@ class Program
process.Start();
process.WaitForExit();
return;
}
}
throw new Exception($"[yt-dlp] No matching files found");
}
class DownloadInfo
@ -1038,7 +1085,7 @@ class Program
static async Task<(string?, List<Track>)> GetSpotifyPlaylist(string url, string id, string secret, bool login)
{
var spotify = new Client(id, secret);
var spotify = new Spotify(id, secret);
if (login)
{
await spotify.AuthorizeLogin();
@ -1053,7 +1100,7 @@ class Program
static async Task<List<Track>> GetSpotifyLikes(string id, string secret)
{
var spotify = new Client(id, secret);
var spotify = new Spotify(id, secret);
await spotify.AuthorizeLogin();
await spotify.IsClientReady();
@ -1209,6 +1256,16 @@ class Program
str = str.Replace(c.ToString(), replaceStr);
return str;
}
static void PrintTracks(List<Track> tracks)
{
foreach (var track in tracks)
{
string title = track.TrackTitle == "" ? track.UnparsedTitle : $"{track.ArtistName} - {track.TrackTitle}";
WriteLastLine($"{title} ({track.Length})");
}
WriteLastLine(tracks.Count);
}
}
public struct Track
@ -1218,6 +1275,7 @@ public struct Track
public string TrackTitle = "";
public string ArtistName = "";
public string Album = "";
public string YtID = "";
public int Length = -1;
public Track() { }
}

View file

@ -7,9 +7,13 @@
"commandName": "Project",
"commandLineArgs": "-p \"C:\\Users\\fiso64\\Music\\Playlists\" --csv \"C:\\Users\\fiso64\\Downloads\\test.csv\" --username \"fakename99123\" --password \"fakepass123123\" --artist-col \"Artist Name(s)\" --album-col \"Album Name\" --track-col \"Track Name\" --length-col \"Duration (ms)\" --time-unit \"ms\" --pref-format \"flac\" --yt-dlp"
},
"Profile 2": {
"Profile 3": {
"commandName": "Project",
"commandLineArgs": "--spotify \"\" -p \"C:\\Users\\fiso64\\Music\\Playlists\" --username \"fakename99123\" --password \"fakepass123123\" --pref-format \"flac\" --yt-dlp"
"commandLineArgs": "--spotify \"likes\" -p \"C:\\Users\\fiso64\\Music\\Playlists\" --username \"fakename99123\" --password \"fakepass123123\" --yt-dlp"
},
"Profile 4": {
"commandName": "Project",
"commandLineArgs": "--youtube \"https://www.youtube.com/playlist?list=PLI_eFW8NAFzYAXZ5DrU6E6mQ_XfhaLBUX\" -p \"C:\\Users\\fiso64\\Music\\Playlists\" --username \"fakename99123\" --password \"fakepass123123\" --yt-dlp"
}
}
}

View file

@ -6,9 +6,7 @@ using SpotifyAPI.Web.Auth;
using Swan;
using TagLib.IFD.Tags;
namespace Spotify
{
public class Client
public class Spotify
{
private EmbedIOAuthServer _server;
private readonly string _clientId;
@ -16,7 +14,7 @@ namespace Spotify
private SpotifyClient _client;
private bool loggedIn = false;
public Client(string clientId, string clientSecret)
public Spotify(string clientId, string clientSecret)
{
_clientId = clientId;
_clientSecret = clientSecret;
@ -80,8 +78,13 @@ namespace Spotify
if (!loggedIn)
throw new Exception("Can't get liked music, not logged in");
var tracks = await _client.Library.GetTracks();
List<Track> res = new List<Track>();
int offset = 0;
int limit = 50;
while (true)
{
var tracks = await _client.Library.GetTracks(new LibraryTracksRequest { Limit = limit, Offset = offset });
foreach (var track in tracks.Items)
{
@ -93,15 +96,28 @@ namespace Spotify
res.Add(new Track { Album = album, ArtistName = artist, TrackTitle = name, Length = duration / 1000 });
}
if (tracks.Items.Count < limit)
break;
offset += limit;
}
return res;
}
public async Task<(string?, List<Track>)> GetPlaylist(string url)
{
var playlistId = GetPlaylistIdFromUrl(url);
var p = await _client.Playlists.Get(playlistId);
var tracks = await _client.Playlists.GetItems(playlistId);
List<Track> res = new List<Track>();
int offset = 0;
int limit = 100;
while (true)
{
var tracks = await _client.Playlists.GetItems(playlistId, new PlaylistGetItemsRequest { Limit = limit, Offset = offset });
foreach (var track in tracks.Items)
{
@ -113,6 +129,12 @@ namespace Spotify
res.Add(new Track { Album = album, ArtistName = artist, TrackTitle = name, Length = duration / 1000 });
}
if (tracks.Items.Count < limit)
break;
offset += limit;
}
return (p.Name, res);
}
@ -123,4 +145,3 @@ namespace Spotify
return segments[segments.Length - 1].TrimEnd('/');
}
}
}

90
slsk-batchdl/YouTube.cs Normal file
View file

@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using YoutubeExplode;
public static class YouTube
{
public static async Task<(string, List<Track>)> GetTracks(string url)
{
var youtube = new YoutubeClient();
var playlist = await youtube.Playlists.GetAsync(url);
var playlistTitle = playlist.Title;
var tracks = new List<Track>();
var videoTasks = new List<(ValueTask<YoutubeExplode.Videos.Video>, int)>();
await foreach (var video in youtube.Playlists.GetVideosAsync(playlist.Id))
{
var title = video.Title;
var uploader = video.Author.Title;
var ytId = video.Id.Value;
var length = (int)video.Duration.Value.TotalSeconds;
title = title.Replace("", "-");
var trackTitle = title.Trim();
var artist = uploader.Trim();
if (artist.EndsWith("- Topic"))
{
artist = artist.Substring(0, artist.Length - 7).Trim();
trackTitle = title;
if (artist == "Various Artists")
{
//var vid = await youtube.Videos.GetAsync(video.Id);
videoTasks.Add((youtube.Videos.GetAsync(video.Id), tracks.Count));
//Thread.Sleep(20);
}
}
else
{
int idx = title.IndexOf('-');
var split = title.Split(new[] { '-' }, 2);
if (idx > 0 && idx < title.Length - 1 && (title[idx - 1] == ' ' || title[idx + 1] == ' ') && split[0].Trim() != "" && split[1].Trim() != "")
{
artist = title.Split(new[] { '-' }, 2)[0].Trim();
trackTitle = title.Split(new[] { '-' }, 2)[1].Trim();
}
else
{
artist = uploader;
trackTitle = title;
}
}
var track = new Track
{
UnparsedTitle = video.Title,
Uploader = uploader,
TrackTitle = trackTitle,
ArtistName = artist,
YtID = ytId,
Length = length
};
tracks.Add(track);
}
foreach ((var vidTask, int idx) in videoTasks)
{
var vid = await vidTask;
var lines = vid.Description.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries);
var dotLine = lines.FirstOrDefault(line => line.Contains(" · "));
if (dotLine != null)
{
var t = tracks[idx];
t.ArtistName = dotLine.Split(new[] { " · " }, StringSplitOptions.None)[1]; // can't be asked to do it properly
tracks[idx] = t;
}
}
return (playlistTitle, tracks);
}
}

View file

@ -12,6 +12,7 @@
<PackageReference Include="SpotifyAPI.Web" Version="7.0.0" />
<PackageReference Include="SpotifyAPI.Web.Auth" Version="7.0.0" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
<PackageReference Include="YoutubeExplode" Version="6.2.11" />
</ItemGroup>
</Project>