diff --git a/Amperfy.xcodeproj/project.pbxproj b/Amperfy.xcodeproj/project.pbxproj index b6861930..5f4ea6fc 100644 --- a/Amperfy.xcodeproj/project.pbxproj +++ b/Amperfy.xcodeproj/project.pbxproj @@ -148,6 +148,9 @@ 508FDE5021F64ED6005A0724 /* TabBarVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 508FDE4F21F64ED6005A0724 /* TabBarVC.swift */; }; 509001B62716C7F600A8056D /* AppDelegateAlertExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509001B52716C7F600A8056D /* AppDelegateAlertExtensions.swift */; }; 509001B82716C8F400A8056D /* AppDelegateNotificationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509001B72716C8F400A8056D /* AppDelegateNotificationExtensions.swift */; }; + 509001BF271829E200A8056D /* catalogs.xml in Resources */ = {isa = PBXBuildFile; fileRef = 509001BE271829E200A8056D /* catalogs.xml */; }; + 509001C127182A1300A8056D /* CatalogParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509001C027182A1300A8056D /* CatalogParserTest.swift */; }; + 509001C327182A4700A8056D /* CatalogParserDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509001C227182A4700A8056D /* CatalogParserDelegate.swift */; }; 5090963826496A9500DD9826 /* UtilitiesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5090963726496A9500DD9826 /* UtilitiesTest.swift */; }; 509121032625EF14008C57EC /* MarqueeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509121022625EF14008C57EC /* MarqueeLabel.swift */; }; 5095F98C23C8389E008B0805 /* MusicPlayerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5095F98B23C8389E008B0805 /* MusicPlayerTest.swift */; }; @@ -500,6 +503,9 @@ 509001B52716C7F600A8056D /* AppDelegateAlertExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateAlertExtensions.swift; sourceTree = ""; }; 509001B72716C8F400A8056D /* AppDelegateNotificationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateNotificationExtensions.swift; sourceTree = ""; }; 509001BD2717120900A8056D /* Amperfy v14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v14.xcdatamodel"; sourceTree = ""; }; + 509001BE271829E200A8056D /* catalogs.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = catalogs.xml; sourceTree = ""; }; + 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 = ""; }; 509121022625EF14008C57EC /* MarqueeLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MarqueeLabel.swift; path = Carthage/Checkouts/MarqueeLabel/Sources/MarqueeLabel.swift; sourceTree = SOURCE_ROOT; }; 5095F98B23C8389E008B0805 /* MusicPlayerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicPlayerTest.swift; sourceTree = ""; }; @@ -1035,6 +1041,7 @@ 50BA930721D75C5600E5901D /* AuthentificationHandshake.swift */, 501267B8264C187600E13F08 /* AmpacheXmlParser.swift */, 5083861921C827E600C4BB32 /* AuthParserDelegate.swift */, + 509001C227182A4700A8056D /* CatalogParserDelegate.swift */, 507361C72632AB08005F151D /* GenreParserDelegate.swift */, 50BA92ED21CBF45D00E5901D /* ArtistParserDelegate.swift */, 50BA92EC21CBF45D00E5901D /* AlbumParserDelegate.swift */, @@ -1168,6 +1175,7 @@ 50DB11A6266533720033BFFA /* ErrorParserTest.swift */, 50DB118E266521610033BFFA /* GenreParserTest.swift */, 50DB1181266502100033BFFA /* ArtistParserTest.swift */, + 509001C027182A1300A8056D /* CatalogParserTest.swift */, 50DB118A266511030033BFFA /* AlbumParserTest.swift */, 50DB1192266522F60033BFFA /* SongParserTest.swift */, 50DB119E26652E090033BFFA /* PlaylistsParserTest.swift */, @@ -1183,6 +1191,7 @@ children = ( 50DB11A026652FCB0033BFFA /* handshake.xml */, 50DB11A42665336B0033BFFA /* error-4700.xml */, + 509001BE271829E200A8056D /* catalogs.xml */, 50DB118C266521290033BFFA /* genres.xml */, 50DB1184266502C00033BFFA /* artists.xml */, 50DB1188266510FF0033BFFA /* albums.xml */, @@ -1432,6 +1441,7 @@ 50AB92DC2666C53F00DCE45C /* directory_example_2.xml in Resources */, 50AB92C826662FCA00DCE45C /* album_example_2.xml in Resources */, 50DB119926652D070033BFFA /* playlist_songs.xml in Resources */, + 509001BF271829E200A8056D /* catalogs.xml in Resources */, 50DB119D26652DFA0033BFFA /* playlists.xml in Resources */, 50AB92D22666C19500DCE45C /* musicFolders_example_1.xml in Resources */, 50AB92CE2666BC0B00DCE45C /* playlist_example_1.xml in Resources */, @@ -1489,6 +1499,7 @@ 50D92CCF25E3FE560017E91D /* MigrationPolicyV4toV5.swift in Sources */, 50C16C1E21E7A35C00F086F0 /* AlbumTableCell.swift in Sources */, 50120A2526281C1900B037B1 /* BasicTableViewController.swift in Sources */, + 509001C327182A4700A8056D /* CatalogParserDelegate.swift in Sources */, 50F81EE223BF6B1E00EAAC3E /* PlayableFileMO+CoreDataClass.swift in Sources */, 50C16C1421E687AB00F086F0 /* ArtistTableCell.swift in Sources */, 501A7D4B2681E6DD0055A51B /* PodcastEpisodeParserDelegate.swift in Sources */, @@ -1734,6 +1745,7 @@ 50B798B223B7F51000551E62 /* PlaylistTest.swift in Sources */, 50DB118B266511030033BFFA /* AlbumParserTest.swift in Sources */, 50DB119F26652E090033BFFA /* PlaylistsParserTest.swift in Sources */, + 509001C127182A1300A8056D /* CatalogParserTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Amperfy/Api/Ampache/AmpacheLibrarySyncer.swift b/Amperfy/Api/Ampache/AmpacheLibrarySyncer.swift index 262e91bd..44f0c57f 100644 --- a/Amperfy/Api/Ampache/AmpacheLibrarySyncer.swift +++ b/Amperfy/Api/Ampache/AmpacheLibrarySyncer.swift @@ -112,15 +112,83 @@ class AmpacheLibrarySyncer: LibrarySyncer { } func syncMusicFolders(library: LibraryStorage) { - ampacheXmlServerApi.eventLogger.error(topic: "Internal Error", statusCode: .internalError, message: "GetMusicFolders API function is not support by Ampache") + guard let syncWave = library.getLatestSyncWave() else { return } + let catalogParser = CatalogParserDelegate(library: library, syncWave: syncWave) + self.ampacheXmlServerApi.requestCatalogs(parserDelegate: catalogParser) + library.saveContext() } func syncIndexes(musicFolder: MusicFolder, library: LibraryStorage) { - ampacheXmlServerApi.eventLogger.error(topic: "Internal Error", statusCode: .internalError, message: "GetIndexes API function is not support by Ampache") + guard let syncWave = library.getLatestSyncWave() else { return } + let artistParser = ArtistParserDelegate(library: library, syncWave: syncWave) + self.ampacheXmlServerApi.requestArtistWithinCatalog(of: musicFolder, parserDelegate: artistParser) + + let directoriesBeforeFetch = Set(musicFolder.directories) + var directoriesAfterFetch: Set = Set() + for artist in artistParser.artistsParsed { + let artistDirId = "artist-\(artist.id)" + var curDir: Directory! + if let foundDir = library.getDirectory(id: artistDirId) { + curDir = foundDir + } else { + curDir = library.createDirectory() + curDir.id = artistDirId + } + curDir.name = artist.name + musicFolder.managedObject.addToDirectories(curDir.managedObject) + directoriesAfterFetch.insert(curDir) + } + + let removedDirectories = directoriesBeforeFetch.subtracting(directoriesAfterFetch) + removedDirectories.forEach{ library.deleteDirectory(directory: $0) } + + library.saveContext() } func sync(directory: Directory, library: LibraryStorage) { - ampacheXmlServerApi.eventLogger.error(topic: "Internal Error", statusCode: .internalError, message: "GetMusicDirectory API function is not support by Ampache") + if directory.id.starts(with: "album-") { + let albumId = String(directory.id.dropFirst("album-".count)) + guard let album = library.getAlbum(id: albumId) else { return } + let songsBeforeFetch = Set(directory.songs) + sync(album: album, library: library) + directory.songs.forEach { directory.managedObject.removeFromSongs($0.managedObject) } + let songsToRemove = songsBeforeFetch.subtracting(Set(album.songs.compactMap{$0.asSong})) + songsToRemove.lazy.compactMap{$0.asSong}.forEach{ + directory.managedObject.removeFromSongs($0.managedObject) + } + album.songs.compactMap{$0.asSong}.forEach{ + directory.managedObject.addToSongs($0.managedObject) + } + library.saveContext() + } else if directory.id.starts(with: "artist-"){ + let artistId = String(directory.id.dropFirst("artist-".count)) + guard let artist = library.getArtist(id: artistId) else { return } + let directoriesBeforeFetch = Set(directory.subdirectories) + sync(artist: artist, library: library) + + var directoriesAfterFetch: Set = Set() + let artistAlbums = library.getAlbums(whichContainsSongsWithArtist: artist) + for album in artistAlbums { + let albumDirId = "album-\(album.id)" + var albumDir: Directory! + if let foundDir = library.getDirectory(id: albumDirId) { + albumDir = foundDir + } else { + albumDir = library.createDirectory() + albumDir.id = albumDirId + } + albumDir.name = album.name + albumDir.artwork = album.artwork + directory.managedObject.addToSubdirectories(albumDir.managedObject) + directoriesAfterFetch.insert(albumDir) + } + + let directoriesToRemove = directoriesBeforeFetch.subtracting(directoriesAfterFetch) + directoriesToRemove.forEach{ + directory.managedObject.removeFromSubdirectories($0.managedObject) + } + library.saveContext() + } } func syncRecentSongs(library: LibraryStorage) { diff --git a/Amperfy/Api/Ampache/AmpacheXmlServerApi.swift b/Amperfy/Api/Ampache/AmpacheXmlServerApi.swift index 0ab759c0..f3ee7dda 100755 --- a/Amperfy/Api/Ampache/AmpacheXmlServerApi.swift +++ b/Amperfy/Api/Ampache/AmpacheXmlServerApi.swift @@ -191,6 +191,12 @@ class AmpacheXmlServerApi { } } + func requestCatalogs(parserDelegate: AmpacheXmlParser) { + guard var apiUrlComponent = createAuthenticatedApiUrlComponent() else { return } + apiUrlComponent.addQueryItem(name: "action", value: "catalogs") + request(fromUrlComponent: apiUrlComponent, viaXmlParser: parserDelegate) + } + func requestGenres(parserDelegate: AmpacheXmlParser) { guard var apiUrlComponent = createAuthenticatedApiUrlComponent() else { return } apiUrlComponent.addQueryItem(name: "action", value: "genres") @@ -223,6 +229,16 @@ class AmpacheXmlServerApi { request(fromUrlComponent: apiUrlComponent, viaXmlParser: parserDelegate) } + func requestArtistWithinCatalog(of catalog: MusicFolder, parserDelegate: AmpacheXmlParser) { + guard var apiUrlComponent = createAuthenticatedApiUrlComponent() else { return } + apiUrlComponent.addQueryItem(name: "action", value: "advanced_search") + apiUrlComponent.addQueryItem(name: "rule_1", value: "catalog") + apiUrlComponent.addQueryItem(name: "rule_1_operator", value: 0) + apiUrlComponent.addQueryItem(name: "rule_1_input", value: Int(catalog.id) ?? 0) + apiUrlComponent.addQueryItem(name: "type", value: "artist") + request(fromUrlComponent: apiUrlComponent, viaXmlParser: parserDelegate) + } + func requestArtistAlbums(of artist: Artist, parserDelegate: AmpacheXmlParser) { guard var apiUrlComponent = createAuthenticatedApiUrlComponent() else { return } apiUrlComponent.addQueryItem(name: "action", value: "artist_albums") diff --git a/Amperfy/Api/Ampache/ArtistParserDelegate.swift b/Amperfy/Api/Ampache/ArtistParserDelegate.swift index f75097b5..9f3d6213 100644 --- a/Amperfy/Api/Ampache/ArtistParserDelegate.swift +++ b/Amperfy/Api/Ampache/ArtistParserDelegate.swift @@ -5,6 +5,7 @@ import os.log class ArtistParserDelegate: AmpacheXmlLibParser { + var artistsParsed = Set() var artistBuffer: Artist? var genreIdToCreate: String? @@ -55,6 +56,9 @@ class ArtistParserDelegate: AmpacheXmlLibParser { case "artist": parsedCount += 1 parseNotifier?.notifyParsedObject(ofType: .artist) + if let parsedArtist = artistBuffer { + artistsParsed.insert(parsedArtist) + } artistBuffer = nil default: break diff --git a/Amperfy/Api/Ampache/CatalogParserDelegate.swift b/Amperfy/Api/Ampache/CatalogParserDelegate.swift new file mode 100644 index 00000000..ce6bd42d --- /dev/null +++ b/Amperfy/Api/Ampache/CatalogParserDelegate.swift @@ -0,0 +1,54 @@ +import Foundation +import UIKit +import CoreData +import os.log + +class CatalogParserDelegate: AmpacheXmlLibParser { + + let musicFoldersBeforeFetch: Set + var musicFoldersParsed = Set() + var musicFolderBuffer: MusicFolder? + + init(library: LibraryStorage, syncWave: SyncWave) { + musicFoldersBeforeFetch = Set(library.getMusicFolders()) + super.init(library: library, syncWave: syncWave) + } + + override func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String]) { + super.parser(parser, didStartElement: elementName, namespaceURI: namespaceURI, qualifiedName: qName, attributes: attributeDict) + + if(elementName == "catalog") { + guard let id = attributeDict["id"] else { + os_log("Found catalog with no id", log: log, type: .error) + return + } + if let fetchedMusicFolder = library.getMusicFolder(id: id) { + musicFolderBuffer = fetchedMusicFolder + } else { + musicFolderBuffer = library.createMusicFolder() + musicFolderBuffer?.id = id + } + } + } + + override func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + switch(elementName) { + case "name": + musicFolderBuffer?.name = buffer + case "catalog": + parsedCount += 1 + if let parsedmusicFolder = musicFolderBuffer { + musicFoldersParsed.insert(parsedmusicFolder) + } + musicFolderBuffer = nil + case "root": + let removedMusicFolders = musicFoldersBeforeFetch.subtracting(musicFoldersParsed) + removedMusicFolders.forEach{ library.deleteMusicFolder(musicFolder: $0) } + default: + break + } + + super.parser(parser, didEndElement: elementName, namespaceURI: namespaceURI, qualifiedName: qName) + } + +} diff --git a/Amperfy/Screens/ViewController/LibraryVC.swift b/Amperfy/Screens/ViewController/LibraryVC.swift index af763069..0c57a898 100644 --- a/Amperfy/Screens/ViewController/LibraryVC.swift +++ b/Amperfy/Screens/ViewController/LibraryVC.swift @@ -22,8 +22,6 @@ class LibraryVC: UITableViewController { let cell = super.tableView(tableView, cellForRowAt: indexPath) if cell == genreTableViewCell, appDelegate.persistentStorage.librarySyncVersion < .v7 { return 0 - } else if cell == directoriesTableViewCell, appDelegate.backendProxy.selectedApi == .ampache { - return 0 } else { return super.tableView(tableView, heightForRowAt: indexPath) } diff --git a/Amperfy/Storage/LibraryStorage.swift b/Amperfy/Storage/LibraryStorage.swift index 0ca904dd..918bd200 100644 --- a/Amperfy/Storage/LibraryStorage.swift +++ b/Amperfy/Storage/LibraryStorage.swift @@ -416,6 +416,17 @@ class LibraryStorage: PlayableFileCachable { return albums ?? [Album]() } + func getAlbums(whichContainsSongsWithArtist artist: Artist) -> [Album] { + let fetchRequest = AlbumMO.identifierSortedFetchRequest + fetchRequest.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [ + self.getFetchPredicate(forArtist: artist), + AlbumMO.getFetchPredicateForAlbumsWhoseSongsHave(artist: artist) + ]) + let foundAlbums = try? context.fetch(fetchRequest) + let albums = foundAlbums?.compactMap{ Album(managedObject: $0) } + return albums ?? [Album]() + } + func getPodcasts() -> [Podcast] { let fetchRequest = PodcastMO.identifierSortedFetchRequest let foundPodcasts = try? context.fetch(fetchRequest) diff --git a/AmperfyTests/Cases/API/Ampache/CatalogParserTest.swift b/AmperfyTests/Cases/API/Ampache/CatalogParserTest.swift new file mode 100644 index 00000000..074a63c9 --- /dev/null +++ b/AmperfyTests/Cases/API/Ampache/CatalogParserTest.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import Amperfy + +class CatalogParserTest: AbstractAmpacheTest { + + override func setUp() { + super.setUp() + xmlData = getTestFileData(name: "catalogs") + recreateParserDelegate() + } + + override func recreateParserDelegate() { + parserDelegate = CatalogParserDelegate(library: library, syncWave: syncWave) + } + + override func checkCorrectParsing() { + XCTAssertEqual(library.musicFolderCount, 4) + + let musicFolders = library.getMusicFolders().sorted(by: {Int($0.id)! < Int($1.id)!} ) + XCTAssertEqual(musicFolders[0].id, "1") + XCTAssertEqual(musicFolders[0].name, "music") + XCTAssertEqual(musicFolders[1].id, "2") + XCTAssertEqual(musicFolders[1].name, "video") + XCTAssertEqual(musicFolders[2].id, "3") + XCTAssertEqual(musicFolders[2].name, "podcast") + XCTAssertEqual(musicFolders[3].id, "4") + XCTAssertEqual(musicFolders[3].name, "upload") + } + +} diff --git a/AmperfyTests/Cases/API/Ampache/Samples/catalogs.xml b/AmperfyTests/Cases/API/Ampache/Samples/catalogs.xml new file mode 100644 index 00000000..78b23b05 --- /dev/null +++ b/AmperfyTests/Cases/API/Ampache/Samples/catalogs.xml @@ -0,0 +1,52 @@ + + + 4 + + + + + 1 + 1627949046 + 1627949154 + 1626835896 + + + + + + + + + 1 + 1617260634 + 1617260599 + 0 + + + + + + + + + 1 + 1617260634 + 1617260634 + 0 + + + + + + + + + 1 + 1627949054 + 1627949144 + 0 + + + + +