Skip to content

Commit ae7153f

Browse files
committed
Adds support for fetching and showing artist image & bio
Adds support for fetching and showing artist image & bio from Deezer and TMDB, in case images are not available, user can manually set an image via search or absolute URL.
1 parent 9739439 commit ae7153f

18 files changed

Lines changed: 1001 additions & 116 deletions

.swiftlint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ custom_rules:
172172
space_after_comment:
173173
included: ".*\\.swift"
174174
name: "Space After Comment"
175-
regex: '(\/\/\w+)'
175+
regex: '(?<!:)(\/\/\w+)'
176176
message: "Add a space after //"
177177
severity: warning
178178

Configuration/Info.plist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,9 @@
6464
<string>$(LASTFM_API_KEY)</string>
6565
<key>LASTFM_SHARED_SECRET</key>
6666
<string>$(LASTFM_SHARED_SECRET)</string>
67+
68+
<!-- TMDB -->
69+
<key>TMDB_READ_ACCESS_TOKEN</key>
70+
<string>$(TMDB_READ_ACCESS_TOKEN)</string>
6771
</dict>
6872
</plist>
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
// Last.fm API Credentials
22
// Copy this file to Secrets.xcconfig and fill in your own values
33
// Get your API key at: https://www.last.fm/api/account/create
4-
LASTFM_API_KEY =
5-
LASTFM_SHARED_SECRET =
4+
LASTFM_API_KEY =
5+
LASTFM_SHARED_SECRET =
6+
7+
// TMDB API Credentials
8+
// Get your token at: https://www.themoviedb.org/settings/api
9+
TMDB_READ_ACCESS_TOKEN =

Managers/ArtistBioManager.swift

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
//
2+
// ArtistBioManager.swift
3+
// Petrichor
4+
//
5+
// Handles fetching artist images (Deezer, TMDB) and bios (Last.fm)
6+
// from online sources and storing them in the database.
7+
//
8+
9+
import Foundation
10+
11+
class ArtistBioManager {
12+
// MARK: - Singleton
13+
14+
static let shared = ArtistBioManager()
15+
16+
/// Minimum image size in bytes to filter out placeholder/silhouette images
17+
private static let minimumImageSize = 15_000
18+
19+
// MARK: - Constants
20+
21+
private enum Deezer {
22+
static let searchURL = "https://api.deezer.com/search/artist"
23+
static let rateLimitDelay: TimeInterval = 0.12 // ~50 req / 5s
24+
}
25+
26+
private enum TMDB {
27+
static let searchURL = "https://api.themoviedb.org/3/search/person"
28+
static let imageBaseURL = "https://image.tmdb.org/t/p/w500"
29+
static let rateLimitDelay: TimeInterval = 0.3 // ~40 req / 10s
30+
}
31+
32+
private enum LastFM {
33+
static let apiBaseURL = "https://ws.audioscrobbler.com/2.0/"
34+
static let rateLimitDelay: TimeInterval = 0.25
35+
}
36+
37+
private enum UserDefaultsKeys {
38+
static let artistInfoFetchEnabled = "artistInfoFetchEnabled"
39+
}
40+
41+
// MARK: - Properties
42+
43+
private var fetchTask: Task<Void, Never>?
44+
private var lastDeezerRequest: Date?
45+
private var lastTMDBRequest: Date?
46+
private var lastLastFMRequest: Date?
47+
48+
private var tmdbReadAccessToken: String? {
49+
Bundle.main.object(forInfoDictionaryKey: "TMDB_READ_ACCESS_TOKEN") as? String
50+
}
51+
52+
private var lastfmApiKey: String? {
53+
Bundle.main.object(forInfoDictionaryKey: "LASTFM_API_KEY") as? String
54+
}
55+
56+
var isArtistInfoFetchEnabled: Bool {
57+
if UserDefaults.standard.object(forKey: UserDefaultsKeys.artistInfoFetchEnabled) == nil {
58+
return true
59+
}
60+
return UserDefaults.standard.bool(forKey: UserDefaultsKeys.artistInfoFetchEnabled)
61+
}
62+
63+
// MARK: - Initialization
64+
65+
private init() {}
66+
67+
// MARK: - Types
68+
69+
struct ImageResult {
70+
let imageData: Data
71+
let imageUrl: String
72+
let source: String
73+
}
74+
75+
// MARK: - Public Methods
76+
77+
func fetchMissingArtistImages(using libraryManager: LibraryManager) {
78+
fetchTask?.cancel()
79+
80+
let databaseManager = libraryManager.databaseManager
81+
82+
fetchTask = Task.detached(priority: .utility) { [weak self] in
83+
guard let self else { return }
84+
guard self.isArtistInfoFetchEnabled else {
85+
Logger.info("ArtistBioManager: Artist info fetching is disabled")
86+
return
87+
}
88+
89+
let artists = databaseManager.getArtistsNeedingImageOrBio()
90+
guard !artists.isEmpty else {
91+
Logger.info("ArtistBioManager: No artists need fetching")
92+
return
93+
}
94+
95+
Logger.info("ArtistBioManager: Starting fetch for \(artists.count) artists")
96+
97+
var pendingUpdates: [(name: String, artworkData: Data)] = []
98+
var lastUIUpdate = Date.distantPast
99+
let uiUpdateInterval: TimeInterval = 2
100+
101+
for artist in artists {
102+
guard !Task.isCancelled, self.isArtistInfoFetchEnabled else {
103+
Logger.info("ArtistBioManager: Fetch stopped")
104+
break
105+
}
106+
107+
// Fetch image and bio, then write once
108+
let imageResult = artist.hasImage ? nil : await self.fetchArtistImage(name: artist.name)
109+
let bio = artist.hasBio ? nil : await self.fetchArtistBio(name: artist.name)
110+
111+
if let imageResult,
112+
let compressed = ImageUtils.compressImage(from: imageResult.imageData, source: "ArtistBioManager/\(imageResult.source)") {
113+
let source = imageResult.source.components(separatedBy: "").first ?? imageResult.source
114+
databaseManager.updateArtistInfo(
115+
artistId: artist.id,
116+
imageData: compressed,
117+
imageUrl: imageResult.imageUrl,
118+
imageSource: source,
119+
bio: bio,
120+
bioSource: bio != nil ? "last.fm" : nil
121+
)
122+
pendingUpdates.append((name: artist.name, artworkData: compressed))
123+
} else if let bio {
124+
databaseManager.updateArtistInfo(artistId: artist.id, bio: bio, bioSource: "last.fm")
125+
if !artist.hasImage {
126+
databaseManager.markArtistImageFetchFailed(artistId: artist.id)
127+
}
128+
} else if !artist.hasImage {
129+
databaseManager.markArtistImageFetchFailed(artistId: artist.id)
130+
}
131+
132+
// Flush pending UI updates every 2 seconds
133+
if !pendingUpdates.isEmpty && Date().timeIntervalSince(lastUIUpdate) >= uiUpdateInterval {
134+
let updates = pendingUpdates
135+
pendingUpdates.removeAll()
136+
lastUIUpdate = Date()
137+
await MainActor.run {
138+
for update in updates {
139+
libraryManager.updateArtistEntityArtwork(name: update.name, artworkData: update.artworkData)
140+
}
141+
}
142+
}
143+
}
144+
145+
// Flush remaining updates
146+
if !pendingUpdates.isEmpty {
147+
let updates = pendingUpdates
148+
await MainActor.run {
149+
for update in updates {
150+
libraryManager.updateArtistEntityArtwork(name: update.name, artworkData: update.artworkData)
151+
}
152+
}
153+
}
154+
155+
Logger.info("ArtistBioManager: Finished fetch")
156+
}
157+
}
158+
159+
/// Search both Deezer and TMDB for all available artist images (used by image picker sheet)
160+
func searchAllImages(for artistName: String) async -> [ImageResult] {
161+
async let deezerResults = searchDeezerImages(name: artistName)
162+
async let tmdbResults = searchTMDBImages(name: artistName)
163+
return await deezerResults + tmdbResults
164+
}
165+
166+
// MARK: - Private: Image Fetch
167+
168+
private func fetchArtistImage(name: String) async -> ImageResult? {
169+
if let result = await searchDeezerImages(name: name, limit: 1).first {
170+
return result
171+
}
172+
return await searchTMDBImages(name: name, limit: 1).first
173+
}
174+
175+
// MARK: - Deezer Search
176+
177+
private func searchDeezerImages(name: String, limit: Int = 6) async -> [ImageResult] {
178+
if limit == 1 {
179+
await waitForRateLimit(lastRequest: &lastDeezerRequest, delay: Deezer.rateLimitDelay)
180+
}
181+
182+
guard var components = URLComponents(string: Deezer.searchURL) else { return [] }
183+
components.queryItems = [URLQueryItem(name: "q", value: name)]
184+
guard let url = components.url else { return [] }
185+
186+
do {
187+
var request = URLRequest(url: url)
188+
request.setValue(AppInfo.userAgent, forHTTPHeaderField: "User-Agent")
189+
190+
let (data, response) = try await AppInfo.urlSession.data(for: request)
191+
guard let httpResponse = response as? HTTPURLResponse,
192+
httpResponse.statusCode == 200,
193+
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
194+
let results = json["data"] as? [[String: Any]] else {
195+
return []
196+
}
197+
198+
var images: [ImageResult] = []
199+
for result in results.prefix(limit) {
200+
guard let pictureXL = result["picture_xl"] as? String,
201+
!isDeezerPlaceholder(pictureXL) else { continue }
202+
203+
// For auto-fetch, only accept close name matches
204+
if limit == 1, let resultName = result["name"] as? String,
205+
!isNameMatch(query: name, result: resultName) { continue }
206+
207+
if let imageData = await downloadImageData(from: pictureXL) {
208+
let label = limit == 1 ? "deezer" : (result["name"] as? String).map { "deezer – \($0)" } ?? "deezer"
209+
images.append(ImageResult(imageData: imageData, imageUrl: pictureXL, source: label))
210+
}
211+
}
212+
return images
213+
} catch {
214+
Logger.error("ArtistBioManager: Deezer error for '\(name)': \(error.localizedDescription)")
215+
return []
216+
}
217+
}
218+
219+
// MARK: - TMDB Search
220+
221+
private func searchTMDBImages(name: String, limit: Int = 6) async -> [ImageResult] {
222+
guard let token = tmdbReadAccessToken, !token.isEmpty else { return [] }
223+
224+
if limit == 1 {
225+
await waitForRateLimit(lastRequest: &lastTMDBRequest, delay: TMDB.rateLimitDelay)
226+
}
227+
228+
guard var components = URLComponents(string: TMDB.searchURL) else { return [] }
229+
components.queryItems = [URLQueryItem(name: "query", value: name)]
230+
guard let url = components.url else { return [] }
231+
232+
do {
233+
var request = URLRequest(url: url)
234+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
235+
request.setValue(AppInfo.userAgent, forHTTPHeaderField: "User-Agent")
236+
237+
let (data, response) = try await AppInfo.urlSession.data(for: request)
238+
guard let httpResponse = response as? HTTPURLResponse,
239+
httpResponse.statusCode == 200,
240+
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
241+
let results = json["results"] as? [[String: Any]] else {
242+
return []
243+
}
244+
245+
var images: [ImageResult] = []
246+
for result in results.prefix(limit) {
247+
guard let profilePath = result["profile_path"] as? String else { continue }
248+
249+
// For auto-fetch, only accept close name matches
250+
if limit == 1, let resultName = result["name"] as? String,
251+
!isNameMatch(query: name, result: resultName) { continue }
252+
253+
let imageUrlString = TMDB.imageBaseURL + profilePath
254+
255+
if let imageData = await downloadImageData(from: imageUrlString) {
256+
let label = limit == 1 ? "tmdb" : (result["name"] as? String).map { "tmdb – \($0)" } ?? "tmdb"
257+
images.append(ImageResult(imageData: imageData, imageUrl: imageUrlString, source: label))
258+
}
259+
}
260+
return images
261+
} catch {
262+
Logger.error("ArtistBioManager: TMDB error for '\(name)': \(error.localizedDescription)")
263+
return []
264+
}
265+
}
266+
267+
// MARK: - Last.fm Bio
268+
269+
private func fetchArtistBio(name: String) async -> String? {
270+
guard let apiKey = lastfmApiKey, !apiKey.isEmpty else { return nil }
271+
272+
await waitForRateLimit(lastRequest: &lastLastFMRequest, delay: LastFM.rateLimitDelay)
273+
274+
guard var components = URLComponents(string: LastFM.apiBaseURL) else { return nil }
275+
components.queryItems = [
276+
URLQueryItem(name: "method", value: "artist.getinfo"),
277+
URLQueryItem(name: "artist", value: name),
278+
URLQueryItem(name: "api_key", value: apiKey),
279+
URLQueryItem(name: "format", value: "json")
280+
]
281+
282+
guard let url = components.url else { return nil }
283+
284+
do {
285+
var request = URLRequest(url: url)
286+
request.setValue(AppInfo.userAgent, forHTTPHeaderField: "User-Agent")
287+
288+
let (data, response) = try await AppInfo.urlSession.data(for: request)
289+
guard let httpResponse = response as? HTTPURLResponse,
290+
httpResponse.statusCode == 200 else {
291+
return nil
292+
}
293+
294+
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
295+
let artist = json["artist"] as? [String: Any],
296+
let bio = artist["bio"] as? [String: Any],
297+
let content = bio["summary"] as? String else {
298+
return nil
299+
}
300+
301+
// Last.fm appends a "Read more" link in HTML — strip it
302+
let cleaned = content
303+
.replacingOccurrences(of: "<a href=\".*?\">.*?</a>", with: "", options: .regularExpression)
304+
.trimmingCharacters(in: .whitespacesAndNewlines)
305+
306+
return cleaned.isEmpty ? nil : cleaned
307+
} catch {
308+
Logger.error("ArtistBioManager: Last.fm bio error for '\(name)': \(error.localizedDescription)")
309+
return nil
310+
}
311+
}
312+
313+
// MARK: - Helpers
314+
315+
private func downloadImageData(from urlString: String) async -> Data? {
316+
guard let url = URL(string: urlString) else { return nil }
317+
guard let (data, response) = try? await AppInfo.urlSession.data(from: url),
318+
let httpResponse = response as? HTTPURLResponse,
319+
httpResponse.statusCode == 200,
320+
data.count >= Self.minimumImageSize else {
321+
return nil
322+
}
323+
return data
324+
}
325+
326+
/// Check if the API result name is a close match to the search query.
327+
/// Accepts exact matches (case-insensitive) or when one name contains the other.
328+
private func isNameMatch(query: String, result: String) -> Bool {
329+
let q = query.lowercased()
330+
let r = result.lowercased()
331+
return q == r || q.contains(r) || r.contains(q)
332+
}
333+
334+
private func isDeezerPlaceholder(_ url: String) -> Bool {
335+
url.contains("/images/artist//")
336+
}
337+
338+
// MARK: - Rate Limiting
339+
340+
private func waitForRateLimit(lastRequest: inout Date?, delay: TimeInterval) async {
341+
if let last = lastRequest {
342+
let elapsed = Date().timeIntervalSince(last)
343+
let waitTime = delay - elapsed
344+
if waitTime > 0 {
345+
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
346+
}
347+
}
348+
lastRequest = Date()
349+
}
350+
}

0 commit comments

Comments
 (0)