mirror of
https://github.com/fiso64/slsk-batchdl.git
synced 2024-12-22 22:42:41 +00:00
commit
This commit is contained in:
parent
87fdff13cd
commit
78d6564695
6 changed files with 1155 additions and 532 deletions
105
README.md
105
README.md
|
@ -6,7 +6,7 @@ A batch downloader for Soulseek built with Soulseek.NET. Accepts CSV files or Sp
|
||||||
|
|
||||||
Download tracks from a csv file:
|
Download tracks from a csv file:
|
||||||
```
|
```
|
||||||
slsk-batchdl test.csv
|
sldl test.csv
|
||||||
```
|
```
|
||||||
<details>
|
<details>
|
||||||
<summary>CSV details</summary>
|
<summary>CSV details</summary>
|
||||||
|
@ -19,7 +19,7 @@ The names of the columns in the csv should be: `Artist`, `Title`, `Album`, `Leng
|
||||||
|
|
||||||
Download spotify likes while skipping songs that already exist in the output folder:
|
Download spotify likes while skipping songs that already exist in the output folder:
|
||||||
```
|
```
|
||||||
slsk-batchdl spotify-likes --skip-existing
|
sldl spotify-likes --skip-existing
|
||||||
```
|
```
|
||||||
<details>
|
<details>
|
||||||
<summary>Spotify details</summary>
|
<summary>Spotify details</summary>
|
||||||
|
@ -32,7 +32,7 @@ To download private playlists or liked songs you will need to provide a client i
|
||||||
|
|
||||||
Download from a youtube playlist with fallback to yt-dlp in case it is not found on soulseek, and retrieve deleted video titles from wayback machine:
|
Download from a youtube playlist with fallback to yt-dlp in case it is not found on soulseek, and retrieve deleted video titles from wayback machine:
|
||||||
```
|
```
|
||||||
slsk-batchdl --get-deleted --yt-dlp "https://www.youtube.com/playlist?list=PLI_eFW8NAFzYAXZ5DrU6E6mQ_XfhaLBUX"
|
sldl --get-deleted --yt-dlp "https://www.youtube.com/playlist?list=PLI_eFW8NAFzYAXZ5DrU6E6mQ_XfhaLBUX"
|
||||||
```
|
```
|
||||||
<details>
|
<details>
|
||||||
<summary>YouTube details</summary>
|
<summary>YouTube details</summary>
|
||||||
|
@ -46,34 +46,48 @@ Also note that due the high number of music videos in the above example playlist
|
||||||
|
|
||||||
Search & download a specific song, preferring lossless:
|
Search & download a specific song, preferring lossless:
|
||||||
```
|
```
|
||||||
slsk-batchdl "title=MC MENTAL @ HIS BEST,length=242" --pref-format "flac,wav"
|
sldl "title=MC MENTAL @ HIS BEST,length=242" --pref-format "flac,wav"
|
||||||
```
|
```
|
||||||
|
<details>
|
||||||
|
<summary>String details</summary>
|
||||||
|
|
||||||
|
The shorthand `sldl "Artist - Title"` is equivalent to `sldl "artist=Artist,title=Title"`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
Interactive album download:
|
Interactive album download:
|
||||||
```
|
```
|
||||||
slsk-batchdl "album=Some Album" --interactive
|
sldl "album=Some Album" --interactive
|
||||||
```
|
```
|
||||||
|
<details>
|
||||||
|
<summary>Album details</summary>
|
||||||
|
|
||||||
|
The shorthand `sldl "Artist - Album" -a` is equivalent to `sldl "artist=Artist,album=Album"`. It's often helpful to restrict to folders which have two or more tracks: `--atc 2+`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
Print all songs by an artist which are not in your library:
|
Print all songs by an artist which are not in your library:
|
||||||
```
|
```
|
||||||
slsk-batchdl "artist=MC MENTAL" --aggregate --print tracks-full --skip-existing --music-dir "path\to\music"
|
sldl "artist=MC MENTAL" --aggregate --skip-existing --music-dir "path/to/music" --print tracks-full
|
||||||
```
|
```
|
||||||
|
|
||||||
## Download Modes
|
## Download Modes
|
||||||
|
|
||||||
Depending on the provided input, the download behaviour changes:
|
Depending on the provided input, the download behaviour changes:
|
||||||
|
|
||||||
- Normal download: When the song title is set (in the CSV row, or in the string input), the program will download a single file for every entry.
|
- Normal download: The program will download a single file for every input entry.
|
||||||
- Album download: When the album name is set and the song title is NOT set, the program will search for the album and download the entire folder.
|
- Album download: The program will search for the album and download an entire folder including non-audio files. Activated when the input is a link to a spotify or bandcamp album, or when the input is a string with unset track title, or when `-a`/`--album` is enabled. Downloading multiple albums from a CSV is also supported.
|
||||||
- Aggregate download: With `--aggregate`, the program will first perform an ordinary search for the input, then attempt to group the results into distinct songs and download one of each kind. This can be used to download an artist's entire discography (or simply printing it, like in the example above), finding remixes of a song, etc. Note that it is not 100% reliable, which is why `--min-users-aggregate` is set to 2 by default, i.e. any song that is shared by only one person will be ignored. Enabling `--relax` will give even more results.
|
- Aggregate download: With `-g`/`--aggregate`, the program will first perform an ordinary search for the input, then attempt to group the results into distinct songs and download one of each kind. This can be used to download an artist's entire discography (or simply printing it, like in the example above), finding remixes of a song, etc. Note that it is not 100% reliable, which is why `--min-users-aggregate` is set to 2 by default, i.e. any song that is shared by only one person will be ignored. Enable `--relax-filtering` to make the file filtering less aggressive.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
|
Acronyms of two- and --three-word-arguments are also accepted, e.g. --twa
|
||||||
```
|
```
|
||||||
Usage: slsk-batchdl <input> [OPTIONS]
|
Usage: sldl <input> [OPTIONS]
|
||||||
|
|
||||||
<input> <input> is one of the following:
|
<input> <input> is one of the following:
|
||||||
|
|
||||||
|
@ -99,14 +113,14 @@ Options:
|
||||||
--user <username> Soulseek username
|
--user <username> Soulseek username
|
||||||
--pass <password> Soulseek password
|
--pass <password> Soulseek password
|
||||||
|
|
||||||
-p --path <path> Download folder
|
-p --path <path> Download directory
|
||||||
-f --folder <name> Subfolder name. Set to '.' to output directly to the
|
-f --folder <name> Subfolder name. Set to '.' to output directly to the
|
||||||
download folder (default: playlist/csv name)
|
download folder (default: playlist/csv name)
|
||||||
-n --number <maxtracks> Download the first n tracks of a playlist
|
-n --number <maxtracks> Download the first n tracks of a playlist
|
||||||
-o --offset <offset> Skip a specified number of tracks
|
-o --offset <offset> Skip a specified number of tracks
|
||||||
-r --reverse Download tracks in reverse order
|
-r --reverse Download tracks in reverse order
|
||||||
--nf --name-format <format> Name format for downloaded tracks, e.g "{artist} - {title}"
|
--name-format <format> Name format for downloaded tracks, e.g "{artist} - {title}"
|
||||||
--fs --fast-search Begin downloading as soon as a file satisfying the preferred
|
--fast-search Begin downloading as soon as a file satisfying the preferred
|
||||||
conditions is found. Increases chance to download bad files.
|
conditions is found. Increases chance to download bad files.
|
||||||
--m3u <option> Create an m3u8 playlist file
|
--m3u <option> Create an m3u8 playlist file
|
||||||
'none': Do not create a playlist file
|
'none': Do not create a playlist file
|
||||||
|
@ -135,8 +149,6 @@ Options:
|
||||||
--max-samplerate <rate> Maximum file sample rate
|
--max-samplerate <rate> Maximum file sample rate
|
||||||
--min-bitdepth <depth> Minimum bit depth
|
--min-bitdepth <depth> Minimum bit depth
|
||||||
--max-bitdepth <depth> Maximum bit depth
|
--max-bitdepth <depth> Maximum bit depth
|
||||||
--strict-title Only download if filename contains track title
|
|
||||||
--strict-artist Only download if filepath contains track artist
|
|
||||||
--banned-users <list> Comma-separated list of users to ignore
|
--banned-users <list> Comma-separated list of users to ignore
|
||||||
|
|
||||||
--pref-format <format> Preferred file format(s), comma-separated (default: mp3)
|
--pref-format <format> Preferred file format(s), comma-separated (default: mp3)
|
||||||
|
@ -147,36 +159,34 @@ Options:
|
||||||
--pref-max-samplerate <rate> Preferred maximum sample rate (default: 96000)
|
--pref-max-samplerate <rate> Preferred maximum sample rate (default: 96000)
|
||||||
--pref-min-bitdepth <depth> Preferred minimum bit depth
|
--pref-min-bitdepth <depth> Preferred minimum bit depth
|
||||||
--pref-max-bitdepth <depth> Preferred maximum bit depth
|
--pref-max-bitdepth <depth> Preferred maximum bit depth
|
||||||
--pref-strict-artist Prefer download if filepath contains track artist
|
|
||||||
--pref-banned-users <list> Comma-separated list of users to deprioritize
|
--pref-banned-users <list> Comma-separated list of users to deprioritize
|
||||||
|
|
||||||
--strict Skip files with missing properties instead of accepting by
|
--strict-conditions Skip files with missing properties instead of accepting by
|
||||||
default; if --min-bitrate is set, ignores any files with
|
default; if --min-bitrate is set, ignores any files with
|
||||||
unknown bitrate.
|
unknown bitrate.
|
||||||
|
|
||||||
-a --aggregate Instead of downloading a single track matching the input,
|
-a --album Album download mode
|
||||||
find and download all distinct songs associated with the
|
-t --interactive When downloading albums: Allows to select the wanted album
|
||||||
provided artist, album, or track title.
|
|
||||||
--min-users-aggregate <num> Minimum number of users sharing a track before it is
|
|
||||||
downloaded in aggregate mode. Setting it to higher values
|
|
||||||
will significantly reduce false positives, but may introduce
|
|
||||||
false negatives. Default: 2
|
|
||||||
--relax Slightly relax file filtering in aggregate mode to include
|
|
||||||
more results
|
|
||||||
|
|
||||||
--interactive When downloading albums: Allows to select the wanted album
|
|
||||||
--album-track-count <num> Specify the exact number of tracks in the album. Folders
|
--album-track-count <num> Specify the exact number of tracks in the album. Folders
|
||||||
with a different number of tracks will be ignored. Append
|
with a different number of tracks will be ignored. Append
|
||||||
a '+' or '-' after the number for the inequalities >= and <=
|
a '+' or '-' after the number for the inequalities >= and <=
|
||||||
--album-ignore-fails When downloading an album and one of the files fails, do not
|
--album-ignore-fails When downloading an album and one of the files fails, do not
|
||||||
skip to the next source and do not delete all successfully
|
skip to the next source and do not delete all successfully
|
||||||
downloaded files
|
downloaded files
|
||||||
--album-art <option> When downloading albums, optionally retrieve album images
|
--album-art <option> Retrieve additional images after downloading the album:
|
||||||
from another location:
|
|
||||||
'default': Download from the same folder as the music
|
|
||||||
'largest': Download from the folder with the largest image
|
'largest': Download from the folder with the largest image
|
||||||
'most': Download from the folder containing the most images
|
'most': Download from the folder containing the most images
|
||||||
--album-art-only Only download album art for the provided album
|
--album-art-only Only download album art for the provided album
|
||||||
|
|
||||||
|
-g --aggregate Instead of downloading a single track matching the input,
|
||||||
|
find and download all distinct songs associated with the
|
||||||
|
provided artist, album, or track title.
|
||||||
|
--min-users-aggregate <num> Minimum number of users sharing a track before it is
|
||||||
|
downloaded in aggregate mode. Setting it to higher values
|
||||||
|
will significantly reduce false positives, but also cause it
|
||||||
|
to ignore rarer songs. Default: 2
|
||||||
|
--relax-filtering Slightly relax file filtering in aggregate mode to include
|
||||||
|
more results
|
||||||
|
|
||||||
-s --skip-existing Skip if a track matching file conditions is found in the
|
-s --skip-existing Skip if a track matching file conditions is found in the
|
||||||
output folder or your music library (if provided)
|
output folder or your music library (if provided)
|
||||||
|
@ -184,8 +194,8 @@ Options:
|
||||||
'name-precise' (default): Use filenames and check conditions
|
'name-precise' (default): Use filenames and check conditions
|
||||||
'tag': Use file tags (slower)
|
'tag': Use file tags (slower)
|
||||||
'tag-precise': Use file tags and check file conditions
|
'tag-precise': Use file tags and check file conditions
|
||||||
--music-dir <path> Specify to skip downloading tracks found in a music library
|
--music-dir <path> Specify to also skip downloading tracks found in a music
|
||||||
Use with --skip-existing
|
library. Use with --skip-existing
|
||||||
--skip-not-found Skip searching for tracks that weren't found on Soulseek
|
--skip-not-found Skip searching for tracks that weren't found on Soulseek
|
||||||
during the last run. Fails are read from the m3u file.
|
during the last run. Fails are read from the m3u file.
|
||||||
|
|
||||||
|
@ -198,13 +208,18 @@ Options:
|
||||||
Useful for sources like SoundCloud where the "artist"
|
Useful for sources like SoundCloud where the "artist"
|
||||||
could just be an uploader. Note that when downloading a
|
could just be an uploader. Note that when downloading a
|
||||||
YouTube playlist via url, this option is set automatically
|
YouTube playlist via url, this option is set automatically
|
||||||
on a per track basis, so it is best kept off in that case.
|
on a per-track basis, so it is best kept off in that case.
|
||||||
-d --desperate Tries harder to find the desired track by searching for the
|
-d --desperate Tries harder to find the desired track by searching for the
|
||||||
artist/album/title only, then filtering. (slower search)
|
artist/album/title only, then filtering. (slower search)
|
||||||
--yt-dlp Use yt-dlp to download tracks that weren't found on
|
--yt-dlp Use yt-dlp to download tracks that weren't found on
|
||||||
Soulseek. yt-dlp must be available from the command line.
|
Soulseek. yt-dlp must be available from the command line.
|
||||||
|
--yt-dlp-argument <str> The command line arguments when running yt-dlp. Default:
|
||||||
|
"{id}" -f bestaudio/best -cix -o "{savepath}.%(ext)s"
|
||||||
|
Available vars are: {id}, {savedir}, {savepath} (w/o ext).
|
||||||
|
Note that with -x, yt-dlp will download webms in case
|
||||||
|
ffmpeg is unavailable.
|
||||||
|
|
||||||
--config <path> Manually specify config file location
|
-c --config <path> Set config file location
|
||||||
--search-timeout <ms> Max search time in ms (default: 5000)
|
--search-timeout <ms> Max search time in ms (default: 5000)
|
||||||
--max-stale-time <ms> Max download time without progress in ms (default: 50000)
|
--max-stale-time <ms> Max download time without progress in ms (default: 50000)
|
||||||
--concurrent-downloads <num> Max concurrent downloads (default: 2)
|
--concurrent-downloads <num> Max concurrent downloads (default: 2)
|
||||||
|
@ -212,7 +227,7 @@ Options:
|
||||||
30-minute bans. (default: 34)
|
30-minute bans. (default: 34)
|
||||||
--searches-renew-time <sec> Controls how often available searches are replenished.
|
--searches-renew-time <sec> Controls how often available searches are replenished.
|
||||||
Lower values may cause 30-minute bans. (default: 220)
|
Lower values may cause 30-minute bans. (default: 220)
|
||||||
--display <option> Changes how searches and downloads are displayed:
|
--display-mode <option> Changes how searches and downloads are displayed:
|
||||||
'single' (default): Show transfer state and percentage
|
'single' (default): Show transfer state and percentage
|
||||||
'double': Transfer state and a large progress bar
|
'double': Transfer state and a large progress bar
|
||||||
'simple': No download bars or changing percentages
|
'simple': No download bars or changing percentages
|
||||||
|
@ -225,11 +240,12 @@ Options:
|
||||||
'results-full': Print search results including full paths
|
'results-full': Print search results including full paths
|
||||||
--debug Print extra debug info
|
--debug Print extra debug info
|
||||||
```
|
```
|
||||||
|
|
||||||
### File conditions
|
### File conditions
|
||||||
Files not satisfying the conditions will not be downloaded. For example, `--length-tol` is set to 3 by default, meaning that files whose duration differs from the supplied duration by more than 3 seconds will not be downloaded (can be disabled by setting it to -1).
|
Files not satisfying the conditions will not be downloaded. For example, `--length-tol` is set to 3 by default, meaning that files whose duration differs from the supplied duration by more than 3 seconds will not be downloaded (can be disabled by setting it to -1).
|
||||||
Files satisfying `pref-` conditions will be preferred; setting `--pref-format "flac,wav"` will make it download high quality files if they exist, and only download low quality files if there's nothing else. Conditions can also be supplied as a semicolon-delimited string to `--cond` and `--pref`, e.g `--cond "br>=320;f=mp3,ogg;sr<96000"`.\
|
Files satisfying `pref-` conditions will be preferred; setting `--pref-format "flac,wav"` will make it download high quality files if they exist, and only download low quality files if there's nothing else. Conditions can also be supplied as a semicolon-delimited string to `--cond` and `--pref`, e.g `--cond "br>=320;f=mp3,ogg;sr<96000"`. See the start of `Program.cs` for the default file conditions.
|
||||||
|
|
||||||
**Important note**: Some info may be unavailable depending on the client used by the peer. For example, the default Soulseek client does not share the file bitrate. By default, if `--min-bitrate` is set, then files with unknown bitrate will still be downloaded. You can configure it to reject all files where one of the checked properties is unavailable by enabling `--strict`. (As a consequence, if `--strict` and `--min-bitrate` is set then any files shared by users with the default client will be ignored)
|
**Important note**: Some info may be unavailable depending on the client used by the peer. For example, the default Soulseek client does not share the file bitrate. By default, if `--min-bitrate` is set, then files with unknown bitrate will still be downloaded. You can configure it to reject all files where one of the checked properties is unavailable by enabling `--strict-conditions`. (As a consequence, if `--min-bitrate` is also set then any files shared by users with the default client will be ignored)
|
||||||
|
|
||||||
### Name format
|
### Name format
|
||||||
Available tags are: artist, artists, album_artist, album_artists, title, album, year, track, disc, filename, default_foldername. Name format supports subdirectories as well as conditional expressions: `{str1|str2}` – If any tags in str1 are null, choose str2. String literals enclosed in parentheses are ignored in the null check.
|
Available tags are: artist, artists, album_artist, album_artists, title, album, year, track, disc, filename, default_foldername. Name format supports subdirectories as well as conditional expressions: `{str1|str2}` – If any tags in str1 are null, choose str2. String literals enclosed in parentheses are ignored in the null check.
|
||||||
|
@ -249,11 +265,12 @@ The following options will make it go faster, but may decrease search result qua
|
||||||
- `--searches-per-time` increase at the risk of ban, see the notes section for details.
|
- `--searches-per-time` increase at the risk of ban, see the notes section for details.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
Create a file named `slsk-batchdl.conf` in the same directory as the executable and write your arguments there, e.g:
|
Create a file named `sldl.conf` in the same directory as the executable and write your arguments there, e.g:
|
||||||
```
|
```bash
|
||||||
--username "fakename"
|
username="fakename"
|
||||||
--password "fakepass"
|
password="fakepass"
|
||||||
--pref-format "flac"
|
pref-format="flac"
|
||||||
|
fast-search="true"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -10,10 +10,22 @@ public class Spotify
|
||||||
private SpotifyClient _client;
|
private SpotifyClient _client;
|
||||||
private bool loggedIn = false;
|
private bool loggedIn = false;
|
||||||
|
|
||||||
|
// default spotify credentials (base64-encoded to keep the bots away)
|
||||||
|
public const string encodedSpotifyId = "MWJmNDY5M1bLaH9WJiYjFhNGY0MWJjZWQ5YjJjMWNmZGJiZDI=";
|
||||||
|
public const string encodedSpotifySecret = "Y2JlM2QxYTE5MzJkNDQ2MmFiOGUy3shTuf4Y2JhY2M3ZDdjYWU=";
|
||||||
|
public bool UsedDefaultCredentials { get; private set; }
|
||||||
|
|
||||||
public Spotify(string clientId, string clientSecret)
|
public Spotify(string clientId, string clientSecret)
|
||||||
{
|
{
|
||||||
_clientId = clientId;
|
_clientId = clientId;
|
||||||
_clientSecret = clientSecret;
|
_clientSecret = clientSecret;
|
||||||
|
|
||||||
|
if (_clientId == "" || _clientSecret == "")
|
||||||
|
{
|
||||||
|
_clientId = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encodedSpotifyId.Replace("1bLaH9", "")));
|
||||||
|
_clientSecret = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encodedSpotifySecret.Replace("3shTuf4", "")));
|
||||||
|
UsedDefaultCredentials = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Authorize(bool login = false, bool needModify = false)
|
public async Task Authorize(bool login = false, bool needModify = false)
|
||||||
|
@ -101,7 +113,7 @@ public class 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, Artist = artist, Title = name, Length = duration / 1000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tracks.Items.Count < limit || res.Count >= max)
|
if (tracks.Items.Count < limit || res.Count >= max)
|
||||||
|
@ -140,13 +152,15 @@ public class Spotify
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string[] artists = ((IEnumerable<object>)track.Track.ReadProperty("artists")).Select(a => (string)a.ReadProperty("name")).ToArray();
|
string[] artists = ((IEnumerable<object>)track.Track.ReadProperty("artists")).Select(a => (string)a.ReadProperty("name")).ToArray();
|
||||||
string artist = artists[0];
|
var t = new Track()
|
||||||
string name = (string)track.Track.ReadProperty("name");
|
{
|
||||||
string album = (string)track.Track.ReadProperty("album").ReadProperty("name");
|
Artist = artists[0],
|
||||||
string uri = (string)track.Track.ReadProperty("uri");
|
Album = (string)track.Track.ReadProperty("album").ReadProperty("name"),
|
||||||
int duration = (int)track.Track.ReadProperty("durationMs");
|
Title = (string)track.Track.ReadProperty("name"),
|
||||||
|
Length = (int)track.Track.ReadProperty("durationMs") / 1000,
|
||||||
res.Add(new Track { Album = album, ArtistName = artist, TrackTitle = name, Length = duration / 1000, URI = uri });
|
URI = (string)track.Track.ReadProperty("uri"),
|
||||||
|
};
|
||||||
|
res.Add(t);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
@ -170,4 +184,35 @@ public class Spotify
|
||||||
var segments = uri.Segments;
|
var segments = uri.Segments;
|
||||||
return segments[segments.Length - 1].TrimEnd('/');
|
return segments[segments.Length - 1].TrimEnd('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(Track, List<Track>)> GetAlbum(string url)
|
||||||
|
{
|
||||||
|
var albumId = GetAlbumIdFromUrl(url);
|
||||||
|
var album = await _client.Albums.Get(albumId);
|
||||||
|
|
||||||
|
List<Track> tracks = new List<Track>();
|
||||||
|
|
||||||
|
foreach (var track in album.Tracks.Items)
|
||||||
|
{
|
||||||
|
var t = new Track()
|
||||||
|
{
|
||||||
|
Album = album.Name,
|
||||||
|
Artist = track.Artists.First().Name,
|
||||||
|
Title = track.Name,
|
||||||
|
Length = track.DurationMs / 1000,
|
||||||
|
URI = track.Uri,
|
||||||
|
};
|
||||||
|
tracks.Add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new Track { Album = album.Name, Artist = album.Artists.First().Name, IsAlbum = true }, tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetAlbumIdFromUrl(string url)
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
var segments = uri.Segments;
|
||||||
|
return segments[segments.Length - 1].TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,17 @@
|
||||||
using System.Text.RegularExpressions;
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
public static class Utils
|
public static class Utils
|
||||||
{
|
{
|
||||||
public static string[] musicExtensions = new string[] { ".mp3", ".wav", ".flac", ".ogg", ".aac", ".wma", ".m4a", ".alac", ".ape", ".opus" };
|
public static string[] musicExtensions = new string[] { ".mp3", ".wav", ".flac", ".ogg", ".aac", ".wma", ".m4a", ".alac", ".ape", ".opus" };
|
||||||
public static string[] imageExtensions = new string[] { ".jpg", ".jpeg", ".png" };
|
public static string[] imageExtensions = new string[] { ".jpg", ".jpeg", ".png" };
|
||||||
|
|
||||||
|
public static bool IsMusicExtension(string extension)
|
||||||
|
{
|
||||||
|
return musicExtensions.Contains(('.' + extension.TrimStart('.')).ToLower());
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsMusicFile(string fileName)
|
public static bool IsMusicFile(string fileName)
|
||||||
{
|
{
|
||||||
return musicExtensions.Contains(Path.GetExtension(fileName).ToLower());
|
return musicExtensions.Contains(Path.GetExtension(fileName).ToLower());
|
||||||
|
@ -15,6 +22,11 @@ public static class Utils
|
||||||
return imageExtensions.Contains(Path.GetExtension(fileName).ToLower());
|
return imageExtensions.Contains(Path.GetExtension(fileName).ToLower());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static decimal Normalize(this double value)
|
||||||
|
{
|
||||||
|
return ((decimal)value) / 1.000000000000000000000000000000000m;
|
||||||
|
}
|
||||||
|
|
||||||
public static int GetRecursiveFileCount(string directory)
|
public static int GetRecursiveFileCount(string directory)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(directory))
|
if (!Directory.Exists(directory))
|
||||||
|
@ -54,6 +66,17 @@ public static class Utils
|
||||||
return String.Join(newVal, temp);
|
return String.Join(newVal, temp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string UnHtmlString(this string s)
|
||||||
|
{
|
||||||
|
s = WebUtility.HtmlDecode(s);
|
||||||
|
string[] zeroWidthChars = { "\u200B", "\u200C", "\u200D", "\u00AD", "\u200E", "\u200F" };
|
||||||
|
foreach (var zwChar in zeroWidthChars)
|
||||||
|
s = s.Replace(zwChar, "");
|
||||||
|
|
||||||
|
s = s.Replace('\u00A0', ' ');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
public static string RemoveFt(this string str, bool removeParentheses = true, bool onlyIfNonempty = true)
|
public static string RemoveFt(this string str, bool removeParentheses = true, bool onlyIfNonempty = true)
|
||||||
{
|
{
|
||||||
string[] ftStrings = { "feat.", "ft." };
|
string[] ftStrings = { "feat.", "ft." };
|
||||||
|
@ -108,14 +131,35 @@ public static class Utils
|
||||||
return s.Contains(other, StringComparison.OrdinalIgnoreCase);
|
return s.Contains(other, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static char[] boundaryChars = { '-', '|', '.', '\\', '/', '_', '—', '(', ')', '[', ']', ',', '?', '!', ';',
|
||||||
|
'@', ':', '*', '=', '+', '{', '}', '|', '\'', '"', '$', '^', '&', '#', '`', '~', '%', '<', '>' };
|
||||||
|
static string boundaryPattern = "^|$|" + string.Join("|", boundaryChars.Select(c => Regex.Escape(c.ToString())));
|
||||||
|
|
||||||
public static bool ContainsWithBoundary(this string str, string value, bool ignoreCase = false)
|
public static bool ContainsWithBoundary(this string str, string value, bool ignoreCase = false)
|
||||||
{
|
{
|
||||||
string boundaryChars = @"\s|-|\.|\\|\/|^|$|_|—|\(|\)|\[|\]|,";
|
if (value == "")
|
||||||
string pattern = $"(?<={boundaryChars}){Regex.Escape(value)}(?={boundaryChars})";
|
return true;
|
||||||
|
if (str == "")
|
||||||
|
return false;
|
||||||
|
string bound = boundaryPattern + "|\\s";
|
||||||
|
string pattern = $@"({bound}){Regex.Escape(value)}({bound})";
|
||||||
RegexOptions options = ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None;
|
RegexOptions options = ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None;
|
||||||
return Regex.IsMatch(str, pattern, options);
|
return Regex.IsMatch(str, pattern, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool ContainsWithBoundaryIgnoreWs(this string str, string value, bool ignoreCase = false, bool acceptLeftDigit = false)
|
||||||
|
{
|
||||||
|
if (value == "")
|
||||||
|
return true;
|
||||||
|
if (str == "")
|
||||||
|
return false;
|
||||||
|
string patternLeft = acceptLeftDigit ? boundaryPattern + @"|\d\s+" : boundaryPattern;
|
||||||
|
string pattern = $@"({patternLeft})\s*{Regex.Escape(value)}\s*({boundaryPattern})";
|
||||||
|
RegexOptions options = ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None;
|
||||||
|
return Regex.IsMatch(str, pattern, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public static bool ContainsInBrackets(this string str, string searchTerm, bool ignoreCase = false)
|
public static bool ContainsInBrackets(this string str, string searchTerm, bool ignoreCase = false)
|
||||||
{
|
{
|
||||||
var regex = new Regex(@"\[(.*?)\]|\((.*?)\)");
|
var regex = new Regex(@"\[(.*?)\]|\((.*?)\)");
|
||||||
|
@ -158,6 +202,39 @@ public static class Utils
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/35462350/13157140
|
||||||
|
public static T ArgMax<T, K>(this IEnumerable<T> source, Func<T, K> map, IComparer<K> comparer = null)
|
||||||
|
{
|
||||||
|
if (Object.ReferenceEquals(null, source))
|
||||||
|
throw new ArgumentNullException("source");
|
||||||
|
else if (Object.ReferenceEquals(null, map))
|
||||||
|
throw new ArgumentNullException("map");
|
||||||
|
|
||||||
|
T result = default(T);
|
||||||
|
K maxKey = default(K);
|
||||||
|
Boolean first = true;
|
||||||
|
|
||||||
|
if (null == comparer)
|
||||||
|
comparer = Comparer<K>.Default;
|
||||||
|
|
||||||
|
foreach (var item in source)
|
||||||
|
{
|
||||||
|
K key = map(item);
|
||||||
|
|
||||||
|
if (first || comparer.Compare(key, maxKey) > 0)
|
||||||
|
{
|
||||||
|
first = false;
|
||||||
|
maxKey = key;
|
||||||
|
result = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!first)
|
||||||
|
return result;
|
||||||
|
else
|
||||||
|
throw new ArgumentException("Can't compute ArgMax on empty sequence.", "source");
|
||||||
|
}
|
||||||
|
|
||||||
public static int Levenshtein(string source, string target)
|
public static int Levenshtein(string source, string target)
|
||||||
{
|
{
|
||||||
if (source.Length == 0)
|
if (source.Length == 0)
|
||||||
|
|
|
@ -9,6 +9,7 @@ using HtmlAgilityPack;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System;
|
||||||
|
|
||||||
public static class YouTube
|
public static class YouTube
|
||||||
{
|
{
|
||||||
|
@ -63,7 +64,7 @@ public static class YouTube
|
||||||
length = (int)XmlConvert.ToTimeSpan(videoResponse.Items[0].ContentDetails.Duration).TotalSeconds;
|
length = (int)XmlConvert.ToTimeSpan(videoResponse.Items[0].ContentDetails.Duration).TotalSeconds;
|
||||||
desc = videoResponse.Items[0].Snippet.Description;
|
desc = videoResponse.Items[0].Snippet.Description;
|
||||||
|
|
||||||
Track track = await ParseTrackInfo(title, uploader, playlistItem.Snippet.ResourceId.VideoId, length, false, desc);
|
Track track = await ParseTrackInfo(title, uploader, playlistItem.Snippet.ResourceId.VideoId, length, desc);
|
||||||
tracks.Add(track);
|
tracks.Add(track);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -90,19 +91,20 @@ public static class YouTube
|
||||||
return (playlistName, tracks);
|
return (playlistName, tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<Track> ParseTrackInfo(string title, string uploader, string id, int length, bool requestInfoIfNeeded, string desc = "")
|
// requestInfoIfNeeded=true is way too slow
|
||||||
|
public static async Task<Track> ParseTrackInfo(string title, string uploader, string id, int length, string desc = "", bool requestInfoIfNeeded=false)
|
||||||
{
|
{
|
||||||
(string title, string uploader, int length, string desc) info = ("", "", -1, "");
|
(string title, string uploader, int length, string desc) info = ("", "", -1, "");
|
||||||
var track = new Track();
|
var track = new Track();
|
||||||
track.URI = id;
|
track.URI = id;
|
||||||
|
|
||||||
title = title.Replace("–", "-");
|
uploader = Regex.Replace(uploader.Replace("–", "-").Trim(), @"\s+", " ");
|
||||||
|
title = Regex.Replace(title.Replace("–", "-").Trim(), @"\s+", " ");
|
||||||
|
|
||||||
var trackTitle = title.Trim();
|
var artist = uploader;
|
||||||
trackTitle = Regex.Replace(trackTitle, @"\s+", " ");
|
var trackTitle = title;
|
||||||
var artist = uploader.Trim();
|
|
||||||
|
|
||||||
if (artist.EndsWith("- Topic"))
|
if (artist.EndsWith(" - Topic"))
|
||||||
{
|
{
|
||||||
artist = artist.Substring(0, artist.Length - 7).Trim();
|
artist = artist.Substring(0, artist.Length - 7).Trim();
|
||||||
trackTitle = title;
|
trackTitle = title;
|
||||||
|
@ -127,16 +129,30 @@ public static class YouTube
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
int idx = title.IndexOf('-');
|
track.ArtistMaybeWrong = !title.ContainsWithBoundary(artist, true) && !desc.ContainsWithBoundary(artist, true);
|
||||||
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() != "")
|
var split = title.Split(" - ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (split.Length == 2)
|
||||||
{
|
{
|
||||||
artist = title.Split(new[] { '-' }, 2)[0].Trim();
|
artist = split[0];
|
||||||
trackTitle = title.Split(new[] { '-' }, 2)[1].Trim();
|
trackTitle = split[1];
|
||||||
|
track.ArtistMaybeWrong = false;
|
||||||
}
|
}
|
||||||
else
|
else if (split.Length > 2)
|
||||||
{
|
{
|
||||||
track.ArtistMaybeWrong = true;
|
int index = Array.FindIndex(split, s => s.ContainsWithBoundary(artist, true));
|
||||||
|
if (index != -1 && index < split.Length - 1)
|
||||||
|
{
|
||||||
|
artist = split[index];
|
||||||
|
trackTitle = String.Join(" - ", split[(index + 1)..]);
|
||||||
|
track.ArtistMaybeWrong = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.ArtistMaybeWrong && requestInfoIfNeeded && desc == "")
|
||||||
|
{
|
||||||
|
info = await GetVideoInfo(id);
|
||||||
|
track.ArtistMaybeWrong = !info.desc.ContainsWithBoundary(artist, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,8 +168,8 @@ public static class YouTube
|
||||||
}
|
}
|
||||||
|
|
||||||
track.Length = length;
|
track.Length = length;
|
||||||
track.ArtistName = artist;
|
track.Artist = artist;
|
||||||
track.TrackTitle = trackTitle;
|
track.Title = trackTitle;
|
||||||
|
|
||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
|
@ -230,7 +246,7 @@ public static class YouTube
|
||||||
var ytId = video.Id.Value;
|
var ytId = video.Id.Value;
|
||||||
var length = (int)video.Duration.Value.TotalSeconds;
|
var length = (int)video.Duration.Value.TotalSeconds;
|
||||||
|
|
||||||
var track = await ParseTrackInfo(title, uploader, ytId, length, true);
|
var track = await ParseTrackInfo(title, uploader, ytId, length);
|
||||||
|
|
||||||
tracks[ytId] = track;
|
tracks[ytId] = track;
|
||||||
}
|
}
|
||||||
|
@ -260,7 +276,7 @@ public static class YouTube
|
||||||
var ytId = video.Id.Value;
|
var ytId = video.Id.Value;
|
||||||
var length = (int)video.Duration.Value.TotalSeconds;
|
var length = (int)video.Duration.Value.TotalSeconds;
|
||||||
|
|
||||||
var track = await ParseTrackInfo(title, uploader, ytId, length, true);
|
var track = await ParseTrackInfo(title, uploader, ytId, length);
|
||||||
|
|
||||||
tracks.Add(track);
|
tracks.Add(track);
|
||||||
}
|
}
|
||||||
|
@ -334,7 +350,7 @@ public static class YouTube
|
||||||
var x = await GetVideoDetails(waybackUrl);
|
var x = await GetVideoDetails(waybackUrl);
|
||||||
if (!string.IsNullOrEmpty(x.title))
|
if (!string.IsNullOrEmpty(x.title))
|
||||||
{
|
{
|
||||||
var track = await ParseTrackInfo(x.title, x.uploader, waybackUrl, x.duration, false);
|
var track = await ParseTrackInfo(x.title, x.uploader, waybackUrl, x.duration);
|
||||||
tracks.Add(track);
|
tracks.Add(track);
|
||||||
if (!Console.IsOutputRedirected)
|
if (!Console.IsOutputRedirected)
|
||||||
{
|
{
|
||||||
|
@ -440,7 +456,7 @@ public static class YouTube
|
||||||
ProcessStartInfo startInfo = new ProcessStartInfo();
|
ProcessStartInfo startInfo = new ProcessStartInfo();
|
||||||
|
|
||||||
startInfo.FileName = "yt-dlp";
|
startInfo.FileName = "yt-dlp";
|
||||||
string search = track.ArtistName != "" ? $"{track.ArtistName} - {track.TrackTitle}" : track.TrackTitle;
|
string search = track.Artist != "" ? $"{track.Artist} - {track.Title}" : track.Title;
|
||||||
startInfo.Arguments = $"\"ytsearch3:{search}\" --print \"%(duration>%s)s === %(id)s === %(title)s\"";
|
startInfo.Arguments = $"\"ytsearch3:{search}\" --print \"%(duration>%s)s === %(id)s === %(title)s\"";
|
||||||
|
|
||||||
startInfo.RedirectStandardOutput = true;
|
startInfo.RedirectStandardOutput = true;
|
||||||
|
@ -482,6 +498,7 @@ public static class YouTube
|
||||||
startInfo.FileName = "yt-dlp";
|
startInfo.FileName = "yt-dlp";
|
||||||
startInfo.Arguments = ytdlpArgument
|
startInfo.Arguments = ytdlpArgument
|
||||||
.Replace("{id}", id)
|
.Replace("{id}", id)
|
||||||
|
.Replace("{savepath}", savePathNoExt)
|
||||||
.Replace("{savepath-noext}", savePathNoExt)
|
.Replace("{savepath-noext}", savePathNoExt)
|
||||||
.Replace("{savedir}", Path.GetDirectoryName(savePathNoExt));
|
.Replace("{savedir}", Path.GetDirectoryName(savePathNoExt));
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>sldl</AssemblyName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
@ -26,7 +27,7 @@
|
||||||
<PackageReference Include="SpotifyAPI.Web" Version="7.0.2" />
|
<PackageReference Include="SpotifyAPI.Web" Version="7.0.2" />
|
||||||
<PackageReference Include="SpotifyAPI.Web.Auth" Version="7.0.2" />
|
<PackageReference Include="SpotifyAPI.Web.Auth" Version="7.0.2" />
|
||||||
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
||||||
<PackageReference Include="YoutubeExplode" Version="6.3.14" />
|
<PackageReference Include="YoutubeExplode" Version="6.3.16" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
Loading…
Reference in a new issue