Skip to content

Commit

Permalink
Implement Last.fm Recommendation Import List
Browse files Browse the repository at this point in the history
  • Loading branch information
Meyn committed Feb 21, 2025
1 parent 21aacf2 commit 72add92
Show file tree
Hide file tree
Showing 6 changed files with 371 additions and 1 deletion.
26 changes: 26 additions & 0 deletions Tubifarry/ImportLists/LastFMRecomendation/LastFMRecommend.cs
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 Tubifarry/ImportLists/LastFMRecomendation/LastFMRecommendParser.cs
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
};
}
}
}
}
39 changes: 39 additions & 0 deletions Tubifarry/ImportLists/LastFMRecomendation/LastFmModels.cs
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; }
}
}
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
}
}
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);
}
}
}
2 changes: 1 addition & 1 deletion Tubifarry/Indexers/Youtube/YoutubeIndexerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class YoutubeIndexerSettings : IIndexerSettings
[FieldDefinition(1, Label = "Cookie Path", Type = FieldType.FilePath, Hidden = HiddenType.Visible, Placeholder = "/path/to/cookies.txt", HelpText = "Specify the path to the Spotify cookies file. This is optional but required for accessing restricted content.", Advanced = true)]
public string CookiePath { get; set; } = string.Empty;

[FieldDefinition(2, Label = "PoToken)", Type = FieldType.Textbox, HelpText = "A unique token to verify the origin of the request.", Advanced = true)]
[FieldDefinition(2, Label = "PoToken", Type = FieldType.Textbox, HelpText = "A unique token to verify the origin of the request.", Advanced = true)]
public string PoToken { get; set; } = string.Empty;

public string BaseUrl { get; set; } = string.Empty;
Expand Down

0 comments on commit 72add92

Please sign in to comment.