Skip to content

Commit c79201f

Browse files
author
Meyn
committed
Add support for downloading lyrics in Slskd downloads and fallback search requests
1 parent 7d069fb commit c79201f

File tree

9 files changed

+256
-55
lines changed

9 files changed

+256
-55
lines changed

Tubifarry/Core/FileInfoParser.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System.Text.RegularExpressions;
2+
3+
namespace Tubifarry.Core
4+
{
5+
public class FileInfoParser
6+
{
7+
8+
public string? Artist { get; private set; }
9+
public string? Title { get; private set; }
10+
public int TrackNumber { get; private set; }
11+
public int DiscNumber { get; private set; }
12+
public string? Tag { get; private set; }
13+
14+
private static readonly List<Tuple<string, string>> CharsAndSeps = new()
15+
{
16+
Tuple.Create(@"a-z0-9,\(\)\.&'’\s", @"\s_-"),
17+
Tuple.Create(@"a-z0-9,\(\)\.\&'’_", @"\s-")
18+
};
19+
20+
21+
public FileInfoParser(string filePath)
22+
{
23+
if (string.IsNullOrWhiteSpace(filePath))
24+
throw new ArgumentException("File path cannot be null or empty.", nameof(filePath));
25+
string filename = Path.GetFileNameWithoutExtension(filePath);
26+
ParseFilename(filename);
27+
}
28+
29+
private void ParseFilename(string filename)
30+
{
31+
foreach (Tuple<string, string> charSep in CharsAndSeps)
32+
{
33+
Regex[] patterns = GeneratePatterns(charSep.Item1, charSep.Item2);
34+
foreach (Regex pattern in patterns)
35+
{
36+
Match match = pattern.Match(filename);
37+
if (match.Success)
38+
{
39+
Artist = match.Groups["artist"].Success ? match.Groups["artist"].Value.Trim() : string.Empty;
40+
Title = match.Groups["title"].Success ? match.Groups["title"].Value.Trim() : string.Empty;
41+
TrackNumber = match.Groups["track"].Success ? int.Parse(match.Groups["track"].Value) : 0;
42+
Tag = match.Groups["tag"].Success ? match.Groups["tag"].Value.Trim() : string.Empty;
43+
if (TrackNumber > 100)
44+
{
45+
DiscNumber = TrackNumber / 100;
46+
TrackNumber = TrackNumber % 100;
47+
}
48+
return;
49+
}
50+
}
51+
}
52+
}
53+
54+
private static Regex[] GeneratePatterns(string chars, string sep)
55+
{
56+
string sep1 = $@"(?<sep>[{sep}]+)";
57+
string sepn = @"\k<sep>";
58+
string artist = $@"(?<artist>[{chars}]+)";
59+
string track = $@"(?<track>\d+)";
60+
string title = $@"(?<title>[{chars}]+)";
61+
string tag = $@"(?<tag>[{chars}]+)";
62+
63+
return new[]
64+
{
65+
new Regex($@"^{track}{sep1}{artist}{sepn}{title}{sepn}{tag}$", RegexOptions.IgnoreCase),
66+
new Regex($@"^{track}{sep1}{artist}{sepn}{tag}{sepn}{title}$", RegexOptions.IgnoreCase),
67+
new Regex($@"^{track}{sep1}{artist}{sepn}{title}$", RegexOptions.IgnoreCase),
68+
69+
new Regex($@"^{artist}{sep1}{tag}{sepn}{track}{sepn}{title}$", RegexOptions.IgnoreCase),
70+
new Regex($@"^{artist}{sep1}{track}{sepn}{title}{sepn}{tag}$", RegexOptions.IgnoreCase),
71+
new Regex($@"^{artist}{sep1}{track}{sepn}{title}$", RegexOptions.IgnoreCase),
72+
73+
new Regex($@"^{artist}{sep1}{title}{sepn}{tag}$", RegexOptions.IgnoreCase),
74+
new Regex($@"^{artist}{sep1}{tag}{sepn}{title}$", RegexOptions.IgnoreCase),
75+
new Regex($@"^{artist}{sep1}{title}$", RegexOptions.IgnoreCase),
76+
77+
new Regex($@"^{track}{sep1}{title}$", RegexOptions.IgnoreCase),
78+
new Regex($@"^{track}{sep1}{tag}{sepn}{title}$", RegexOptions.IgnoreCase),
79+
new Regex($@"^{track}{sep1}{title}{sepn}{tag}$", RegexOptions.IgnoreCase),
80+
81+
new Regex($@"^{title}$", RegexOptions.IgnoreCase),
82+
};
83+
}
84+
}
85+
}
86+

Tubifarry/Core/Lyric.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,15 @@
33
using Newtonsoft.Json.Linq;
44
using NzbDrone.Core.Parser.Model;
55
using System.Text.RegularExpressions;
6-
using YouTubeMusicAPI.Models.Info;
76

87
namespace Tubifarry.Core
98
{
109

1110
public record Lyric(string? PlainLyrics, SyncLyric? SyncedLyrics)
1211
{
13-
public static async Task<Lyric?> FetchLyricsFromLRCLIBAsync(string instance, ReleaseInfo releaseInfo, AlbumSongInfo trackInfo, CancellationToken token = default)
12+
public static async Task<Lyric?> FetchLyricsFromLRCLIBAsync(string instance, ReleaseInfo releaseInfo, string trackName, int duration = 0, CancellationToken token = default)
1413
{
15-
string requestUri = $"{instance}/api/get?artist_name={Uri.EscapeDataString(releaseInfo.Artist)}&track_name={Uri.EscapeDataString(trackInfo.Name)}&album_name={Uri.EscapeDataString(releaseInfo.Album)}&duration={trackInfo.Duration.TotalSeconds}";
14+
string requestUri = $"{instance}/api/get?artist_name={Uri.EscapeDataString(releaseInfo.Artist)}&track_name={Uri.EscapeDataString(trackName)}&album_name={Uri.EscapeDataString(releaseInfo.Album)}{(duration != 0 ? $"&duration={duration}" : "")}";
1615
HttpResponseMessage response = await HttpGet.HttpClient.GetAsync(requestUri, token);
1716
if (!response.IsSuccessStatusCode) return null;
1817
JObject json = JObject.Parse(await response.Content.ReadAsStringAsync(token));

Tubifarry/Download/Clients/Soulseek/SlskdClient.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using NzbDrone.Core.RemotePathMappings;
1111
using System.Net;
1212
using System.Text.Json;
13+
using Tubifarry.Core;
1314

1415
namespace NzbDrone.Core.Download.Clients.Soulseek
1516
{
@@ -34,10 +35,40 @@ public override async Task<string> Download(RemoteAlbum remoteAlbum, IIndexer in
3435

3536
if (response.StatusCode != HttpStatusCode.Created)
3637
throw new DownloadClientException("Failed to create download.");
38+
if (Settings.UseLRCLIB)
39+
item.FileStateChanged += FileStateChanged;
3740
AddDownloadItem(item);
3841
return item.ID.ToString();
3942
}
4043

44+
private void FileStateChanged(object? sender, SlskdDownloadFile file)
45+
{
46+
string filename = file.Filename;
47+
string extension = Path.GetExtension(filename);
48+
AudioFormat format = AudioFormatHelper.GetAudioCodecFromExtension(extension.TrimStart('.'));
49+
50+
if (file.GetStatus() != DownloadItemStatus.Completed || format == AudioFormat.Unknown)
51+
return;
52+
PostProcess((SlskdDownloadItem)sender!, file);
53+
}
54+
55+
private void PostProcess(SlskdDownloadItem item, SlskdDownloadFile file) => item.PostProcessTasks.Add(Task.Run(async () =>
56+
{
57+
string filename = file.Filename;
58+
string filePath = Path.Combine(Settings.DownloadPath, filename);
59+
_logger.Info("PostProcess");
60+
if (!File.Exists(filePath))
61+
return;
62+
_logger.Info("Parser Process");
63+
FileInfoParser parser = new(file.Filename);
64+
if (parser.Title == null)
65+
return;
66+
_logger.Info("Title: " + parser.Title);
67+
Lyric? lyric = await Lyric.FetchLyricsFromLRCLIBAsync(Settings.LRCLIBInstance, item.RemoteAlbum.Release, parser.Title);
68+
AudioMetadataHandler metadataHandler = new(filePath) { Lyric = lyric };
69+
bool s = await metadataHandler.TryCreateLrcFileAsync(default);
70+
_logger.Info("lyrics: " + s);
71+
}));
4172

4273
public override IEnumerable<DownloadClientItem> GetItems()
4374
{

Tubifarry/Download/Clients/Soulseek/SlskdModels.cs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,25 @@ public class SlskdDownloadItem
1515
public List<SlskdFileData> FileData { get; set; } = new();
1616
public string? Username { get; set; }
1717
public RemoteAlbum RemoteAlbum { get; set; }
18-
public SlskdDownloadDirectory? SlskdDownloadDirectory { get; set; }
18+
19+
public event EventHandler<SlskdDownloadFile>? FileStateChanged;
20+
21+
private SlskdDownloadDirectory? _slskdDownloadDirectory;
22+
private Dictionary<string, string> _previousFileStates = new();
23+
24+
public List<Task> PostProcessTasks { get; } = new();
25+
26+
public SlskdDownloadDirectory? SlskdDownloadDirectory
27+
{
28+
get => _slskdDownloadDirectory;
29+
set
30+
{
31+
if (_slskdDownloadDirectory == value)
32+
return;
33+
CompareFileStates(_slskdDownloadDirectory, value);
34+
_slskdDownloadDirectory = value;
35+
}
36+
}
1937

2038
public SlskdDownloadItem(RemoteAlbum remoteAlbum)
2139
{
@@ -30,6 +48,17 @@ public SlskdDownloadItem(RemoteAlbum remoteAlbum)
3048
_downloadClientItem = new() { DownloadId = ID.ToString(), CanBeRemoved = true, CanMoveFiles = true };
3149
}
3250

51+
private void CompareFileStates(SlskdDownloadDirectory? previousDirectory, SlskdDownloadDirectory? newDirectory)
52+
{
53+
if (newDirectory?.Files == null)
54+
return;
55+
56+
foreach (SlskdDownloadFile file in newDirectory.Files)
57+
if (_previousFileStates.TryGetValue(file.Id, out string? previousState) && previousState != file.State)
58+
FileStateChanged?.Invoke(this, file);
59+
_previousFileStates = newDirectory.Files.ToDictionary(file => file.Id, file => file.State);
60+
}
61+
3362
public DownloadClientItem GetDownloadClientItem(string downloadPath)
3463
{
3564
_downloadClientItem.OutputPath = new OsPath(Path.Combine(downloadPath, SlskdDownloadDirectory?.Directory
@@ -73,7 +102,12 @@ public DownloadClientItem GetDownloadClientItem(string downloadPath)
73102
_downloadClientItem.Message = $"Downloading {failedFiles.Count} files failed: {string.Join(", ", failedFiles)}";
74103
}
75104
else if (fileStatuses.All(status => status == DownloadItemStatus.Completed))
76-
status = DownloadItemStatus.Completed;
105+
{
106+
if (PostProcessTasks.Any(task => !task.IsCompleted))
107+
status = DownloadItemStatus.Downloading;
108+
else
109+
status = DownloadItemStatus.Completed;
110+
}
77111
else if (fileStatuses.Any(status => status == DownloadItemStatus.Paused))
78112
status = DownloadItemStatus.Paused;
79113
else if (fileStatuses.Any(status => status == DownloadItemStatus.Downloading))

Tubifarry/Download/Clients/Soulseek/SlskdProviderSettings.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,16 @@ public class SlskdProviderSettings : IProviderConfig
4141
[FieldDefinition(3, Label = "Download Path", Type = FieldType.Path, HelpText = "Specify the directory where downloaded files will be saved. If not specified, Slskd's default download path is used.")]
4242
public string DownloadPath { get; set; } = string.Empty;
4343

44-
[FieldDefinition(4, Label = "Is Fetched remote", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)]
44+
[FieldDefinition(5, Label = "Use LRCLIB for Lyrics", HelpText = "Enable this option to fetch lyrics from LRCLIB after the download is complete.", Type = FieldType.Checkbox)]
45+
public bool UseLRCLIB { get; set; } = false;
46+
47+
[FieldDefinition(6, Label = "LRC Lib Instance", Type = FieldType.Url, HelpText = "The URL of a LRC Lib instance to connect to. Default is 'https://lrclib.net'.", Advanced = true)]
48+
public string LRCLIBInstance { get; set; } = "https://lrclib.net";
49+
50+
[FieldDefinition(98, Label = "Is Fetched remote", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)]
4551
public bool IsRemotePath { get; set; }
4652

47-
[FieldDefinition(4, Label = "Is Localhost", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)]
53+
[FieldDefinition(99, Label = "Is Localhost", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)]
4854
public bool IsLocalhost { get; set; }
4955

5056
public NzbDroneValidationResult Validate() => new(Validator.Validate(this));

Tubifarry/Download/Clients/YouTubeAlbumRequest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ private async Task<bool> SongDownloadCompletedAsync(AlbumInfo albumInfo, AlbumSo
204204
AudioMetadataHandler audioData = new(trackPath) { AlbumCover = _albumCover, UseID3v2_3 = Options.UseID3v2_3 };
205205

206206
if (Options.TryIncludeLrc)
207-
audioData.Lyric = await Lyric.FetchLyricsFromLRCLIBAsync(Options.LRCLIBInstance, ReleaseInfo, trackInfo, token);
207+
audioData.Lyric = await Lyric.FetchLyricsFromLRCLIBAsync(Options.LRCLIBInstance, ReleaseInfo, trackInfo.Name, (int)trackInfo.Duration.TotalSeconds, token);
208208

209209
AudioFormat format = AudioFormatHelper.ConvertOptionToAudioFormat(Options.ReEncodeOptions);
210210

Tubifarry/Indexers/Soulseek/SlskdRequestGenerator.cs

Lines changed: 80 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -30,53 +30,97 @@ public IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchC
3030
{
3131
_logger.Trace($"Generating search requests for album: {searchCriteria.AlbumQuery} by artist: {searchCriteria.ArtistQuery}");
3232
IndexerPageableRequestChain chain = new();
33-
chain.AddTier(GetRequests(searchCriteria.ArtistQuery, searchCriteria.AlbumQuery, searchCriteria.InteractiveSearch));
33+
34+
chain.AddTier(DeferredGetRequests(searchCriteria.ArtistQuery, searchCriteria.AlbumQuery, searchCriteria.InteractiveSearch));
35+
36+
if (!Settings.UseFallbackSearch)
37+
return chain;
38+
39+
List<string> aliases = searchCriteria.Artist.Metadata.Value.Aliases;
40+
for (int i = 0; i < 2 && i < aliases.Count; i++)
41+
if (aliases[i].Length > 3)
42+
chain.AddTier(DeferredGetRequests(aliases[i], searchCriteria.AlbumQuery, searchCriteria.InteractiveSearch));
43+
if (searchCriteria.AlbumQuery.Length > 20)
44+
{
45+
string[] albumWords = searchCriteria.AlbumQuery.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
46+
int halfLength = (int)Math.Ceiling(albumWords.Length / 2.0);
47+
string halfAlbumTitle = string.Join(" ", albumWords.Take(halfLength));
48+
chain.AddTier(DeferredGetRequests(searchCriteria.ArtistQuery, halfAlbumTitle, searchCriteria.InteractiveSearch, searchCriteria.AlbumQuery));
49+
}
50+
chain.AddTier(DeferredGetRequests(searchCriteria.ArtistQuery, null, searchCriteria.InteractiveSearch, searchCriteria.AlbumQuery));
3451
return chain;
3552
}
3653

54+
3755
public IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria)
3856
{
3957
_logger.Trace($"Generating search requests for artist: {searchCriteria.ArtistQuery}");
4058
IndexerPageableRequestChain chain = new();
41-
chain.AddTier(GetRequests(searchCriteria.ArtistQuery, null, searchCriteria.InteractiveSearch));
59+
List<string> aliases = searchCriteria.Artist.Metadata.Value.Aliases;
60+
for (int i = 0; i < 3 && i < aliases.Count && Settings.UseFallbackSearch; i++)
61+
if (aliases[i].Length > 3)
62+
chain.AddTier(DeferredGetRequests(aliases[i], null, searchCriteria.InteractiveSearch));
4263
return chain;
4364
}
4465

45-
private IEnumerable<IndexerRequest> GetRequests(string artist, string? album = null, bool interactive = false)
66+
67+
private IEnumerable<IndexerRequest> DeferredGetRequests(string artist, string? album, bool interactive, string? fullAlbum = null)
68+
{
69+
_searchResultsRequest = null;
70+
IndexerRequest? request = GetRequestsAsync(artist, album, interactive, fullAlbum).Result;
71+
if (request != null)
72+
yield return request;
73+
}
74+
75+
private async Task<IndexerRequest?> GetRequestsAsync(string artist, string? album, bool interactive, string? fullAlbum = null)
4676
{
47-
var searchData = new
77+
try
4878
{
49-
Id = Guid.NewGuid().ToString(),
50-
Settings.FileLimit,
51-
FilterResponses = true,
52-
Settings.MaximumPeerQueueLength,
53-
Settings.MinimumPeerUploadSpeed,
54-
Settings.MinimumResponseFileCount,
55-
Settings.ResponseLimit,
56-
SearchText = $"{album} {artist}",
57-
SearchTimeout = (int)(Settings.TimeoutInSeconds * 1000),
58-
};
59-
60-
HttpRequest searchRequest = new HttpRequestBuilder($"{Settings.BaseUrl}/api/v0/searches")
61-
.SetHeader("X-API-KEY", Settings.ApiKey)
62-
.SetHeader("Content-Type", "application/json")
63-
.Post()
64-
.Build();
65-
66-
searchRequest.SetContent(JsonConvert.SerializeObject(searchData));
67-
_client.Execute(searchRequest);
68-
WaitOnSearchCompletionAsync(searchData.Id, TimeSpan.FromSeconds(Settings.TimeoutInSeconds)).Wait();
69-
70-
_logger.Trace($"Generated search initiation request: {searchRequest.Url}");
71-
72-
HttpRequest request = new HttpRequestBuilder($"{Settings.BaseUrl}/api/v0/searches/{searchData.Id}")
73-
.AddQueryParam("includeResponses", true)
74-
.SetHeader("X-API-KEY", Settings.ApiKey)
75-
.SetHeader("X-ALBUM", Convert.ToBase64String(Encoding.UTF8.GetBytes(album ?? "")))
76-
.SetHeader("X-ARTIST", Convert.ToBase64String(Encoding.UTF8.GetBytes(artist)))
77-
.SetHeader("X-INTERACTIVE", interactive.ToString())
78-
.Build();
79-
yield return new IndexerRequest(request);
79+
var searchData = new
80+
{
81+
Id = Guid.NewGuid().ToString(),
82+
Settings.FileLimit,
83+
FilterResponses = true,
84+
Settings.MaximumPeerQueueLength,
85+
Settings.MinimumPeerUploadSpeed,
86+
Settings.MinimumResponseFileCount,
87+
Settings.ResponseLimit,
88+
SearchText = $"{album} {artist}",
89+
SearchTimeout = (int)(Settings.TimeoutInSeconds * 1000),
90+
};
91+
92+
HttpRequest searchRequest = new HttpRequestBuilder($"{Settings.BaseUrl}/api/v0/searches")
93+
.SetHeader("X-API-KEY", Settings.ApiKey)
94+
.SetHeader("Content-Type", "application/json")
95+
.Post()
96+
.Build();
97+
98+
searchRequest.SetContent(JsonConvert.SerializeObject(searchData));
99+
await _client.ExecuteAsync(searchRequest);
100+
await WaitOnSearchCompletionAsync(searchData.Id, TimeSpan.FromSeconds(Settings.TimeoutInSeconds));
101+
102+
_logger.Trace($"Generated search initiation request: {searchRequest.Url}");
103+
104+
HttpRequest request = new HttpRequestBuilder($"{Settings.BaseUrl}/api/v0/searches/{searchData.Id}")
105+
.AddQueryParam("includeResponses", true)
106+
.SetHeader("X-API-KEY", Settings.ApiKey)
107+
.SetHeader("X-ALBUM", Convert.ToBase64String(Encoding.UTF8.GetBytes(fullAlbum ?? album ?? "")))
108+
.SetHeader("X-ARTIST", Convert.ToBase64String(Encoding.UTF8.GetBytes(artist)))
109+
.SetHeader("X-INTERACTIVE", interactive.ToString())
110+
.Build();
111+
112+
return new IndexerRequest(request);
113+
}
114+
catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound)
115+
{
116+
_logger.Warn($"Search request failed for artist: {artist}, album: {album}. Error: {ex.Message}");
117+
return null;
118+
}
119+
catch (Exception ex)
120+
{
121+
_logger.Error(ex, $"An error occurred while generating search request for artist: {artist}, album: {album}");
122+
return null;
123+
}
80124
}
81125

82126
private async Task WaitOnSearchCompletionAsync(string searchId, TimeSpan timeout)
@@ -137,9 +181,7 @@ private static double CalculateQuadraticDelay(double progress)
137181
private async Task<dynamic?> GetSearchResultsAsync(string searchId)
138182
{
139183
_searchResultsRequest ??= new HttpRequestBuilder($"{Settings.BaseUrl}/api/v0/searches/{searchId}")
140-
.SetHeader("X-API-KEY", Settings.ApiKey)
141-
.Build();
142-
184+
.SetHeader("X-API-KEY", Settings.ApiKey).Build();
143185
HttpResponse response = await _client.ExecuteAsync(_searchResultsRequest);
144186

145187
if (response.StatusCode != HttpStatusCode.OK)

0 commit comments

Comments
 (0)