-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement Last.fm Recommendation Import List
- Loading branch information
Meyn
committed
Feb 21, 2025
1 parent
21aacf2
commit 72add92
Showing
6 changed files
with
371 additions
and
1 deletion.
There are no files selected for viewing
26 changes: 26 additions & 0 deletions
26
Tubifarry/ImportLists/LastFMRecomendation/LastFMRecommend.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
using NLog; | ||
using NzbDrone.Common.Http; | ||
using NzbDrone.Core.Configuration; | ||
using NzbDrone.Core.ImportLists; | ||
using NzbDrone.Core.Parser; | ||
using Tubifarry.ImportLists.LastFmRecomendation; | ||
|
||
namespace Tubifarry.ImportLists.LastFmRecommend | ||
{ | ||
internal class LastFmRecommend : HttpImportListBase<LastFmRecommendSettings> | ||
{ | ||
private readonly IHttpClient _client; | ||
public override string Name => "Last.fm Recommend"; | ||
public override TimeSpan MinRefreshInterval => TimeSpan.FromDays(7); | ||
public override ImportListType ListType => ImportListType.LastFm; | ||
|
||
public override int PageSize => 100; | ||
public override TimeSpan RateLimit => TimeSpan.FromSeconds(5); | ||
|
||
public LastFmRecommend(IHttpClient httpClient, IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, Logger logger) : base(httpClient, importListStatusService, configService, parsingService, logger) => _client = httpClient; | ||
|
||
public override IImportListRequestGenerator GetRequestGenerator() => new LastFmRecomendRequestGenerator(Settings); | ||
|
||
public override IParseImportListResponse GetParser() => new LastFmRecommendParser(Settings, _client); | ||
} | ||
} |
169 changes: 169 additions & 0 deletions
169
Tubifarry/ImportLists/LastFMRecomendation/LastFMRecommendParser.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
using NLog; | ||
using NzbDrone.Common.Http; | ||
using NzbDrone.Common.Instrumentation; | ||
using NzbDrone.Common.Serializer; | ||
using NzbDrone.Core.ImportLists; | ||
using NzbDrone.Core.ImportLists.Exceptions; | ||
using NzbDrone.Core.ImportLists.LastFm; | ||
using NzbDrone.Core.Parser.Model; | ||
using System.Net; | ||
|
||
namespace Tubifarry.ImportLists.LastFmRecomendation | ||
{ | ||
internal class LastFmRecommendParser : IParseImportListResponse | ||
{ | ||
private readonly LastFmRecommendSettings _settings; | ||
private readonly IHttpClient _httpClient; | ||
private readonly Logger _logger; | ||
|
||
public LastFmRecommendParser(LastFmRecommendSettings settings, IHttpClient httpClient) | ||
{ | ||
_settings = settings; | ||
_httpClient = httpClient; | ||
_logger = NzbDroneLogger.GetLogger(this); | ||
} | ||
|
||
public IList<ImportListItemInfo> ParseResponse(ImportListResponse importListResponse) | ||
{ | ||
List<ImportListItemInfo> items = new(); | ||
|
||
if (!PreProcess(importListResponse)) | ||
return items; | ||
|
||
LastFmTopResponse jsonResponse = Json.Deserialize<LastFmTopResponse>(importListResponse.Content); | ||
if (jsonResponse == null) | ||
return items; | ||
if (jsonResponse.TopAlbums != null) | ||
{ | ||
_logger.Trace("Processing top albums response"); | ||
List<LastFmArtist> inputArtists = jsonResponse.TopAlbums.Album.Select(x => x.Artist).ToList(); | ||
foreach (LastFmArtist artist in FetchRecommendedArtists(inputArtists)) | ||
{ | ||
items.AddRange(ConvertAlbumsToImportListItems(FetchTopAlbumsForArtist(artist))); | ||
} | ||
} | ||
else if (jsonResponse.TopArtists != null) | ||
{ | ||
_logger.Trace("Processing top artists response"); | ||
items.AddRange(ConvertArtistsToImportListItems(FetchRecommendedArtists(jsonResponse.TopArtists.Artist))); | ||
} | ||
else if (jsonResponse.TopTracks != null) | ||
{ | ||
_logger.Trace("Processing top tracks response"); | ||
items.AddRange(ConvertArtistsToImportListItems(FetchRecommendedTracks(jsonResponse.TopTracks.Track))); | ||
} | ||
|
||
_logger.Debug($"Parsed {items.Count} items from Last.fm response"); | ||
return items; | ||
} | ||
|
||
private List<LastFmArtist> FetchRecommendedArtists(List<LastFmArtist> artists) | ||
{ | ||
List<LastFmArtist> recommended = new(); | ||
_logger.Trace($"Fetching similar artists for {artists.Count} input artists"); | ||
|
||
foreach (LastFmArtist artist in artists) | ||
{ | ||
HttpRequest request = BuildRequest("artist.getSimilar", new Dictionary<string, string> { { "artist", artist.Name } }); | ||
ImportListResponse response = FetchImportListResponse(request); | ||
LastFmSimilarArtistsResponse similarArtistsResponse = Json.Deserialize<LastFmSimilarArtistsResponse>(response.Content); | ||
|
||
if (similarArtistsResponse?.SimilarArtists?.Artist != null) | ||
{ | ||
recommended.AddRange(similarArtistsResponse.SimilarArtists.Artist); | ||
_logger.Trace($"Found {similarArtistsResponse.SimilarArtists.Artist.Count} similar artists for {artist.Name}"); | ||
} | ||
} | ||
return recommended; | ||
} | ||
|
||
private List<LastFmArtist> FetchRecommendedTracks(List<LastFmTrack> tracks) | ||
{ | ||
List<LastFmArtist> recommended = new(); | ||
_logger.Trace($"Processing {tracks.Count} tracks for recommendations"); | ||
|
||
foreach (LastFmTrack track in tracks) | ||
{ | ||
HttpRequest request = BuildRequest("track.getSimilar", new Dictionary<string, string> { | ||
{ "artist", track.Artist.Name }, { "track", track.Name } | ||
}); | ||
ImportListResponse response = FetchImportListResponse(request); | ||
LastFmSimilarTracksResponse similarTracksResponse = Json.Deserialize<LastFmSimilarTracksResponse>(response.Content); | ||
|
||
foreach (LastFmTrack similarTrack in similarTracksResponse?.SimilarTracks?.Track ?? new()) | ||
{ | ||
recommended.Add(similarTrack.Artist); | ||
} | ||
} | ||
return recommended; | ||
} | ||
|
||
private List<LastFmAlbum> FetchTopAlbumsForArtist(LastFmArtist artist) | ||
{ | ||
_logger.Trace($"Fetching top albums for {artist.Name}"); | ||
HttpRequest request = BuildRequest("artist.gettopalbums", new Dictionary<string, string> { { "artist", artist.Name } }); | ||
ImportListResponse response = FetchImportListResponse(request); | ||
return Json.Deserialize<LastFmTopAlbumsResponse>(response.Content)?.TopAlbums?.Album ?? new List<LastFmAlbum>(); | ||
} | ||
|
||
private HttpRequest BuildRequest(string method, Dictionary<string, string> parameters) | ||
{ | ||
HttpRequestBuilder requestBuilder = new HttpRequestBuilder(_settings.BaseUrl) | ||
.AddQueryParam("api_key", _settings.ApiKey) | ||
.AddQueryParam("method", method) | ||
.AddQueryParam("limit", _settings.ImportCount) | ||
.AddQueryParam("format", "json") | ||
.WithRateLimit(5) | ||
.Accept(HttpAccept.Json); | ||
|
||
foreach (KeyValuePair<string, string> param in parameters) | ||
requestBuilder.AddQueryParam(param.Key, param.Value); | ||
|
||
_logger.Trace($"Built request for {method} API method"); | ||
return requestBuilder.Build(); | ||
} | ||
|
||
protected virtual ImportListResponse FetchImportListResponse(HttpRequest request) | ||
{ | ||
_logger.Debug($"Fetching API response from {request.Url}"); | ||
return new ImportListResponse(new ImportListRequest(request), _httpClient.Execute(request)); | ||
} | ||
|
||
protected virtual bool PreProcess(ImportListResponse importListResponse) | ||
{ | ||
if (importListResponse.HttpResponse.StatusCode != HttpStatusCode.OK) | ||
throw new ImportListException(importListResponse, "Unexpected StatusCode [{0}]", importListResponse.HttpResponse.StatusCode); | ||
|
||
if (importListResponse.HttpResponse.Headers.ContentType?.Contains("text/json") == true && | ||
importListResponse.HttpRequest.Headers.Accept?.Contains("text/json") == false) | ||
throw new ImportListException(importListResponse, "Server returned HTML content"); | ||
return true; | ||
} | ||
|
||
private IEnumerable<ImportListItemInfo> ConvertAlbumsToImportListItems(IEnumerable<LastFmAlbum> albums) | ||
{ | ||
foreach (LastFmAlbum album in albums) | ||
{ | ||
yield return new ImportListItemInfo | ||
{ | ||
Album = album.Name, | ||
AlbumMusicBrainzId = album.Mbid, | ||
Artist = album.Artist.Name, | ||
ArtistMusicBrainzId = album.Artist.Mbid | ||
}; | ||
} | ||
} | ||
|
||
private IEnumerable<ImportListItemInfo> ConvertArtistsToImportListItems(IEnumerable<LastFmArtist> artists) | ||
{ | ||
foreach (LastFmArtist artist in artists) | ||
{ | ||
yield return new ImportListItemInfo | ||
{ | ||
Artist = artist.Name, | ||
ArtistMusicBrainzId = artist.Mbid | ||
}; | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
using NzbDrone.Core.ImportLists.LastFm; | ||
|
||
namespace Tubifarry.ImportLists.LastFmRecomendation | ||
{ | ||
public class LastFmTopResponse | ||
{ | ||
public LastFmArtistList? TopArtists { get; set; } | ||
public LastFmAlbumList? TopAlbums { get; set; } | ||
public LastFmTrackList? TopTracks { get; set; } | ||
} | ||
|
||
public class LastFmTrackList | ||
{ | ||
public List<LastFmTrack> Track { get; set; } = new(); | ||
} | ||
|
||
public class LastFmTrack | ||
{ | ||
public string Name { get; set; } = string.Empty; | ||
public int Duration { get; set; } | ||
public string Url { get; set; } = string.Empty; | ||
public LastFmArtist Artist { get; set; } = new(); | ||
} | ||
|
||
public class LastFmSimilarArtistsResponse | ||
{ | ||
public LastFmArtistList? SimilarArtists { get; set; } | ||
} | ||
|
||
public class LastFmSimilarTracksResponse | ||
{ | ||
public LastFmTrackList? SimilarTracks { get; set; } | ||
} | ||
|
||
public class LastFmTopAlbumsResponse | ||
{ | ||
public LastFmAlbumList? TopAlbums { get; set; } | ||
} | ||
} |
81 changes: 81 additions & 0 deletions
81
Tubifarry/ImportLists/LastFMRecomendation/LastFmRecommendSettings.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
using FluentValidation; | ||
using NzbDrone.Core.Annotations; | ||
using NzbDrone.Core.ImportLists; | ||
using NzbDrone.Core.ImportLists.LastFm; | ||
using NzbDrone.Core.Validation; | ||
|
||
namespace Tubifarry.ImportLists.LastFmRecomendation | ||
{ | ||
public class LastFmRecommendSettingsValidator : AbstractValidator<LastFmRecommendSettings> | ||
{ | ||
public LastFmRecommendSettingsValidator() | ||
{ | ||
// Validate that the RefreshInterval field is not empty and meets the minimum requirement | ||
RuleFor(c => c.RefreshInterval) | ||
.GreaterThanOrEqualTo(5) | ||
.WithMessage("Refresh interval must be at least 5 days."); | ||
|
||
// Validate that the UserId field is not empty | ||
RuleFor(c => c.UserId) | ||
.NotEmpty() | ||
.WithMessage("Last.fm UserID is required to generate recommendations"); | ||
|
||
// Validate that the fetch limit does not exceed 100 | ||
RuleFor(c => c.FetchCount) | ||
.LessThanOrEqualTo(100) | ||
.WithMessage("Cannot fetch more than 100 items"); | ||
|
||
// Validate that the import limit does not exceed 20 | ||
RuleFor(c => c.ImportCount) | ||
.LessThanOrEqualTo(20) | ||
.WithMessage("Maximum recommendation import limit is 20"); | ||
} | ||
} | ||
|
||
public class LastFmRecommendSettings : IImportListSettings | ||
{ | ||
private static readonly LastFmRecommendSettingsValidator Validator = new(); | ||
|
||
public LastFmRecommendSettings() | ||
{ | ||
BaseUrl = "https://ws.audioscrobbler.com/2.0/"; | ||
ApiKey = new LastFmUserSettings().ApiKey; | ||
Method = (int)LastFmRecommendMethodList.TopArtists; | ||
Period = (int)LastFmUserTimePeriod.Overall; | ||
} | ||
|
||
// Hidden API configuration | ||
public string BaseUrl { get; set; } | ||
public string ApiKey { get; set; } | ||
|
||
[FieldDefinition(0, Label = "Last.fm Username", HelpText = "Your Last.fm username to generate personalized recommendations", Placeholder = "EnterLastFMUsername")] | ||
public string UserId { get; set; } = string.Empty; | ||
|
||
[FieldDefinition(1, Label = "Refresh Interval", Type = FieldType.Textbox, HelpText = "The interval to refresh the import list. Fractional values are allowed (e.g., 1.5 for 1 day and 12 hours).", Unit = "days", Advanced = true, Placeholder = "7")] | ||
public double RefreshInterval { get; set; } = 7; | ||
|
||
[FieldDefinition(2, Label = "Recommendation Source", Type = FieldType.Select, SelectOptions = typeof(LastFmRecommendMethodList), HelpText = "Type of listening data to use for recommendations (Top Artists, Albums or Tracks)")] | ||
public int Method { get; set; } | ||
|
||
[FieldDefinition(3, Label = "Time Range", Type = FieldType.Select, SelectOptions = typeof(LastFmUserTimePeriod), HelpText = "Time period to analyze for generating recommendations (Last week/3 months/6 months/All time)")] | ||
public int Period { get; set; } | ||
|
||
[FieldDefinition(4, Label = "Fetch Limit", Type = FieldType.Number, HelpText = "Number of results to pull from the top list on Last.fm")] | ||
public int FetchCount { get; set; } = 25; | ||
|
||
[FieldDefinition(5, Label = "Import Limit", Type = FieldType.Number, HelpText = "Number of recommendations per top list result to actually import to your library")] | ||
public int ImportCount { get; set; } = 3; | ||
|
||
public NzbDroneValidationResult Validate() => new(Validator.Validate(this)); | ||
} | ||
|
||
public enum LastFmRecommendMethodList | ||
{ | ||
[FieldOption(Label = "Top Artists")] | ||
TopArtists = 0, | ||
[FieldOption(Label = "Top Albums")] | ||
TopAlbums = 1, | ||
[FieldOption(Label = "Top Tracks")] | ||
TopTracks = 2 | ||
} | ||
} |
55 changes: 55 additions & 0 deletions
55
Tubifarry/ImportLists/LastFmRecomendation/LastFmRecomendRequestGenerator.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
using NzbDrone.Common.Http; | ||
using NzbDrone.Core.ImportLists; | ||
using NzbDrone.Core.ImportLists.LastFm; | ||
|
||
namespace Tubifarry.ImportLists.LastFmRecomendation | ||
{ | ||
public class LastFmRecomendRequestGenerator : IImportListRequestGenerator | ||
{ | ||
private readonly LastFmRecommendSettings _settings; | ||
|
||
public LastFmRecomendRequestGenerator(LastFmRecommendSettings settings) => _settings = settings; | ||
|
||
|
||
public virtual ImportListPageableRequestChain GetListItems() | ||
{ | ||
ImportListPageableRequestChain pageableRequests = new(); | ||
|
||
pageableRequests.Add(GetPagedRequests()); | ||
|
||
return pageableRequests; | ||
} | ||
|
||
private IEnumerable<ImportListRequest> GetPagedRequests() | ||
{ | ||
string method = _settings.Method switch | ||
{ | ||
(int)LastFmRecommendMethodList.TopAlbums => "user.gettopalbums", | ||
(int)LastFmRecommendMethodList.TopArtists => "user.getTopArtists", | ||
_ => "user.getTopTracks" | ||
}; | ||
|
||
string period = _settings.Period switch | ||
{ | ||
(int)LastFmUserTimePeriod.LastWeek => "7day", | ||
(int)LastFmUserTimePeriod.LastMonth => "1month", | ||
(int)LastFmUserTimePeriod.LastThreeMonths => "3month", | ||
(int)LastFmUserTimePeriod.LastSixMonths => "6month", | ||
(int)LastFmUserTimePeriod.LastTwelveMonths => "12month", | ||
_ => "overall" | ||
}; | ||
|
||
HttpRequest request = new HttpRequestBuilder(_settings.BaseUrl) | ||
.AddQueryParam("api_key", _settings.ApiKey) | ||
.AddQueryParam("method", method) | ||
.AddQueryParam("user", _settings.UserId) | ||
.AddQueryParam("period", period) | ||
.AddQueryParam("limit", _settings.FetchCount) | ||
.AddQueryParam("format", "json") | ||
.Accept(HttpAccept.Json) | ||
.Build(); | ||
|
||
yield return new ImportListRequest(request); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters