diff --git a/README.md b/README.md index f91834f..aae2621 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A batch downloader for Soulseek using Soulseek.NET. Accepts CSV files, Spotify & ``` slsk-batchdl --csv test.csv --artist-col "Artist Name(s)" --track-col "Track Name" --length-col "Duration (ms)" --time-unit ms ``` -You can omit the column names provided they are named predictably (like in this example). Use `--print-tracks` before downloading to check if everything has been parsed correctly. +You can omit the column names if they are named predictably (like in this example). Use `--print-tracks` before downloading to check if everything has been parsed correctly. - Download spotify likes while skipping existing songs, and create an m3u file: ``` @@ -59,6 +59,7 @@ Options: -n --number Download at most n tracks of a playlist -o --offset Skip a specified number of tracks --reverse Download tracks in reverse order + --remove-from-playlist Remove downloaded tracks from playlist (spotify only) --name-format Name format for downloaded tracks, e.g "{artist} - {title}" --m3u Create an m3u8 playlist file @@ -131,3 +132,4 @@ Supports .conf files: Create a file named `slsk-batchdl.conf` in the same direct ### Notes: - The CSV file must be saved with `,` as field delimiter and `"` as string delimiter, encoded with UTF8 - `--display single` and especially `double` can cause the printed lines to be duplicated or overwritten on some configurations. Use `simple` if that's an issue. In my testing on Windows, the terminal app seems to be affected by this (unlike the old command prompt). +- Why didn't I just use Python? diff --git a/slsk-batchdl/Program.cs b/slsk-batchdl/Program.cs index 3c23805..aec9344 100644 --- a/slsk-batchdl/Program.cs +++ b/slsk-batchdl/Program.cs @@ -1,10 +1,13 @@ -using Konsole; +using AngleSharp.Dom; +using Konsole; using Soulseek; using System.Collections.Concurrent; using System.Diagnostics; using System.IO; using System.Net.NetworkInformation; +using System.Net.Sockets; using System.Text.RegularExpressions; +using YoutubeExplode.Playlists; static class Program { @@ -56,6 +59,7 @@ static class Program static bool createM3u = false; static bool m3uOnly = false; static bool useTagsCheckExisting = false; + static bool removeTracksFromSource = false; static int maxTracks = int.MaxValue; static int offset = 0; @@ -84,6 +88,8 @@ static class Program static string confPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "slsk-batchdl.conf"); + static string playlistUri = ""; + static Spotify? spotifyClient = null; static string ytdlpFormat = "bestaudio/best"; static int downloadMaxStaleTime = 50000; static int updateDelay = 100; @@ -140,6 +146,7 @@ static class Program "\n -n --number Download at most n tracks of a playlist" + "\n -o --offset Skip a specified number of tracks" + "\n --reverse Download tracks in reverse order" + + "\n --remove-from-playlist Remove downloaded tracks from playlist (spotify only)" + "\n --name-format Name format for downloaded tracks, e.g \"{artist} - {title}\"" + "\n --m3u Create an m3u8 playlist file" + "\n" + @@ -210,7 +217,7 @@ static class Program try { if (Console.BufferHeight <= 50) - WriteLine("You may be using the windows terminal app. Recommended to use command prompt to avoid printing issues.", ConsoleColor.DarkYellow); + WriteLine("Recommended to use the command prompt instead of terminal app to avoid printing issues.", ConsoleColor.DarkYellow); } catch { } #endif @@ -346,6 +353,9 @@ static class Program case "--skip-not-found": skipNotFound = true; break; + case "--remove-from-playlist": + removeTracksFromSource = true; + break; case "--remove-ft": removeFt = true; break; @@ -486,53 +496,67 @@ static class Program int max = reverse ? int.MaxValue : maxTracks; int off = reverse ? 0 : offset; + if (spotifyUrl != "") { + string? playlistName; bool usedDefaultId = false; + bool login = spotifyUrl == "likes" || removeTracksFromSource; + + void readSpotifyCreds() + { + Console.Write("Spotify client ID:"); + spotifyId = Console.ReadLine(); + Console.Write("Spotify client secret:"); + spotifySecret = Console.ReadLine(); + Console.WriteLine(); + } + if (spotifyId == "" || spotifySecret == "") { - spotifyId = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encodedSpotifyId)); - spotifySecret = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encodedSpotifySecret)); - usedDefaultId = true; + if (login) + readSpotifyCreds(); + else + { + spotifyId = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encodedSpotifyId)); + spotifySecret = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encodedSpotifySecret)); + usedDefaultId = true; + } } - string? playlistName; + + spotifyClient = new Spotify(spotifyId, spotifySecret); + await spotifyClient.Authorize(login, removeTracksFromSource); + if (spotifyUrl == "likes") { + Console.WriteLine("Loading Spotify likes"); + tracks = await spotifyClient.GetLikes(max, off); playlistName = "Spotify Likes"; - if (usedDefaultId) - { - Console.Write("Spotify client ID:"); - spotifyId = Console.ReadLine(); - Console.Write("Spotify client secret:"); - spotifySecret = Console.ReadLine(); - Console.WriteLine(); - } - tracks = await GetSpotifyLikes(spotifyId, spotifySecret, max, off); } else { try { - (playlistName, tracks) = await GetSpotifyPlaylist(spotifyUrl, spotifyId, spotifySecret, false, max, off); + Console.WriteLine("Loading Spotify tracks"); + (playlistName, playlistUri, tracks) = await spotifyClient.GetPlaylist(spotifyUrl, max, off); } catch (SpotifyAPI.Web.APIException) { - Console.WriteLine("Spotify playlist not found. It may be set to private. Login? [Y/n]"); - string answer = Console.ReadLine(); - if (answer.ToLower() == "y") + if (!login) { - if (usedDefaultId) + Console.WriteLine("Spotify playlist not found. It may be set to private. Login? [Y/n]"); + string answer = Console.ReadLine(); + if (answer.ToLower() == "y") { - Console.Write("Spotify client ID:"); - spotifyId = Console.ReadLine(); - Console.Write("Spotify client secret:"); - spotifySecret = Console.ReadLine(); - Console.WriteLine(); + if (usedDefaultId) + readSpotifyCreds(); + await spotifyClient.Authorize(true); + Console.WriteLine("Loading Spotify tracks"); + (playlistName, playlistUri, tracks) = await spotifyClient.GetPlaylist(spotifyUrl, max, off); } - (playlistName, tracks) = await GetSpotifyPlaylist(spotifyUrl, spotifyId, spotifySecret, true, max, off); + else return; } - else - return; + else throw; } } if (folderName == "") @@ -561,7 +585,7 @@ static class Program else if (tracksCsv != "") { if (!System.IO.File.Exists(tracksCsv)) - throw new Exception("csv file not found"); + throw new Exception("CSV file not found"); tracks = await ParseCsvIntoTrackInfo(tracksCsv, artistCol, trackCol, lengthCol, albumCol, descCol, ytIdCol, timeUnit, ytParse); tracks = tracks.Skip(off).Take(max).ToList(); @@ -761,8 +785,14 @@ static class Program if (savedFilePath != "") { Interlocked.Increment(ref successCount); - m3uLines[tracksStart.IndexOf(track)] = Path.GetFileName(savedFilePath); + if (removeTracksFromSource) + { + if (!string.IsNullOrEmpty(spotifyUrl)) + spotifyClient.RemoveTrackFromPlaylist(playlistUri, track.URI); + } + + m3uLines[tracksStart.IndexOf(track)] = Path.GetFileName(savedFilePath); if (createM3u) { using (var fileStream = new FileStream(m3uFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite)) @@ -1259,11 +1289,11 @@ static class Program static async Task YtdlpSearchAndDownload(Track track, ProgressBar progress) { - if (track.YtID != "") + if (track.URI != "") { - string videoTitle = (await YouTube.GetVideoInfo(track.YtID)).title; + string videoTitle = (await YouTube.GetVideoInfo(track.URI)).title; string saveFilePathNoExt = GetSavePathNoExt(videoTitle, track); - await YtdlpDownload(track.YtID, saveFilePathNoExt, progress); + await YtdlpDownload(track.URI, saveFilePathNoExt, progress); return saveFilePathNoExt; } @@ -1625,33 +1655,6 @@ static class Program } } - static async Task<(string?, List)> GetSpotifyPlaylist(string url, string id, string secret, bool login, int max=int.MaxValue, int offset=0) - { - var spotify = new Spotify(id, secret); - if (login) - { - await spotify.AuthorizeLogin(); - await spotify.IsClientReady(); - } - else - await spotify.Authorize(); - - Console.WriteLine("Loading Spotify tracks"); - (string? name, var res) = await spotify.GetPlaylist(url, max, offset); - return (name, res); - } - - static async Task> GetSpotifyLikes(string id, string secret, int max = int.MaxValue, int offset = 0) - { - var spotify = new Spotify(id, secret); - await spotify.AuthorizeLogin(); - await spotify.IsClientReady(); - - Console.WriteLine("Loading Spotify tracks"); - var res = await spotify.GetLikes(max, offset); - return res; - } - static async Task> ParseCsvIntoTrackInfo(string path, string? artistCol = "", string? trackCol = "", string? lengthCol = "", string? albumCol = "", string? descCol = "", string? ytIdCol = "", string timeUnit = "", bool ytParse = false) { @@ -1693,7 +1696,7 @@ static class Program } if (!string.IsNullOrEmpty(usingColumns)) - Console.WriteLine($"Using columns: {usingColumns.TrimEnd(' ', ',')}."); + Console.WriteLine($"Using inferred columns: {usingColumns.TrimEnd(' ', ',')}."); if (cols[0] == "") WriteLine($"Warning: No artist column specified, results may be imprecise", ConsoleColor.DarkYellow); @@ -2218,7 +2221,7 @@ public struct Track public string TrackTitle = ""; public string ArtistName = ""; public string Album = ""; - public string YtID = ""; + public string URI = ""; public int Length = -1; public bool ArtistMaybeWrong = false; diff --git a/slsk-batchdl/Spotify.cs b/slsk-batchdl/Spotify.cs index a3061cc..32ddb80 100644 --- a/slsk-batchdl/Spotify.cs +++ b/slsk-batchdl/Spotify.cs @@ -16,31 +16,42 @@ public class Spotify _clientSecret = clientSecret; } - public async Task Authorize() + public async Task Authorize(bool login = false, bool needModify = false) { - 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:48721/callback"), 48721); - await _server.Start(); - - _server.AuthorizationCodeReceived += OnAuthorizationCodeReceived; - _server.ErrorReceived += OnErrorReceived; - - var request = new LoginRequest(_server.BaseUri, _clientId, LoginRequest.ResponseType.Code) + if (!login) { - Scope = new List { Scopes.UserLibraryRead, Scopes.PlaylistReadPrivate } - }; + var config = SpotifyClientConfig.CreateDefault(); - BrowserUtil.Open(request.ToUri()); + var request = new ClientCredentialsRequest(_clientId, _clientSecret); + var response = await new OAuthClient(config).RequestToken(request); + + _client = new SpotifyClient(config.WithToken(response.AccessToken)); + } + else + { + Swan.Logging.Logger.NoLogging(); + _server = new EmbedIOAuthServer(new Uri("http://localhost:48721/callback"), 48721); + await _server.Start(); + + _server.AuthorizationCodeReceived += OnAuthorizationCodeReceived; + _server.ErrorReceived += OnErrorReceived; + + var scope = new List { + Scopes.UserLibraryRead, Scopes.PlaylistReadPrivate, Scopes.PlaylistReadCollaborative + }; + + if (needModify) + { + scope.Add(Scopes.PlaylistModifyPublic); + scope.Add(Scopes.PlaylistModifyPrivate); + } + + var request = new LoginRequest(_server.BaseUri, _clientId, LoginRequest.ResponseType.Code) { Scope = scope }; + + BrowserUtil.Open(request.ToUri()); + + await IsClientReady(); + } } private async Task OnAuthorizationCodeReceived(object sender, AuthorizationCodeResponse response) @@ -103,8 +114,16 @@ public class Spotify return res; } + public async Task RemoveTrackFromPlaylist(string playlistId, string trackUri) + { + var item = new PlaylistRemoveItemsRequest.Item { Uri = trackUri }; + var pr = new PlaylistRemoveItemsRequest(); + pr.Tracks = new List() { item }; + try { await _client.Playlists.RemoveItems(playlistId, pr); } + catch { } + } - public async Task<(string?, List)> GetPlaylist(string url, int max = int.MaxValue, int offset = 0) + public async Task<(string?, string?, List)> GetPlaylist(string url, int max = int.MaxValue, int offset = 0) { var playlistId = GetPlaylistIdFromUrl(url); var p = await _client.Playlists.Get(playlistId); @@ -122,8 +141,10 @@ public class Spotify string artist = artists[0]; string name = (string)track.Track.ReadProperty("name"); string album = (string)track.Track.ReadProperty("album").ReadProperty("name"); + string uri = (string)track.Track.ReadProperty("uri"); int duration = (int)track.Track.ReadProperty("durationMs"); - res.Add(new Track { Album = album, ArtistName = artist, TrackTitle = name, Length = duration / 1000 }); + + res.Add(new Track { Album = album, ArtistName = artist, TrackTitle = name, Length = duration / 1000, URI = uri }); } if (tracks.Items.Count < limit || res.Count >= max) @@ -133,7 +154,7 @@ public class Spotify limit = Math.Min(max - res.Count, 100); } - return (p.Name, res); + return (p.Name, p.Id, res); } private string GetPlaylistIdFromUrl(string url) diff --git a/slsk-batchdl/YouTube.cs b/slsk-batchdl/YouTube.cs index 2e76d6c..c5feb60 100644 --- a/slsk-batchdl/YouTube.cs +++ b/slsk-batchdl/YouTube.cs @@ -89,7 +89,7 @@ public static class YouTube { (string title, string uploader, int length, string desc) info = ("", "", -1, ""); var track = new Track(); - track.YtID = id; + track.URI = id; title = title.Replace("–", "-"); diff --git a/slsk-batchdl/slsk-batchdl.csproj b/slsk-batchdl/slsk-batchdl.csproj index 8736e9a..daab983 100644 --- a/slsk-batchdl/slsk-batchdl.csproj +++ b/slsk-batchdl/slsk-batchdl.csproj @@ -12,7 +12,7 @@ - $(DefineConstants)TRACE; + $(DefineConstants);TRACE