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:
parent
c37287d926
commit
5c49877040
6 changed files with 299 additions and 120 deletions
11
README.md
11
README.md
|
@ -1,6 +1,6 @@
|
||||||
# slsk-batchdl
|
# 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]
|
Usage: slsk-batchdl.exe [OPTIONS]
|
||||||
|
@ -10,14 +10,16 @@ Options:
|
||||||
--username <username> Soulseek username
|
--username <username> Soulseek username
|
||||||
--password <password> Soulseek password
|
--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-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)
|
--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
|
--csv <path> Use a csv file containing track info to download
|
||||||
--artist-col <column> Specify if the csv file contains an artist name column
|
--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
|
--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
|
--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)
|
--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
|
--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-bitrate <rate> Necessary maximum bitrate
|
||||||
--nec-max-sample-rate <rate> Necessary maximum sample rate
|
--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)
|
--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
|
--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.
|
--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
|
--create-m3u Create an m3u playlist file
|
||||||
--m3u-only Only create an m3u playlist file with existing tracks and exit
|
--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").
|
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:
|
## 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
|
- The console output tends to break after a while
|
||||||
- Much of the code was written by ChatGPT
|
- Much of the code was written by ChatGPT
|
||||||
|
|
|
@ -13,7 +13,6 @@ using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
using Soulseek;
|
using Soulseek;
|
||||||
using Spotify;
|
|
||||||
using TagLib.Matroska;
|
using TagLib.Matroska;
|
||||||
using static System.Formats.Asn1.AsnWriter;
|
using static System.Formats.Asn1.AsnWriter;
|
||||||
using static System.Net.WebRequestMethods;
|
using static System.Net.WebRequestMethods;
|
||||||
|
@ -45,14 +44,16 @@ class Program
|
||||||
Console.WriteLine(" --username <username> Soulseek username");
|
Console.WriteLine(" --username <username> Soulseek username");
|
||||||
Console.WriteLine(" --password <password> Soulseek password");
|
Console.WriteLine(" --password <password> Soulseek password");
|
||||||
Console.WriteLine();
|
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-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(" --spotify-secret <sec> Your spotify client secret (use if the default fails or if playlist private)");
|
||||||
Console.WriteLine();
|
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(" --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(" --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(" --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(" --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(" --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");
|
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-bitrate <rate> Necessary maximum bitrate");
|
||||||
Console.WriteLine(" --nec-max-sample-rate <rate> Necessary maximum sample rate");
|
Console.WriteLine(" --nec-max-sample-rate <rate> Necessary maximum sample rate");
|
||||||
Console.WriteLine();
|
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(" --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(" --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(" --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(" --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-only Only create an m3u playlist file with existing tracks and exit");
|
||||||
|
@ -101,6 +104,7 @@ class Program
|
||||||
musicDir = "";
|
musicDir = "";
|
||||||
string parentFolder = "";
|
string parentFolder = "";
|
||||||
string folderName = "";
|
string folderName = "";
|
||||||
|
string ytUrl = "";
|
||||||
string spotifyUrl = "";
|
string spotifyUrl = "";
|
||||||
string spotifyId = "";
|
string spotifyId = "";
|
||||||
string spotifySecret = "";
|
string spotifySecret = "";
|
||||||
|
@ -117,9 +121,11 @@ class Program
|
||||||
string lengthCol = "";
|
string lengthCol = "";
|
||||||
string timeUnit = "s";
|
string timeUnit = "s";
|
||||||
ytdlpFormat = "bestaudio/best";
|
ytdlpFormat = "bestaudio/best";
|
||||||
|
bool reverse = false;
|
||||||
bool useYtdlp = false;
|
bool useYtdlp = false;
|
||||||
bool skipExisting = false;
|
bool skipExisting = false;
|
||||||
bool skipIfPrefFailed = false;
|
bool skipIfPrefFailed = false;
|
||||||
|
bool albumSearch = false;
|
||||||
bool createM3u = false;
|
bool createM3u = false;
|
||||||
bool m3uOnly = false;
|
bool m3uOnly = false;
|
||||||
int searchTimeout = 15000;
|
int searchTimeout = 15000;
|
||||||
|
@ -161,6 +167,9 @@ class Program
|
||||||
case "--csv":
|
case "--csv":
|
||||||
tracksCsv = args[++i];
|
tracksCsv = args[++i];
|
||||||
break;
|
break;
|
||||||
|
case "--youtube":
|
||||||
|
ytUrl = args[++i];
|
||||||
|
break;
|
||||||
case "--spotify":
|
case "--spotify":
|
||||||
spotifyUrl = args[++i];
|
spotifyUrl = args[++i];
|
||||||
break;
|
break;
|
||||||
|
@ -185,6 +194,9 @@ class Program
|
||||||
case "--album-col":
|
case "--album-col":
|
||||||
albumCol = args[++i];
|
albumCol = args[++i];
|
||||||
break;
|
break;
|
||||||
|
case "--album-search":
|
||||||
|
albumSearch = true;
|
||||||
|
break;
|
||||||
case "--full-title-col":
|
case "--full-title-col":
|
||||||
fullTitleCol = args[++i];
|
fullTitleCol = args[++i];
|
||||||
break;
|
break;
|
||||||
|
@ -206,6 +218,9 @@ class Program
|
||||||
case "--skip-existing":
|
case "--skip-existing":
|
||||||
skipExisting = true;
|
skipExisting = true;
|
||||||
break;
|
break;
|
||||||
|
case "--reverse":
|
||||||
|
reverse = true;
|
||||||
|
break;
|
||||||
case "--skip-if-pref-failed":
|
case "--skip-if-pref-failed":
|
||||||
skipIfPrefFailed = true;
|
skipIfPrefFailed = true;
|
||||||
break;
|
break;
|
||||||
|
@ -279,6 +294,15 @@ class Program
|
||||||
if (spotifyUrl == "likes")
|
if (spotifyUrl == "likes")
|
||||||
{
|
{
|
||||||
playlistName = "Spotify 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);
|
tracks = await GetSpotifyLikes(spotifyId, spotifySecret);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
@ -310,7 +334,15 @@ class Program
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (folderName == "")
|
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 != "")
|
else if (tracksCsv != "")
|
||||||
{
|
{
|
||||||
|
@ -327,7 +359,7 @@ class Program
|
||||||
folderName = Path.GetFileNameWithoutExtension(tracksCsv);
|
folderName = Path.GetFileNameWithoutExtension(tracksCsv);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
throw new Exception("No csv or spotify url provided");
|
throw new Exception("No csv, spotify or youtube url provided");
|
||||||
|
|
||||||
|
|
||||||
folderName = RemoveInvalidChars(folderName, " ");
|
folderName = RemoveInvalidChars(folderName, " ");
|
||||||
|
@ -423,7 +455,10 @@ class Program
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
albumSearch |= albumCol != "";
|
||||||
int tracksRemaining = tracks.Count;
|
int tracksRemaining = tracks.Count;
|
||||||
|
if (reverse)
|
||||||
|
tracks.Reverse();
|
||||||
|
|
||||||
//foreach (var track in tracks)
|
//foreach (var track in tracks)
|
||||||
// WriteLastLine($"{track.Title}, {track.ArtistName} - {track.TackTitle} ({track.Length}s)");
|
// WriteLastLine($"{track.Title}, {track.ArtistName} - {track.TackTitle} ({track.Length}s)");
|
||||||
|
@ -441,7 +476,7 @@ class Program
|
||||||
await semaphore.WaitAsync();
|
await semaphore.WaitAsync();
|
||||||
try
|
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 != "")
|
if (savedFilePath != "")
|
||||||
{
|
{
|
||||||
tracksRemaining--;
|
tracksRemaining--;
|
||||||
|
@ -471,7 +506,7 @@ class Program
|
||||||
WriteLastLine($"Failed to download:\n{System.IO.File.ReadAllText(failsFilePath)}");
|
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}";
|
var title = track.TrackTitle == "" ? $"{track.UnparsedTitle}" : $"{track.ArtistName} - {track.TrackTitle}";
|
||||||
if (track.TrackTitle == "")
|
if (track.TrackTitle == "")
|
||||||
|
@ -544,13 +579,13 @@ class Program
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException ex) { }
|
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\"");
|
Debug.WriteLine("\"Artist - Track\" not found, trying \"Album Track\"");
|
||||||
string searchText = $"{track.Album} {track.TrackTitle}";
|
string searchText = $"{track.Album} {track.TrackTitle}";
|
||||||
searchOptions = new SearchOptions
|
searchOptions = new SearchOptions
|
||||||
(
|
(
|
||||||
minimumPeerUploadSpeed: 1, searchTimeout: albumSearchTimeout,
|
minimumPeerUploadSpeed: 1, searchTimeout: 5000,
|
||||||
responseFilter: (response) =>
|
responseFilter: (response) =>
|
||||||
{
|
{
|
||||||
return response.UploadSpeed > 0;
|
return response.UploadSpeed > 0;
|
||||||
|
@ -647,7 +682,7 @@ class Program
|
||||||
try {
|
try {
|
||||||
downloading = true;
|
downloading = true;
|
||||||
string fname = GetSaveName(track);
|
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 + ".*");
|
string[] files = System.IO.Directory.GetFiles(outputFolder, fname + ".*");
|
||||||
foreach (string file in files)
|
foreach (string file in files)
|
||||||
{
|
{
|
||||||
|
@ -799,14 +834,20 @@ class Program
|
||||||
return RemoveInvalidChars(name, " ");
|
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();
|
Process process = new Process();
|
||||||
ProcessStartInfo startInfo = new ProcessStartInfo();
|
ProcessStartInfo startInfo = new ProcessStartInfo();
|
||||||
|
|
||||||
startInfo.FileName = "yt-dlp";
|
startInfo.FileName = "yt-dlp";
|
||||||
string search = track.TrackTitle == "" ? track.UnparsedTitle : $"{track.ArtistName} - {track.TrackTitle}";
|
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.RedirectStandardOutput = true;
|
||||||
startInfo.RedirectStandardError = true;
|
startInfo.RedirectStandardError = true;
|
||||||
|
@ -822,7 +863,7 @@ class Program
|
||||||
|
|
||||||
List<(int, string, string)> results = new List<(int, string, string)>();
|
List<(int, string, string)> results = new List<(int, string, string)>();
|
||||||
string output;
|
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)
|
while ((output = process.StandardOutput.ReadLine()) != null)
|
||||||
{
|
{
|
||||||
Match match = regex.Match(output);
|
Match match = regex.Match(output);
|
||||||
|
@ -842,25 +883,11 @@ class Program
|
||||||
|
|
||||||
foreach (var res in results)
|
foreach (var res in results)
|
||||||
{
|
{
|
||||||
|
bool possibleMatch = false;
|
||||||
if (conditions.LengthToleranceSatisfies(track, res.Item1))
|
if (conditions.LengthToleranceSatisfies(track, res.Item1))
|
||||||
{
|
{
|
||||||
WriteLastLine($"[yt-dlp] Downloading: {res.Item3} ({res.Item1}s)");
|
WriteLastLine($"[yt-dlp] Downloading: {res.Item3} ({res.Item1}s)");
|
||||||
process = new Process();
|
YtdlpDownload(res.Item2, savePathNoExt);
|
||||||
startInfo = new ProcessStartInfo();
|
|
||||||
|
|
||||||
startInfo.FileName = "yt-dlp";
|
|
||||||
startInfo.Arguments = $"{res.Item2} -f {ytdlpFormat} -ci -o \"{savePath}.%(ext)s\" --extract-audio";
|
|
||||||
WriteLastLine($"{startInfo.FileName} {startInfo.Arguments}");
|
|
||||||
|
|
||||||
startInfo.RedirectStandardOutput = true;
|
|
||||||
startInfo.RedirectStandardError = true;
|
|
||||||
startInfo.UseShellExecute = false;
|
|
||||||
process.StartInfo = startInfo;
|
|
||||||
process.OutputDataReceived += (sender, e) => { WriteLastLine(e.Data); };
|
|
||||||
process.ErrorDataReceived += (sender, e) => { WriteLastLine(e.Data); };
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
process.WaitForExit();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -868,6 +895,26 @@ class Program
|
||||||
throw new Exception($"[yt-dlp] No matching files found");
|
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 = $"{id} -f {ytdlpFormat} -ci -o \"{savePathNoExt}.%(ext)s\" --extract-audio";
|
||||||
|
WriteLastLine($"{startInfo.FileName} {startInfo.Arguments}");
|
||||||
|
|
||||||
|
startInfo.RedirectStandardOutput = true;
|
||||||
|
startInfo.RedirectStandardError = true;
|
||||||
|
startInfo.UseShellExecute = false;
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.OutputDataReceived += (sender, e) => { WriteLastLine(e.Data); };
|
||||||
|
process.ErrorDataReceived += (sender, e) => { WriteLastLine(e.Data); };
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
process.WaitForExit();
|
||||||
|
}
|
||||||
|
|
||||||
class DownloadInfo
|
class DownloadInfo
|
||||||
{
|
{
|
||||||
public string savePath;
|
public string savePath;
|
||||||
|
@ -1038,7 +1085,7 @@ class Program
|
||||||
|
|
||||||
static async Task<(string?, List<Track>)> GetSpotifyPlaylist(string url, string id, string secret, bool login)
|
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)
|
if (login)
|
||||||
{
|
{
|
||||||
await spotify.AuthorizeLogin();
|
await spotify.AuthorizeLogin();
|
||||||
|
@ -1053,7 +1100,7 @@ class Program
|
||||||
|
|
||||||
static async Task<List<Track>> GetSpotifyLikes(string id, string secret)
|
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.AuthorizeLogin();
|
||||||
await spotify.IsClientReady();
|
await spotify.IsClientReady();
|
||||||
|
|
||||||
|
@ -1209,6 +1256,16 @@ class Program
|
||||||
str = str.Replace(c.ToString(), replaceStr);
|
str = str.Replace(c.ToString(), replaceStr);
|
||||||
return str;
|
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
|
public struct Track
|
||||||
|
@ -1218,6 +1275,7 @@ public struct Track
|
||||||
public string TrackTitle = "";
|
public string TrackTitle = "";
|
||||||
public string ArtistName = "";
|
public string ArtistName = "";
|
||||||
public string Album = "";
|
public string Album = "";
|
||||||
|
public string YtID = "";
|
||||||
public int Length = -1;
|
public int Length = -1;
|
||||||
public Track() { }
|
public Track() { }
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,9 +7,13 @@
|
||||||
"commandName": "Project",
|
"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"
|
"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",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -6,82 +6,85 @@ using SpotifyAPI.Web.Auth;
|
||||||
using Swan;
|
using Swan;
|
||||||
using TagLib.IFD.Tags;
|
using TagLib.IFD.Tags;
|
||||||
|
|
||||||
namespace Spotify
|
public class Spotify
|
||||||
{
|
{
|
||||||
public class Client
|
private EmbedIOAuthServer _server;
|
||||||
|
private readonly string _clientId;
|
||||||
|
private readonly string _clientSecret;
|
||||||
|
private SpotifyClient _client;
|
||||||
|
private bool loggedIn = false;
|
||||||
|
|
||||||
|
public Spotify(string clientId, string clientSecret)
|
||||||
{
|
{
|
||||||
private EmbedIOAuthServer _server;
|
_clientId = clientId;
|
||||||
private readonly string _clientId;
|
_clientSecret = clientSecret;
|
||||||
private readonly string _clientSecret;
|
}
|
||||||
private SpotifyClient _client;
|
|
||||||
private bool loggedIn = false;
|
|
||||||
|
|
||||||
public Client(string clientId, string 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)
|
||||||
{
|
{
|
||||||
_clientId = clientId;
|
Scope = new List<string> { Scopes.UserLibraryRead, Scopes.PlaylistReadPrivate }
|
||||||
_clientSecret = clientSecret;
|
};
|
||||||
}
|
BrowserUtil.Open(request.ToUri());
|
||||||
|
loggedIn = true;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task Authorize()
|
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<List<Track>> GetLikes()
|
||||||
|
{
|
||||||
|
if (!loggedIn)
|
||||||
|
throw new Exception("Can't get liked music, not logged in");
|
||||||
|
|
||||||
|
List<Track> res = new List<Track>();
|
||||||
|
int offset = 0;
|
||||||
|
int limit = 50;
|
||||||
|
|
||||||
|
while (true)
|
||||||
{
|
{
|
||||||
var config = SpotifyClientConfig.CreateDefault();
|
var tracks = await _client.Library.GetTracks(new LibraryTracksRequest { Limit = limit, Offset = offset });
|
||||||
|
|
||||||
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.UserLibraryRead, Scopes.PlaylistReadPrivate }
|
|
||||||
};
|
|
||||||
BrowserUtil.Open(request.ToUri());
|
|
||||||
loggedIn = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<List<Track>> GetLikes()
|
|
||||||
{
|
|
||||||
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>();
|
|
||||||
|
|
||||||
foreach (var track in tracks.Items)
|
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 });
|
res.Add(new Track { Album = album, ArtistName = artist, TrackTitle = name, Length = duration / 1000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
if (tracks.Items.Count < limit)
|
||||||
|
break;
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(string?, List<Track>)> GetPlaylist(string url)
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task<(string?, List<Track>)> GetPlaylist(string url)
|
||||||
|
{
|
||||||
|
var playlistId = GetPlaylistIdFromUrl(url);
|
||||||
|
var p = await _client.Playlists.Get(playlistId);
|
||||||
|
|
||||||
|
List<Track> res = new List<Track>();
|
||||||
|
int offset = 0;
|
||||||
|
int limit = 100;
|
||||||
|
|
||||||
|
while (true)
|
||||||
{
|
{
|
||||||
var playlistId = GetPlaylistIdFromUrl(url);
|
var tracks = await _client.Playlists.GetItems(playlistId, new PlaylistGetItemsRequest { Limit = limit, Offset = offset });
|
||||||
var p = await _client.Playlists.Get(playlistId);
|
|
||||||
var tracks = await _client.Playlists.GetItems(playlistId);
|
|
||||||
List<Track> res = new List<Track>();
|
|
||||||
|
|
||||||
foreach (var track in tracks.Items)
|
foreach (var track in tracks.Items)
|
||||||
{
|
{
|
||||||
|
@ -110,17 +126,22 @@ namespace Spotify
|
||||||
string name = (string)track.Track.ReadProperty("name");
|
string name = (string)track.Track.ReadProperty("name");
|
||||||
string album = (string)track.Track.ReadProperty("album").ReadProperty("name");
|
string album = (string)track.Track.ReadProperty("album").ReadProperty("name");
|
||||||
int duration = (int)track.Track.ReadProperty("durationMs");
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (p.Name, res);
|
if (tracks.Items.Count < limit)
|
||||||
|
break;
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetPlaylistIdFromUrl(string url)
|
return (p.Name, res);
|
||||||
{
|
}
|
||||||
var uri = new Uri(url);
|
|
||||||
var segments = uri.Segments;
|
private string GetPlaylistIdFromUrl(string url)
|
||||||
return segments[segments.Length - 1].TrimEnd('/');
|
{
|
||||||
}
|
var uri = new Uri(url);
|
||||||
|
var segments = uri.Segments;
|
||||||
|
return segments[segments.Length - 1].TrimEnd('/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
90
slsk-batchdl/YouTube.cs
Normal file
90
slsk-batchdl/YouTube.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,6 +12,7 @@
|
||||||
<PackageReference Include="SpotifyAPI.Web" Version="7.0.0" />
|
<PackageReference Include="SpotifyAPI.Web" Version="7.0.0" />
|
||||||
<PackageReference Include="SpotifyAPI.Web.Auth" Version="7.0.0" />
|
<PackageReference Include="SpotifyAPI.Web.Auth" Version="7.0.0" />
|
||||||
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
||||||
|
<PackageReference Include="YoutubeExplode" Version="6.2.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
Loading…
Reference in a new issue