Skip to content

Commit 98637cf

Browse files
author
Meyn
committed
Implement Last.fm Recommendation Import List
1 parent 21aacf2 commit 98637cf

File tree

5 files changed

+370
-0
lines changed

5 files changed

+370
-0
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using NLog;
2+
using NzbDrone.Common.Http;
3+
using NzbDrone.Core.Configuration;
4+
using NzbDrone.Core.ImportLists;
5+
using NzbDrone.Core.Parser;
6+
using Tubifarry.ImportLists.LastFmRecomendation;
7+
8+
namespace Tubifarry.ImportLists.LastFmRecommend
9+
{
10+
internal class LastFmRecommend : HttpImportListBase<LastFmRecommendSettings>
11+
{
12+
private readonly IHttpClient _client;
13+
public override string Name => "Last.fm Recommend";
14+
public override TimeSpan MinRefreshInterval => TimeSpan.FromDays(7);
15+
public override ImportListType ListType => ImportListType.LastFm;
16+
17+
public override int PageSize => 100;
18+
public override TimeSpan RateLimit => TimeSpan.FromSeconds(5);
19+
20+
public LastFmRecommend(IHttpClient httpClient, IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, Logger logger) : base(httpClient, importListStatusService, configService, parsingService, logger) => _client = httpClient;
21+
22+
public override IImportListRequestGenerator GetRequestGenerator() => new LastFmRecomendRequestGenerator(Settings);
23+
24+
public override IParseImportListResponse GetParser() => new LastFmRecommendParser(Settings, _client);
25+
}
26+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
using NLog;
2+
using NzbDrone.Common.Http;
3+
using NzbDrone.Common.Instrumentation;
4+
using NzbDrone.Common.Serializer;
5+
using NzbDrone.Core.ImportLists;
6+
using NzbDrone.Core.ImportLists.Exceptions;
7+
using NzbDrone.Core.ImportLists.LastFm;
8+
using NzbDrone.Core.Parser.Model;
9+
using System.Net;
10+
11+
namespace Tubifarry.ImportLists.LastFmRecomendation
12+
{
13+
internal class LastFmRecommendParser : IParseImportListResponse
14+
{
15+
private readonly LastFmRecommendSettings _settings;
16+
private readonly IHttpClient _httpClient;
17+
private readonly Logger _logger;
18+
19+
public LastFmRecommendParser(LastFmRecommendSettings settings, IHttpClient httpClient)
20+
{
21+
_settings = settings;
22+
_httpClient = httpClient;
23+
_logger = NzbDroneLogger.GetLogger(this);
24+
}
25+
26+
public IList<ImportListItemInfo> ParseResponse(ImportListResponse importListResponse)
27+
{
28+
List<ImportListItemInfo> items = new();
29+
30+
if (!PreProcess(importListResponse))
31+
return items;
32+
33+
LastFmTopResponse jsonResponse = Json.Deserialize<LastFmTopResponse>(importListResponse.Content);
34+
if (jsonResponse == null)
35+
return items;
36+
if (jsonResponse.TopAlbums != null)
37+
{
38+
_logger.Trace("Processing top albums response");
39+
List<LastFmArtist> inputArtists = jsonResponse.TopAlbums.Album.Select(x => x.Artist).ToList();
40+
foreach (LastFmArtist artist in FetchRecommendedArtists(inputArtists))
41+
{
42+
items.AddRange(ConvertAlbumsToImportListItems(FetchTopAlbumsForArtist(artist)));
43+
}
44+
}
45+
else if (jsonResponse.TopArtists != null)
46+
{
47+
_logger.Trace("Processing top artists response");
48+
items.AddRange(ConvertArtistsToImportListItems(FetchRecommendedArtists(jsonResponse.TopArtists.Artist)));
49+
}
50+
else if (jsonResponse.TopTracks != null)
51+
{
52+
_logger.Trace("Processing top tracks response");
53+
items.AddRange(ConvertArtistsToImportListItems(FetchRecommendedTracks(jsonResponse.TopTracks.Track)));
54+
}
55+
56+
_logger.Debug($"Parsed {items.Count} items from Last.fm response");
57+
return items;
58+
}
59+
60+
private List<LastFmArtist> FetchRecommendedArtists(List<LastFmArtist> artists)
61+
{
62+
List<LastFmArtist> recommended = new();
63+
_logger.Trace($"Fetching similar artists for {artists.Count} input artists");
64+
65+
foreach (LastFmArtist artist in artists)
66+
{
67+
HttpRequest request = BuildRequest("artist.getSimilar", new Dictionary<string, string> { { "artist", artist.Name } });
68+
ImportListResponse response = FetchImportListResponse(request);
69+
LastFmSimilarArtistsResponse similarArtistsResponse = Json.Deserialize<LastFmSimilarArtistsResponse>(response.Content);
70+
71+
if (similarArtistsResponse?.SimilarArtists?.Artist != null)
72+
{
73+
recommended.AddRange(similarArtistsResponse.SimilarArtists.Artist);
74+
_logger.Trace($"Found {similarArtistsResponse.SimilarArtists.Artist.Count} similar artists for {artist.Name}");
75+
}
76+
}
77+
return recommended;
78+
}
79+
80+
private List<LastFmArtist> FetchRecommendedTracks(List<LastFmTrack> tracks)
81+
{
82+
List<LastFmArtist> recommended = new();
83+
_logger.Trace($"Processing {tracks.Count} tracks for recommendations");
84+
85+
foreach (LastFmTrack track in tracks)
86+
{
87+
HttpRequest request = BuildRequest("track.getSimilar", new Dictionary<string, string> {
88+
{ "artist", track.Artist.Name }, { "track", track.Name }
89+
});
90+
ImportListResponse response = FetchImportListResponse(request);
91+
LastFmSimilarTracksResponse similarTracksResponse = Json.Deserialize<LastFmSimilarTracksResponse>(response.Content);
92+
93+
foreach (LastFmTrack similarTrack in similarTracksResponse?.SimilarTracks?.Track ?? new())
94+
{
95+
recommended.Add(similarTrack.Artist);
96+
}
97+
}
98+
return recommended;
99+
}
100+
101+
private List<LastFmAlbum> FetchTopAlbumsForArtist(LastFmArtist artist)
102+
{
103+
_logger.Trace($"Fetching top albums for {artist.Name}");
104+
HttpRequest request = BuildRequest("artist.gettopalbums", new Dictionary<string, string> { { "artist", artist.Name } });
105+
ImportListResponse response = FetchImportListResponse(request);
106+
return Json.Deserialize<LastFmTopAlbumsResponse>(response.Content)?.TopAlbums?.Album ?? new List<LastFmAlbum>();
107+
}
108+
109+
private HttpRequest BuildRequest(string method, Dictionary<string, string> parameters)
110+
{
111+
HttpRequestBuilder requestBuilder = new HttpRequestBuilder(_settings.BaseUrl)
112+
.AddQueryParam("api_key", _settings.ApiKey)
113+
.AddQueryParam("method", method)
114+
.AddQueryParam("limit", _settings.ImportCount)
115+
.AddQueryParam("format", "json")
116+
.WithRateLimit(5)
117+
.Accept(HttpAccept.Json);
118+
119+
foreach (KeyValuePair<string, string> param in parameters)
120+
requestBuilder.AddQueryParam(param.Key, param.Value);
121+
122+
_logger.Trace($"Built request for {method} API method");
123+
return requestBuilder.Build();
124+
}
125+
126+
protected virtual ImportListResponse FetchImportListResponse(HttpRequest request)
127+
{
128+
_logger.Debug($"Fetching API response from {request.Url}");
129+
return new ImportListResponse(new ImportListRequest(request), _httpClient.Execute(request));
130+
}
131+
132+
protected virtual bool PreProcess(ImportListResponse importListResponse)
133+
{
134+
if (importListResponse.HttpResponse.StatusCode != HttpStatusCode.OK)
135+
throw new ImportListException(importListResponse, "Unexpected StatusCode [{0}]", importListResponse.HttpResponse.StatusCode);
136+
137+
if (importListResponse.HttpResponse.Headers.ContentType?.Contains("text/json") == true &&
138+
importListResponse.HttpRequest.Headers.Accept?.Contains("text/json") == false)
139+
throw new ImportListException(importListResponse, "Server returned HTML content");
140+
return true;
141+
}
142+
143+
private IEnumerable<ImportListItemInfo> ConvertAlbumsToImportListItems(IEnumerable<LastFmAlbum> albums)
144+
{
145+
foreach (LastFmAlbum album in albums)
146+
{
147+
yield return new ImportListItemInfo
148+
{
149+
Album = album.Name,
150+
AlbumMusicBrainzId = album.Mbid,
151+
Artist = album.Artist.Name,
152+
ArtistMusicBrainzId = album.Artist.Mbid
153+
};
154+
}
155+
}
156+
157+
private IEnumerable<ImportListItemInfo> ConvertArtistsToImportListItems(IEnumerable<LastFmArtist> artists)
158+
{
159+
foreach (LastFmArtist artist in artists)
160+
{
161+
yield return new ImportListItemInfo
162+
{
163+
Artist = artist.Name,
164+
ArtistMusicBrainzId = artist.Mbid
165+
};
166+
}
167+
}
168+
}
169+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using NzbDrone.Core.ImportLists.LastFm;
2+
3+
namespace Tubifarry.ImportLists.LastFmRecomendation
4+
{
5+
public class LastFmTopResponse
6+
{
7+
public LastFmArtistList? TopArtists { get; set; }
8+
public LastFmAlbumList? TopAlbums { get; set; }
9+
public LastFmTrackList? TopTracks { get; set; }
10+
}
11+
12+
public class LastFmTrackList
13+
{
14+
public List<LastFmTrack> Track { get; set; } = new();
15+
}
16+
17+
public class LastFmTrack
18+
{
19+
public string Name { get; set; } = string.Empty;
20+
public int Duration { get; set; }
21+
public string Url { get; set; } = string.Empty;
22+
public LastFmArtist Artist { get; set; } = new();
23+
}
24+
25+
public class LastFmSimilarArtistsResponse
26+
{
27+
public LastFmArtistList? SimilarArtists { get; set; }
28+
}
29+
30+
public class LastFmSimilarTracksResponse
31+
{
32+
public LastFmTrackList? SimilarTracks { get; set; }
33+
}
34+
35+
public class LastFmTopAlbumsResponse
36+
{
37+
public LastFmAlbumList? TopAlbums { get; set; }
38+
}
39+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using FluentValidation;
2+
using NzbDrone.Core.Annotations;
3+
using NzbDrone.Core.ImportLists;
4+
using NzbDrone.Core.ImportLists.LastFm;
5+
using NzbDrone.Core.Validation;
6+
7+
namespace Tubifarry.ImportLists.LastFmRecomendation
8+
{
9+
public class LastFmRecommendSettingsValidator : AbstractValidator<LastFmRecommendSettings>
10+
{
11+
public LastFmRecommendSettingsValidator()
12+
{
13+
// Validate that the RefreshInterval field is not empty and meets the minimum requirement
14+
RuleFor(c => c.RefreshInterval)
15+
.GreaterThanOrEqualTo(5)
16+
.WithMessage("Refresh interval must be at least 5 days.");
17+
18+
// Validate that the UserId field is not empty
19+
RuleFor(c => c.UserId)
20+
.NotEmpty()
21+
.WithMessage("Last.fm UserID is required to generate recommendations");
22+
23+
// Validate that the fetch limit does not exceed 100
24+
RuleFor(c => c.FetchCount)
25+
.LessThanOrEqualTo(100)
26+
.WithMessage("Cannot fetch more than 100 items");
27+
28+
// Validate that the import limit does not exceed 20
29+
RuleFor(c => c.ImportCount)
30+
.LessThanOrEqualTo(20)
31+
.WithMessage("Maximum recommendation import limit is 20");
32+
}
33+
}
34+
35+
public class LastFmRecommendSettings : IImportListSettings
36+
{
37+
private static readonly LastFmRecommendSettingsValidator Validator = new();
38+
39+
public LastFmRecommendSettings()
40+
{
41+
BaseUrl = "https://ws.audioscrobbler.com/2.0/";
42+
ApiKey = "204c76646d6020eee36bbc51a2fcd810";
43+
Method = (int)LastFmRecommendMethodList.TopArtists;
44+
Period = (int)LastFmUserTimePeriod.Overall;
45+
}
46+
47+
// Hidden API configuration
48+
public string BaseUrl { get; set; }
49+
public string ApiKey { get; set; }
50+
51+
[FieldDefinition(0, Label = "Last.fm Username", HelpText = "Your Last.fm username to generate personalized recommendations", Placeholder = "EnterLastFMUsername")]
52+
public string UserId { get; set; } = string.Empty;
53+
54+
[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")]
55+
public double RefreshInterval { get; set; } = 7;
56+
57+
[FieldDefinition(2, Label = "Recommendation Source", Type = FieldType.Select, SelectOptions = typeof(LastFmRecommendMethodList), HelpText = "Type of listening data to use for recommendations (Top Artists/Loved Tracks/Recent Tracks)")]
58+
public int Method { get; set; }
59+
60+
[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)")]
61+
public int Period { get; set; }
62+
63+
[FieldDefinition(4, Label = "Fetch Limit", Type = FieldType.Number, HelpText = "Number of results to pull from the top list on Last.fm")]
64+
public int FetchCount { get; set; } = 25;
65+
66+
[FieldDefinition(5, Label = "Import Limit", Type = FieldType.Number, Unit = "artists", HelpText = "Number of recommendations to actually import to your library")]
67+
public int ImportCount { get; set; } = 3;
68+
69+
public NzbDroneValidationResult Validate() => new(Validator.Validate(this));
70+
}
71+
72+
public enum LastFmRecommendMethodList
73+
{
74+
[FieldOption(Label = "Top Artists")]
75+
TopArtists = 0,
76+
[FieldOption(Label = "Top Albums")]
77+
TopAlbums = 1,
78+
[FieldOption(Label = "Top Tracks")]
79+
TopTracks = 2
80+
}
81+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using NzbDrone.Common.Http;
2+
using NzbDrone.Core.ImportLists;
3+
using NzbDrone.Core.ImportLists.LastFm;
4+
5+
namespace Tubifarry.ImportLists.LastFmRecomendation
6+
{
7+
public class LastFmRecomendRequestGenerator : IImportListRequestGenerator
8+
{
9+
private readonly LastFmRecommendSettings _settings;
10+
11+
public LastFmRecomendRequestGenerator(LastFmRecommendSettings settings) => _settings = settings;
12+
13+
14+
public virtual ImportListPageableRequestChain GetListItems()
15+
{
16+
ImportListPageableRequestChain pageableRequests = new();
17+
18+
pageableRequests.Add(GetPagedRequests());
19+
20+
return pageableRequests;
21+
}
22+
23+
private IEnumerable<ImportListRequest> GetPagedRequests()
24+
{
25+
string method = _settings.Method switch
26+
{
27+
(int)LastFmRecommendMethodList.TopAlbums => "user.gettopalbums",
28+
(int)LastFmRecommendMethodList.TopArtists => "user.getTopArtists",
29+
_ => "user.getTopTracks"
30+
};
31+
32+
string period = _settings.Period switch
33+
{
34+
(int)LastFmUserTimePeriod.LastWeek => "7day",
35+
(int)LastFmUserTimePeriod.LastMonth => "1month",
36+
(int)LastFmUserTimePeriod.LastThreeMonths => "3month",
37+
(int)LastFmUserTimePeriod.LastSixMonths => "6month",
38+
(int)LastFmUserTimePeriod.LastTwelveMonths => "12month",
39+
_ => "overall"
40+
};
41+
42+
HttpRequest request = new HttpRequestBuilder(_settings.BaseUrl)
43+
.AddQueryParam("api_key", _settings.ApiKey)
44+
.AddQueryParam("method", method)
45+
.AddQueryParam("user", _settings.UserId)
46+
.AddQueryParam("period", period)
47+
.AddQueryParam("limit", _settings.FetchCount)
48+
.AddQueryParam("format", "json")
49+
.Accept(HttpAccept.Json)
50+
.Build();
51+
52+
yield return new ImportListRequest(request);
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)