1
0
Fork 0
mirror of https://github.com/fiso64/slsk-batchdl.git synced 2024-12-22 14:32:40 +00:00

added spotify url option

This commit is contained in:
fiso64 2023-03-26 17:35:41 +02:00
parent b859d02a16
commit 7245c4179b
5 changed files with 288 additions and 61 deletions

View file

@ -1,15 +1,20 @@
# slsk-batchdl
A barely-functional batch downloader for Soulseek using Soulseek.NET. Accepts csv files.
A batch downloader for Soulseek using Soulseek.NET. Accepts csv files and spotify playlist urls.
```
Usage: slsk-batchdl.exe [OPTIONS]
Options:
--output <path> Downloaded files will be placed here
--csv <path> The csv file containing track information (in case it's not in the output folder)
-p --parent <path> Downloaded music will be placed here
-n --name <name> Folder / playlist name. If not specified, the name of the csv file / spotify playlist is used.
--username <username> Soulseek username
--password <password> Soulseek password
--spotify <url> Download a spotify playlist
--spotify-id <id> Your spotify client id (in case the default one failed)
--spotify-secret <sec> Your spotify client secret (in case the default one failed)
--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
--full-title-col <column> Specify only if there are no separate artist and track name columns in the csv
@ -17,12 +22,6 @@ Options:
--length-col <column> Specify the name of the track duration column, if exists
--time-unit <unit> Time unit for the track duration column, ms or s (default: s)
--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
--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 in the output dir
--m3u-only Only create an m3u playlist file with existing tracks and exit
--pref-format <format> Preferred file format (default: mp3)
--pref-length-tolerance <tol> Preferred length tolerance (if length col provided) (default: 3)
--pref-min-bitrate <rate> Preferred minimum bitrate (default: 200)
@ -34,18 +33,28 @@ Options:
--nec-max-bitrate <rate> Necessary maximum bitrate
--nec-max-sample-rate <rate> Necessary maximum sample rate
--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
--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
--m3u <path> Where to place created m3u files (--parent by default)
--search-timeout <timeout> Maximal search time (default: 15000)
--download-max-stale-time <time> Maximal download time with no progress (default: 80000)
--max-concurrent-processes <num> Max concurrent searches / downloads (default: 2)
--max-retries-per-file <num> Maximum number of users to try downloading from before skipping track (default: 30)
```
- Provide either both a track-col and artist-col (ideally), or full-title-col in case separate artist and track names are unavailable. You can also specify --uploader-col (channel names) in that case to use as artist names whenever full-title-col doesn't contain them.
- Always provide a length-col or get wrong results
- Files satisfying `pref` conditions will be preferred. Files not satisfying `nec` conditions will not be downloaded.
- When using csv, provide either both a track-col and artist-col (ideally), or full-title-col in case separate artist and track names are unavailable. You can also specify --uploader-col (channel names) in that case to use as artist names whenever full-title-col doesn't contain them. Always provide a length-col or get wrong results
Example use (with a csv from https://exportify.net/):
Download tracks from a csv file and create m3u:
```
slsk-batchdl.exe --output "C:\Users\fiso64\Music\Playlists\test" --csv "C:\Users\fiso64\Downloads\test.csv" --username "fakename" --password "fakepass" --artist-col "Artist Name(s)" --track-col "Track Name" --length-col "Duration (ms)" --time-unit "ms" --skip-existing --create-m3u --pref-format "flac"
slsk-batchdl.exe -p "C:\Users\fiso64\Music\Playlists" --csv "C:\Users\fiso64\Downloads\test.csv" --username "fakename" --password "fakepass" --artist-col "Artist Name(s)" --track-col "Track Name" --length-col "Duration (ms)" --time-unit "ms" --skip-existing --create-m3u --pref-format "flac"
```
Download spotify playlist and create m3u:
```
slsk-batchdl.exe --spotify <url> -p "C:\Users\fiso64\Music\Playlists" --m3u "C:\Users\fiso64\Documents\MusicBee\Playlists" --music-dir "C:\Users\fiso64\Music" --username "fakename" --password "fakepass" --skip-existing --pref-format "flac"
```
## Notes:

View file

@ -12,6 +12,7 @@ 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;
@ -37,11 +38,16 @@ class Program
{
Console.WriteLine("Usage: slsk-batchdl.exe [OPTIONS]");
Console.WriteLine("Options:");
Console.WriteLine(" --output <path> Downloaded files will be placed here");
Console.WriteLine(" --csv <path> The csv file containing track information (in case it's not in the output folder)");
Console.WriteLine(" -p --parent <path> Downloaded music will be placed here");
Console.WriteLine(" -n --name <name> Folder / playlist name. If not specified, the name of the csv file / spotify playlist is used.");
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-id <id> Your spotify client id (in case the default one failed)");
Console.WriteLine(" --spotify-secret <sec> Your spotify client secret (in case the default one failed)");
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(" --full-title-col <column> Specify only if there are no separate artist and track name columns in the csv");
@ -49,12 +55,6 @@ class Program
Console.WriteLine(" --length-col <column> Specify the name of the track duration column, if exists");
Console.WriteLine(" --time-unit <unit> Time unit for the track duration column, ms or s (default: s)");
Console.WriteLine();
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(" --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 in the output dir");
Console.WriteLine(" --m3u-only Only create an m3u playlist file with existing tracks and exit");
Console.WriteLine();
Console.WriteLine(" --pref-format <format> Preferred file format (default: mp3)");
Console.WriteLine(" --pref-length-tolerance <tol> Preferred length tolerance (if length col provided) (default: 3)");
Console.WriteLine(" --pref-min-bitrate <rate> Preferred minimum bitrate (default: 200)");
@ -66,6 +66,13 @@ 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(" --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(" --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");
Console.WriteLine(" --m3u <path> Where to place created m3u files (--parent by default)");
Console.WriteLine();
Console.WriteLine(" --search-timeout <timeout> Maximal search time (default: 15000)");
Console.WriteLine(" --download-max-stale-time <time> Maximal download time with no progress (default: 80000)");
Console.WriteLine(" --max-concurrent-processes <num> Max concurrent searches / downloads (default: 2)");
@ -80,14 +87,19 @@ class Program
Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine();
lastLine = Console.CursorTop;
if (args.Contains("--help") || args.Length <= 1 || !args.Contains("--output"))
if (args.Contains("--help"))
{
PrintHelp();
return;
}
outputFolder = "";
musicDir = "";
string parentFolder = "";
string folderName = "";
string spotifyUrl = "";
string spotifyId = "1bf4691bbb1a4f41bced9b2c1cfdbbd2";
string spotifySecret = "e79992e56f4642169acef68c742303f1";
string tracksCsv = "";
string username = "";
string password = "";
@ -127,8 +139,13 @@ class Program
{
switch (args[i])
{
case "--output":
outputFolder = args[++i];
case "-p":
case "--parent":
parentFolder = args[++i];
break;
case "-n":
case "--name":
folderName = args[++i];
break;
case "--music-dir":
musicDir = args[++i];
@ -136,6 +153,15 @@ class Program
case "--csv":
tracksCsv = args[++i];
break;
case "--spotify":
spotifyUrl = args[++i];
break;
case "--spotify-id":
spotifyId = args[++i];
break;
case "--spotify-secret":
spotifySecret = args[++i];
break;
case "--username":
username = args[++i];
break;
@ -172,6 +198,9 @@ class Program
case "--m3u-only":
m3uOnly = true;
break;
case "--m3u":
m3uFilePath = args[++i];
break;
case "--search-timeout":
searchTimeout = int.Parse(args[++i]);
break;
@ -215,59 +244,104 @@ class Program
necessaryCond.MaxSampleRate = int.Parse(args[++i]);
break;
default:
Console.WriteLine($"Unknown argument: {args[i]}");
WriteLastLine($"Unknown argument: {args[i]}", ConsoleColor.Red);
break;
}
}
if ((trackCol == "" && artistCol == "" && fullTitleCol == "") || (trackCol != "" && artistCol == "") || (fullTitleCol != "" && (artistCol != "" || trackCol != "")))
throw new Exception("Use one of: full title column, (artist column AND track name)");
if (lengthCol == "")
WriteLastLine($"Warning: No length column specified, results may be imprecise.");
if (spotifyUrl != "")
{
string? playlistName;
try
{
(playlistName, tracks) = await GetSpotifyPlaylist(spotifyUrl, spotifyId, spotifySecret, false);
}
catch (SpotifyAPI.Web.APIException)
{
WriteLastLine("Spotify playlist not found. It may be set to private. Login? [Y/n]");
string answer = Console.ReadLine();
if (answer.ToLower() == "y")
{
try { (playlistName, tracks) = await GetSpotifyPlaylist(spotifyUrl, spotifyId, spotifySecret, true); }
catch (SpotifyAPI.Web.APIException) { throw; }
}
else
return;
}
if (folderName == "")
folderName = playlistName;
}
else if (tracksCsv != "")
{
if (!System.IO.File.Exists(tracksCsv))
throw new Exception("csv file not found");
if ((trackCol == "" && artistCol == "" && fullTitleCol == "") || (trackCol != "" && artistCol == "") || (fullTitleCol != "" && (artistCol != "" || trackCol != "")))
throw new Exception("Use one of: full title column, (artist column AND track name)");
if (lengthCol == "")
WriteLastLine($"Warning: No length column specified, results may be imprecise.");
tracks = ParseCsvIntoTrackInfo(tracksCsv, artistCol, trackCol, lengthCol, fullTitleCol, uploaderCol, timeUnit: timeUnit);
System.IO.Directory.CreateDirectory(outputFolder);
if (folderName == "")
folderName = Path.GetFileNameWithoutExtension(tracksCsv);
}
else
throw new Exception("No csv or spotify url provided");
string[] csvFiles = System.IO.Directory.GetFiles(outputFolder, "*.csv");
if ((tracksCsv != "" && !System.IO.File.Exists(tracksCsv)) && csvFiles.Length == 0)
throw new Exception("csv file not found");
if (tracksCsv == "" && csvFiles.Length > 0)
tracksCsv = csvFiles[0];
tracks = ParseCsvIntoTrackInfo(tracksCsv, artistCol, trackCol, lengthCol, fullTitleCol, uploaderCol, timeUnit: timeUnit);
folderName = RemoveInvalidChars(folderName, " ");
if (parentFolder == "" && !m3uOnly)
throw new Exception("No folder provided (-p <path>)");
else if (parentFolder != "")
{
outputFolder = Path.Combine(parentFolder, folderName);
System.IO.Directory.CreateDirectory(outputFolder);
failsFilePath = Path.Combine(outputFolder, $"{folderName}_failed.txt");
if (!m3uOnly && System.IO.File.Exists(failsFilePath))
{
WriteAllLinesOutputFile("");
try { System.IO.File.Delete(failsFilePath); }
catch { }
}
}
if (m3uFilePath != "")
{
m3uFilePath = Path.Combine(m3uFilePath, folderName + ".m3u");
createM3u = true;
}
else if (outputFolder != "")
m3uFilePath = Path.Combine(outputFolder, folderName + ".m3u");
Track[] tmp = new Track[tracks.Count];
tracks.CopyTo(tmp);
var tracksStart = tmp.ToList();
failsFilePath = Path.Combine(outputFolder, "_failed.txt");
if (System.IO.File.Exists(failsFilePath))
{
WriteAllLinesOutputFile("");
try { System.IO.File.Delete(failsFilePath); }
catch { }
}
createM3u |= m3uOnly;
m3uFilePath = Path.Combine(outputFolder, "playlist.m3u");
List<string> m3uLines = Enumerable.Repeat("", tracksStart.Count).ToList();
if (skipExisting || m3uOnly)
if (skipExisting || m3uOnly || musicDir != "")
{
WriteLastLine("Checking if tracks exist in output folder...");
var outputDirFiles = System.IO.Directory.GetFiles(outputFolder, "*", SearchOption.AllDirectories);
var musicFiles = outputDirFiles.Where(f => IsMusicFile(f)).ToArray();
tracks = tracks.Where(x =>
if (outputFolder != "")
{
bool exists = FileExistsInCollection(x.TrackTitle == "" ? x.UnparsedTitle : x.TrackTitle, x.Length, necessaryCond, musicFiles, out string? path);
if (exists)
m3uLines[tracksStart.IndexOf(x)] = path;
return !exists;
}).ToList();
WriteLastLine("Checking if tracks exist in output folder...");
var outputDirFiles = System.IO.Directory.GetFiles(outputFolder, "*", SearchOption.AllDirectories);
var musicFiles = outputDirFiles.Where(f => IsMusicFile(f)).ToArray();
tracks = tracks.Where(x =>
{
bool exists = FileExistsInCollection(x.TrackTitle == "" ? x.UnparsedTitle : x.TrackTitle, x.Length, necessaryCond, musicFiles, out string? path);
if (exists)
m3uLines[tracksStart.IndexOf(x)] = path;
return !exists;
}).ToList();
}
if (musicDir != "")
{
WriteLastLine($"Checking if tracks exist in library...");
var musicDirFiles = System.IO.Directory.GetFiles(musicDir, "*", SearchOption.AllDirectories);
musicFiles = musicDirFiles
.Where(filename => !filename.Contains(outputFolder))
var musicFiles = musicDirFiles
.Where(filename => outputFolder == "" || !filename.Contains(outputFolder))
.Where(filename => IsMusicFile(filename)).ToArray();
tracks = tracks.Where(x =>
{
@ -281,6 +355,7 @@ class Program
if (createM3u)
{
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(m3uFilePath));
if (System.IO.File.Exists(m3uFilePath))
using (var fileStream = new FileStream(m3uFilePath, FileMode.Truncate, FileAccess.Write, FileShare.ReadWrite)) { fileStream.SetLength(0); }
if (tracks.Count < tracksStart.Count)
@ -296,6 +371,12 @@ class Program
if (m3uOnly)
{
WriteLastLine($"Created m3u file: {tracksStart.Count - tracks.Count} of {tracksStart.Count} found as local files");
if (tracks.Count > 0)
{
WriteLastLine($"Missing:");
foreach (var t in tracks)
WriteLastLine((t.TrackTitle == "" ? t.UnparsedTitle : $"{t.TrackTitle} - {t.ArtistName}") + (t.Length > 0 ? $" ({t.Length}s)" : ""));
}
return;
}
}
@ -458,7 +539,8 @@ class Program
if (fileResponses.Count == 0)
{
WriteLastLine($"Failed to find: {title}, skipping", ConsoleColor.Red);
var failedDownloadInfo = $"{title} ({track.Length}s) [Reason: No file found with matching criteria]";
var length = track.Length > 0 ? $"({track.Length}s) " : "";
var failedDownloadInfo = $"{title} {length}[Reason: No file found with matching criteria]";
WriteLineOutputFile(failedDownloadInfo);
cts.Dispose();
return "";
@ -471,7 +553,8 @@ class Program
if (skipIfPrefFailed && attemptedDownloadPref && !pref)
{
WriteLastLine($"Pref. version of the file exists, but couldn't be downloaded: {title}, skipping", ConsoleColor.Red);
var failedDownloadInfo = $"{title} ({track.Length}s) [Preferred version of the file exists, but couldn't be downloaded]";
var length = track.Length > 0 ? $"({track.Length}s) " : "";
var failedDownloadInfo = $"{title} {length}[Preferred version of the file exists, but couldn't be downloaded]";
WriteLineOutputFile(failedDownloadInfo);
cts.Dispose();
return "";
@ -493,7 +576,8 @@ class Program
if (--downloadRetries <= 0)
{
WriteLastLine($"Failed to download: {title}, skipping", ConsoleColor.Red);
var failedDownloadInfo = $"{title} ({track.Length}s) [Reason: Out of download retries]";
var length = track.Length > 0 ? $"({track.Length}s) " : "";
var failedDownloadInfo = $"{title} {length}[Reason: Out of download retries]";
WriteLineOutputFile(failedDownloadInfo);
cts.Dispose();
return "";
@ -504,7 +588,8 @@ class Program
if (!downloading)
{
WriteLastLine($"Failed to download: {title}", ConsoleColor.Red);
var failedDownloadInfo = $"{title} ({track.Length}s) [Reason: All downloads failed]";
var length = track.Length > 0 ? $"({track.Length}s) " : "";
var failedDownloadInfo = $"{title} {length}[Reason: All downloads failed]";
WriteLineOutputFile(failedDownloadInfo);
cts.Dispose();
return "";
@ -783,6 +868,29 @@ class Program
}
}
static async Task<(string?, List<Track>)> GetSpotifyPlaylist(string url, string id, string secret, bool login)
{
var spotify = new Client(id, secret);
if (login)
{
await spotify.AuthorizeLogin();
await spotify.IsClientReady();
}
else
await spotify.Authorize();
(string? name, var res) = await spotify.GetPlaylist(url);
List<Track> trackList = res.Select(t =>
new Track
{
TrackTitle = t.Item2,
ArtistName = t.Item1,
Length = t.Item3
}).ToList();
return (name, trackList);
}
static List<Track> ParseCsvIntoTrackInfo(string path, string artistCol = "", string trackCol = "", string lengthCol = "", string titleCol = "", string uploaderCol = "", string timeUnit = "s")
{
var tracks = new List<Track>();

View file

@ -0,0 +1,7 @@
{
"profiles": {
"slsk-batchdl": {
"commandName": "Project"
}
}
}

101
slsk-batchdl/Spotify.cs Normal file
View file

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using SpotifyAPI.Web;
using SpotifyAPI.Web.Auth;
using Swan;
namespace Spotify
{
public class Client
{
private EmbedIOAuthServer _server;
private readonly string _clientId;
private readonly string _clientSecret;
private SpotifyClient _client;
public Client(string clientId, string clientSecret)
{
_clientId = clientId;
_clientSecret = clientSecret;
}
public async Task Authorize()
{
var config = SpotifyClientConfig.CreateDefault();
var request = new ClientCredentialsRequest(_clientId, _clientSecret);
var response = await new OAuthClient(config).RequestToken(request);
_client = new SpotifyClient(config.WithToken(response.AccessToken));
}
public async Task AuthorizeLogin()
{
Swan.Logging.Logger.NoLogging();
_server = new EmbedIOAuthServer(new Uri("http://localhost:5000/callback"), 5000);
await _server.Start();
_server.AuthorizationCodeReceived += OnAuthorizationCodeReceived;
_server.ErrorReceived += OnErrorReceived;
var request = new LoginRequest(_server.BaseUri, _clientId, LoginRequest.ResponseType.Code)
{
Scope = new List<string> { Scopes.UserReadEmail }
};
BrowserUtil.Open(request.ToUri());
}
private async Task OnAuthorizationCodeReceived(object sender, AuthorizationCodeResponse response)
{
await _server.Stop();
var config = SpotifyClientConfig.CreateDefault();
var tokenResponse = await new OAuthClient(config).RequestToken(
new AuthorizationCodeTokenRequest(
_clientId, _clientSecret, response.Code, new Uri("http://localhost:5000/callback")
)
);
_client = new SpotifyClient(tokenResponse.AccessToken);
}
private async Task OnErrorReceived(object sender, string error, string state)
{
Console.WriteLine($"Aborting authorization, error received: {error}");
await _server.Stop();
}
public async Task<bool> IsClientReady()
{
while (_client == null)
await Task.Delay(1000);
return true;
}
public async Task<(string?, List<(string, string, int)> )> GetPlaylist(string url)
{
var playlistId = GetPlaylistIdFromUrl(url);
var p = await _client.Playlists.Get(playlistId);
var tracks = await _client.Playlists.GetItems(playlistId);
List<(string, string, int)> res = new List<(string, string, int)>();
foreach (var track in tracks.Items)
{
string[] artists = ((IEnumerable<object>)track.Track.ReadProperty("artists")).Select(a => (string)a.ReadProperty("name")).ToArray();
string artist = artists[0];
string name = (string)track.Track.ReadProperty("name");
int duration = (int)track.Track.ReadProperty("durationMs");
res.Add((artist, name, duration / 1000));
}
return (p.Name, res);
}
private string GetPlaylistIdFromUrl(string url)
{
var uri = new Uri(url);
var segments = uri.Segments;
return segments[segments.Length - 1].TrimEnd('/');
}
}
}

View file

@ -9,6 +9,8 @@
<ItemGroup>
<PackageReference Include="Soulseek" Version="6.1.1" />
<PackageReference Include="SpotifyAPI.Web" Version="7.0.0" />
<PackageReference Include="SpotifyAPI.Web.Auth" Version="7.0.0" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
</ItemGroup>