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