Skip to content

Commit 4ed66f1

Browse files
authored
Merge pull request #72 from rryam/feature/add-infavorites-property
Add inFavorites property support for music items
2 parents f68bcef + 6d923a4 commit 4ed66f1

File tree

8 files changed

+463
-4
lines changed

8 files changed

+463
-4
lines changed

Musadora/Musadora/Music Items/Song/SongsView.swift

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct SongsView: View {
1313
private var songs: Songs
1414
@State private var favoritedSongIDs: Set<MusicItemID> = []
1515
@State private var favoritingSongIDs: Set<MusicItemID> = []
16+
@State private var isLoadingFavorites = false
1617

1718
init(with songs: Songs) {
1819
self.songs = songs
@@ -81,8 +82,8 @@ struct SongsView: View {
8182
}
8283
} label: {
8384
let isFavorited = favoritedSongIDs.contains(song.id)
84-
Image(systemName: isFavorited ? "heart.fill" : "heart")
85-
.foregroundColor(isFavorited ? .red : .secondary)
85+
Image(systemName: isFavorited ? "star.fill" : "star")
86+
.foregroundColor(isFavorited ? .yellow : .secondary)
8687
}
8788
.buttonStyle(.plain)
8889
.disabled(favoritingSongIDs.contains(song.id) || favoritedSongIDs.contains(song.id))
@@ -104,5 +105,31 @@ struct SongsView: View {
104105
#if os(iOS)
105106
.navigationBarTitleDisplayMode(.large)
106107
#endif
108+
.onChange(of: songs.count) { _, _ in
109+
Task {
110+
await loadFavoritesStatus()
111+
}
112+
}
113+
}
114+
115+
private func loadFavoritesStatus() async {
116+
guard !isLoadingFavorites else { return }
117+
isLoadingFavorites = true
118+
119+
for song in songs {
120+
do {
121+
let isFavorite = try await song.inFavorites
122+
if isFavorite {
123+
await MainActor.run {
124+
favoritedSongIDs.insert(song.id)
125+
}
126+
}
127+
} catch {
128+
// Song is not in library or other error - silently skip
129+
continue
130+
}
131+
}
132+
133+
isLoadingFavorites = false
107134
}
108135
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//
2+
// InFavoritesParser.swift
3+
// MusadoraKit
4+
//
5+
// Created by Rudrank Riyam on 10/11/25.
6+
//
7+
8+
import Foundation
9+
10+
/// Response structure for Apple Music API inFavorites queries.
11+
private struct InFavoritesResponse: Decodable {
12+
let data: [InFavoritesResponseItem]
13+
}
14+
15+
private struct InFavoritesResponseItem: Decodable {
16+
let attributes: InFavoritesAttributes?
17+
let relationships: InFavoritesRelationships?
18+
}
19+
20+
private struct InFavoritesAttributes: Decodable {
21+
let inFavorites: Bool?
22+
}
23+
24+
private struct InFavoritesRelationships: Decodable {
25+
let library: InFavoritesLibrary?
26+
}
27+
28+
private struct InFavoritesLibrary: Decodable {
29+
struct Item: Decodable {}
30+
let data: [Item]
31+
}
32+
33+
/// Internal parser for extracting inFavorites status from Apple Music API responses.
34+
internal enum InFavoritesParser {
35+
/// Parse the inFavorites response from Apple Music API data.
36+
///
37+
/// This function extracts the inFavorites boolean value from the API response,
38+
/// checking both that the item is in the library and that the inFavorites attribute exists.
39+
///
40+
/// - Parameters:
41+
/// - data: The raw Data from the API response
42+
/// - itemType: The type of music item being parsed
43+
/// - Returns: The inFavorites boolean value
44+
/// - Throws: MusadoraKitError if parsing fails, item not found, not in library, or inFavorites not found
45+
static func parse(from data: Data, itemType: LibraryMusicItemType) throws -> Bool {
46+
let response = try JSONDecoder().decode(InFavoritesResponse.self, from: data)
47+
48+
guard let item = response.data.first else {
49+
throw MusadoraKitError.notFound(for: "\(itemType.rawValue) in catalog")
50+
}
51+
52+
guard let libraryData = item.relationships?.library?.data, !libraryData.isEmpty else {
53+
throw MusadoraKitError.notInLibrary(item: itemType.rawValue)
54+
}
55+
56+
guard let inFavorites = item.attributes?.inFavorites else {
57+
throw MusadoraKitError.notFound(for: "inFavorites")
58+
}
59+
60+
return inFavorites
61+
}
62+
63+
/// Fetch inFavorites status for a music item from the Apple Music API.
64+
///
65+
/// This helper method handles the common logic for fetching favorite status across all music item types.
66+
///
67+
/// - Parameters:
68+
/// - id: The catalog ID of the music item
69+
/// - itemType: The type of music item
70+
/// - Returns: `true` if the item is in favorites, `false` otherwise
71+
/// - Throws: An error if the item is not in library or if the request fails
72+
static func fetchInFavorites(for id: MusicItemID, itemType: LibraryMusicItemType) async throws -> Bool {
73+
let storefront = try await MusicDataRequest.currentCountryCode
74+
var components = AppleMusicURLComponents()
75+
components.path = "catalog/\(storefront)/\(itemType.rawValue)/\(id.rawValue)"
76+
components.queryItems = [
77+
URLQueryItem(name: "relate", value: "library"),
78+
URLQueryItem(name: "extend", value: "inFavorites")
79+
]
80+
81+
guard let url = components.url else {
82+
throw URLError(.badURL)
83+
}
84+
85+
let request = MusicDataRequest(urlRequest: .init(url: url))
86+
let response = try await request.response()
87+
88+
return try parse(from: response.data, itemType: itemType)
89+
}
90+
}

Sources/MusadoraKit/Library/MLibrary+Album.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,26 @@ public extension Album {
209209
return catalogId
210210
}
211211
}
212+
213+
/// A Boolean value indicating whether the album is in the user's favorites.
214+
///
215+
/// This property fetches the album from the library and checks its favorite status.
216+
///
217+
/// Example usage:
218+
///
219+
/// let album: Album = ...
220+
/// if try await album.inFavorites {
221+
/// print("This album is in favorites!")
222+
/// }
223+
///
224+
/// - Returns: `true` if the album is in favorites, `false` otherwise.
225+
/// - Throws: An error if the album is not in library or if the request fails.
226+
var inFavorites: Bool {
227+
get async throws {
228+
let catalogId = try self.catalogID
229+
return try await InFavoritesParser.fetchInFavorites(for: catalogId, itemType: .albums)
230+
}
231+
}
212232
}
213233

214234
struct AlbumPlayParameters: Codable {

Sources/MusadoraKit/Library/MLibrary+Artist.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,24 @@ public extension MLibrary {
141141
}
142142
}
143143
}
144+
145+
public extension Artist {
146+
/// A Boolean value indicating whether the artist is in the user's favorites.
147+
///
148+
/// This property fetches the artist from the library and checks its favorite status.
149+
///
150+
/// Example usage:
151+
///
152+
/// let artist: Artist = ...
153+
/// if try await artist.inFavorites {
154+
/// print("This artist is in favorites!")
155+
/// }
156+
///
157+
/// - Returns: `true` if the artist is in favorites, `false` otherwise.
158+
/// - Throws: An error if the artist is not in library or if the request fails.
159+
var inFavorites: Bool {
160+
get async throws {
161+
return try await InFavoritesParser.fetchInFavorites(for: id, itemType: .artists)
162+
}
163+
}
164+
}

Sources/MusadoraKit/Library/MLibrary+Playlist.swift

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,9 @@ public extension MLibrary {
283283
static func madeForYouPlaylists() async throws -> LibraryPlaylists {
284284
var components = AppleMusicURLComponents()
285285
components.path = "me/library/playlists"
286-
components.queryItems = [URLQueryItem(name: "filter[featured]", value: "made-for-you")]
286+
components.queryItems = [
287+
URLQueryItem(name: "filter[featured]", value: "made-for-you")
288+
]
287289

288290
guard let url = components.url else {
289291
throw URLError(.badURL)
@@ -302,8 +304,31 @@ public extension MLibrary {
302304

303305
var components = AppleMusicURLComponents()
304306
components.path = "me/library/playlists"
305-
components.queryItems = [URLQueryItem(name: "limit", value: "\(limit)")]
307+
components.queryItems = [
308+
URLQueryItem(name: "limit", value: "\(limit)")
309+
]
306310

307311
return components.url
308312
}
309313
}
314+
315+
public extension Playlist {
316+
/// A Boolean value indicating whether the playlist is in the user's favorites.
317+
///
318+
/// This property fetches the playlist from the library and checks its favorite status.
319+
///
320+
/// Example usage:
321+
///
322+
/// let playlist: Playlist = ...
323+
/// if try await playlist.inFavorites {
324+
/// print("This playlist is in favorites!")
325+
/// }
326+
///
327+
/// - Returns: `true` if the playlist is in favorites, `false` otherwise.
328+
/// - Throws: An error if the playlist is not in library or if the request fails.
329+
var inFavorites: Bool {
330+
get async throws {
331+
return try await InFavoritesParser.fetchInFavorites(for: id, itemType: .playlists)
332+
}
333+
}
334+
}

Sources/MusadoraKit/Library/MLibrary+Song.swift

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// Created by Rudrank Riyam on 14/08/21.
66
//
77

8+
// swiftlint:disable file_length
89
import MediaPlayer
910

1011
public extension MLibrary {
@@ -488,3 +489,58 @@ public extension MLibrary {
488489
return response.sections
489490
}
490491
}
492+
493+
public extension Song {
494+
/// A Boolean value indicating whether the song is in the user's favorites.
495+
///
496+
/// This property fetches the song from the library and checks its favorite status.
497+
///
498+
/// Example usage:
499+
///
500+
/// let song: Song = ...
501+
/// if try await song.inFavorites {
502+
/// print("This song is in favorites!")
503+
/// }
504+
///
505+
/// - Returns: `true` if the song is in favorites, `false` otherwise.
506+
/// - Throws: An error if the song is not in library or if the request fails.
507+
var inFavorites: Bool {
508+
get async throws {
509+
let catalogId = try self.catalogID
510+
return try await InFavoritesParser.fetchInFavorites(for: catalogId, itemType: .songs)
511+
}
512+
}
513+
514+
/// The catalog identifier for the song.
515+
///
516+
/// This property decodes the play parameters of the song to retrieve its catalog identifier.
517+
///
518+
/// - Returns: The catalog ID of the song.
519+
/// - Throws: `MusadoraKitError.notFound` if play parameters or catalog ID are not available.
520+
var catalogID: MusicItemID {
521+
get throws {
522+
guard let playParameters = playParameters else {
523+
throw MusadoraKitError.notFound(for: "playParameters")
524+
}
525+
526+
let playParamData = try JSONEncoder().encode(playParameters)
527+
let params = try JSONDecoder().decode(SongPlayParameters.self, from: playParamData)
528+
529+
guard let catalogId = params.catalogId else {
530+
throw MusadoraKitError.notFound(for: "catalogId")
531+
}
532+
533+
return catalogId
534+
}
535+
}
536+
}
537+
538+
internal struct SongPlayParameters: Codable {
539+
let isLibrary: Bool?
540+
let catalogId: MusicItemID?
541+
542+
private enum CodingKeys: String, CodingKey {
543+
case isLibrary
544+
case catalogId
545+
}
546+
}

Sources/MusadoraKit/Models/MusadoraKitError.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ public enum MusadoraKitError: Error, Equatable {
1212
/// The specified music item could not be found.
1313
case notFound(for: String)
1414

15+
/// The specified music item is not in the user's library.
16+
case notInLibrary(item: String)
17+
1518
/// One or more types must be specified for the operation.
1619
case typeMissing
1720

@@ -75,6 +78,8 @@ extension MusadoraKitError: CustomStringConvertible {
7578
switch self {
7679
case let .notFound(id):
7780
return "The specified music item could not be found for \(id)."
81+
case let .notInLibrary(item):
82+
return "The \(item) is not in the user's library."
7883
case .typeMissing:
7984
return "One or more types must be specified for fetching top results in search suggestions."
8085
case let .recommendationOverLimit(limit):

0 commit comments

Comments
 (0)