Apple Music fix

This commit is contained in:
2026-04-07 20:15:56 +02:00
parent fe3ed1e204
commit f55b7e478b
7 changed files with 145 additions and 51 deletions
@@ -267,6 +267,17 @@ enum MediaType: String, Codable {
case unknown 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 { struct MAArtist: Codable, Identifiable, Hashable {
let uri: String let uri: String
let name: String let name: String
@@ -274,6 +285,8 @@ struct MAArtist: Codable, Identifiable, Hashable {
let sortName: String? let sortName: String?
let musicbrainzId: String? let musicbrainzId: String?
let favorite: Bool 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 id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path } var imageUrl: String? { metadata?.thumbImage?.path }
@@ -283,6 +296,7 @@ struct MAArtist: Codable, Identifiable, Hashable {
case uri, name, metadata, favorite case uri, name, metadata, favorite
case sortName = "sort_name" case sortName = "sort_name"
case musicbrainzId = "musicbrainz_id" case musicbrainzId = "musicbrainz_id"
case providerMappings = "provider_mappings"
case image // Direct image field 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.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) }
self.sortName = sortName; self.musicbrainzId = musicbrainzId self.sortName = sortName; self.musicbrainzId = musicbrainzId
self.favorite = favorite self.favorite = favorite
self.providerMappings = []
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
@@ -300,6 +315,7 @@ struct MAArtist: Codable, Identifiable, Hashable {
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
sortName = try? c.decodeIfPresent(String.self, forKey: .sortName) sortName = try? c.decodeIfPresent(String.self, forKey: .sortName)
musicbrainzId = try? c.decodeIfPresent(String.self, forKey: .musicbrainzId) musicbrainzId = try? c.decodeIfPresent(String.self, forKey: .musicbrainzId)
providerMappings = (try? c.decodeIfPresent([MAProviderMapping].self, forKey: .providerMappings)) ?? []
// Try to decode metadata first // Try to decode metadata first
var decodedMetadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata) var decodedMetadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata)
@@ -320,6 +336,7 @@ struct MAArtist: Codable, Identifiable, Hashable {
try c.encode(name, forKey: .name) try c.encode(name, forKey: .name)
try c.encodeIfPresent(sortName, forKey: .sortName) try c.encodeIfPresent(sortName, forKey: .sortName)
try c.encodeIfPresent(musicbrainzId, forKey: .musicbrainzId) try c.encodeIfPresent(musicbrainzId, forKey: .musicbrainzId)
try c.encode(providerMappings, forKey: .providerMappings)
try c.encodeIfPresent(metadata, forKey: .metadata) try c.encodeIfPresent(metadata, forKey: .metadata)
try c.encode(favorite, forKey: .favorite) try c.encode(favorite, forKey: .favorite)
} }
+18 -4
View File
@@ -491,12 +491,26 @@ final class MAService {
/// Parse a Music Assistant URI into (provider, itemId) /// Parse a Music Assistant URI into (provider, itemId)
/// MA URIs follow the format: scheme://media_type/item_id /// 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)? { private func parseMAUri(_ uri: String) -> (provider: String, itemId: String)? {
guard let url = URL(string: uri), guard let sepRange = uri.range(of: "://") else { return nil }
let provider = url.scheme, let provider = String(uri[uri.startIndex..<sepRange.lowerBound])
let host = url.host else { return nil } guard !provider.isEmpty else { return nil }
let itemId = url.path.isEmpty ? host : String(url.path.dropFirst())
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) return (provider, itemId)
} else {
return (provider, remainder)
}
} }
// MARK: - Image Proxy // MARK: - Image Proxy
@@ -222,7 +222,7 @@ struct AlbumDetailView: View {
} }
HStack(spacing: 6) { HStack(spacing: 6) {
ProviderBadge(uri: album.uri, imageProvider: album.imageProvider) ProviderBadge(uri: album.uri, metadata: album.metadata)
if let year = album.year { if let year = album.year {
Text(String(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), // 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. // 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 { completeAlbum = service.libraryManager.albums.first {
$0.name.caseInsensitiveCompare(album.name) == .orderedSame $0.name.caseInsensitiveCompare(album.name) == .orderedSame
&& $0.uri.hasPrefix("library://") && $0.uri.hasPrefix("library://")
@@ -18,7 +18,7 @@ struct ArtistDetailView: View {
@State private var showError = false @State private var showError = false
@State private var kenBurnsScale: CGFloat = 1.0 @State private var kenBurnsScale: CGFloat = 1.0
@State private var isBiographyExpanded = false @State private var isBiographyExpanded = false
@State private var scrollPositionAlbumID: String? = nil @State private var kenBurnsStarted = false
var body: some View { var body: some View {
ZStack { ZStack {
@@ -53,7 +53,6 @@ struct ArtistDetailView: View {
} }
} }
} }
.scrollPosition(id: $scrollPositionAlbumID)
} }
.navigationTitle(artist.name) .navigationTitle(artist.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -70,6 +69,8 @@ struct ArtistDetailView: View {
await loadArtistDetail() await loadArtistDetail()
} }
.onAppear { .onAppear {
guard !kenBurnsStarted else { return }
kenBurnsStarted = true
withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) { withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) {
kenBurnsScale = 1.15 kenBurnsScale = 1.15
} }
@@ -127,9 +128,46 @@ struct ArtistDetailView: View {
// MARK: - Artist Header // 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 @ViewBuilder
private var artistHeader: some View { 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 CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 512)) { image in
image image
.resizable() .resizable()
@@ -147,8 +185,19 @@ struct ArtistDetailView: View {
.clipShape(Circle()) .clipShape(Circle())
.shadow(color: .black.opacity(0.5), radius: 20, y: 10) .shadow(color: .black.opacity(0.5), radius: 20, y: 10)
HStack(spacing: 6) { // One badge per distinct source
ProviderBadge(uri: artist.uri, imageProvider: artist.imageProvider) 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 { if !albums.isEmpty {
Text("\(albums.count) albums") Text("\(albums.count) albums")
@@ -158,7 +207,6 @@ struct ArtistDetailView: View {
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
} }
} }
}
.padding(.top) .padding(.top)
} }
@@ -184,7 +232,6 @@ struct ArtistDetailView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
.scrollTargetLayout()
.padding(.horizontal) .padding(.horizontal)
.padding(.bottom, 24) .padding(.bottom, 24)
} }
@@ -238,7 +238,6 @@ struct ArtistGridItem: View {
} }
} }
Text(artist.name) Text(artist.name)
.font(.caption) .font(.caption)
.fontWeight(.medium) .fontWeight(.medium)
@@ -180,7 +180,7 @@ struct PlaylistDetailView: View {
} }
HStack(spacing: 6) { HStack(spacing: 6) {
ProviderBadge(uri: playlist.uri, imageProvider: playlist.imageProvider) ProviderBadge(uri: playlist.uri, metadata: playlist.metadata)
if !tracks.isEmpty { if !tracks.isEmpty {
Text("\(tracks.count) tracks") Text("\(tracks.count) tracks")
+36 -19
View File
@@ -7,31 +7,47 @@
import SwiftUI import SwiftUI
/// Small monochrome badge indicating which music provider an item comes from. /// Monochrome badges indicating which music provider(s) an item comes from.
/// Uses the URI scheme first, then falls back to the image provider field. /// For provider-specific URIs (e.g. spotify://) a single badge is shown.
/// For library:// items all distinct source providers found in the metadata
/// images are shown side by side.
struct ProviderBadge: View { struct ProviderBadge: View {
let uri: String let uri: String
var imageProvider: String? = nil var metadata: MediaItemMetadata? = nil
private var provider: MusicProvider? { private var providers: [MusicProvider] {
// Try URI scheme first (provider-specific items like subsonic://...) // Use string-based parsing URL(string:) returns nil for schemes with
if let fromScheme = MusicProvider.from(scheme: URL(string: uri)?.scheme), // underscores like "apple_music" which violate RFC 2396.
fromScheme != .library { let scheme = uri.components(separatedBy: "://").first?.lowercased()
return fromScheme
// Non-library URI show only that provider
if let fromScheme = MusicProvider.from(scheme: scheme), fromScheme != .library {
return [fromScheme]
} }
// Fall back to the image provider metadata
if let imageProvider, let fromImage = MusicProvider.from(providerKey: imageProvider) { // library:// URI collect all distinct providers from metadata images
return fromImage var seen = Set<MusicProvider>()
var result = [MusicProvider]()
let keys = metadata?.images?.compactMap { $0.provider } ?? []
for key in keys {
if let p = MusicProvider.from(providerKey: key), !seen.contains(p) {
seen.insert(p)
result.append(p)
} }
// URI scheme is library:// and no image provider show library badge
if URL(string: uri)?.scheme?.lowercased() == "library" {
return .library
} }
return nil
// Nothing found for a library item fall back to the library badge
if result.isEmpty && scheme == "library" {
return [.library]
}
return result
} }
var body: some View { var body: some View {
if let provider { HStack(spacing: 4) {
ForEach(providers, id: \.self) { provider in
Image(systemName: provider.icon) Image(systemName: provider.icon)
.font(.system(size: 9, weight: .bold)) .font(.system(size: 9, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(.white)
@@ -40,11 +56,12 @@ struct ProviderBadge: View {
.clipShape(Circle()) .clipShape(Circle())
} }
} }
}
} }
// MARK: - Provider Mapping // MARK: - Provider Mapping
enum MusicProvider { enum MusicProvider: Hashable {
case library case library
case subsonic case subsonic
case spotify case spotify
@@ -64,7 +81,7 @@ enum MusicProvider {
var icon: String { var icon: String {
switch self { switch self {
case .library: return "building.columns.fill" case .library: return "building.columns.fill"
case .subsonic: return "sailboat.fill" case .subsonic: return "ferry.fill"
case .spotify: return "antenna.radiowaves.left.and.right.circle.fill" case .spotify: return "antenna.radiowaves.left.and.right.circle.fill"
case .tidal: return "water.waves" case .tidal: return "water.waves"
case .qobuz: return "hifispeaker.fill" case .qobuz: return "hifispeaker.fill"
@@ -119,7 +136,7 @@ enum MusicProvider {
if k.hasPrefix("filesystem") { return .filesystem } if k.hasPrefix("filesystem") { return .filesystem }
if k.hasPrefix("jellyfin") { return .jellyfin } if k.hasPrefix("jellyfin") { return .jellyfin }
if k.hasPrefix("dlna") { return .dlna } if k.hasPrefix("dlna") { return .dlna }
// Common image-only providers not a music source // Image-only metadata providers not a music source
if k == "lastfm" || k == "musicbrainz" || k == "fanarttv" { return nil } if k == "lastfm" || k == "musicbrainz" || k == "fanarttv" { return nil }
return nil return nil