From 622f3836dfa3f44df6da4e5100fa1f18944bd6d2 Mon Sep 17 00:00:00 2001 From: brandon Date: Mon, 25 May 2026 23:58:35 -0400 Subject: [PATCH 1/5] adding the ability to view tags for a song. also showing "artists" tag for a song when available in the ui --- Amperfy.xcodeproj/project.pbxproj | 30 +- .../ViewController/EntityPreviewVC.swift | 27 ++ .../SwiftUI/SongTags/SongTagsFilterView.swift | 56 +++ Amperfy/SwiftUI/SongTags/SongTagsView.swift | 293 +++++++++++++++ .../Api/Subsonic/SsSongParserDelegate.swift | 199 +++++++++- AmperfyKit/Storage/EntityWrappers/Song.swift | 88 ++++- .../Amperfy.xcdatamodeld/.xccurrentversion | 2 +- .../Amperfy v50.xcdatamodel/contents | 353 ++++++++++++++++++ .../Migration/CoreDataMigrationVersion.swift | 3 + .../SongMO+CoreDataProperties.swift | 34 ++ 10 files changed, 1076 insertions(+), 9 deletions(-) create mode 100644 Amperfy/SwiftUI/SongTags/SongTagsFilterView.swift create mode 100644 Amperfy/SwiftUI/SongTags/SongTagsView.swift create mode 100644 AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/Amperfy v50.xcdatamodel/contents diff --git a/Amperfy.xcodeproj/project.pbxproj b/Amperfy.xcodeproj/project.pbxproj index 5f71d962..83e22b7e 100644 --- a/Amperfy.xcodeproj/project.pbxproj +++ b/Amperfy.xcodeproj/project.pbxproj @@ -205,7 +205,6 @@ 50BE5D532850F4E700156FC6 /* MusicPlayerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5095F98B23C8389E008B0805 /* MusicPlayerTest.swift */; }; 50BE5D542850F4E700156FC6 /* SubsonicVersionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E964AE25E8E25E00E3210F /* SubsonicVersionTest.swift */; }; 50BE5D552850F4E700156FC6 /* UtilitiesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5090963726496A9500DD9826 /* UtilitiesTest.swift */; }; - B5A5CE010000000000000001 /* ShareSongActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A5CE010000000000000002 /* ShareSongActionTest.swift */; }; 50BE5D562850F4E700156FC6 /* PlayQueueHandlerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5011712D27453D1300B7C08D /* PlayQueueHandlerTest.swift */; }; 50BE5D572850F4F600156FC6 /* album_missing_artistId.xml in Resources */ = {isa = PBXBuildFile; fileRef = 50ED2B9227BB932700331BF7 /* album_missing_artistId.xml */; }; 50BE5D582850F4F600156FC6 /* artist_example_1.xml in Resources */ = {isa = PBXBuildFile; fileRef = 50AB92C526661F5800DCE45C /* artist_example_1.xml */; }; @@ -524,16 +523,19 @@ 50FF311B2BBC4D8000C2C3B9 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF311A2BBC4D8000C2C3B9 /* NetworkMonitor.swift */; }; 50RA71NG2F5A01240085CBB3 /* RatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50RA71NG2F5A01230085CBB3 /* RatingView.swift */; }; 631C166B2C6D3D9A0085F62E /* SettingsRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631C166A2C6D3D9A0085F62E /* SettingsRow.swift */; }; + A0B5F09C0B574CC98E0A62CE /* SongTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8799286DACD436E80B4C074 /* SongTagsView.swift */; }; + F4F3C532709B4EE281A77BF8 /* SongTagsFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF616AAB192A45B8A702D3F3 /* SongTagsFilterView.swift */; }; 631C166D2C6D61110085F62E /* NavigationTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631C166C2C6D61110085F62E /* NavigationTarget.swift */; }; 631C166F2C6D63510085F62E /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631C166E2C6D63510085F62E /* SettingsSection.swift */; }; 632F51242C83A7970032860D /* QueueVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632F51232C83A7970032860D /* QueueVC.swift */; }; 632F51262C83A8400032860D /* LyricsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632F51252C83A8400032860D /* LyricsVC.swift */; }; 6333C8E12C6FDB5200CCA50A /* SecondaryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6333C8E02C6FDB5200CCA50A /* SecondaryText.swift */; }; 637A28B52C79409C0082FACC /* MiniPlayerSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 637A28B42C79409B0082FACC /* MiniPlayerSceneDelegate.swift */; }; - C0A1B0032EEE000000000003 /* MacWindowHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A1B0022EEE000000000002 /* MacWindowHelper.swift */; }; 638920F02C8C8E9000932EE8 /* SettingsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638920EF2C8C8E9000932EE8 /* SettingsList.swift */; }; 63DCDB7A2C674B5D00522F68 /* SettingsSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63DCDB792C674B5D00522F68 /* SettingsSceneDelegate.swift */; }; 641708592C13BF5100BA2619 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641708582C13BF5100BA2619 /* Haptics.swift */; }; + B5A5CE010000000000000001 /* ShareSongActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A5CE010000000000000002 /* ShareSongActionTest.swift */; }; + C0A1B0032EEE000000000003 /* MacWindowHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A1B0022EEE000000000002 /* MacWindowHelper.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -840,7 +842,6 @@ 509001C027182A1300A8056D /* CatalogParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogParserTest.swift; sourceTree = ""; }; 509001C227182A4700A8056D /* CatalogParserDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogParserDelegate.swift; sourceTree = ""; }; 5090963726496A9500DD9826 /* UtilitiesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilitiesTest.swift; sourceTree = ""; }; - B5A5CE010000000000000002 /* ShareSongActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSongActionTest.swift; sourceTree = ""; }; 509357512E3BF47800CD4075 /* MiniPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniPlayerView.swift; sourceTree = ""; }; 509362EC28E041FF005C2AAC /* Amperfy v28.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v28.xcdatamodel"; sourceTree = ""; }; 50947E0227DA011F00C368D7 /* ScrobbleEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobbleEntry.swift; sourceTree = ""; }; @@ -1142,18 +1143,21 @@ 50FF311A2BBC4D8000C2C3B9 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; 50RA71NG2F5A01230085CBB3 /* RatingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingView.swift; sourceTree = ""; }; 631C166A2C6D3D9A0085F62E /* SettingsRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRow.swift; sourceTree = ""; }; + E8799286DACD436E80B4C074 /* SongTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SongTagsView.swift; sourceTree = ""; }; + BF616AAB192A45B8A702D3F3 /* SongTagsFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SongTagsFilterView.swift; sourceTree = ""; }; 631C166C2C6D61110085F62E /* NavigationTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationTarget.swift; sourceTree = ""; }; 631C166E2C6D63510085F62E /* SettingsSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; 632F51232C83A7970032860D /* QueueVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueVC.swift; sourceTree = ""; }; 632F51252C83A8400032860D /* LyricsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsVC.swift; sourceTree = ""; }; 6333C8E02C6FDB5200CCA50A /* SecondaryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryText.swift; sourceTree = ""; }; 637A28B42C79409B0082FACC /* MiniPlayerSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniPlayerSceneDelegate.swift; sourceTree = ""; }; - C0A1B0022EEE000000000002 /* MacWindowHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacWindowHelper.swift; sourceTree = ""; }; 638920EF2C8C8E9000932EE8 /* SettingsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsList.swift; sourceTree = ""; }; 63DCDB792C674B5D00522F68 /* SettingsSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSceneDelegate.swift; sourceTree = ""; }; 641708582C13BF5100BA2619 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 643A04712CD29AB20012DEA3 /* Amperfy v39.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v39.xcdatamodel"; sourceTree = ""; }; 64C433822E1391FA0082A165 /* Amperfy v46.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v46.xcdatamodel"; sourceTree = ""; }; + B5A5CE010000000000000002 /* ShareSongActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSongActionTest.swift; sourceTree = ""; }; + C0A1B0022EEE000000000002 /* MacWindowHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacWindowHelper.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2131,10 +2135,20 @@ children = ( 50791E7D28D363CA006CE6E5 /* Basics */, 50791E8228D36EAD006CE6E5 /* Settings */, + 1827478EA3BF4788A3D228C1 /* SongTags */, ); path = SwiftUI; sourceTree = ""; }; + 1827478EA3BF4788A3D228C1 /* SongTags */ = { + isa = PBXGroup; + children = ( + E8799286DACD436E80B4C074 /* SongTagsView.swift */, + BF616AAB192A45B8A702D3F3 /* SongTagsFilterView.swift */, + ); + path = SongTags; + sourceTree = ""; + }; 50E964AD25E8E23D00E3210F /* API */ = { isa = PBXGroup; children = ( @@ -2453,6 +2467,8 @@ 50B137002EFC36D500738475 /* ShuffleTypeAppEnum.swift in Sources */, 504B441D28D7A6330033982C /* XCallbackURLsSetttingsView.swift in Sources */, 631C166B2C6D3D9A0085F62E /* SettingsRow.swift in Sources */, + A0B5F09C0B574CC98E0A62CE /* SongTagsView.swift in Sources */, + F4F3C532709B4EE281A77BF8 /* SongTagsFilterView.swift in Sources */, 507361E02632BA3B005F151D /* GenresVC.swift in Sources */, 50C1715A2D107E7600C0C53A /* PlaylistAddDirectoriesVC.swift in Sources */, 50E59A4B2ED747F000F2B65C /* HomeEditorVC.swift in Sources */, @@ -2968,7 +2984,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = U3R6D65S8W; + DEVELOPMENT_TEAM = 8N5BD6J6TY; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3036,7 +3052,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = U3R6D65S8W; + DEVELOPMENT_TEAM = 8N5BD6J6TY; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3069,6 +3085,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 85AQZ68KL2; INFOPLIST_FILE = Amperfy/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -3108,6 +3125,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 85AQZ68KL2; INFOPLIST_FILE = Amperfy/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Amperfy/Screens/ViewController/EntityPreviewVC.swift b/Amperfy/Screens/ViewController/EntityPreviewVC.swift index a7803d7a..27b67fe2 100644 --- a/Amperfy/Screens/ViewController/EntityPreviewVC.swift +++ b/Amperfy/Screens/ViewController/EntityPreviewVC.swift @@ -22,6 +22,7 @@ import AmperfyKit import Foundation import MarqueeLabel +import SwiftUI import UIKit typealias GetPlayContextCallback = () -> PlayContext? @@ -69,6 +70,7 @@ class EntityPreviewActionBuilder { private var isGoToSiteUrl = false private var isShowPodcastDetails = false private var isShowSongDetails = false + private var isShowSongTags = false private var isInstantMix = false private var isShareable = false @@ -128,6 +130,10 @@ class EntityPreviewActionBuilder { let lyricsShowAction = createShowLyricsAction(song: song) { gotoActions.append(lyricsShowAction) } + if isShowSongTags, + let song = (entityContainer as? AbstractPlayable)?.asSong { + gotoActions.append(createShowSongTagsAction(song: song)) + } if isShowPodcastDetails, let podcastEpisode = (entityContainer as? AbstractPlayable)?.asPodcastEpisode { gotoActions.append(createShowEpisodeDetailsAction(podcastEpisode: podcastEpisode)) @@ -265,6 +271,7 @@ class EntityPreviewActionBuilder { isGoToSiteUrl = false isShowPodcastDetails = false isShowSongDetails = true + isShowSongTags = SongTagKey.allCases.contains { $0.value(for: song) != nil } isInstantMix = appDelegate.storage.settings.user.isOnlineMode isShareable = song.isCached || appDelegate.storage.settings.user.isOnlineMode } @@ -861,6 +868,26 @@ class EntityPreviewActionBuilder { } } + private func createShowSongTagsAction(song: Song) -> UIAction { + UIAction(title: "View Tags", image: UIImage(systemName: "tag")) { [weak self] _ in + self?.showSongTags(song: song) + } + } + + private func showSongTags(song: Song) { + // Bail out silently if the managed object has been invalidated between menu + // construction and the user tapping the action — prevents an empty/broken sheet. + guard song.managedObject.managedObjectContext != nil else { return } + let tagsView = SongTagsView(song: song) + let hostingVC = UIHostingController(rootView: tagsView) + let navVC = UINavigationController(rootViewController: hostingVC) + if let sheet = navVC.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + } + rootView.present(navVC, animated: true) + } + private func createShowLyricsAction(song: Song) -> UIAction? { guard let playable = entityContainer as? AbstractPlayable, let song = playable.asSong, diff --git a/Amperfy/SwiftUI/SongTags/SongTagsFilterView.swift b/Amperfy/SwiftUI/SongTags/SongTagsFilterView.swift new file mode 100644 index 00000000..7fd94f93 --- /dev/null +++ b/Amperfy/SwiftUI/SongTags/SongTagsFilterView.swift @@ -0,0 +1,56 @@ +// +// SongTagsFilterView.swift +// Amperfy +// +// Created by Amperfy on 25.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +import Foundation +import SwiftUI + +// MARK: - SongTagsFilterView + +struct SongTagsFilterView: View { + @ObservedObject var store: TagVisibilityStore + @Environment(\.dismiss) private var dismiss + + var body: some View { + List { + Section { + ForEach(SongTagKey.allCases, id: \.rawValue) { key in + let isOn = Binding( + get: { !store.hiddenKeys.contains(key.rawValue) }, + set: { store.setVisible(key, visible: $0) } + ) + SettingsCheckBoxRow(title: key.displayName, isOn: isOn) + } + } + Section { + Button("Show All") { + store.showAll() + } + } + } + .navigationTitle("Visible Tags") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() } + } + } + } +} diff --git a/Amperfy/SwiftUI/SongTags/SongTagsView.swift b/Amperfy/SwiftUI/SongTags/SongTagsView.swift new file mode 100644 index 00000000..a4ec5836 --- /dev/null +++ b/Amperfy/SwiftUI/SongTags/SongTagsView.swift @@ -0,0 +1,293 @@ +// +// SongTagsView.swift +// Amperfy +// +// Created by Amperfy on 25.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +import AmperfyKit +import Foundation +import SwiftUI + +// MARK: - SongTagKey + +enum SongTagKey: String, CaseIterable { + case title = "title" + case artists = "artists" + case albumArtists = "albumArtists" + case album = "album" + case genre = "genre" + case genres = "genres" + case trackNumber = "trackNumber" + case discNumber = "discNumber" + case year = "year" + case duration = "duration" + case bpm = "bpm" + case bitrate = "bitrate" + case bitDepth = "bitDepth" + case samplingRate = "samplingRate" + case channelCount = "channelCount" + case contentType = "contentType" + case fileSize = "fileSize" + case dateAdded = "dateAdded" + case rating = "rating" + case favorite = "favorite" + case explicitStatus = "explicitStatus" + case comment = "comment" + case sortName = "sortName" + case musicBrainzId = "musicBrainzId" + case isrc = "isrc" + case moods = "moods" + case groupings = "groupings" + case contributors = "contributors" + case displayComposer = "displayComposer" + case replayGainTrack = "replayGainTrack" + case replayGainAlbum = "replayGainAlbum" + + var displayName: String { + switch self { + case .title: return "Title" + case .artists: return "Artists" + case .albumArtists: return "Album Artists" + case .album: return "Album" + case .genre: return "Genre" + case .genres: return "Genres (Multi)" + case .trackNumber: return "Track" + case .discNumber: return "Disc" + case .year: return "Year" + case .duration: return "Duration" + case .bpm: return "BPM" + case .bitrate: return "Bitrate" + case .bitDepth: return "Bit Depth" + case .samplingRate: return "Sample Rate" + case .channelCount: return "Channels" + case .contentType: return "Format" + case .fileSize: return "File Size" + case .dateAdded: return "Date Added" + case .rating: return "Rating" + case .favorite: return "Favorite" + case .explicitStatus: return "Explicit" + case .comment: return "Comment" + case .sortName: return "Sort Name" + case .musicBrainzId: return "MusicBrainz ID" + case .isrc: return "ISRC" + case .moods: return "Moods" + case .groupings: return "Groupings" + case .contributors: return "Contributors" + case .displayComposer: return "Composer" + case .replayGainTrack: return "Replay Gain (Track)" + case .replayGainAlbum: return "Replay Gain (Album)" + } + } + + func value(for song: Song) -> String? { + // Guard against invalid/deleted Core Data objects — accessing their properties + // throws an uncatchable ObjC exception if the managed object context is gone. + guard song.managedObject.managedObjectContext != nil else { return nil } + switch self { + case .title: + return song.title.isEmpty ? nil : song.title + case .artists: + let v = song.artistsString ?? song.artist?.name + return (v?.isEmpty == false) ? v : nil + case .albumArtists: + let v = song.albumArtistsString ?? song.displayAlbumArtist + return (v?.isEmpty == false) ? v : nil + case .album: + return song.album?.name + case .genre: + return song.genre?.name + case .genres: + let v = song.genresList + return (v?.isEmpty == false) ? v : nil + case .trackNumber: + return song.track > 0 ? String(song.track) : nil + case .discNumber: + let d = song.disk?.trimmingCharacters(in: .whitespacesAndNewlines) + return (d?.isEmpty == false) ? d : nil + case .year: + return song.year > 0 ? String(song.year) : nil + case .duration: + return song.duration > 0 ? song.duration.asDurationString : nil + case .bpm: + return song.bpm > 0 ? "\(song.bpm) BPM" : nil + case .bitrate: + return song.bitrate > 0 ? "\(song.bitrate / 1000) kbps" : nil + case .bitDepth: + return song.bitDepth > 0 ? "\(song.bitDepth)-bit" : nil + case .samplingRate: + guard song.samplingRate > 0 else { return nil } + let khz = Double(song.samplingRate) / 1000.0 + return String(format: "%.1f kHz", khz) + case .channelCount: + switch song.channelCount { + case 1: return "Mono" + case 2: return "Stereo" + case let c where c > 2: return "\(c) channels" + default: return nil + } + case .contentType: + let ct = song.contentType?.trimmingCharacters(in: .whitespacesAndNewlines) + return (ct?.isEmpty == false) ? ct : nil + case .fileSize: + guard song.size > 0 else { return nil } + return ByteCountFormatter.string(fromByteCount: Int64(song.size), countStyle: .file) + case .dateAdded: + guard let date = song.addedDate else { return nil } + return DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .none) + case .rating: + return song.rating > 0 ? "\(song.rating) / 5" : nil + case .favorite: + return song.isFavorite ? "Yes" : nil + case .explicitStatus: + let v = song.explicitStatus + return (v?.isEmpty == false) ? v : nil + case .comment: + let v = song.comment + return (v?.isEmpty == false) ? v : nil + case .sortName: + let v = song.sortName + return (v?.isEmpty == false) ? v : nil + case .musicBrainzId: + let v = song.musicBrainzId + return (v?.isEmpty == false) ? v : nil + case .isrc: + let v = song.isrcList + return (v?.isEmpty == false) ? v : nil + case .moods: + let v = song.moodsList + return (v?.isEmpty == false) ? v : nil + case .groupings: + let v = song.groupingsList + return (v?.isEmpty == false) ? v : nil + case .contributors: + let v = song.contributorsString + return (v?.isEmpty == false) ? v : nil + case .displayComposer: + let v = song.displayComposer + return (v?.isEmpty == false) ? v : nil + case .replayGainTrack: + guard song.replayGainTrackGain != 0 else { return nil } + return String(format: "%.2f dB", song.replayGainTrackGain) + case .replayGainAlbum: + guard song.replayGainAlbumGain != 0 else { return nil } + return String(format: "%.2f dB", song.replayGainAlbumGain) + } + } +} + +// MARK: - TagVisibilityStore + +class TagVisibilityStore: ObservableObject { + static let udKey = "songTagVisibility" + + @Published var hiddenKeys: Set + + init() { + if let saved = UserDefaults.standard.stringArray(forKey: Self.udKey) { + hiddenKeys = Set(saved) + } else { + hiddenKeys = [] + } + } + + func setVisible(_ key: SongTagKey, visible: Bool) { + if visible { + hiddenKeys.remove(key.rawValue) + } else { + hiddenKeys.insert(key.rawValue) + } + persist() + } + + func showAll() { + hiddenKeys = [] + persist() + } + + private func persist() { + UserDefaults.standard.set(Array(hiddenKeys), forKey: Self.udKey) + } +} + +// MARK: - SongTagsView + +struct SongTagsView: View { + let song: Song + @StateObject private var store = TagVisibilityStore() + @State private var showFilter = false + + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 4) { + Text(song.title) + .font(.title2) + .fontWeight(.semibold) + Text(song.creatorName) + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.vertical, 8) + } + Section { + if visibleTags.isEmpty { + Text("No tags to display") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 4) + } else { + ForEach(visibleTags, id: \.key.rawValue) { item in + HStack(alignment: .top) { + Text(item.key.displayName) + .foregroundColor(.secondary) + .frame(minWidth: 110, alignment: .leading) + Spacer() + Text(item.value) + .multilineTextAlignment(.trailing) + } + } + } + } + } + .navigationTitle("Song Info") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showFilter = true + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + } + } + .sheet(isPresented: $showFilter) { + NavigationView { + SongTagsFilterView(store: store) + } + } + } + + private var visibleTags: [(key: SongTagKey, value: String)] { + SongTagKey.allCases.compactMap { key in + guard !store.hiddenKeys.contains(key.rawValue), + let value = key.value(for: song) + else { return nil } + return (key: key, value: value) + } + } +} diff --git a/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift b/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift index 144b0e8d..3195dc87 100644 --- a/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift +++ b/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift @@ -30,6 +30,23 @@ class SsSongParserDelegate: SsPlayableParserDelegate { var guessedArtist: Artist? var guessedAlbum: Album? var guessedGenre: Genre? + // Accumulates individual artist names from OpenSubsonic child elements. + var collectedArtistNames = [String]() + // Accumulates album artist names from OpenSubsonic child elements. + var collectedAlbumArtistNames = [String]() + // Accumulates genre names from OpenSubsonic child elements. + var collectedGenreNames = [String]() + // Accumulates contributors from OpenSubsonic . + var collectedContributors = [(role: String, subRole: String, name: String)]() + var currentContributorRole = "" + var currentContributorSubRole = "" + var isInsideContributor = false + // For text-content child elements (isrc, moods, groupings). + var currentTextElementName = "" + var currentTextBuffer = "" + var collectedISRCs = [String]() + var collectedMoods = [String]() + var collectedGroupings = [String]() override func parser( _ parser: XMLParser, @@ -95,6 +112,57 @@ class SsSongParserDelegate: SsPlayableParserDelegate { } } + // Store the fallback display string now; overwritten at element close if + // OpenSubsonic child elements are present. + if let displayArtist = attributeDict["displayArtist"], !displayArtist.isEmpty { + songBuffer?.artistsString = displayArtist + } else if let artistDisplayString = attributeDict["artist"] { + songBuffer?.artistsString = artistDisplayString + } + collectedArtistNames = [] + collectedAlbumArtistNames = [] + collectedGenreNames = [] + collectedContributors = [] + collectedISRCs = [] + collectedMoods = [] + collectedGroupings = [] + isInsideContributor = false + currentTextElementName = "" + currentTextBuffer = "" + + // OpenSubsonic simple-attribute fields + if let bpmStr = attributeDict["bpm"], let bpmVal = Int16(bpmStr) { + songBuffer?.bpm = bpmVal + } + if let commentStr = attributeDict["comment"] { + songBuffer?.comment = commentStr.isEmpty ? nil : commentStr + } + if let sortNameStr = attributeDict["sortName"] { + songBuffer?.sortName = sortNameStr.isEmpty ? nil : sortNameStr + } + if let mbidStr = attributeDict["musicBrainzId"] { + songBuffer?.musicBrainzId = mbidStr.isEmpty ? nil : mbidStr + } + if let displayAlbumArtistStr = attributeDict["displayAlbumArtist"] { + songBuffer?.displayAlbumArtist = + displayAlbumArtistStr.isEmpty ? nil : displayAlbumArtistStr + } + if let displayComposerStr = attributeDict["displayComposer"] { + songBuffer?.displayComposer = displayComposerStr.isEmpty ? nil : displayComposerStr + } + if let explicitStatusStr = attributeDict["explicitStatus"] { + songBuffer?.explicitStatus = explicitStatusStr.isEmpty ? nil : explicitStatusStr + } + if let channelStr = attributeDict["channelCount"], let channelVal = Int16(channelStr) { + songBuffer?.channelCount = channelVal + } + if let srStr = attributeDict["samplingRate"], let srVal = Int32(srStr) { + songBuffer?.samplingRate = srVal + } + if let bdStr = attributeDict["bitDepth"], let bdVal = Int16(bdStr) { + songBuffer?.bitDepth = bdVal + } + if let albumId = attributeDict["albumId"] { if let guessedAlbum, guessedAlbum.id == albumId { songBuffer?.album = guessedAlbum @@ -138,6 +206,51 @@ class SsSongParserDelegate: SsPlayableParserDelegate { } } + // Each OpenSubsonic song artist is its own element + // (the element name is plural). Collect while inside a song. + if elementName == "artists", songBuffer != nil, let name = attributeDict["name"], + !name.isEmpty + { + collectedArtistNames.append(name) + } + + // Album artists: + if elementName == "albumArtists", songBuffer != nil, let name = attributeDict["name"], + !name.isEmpty + { + collectedAlbumArtistNames.append(name) + } + + // Multi-genre: + if elementName == "genres", songBuffer != nil, let name = attributeDict["name"], + !name.isEmpty + { + collectedGenreNames.append(name) + } + + // Contributors: + if elementName == "contributors", songBuffer != nil { + currentContributorRole = attributeDict["role"] ?? "" + currentContributorSubRole = attributeDict["subRole"] ?? "" + isInsideContributor = true + } + // Inner element inside a block + if elementName == "artist", isInsideContributor, let name = attributeDict["name"], + !name.isEmpty + { + collectedContributors.append( + (role: currentContributorRole, subRole: currentContributorSubRole, name: name) + ) + } + + // Text-content child elements: , , + if (elementName == "isrc" || elementName == "moods" || elementName == "groupings"), + songBuffer != nil + { + currentTextElementName = elementName + currentTextBuffer = "" + } + super.parser( parser, didStartElement: elementName, @@ -147,14 +260,98 @@ class SsSongParserDelegate: SsPlayableParserDelegate { ) } + override func parser(_ parser: XMLParser, foundCharacters string: String) { + guard !currentTextElementName.isEmpty, songBuffer != nil else { return } + currentTextBuffer += string + } + override func parser( _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String? ) { + // Finalise text-content child elements + if elementName == currentTextElementName, !currentTextElementName.isEmpty { + let trimmed = currentTextBuffer.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + switch currentTextElementName { + case "isrc": collectedISRCs.append(trimmed) + case "moods": collectedMoods.append(trimmed) + case "groupings": collectedGroupings.append(trimmed) + default: break + } + } + currentTextElementName = "" + currentTextBuffer = "" + } + + // Contributors block ends + if elementName == "contributors" { + isInsideContributor = false + } + if elementName == "song" || elementName == "entry" || elementName == "child" || elementName == - "episode", songBuffer != nil { + "episode", songBuffer != nil + { + // Multi-artist display string + if !collectedArtistNames.isEmpty { + songBuffer?.artistsString = collectedArtistNames.joined(separator: ", ") + } + + // Album artists + if !collectedAlbumArtistNames.isEmpty { + songBuffer?.albumArtistsString = collectedAlbumArtistNames.joined(separator: ", ") + } + + // Multi-genre list + if !collectedGenreNames.isEmpty { + songBuffer?.genresList = collectedGenreNames.joined(separator: ", ") + } + + // ISRC list + if !collectedISRCs.isEmpty { + songBuffer?.isrcList = collectedISRCs.joined(separator: ", ") + } + + // Moods list + if !collectedMoods.isEmpty { + songBuffer?.moodsList = collectedMoods.joined(separator: ", ") + } + + // Groupings list + if !collectedGroupings.isEmpty { + songBuffer?.groupingsList = collectedGroupings.joined(separator: ", ") + } + + // Contributors: group by role, format as "Role: Name1, Name2" + if !collectedContributors.isEmpty { + var roleGroups = [String: [String]]() + var roleOrder = [String]() + for contributor in collectedContributors { + let roleLabel = contributor.subRole.isEmpty + ? contributor.role.capitalized + : "\(contributor.role.capitalized) (\(contributor.subRole))" + if roleGroups[roleLabel] == nil { + roleGroups[roleLabel] = [] + roleOrder.append(roleLabel) + } + roleGroups[roleLabel]?.append(contributor.name) + } + let lines = roleOrder.compactMap { role -> String? in + guard let names = roleGroups[role], !names.isEmpty else { return nil } + return "\(role): \(names.joined(separator: ", "))" + } + songBuffer?.contributorsString = lines.joined(separator: "\n") + } + + collectedArtistNames = [] + collectedAlbumArtistNames = [] + collectedGenreNames = [] + collectedContributors = [] + collectedISRCs = [] + collectedMoods = [] + collectedGroupings = [] parsedCount += 1 resetPlayableBuffer() if let song = songBuffer { diff --git a/AmperfyKit/Storage/EntityWrappers/Song.swift b/AmperfyKit/Storage/EntityWrappers/Song.swift index c3571ad0..22e8022b 100644 --- a/AmperfyKit/Storage/EntityWrappers/Song.swift +++ b/AmperfyKit/Storage/EntityWrappers/Song.swift @@ -106,8 +106,94 @@ public class Song: AbstractPlayable, Identifyable { } } + public var artistsString: String? { + get { managedObject.artistsString } + set { managedObject.artistsString = newValue } + } + + public var albumArtistsString: String? { + get { managedObject.albumArtistsString } + set { managedObject.albumArtistsString = newValue } + } + + public var bpm: Int16 { + get { managedObject.bpm } + set { managedObject.bpm = newValue } + } + + public var bitDepth: Int16 { + get { managedObject.bitDepth } + set { managedObject.bitDepth = newValue } + } + + public var channelCount: Int16 { + get { managedObject.channelCount } + set { managedObject.channelCount = newValue } + } + + public var samplingRate: Int32 { + get { managedObject.samplingRate } + set { managedObject.samplingRate = newValue } + } + + public var comment: String? { + get { managedObject.comment } + set { managedObject.comment = newValue } + } + + public var sortName: String? { + get { managedObject.sortName } + set { managedObject.sortName = newValue } + } + + public var musicBrainzId: String? { + get { managedObject.musicBrainzId } + set { managedObject.musicBrainzId = newValue } + } + + public var isrcList: String? { + get { managedObject.isrcList } + set { managedObject.isrcList = newValue } + } + + public var genresList: String? { + get { managedObject.genresList } + set { managedObject.genresList = newValue } + } + + public var moodsList: String? { + get { managedObject.moodsList } + set { managedObject.moodsList = newValue } + } + + public var groupingsList: String? { + get { managedObject.groupingsList } + set { managedObject.groupingsList = newValue } + } + + public var displayAlbumArtist: String? { + get { managedObject.displayAlbumArtist } + set { managedObject.displayAlbumArtist = newValue } + } + + public var contributorsString: String? { + get { managedObject.contributorsString } + set { managedObject.contributorsString = newValue } + } + + public var displayComposer: String? { + get { managedObject.displayComposer } + set { managedObject.displayComposer = newValue } + } + + public var explicitStatus: String? { + get { managedObject.explicitStatus } + set { managedObject.explicitStatus = newValue } + } + override public var creatorName: String { - artist?.name ?? "Unknown Artist" + if let s = managedObject.artistsString, !s.isEmpty { return s } + return artist?.name ?? "Unknown Artist" } public var detailInfo: String { diff --git a/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion b/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion index 887518ef..82cb0268 100644 --- a/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion +++ b/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Amperfy v49.xcdatamodel + Amperfy v50.xcdatamodel diff --git a/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/Amperfy v50.xcdatamodel/contents b/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/Amperfy v50.xcdatamodel/contents new file mode 100644 index 00000000..a64c35eb --- /dev/null +++ b/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/Amperfy v50.xcdatamodel/contents @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AmperfyKit/Storage/ManagedObjects/Migration/CoreDataMigrationVersion.swift b/AmperfyKit/Storage/ManagedObjects/Migration/CoreDataMigrationVersion.swift index 66183286..1947c243 100644 --- a/AmperfyKit/Storage/ManagedObjects/Migration/CoreDataMigrationVersion.swift +++ b/AmperfyKit/Storage/ManagedObjects/Migration/CoreDataMigrationVersion.swift @@ -60,6 +60,7 @@ enum CoreDataMigrationVersion: String, CaseIterable { case v48 = "Amperfy v48" // Account support: add account (url + user) case v49 = "Amperfy v49" // Remove PlayableFile and Artwork data (they were already deprecated); Account: add apiType + case v50 = "Amperfy v50" // Store joined artist display string for multi-artist songs // MARK: - Current @@ -172,6 +173,8 @@ enum CoreDataMigrationVersion: String, CaseIterable { case .v48: return .v49 case .v49: + return .v50 + case .v50: return nil } } diff --git a/AmperfyKit/Storage/ManagedObjects/SongMO+CoreDataProperties.swift b/AmperfyKit/Storage/ManagedObjects/SongMO+CoreDataProperties.swift index 8e84b2d3..57ec4140 100644 --- a/AmperfyKit/Storage/ManagedObjects/SongMO+CoreDataProperties.swift +++ b/AmperfyKit/Storage/ManagedObjects/SongMO+CoreDataProperties.swift @@ -31,8 +31,42 @@ extension SongMO { @NSManaged public var lyricsRelFilePath: String? @NSManaged + public var artistsString: String? + @NSManaged + public var albumArtistsString: String? + @NSManaged public var addedDate: Date? @NSManaged + public var bpm: Int16 + @NSManaged + public var bitDepth: Int16 + @NSManaged + public var channelCount: Int16 + @NSManaged + public var samplingRate: Int32 + @NSManaged + public var comment: String? + @NSManaged + public var sortName: String? + @NSManaged + public var musicBrainzId: String? + @NSManaged + public var isrcList: String? + @NSManaged + public var genresList: String? + @NSManaged + public var moodsList: String? + @NSManaged + public var groupingsList: String? + @NSManaged + public var displayAlbumArtist: String? + @NSManaged + public var contributorsString: String? + @NSManaged + public var displayComposer: String? + @NSManaged + public var explicitStatus: String? + @NSManaged public var album: AlbumMO? @NSManaged public var artist: ArtistMO? From e18ac20dba2e3e4db1d2f4671427d7ae43f42c43 Mon Sep 17 00:00:00 2001 From: brandon Date: Tue, 26 May 2026 15:27:13 -0400 Subject: [PATCH 2/5] adding a test for the new tag functionality and applying formatting --- Amperfy.xcodeproj/project.pbxproj | 8 + .../SwiftUI/SongTags/SongTagsFilterView.swift | 6 +- Amperfy/SwiftUI/SongTags/SongTagsView.swift | 75 +++++----- AmperfyKit/AmperfyKit.swift | 2 +- .../Api/Subsonic/SsSongParserDelegate.swift | 20 +-- AmperfyKit/Download/DownloadManager.swift | 12 +- AmperfyKit/Player/AudioPlayer.swift | 8 +- .../album_opensubsonic_tags_example_1.xml | 108 ++++++++++++++ .../SsSongOpenSubsonicTagsParserTest.swift | 137 ++++++++++++++++++ 9 files changed, 318 insertions(+), 58 deletions(-) create mode 100644 AmperfyKitTests/Cases/API/Subsonic/Samples/album_opensubsonic_tags_example_1.xml create mode 100644 AmperfyKitTests/Cases/API/Subsonic/SsSongOpenSubsonicTagsParserTest.swift diff --git a/Amperfy.xcodeproj/project.pbxproj b/Amperfy.xcodeproj/project.pbxproj index 83e22b7e..c6ad630b 100644 --- a/Amperfy.xcodeproj/project.pbxproj +++ b/Amperfy.xcodeproj/project.pbxproj @@ -174,6 +174,7 @@ 50AE79B62C1F65850085CBB3 /* SsLyricsBySongId1ParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AE79B52C1F65850085CBB3 /* SsLyricsBySongId1ParserTest.swift */; }; 50AE79B82C1F68090085CBB3 /* SsLyricsBySongId2ParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AE79B72C1F68080085CBB3 /* SsLyricsBySongId2ParserTest.swift */; }; 50AE79BA2C1F69A00085CBB3 /* OpenSubsonicExtensions_example_1.xml in Resources */ = {isa = PBXBuildFile; fileRef = 50AE79B92C1F69A00085CBB3 /* OpenSubsonicExtensions_example_1.xml */; }; + BB0A0002BB0A00020085CBB3 /* album_opensubsonic_tags_example_1.xml in Resources */ = {isa = PBXBuildFile; fileRef = BB0A0001BB0A00010085CBB3 /* album_opensubsonic_tags_example_1.xml */; }; 50AE79BC2C1F6ADF0085CBB3 /* SsOpenSubsonicExtensionsParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AE79BB2C1F6ADE0085CBB3 /* SsOpenSubsonicExtensionsParserTest.swift */; }; 50AE79C22C20709C0085CBB3 /* LyricsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AE79C12C20709C0085CBB3 /* LyricsView.swift */; }; 50AE79C32C2070F40085CBB3 /* LyricTableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AE79BF2C206EF10085CBB3 /* LyricTableCell.swift */; }; @@ -239,6 +240,7 @@ 50BE5D752850F4FB00156FC6 /* SsPlaylistSongsParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AB92CF2666BC2000DCE45C /* SsPlaylistSongsParserTest.swift */; }; 50BE5D762850F4FB00156FC6 /* SsArtistParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AB92BD26660FE000DCE45C /* SsArtistParserTest.swift */; }; 50BE5D772850F4FB00156FC6 /* SsSongExample2ParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AB92E126675B7000DCE45C /* SsSongExample2ParserTest.swift */; }; + BB0A0004BB0A00040085CBB3 /* SsSongOpenSubsonicTagsParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0A0003BB0A00030085CBB3 /* SsSongOpenSubsonicTagsParserTest.swift */; }; 50BE5D782850F4FB00156FC6 /* SsAlbumMissingArtistsIdParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50ED2B9427BB938500331BF7 /* SsAlbumMissingArtistsIdParserTest.swift */; }; 50BE5D792850F4FB00156FC6 /* SsDirectoriesExample2ParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AB92E3266760C100DCE45C /* SsDirectoriesExample2ParserTest.swift */; }; 50BE5D7A2850F50700156FC6 /* podcasts.xml in Resources */ = {isa = PBXBuildFile; fileRef = 501A7D1F26808A9D0055A51B /* podcasts.xml */; }; @@ -899,6 +901,7 @@ 50AB92C326661F2600DCE45C /* SsAlbumParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SsAlbumParserTest.swift; sourceTree = ""; }; 50AB92C526661F5800DCE45C /* artist_example_1.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = artist_example_1.xml; sourceTree = ""; }; 50AB92C726662DAC00DCE45C /* album_example_2.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = album_example_2.xml; sourceTree = ""; }; + BB0A0001BB0A00010085CBB3 /* album_opensubsonic_tags_example_1.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = album_opensubsonic_tags_example_1.xml; sourceTree = ""; }; 50AB92C92666BAC300DCE45C /* SsPlaylistsParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SsPlaylistsParserTest.swift; sourceTree = ""; }; 50AB92CB2666BAED00DCE45C /* playlists_example_1.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = playlists_example_1.xml; sourceTree = ""; }; 50AB92CD2666BC0B00DCE45C /* playlist_example_1.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = playlist_example_1.xml; sourceTree = ""; }; @@ -912,6 +915,7 @@ 50AB92DD2666C54500DCE45C /* SsIndexesParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SsIndexesParserTest.swift; sourceTree = ""; }; 50AB92DF2667564800DCE45C /* AbstractSsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbstractSsTest.swift; sourceTree = ""; }; 50AB92E126675B7000DCE45C /* SsSongExample2ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SsSongExample2ParserTest.swift; sourceTree = ""; }; + BB0A0003BB0A00030085CBB3 /* SsSongOpenSubsonicTagsParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SsSongOpenSubsonicTagsParserTest.swift; sourceTree = ""; }; 50AB92E3266760C100DCE45C /* SsDirectoriesExample2ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SsDirectoriesExample2ParserTest.swift; sourceTree = ""; }; 50AB92E52667615400DCE45C /* AbstractAmpacheTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbstractAmpacheTest.swift; sourceTree = ""; }; 50AC4E5128D909720091FF33 /* EventLogSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventLogSettingsView.swift; sourceTree = ""; }; @@ -2024,6 +2028,7 @@ 50ED2B9427BB938500331BF7 /* SsAlbumMissingArtistsIdParserTest.swift */, 50AB92C12666127100DCE45C /* SsSongExample1ParserTest.swift */, 50AB92E126675B7000DCE45C /* SsSongExample2ParserTest.swift */, + BB0A0003BB0A00030085CBB3 /* SsSongOpenSubsonicTagsParserTest.swift */, 50AB92C92666BAC300DCE45C /* SsPlaylistsParserTest.swift */, 50AB92CF2666BC2000DCE45C /* SsPlaylistSongsParserTest.swift */, 50AB92D72666C2D900DCE45C /* SsMusicFolderParserTest.swift */, @@ -2051,6 +2056,7 @@ 5067E371278C1DC900807A78 /* album_multidisc_example_1.xml */, 50AB92BF2666126300DCE45C /* album_example_1.xml */, 50AB92C726662DAC00DCE45C /* album_example_2.xml */, + BB0A0001BB0A00010085CBB3 /* album_opensubsonic_tags_example_1.xml */, 50ED2B9227BB932700331BF7 /* album_missing_artistId.xml */, 50AB92CB2666BAED00DCE45C /* playlists_example_1.xml */, 50AB92CD2666BC0B00DCE45C /* playlist_example_1.xml */, @@ -2389,6 +2395,7 @@ files = ( 50BE5D632850F4F600156FC6 /* album_example_2.xml in Resources */, 50AE79BA2C1F69A00085CBB3 /* OpenSubsonicExtensions_example_1.xml in Resources */, + BB0A0002BB0A00020085CBB3 /* album_opensubsonic_tags_example_1.xml in Resources */, 50BE5D7D2850F50700156FC6 /* catalogs.xml in Resources */, 50BE5D7C2850F50700156FC6 /* podcast_episodes.xml in Resources */, 50AE79B42C1F64F50085CBB3 /* getLyricsBySongId_example_2.xml in Resources */, @@ -2905,6 +2912,7 @@ 50BE5D4A2850F4C900156FC6 /* SongTest.swift in Sources */, 50CEC6492D1F41A400D0E696 /* RadiosExampleParserTest.swift in Sources */, 50BE5D772850F4FB00156FC6 /* SsSongExample2ParserTest.swift in Sources */, + BB0A0004BB0A00040085CBB3 /* SsSongOpenSubsonicTagsParserTest.swift in Sources */, 50BE5D762850F4FB00156FC6 /* SsArtistParserTest.swift in Sources */, 50BE5D682850F4FB00156FC6 /* SsPodcastEpisodesParserTest.swift in Sources */, 50BE5D692850F4FB00156FC6 /* SsXmlParserTest.swift in Sources */, diff --git a/Amperfy/SwiftUI/SongTags/SongTagsFilterView.swift b/Amperfy/SwiftUI/SongTags/SongTagsFilterView.swift index 7fd94f93..b09e8d99 100644 --- a/Amperfy/SwiftUI/SongTags/SongTagsFilterView.swift +++ b/Amperfy/SwiftUI/SongTags/SongTagsFilterView.swift @@ -25,8 +25,10 @@ import SwiftUI // MARK: - SongTagsFilterView struct SongTagsFilterView: View { - @ObservedObject var store: TagVisibilityStore - @Environment(\.dismiss) private var dismiss + @ObservedObject + var store: TagVisibilityStore + @Environment(\.dismiss) + private var dismiss var body: some View { List { diff --git a/Amperfy/SwiftUI/SongTags/SongTagsView.swift b/Amperfy/SwiftUI/SongTags/SongTagsView.swift index a4ec5836..a793a4df 100644 --- a/Amperfy/SwiftUI/SongTags/SongTagsView.swift +++ b/Amperfy/SwiftUI/SongTags/SongTagsView.swift @@ -26,37 +26,37 @@ import SwiftUI // MARK: - SongTagKey enum SongTagKey: String, CaseIterable { - case title = "title" - case artists = "artists" - case albumArtists = "albumArtists" - case album = "album" - case genre = "genre" - case genres = "genres" - case trackNumber = "trackNumber" - case discNumber = "discNumber" - case year = "year" - case duration = "duration" - case bpm = "bpm" - case bitrate = "bitrate" - case bitDepth = "bitDepth" - case samplingRate = "samplingRate" - case channelCount = "channelCount" - case contentType = "contentType" - case fileSize = "fileSize" - case dateAdded = "dateAdded" - case rating = "rating" - case favorite = "favorite" - case explicitStatus = "explicitStatus" - case comment = "comment" - case sortName = "sortName" - case musicBrainzId = "musicBrainzId" - case isrc = "isrc" - case moods = "moods" - case groupings = "groupings" - case contributors = "contributors" - case displayComposer = "displayComposer" - case replayGainTrack = "replayGainTrack" - case replayGainAlbum = "replayGainAlbum" + case title + case artists + case albumArtists + case album + case genre + case genres + case trackNumber + case discNumber + case year + case duration + case bpm + case bitrate + case bitDepth + case samplingRate + case channelCount + case contentType + case fileSize + case dateAdded + case rating + case favorite + case explicitStatus + case comment + case sortName + case musicBrainzId + case isrc + case moods + case groupings + case contributors + case displayComposer + case replayGainTrack + case replayGainAlbum var displayName: String { switch self { @@ -195,13 +195,14 @@ enum SongTagKey: String, CaseIterable { class TagVisibilityStore: ObservableObject { static let udKey = "songTagVisibility" - @Published var hiddenKeys: Set + @Published + var hiddenKeys: Set init() { if let saved = UserDefaults.standard.stringArray(forKey: Self.udKey) { - hiddenKeys = Set(saved) + self.hiddenKeys = Set(saved) } else { - hiddenKeys = [] + self.hiddenKeys = [] } } @@ -228,8 +229,10 @@ class TagVisibilityStore: ObservableObject { struct SongTagsView: View { let song: Song - @StateObject private var store = TagVisibilityStore() - @State private var showFilter = false + @StateObject + private var store = TagVisibilityStore() + @State + private var showFilter = false var body: some View { List { diff --git a/AmperfyKit/AmperfyKit.swift b/AmperfyKit/AmperfyKit.swift index b529c010..059ea52b 100755 --- a/AmperfyKit/AmperfyKit.swift +++ b/AmperfyKit/AmperfyKit.swift @@ -159,7 +159,7 @@ public class AmperKit { backendAudioPlayer.triggerReinsertPlayableCB = curPlayer.play curPlayer.autoInstantMixCB = { [weak self] song in guard let self, let accountInfo = song.account?.info else { return [] } - return try await self.getMeta(accountInfo).librarySyncer.requestSimilarSongs( + return try await getMeta(accountInfo).librarySyncer.requestSimilarSongs( song: song, count: 99 ) diff --git a/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift b/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift index 3195dc87..3e46fc29 100644 --- a/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift +++ b/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift @@ -209,22 +209,19 @@ class SsSongParserDelegate: SsPlayableParserDelegate { // Each OpenSubsonic song artist is its own element // (the element name is plural). Collect while inside a song. if elementName == "artists", songBuffer != nil, let name = attributeDict["name"], - !name.isEmpty - { + !name.isEmpty { collectedArtistNames.append(name) } // Album artists: if elementName == "albumArtists", songBuffer != nil, let name = attributeDict["name"], - !name.isEmpty - { + !name.isEmpty { collectedAlbumArtistNames.append(name) } // Multi-genre: if elementName == "genres", songBuffer != nil, let name = attributeDict["name"], - !name.isEmpty - { + !name.isEmpty { collectedGenreNames.append(name) } @@ -236,17 +233,15 @@ class SsSongParserDelegate: SsPlayableParserDelegate { } // Inner element inside a block if elementName == "artist", isInsideContributor, let name = attributeDict["name"], - !name.isEmpty - { + !name.isEmpty { collectedContributors.append( (role: currentContributorRole, subRole: currentContributorSubRole, name: name) ) } // Text-content child elements: , , - if (elementName == "isrc" || elementName == "moods" || elementName == "groupings"), - songBuffer != nil - { + if elementName == "isrc" || elementName == "moods" || elementName == "groupings", + songBuffer != nil { currentTextElementName = elementName currentTextBuffer = "" } @@ -292,8 +287,7 @@ class SsSongParserDelegate: SsPlayableParserDelegate { } if elementName == "song" || elementName == "entry" || elementName == "child" || elementName == - "episode", songBuffer != nil - { + "episode", songBuffer != nil { // Multi-artist display string if !collectedArtistNames.isEmpty { songBuffer?.artistsString = collectedArtistNames.joined(separator: ", ") diff --git a/AmperfyKit/Download/DownloadManager.swift b/AmperfyKit/Download/DownloadManager.swift index e7cc8f0d..e695b73f 100644 --- a/AmperfyKit/Download/DownloadManager.swift +++ b/AmperfyKit/Download/DownloadManager.swift @@ -155,7 +155,11 @@ actor DownloadManager: NSObject, DownloadManageable { object: nil ) Task { - await _initialize(urlSession: urlSession, isCheckForCachedNeeded: isCheckForCachedNeeded, validationCB: validationCB) + await _initialize( + urlSession: urlSession, + isCheckForCachedNeeded: isCheckForCachedNeeded, + validationCB: validationCB + ) } } @@ -164,7 +168,11 @@ actor DownloadManager: NSObject, DownloadManageable { _urlSessionIdentifier } - private func _initialize(urlSession: URLSession, isCheckForCachedNeeded: Bool, validationCB: PreDownloadIsValidCB?) { + private func _initialize( + urlSession: URLSession, + isCheckForCachedNeeded: Bool, + validationCB: PreDownloadIsValidCB? + ) { self.urlSession = urlSession let ident = self.urlSession?.configuration.identifier preDownloadIsValidCheck = validationCB diff --git a/AmperfyKit/Player/AudioPlayer.swift b/AmperfyKit/Player/AudioPlayer.swift index ce1df459..ec78ce12 100644 --- a/AmperfyKit/Player/AudioPlayer.swift +++ b/AmperfyKit/Player/AudioPlayer.swift @@ -218,11 +218,11 @@ public class AudioPlayer: NSObject, BackendAudioPlayerNotifiable { guard let self else { return } do { let similarSongs = try await cb(song) - guard !similarSongs.isEmpty else { self.stop(); return } - self.queueHandler.appendContextQueue(playables: similarSongs) - self.play(playerIndex: PlayerIndex(queueType: .next, index: 0)) + guard !similarSongs.isEmpty else { stop(); return } + queueHandler.appendContextQueue(playables: similarSongs) + play(playerIndex: PlayerIndex(queueType: .next, index: 0)) } catch { - self.stop() + stop() } } } else { diff --git a/AmperfyKitTests/Cases/API/Subsonic/Samples/album_opensubsonic_tags_example_1.xml b/AmperfyKitTests/Cases/API/Subsonic/Samples/album_opensubsonic_tags_example_1.xml new file mode 100644 index 00000000..3207d1f7 --- /dev/null +++ b/AmperfyKitTests/Cases/API/Subsonic/Samples/album_opensubsonic_tags_example_1.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + USRC17607839 + Happy + Energetic + Group A + + + + + + + + + + + + + + + + + + + diff --git a/AmperfyKitTests/Cases/API/Subsonic/SsSongOpenSubsonicTagsParserTest.swift b/AmperfyKitTests/Cases/API/Subsonic/SsSongOpenSubsonicTagsParserTest.swift new file mode 100644 index 00000000..5b24ee33 --- /dev/null +++ b/AmperfyKitTests/Cases/API/Subsonic/SsSongOpenSubsonicTagsParserTest.swift @@ -0,0 +1,137 @@ +// +// SsSongOpenSubsonicTagsParserTest.swift +// AmperfyKitTests +// +// Created by Amperfy on 26.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +@testable import AmperfyKit +import XCTest + +/// Tests that every new OpenSubsonic field added in v50 is correctly parsed +/// by SsSongParserDelegate and stored on the Song entity. +/// +/// Fixture: album_opensubsonic_tags_example_1.xml (3 songs) +/// ost1 – all new simple attributes + all new child element types +/// ost2 – contributor subRole + same-role grouping +/// ost3 – minimal song (no new fields); verifies state resets between songs +class SsSongOpenSubsonicTagsParserTest: AbstractSsParserTest { + override func setUp() async throws { + try await super.setUp() + xmlData = getTestFileData(name: "album_opensubsonic_tags_example_1") + } + + override func createParserDelegate() { + let prefetch = library.getElements( + account: account, + prefetchIDs: ssIdParserDelegate.prefetchIDs + ) + ssParserDelegate = SsSongParserDelegate( + performanceMonitor: MOCK_PerformanceMonitor(), + prefetch: prefetch, + account: account, + library: library, + parseNotifier: nil + ) + } + + override func checkCorrectParsing() { + let songs = library.getSongs(for: account).sorted { $0.id < $1.id } + XCTAssertEqual(songs.count, 3) + + // MARK: ost1 – full OpenSubsonic tags + + let song1 = songs[0] + XCTAssertEqual(song1.id, "ost1") + + // Simple numeric attributes + XCTAssertEqual(song1.bpm, 120) + XCTAssertEqual(song1.bitDepth, 24) + XCTAssertEqual(song1.samplingRate, 44100) + XCTAssertEqual(song1.channelCount, 2) + + // Simple string attributes + XCTAssertEqual(song1.comment, "Test comment") + XCTAssertEqual(song1.sortName, "Full Tag Sort") + XCTAssertEqual(song1.musicBrainzId, "550e8400-e29b-41d4-a716-446655440000") + XCTAssertEqual(song1.displayAlbumArtist, "Album Artist Display") + XCTAssertEqual(song1.displayComposer, "John Smith") + XCTAssertEqual(song1.explicitStatus, "explicit") + + // children must override the displayArtist attribute + XCTAssertEqual(song1.artistsString, "Artist One, Artist Two") + + // children + XCTAssertEqual(song1.albumArtistsString, "Album Artist One") + + // children (multi-genre list, separate from primary genre entity) + XCTAssertEqual(song1.genresList, "Rock, Metal") + + // Text-content child elements + XCTAssertEqual(song1.isrcList, "USRC17607839") + XCTAssertEqual(song1.moodsList, "Happy, Energetic") + XCTAssertEqual(song1.groupingsList, "Group A") + + // Contributors: two distinct roles → two lines + XCTAssertEqual(song1.contributorsString, "Composer: John Smith\nLyricist: Jane Doe") + + // MARK: ost2 – contributor subRole + same-role grouping + + let song2 = songs[1] + XCTAssertEqual(song2.id, "ost2") + + // Both contributors share role="composer" subRole="orchestral" → one grouped line + XCTAssertEqual( + song2.contributorsString, + "Composer (orchestral): Composer A, Composer B" + ) + + // No other new fields on song2 + XCTAssertEqual(song2.bpm, 0) + XCTAssertNil(song2.comment) + XCTAssertNil(song2.albumArtistsString) + XCTAssertNil(song2.genresList) + XCTAssertNil(song2.isrcList) + XCTAssertNil(song2.moodsList) + XCTAssertNil(song2.groupingsList) + + // MARK: ost3 – minimal song, verifies complete state reset between songs + + let song3 = songs[2] + XCTAssertEqual(song3.id, "ost3") + + XCTAssertEqual(song3.bpm, 0) + XCTAssertEqual(song3.bitDepth, 0) + XCTAssertEqual(song3.samplingRate, 0) + XCTAssertEqual(song3.channelCount, 0) + XCTAssertNil(song3.comment) + XCTAssertNil(song3.sortName) + XCTAssertNil(song3.musicBrainzId) + XCTAssertNil(song3.displayAlbumArtist) + XCTAssertNil(song3.displayComposer) + XCTAssertNil(song3.explicitStatus) + XCTAssertNil(song3.albumArtistsString) + XCTAssertNil(song3.genresList) + XCTAssertNil(song3.isrcList) + XCTAssertNil(song3.moodsList) + XCTAssertNil(song3.groupingsList) + XCTAssertNil(song3.contributorsString) + + // artistsString comes from the artist attribute fallback (no children) + XCTAssertEqual(song3.artistsString, "Simple Artist") + } +} From 27ddd475199618ee8eb8869c50a801517c3f4496 Mon Sep 17 00:00:00 2001 From: brandon Date: Mon, 1 Jun 2026 19:17:21 -0400 Subject: [PATCH 3/5] addressing various pr comments --- .../ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion b/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion index 82cb0268..6ecdacf4 100644 --- a/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion +++ b/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Amperfy v50.xcdatamodel + Amperfy v51.xcdatamodel From 04c8e933f966cbc8bbe615a8b6f70c212c8199b0 Mon Sep 17 00:00:00 2001 From: brandon Date: Mon, 1 Jun 2026 19:24:23 -0400 Subject: [PATCH 4/5] addressing pr comments --- Amperfy.xcodeproj/project.pbxproj | 10 +- Amperfy/SwiftUI/SongTags/SongTagsView.swift | 15 +- .../Api/Ampache/SongParserDelegate.swift | 47 ++- .../Api/Subsonic/SsIDsParserDelegate.swift | 15 + .../Api/Subsonic/SsSongParserDelegate.swift | 103 +++-- AmperfyKit/Storage/EntityWrappers/Song.swift | 63 +++- AmperfyKit/Storage/LibraryStorage.swift | 8 +- .../Amperfy v51.xcdatamodel/contents | 356 ++++++++++++++++++ .../ArtistMO+CoreDataProperties.swift | 44 +++ .../GenreMO+CoreDataProperties.swift | 22 ++ .../Migration/CoreDataMigrationVersion.swift | 4 + .../SongMO+CoreDataProperties.swift | 72 +++- AmperfyKit/Storage/Settings.swift | 6 + 13 files changed, 684 insertions(+), 81 deletions(-) create mode 100644 AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/Amperfy v51.xcdatamodel/contents diff --git a/Amperfy.xcodeproj/project.pbxproj b/Amperfy.xcodeproj/project.pbxproj index c6ad630b..fa57087a 100644 --- a/Amperfy.xcodeproj/project.pbxproj +++ b/Amperfy.xcodeproj/project.pbxproj @@ -767,6 +767,8 @@ 506890E528CF3696009722B0 /* Amperfy v27.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v27.xcdatamodel"; sourceTree = ""; }; 5068D37F26A85C2D0006710D /* DownloadError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadError.swift; sourceTree = ""; }; 506B3A3823B4539D00E31F21 /* Amperfy v2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v2.xcdatamodel"; sourceTree = ""; }; + 7ADDC2F0E741496CAAE5136A /* Amperfy v51.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v51.xcdatamodel"; sourceTree = ""; }; + 01CBCC57D73D42BDBCFFDD90 /* Amperfy v50.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v50.xcdatamodel"; sourceTree = ""; }; 506C314D2EE6D2100011A2C3 /* Amperfy v49.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v49.xcdatamodel"; sourceTree = ""; }; 5070ED2C2D46979A00EB2972 /* Amperfy v42.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v42.xcdatamodel"; sourceTree = ""; }; 507148AB2B767FE200557904 /* ContextQueuePrevSectionHeader.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ContextQueuePrevSectionHeader.xib; sourceTree = ""; }; @@ -2992,7 +2994,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = 8N5BD6J6TY; + DEVELOPMENT_TEAM = U3R6D65S8W; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3060,7 +3062,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 8N5BD6J6TY; + DEVELOPMENT_TEAM = U3R6D65S8W; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3542,6 +3544,8 @@ 500BB49521CAAA2700D367CF /* Amperfy.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 7ADDC2F0E741496CAAE5136A /* Amperfy v51.xcdatamodel */, + 01CBCC57D73D42BDBCFFDD90 /* Amperfy v50.xcdatamodel */, 506C314D2EE6D2100011A2C3 /* Amperfy v49.xcdatamodel */, 5084F70C2ED9D87500D8D3DA /* Amperfy v48.xcdatamodel */, 507C9AD82E29905D001589F8 /* Amperfy v47.xcdatamodel */, @@ -3592,7 +3596,7 @@ 506B3A3823B4539D00E31F21 /* Amperfy v2.xcdatamodel */, 500BB49621CAAA2700D367CF /* Amperfy.xcdatamodel */, ); - currentVersion = 506C314D2EE6D2100011A2C3 /* Amperfy v49.xcdatamodel */; + currentVersion = 7ADDC2F0E741496CAAE5136A /* Amperfy v51.xcdatamodel */; path = Amperfy.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Amperfy/SwiftUI/SongTags/SongTagsView.swift b/Amperfy/SwiftUI/SongTags/SongTagsView.swift index a793a4df..e51a7176 100644 --- a/Amperfy/SwiftUI/SongTags/SongTagsView.swift +++ b/Amperfy/SwiftUI/SongTags/SongTagsView.swift @@ -193,17 +193,13 @@ enum SongTagKey: String, CaseIterable { // MARK: - TagVisibilityStore class TagVisibilityStore: ObservableObject { - static let udKey = "songTagVisibility" - @Published var hiddenKeys: Set init() { - if let saved = UserDefaults.standard.stringArray(forKey: Self.udKey) { - self.hiddenKeys = Set(saved) - } else { - self.hiddenKeys = [] - } + self.hiddenKeys = Set( + (UIApplication.shared.delegate as! AppDelegate).storage.settings.user.hiddenSongTagKeys + ) } func setVisible(_ key: SongTagKey, visible: Bool) { @@ -221,7 +217,10 @@ class TagVisibilityStore: ObservableObject { } private func persist() { - UserDefaults.standard.set(Array(hiddenKeys), forKey: Self.udKey) + let appDelegate = UIApplication.shared.delegate as! AppDelegate + var userSettings = appDelegate.storage.settings.user + userSettings.hiddenSongTagKeys = Array(hiddenKeys) + appDelegate.storage.settings.user = userSettings } } diff --git a/AmperfyKit/Api/Ampache/SongParserDelegate.swift b/AmperfyKit/Api/Ampache/SongParserDelegate.swift index 52b0809c..c84abd71 100644 --- a/AmperfyKit/Api/Ampache/SongParserDelegate.swift +++ b/AmperfyKit/Api/Ampache/SongParserDelegate.swift @@ -30,6 +30,8 @@ class SongParserDelegate: PlayableParserDelegate { var artistIdToCreate: String? var albumIdToCreate: String? var genreIdToCreate: String? + var albumArtistIdToCreate: String? + var collectedMultiGenres = [Genre]() var guessedArtist: Artist? var guessedAlbum: Album? @@ -71,6 +73,8 @@ class SongParserDelegate: PlayableParserDelegate { guessedGenre = nil } playableBuffer = songBuffer + collectedMultiGenres = [] + albumArtistIdToCreate = nil case "artist": guard let song = songBuffer, let artistId = attributeDict["id"] else { return } if let guessedArtist, guessedArtist.id == artistId { @@ -80,6 +84,14 @@ class SongParserDelegate: PlayableParserDelegate { } else { artistIdToCreate = artistId } + case "albumartist": + guard songBuffer != nil, let artistId = attributeDict["id"] else { return } + if let prefetchedArtist = prefetch.prefetchedArtistDict[artistId] { + collectedMultiGenres.isEmpty ? () : () + songBuffer?.albumArtists = [prefetchedArtist] + } else { + albumArtistIdToCreate = artistId + } case "album": guard let song = songBuffer, let albumId = attributeDict["id"] else { return } if let guessedAlbum, guessedAlbum.id == albumId { @@ -91,10 +103,13 @@ class SongParserDelegate: PlayableParserDelegate { } case "genre": guard let song = songBuffer, let genreId = attributeDict["id"] else { return } + // Ampache can have multiple elements. First one sets song.genre; all go to multiGenres. if let guessedGenre = guessedGenre, guessedGenre.id == genreId { - song.genre = guessedGenre + if song.genre == nil { song.genre = guessedGenre } + collectedMultiGenres.append(guessedGenre) } else if let prefetchedGenre = prefetch.prefetchedGenreDict[genreId] { - song.genre = prefetchedGenre + if song.genre == nil { song.genre = prefetchedGenre } + collectedMultiGenres.append(prefetchedGenre) } else { genreIdToCreate = genreId } @@ -120,6 +135,18 @@ class SongParserDelegate: PlayableParserDelegate { songBuffer?.artist = artist artistIdToCreate = nil } + case "albumartist": + if let artistId = albumArtistIdToCreate { + os_log( + "AlbumArtist <%s> with id %s has been created", log: log, type: .error, buffer, artistId + ) + let artist = library.createArtist(account: account) + prefetch.prefetchedArtistDict[artistId] = artist + artist.id = artistId + artist.name = buffer + songBuffer?.albumArtists = [artist] + albumArtistIdToCreate = nil + } case "album": if let albumId = albumIdToCreate { os_log("Album <%s> with id %s has been created", log: log, type: .error, buffer, albumId) @@ -137,10 +164,24 @@ class SongParserDelegate: PlayableParserDelegate { prefetch.prefetchedGenreDict[genreId] = genre genre.id = genreId genre.name = buffer - songBuffer?.genre = genre + if songBuffer?.genre == nil { songBuffer?.genre = genre } + collectedMultiGenres.append(genre) genreIdToCreate = nil } + case "rate": + if let val = Int(buffer) { songBuffer?.samplingRate = val } + case "channels": + if let val = Int(buffer) { songBuffer?.channelCount = val } + case "composer": + if !buffer.isEmpty { songBuffer?.displayComposer = buffer } + case "comment": + if !buffer.isEmpty { songBuffer?.comment = buffer } + case "mbid": + if !buffer.isEmpty { songBuffer?.musicBrainzId = buffer } case "song": + if !collectedMultiGenres.isEmpty { + songBuffer?.multiGenres = collectedMultiGenres + } parsedCount += 1 parseNotifier?.notifyParsedObject(ofType: .song) songBuffer?.rating = rating diff --git a/AmperfyKit/Api/Subsonic/SsIDsParserDelegate.swift b/AmperfyKit/Api/Subsonic/SsIDsParserDelegate.swift index 3c52041d..844f8d1e 100644 --- a/AmperfyKit/Api/Subsonic/SsIDsParserDelegate.swift +++ b/AmperfyKit/Api/Subsonic/SsIDsParserDelegate.swift @@ -87,6 +87,16 @@ class SsIDsParserDelegate: SsNotifiableXmlParser { prefetchIDs.localArtistNames.insert(artistName) } + // OpenSubsonic multi-artist: child elements inside a song + if elementName == "artists", let artistId = attributeDict["id"] { + prefetchIDs.artistIDs.insert(artistId) + } + + // OpenSubsonic multi-albumArtist: child elements inside a song + if elementName == "albumArtists", let artistId = attributeDict["id"] { + prefetchIDs.artistIDs.insert(artistId) + } + if let albumId = attributeDict["albumId"] { prefetchIDs.albumIDs.insert(albumId) } @@ -100,6 +110,11 @@ class SsIDsParserDelegate: SsNotifiableXmlParser { // ignore podcast episode genre prefetchIDs.genreNames.insert(genreName) } + + // OpenSubsonic multi-genre: child elements inside a song + if elementName == "genres", let genreName = attributeDict["name"] { + prefetchIDs.genreNames.insert(genreName) + } } override func parser( diff --git a/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift b/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift index 3e46fc29..79cec433 100644 --- a/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift +++ b/AmperfyKit/Api/Subsonic/SsSongParserDelegate.swift @@ -31,11 +31,11 @@ class SsSongParserDelegate: SsPlayableParserDelegate { var guessedAlbum: Album? var guessedGenre: Genre? // Accumulates individual artist names from OpenSubsonic child elements. - var collectedArtistNames = [String]() - // Accumulates album artist names from OpenSubsonic child elements. - var collectedAlbumArtistNames = [String]() - // Accumulates genre names from OpenSubsonic child elements. - var collectedGenreNames = [String]() + var collectedMultiArtists = [Artist]() + // Accumulates album artist entities from OpenSubsonic child elements. + var collectedAlbumArtists = [Artist]() + // Accumulates genre entities from OpenSubsonic child elements. + var collectedMultiGenres = [Genre]() // Accumulates contributors from OpenSubsonic . var collectedContributors = [(role: String, subRole: String, name: String)]() var currentContributorRole = "" @@ -112,16 +112,9 @@ class SsSongParserDelegate: SsPlayableParserDelegate { } } - // Store the fallback display string now; overwritten at element close if - // OpenSubsonic child elements are present. - if let displayArtist = attributeDict["displayArtist"], !displayArtist.isEmpty { - songBuffer?.artistsString = displayArtist - } else if let artistDisplayString = attributeDict["artist"] { - songBuffer?.artistsString = artistDisplayString - } - collectedArtistNames = [] - collectedAlbumArtistNames = [] - collectedGenreNames = [] + collectedMultiArtists = [] + collectedAlbumArtists = [] + collectedMultiGenres = [] collectedContributors = [] collectedISRCs = [] collectedMoods = [] @@ -131,7 +124,7 @@ class SsSongParserDelegate: SsPlayableParserDelegate { currentTextBuffer = "" // OpenSubsonic simple-attribute fields - if let bpmStr = attributeDict["bpm"], let bpmVal = Int16(bpmStr) { + if let bpmStr = attributeDict["bpm"], let bpmVal = Int(bpmStr) { songBuffer?.bpm = bpmVal } if let commentStr = attributeDict["comment"] { @@ -153,13 +146,13 @@ class SsSongParserDelegate: SsPlayableParserDelegate { if let explicitStatusStr = attributeDict["explicitStatus"] { songBuffer?.explicitStatus = explicitStatusStr.isEmpty ? nil : explicitStatusStr } - if let channelStr = attributeDict["channelCount"], let channelVal = Int16(channelStr) { + if let channelStr = attributeDict["channelCount"], let channelVal = Int(channelStr) { songBuffer?.channelCount = channelVal } - if let srStr = attributeDict["samplingRate"], let srVal = Int32(srStr) { + if let srStr = attributeDict["samplingRate"], let srVal = Int(srStr) { songBuffer?.samplingRate = srVal } - if let bdStr = attributeDict["bitDepth"], let bdVal = Int16(bdStr) { + if let bdStr = attributeDict["bitDepth"], let bdVal = Int(bdStr) { songBuffer?.bitDepth = bdVal } @@ -206,23 +199,51 @@ class SsSongParserDelegate: SsPlayableParserDelegate { } } - // Each OpenSubsonic song artist is its own element - // (the element name is plural). Collect while inside a song. - if elementName == "artists", songBuffer != nil, let name = attributeDict["name"], - !name.isEmpty { - collectedArtistNames.append(name) + // Each OpenSubsonic song artist is its own element. + // Look up or create the Artist entity and collect it. + if elementName == "artists", songBuffer != nil { + if let artistId = attributeDict["id"] { + if let prefetchedArtist = prefetch.prefetchedArtistDict[artistId] { + collectedMultiArtists.append(prefetchedArtist) + } else if let artistName = attributeDict["name"] { + let artist = library.createArtist(account: account) + prefetch.prefetchedArtistDict[artistId] = artist + artist.id = artistId + artist.name = artistName + os_log("Multi-artist <%s> id %s created", log: log, type: .error, artistName, artistId) + collectedMultiArtists.append(artist) + } + } } // Album artists: - if elementName == "albumArtists", songBuffer != nil, let name = attributeDict["name"], - !name.isEmpty { - collectedAlbumArtistNames.append(name) + if elementName == "albumArtists", songBuffer != nil { + if let artistId = attributeDict["id"] { + if let prefetchedArtist = prefetch.prefetchedArtistDict[artistId] { + collectedAlbumArtists.append(prefetchedArtist) + } else if let artistName = attributeDict["name"] { + let artist = library.createArtist(account: account) + prefetch.prefetchedArtistDict[artistId] = artist + artist.id = artistId + artist.name = artistName + os_log("Album artist <%s> id %s created", log: log, type: .error, artistName, artistId) + collectedAlbumArtists.append(artist) + } + } } - // Multi-genre: + // Multi-genre: (no ID in OpenSubsonic, keyed by name) if elementName == "genres", songBuffer != nil, let name = attributeDict["name"], !name.isEmpty { - collectedGenreNames.append(name) + if let prefetchedGenre = prefetch.prefetchedGenreDict[name] { + collectedMultiGenres.append(prefetchedGenre) + } else { + let genre = library.createGenre(account: account) + prefetch.prefetchedGenreDict[name] = genre + genre.name = name + os_log("Multi-genre <%s> created", log: log, type: .error, name) + collectedMultiGenres.append(genre) + } } // Contributors: @@ -288,19 +309,19 @@ class SsSongParserDelegate: SsPlayableParserDelegate { if elementName == "song" || elementName == "entry" || elementName == "child" || elementName == "episode", songBuffer != nil { - // Multi-artist display string - if !collectedArtistNames.isEmpty { - songBuffer?.artistsString = collectedArtistNames.joined(separator: ", ") + // Multi-artist entities + if !collectedMultiArtists.isEmpty { + songBuffer?.multiArtists = collectedMultiArtists } - // Album artists - if !collectedAlbumArtistNames.isEmpty { - songBuffer?.albumArtistsString = collectedAlbumArtistNames.joined(separator: ", ") + // Album artist entities + if !collectedAlbumArtists.isEmpty { + songBuffer?.albumArtists = collectedAlbumArtists } - // Multi-genre list - if !collectedGenreNames.isEmpty { - songBuffer?.genresList = collectedGenreNames.joined(separator: ", ") + // Multi-genre entities + if !collectedMultiGenres.isEmpty { + songBuffer?.multiGenres = collectedMultiGenres } // ISRC list @@ -339,9 +360,9 @@ class SsSongParserDelegate: SsPlayableParserDelegate { songBuffer?.contributorsString = lines.joined(separator: "\n") } - collectedArtistNames = [] - collectedAlbumArtistNames = [] - collectedGenreNames = [] + collectedMultiArtists = [] + collectedAlbumArtists = [] + collectedMultiGenres = [] collectedContributors = [] collectedISRCs = [] collectedMoods = [] diff --git a/AmperfyKit/Storage/EntityWrappers/Song.swift b/AmperfyKit/Storage/EntityWrappers/Song.swift index 22e8022b..42d57c68 100644 --- a/AmperfyKit/Storage/EntityWrappers/Song.swift +++ b/AmperfyKit/Storage/EntityWrappers/Song.swift @@ -107,33 +107,59 @@ public class Song: AbstractPlayable, Identifyable { } public var artistsString: String? { - get { managedObject.artistsString } - set { managedObject.artistsString = newValue } + if multiArtists.isEmpty { return artist?.name } + return multiArtists.map { $0.name }.joined(separator: ", ") } public var albumArtistsString: String? { - get { managedObject.albumArtistsString } - set { managedObject.albumArtistsString = newValue } + albumArtists.isEmpty ? nil : albumArtists.map { $0.name }.joined(separator: ", ") } - public var bpm: Int16 { - get { managedObject.bpm } - set { managedObject.bpm = newValue } + public var multiArtists: [Artist] { + get { + (managedObject.multiArtists?.array as? [ArtistMO])?.map { Artist(managedObject: $0) } ?? [] + } + set { + managedObject.multiArtists = NSOrderedSet(array: newValue.map { $0.managedObject }) + } + } + + public var albumArtists: [Artist] { + get { + (managedObject.albumArtists?.array as? [ArtistMO])?.map { Artist(managedObject: $0) } ?? [] + } + set { + managedObject.albumArtists = NSOrderedSet(array: newValue.map { $0.managedObject }) + } + } + + public var multiGenres: [Genre] { + get { + (managedObject.multiGenres?.array as? [GenreMO])?.map { Genre(managedObject: $0) } ?? [] + } + set { + managedObject.multiGenres = NSOrderedSet(array: newValue.map { $0.managedObject }) + } + } + + public var bpm: Int { + get { Int(managedObject.bpm) } + set { managedObject.bpm = Int16(newValue) } } - public var bitDepth: Int16 { - get { managedObject.bitDepth } - set { managedObject.bitDepth = newValue } + public var bitDepth: Int { + get { Int(managedObject.bitDepth) } + set { managedObject.bitDepth = Int16(newValue) } } - public var channelCount: Int16 { - get { managedObject.channelCount } - set { managedObject.channelCount = newValue } + public var channelCount: Int { + get { Int(managedObject.channelCount) } + set { managedObject.channelCount = Int16(newValue) } } - public var samplingRate: Int32 { - get { managedObject.samplingRate } - set { managedObject.samplingRate = newValue } + public var samplingRate: Int { + get { Int(managedObject.samplingRate) } + set { managedObject.samplingRate = Int32(newValue) } } public var comment: String? { @@ -157,8 +183,7 @@ public class Song: AbstractPlayable, Identifyable { } public var genresList: String? { - get { managedObject.genresList } - set { managedObject.genresList = newValue } + multiGenres.isEmpty ? nil : multiGenres.map { $0.name }.joined(separator: ", ") } public var moodsList: String? { @@ -192,7 +217,7 @@ public class Song: AbstractPlayable, Identifyable { } override public var creatorName: String { - if let s = managedObject.artistsString, !s.isEmpty { return s } + if let s = artistsString, !s.isEmpty { return s } return artist?.name ?? "Unknown Artist" } diff --git a/AmperfyKit/Storage/LibraryStorage.swift b/AmperfyKit/Storage/LibraryStorage.swift index 148ce1e0..821a7629 100755 --- a/AmperfyKit/Storage/LibraryStorage.swift +++ b/AmperfyKit/Storage/LibraryStorage.swift @@ -963,7 +963,13 @@ public class LibraryStorage: PlayableFileCachable { } func getFetchPredicate(forArtist artist: Artist) -> NSPredicate { - NSPredicate(format: "artist == %@", artist.managedObject.objectID) + NSCompoundPredicate(orPredicateWithSubpredicates: [ + NSPredicate(format: "artist == %@", artist.managedObject.objectID), + NSPredicate( + format: "SUBQUERY(multiArtists, $ma, $ma == %@).@count > 0", + artist.managedObject.objectID + ), + ]) } func getFetchPredicate(forAlbum album: Album) -> NSPredicate { diff --git a/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/Amperfy v51.xcdatamodel/contents b/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/Amperfy v51.xcdatamodel/contents new file mode 100644 index 00000000..fa1445cc --- /dev/null +++ b/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/Amperfy v51.xcdatamodel/contents @@ -0,0 +1,356 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AmperfyKit/Storage/ManagedObjects/ArtistMO+CoreDataProperties.swift b/AmperfyKit/Storage/ManagedObjects/ArtistMO+CoreDataProperties.swift index 62b78899..af1a27c1 100644 --- a/AmperfyKit/Storage/ManagedObjects/ArtistMO+CoreDataProperties.swift +++ b/AmperfyKit/Storage/ManagedObjects/ArtistMO+CoreDataProperties.swift @@ -41,10 +41,14 @@ extension ArtistMO { @NSManaged public var name: String? @NSManaged + public var albumArtistSongs: NSOrderedSet? + @NSManaged public var albums: NSOrderedSet? @NSManaged public var genre: GenreMO? @NSManaged + public var multiArtistSongs: NSOrderedSet? + @NSManaged public var songs: NSOrderedSet? static let relationshipKeyPathsForPrefetching = [ @@ -139,3 +143,43 @@ extension ArtistMO { @NSManaged public func removeFromSongs(_ values: NSOrderedSet) } + +// MARK: Generated accessors for multiArtistSongs + +extension ArtistMO { + @objc(addMultiArtistSongsObject:) + @NSManaged + public func addToMultiArtistSongs(_ value: SongMO) + + @objc(removeMultiArtistSongsObject:) + @NSManaged + public func removeFromMultiArtistSongs(_ value: SongMO) + + @objc(addMultiArtistSongs:) + @NSManaged + public func addToMultiArtistSongs(_ values: NSOrderedSet) + + @objc(removeMultiArtistSongs:) + @NSManaged + public func removeFromMultiArtistSongs(_ values: NSOrderedSet) +} + +// MARK: Generated accessors for albumArtistSongs + +extension ArtistMO { + @objc(addAlbumArtistSongsObject:) + @NSManaged + public func addToAlbumArtistSongs(_ value: SongMO) + + @objc(removeAlbumArtistSongsObject:) + @NSManaged + public func removeFromAlbumArtistSongs(_ value: SongMO) + + @objc(addAlbumArtistSongs:) + @NSManaged + public func addToAlbumArtistSongs(_ values: NSOrderedSet) + + @objc(removeAlbumArtistSongs:) + @NSManaged + public func removeFromAlbumArtistSongs(_ values: NSOrderedSet) +} diff --git a/AmperfyKit/Storage/ManagedObjects/GenreMO+CoreDataProperties.swift b/AmperfyKit/Storage/ManagedObjects/GenreMO+CoreDataProperties.swift index 89c39d44..59d6d0a0 100644 --- a/AmperfyKit/Storage/ManagedObjects/GenreMO+CoreDataProperties.swift +++ b/AmperfyKit/Storage/ManagedObjects/GenreMO+CoreDataProperties.swift @@ -41,6 +41,8 @@ extension GenreMO { @NSManaged public var artists: NSOrderedSet? @NSManaged + public var multiGenreSongs: NSOrderedSet? + @NSManaged public var songs: NSOrderedSet? static let relationshipKeyPathsForPrefetching = [ @@ -179,3 +181,23 @@ extension GenreMO { @NSManaged public func removeFromSongs(_ values: NSOrderedSet) } + +// MARK: Generated accessors for multiGenreSongs + +extension GenreMO { + @objc(addMultiGenreSongsObject:) + @NSManaged + public func addToMultiGenreSongs(_ value: SongMO) + + @objc(removeMultiGenreSongsObject:) + @NSManaged + public func removeFromMultiGenreSongs(_ value: SongMO) + + @objc(addMultiGenreSongs:) + @NSManaged + public func addToMultiGenreSongs(_ values: NSOrderedSet) + + @objc(removeMultiGenreSongs:) + @NSManaged + public func removeFromMultiGenreSongs(_ values: NSOrderedSet) +} diff --git a/AmperfyKit/Storage/ManagedObjects/Migration/CoreDataMigrationVersion.swift b/AmperfyKit/Storage/ManagedObjects/Migration/CoreDataMigrationVersion.swift index 1947c243..085cd71d 100644 --- a/AmperfyKit/Storage/ManagedObjects/Migration/CoreDataMigrationVersion.swift +++ b/AmperfyKit/Storage/ManagedObjects/Migration/CoreDataMigrationVersion.swift @@ -61,6 +61,8 @@ enum CoreDataMigrationVersion: String, CaseIterable { case v49 = "Amperfy v49" // Remove PlayableFile and Artwork data (they were already deprecated); Account: add apiType case v50 = "Amperfy v50" // Store joined artist display string for multi-artist songs + case v51 = + "Amperfy v51" // Replace string-denormalized artists/albumArtists/genres with Core Data relationships // MARK: - Current @@ -175,6 +177,8 @@ enum CoreDataMigrationVersion: String, CaseIterable { case .v49: return .v50 case .v50: + return .v51 + case .v51: return nil } } diff --git a/AmperfyKit/Storage/ManagedObjects/SongMO+CoreDataProperties.swift b/AmperfyKit/Storage/ManagedObjects/SongMO+CoreDataProperties.swift index 57ec4140..58adc9b1 100644 --- a/AmperfyKit/Storage/ManagedObjects/SongMO+CoreDataProperties.swift +++ b/AmperfyKit/Storage/ManagedObjects/SongMO+CoreDataProperties.swift @@ -31,10 +31,6 @@ extension SongMO { @NSManaged public var lyricsRelFilePath: String? @NSManaged - public var artistsString: String? - @NSManaged - public var albumArtistsString: String? - @NSManaged public var addedDate: Date? @NSManaged public var bpm: Int16 @@ -53,8 +49,6 @@ extension SongMO { @NSManaged public var isrcList: String? @NSManaged - public var genresList: String? - @NSManaged public var moodsList: String? @NSManaged public var groupingsList: String? @@ -69,8 +63,14 @@ extension SongMO { @NSManaged public var album: AlbumMO? @NSManaged + public var albumArtists: NSOrderedSet? + @NSManaged public var artist: ArtistMO? @NSManaged + public var multiArtists: NSOrderedSet? + @NSManaged + public var multiGenres: NSOrderedSet? + @NSManaged public var directory: DirectoryMO? @NSManaged public var genre: GenreMO? @@ -85,3 +85,63 @@ extension SongMO { #keyPath(SongMO.embeddedArtwork), ] } + +// MARK: Generated accessors for multiArtists + +extension SongMO { + @objc(addMultiArtistsObject:) + @NSManaged + public func addToMultiArtists(_ value: ArtistMO) + + @objc(removeMultiArtistsObject:) + @NSManaged + public func removeFromMultiArtists(_ value: ArtistMO) + + @objc(addMultiArtists:) + @NSManaged + public func addToMultiArtists(_ values: NSOrderedSet) + + @objc(removeMultiArtists:) + @NSManaged + public func removeFromMultiArtists(_ values: NSOrderedSet) +} + +// MARK: Generated accessors for albumArtists + +extension SongMO { + @objc(addAlbumArtistsObject:) + @NSManaged + public func addToAlbumArtists(_ value: ArtistMO) + + @objc(removeAlbumArtistsObject:) + @NSManaged + public func removeFromAlbumArtists(_ value: ArtistMO) + + @objc(addAlbumArtists:) + @NSManaged + public func addToAlbumArtists(_ values: NSOrderedSet) + + @objc(removeAlbumArtists:) + @NSManaged + public func removeFromAlbumArtists(_ values: NSOrderedSet) +} + +// MARK: Generated accessors for multiGenres + +extension SongMO { + @objc(addMultiGenresObject:) + @NSManaged + public func addToMultiGenres(_ value: GenreMO) + + @objc(removeMultiGenresObject:) + @NSManaged + public func removeFromMultiGenres(_ value: GenreMO) + + @objc(addMultiGenres:) + @NSManaged + public func addToMultiGenres(_ values: NSOrderedSet) + + @objc(removeMultiGenres:) + @NSManaged + public func removeFromMultiGenres(_ values: NSOrderedSet) +} diff --git a/AmperfyKit/Storage/Settings.swift b/AmperfyKit/Storage/Settings.swift index c702a3f4..991bf679 100644 --- a/AmperfyKit/Storage/Settings.swift +++ b/AmperfyKit/Storage/Settings.swift @@ -310,6 +310,12 @@ public struct UserSettings: Sendable, Codable { } set { _albumsGridSizeSetting = newValue } } + + private var _hiddenSongTagKeys: [String] = [] + public var hiddenSongTagKeys: [String] { + get { _hiddenSongTagKeys } + set { _hiddenSongTagKeys = newValue } + } } // MARK: - AccountSetting From 122f99d674e89dead1496d88b358817863208c7c Mon Sep 17 00:00:00 2001 From: brandon Date: Fri, 5 Jun 2026 18:46:29 -0400 Subject: [PATCH 5/5] fixing tests --- AmperfyKit/Api/Ampache/IDsParserDelegate.swift | 2 +- AmperfyKit/Api/Ampache/SongParserDelegate.swift | 8 ++++---- .../Cases/API/Ampache/AbstractAmpacheTest.swift | 6 +++++- .../Cases/API/Ampache/PlaylistSongsParserTest.swift | 2 +- AmperfyKitTests/Cases/API/Ampache/SongParserTest.swift | 2 +- .../Samples/album_opensubsonic_tags_example_1.xml | 4 ++-- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/AmperfyKit/Api/Ampache/IDsParserDelegate.swift b/AmperfyKit/Api/Ampache/IDsParserDelegate.swift index e333f9cf..08cb62b9 100644 --- a/AmperfyKit/Api/Ampache/IDsParserDelegate.swift +++ b/AmperfyKit/Api/Ampache/IDsParserDelegate.swift @@ -50,7 +50,7 @@ class IDsParserDelegate: AmpacheNotifiableXmlParser { if let id = attributeDict["id"] { prefetchIDs.musicFolderIDs.insert(id) } - case "artist": + case "albumartist", "artist": if let id = attributeDict["id"] { prefetchIDs.artistIDs.insert(id) } diff --git a/AmperfyKit/Api/Ampache/SongParserDelegate.swift b/AmperfyKit/Api/Ampache/SongParserDelegate.swift index c84abd71..4ce8e00a 100644 --- a/AmperfyKit/Api/Ampache/SongParserDelegate.swift +++ b/AmperfyKit/Api/Ampache/SongParserDelegate.swift @@ -103,12 +103,12 @@ class SongParserDelegate: PlayableParserDelegate { } case "genre": guard let song = songBuffer, let genreId = attributeDict["id"] else { return } - // Ampache can have multiple elements. First one sets song.genre; all go to multiGenres. + // Ampache can have multiple elements. Last one sets song.genre; all go to multiGenres. if let guessedGenre = guessedGenre, guessedGenre.id == genreId { - if song.genre == nil { song.genre = guessedGenre } + song.genre = guessedGenre collectedMultiGenres.append(guessedGenre) } else if let prefetchedGenre = prefetch.prefetchedGenreDict[genreId] { - if song.genre == nil { song.genre = prefetchedGenre } + song.genre = prefetchedGenre collectedMultiGenres.append(prefetchedGenre) } else { genreIdToCreate = genreId @@ -164,7 +164,7 @@ class SongParserDelegate: PlayableParserDelegate { prefetch.prefetchedGenreDict[genreId] = genre genre.id = genreId genre.name = buffer - if songBuffer?.genre == nil { songBuffer?.genre = genre } + songBuffer?.genre = genre collectedMultiGenres.append(genre) genreIdToCreate = nil } diff --git a/AmperfyKitTests/Cases/API/Ampache/AbstractAmpacheTest.swift b/AmperfyKitTests/Cases/API/Ampache/AbstractAmpacheTest.swift index 9c156049..4d5db713 100644 --- a/AmperfyKitTests/Cases/API/Ampache/AbstractAmpacheTest.swift +++ b/AmperfyKitTests/Cases/API/Ampache/AbstractAmpacheTest.swift @@ -72,7 +72,11 @@ class AbstractAmpacheTest: XCTestCase { idParserDelegate = IDsParserDelegate(performanceMonitor: MOCK_PerformanceMonitor()) } - override func tearDown() {} + override func tearDown() { + xmlData = nil + parserDelegate = nil + super.tearDown() + } var prefetchIdTester: PrefetchIdTester { PrefetchIdTester(library: library, prefetchIDs: idParserDelegate.prefetchIDs) diff --git a/AmperfyKitTests/Cases/API/Ampache/PlaylistSongsParserTest.swift b/AmperfyKitTests/Cases/API/Ampache/PlaylistSongsParserTest.swift index 55164b8d..4160a260 100644 --- a/AmperfyKitTests/Cases/API/Ampache/PlaylistSongsParserTest.swift +++ b/AmperfyKitTests/Cases/API/Ampache/PlaylistSongsParserTest.swift @@ -130,7 +130,7 @@ class PlaylistSongsParserTest: AbstractAmpacheTest { prefetchIdTester.checkPrefetchIdCounts( artworkCount: 3, genreIdCount: 4, - artistCount: 4, + artistCount: 5, albumCount: 2, songCount: 4, songLibraryCount: 4 + createdSongCount diff --git a/AmperfyKitTests/Cases/API/Ampache/SongParserTest.swift b/AmperfyKitTests/Cases/API/Ampache/SongParserTest.swift index d8d3e0d2..9aeb4c22 100644 --- a/AmperfyKitTests/Cases/API/Ampache/SongParserTest.swift +++ b/AmperfyKitTests/Cases/API/Ampache/SongParserTest.swift @@ -41,7 +41,7 @@ class SongParserTest: AbstractAmpacheTest { prefetchIdTester.checkPrefetchIdCounts( artworkCount: 3, genreIdCount: 4, - artistCount: 4, + artistCount: 5, albumCount: 2, songCount: 4 ) diff --git a/AmperfyKitTests/Cases/API/Subsonic/Samples/album_opensubsonic_tags_example_1.xml b/AmperfyKitTests/Cases/API/Subsonic/Samples/album_opensubsonic_tags_example_1.xml index 3207d1f7..0b1650fb 100644 --- a/AmperfyKitTests/Cases/API/Subsonic/Samples/album_opensubsonic_tags_example_1.xml +++ b/AmperfyKitTests/Cases/API/Subsonic/Samples/album_opensubsonic_tags_example_1.xml @@ -45,7 +45,7 @@ - + @@ -94,7 +94,7 @@ album="OpenSubsonic Tags Album" albumId="ost-album" artist="Simple Artist" - artistId="ost-artist" + artistId="ost-artist-simple" isDir="false" created="2024-01-01T00:00:00Z" duration="120"