Apple Music fix
This commit is contained in:
@@ -267,6 +267,17 @@ enum MediaType: String, Codable {
|
||||
case unknown
|
||||
}
|
||||
|
||||
/// Maps a library item to its backing provider instance.
|
||||
struct MAProviderMapping: Codable, Hashable {
|
||||
let providerDomain: String
|
||||
let itemId: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case providerDomain = "provider_domain"
|
||||
case itemId = "item_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct MAArtist: Codable, Identifiable, Hashable {
|
||||
let uri: String
|
||||
let name: String
|
||||
@@ -274,6 +285,8 @@ struct MAArtist: Codable, Identifiable, Hashable {
|
||||
let sortName: String?
|
||||
let musicbrainzId: String?
|
||||
let favorite: Bool
|
||||
/// All provider instances this artist is mapped to (from MA's provider_mappings field).
|
||||
let providerMappings: [MAProviderMapping]
|
||||
|
||||
var id: String { uri }
|
||||
var imageUrl: String? { metadata?.thumbImage?.path }
|
||||
@@ -283,6 +296,7 @@ struct MAArtist: Codable, Identifiable, Hashable {
|
||||
case uri, name, metadata, favorite
|
||||
case sortName = "sort_name"
|
||||
case musicbrainzId = "musicbrainz_id"
|
||||
case providerMappings = "provider_mappings"
|
||||
case image // Direct image field
|
||||
}
|
||||
|
||||
@@ -291,6 +305,7 @@ struct MAArtist: Codable, Identifiable, Hashable {
|
||||
self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) }
|
||||
self.sortName = sortName; self.musicbrainzId = musicbrainzId
|
||||
self.favorite = favorite
|
||||
self.providerMappings = []
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -300,17 +315,18 @@ struct MAArtist: Codable, Identifiable, Hashable {
|
||||
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
|
||||
sortName = try? c.decodeIfPresent(String.self, forKey: .sortName)
|
||||
musicbrainzId = try? c.decodeIfPresent(String.self, forKey: .musicbrainzId)
|
||||
|
||||
providerMappings = (try? c.decodeIfPresent([MAProviderMapping].self, forKey: .providerMappings)) ?? []
|
||||
|
||||
// Try to decode metadata first
|
||||
var decodedMetadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata)
|
||||
|
||||
|
||||
// If metadata is missing, try to get image from direct "image" field
|
||||
if decodedMetadata == nil {
|
||||
if let imageObj = try? c.decodeIfPresent(MediaItemImage.self, forKey: .image) {
|
||||
decodedMetadata = MediaItemMetadata(images: [imageObj], cacheChecksum: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
metadata = decodedMetadata
|
||||
}
|
||||
|
||||
@@ -320,6 +336,7 @@ struct MAArtist: Codable, Identifiable, Hashable {
|
||||
try c.encode(name, forKey: .name)
|
||||
try c.encodeIfPresent(sortName, forKey: .sortName)
|
||||
try c.encodeIfPresent(musicbrainzId, forKey: .musicbrainzId)
|
||||
try c.encode(providerMappings, forKey: .providerMappings)
|
||||
try c.encodeIfPresent(metadata, forKey: .metadata)
|
||||
try c.encode(favorite, forKey: .favorite)
|
||||
}
|
||||
|
||||
@@ -491,12 +491,26 @@ final class MAService {
|
||||
|
||||
/// Parse a Music Assistant URI into (provider, itemId)
|
||||
/// MA URIs follow the format: scheme://media_type/item_id
|
||||
///
|
||||
/// Uses manual string parsing because some schemes contain underscores
|
||||
/// (e.g. "apple_music") which are invalid per RFC 2396, causing Swift's
|
||||
/// URL(string:) initialiser to return nil for those URIs.
|
||||
private func parseMAUri(_ uri: String) -> (provider: String, itemId: String)? {
|
||||
guard let url = URL(string: uri),
|
||||
let provider = url.scheme,
|
||||
let host = url.host else { return nil }
|
||||
let itemId = url.path.isEmpty ? host : String(url.path.dropFirst())
|
||||
return (provider, itemId)
|
||||
guard let sepRange = uri.range(of: "://") else { return nil }
|
||||
let provider = String(uri[uri.startIndex..<sepRange.lowerBound])
|
||||
guard !provider.isEmpty else { return nil }
|
||||
|
||||
let remainder = String(uri[sepRange.upperBound...])
|
||||
guard !remainder.isEmpty else { return nil }
|
||||
|
||||
// remainder is "media_type/item_id" or just "item_id"
|
||||
if let slashIdx = remainder.firstIndex(of: "/") {
|
||||
let itemId = String(remainder[remainder.index(after: slashIdx)...])
|
||||
guard !itemId.isEmpty else { return nil }
|
||||
return (provider, itemId)
|
||||
} else {
|
||||
return (provider, remainder)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Proxy
|
||||
|
||||
@@ -222,7 +222,7 @@ struct AlbumDetailView: View {
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
ProviderBadge(uri: album.uri, imageProvider: album.imageProvider)
|
||||
ProviderBadge(uri: album.uri, metadata: album.metadata)
|
||||
|
||||
if let year = album.year {
|
||||
Text(String(year))
|
||||
@@ -403,7 +403,7 @@ struct AlbumDetailView: View {
|
||||
|
||||
// If this album came from a provider-specific URI (not the full library version),
|
||||
// try to find the matching library album so we can offer a "Show complete album" link.
|
||||
if let scheme = URL(string: album.uri)?.scheme, scheme != "library" {
|
||||
if let scheme = album.uri.components(separatedBy: "://").first, scheme != "library" {
|
||||
completeAlbum = service.libraryManager.albums.first {
|
||||
$0.name.caseInsensitiveCompare(album.name) == .orderedSame
|
||||
&& $0.uri.hasPrefix("library://")
|
||||
|
||||
@@ -18,7 +18,7 @@ struct ArtistDetailView: View {
|
||||
@State private var showError = false
|
||||
@State private var kenBurnsScale: CGFloat = 1.0
|
||||
@State private var isBiographyExpanded = false
|
||||
@State private var scrollPositionAlbumID: String? = nil
|
||||
@State private var kenBurnsStarted = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -53,7 +53,6 @@ struct ArtistDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollPosition(id: $scrollPositionAlbumID)
|
||||
}
|
||||
.navigationTitle(artist.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -70,6 +69,8 @@ struct ArtistDetailView: View {
|
||||
await loadArtistDetail()
|
||||
}
|
||||
.onAppear {
|
||||
guard !kenBurnsStarted else { return }
|
||||
kenBurnsStarted = true
|
||||
withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) {
|
||||
kenBurnsScale = 1.15
|
||||
}
|
||||
@@ -126,10 +127,47 @@ struct ArtistDetailView: View {
|
||||
}
|
||||
|
||||
// MARK: - Artist Header
|
||||
|
||||
|
||||
/// All distinct music providers for this artist.
|
||||
/// Uses the authoritative provider_mappings field from MA when available,
|
||||
/// and falls back to scanning loaded album metadata.
|
||||
private var artistProviders: [MusicProvider] {
|
||||
var seen = Set<MusicProvider>()
|
||||
var result = [MusicProvider]()
|
||||
|
||||
// Primary: provider_mappings from the artist object (available immediately)
|
||||
for mapping in artist.providerMappings {
|
||||
if let p = MusicProvider.from(providerKey: mapping.providerDomain), !seen.contains(p) {
|
||||
seen.insert(p)
|
||||
result.append(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan loaded albums when providerMappings is empty
|
||||
if result.isEmpty {
|
||||
for album in albums {
|
||||
if let scheme = album.uri.components(separatedBy: "://").first,
|
||||
let p = MusicProvider.from(scheme: scheme),
|
||||
p != .library,
|
||||
!seen.contains(p) {
|
||||
seen.insert(p)
|
||||
result.append(p)
|
||||
}
|
||||
for key in album.metadata?.images?.compactMap({ $0.provider }) ?? [] {
|
||||
if let p = MusicProvider.from(providerKey: key), !seen.contains(p) {
|
||||
seen.insert(p)
|
||||
result.append(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var artistHeader: some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 12) {
|
||||
CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 512)) { image in
|
||||
image
|
||||
.resizable()
|
||||
@@ -146,18 +184,28 @@ struct ArtistDetailView: View {
|
||||
.frame(width: 200, height: 200)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.5), radius: 20, y: 10)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
ProviderBadge(uri: artist.uri, imageProvider: artist.imageProvider)
|
||||
|
||||
if !albums.isEmpty {
|
||||
Text("\(albums.count) albums")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
||||
// One badge per distinct source
|
||||
if !artistProviders.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(artistProviders, id: \.self) { provider in
|
||||
Image(systemName: provider.icon)
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 20, height: 20)
|
||||
.background(.black.opacity(0.55))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !albums.isEmpty {
|
||||
Text("\(albums.count) albums")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
@@ -184,7 +232,6 @@ struct ArtistDetailView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
|
||||
@@ -238,7 +238,6 @@ struct ArtistGridItem: View {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Text(artist.name)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
|
||||
@@ -180,7 +180,7 @@ struct PlaylistDetailView: View {
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
ProviderBadge(uri: playlist.uri, imageProvider: playlist.imageProvider)
|
||||
ProviderBadge(uri: playlist.uri, metadata: playlist.metadata)
|
||||
|
||||
if !tracks.isEmpty {
|
||||
Text("\(tracks.count) tracks")
|
||||
|
||||
Reference in New Issue
Block a user