diff --git a/Mobile Music Assistant/ModelsMAModels.swift b/Mobile Music Assistant/ModelsMAModels.swift index 9fe6dcf..cabf2d4 100644 --- a/Mobile Music Assistant/ModelsMAModels.swift +++ b/Mobile Music Assistant/ModelsMAModels.swift @@ -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) } diff --git a/Mobile Music Assistant/ServicesMAService.swift b/Mobile Music Assistant/ServicesMAService.swift index c9f902f..2b69da9 100644 --- a/Mobile Music Assistant/ServicesMAService.swift +++ b/Mobile Music Assistant/ServicesMAService.swift @@ -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..() + 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) } diff --git a/Mobile Music Assistant/ViewsLibraryArtistsView.swift b/Mobile Music Assistant/ViewsLibraryArtistsView.swift index 3b0a338..24ba577 100644 --- a/Mobile Music Assistant/ViewsLibraryArtistsView.swift +++ b/Mobile Music Assistant/ViewsLibraryArtistsView.swift @@ -238,7 +238,6 @@ struct ArtistGridItem: View { } } - Text(artist.name) .font(.caption) .fontWeight(.medium) diff --git a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift index f3e3ddf..0f12897 100644 --- a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift @@ -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") diff --git a/ViewsComponentsProviderBadge.swift b/ViewsComponentsProviderBadge.swift index dccf18f..93c7dcd 100644 --- a/ViewsComponentsProviderBadge.swift +++ b/ViewsComponentsProviderBadge.swift @@ -7,44 +7,61 @@ import SwiftUI -/// Small monochrome badge indicating which music provider an item comes from. -/// Uses the URI scheme first, then falls back to the image provider field. +/// Monochrome badges indicating which music provider(s) an item comes from. +/// 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 { let uri: String - var imageProvider: String? = nil + var metadata: MediaItemMetadata? = nil - private var provider: MusicProvider? { - // Try URI scheme first (provider-specific items like subsonic://...) - if let fromScheme = MusicProvider.from(scheme: URL(string: uri)?.scheme), - fromScheme != .library { - return fromScheme + private var providers: [MusicProvider] { + // Use string-based parsing — URL(string:) returns nil for schemes with + // underscores like "apple_music" which violate RFC 2396. + let scheme = uri.components(separatedBy: "://").first?.lowercased() + + // 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) { - return fromImage + + // library:// URI → collect all distinct providers from metadata images + var seen = Set() + 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 + + // Nothing found for a library item → fall back to the library badge + if result.isEmpty && scheme == "library" { + return [.library] } - return nil + + return result } var body: some View { - if let provider { - Image(systemName: provider.icon) - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 20, height: 20) - .background(.black.opacity(0.55)) - .clipShape(Circle()) + HStack(spacing: 4) { + ForEach(providers, 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()) + } } } } // MARK: - Provider Mapping -enum MusicProvider { +enum MusicProvider: Hashable { case library case subsonic case spotify @@ -64,7 +81,7 @@ enum MusicProvider { var icon: String { switch self { 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 .tidal: return "water.waves" case .qobuz: return "hifispeaker.fill" @@ -119,7 +136,7 @@ enum MusicProvider { if k.hasPrefix("filesystem") { return .filesystem } if k.hasPrefix("jellyfin") { return .jellyfin } 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 } return nil