Apple Music fix
This commit is contained in:
@@ -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,17 +315,18 @@ 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)
|
||||||
|
|
||||||
// If metadata is missing, try to get image from direct "image" field
|
// If metadata is missing, try to get image from direct "image" field
|
||||||
if decodedMetadata == nil {
|
if decodedMetadata == nil {
|
||||||
if let imageObj = try? c.decodeIfPresent(MediaItemImage.self, forKey: .image) {
|
if let imageObj = try? c.decodeIfPresent(MediaItemImage.self, forKey: .image) {
|
||||||
decodedMetadata = MediaItemMetadata(images: [imageObj], cacheChecksum: nil)
|
decodedMetadata = MediaItemMetadata(images: [imageObj], cacheChecksum: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata = decodedMetadata
|
metadata = decodedMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
|
||||||
return (provider, itemId)
|
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
|
// 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
|
||||||
}
|
}
|
||||||
@@ -126,10 +127,47 @@ 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()
|
||||||
@@ -146,18 +184,28 @@ struct ArtistDetailView: View {
|
|||||||
.frame(width: 200, height: 200)
|
.frame(width: 200, height: 200)
|
||||||
.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) {
|
|
||||||
ProviderBadge(uri: artist.uri, imageProvider: artist.imageProvider)
|
|
||||||
|
|
||||||
if !albums.isEmpty {
|
// One badge per distinct source
|
||||||
Text("\(albums.count) albums")
|
if !artistProviders.isEmpty {
|
||||||
.font(.subheadline)
|
HStack(spacing: 4) {
|
||||||
.fontWeight(.semibold)
|
ForEach(artistProviders, id: \.self) { provider in
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
Image(systemName: provider.icon)
|
||||||
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
.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)
|
.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")
|
||||||
|
|||||||
@@ -7,44 +7,61 @@
|
|||||||
|
|
||||||
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" {
|
// Nothing found for a library item → fall back to the library badge
|
||||||
return .library
|
if result.isEmpty && scheme == "library" {
|
||||||
|
return [.library]
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let provider {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: provider.icon)
|
ForEach(providers, id: \.self) { provider in
|
||||||
.font(.system(size: 9, weight: .bold))
|
Image(systemName: provider.icon)
|
||||||
.foregroundStyle(.white)
|
.font(.system(size: 9, weight: .bold))
|
||||||
.frame(width: 20, height: 20)
|
.foregroundStyle(.white)
|
||||||
.background(.black.opacity(0.55))
|
.frame(width: 20, height: 20)
|
||||||
.clipShape(Circle())
|
.background(.black.opacity(0.55))
|
||||||
|
.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
|
||||||
|
|||||||
Reference in New Issue
Block a user