diff --git a/Amperfy.xcodeproj/project.pbxproj b/Amperfy.xcodeproj/project.pbxproj index 5f71d962..fa57087a 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 */; }; @@ -205,7 +206,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 */; }; @@ -240,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 */; }; @@ -524,16 +525,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 */ @@ -763,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 = ""; }; @@ -840,7 +846,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 = ""; }; @@ -898,6 +903,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 = ""; }; @@ -911,6 +917,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 = ""; }; @@ -1142,18 +1149,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 */ @@ -2020,6 +2030,7 @@ 50ED2B9427BB938500331BF7 /* SsAlbumMissingArtistsIdParserTest.swift */, 50AB92C12666127100DCE45C /* SsSongExample1ParserTest.swift */, 50AB92E126675B7000DCE45C /* SsSongExample2ParserTest.swift */, + BB0A0003BB0A00030085CBB3 /* SsSongOpenSubsonicTagsParserTest.swift */, 50AB92C92666BAC300DCE45C /* SsPlaylistsParserTest.swift */, 50AB92CF2666BC2000DCE45C /* SsPlaylistSongsParserTest.swift */, 50AB92D72666C2D900DCE45C /* SsMusicFolderParserTest.swift */, @@ -2047,6 +2058,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 */, @@ -2131,10 +2143,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 = ( @@ -2375,6 +2397,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 */, @@ -2453,6 +2476,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 */, @@ -2889,6 +2914,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 */, @@ -3069,6 +3095,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 +3135,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 = ( @@ -3516,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 */, @@ -3566,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/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..b09e8d99 --- /dev/null +++ b/Amperfy/SwiftUI/SongTags/SongTagsFilterView.swift @@ -0,0 +1,58 @@ +// +// 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..e51a7176 --- /dev/null +++ b/Amperfy/SwiftUI/SongTags/SongTagsView.swift @@ -0,0 +1,295 @@ +// +// 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 + 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 { + 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 { + @Published + var hiddenKeys: Set + + init() { + self.hiddenKeys = Set( + (UIApplication.shared.delegate as! AppDelegate).storage.settings.user.hiddenSongTagKeys + ) + } + + 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() { + let appDelegate = UIApplication.shared.delegate as! AppDelegate + var userSettings = appDelegate.storage.settings.user + userSettings.hiddenSongTagKeys = Array(hiddenKeys) + appDelegate.storage.settings.user = userSettings + } +} + +// 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/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/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 52b0809c..4ce8e00a 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. Last one sets song.genre; all go to multiGenres. if let guessedGenre = guessedGenre, guessedGenre.id == genreId { song.genre = guessedGenre + collectedMultiGenres.append(guessedGenre) } else if let prefetchedGenre = prefetch.prefetchedGenreDict[genreId] { 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) @@ -138,9 +165,23 @@ class SongParserDelegate: PlayableParserDelegate { genre.id = genreId genre.name = buffer 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 144b0e8d..79cec433 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 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 = "" + 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,50 @@ class SsSongParserDelegate: SsPlayableParserDelegate { } } + collectedMultiArtists = [] + collectedAlbumArtists = [] + collectedMultiGenres = [] + collectedContributors = [] + collectedISRCs = [] + collectedMoods = [] + collectedGroupings = [] + isInsideContributor = false + currentTextElementName = "" + currentTextBuffer = "" + + // OpenSubsonic simple-attribute fields + if let bpmStr = attributeDict["bpm"], let bpmVal = Int(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 = Int(channelStr) { + songBuffer?.channelCount = channelVal + } + if let srStr = attributeDict["samplingRate"], let srVal = Int(srStr) { + songBuffer?.samplingRate = srVal + } + if let bdStr = attributeDict["bitDepth"], let bdVal = Int(bdStr) { + songBuffer?.bitDepth = bdVal + } + if let albumId = attributeDict["albumId"] { if let guessedAlbum, guessedAlbum.id == albumId { songBuffer?.album = guessedAlbum @@ -138,6 +199,74 @@ class SsSongParserDelegate: SsPlayableParserDelegate { } } + // 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 { + 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: (no ID in OpenSubsonic, keyed by name) + if elementName == "genres", songBuffer != nil, let name = attributeDict["name"], + !name.isEmpty { + 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: + 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 +276,97 @@ 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 { + // Multi-artist entities + if !collectedMultiArtists.isEmpty { + songBuffer?.multiArtists = collectedMultiArtists + } + + // Album artist entities + if !collectedAlbumArtists.isEmpty { + songBuffer?.albumArtists = collectedAlbumArtists + } + + // Multi-genre entities + if !collectedMultiGenres.isEmpty { + songBuffer?.multiGenres = collectedMultiGenres + } + + // 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") + } + + collectedMultiArtists = [] + collectedAlbumArtists = [] + collectedMultiGenres = [] + collectedContributors = [] + collectedISRCs = [] + collectedMoods = [] + collectedGroupings = [] parsedCount += 1 resetPlayableBuffer() if let song = songBuffer { 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/AmperfyKit/Storage/EntityWrappers/Song.swift b/AmperfyKit/Storage/EntityWrappers/Song.swift index c3571ad0..42d57c68 100644 --- a/AmperfyKit/Storage/EntityWrappers/Song.swift +++ b/AmperfyKit/Storage/EntityWrappers/Song.swift @@ -106,8 +106,119 @@ public class Song: AbstractPlayable, Identifyable { } } + public var artistsString: String? { + if multiArtists.isEmpty { return artist?.name } + return multiArtists.map { $0.name }.joined(separator: ", ") + } + + public var albumArtistsString: String? { + albumArtists.isEmpty ? nil : albumArtists.map { $0.name }.joined(separator: ", ") + } + + 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: Int { + get { Int(managedObject.bitDepth) } + set { managedObject.bitDepth = Int16(newValue) } + } + + public var channelCount: Int { + get { Int(managedObject.channelCount) } + set { managedObject.channelCount = Int16(newValue) } + } + + public var samplingRate: Int { + get { Int(managedObject.samplingRate) } + set { managedObject.samplingRate = Int32(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? { + multiGenres.isEmpty ? nil : multiGenres.map { $0.name }.joined(separator: ", ") + } + + 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 = artistsString, !s.isEmpty { return s } + return artist?.name ?? "Unknown Artist" } public var detailInfo: String { 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/.xccurrentversion b/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion index 887518ef..6ecdacf4 100644 --- a/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion +++ b/AmperfyKit/Storage/ManagedObjects/Amperfy.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Amperfy v49.xcdatamodel + Amperfy v51.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/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 66183286..085cd71d 100644 --- a/AmperfyKit/Storage/ManagedObjects/Migration/CoreDataMigrationVersion.swift +++ b/AmperfyKit/Storage/ManagedObjects/Migration/CoreDataMigrationVersion.swift @@ -60,6 +60,9 @@ 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 + case v51 = + "Amperfy v51" // Replace string-denormalized artists/albumArtists/genres with Core Data relationships // MARK: - Current @@ -172,6 +175,10 @@ enum CoreDataMigrationVersion: String, CaseIterable { case .v48: return .v49 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 8e84b2d3..58adc9b1 100644 --- a/AmperfyKit/Storage/ManagedObjects/SongMO+CoreDataProperties.swift +++ b/AmperfyKit/Storage/ManagedObjects/SongMO+CoreDataProperties.swift @@ -33,10 +33,44 @@ extension SongMO { @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 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 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? @@ -51,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 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 new file mode 100644 index 00000000..0b1650fb --- /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") + } +}