Skip to content

Commit cbb1433

Browse files
author
Meyn
committed
Implement LastFM similar artists search
1 parent 07ec90d commit cbb1433

5 files changed

Lines changed: 467 additions & 2 deletions

File tree

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ For further customization, Codec Tinker lets you convert audio files between for
2121
7. [Lyrics Fetcher 📜](#lyrics-fetcher-)
2222
8. [Search Sniper 🏹](#search-sniper-)
2323
9. [Custom Metadata Sources 🧩](#custom-metadata-sources-)
24-
10. [Troubleshooting 🛠️](#troubleshooting-%EF%B8%8F)
24+
10. [Similar Artists 🧷](#similar-artists-)
25+
11. [Troubleshooting 🛠️](#troubleshooting-%EF%B8%8F)
2526

2627
----
2728

@@ -229,6 +230,28 @@ The feature currently works best with artists that are properly linked across di
229230

230231
---
231232

233+
### Similar Artists 🧷
234+
235+
**Similar Artists** lets you discover related artists using Last.fm's
236+
recommendation data directly in Lidarr's search. Search for an artist
237+
with the `~` prefix and get back a list of similar musicians ready to
238+
be added to your library.
239+
240+
#### How to Enable Similar Artists
241+
242+
1. Go to `Settings > Metadata` in Lidarr.
243+
2. Enable these three metadata sources:
244+
- **Similar Artists** - Enter your Last.fm API key
245+
- **Lidarr Default** - Required to handle normal searches
246+
- **MetaMix** - Required to coordinate the search
247+
3. Optional: Adjust result limit, enable image fetching, and configure caching.
248+
249+
**Examples:**
250+
- `similar:Pink Floyd`
251+
- `~20244d07-534f-4eff-b4d4-930878889970`
252+
253+
---
254+
232255
## Troubleshooting 🛠️
233256

234257
- **Slskd Download Path Permissions**:

Tubifarry/Metadata/Proxy/MetadataProvider/Lastfm/LastfmApiService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,7 @@ private async Task<JsonElement> ExecuteRequestWithRetryAsync(HttpRequestBuilder
431431
_circuitBreaker.RecordFailure();
432432
return default;
433433
}
434-
_logger.Debug(response.Content);
434+
_logger.Trace(response.Content);
435435
using JsonDocument jsonDoc = JsonDocument.Parse(response.Content);
436436
_circuitBreaker.RecordSuccess();
437437
return jsonDoc.RootElement.Clone();
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
using NLog;
2+
using NzbDrone.Common.Http;
3+
using NzbDrone.Core.Datastore;
4+
using NzbDrone.Core.ImportLists.LastFm;
5+
using NzbDrone.Core.MediaCover;
6+
using NzbDrone.Core.Music;
7+
using NzbDrone.Core.Parser;
8+
using System.Text.Json;
9+
using Tubifarry.Core.Utilities;
10+
using Tubifarry.ImportLists.LastFmRecommendation;
11+
using Tubifarry.Metadata.Proxy.MetadataProvider.Lastfm;
12+
13+
namespace Tubifarry.Metadata.Proxy.RecommendArtists
14+
{
15+
public interface ILastFmSimilarArtistsService
16+
{
17+
public List<Artist> GetSimilarArtistsWithMetadata(string artistIdentifier, SimilarArtistsProxySettings settings);
18+
}
19+
20+
/// <summary>
21+
/// Service for fetching similar artists from Last.fm API with full metadata
22+
/// </summary>
23+
public class LastFmSimilarArtistsService : ILastFmSimilarArtistsService
24+
{
25+
private readonly IHttpClient _httpClient;
26+
private readonly Logger _logger;
27+
private CacheService _cache = null!;
28+
private LastfmImageScraper _imageScraper = null!;
29+
private LastfmApiService _apiService = null!;
30+
31+
private const string LASTFM_API_BASE = "https://ws.audioscrobbler.com/2.0/";
32+
33+
public LastFmSimilarArtistsService(IHttpClient httpClient, Logger logger)
34+
{
35+
_httpClient = httpClient;
36+
_logger = logger;
37+
}
38+
39+
public List<Artist> GetSimilarArtistsWithMetadata(string artistIdentifier, SimilarArtistsProxySettings settings)
40+
{
41+
if (string.IsNullOrWhiteSpace(artistIdentifier))
42+
{
43+
_logger.Warn("Artist identifier is empty, cannot fetch similar artists");
44+
return [];
45+
}
46+
47+
if (string.IsNullOrWhiteSpace(settings?.ApiKey))
48+
{
49+
_logger.Warn("Last.fm API key not configured");
50+
return [];
51+
}
52+
53+
InitServices(settings);
54+
55+
List<LastFmArtist> similarArtists = FetchSimilarArtistsFromApi(artistIdentifier, settings, settings.ResultLimit);
56+
57+
if (similarArtists.Count == 0)
58+
{
59+
_logger.Debug($"No similar artists found for: {artistIdentifier}");
60+
return [];
61+
}
62+
63+
_logger.Trace($"Found {similarArtists.Count} similar artists for '{artistIdentifier}', fetching detailed info...");
64+
65+
List<Artist> results = [];
66+
67+
foreach (LastFmArtist similarArtist in similarArtists)
68+
{
69+
try
70+
{
71+
// Get detailed artist info from Last.fm API with caching
72+
LastfmArtist? detailedArtist = _cache.FetchAndCacheAsync(
73+
$"artist:{similarArtist.Name}",
74+
() => _apiService.GetArtistInfoAsync(similarArtist.Name)
75+
).GetAwaiter().GetResult();
76+
77+
if (detailedArtist == null)
78+
continue;
79+
if (string.IsNullOrWhiteSpace(detailedArtist.MBID))
80+
continue;
81+
82+
Artist artist = MapArtistFromLastfmArtist(detailedArtist, artistIdentifier, settings, _imageScraper);
83+
results.Add(artist);
84+
}
85+
catch (Exception ex)
86+
{
87+
_logger.Warn(ex, $"Failed to process artist: {similarArtist.Name}");
88+
}
89+
}
90+
91+
_logger.Trace($"Returning {results.Count} similar artists with valid MusicBrainz IDs for '{artistIdentifier}'");
92+
return results;
93+
}
94+
95+
private void InitServices(SimilarArtistsProxySettings settings)
96+
{
97+
_cache ??= new CacheService
98+
{
99+
CacheDuration = TimeSpan.FromDays(21),
100+
CacheDirectory = settings.CacheDirectory,
101+
CacheType = (CacheType)settings.RequestCacheType,
102+
};
103+
104+
_imageScraper ??= new LastfmImageScraper(_httpClient, Tubifarry.UserAgent, _cache);
105+
106+
_apiService ??= new LastfmApiService(_httpClient, Tubifarry.UserAgent)
107+
{
108+
ApiKey = settings.ApiKey,
109+
PageSize = 50,
110+
MaxPageLimit = 1
111+
};
112+
}
113+
114+
private List<LastFmArtist> FetchSimilarArtistsFromApi(string artistIdentifier, SimilarArtistsProxySettings settings, int limit)
115+
{
116+
try
117+
{
118+
bool isMbid = Guid.TryParse(artistIdentifier, out _);
119+
_logger.Trace($"Fetching similar artists for: {artistIdentifier} (using {(isMbid ? "MBID" : "artist name")})");
120+
121+
HttpRequestBuilder requestBuilder = new HttpRequestBuilder(LASTFM_API_BASE)
122+
.AddQueryParam("method", "artist.getSimilar")
123+
.AddQueryParam("api_key", settings.ApiKey)
124+
.AddQueryParam("format", "json")
125+
.AddQueryParam("limit", limit)
126+
.SetHeader("User-Agent", Tubifarry.UserAgent)
127+
.Accept(HttpAccept.Json);
128+
129+
if (isMbid)
130+
requestBuilder.AddQueryParam("mbid", artistIdentifier);
131+
else
132+
requestBuilder.AddQueryParam("artist", artistIdentifier);
133+
134+
HttpRequest request = requestBuilder.Build();
135+
HttpResponse response = _httpClient.Get(request);
136+
137+
if (response.HasHttpError)
138+
{
139+
_logger.Warn($"HTTP error fetching similar artists for: {artistIdentifier}");
140+
return [];
141+
}
142+
143+
LastFmSimilarArtistsResponse? apiResponse = JsonSerializer.Deserialize<LastFmSimilarArtistsResponse>(response.Content);
144+
145+
if (apiResponse?.SimilarArtists?.Artist?.Count > 0)
146+
{
147+
_logger.Debug($"Found {apiResponse.SimilarArtists.Artist.Count} similar artists for '{artistIdentifier}'");
148+
return apiResponse.SimilarArtists.Artist;
149+
}
150+
151+
_logger.Debug($"No similar artists found for: {artistIdentifier}");
152+
return [];
153+
}
154+
catch (Exception ex)
155+
{
156+
_logger.Error(ex, $"Error fetching similar artists for: {artistIdentifier}");
157+
return [];
158+
}
159+
}
160+
161+
/// <summary>
162+
/// Maps a Last.fm artist to a Lidarr Artist object using the MusicBrainz ID
163+
/// </summary>
164+
private Artist MapArtistFromLastfmArtist(LastfmArtist lastfmArtist, string sourceArtistIdentifier, SimilarArtistsProxySettings settings, LastfmImageScraper imageScraper)
165+
{
166+
string foreignArtistId = lastfmArtist.MBID;
167+
168+
ArtistMetadata metadata = new()
169+
{
170+
ForeignArtistId = foreignArtistId,
171+
Name = lastfmArtist.Name ?? string.Empty,
172+
Links =
173+
[
174+
new Links { Url = lastfmArtist.Url, Name = "Last.fm" },
175+
new Links
176+
{
177+
Url = $"https://musicbrainz.org/artist/{foreignArtistId}",
178+
Name = "MusicBrainz"
179+
}
180+
],
181+
Genres = lastfmArtist.Tags?.Tag?.Select(t => t.Name).ToList() ?? [],
182+
Status = ArtistStatusType.Continuing,
183+
Type = string.Empty,
184+
Aliases = []
185+
};
186+
187+
// Set overview
188+
if (lastfmArtist.Bio != null && !string.IsNullOrEmpty(lastfmArtist.Bio.Summary))
189+
{
190+
metadata.Overview = $"Artist similar to {sourceArtistIdentifier}. {lastfmArtist.Bio.Summary}";
191+
}
192+
else if (lastfmArtist.Stats != null)
193+
{
194+
List<string> overviewParts = [$"Artist similar to {sourceArtistIdentifier}"];
195+
if (lastfmArtist.Stats.PlayCount > 0)
196+
overviewParts.Add($"Playcount: {lastfmArtist.Stats.PlayCount:N0}");
197+
if (!string.IsNullOrEmpty(lastfmArtist.Stats.Listeners))
198+
overviewParts.Add($"Listeners: {int.Parse(lastfmArtist.Stats.Listeners):N0}");
199+
metadata.Overview = string.Join(" • ", overviewParts);
200+
}
201+
else
202+
{
203+
metadata.Overview = $"Artist similar to {sourceArtistIdentifier}. Found on Last.fm";
204+
}
205+
206+
// Calculate rating
207+
metadata.Ratings = LastfmMappingHelper.ComputeLastfmRating(lastfmArtist.Stats?.Listeners ?? "0", lastfmArtist.Stats?.PlayCount ?? 0);
208+
209+
// Fetch images if enabled
210+
if (settings.FetchImages)
211+
metadata.Images = FetchArtistImages(lastfmArtist.Name!, imageScraper);
212+
else
213+
metadata.Images = MapLastfmImages(lastfmArtist.Images);
214+
215+
return new Artist
216+
{
217+
ForeignArtistId = foreignArtistId,
218+
Name = lastfmArtist.Name,
219+
SortName = lastfmArtist.Name,
220+
CleanName = lastfmArtist.Name.CleanArtistName(),
221+
Monitored = false,
222+
Metadata = new LazyLoaded<ArtistMetadata>(metadata),
223+
Albums = new LazyLoaded<List<Album>>([])
224+
};
225+
}
226+
227+
/// <summary>
228+
/// Fetches artist images using web scraping
229+
/// </summary>
230+
private List<MediaCover> FetchArtistImages(string artistName, LastfmImageScraper imageScraper)
231+
{
232+
try
233+
{
234+
_logger.Trace($"Fetching images for artist: {artistName}");
235+
236+
List<string> imageUrls = imageScraper.GetArtistImagesAsync(artistName).GetAwaiter().GetResult();
237+
238+
if (imageUrls.Count == 0)
239+
{
240+
_logger.Trace($"No images found for artist: {artistName}");
241+
return [];
242+
}
243+
244+
List<MediaCover> mediaCovers = [];
245+
for (int i = 0; i < Math.Min(imageUrls.Count, 4); i++)
246+
{
247+
MediaCoverTypes coverType = i switch
248+
{
249+
0 => MediaCoverTypes.Poster,
250+
1 => MediaCoverTypes.Fanart,
251+
2 => MediaCoverTypes.Banner,
252+
_ => MediaCoverTypes.Cover
253+
};
254+
255+
mediaCovers.Add(new MediaCover(coverType, imageUrls[i]));
256+
}
257+
258+
_logger.Trace("Fetched {0} images for {1}", mediaCovers.Count, artistName);
259+
return mediaCovers;
260+
}
261+
catch (Exception ex)
262+
{
263+
_logger.Error(ex, $"Failed to fetch images for artist: {artistName}");
264+
return [];
265+
}
266+
}
267+
268+
/// <summary>
269+
/// Maps Last.fm API images to MediaCover objects
270+
/// </summary>
271+
private static List<MediaCover> MapLastfmImages(List<LastfmImage>? images) => images?
272+
.Where(i => !string.IsNullOrEmpty(i.Url))
273+
.Select(i => new MediaCover
274+
{
275+
Url = i.Url,
276+
CoverType = MapImageSize(i.Size)
277+
}).ToList() ?? [];
278+
279+
280+
/// <summary>
281+
/// Maps Last.fm image size to MediaCoverTypes
282+
/// </summary>
283+
private static MediaCoverTypes MapImageSize(string size) => size?.ToLowerInvariant() switch
284+
{
285+
"mega" or "extralarge" => MediaCoverTypes.Poster,
286+
"large" => MediaCoverTypes.Fanart,
287+
"medium" => MediaCoverTypes.Headshot,
288+
"small" => MediaCoverTypes.Logo,
289+
_ => MediaCoverTypes.Poster
290+
};
291+
}
292+
}

0 commit comments

Comments
 (0)