From 2ca9ce797d3cccee92fd4c7154e5741b0de9f9bc Mon Sep 17 00:00:00 2001 From: benonymity Date: Wed, 6 Jul 2022 10:09:17 -0400 Subject: [PATCH 001/103] feat: iOS download groundwork --- ios/App/App.xcodeproj/project.pbxproj | 4 + ios/App/App/plugins/AbsDownloader.swift | 99 ++++++++------ ios/App/Shared/models/DataClasses.swift | 165 ++++++++++++----------- ios/App/Shared/models/LocalLibrary.swift | 153 +++++++++++++++++++++ ios/App/Shared/util/Database.swift | 65 +++++++++ pages/item/_id.vue | 2 +- 6 files changed, 368 insertions(+), 120 deletions(-) create mode 100644 ios/App/Shared/models/LocalLibrary.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 8a801dcf..015d9deb 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; + C4B265F5285A5A6600E1B5C3 /* LocalLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */; }; C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; }; /* End PBXBuildFile section */ @@ -71,6 +72,7 @@ 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; + C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibrary.swift; sourceTree = ""; }; C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -135,6 +137,7 @@ 3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */, 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */, C4D0677428106D0C00B8F875 /* DataClasses.swift */, + C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */, 3A90295E280968E700E1D427 /* PlaybackReport.swift */, 4DF74911287105C600AC7814 /* DeviceSettings.swift */, ); @@ -315,6 +318,7 @@ 3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, 3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */, + C4B265F5285A5A6600E1B5C3 /* LocalLibrary.swift in Sources */, 3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */, 3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */, 3AD4FCED28044E6C006DB301 /* Store.swift in Sources */, diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index d2efedcc..d2b9a003 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -14,58 +14,71 @@ public class AbsDownloader: CAPPlugin { let libraryItemId = call.getString("libraryItemId") let episodeId = call.getString("episodeId") - NSLog("Download library item \(libraryItemId ?? "N/A") episode \(episodeId ?? "")") + NSLog("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "")") ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId!, episodeId: episodeId) { libraryItem in if (libraryItem == nil) { NSLog("Library item not found") call.resolve() } else { - NSLog("Got library item \(libraryItem!)") - - // TODO: break out in seperate functions - libraryItem!.media.tracks?.forEach { track in - NSLog("TRACK \(track.contentUrl!)") - // filename needs to be encoded otherwise would just use contentUrl - let filename = track.metadata?.filename ?? "" - let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) - let urlstr = "\(Store.serverConfig!.address)/s/item/\(libraryItemId!)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" - let url = URL(string: urlstr)! - - - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let itemDirectory = documentsDirectory.appendingPathComponent("\(libraryItemId!)") - NSLog("ITEM DIR \(itemDirectory)") - - // Create library item directory - do { - try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: false) - } catch { - NSLog("Failed to CREATE LI DIRECTORY \(error)") - } - - // Output filename - let trackFilename = itemDirectory.appendingPathComponent("\(filename)") - - let downloadTask = URLSession.shared.downloadTask(with: url) { urlOrNil, responseOrNil, errorOrNil in - - guard let fileURL = urlOrNil else { return } - - do { - NSLog("Download TMP file URL \(fileURL)") - let imageData = try Data(contentsOf:fileURL) - try imageData.write(to: trackFilename) - NSLog("Download written to \(trackFilename)") - } catch { - NSLog("FILE ERROR: \(error)") - } - } - downloadTask.resume() - } - + NSLog("Got library item from server \(libraryItem!.id)") + self.startLibraryItemDownload(libraryItem: libraryItem!) call.resolve() } } } + func startLibraryItemDownload(libraryItem: LibraryItem) { + let length = libraryItem.media.tracks.count + if length > 0 { + libraryItem.media.tracks.enumerated().forEach { position, track in + NSLog("TRACK \(track.contentUrl!)") + // filename needs to be encoded otherwise would just use contentUrl + let filename = track.metadata?.filename ?? "" + let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) + let urlstr = "\(Store.serverConfig!.address)/s/item/\(libraryItem.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" + let url = URL(string: urlstr)! + + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let itemDirectory = documentsDirectory.appendingPathComponent("\(libraryItem.id)") + NSLog("ITEM DIR \(itemDirectory)") + + // Create library item directory + do { + try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: false) + } catch { + NSLog("Failed to CREATE LI DIRECTORY \(error)") + } + + // Output filename + let trackFilename = itemDirectory.appendingPathComponent("\(filename)") + + let downloadTask = URLSession.shared.downloadTask(with: url) { urlOrNil, responseOrNil, errorOrNil in + + guard let fileURL = urlOrNil else { return } + + do { + NSLog("Download TMP file URL \(fileURL)") + let audioData = try Data(contentsOf:fileURL) + try audioData.write(to: trackFilename) + NSLog("Download written to \(trackFilename)") + } catch { + NSLog("FILE ERROR: \(error)") + } + } + downloadTask.resume() + } + } else { + NSLog("No audio tracks for the supplied library item") + } +// let encoder = JSONEncoder() +// let jsobj = try encoder.encode(Download) +// notifyListeners("onItemDownloadComplete", data: jsobj) + } +} +struct DownloadItem: Codable { + var isDownloading = false + var progress: Float = 0 + var resumeData: Data? +// var task: URLSessionDownloadTask? } diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index 4237bc7d..753cf432 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -2,11 +2,12 @@ // DataClasses.swift // App // -// Created by Benonymity on 4/20/22. +// Created by benonymity on 4/20/22. // import Foundation import CoreMedia +import RealmSwift struct LibraryItem: Codable { var id: String @@ -30,87 +31,88 @@ struct LibraryItem: Codable { var libraryFiles: [LibraryFile] var userMediaProgress:MediaProgress? } -struct MediaType: Codable { - var libraryItemId: String? - var metadata: Metadata - var coverPath: String? - var tags: [String]? - var audioFiles: [AudioTrack]? - var chapters: [Chapter]? - var tracks: [AudioTrack]? - var size: Int64? - var duration: Double? - var episodes: [PodcastEpisode]? - var autoDownloadEpisodes: Bool? +class MediaType: Object, Codable { + var libraryItemId: String? = "" + var metadata: Metadata? + var coverPath: String? = "" + var tags: List + var audioFiles: List + var chapters: List + var tracks: List + var size: Int64? = nil + var duration: Double? = nil + var episodes: List + var autoDownloadEpisodes: Bool? = nil } -struct Metadata: Codable { +class Metadata: Object, Codable { var title: String - var subtitle: String? - var authors: [Author]? - var narrators: [String]? - var genres: [String] - var publishedYear: String? - var publishedDate: String? - var publisher: String? - var description: String? - var isbn: String? - var asin: String? - var language: String? + var subtitle: String? = "" + var authors: List + var narrators: List + var genres: List + var publishedYear: String? = "" + var publishedDate: String? = "" + var publisher: String? = "" + // I think calling the below variable description conflicts with some public variables declared in some of the Pods we use, so it's desc. ¯\_(ツ)_/¯ + final var description: String + var isbn: String? = "" + var asin: String? = "" + var language: String? = "" var explicit: Bool - var authorName: String? - var authorNameLF: String? - var narratorName: String? - var seriesName: String? - var feedUrl: String? + var authorName: String? = "" + var authorNameLF: String? = "" + var narratorName: String? = "" + var seriesName: String? = "" + var feedUrl: String? = "" } -struct PodcastEpisode: Codable { +class PodcastEpisode: Object, Codable { var id: String var index: Int - var episode: String? - var episodeType: String? + var episode: String? = "" + var episodeType: String? = "" var title: String - var subtitle: String? - var description: String? - var audioFile: AudioFile? - var audioTrack: AudioTrack? + var subtitle: String? = "" + var escription: String? = "" + var audioFile: AudioFile? = nil + var audioTrack: AudioTrack? = nil var duration: Double var size: Int64 // var serverEpisodeId: String? } -struct AudioFile: Codable { - var index: Int - var ino: String - var metadata: FileMetadata +class AudioFile: Object, Codable { + @Persisted var index: Int + @Persisted var ino: String + @Persisted var metadata: FileMetadata? } -struct Author: Codable { - var id: String - var name: String - var coverPath: String? +class Author: Object, Codable { + @Persisted var id: String + @Persisted var name: String + @Persisted var coverPath: String? = "" } -struct Chapter: Codable { - var id: Int - var start: Double - var end: Double - var title: String? +class Chapter: Object, Codable { + @Persisted var id: Int + @Persisted var start: Double + @Persisted var end: Double + @Persisted var title: String? = nil } struct AudioTrack: Codable { - var index: Int? - var startOffset: Double? + var index: Int? = nil + var startOffset: Double? = nil var duration: Double - var title: String? - var contentUrl: String? + var title: String? = "" + var contentUrl: String? = "" var mimeType: String - var metadata: FileMetadata? - // var isLocal: Bool - // var localFileId: String? - // var audioProbeResult: AudioProbeResult? Needed for local playback - var serverIndex: Int? + var metadata: FileMetadata? = nil + var isLocal: Bool + var localFileId: String? = "" +// var audioProbeResult: AudioProbeResult? // Needed for local playback. Requires local FFMPEG? Not sure how doable this is on iOS + var serverIndex: Int? = nil } -struct FileMetadata: Codable { - var filename: String - var ext: String - var path: String - var relPath: String +class FileMetadata: Object, Codable { + @Persisted var filename: String + @Persisted var ext: String + @Persisted var path: String + @Persisted var relPath: String } struct Library: Codable { var id: String @@ -125,17 +127,28 @@ struct Folder: Codable { } struct LibraryFile: Codable { var ino: String - var metadata: FileMetadata + var metadata: FileMetadata? } -struct MediaProgress:Codable { - var id:String - var libraryItemId:String - var episodeId:String? - var duration:Double - var progress:Double - var currentTime:Double - var isFinished:Bool - var lastUpdate:Int64 - var startedAt:Int64 - var finishedAt:Int64? +struct MediaProgress: Codable { + var id: String + var libraryItemId: String + var episodeId: String? + var duration: Double + var progress: Double + var currentTime: Double + var isFinished: Bool + var lastUpdate: Int64 + var startedAt: Int64 + var finishedAt: Int64? +} +struct PlaybackMetadata: Codable { + var duration: Double + var currentTime: Double + var playerState: PlayerState +} +enum PlayerState: Codable { + case IDLE + case BUFFERING + case READY + case ENDED } diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift new file mode 100644 index 00000000..cb5160a7 --- /dev/null +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -0,0 +1,153 @@ +// +// LocalLibrary.swift +// App +// +// Created by benonymity on 6/15/22. +// + +import Foundation +import RealmSwift + + +class LocalLibraryItem: Object, Codable { + @Persisted(primaryKey: true) var id: String + @Persisted var basePath: String + @Persisted var absolutePath: String + @Persisted var contentUrl: String + @Persisted var isInvalid: Bool + @Persisted var mediaType: String + @Persisted var media: MediaType? + @Persisted var localFiles: List + @Persisted var coverContentUrl: String? = nil + @Persisted var coverAbsolutePath: String? = nil + @Persisted var isLocal: Bool + @Persisted var serverConnectionConfigId: String? = nil + @Persisted var serverAddress: String? = nil + @Persisted var serverUserId: String? = nil + @Persisted var libraryItemId: String? = nil +} +class LocalMediaItem: Object, Codable { + @Persisted var id: String + @Persisted var name: String + @Persisted var mediaType: String + @Persisted var folderId: String + @Persisted var contentUrl: String + @Persisted var simplePath: String + @Persisted var basePath: String + @Persisted var absolutePath: String + @Persisted var audioTracks: List + @Persisted var localFiles: List + @Persisted var coverContentUrl: String? = "" + @Persisted var coverAbsolutePath: String? = "" +} +class MediaType: Object, Codable { + @Persisted var libraryItemId: String? = "" + @Persisted var metadata: Metadata? + @Persisted var coverPath: String? = "" + @Persisted var tags: List + @Persisted var audioFiles: List + @Persisted var chapters: List + @Persisted var tracks: List + @Persisted var size: Int64? = nil + @Persisted var duration: Double? = nil + @Persisted var episodes: List + @Persisted var autoDownloadEpisodes: Bool? = nil +} +class Metadata: Object, Codable { + @Persisted var title: String + @Persisted var subtitle: String? = "" + @Persisted var authors: List + @Persisted var narrators: List + @Persisted var genres: List + @Persisted var publishedYear: String? = "" + @Persisted var publishedDate: String? = "" + @Persisted var publisher: String? = "" + // I think calling the below variable description conflicts with some public variables declared in some of the Pods we use, so it's desc. ¯\_(ツ)_/¯ + @Persisted final var description: String + @Persisted var isbn: String? = "" + @Persisted var asin: String? = "" + @Persisted var language: String? = "" + @Persisted var explicit: Bool + @Persisted var authorName: String? = "" + @Persisted var authorNameLF: String? = "" + @Persisted var narratorName: String? = "" + @Persisted var seriesName: String? = "" + @Persisted var feedUrl: String? = "" +} +class PodcastEpisode: Object, Codable { + @Persisted var id: String + @Persisted var index: Int + @Persisted var episode: String? = "" + @Persisted var episodeType: String? = "" + @Persisted var title: String + @Persisted var subtitle: String? = "" + @Persisted var escription: String? = "" + @Persisted var audioFile: AudioFile? = nil + @Persisted var audioTrack: AudioTrack? = nil + @Persisted var duration: Double + @Persisted var size: Int64 +// @Persisted var serverEpisodeId: String? +} +class AudioFile: Object, Codable { + @Persisted var index: Int + @Persisted var ino: String + @Persisted var metadata: FileMetadata? +} +class Author: Object, Codable { + @Persisted var id: String + @Persisted var name: String + @Persisted var coverPath: String? = "" +} +class Chapter: Object, Codable { + @Persisted var id: Int + @Persisted var start: Double + @Persisted var end: Double + @Persisted var title: String? = nil +} +class AudioTrack: Object, Codable { + @Persisted var index: Int? = nil + @Persisted var startOffset: Double? = nil + @Persisted var duration: Double + @Persisted var title: String? = "" + @Persisted var contentUrl: String? = "" + @Persisted var mimeType: String + @Persisted var metadata: FileMetadata? = nil + @Persisted var isLocal: Bool + @Persisted var localFileId: String? = "" +// var audioProbeResult: AudioProbeResult? // Needed for local playback. Requires local FFMPEG? Not sure how doable this is on iOS + @Persisted var serverIndex: Int? = nil +} +class FileMetadata: Object, Codable { + @Persisted var filename: String + @Persisted var ext: String + @Persisted var path: String + @Persisted var relPath: String +} +class LocalFile: Object, Codable { + @Persisted var id: String + @Persisted var filename: String? = "" + @Persisted var contentUrl: String + @Persisted var basePath: String + @Persisted var absolutePath: String + @Persisted var simplePath: String + @Persisted var mimeType: String? = "" + @Persisted var size: Int64 +} +class LocalMediaProgress: Object, Codable { + @Persisted var id: String + @Persisted var localLibraryItemId: String + @Persisted var localEpisodeId: String? = "" + @Persisted var duration: Double + @Persisted var progress: Double // 0 to 1 + @Persisted var currentTime: Double + @Persisted var isFinished: Bool + @Persisted var lastUpdate: Int64 + @Persisted var startedAt: Int64 + @Persisted var finishedAt: Int64? = nil + // For local lib items from server to support server sync + @Persisted var serverConnectionConfigId: String? = "" + @Persisted var serverAddress: String? = "" + @Persisted var serverUserId: String? = "" + @Persisted var libraryItemId: String? = "" + @Persisted var episodeId: String? = "" +} diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index f2276336..82a4dbec 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -64,6 +64,7 @@ class Database { setLastActiveConfigIndex(index: config.index) } } + public func deleteServerConnectionConfig(id: String) { Database.realmQueue.sync { let config = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id) @@ -80,6 +81,7 @@ class Database { } } } + public func getServerConnectionConfigs() -> [ServerConnectionConfig] { var refrences: [ThreadSafeReference] = [] @@ -108,6 +110,7 @@ class Database { setLastActiveConfigIndex(index: nil) } } + public func setLastActiveConfigIndex(index: Int?) { let existing = instance.objects(ServerConnectionConfigActiveIndex.self) let obj = ServerConnectionConfigActiveIndex() @@ -123,6 +126,7 @@ class Database { debugPrint(exception) } } + public func getLastActiveConfigIndex() -> Int? { return Database.realmQueue.sync { return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil @@ -139,6 +143,49 @@ class Database { } } catch(let exception) { NSLog("failed to save device settings") + + public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] { + var localLibraryItems: [ThreadSafeReference] = [] + + Database.realmQueue.sync { + let items = instance.objects(LocalLibraryItem.self) + localLibraryItems = items.map { item in + return ThreadSafeReference(to: item) + } + } + + do { + let realm = try Realm() + + return localLibraryItems.map { item in + return realm.resolve(item)! + } + } catch(let exception) { + NSLog("error while readling local library items") + debugPrint(exception) + return [] + } + } + + public func getLocalLibraryItemByLLId(libraryItem: String) -> LocalLibraryItem? { + let items = getLocalLibraryItems() + for item in items { + if (item.id == libraryItem) { + return item + } + } + NSLog("Local library item with id \(libraryItem) not found") + return nil + } + + public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) { + Database.realmQueue.sync { + do { + try instance.write { + instance.add(localLibraryItem); + } + } catch(let exception) { + NSLog("Unable to save local library item") debugPrint(exception) } } @@ -148,4 +195,22 @@ class Database { return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() } } + + public func removeLocalLibraryItem(localLibraryItemId: String) { + let item = getLocalLibraryItemByLLId(libraryItem: localLibraryItemId) + Database.realmQueue.sync { + do { + try instance.write { + if item != nil { + instance.delete(item!) + } else { + NSLog("Unable to find local library item to delete") + } + } + } catch(let exception) { + NSLog("Unable to delete local library item") + debugPrint(exception) + } + } + } } diff --git a/pages/item/_id.vue b/pages/item/_id.vue index f01d283a..bf157cc4 100644 --- a/pages/item/_id.vue +++ b/pages/item/_id.vue @@ -307,7 +307,7 @@ export default { return this.ebookFile && this.ebookFormat !== 'pdf' }, showDownload() { - if (this.isIos) return false + // if (this.isIos) return false return this.user && this.userCanDownload && this.showPlay && !this.hasLocal }, ebookFile() { From 16ca7460f4b96b0bb9ad2178ea34b49f0e25edac Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 9 Jul 2022 15:18:15 -0400 Subject: [PATCH 002/103] Convert back to Swift objects --- ios/App/Shared/models/DataClasses.swift | 167 +++++++++++++----------- 1 file changed, 90 insertions(+), 77 deletions(-) diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index 753cf432..cea3d912 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -29,91 +29,99 @@ struct LibraryItem: Codable { var mediaType: String var media: MediaType var libraryFiles: [LibraryFile] - var userMediaProgress:MediaProgress? + var userMediaProgress: MediaProgress? } -class MediaType: Object, Codable { - var libraryItemId: String? = "" - var metadata: Metadata? - var coverPath: String? = "" - var tags: List - var audioFiles: List - var chapters: List - var tracks: List - var size: Int64? = nil - var duration: Double? = nil - var episodes: List - var autoDownloadEpisodes: Bool? = nil + +struct MediaType: Codable { + var libraryItemId: String? + var metadata: Metadata + var coverPath: String? + var tags: [String]? + var audioFiles: [AudioTrack]? + var chapters: [Chapter]? + var tracks: [AudioTrack]? + var size: Int64? + var duration: Double? + var episodes: [PodcastEpisode]? + var autoDownloadEpisodes: Bool? } -class Metadata: Object, Codable { + +struct Metadata: Codable { var title: String - var subtitle: String? = "" - var authors: List - var narrators: List - var genres: List - var publishedYear: String? = "" - var publishedDate: String? = "" - var publisher: String? = "" - // I think calling the below variable description conflicts with some public variables declared in some of the Pods we use, so it's desc. ¯\_(ツ)_/¯ - final var description: String - var isbn: String? = "" - var asin: String? = "" - var language: String? = "" + var subtitle: String? + var authors: [Author]? + var narrators: [String]? + var genres: [String] + var publishedYear: String? + var publishedDate: String? + var publisher: String? + var description: String? + var isbn: String? + var asin: String? + var language: String? var explicit: Bool - var authorName: String? = "" - var authorNameLF: String? = "" - var narratorName: String? = "" - var seriesName: String? = "" - var feedUrl: String? = "" + var authorName: String? + var authorNameLF: String? + var narratorName: String? + var seriesName: String? + var feedUrl: String? } -class PodcastEpisode: Object, Codable { + +struct PodcastEpisode: Codable { var id: String var index: Int - var episode: String? = "" - var episodeType: String? = "" + var episode: String? + var episodeType: String? var title: String - var subtitle: String? = "" - var escription: String? = "" - var audioFile: AudioFile? = nil - var audioTrack: AudioTrack? = nil + var subtitle: String? + var description: String? + var audioFile: AudioFile? + var audioTrack: AudioTrack? var duration: Double var size: Int64 // var serverEpisodeId: String? } -class AudioFile: Object, Codable { - @Persisted var index: Int - @Persisted var ino: String - @Persisted var metadata: FileMetadata? + +struct AudioFile: Codable { + var index: Int + var ino: String + var metadata: FileMetadata } -class Author: Object, Codable { - @Persisted var id: String - @Persisted var name: String - @Persisted var coverPath: String? = "" + +struct Author: Codable { + var id: String + var name: String + var coverPath: String? } -class Chapter: Object, Codable { - @Persisted var id: Int - @Persisted var start: Double - @Persisted var end: Double - @Persisted var title: String? = nil + +struct Chapter: Codable { + var id: Int + var start: Double + var end: Double + var title: String? } + struct AudioTrack: Codable { - var index: Int? = nil - var startOffset: Double? = nil + var index: Int? + var startOffset: Double? var duration: Double - var title: String? = "" - var contentUrl: String? = "" + var title: String? + var contentUrl: String? var mimeType: String - var metadata: FileMetadata? = nil - var isLocal: Bool - var localFileId: String? = "" -// var audioProbeResult: AudioProbeResult? // Needed for local playback. Requires local FFMPEG? Not sure how doable this is on iOS - var serverIndex: Int? = nil + var metadata: FileMetadata? + // var isLocal: Bool + // var localFileId: String? + // var audioProbeResult: AudioProbeResult? Needed for local playback + var serverIndex: Int? } -class FileMetadata: Object, Codable { - @Persisted var filename: String - @Persisted var ext: String - @Persisted var path: String - @Persisted var relPath: String + +struct FileMetadata: Codable { + var filename: String + var ext: String + var path: String + var relPath: String } + struct Library: Codable { var id: String var name: String @@ -121,31 +129,36 @@ struct Library: Codable { var icon: String var mediaType: String } + struct Folder: Codable { var id: String var fullPath: String } + struct LibraryFile: Codable { var ino: String - var metadata: FileMetadata? + var metadata: FileMetadata } -struct MediaProgress: Codable { - var id: String - var libraryItemId: String - var episodeId: String? - var duration: Double - var progress: Double - var currentTime: Double - var isFinished: Bool - var lastUpdate: Int64 - var startedAt: Int64 - var finishedAt: Int64? + +struct MediaProgress:Codable { + var id:String + var libraryItemId:String + var episodeId:String? + var duration:Double + var progress:Double + var currentTime:Double + var isFinished:Bool + var lastUpdate:Int64 + var startedAt:Int64 + var finishedAt:Int64? } + struct PlaybackMetadata: Codable { var duration: Double var currentTime: Double var playerState: PlayerState } + enum PlayerState: Codable { case IDLE case BUFFERING From affcdea09bfa9e31203e3a487f2dd83c15947583 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 9 Jul 2022 15:18:34 -0400 Subject: [PATCH 003/103] Fix class name collisions --- ios/App/Shared/models/LocalLibrary.swift | 53 ++++++++++++++---------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index cb5160a7..a1016226 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -16,7 +16,7 @@ class LocalLibraryItem: Object, Codable { @Persisted var contentUrl: String @Persisted var isInvalid: Bool @Persisted var mediaType: String - @Persisted var media: MediaType? + @Persisted var media: LocalMediaType? @Persisted var localFiles: List @Persisted var coverContentUrl: String? = nil @Persisted var coverAbsolutePath: String? = nil @@ -26,6 +26,7 @@ class LocalLibraryItem: Object, Codable { @Persisted var serverUserId: String? = nil @Persisted var libraryItemId: String? = nil } + class LocalMediaItem: Object, Codable { @Persisted var id: String @Persisted var name: String @@ -35,35 +36,37 @@ class LocalMediaItem: Object, Codable { @Persisted var simplePath: String @Persisted var basePath: String @Persisted var absolutePath: String - @Persisted var audioTracks: List + @Persisted var audioTracks: List @Persisted var localFiles: List @Persisted var coverContentUrl: String? = "" @Persisted var coverAbsolutePath: String? = "" } -class MediaType: Object, Codable { + +class LocalMediaType: Object, Codable { @Persisted var libraryItemId: String? = "" - @Persisted var metadata: Metadata? + @Persisted var metadata: LocalMetadata? @Persisted var coverPath: String? = "" @Persisted var tags: List - @Persisted var audioFiles: List - @Persisted var chapters: List - @Persisted var tracks: List + @Persisted var audioFiles: List + @Persisted var chapters: List + @Persisted var tracks: List @Persisted var size: Int64? = nil @Persisted var duration: Double? = nil - @Persisted var episodes: List + @Persisted var episodes: List @Persisted var autoDownloadEpisodes: Bool? = nil } -class Metadata: Object, Codable { + +class LocalMetadata: Object, Codable { @Persisted var title: String @Persisted var subtitle: String? = "" - @Persisted var authors: List + @Persisted var authors: List @Persisted var narrators: List @Persisted var genres: List @Persisted var publishedYear: String? = "" @Persisted var publishedDate: String? = "" @Persisted var publisher: String? = "" // I think calling the below variable description conflicts with some public variables declared in some of the Pods we use, so it's desc. ¯\_(ツ)_/¯ - @Persisted final var description: String + @Persisted final var desc: String @Persisted var isbn: String? = "" @Persisted var asin: String? = "" @Persisted var language: String? = "" @@ -74,7 +77,8 @@ class Metadata: Object, Codable { @Persisted var seriesName: String? = "" @Persisted var feedUrl: String? = "" } -class PodcastEpisode: Object, Codable { + +class LocalPodcastEpisode: Object, Codable { @Persisted var id: String @Persisted var index: Int @Persisted var episode: String? = "" @@ -82,47 +86,53 @@ class PodcastEpisode: Object, Codable { @Persisted var title: String @Persisted var subtitle: String? = "" @Persisted var escription: String? = "" - @Persisted var audioFile: AudioFile? = nil - @Persisted var audioTrack: AudioTrack? = nil + @Persisted var audioFile: LocalAudioFile? = nil + @Persisted var audioTrack: LocalAudioTrack? = nil @Persisted var duration: Double @Persisted var size: Int64 // @Persisted var serverEpisodeId: String? } -class AudioFile: Object, Codable { + +class LocalAudioFile: Object, Codable { @Persisted var index: Int @Persisted var ino: String - @Persisted var metadata: FileMetadata? + @Persisted var metadata: LocalFileMetadata? } -class Author: Object, Codable { + +class LocalAuthor: Object, Codable { @Persisted var id: String @Persisted var name: String @Persisted var coverPath: String? = "" } -class Chapter: Object, Codable { + +class LocalChapter: Object, Codable { @Persisted var id: Int @Persisted var start: Double @Persisted var end: Double @Persisted var title: String? = nil } -class AudioTrack: Object, Codable { + +class LocalAudioTrack: Object, Codable { @Persisted var index: Int? = nil @Persisted var startOffset: Double? = nil @Persisted var duration: Double @Persisted var title: String? = "" @Persisted var contentUrl: String? = "" @Persisted var mimeType: String - @Persisted var metadata: FileMetadata? = nil + @Persisted var metadata: LocalFileMetadata? = nil @Persisted var isLocal: Bool @Persisted var localFileId: String? = "" // var audioProbeResult: AudioProbeResult? // Needed for local playback. Requires local FFMPEG? Not sure how doable this is on iOS @Persisted var serverIndex: Int? = nil } -class FileMetadata: Object, Codable { + +class LocalFileMetadata: Object, Codable { @Persisted var filename: String @Persisted var ext: String @Persisted var path: String @Persisted var relPath: String } + class LocalFile: Object, Codable { @Persisted var id: String @Persisted var filename: String? = "" @@ -133,6 +143,7 @@ class LocalFile: Object, Codable { @Persisted var mimeType: String? = "" @Persisted var size: Int64 } + class LocalMediaProgress: Object, Codable { @Persisted var id: String @Persisted var localLibraryItemId: String From f40da562a6013a719a9efabb190439469b434190 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 9 Jul 2022 15:18:45 -0400 Subject: [PATCH 004/103] Fix missing braces --- ios/App/Shared/util/Database.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 82a4dbec..08548e4d 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -132,6 +132,7 @@ class Database { return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil } } + public func setDeviceSettings(deviceSettings: DeviceSettings) { Database.realmQueue.sync { let existing = instance.objects(DeviceSettings.self) @@ -143,6 +144,9 @@ class Database { } } catch(let exception) { NSLog("failed to save device settings") + } + } + } public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] { var localLibraryItems: [ThreadSafeReference] = [] @@ -190,6 +194,7 @@ class Database { } } } + public func getDeviceSettings() -> DeviceSettings { return Database.realmQueue.sync { return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() @@ -207,10 +212,10 @@ class Database { NSLog("Unable to find local library item to delete") } } - } catch(let exception) { - NSLog("Unable to delete local library item") - debugPrint(exception) - } + } catch (let exception) { + NSLog("Unable to delete local library item") + debugPrint(exception) } } + } } From 29f08a5e5de357e88cb19e06b8e9145be6be091e Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 9 Jul 2022 15:18:59 -0400 Subject: [PATCH 005/103] Fix use of optionals --- ios/App/App/plugins/AbsDownloader.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index d2b9a003..ea68e482 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -28,10 +28,11 @@ public class AbsDownloader: CAPPlugin { } } } + func startLibraryItemDownload(libraryItem: LibraryItem) { - let length = libraryItem.media.tracks.count + let length = libraryItem.media.tracks?.count ?? 0 if length > 0 { - libraryItem.media.tracks.enumerated().forEach { position, track in + libraryItem.media.tracks?.enumerated().forEach { position, track in NSLog("TRACK \(track.contentUrl!)") // filename needs to be encoded otherwise would just use contentUrl let filename = track.metadata?.filename ?? "" From a29a7669e1c47170f990b37896512c6f214e9c5f Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 9 Jul 2022 15:19:20 -0400 Subject: [PATCH 006/103] Add unrealm to use Swift object in db --- ios/App/Podfile | 1 + 1 file changed, 1 insertion(+) diff --git a/ios/App/Podfile b/ios/App/Podfile index dcdae730..9d135382 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -22,5 +22,6 @@ target 'App' do # Add your Pods here pod 'RealmSwift', '~>10' + pod 'Unrealm', '~>1.9' pod 'Alamofire', '~> 5.5' end From f56561b8984e727fb4b20fc9ca9f8b84a3f45570 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 9 Jul 2022 15:57:45 -0400 Subject: [PATCH 007/103] Refactor into smaller functions --- ios/App/App/plugins/AbsDownloader.swift | 109 ++++++++++++++---------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index ea68e482..4f33b51f 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -22,61 +22,84 @@ public class AbsDownloader: CAPPlugin { call.resolve() } else { NSLog("Got library item from server \(libraryItem!.id)") - - self.startLibraryItemDownload(libraryItem: libraryItem!) + self.startLibraryItemDownload(item: libraryItem!) call.resolve() } } } - func startLibraryItemDownload(libraryItem: LibraryItem) { - let length = libraryItem.media.tracks?.count ?? 0 + private func startLibraryItemDownload(item: LibraryItem) { + let length = item.media.tracks?.count ?? 0 if length > 0 { - libraryItem.media.tracks?.enumerated().forEach { position, track in - NSLog("TRACK \(track.contentUrl!)") - // filename needs to be encoded otherwise would just use contentUrl - let filename = track.metadata?.filename ?? "" - let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) - let urlstr = "\(Store.serverConfig!.address)/s/item/\(libraryItem.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" - let url = URL(string: urlstr)! - - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let itemDirectory = documentsDirectory.appendingPathComponent("\(libraryItem.id)") - NSLog("ITEM DIR \(itemDirectory)") - - // Create library item directory - do { - try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: false) - } catch { - NSLog("Failed to CREATE LI DIRECTORY \(error)") - } - - // Output filename - let trackFilename = itemDirectory.appendingPathComponent("\(filename)") - - let downloadTask = URLSession.shared.downloadTask(with: url) { urlOrNil, responseOrNil, errorOrNil in - - guard let fileURL = urlOrNil else { return } - - do { - NSLog("Download TMP file URL \(fileURL)") - let audioData = try Data(contentsOf:fileURL) - try audioData.write(to: trackFilename) - NSLog("Download written to \(trackFilename)") - } catch { - NSLog("FILE ERROR: \(error)") - } - } - downloadTask.resume() + item.media.tracks?.enumerated().forEach { position, track in + startLibraryItemTrackDownload(item: item, position: position, track: track) } } else { NSLog("No audio tracks for the supplied library item") } -// let encoder = JSONEncoder() -// let jsobj = try encoder.encode(Download) -// notifyListeners("onItemDownloadComplete", data: jsobj) + } + + private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) -> URLSessionDownloadTask? { + NSLog("TRACK \(track.contentUrl!)") + + // If we don't name metadata, then we can't proceed + guard let filename = track.metadata?.filename else { + NSLog("No metadata for track, unable to download") + return nil + } + + let serverUrl = urlForTrack(item: item, track: track) + let itemDirectory = createLibraryItemFileDirectory(item: item) + let localUrl = itemDirectory.appendingPathComponent("\(filename)") + + return downloadTrack(serverUrl: serverUrl, localUrl: localUrl) + } + + private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { + // filename needs to be encoded otherwise would just use contentUrl + let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) + let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" + return URL(string: urlstr)! + } + + private func createLibraryItemFileDirectory(item: LibraryItem) -> URL { + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let itemDirectory = documentsDirectory.appendingPathComponent("\(item.id)") + + NSLog("ITEM DIR \(itemDirectory)") + + // Create library item directory + do { + try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: false) + } catch { + NSLog("Failed to CREATE LI DIRECTORY \(error)") + } + + return itemDirectory + } + + private func downloadTrack(serverUrl: URL, localUrl: URL) -> URLSessionDownloadTask { + let downloadTask = URLSession.shared.downloadTask(with: serverUrl) { urlOrNil, responseOrNil, errorOrNil in + + guard let fileURL = urlOrNil else { return } + + do { + NSLog("Download TMP file URL \(fileURL)") + let audioData = try Data(contentsOf:fileURL) + try audioData.write(to: localUrl) + NSLog("Download written to \(localUrl)") + } catch { + NSLog("FILE ERROR: \(error)") + } + } + + // Start the download + downloadTask.resume() + + return downloadTask } } + struct DownloadItem: Codable { var isDownloading = false var progress: Float = 0 From e620f537058a223b7cacb3d7bb700cae79b1a294 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 9 Jul 2022 17:35:59 -0400 Subject: [PATCH 008/103] Get something saving in the database --- ios/App/App/plugins/AbsDownloader.swift | 13 ++- ios/App/Shared/models/LocalLibrary.swift | 119 ++++++++++++++++++----- 2 files changed, 103 insertions(+), 29 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 4f33b51f..78ddce22 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -10,6 +10,8 @@ import Capacitor @objc(AbsDownloader) public class AbsDownloader: CAPPlugin { + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + @objc func downloadLibraryItem(_ call: CAPPluginCall) { let libraryItemId = call.getString("libraryItemId") let episodeId = call.getString("episodeId") @@ -31,28 +33,32 @@ public class AbsDownloader: CAPPlugin { private func startLibraryItemDownload(item: LibraryItem) { let length = item.media.tracks?.count ?? 0 if length > 0 { + let localLibraryItem = LocalLibraryItem(item: item, localUrl: documentsDirectory, server: Store.serverConfig!) + item.media.tracks?.enumerated().forEach { position, track in startLibraryItemTrackDownload(item: item, position: position, track: track) } + + Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) } else { NSLog("No audio tracks for the supplied library item") } } - private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) -> URLSessionDownloadTask? { + private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) { NSLog("TRACK \(track.contentUrl!)") // If we don't name metadata, then we can't proceed guard let filename = track.metadata?.filename else { NSLog("No metadata for track, unable to download") - return nil + return } let serverUrl = urlForTrack(item: item, track: track) let itemDirectory = createLibraryItemFileDirectory(item: item) let localUrl = itemDirectory.appendingPathComponent("\(filename)") - return downloadTrack(serverUrl: serverUrl, localUrl: localUrl) + downloadTrack(serverUrl: serverUrl, localUrl: localUrl) } private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { @@ -63,7 +69,6 @@ public class AbsDownloader: CAPPlugin { } private func createLibraryItemFileDirectory(item: LibraryItem) -> URL { - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let itemDirectory = documentsDirectory.appendingPathComponent("\(item.id)") NSLog("ITEM DIR \(itemDirectory)") diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index a1016226..a079bebe 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -9,25 +9,72 @@ import Foundation import RealmSwift -class LocalLibraryItem: Object, Codable { +class LocalLibraryItem: Object { @Persisted(primaryKey: true) var id: String - @Persisted var basePath: String - @Persisted var absolutePath: String + @Persisted var basePath: String = "" + @Persisted var absolutePath: String = "" @Persisted var contentUrl: String - @Persisted var isInvalid: Bool + @Persisted var isInvalid: Bool = false @Persisted var mediaType: String @Persisted var media: LocalMediaType? @Persisted var localFiles: List @Persisted var coverContentUrl: String? = nil @Persisted var coverAbsolutePath: String? = nil - @Persisted var isLocal: Bool + @Persisted var isLocal: Bool = true @Persisted var serverConnectionConfigId: String? = nil @Persisted var serverAddress: String? = nil @Persisted var serverUserId: String? = nil @Persisted var libraryItemId: String? = nil + + override init() { + super.init() + } + + init(item: LibraryItem, localUrl: URL, server: ServerConnectionConfig) { + super.init() + self.id = item.id + self.contentUrl = localUrl.absoluteString + self.mediaType = item.mediaType + self.media = LocalMediaType(mediaType: item.media) + // TODO: self.localFiles + // TODO: self.coverContentURL + // TODO: self.converAbsolutePath + self.libraryItemId = item.id + self.serverConnectionConfigId = server.id + self.serverAddress = server.address + self.serverUserId = server.userId + } } -class LocalMediaItem: Object, Codable { +class LocalMediaType: Object { + @Persisted var libraryItemId: String? = "" + @Persisted var metadata: LocalMetadata? + @Persisted var coverPath: String? = "" + @Persisted var tags: List + @Persisted var audioFiles: List + @Persisted var chapters: List + @Persisted var tracks: List + @Persisted var size: Int64? = nil + @Persisted var duration: Double? = nil + @Persisted var episodes: List + @Persisted var autoDownloadEpisodes: Bool? = nil + + override init() { + super.init() + } + + init(mediaType: MediaType) { + super.init() + self.libraryItemId = mediaType.libraryItemId + self.metadata = LocalMetadata(metadata: mediaType.metadata) + self.tags.append(objectsIn: mediaType.tags ?? []) + self.size = mediaType.size + self.duration = mediaType.duration + self.autoDownloadEpisodes = mediaType.autoDownloadEpisodes + } +} + +class LocalMediaItem: Object { @Persisted var id: String @Persisted var name: String @Persisted var mediaType: String @@ -42,21 +89,7 @@ class LocalMediaItem: Object, Codable { @Persisted var coverAbsolutePath: String? = "" } -class LocalMediaType: Object, Codable { - @Persisted var libraryItemId: String? = "" - @Persisted var metadata: LocalMetadata? - @Persisted var coverPath: String? = "" - @Persisted var tags: List - @Persisted var audioFiles: List - @Persisted var chapters: List - @Persisted var tracks: List - @Persisted var size: Int64? = nil - @Persisted var duration: Double? = nil - @Persisted var episodes: List - @Persisted var autoDownloadEpisodes: Bool? = nil -} - -class LocalMetadata: Object, Codable { +class LocalMetadata: Object { @Persisted var title: String @Persisted var subtitle: String? = "" @Persisted var authors: List @@ -65,8 +98,7 @@ class LocalMetadata: Object, Codable { @Persisted var publishedYear: String? = "" @Persisted var publishedDate: String? = "" @Persisted var publisher: String? = "" - // I think calling the below variable description conflicts with some public variables declared in some of the Pods we use, so it's desc. ¯\_(ツ)_/¯ - @Persisted final var desc: String + @Persisted var desc: String? = "" @Persisted var isbn: String? = "" @Persisted var asin: String? = "" @Persisted var language: String? = "" @@ -76,6 +108,31 @@ class LocalMetadata: Object, Codable { @Persisted var narratorName: String? = "" @Persisted var seriesName: String? = "" @Persisted var feedUrl: String? = "" + + override init() { + super.init() + } + + init(metadata: Metadata) { + super.init() + self.title = metadata.title + self.subtitle = metadata.subtitle + self.narrators.append(objectsIn: metadata.narrators ?? []) + self.genres.append(objectsIn: metadata.genres) + self.publishedYear = metadata.publishedYear + self.publishedDate = metadata.publishedDate + self.publisher = metadata.publisher + self.desc = metadata.description + self.isbn = metadata.isbn + self.asin = metadata.asin + self.language = metadata.language + self.explicit = metadata.explicit + self.authorName = metadata.authorName + self.authorNameLF = metadata.authorNameLF + self.narratorName = metadata.narratorName + self.seriesName = metadata.seriesName + self.feedUrl = metadata.feedUrl + } } class LocalPodcastEpisode: Object, Codable { @@ -85,7 +142,7 @@ class LocalPodcastEpisode: Object, Codable { @Persisted var episodeType: String? = "" @Persisted var title: String @Persisted var subtitle: String? = "" - @Persisted var escription: String? = "" + @Persisted var desc: String? = "" @Persisted var audioFile: LocalAudioFile? = nil @Persisted var audioTrack: LocalAudioTrack? = nil @Persisted var duration: Double @@ -110,6 +167,18 @@ class LocalChapter: Object, Codable { @Persisted var start: Double @Persisted var end: Double @Persisted var title: String? = nil + + override init() { + super.init() + } + + init(chapter: Chapter) { + super.init() + self.id = chapter.id + self.start = chapter.start + self.end = chapter.end + self.title = chapter.title + } } class LocalAudioTrack: Object, Codable { @@ -133,7 +202,7 @@ class LocalFileMetadata: Object, Codable { @Persisted var relPath: String } -class LocalFile: Object, Codable { +class LocalFile: Object { @Persisted var id: String @Persisted var filename: String? = "" @Persisted var contentUrl: String From 175e64208130e30f03acce147adb10b6742620ce Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 9 Jul 2022 18:37:32 -0400 Subject: [PATCH 009/103] First pass at attempting to save library items --- ios/App/App/plugins/AbsDownloader.swift | 16 +++-- ios/App/Shared/models/DataClasses.swift | 2 +- ios/App/Shared/models/LocalLibrary.swift | 77 +++++++++++++++++------- 3 files changed, 64 insertions(+), 31 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 78ddce22..6db10669 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -33,25 +33,24 @@ public class AbsDownloader: CAPPlugin { private func startLibraryItemDownload(item: LibraryItem) { let length = item.media.tracks?.count ?? 0 if length > 0 { - let localLibraryItem = LocalLibraryItem(item: item, localUrl: documentsDirectory, server: Store.serverConfig!) - - item.media.tracks?.enumerated().forEach { position, track in - startLibraryItemTrackDownload(item: item, position: position, track: track) + let files = item.media.tracks!.enumerated().map { + position, track -> LocalFile in startLibraryItemTrackDownload(item: item, position: position, track: track) } + let localLibraryItem = LocalLibraryItem(item: item, localUrl: documentsDirectory, server: Store.serverConfig!, files: files) Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) } else { NSLog("No audio tracks for the supplied library item") } } - private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) { + private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) -> LocalFile { NSLog("TRACK \(track.contentUrl!)") // If we don't name metadata, then we can't proceed guard let filename = track.metadata?.filename else { NSLog("No metadata for track, unable to download") - return + return LocalFile() } let serverUrl = urlForTrack(item: item, track: track) @@ -59,6 +58,7 @@ public class AbsDownloader: CAPPlugin { let localUrl = itemDirectory.appendingPathComponent("\(filename)") downloadTrack(serverUrl: serverUrl, localUrl: localUrl) + return LocalFile(filename: filename, localUrl: localUrl) } private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { @@ -83,7 +83,7 @@ public class AbsDownloader: CAPPlugin { return itemDirectory } - private func downloadTrack(serverUrl: URL, localUrl: URL) -> URLSessionDownloadTask { + private func downloadTrack(serverUrl: URL, localUrl: URL) { let downloadTask = URLSession.shared.downloadTask(with: serverUrl) { urlOrNil, responseOrNil, errorOrNil in guard let fileURL = urlOrNil else { return } @@ -100,8 +100,6 @@ public class AbsDownloader: CAPPlugin { // Start the download downloadTask.resume() - - return downloadTask } } diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index cea3d912..b29b7a6f 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -37,7 +37,7 @@ struct MediaType: Codable { var metadata: Metadata var coverPath: String? var tags: [String]? - var audioFiles: [AudioTrack]? + var audioFiles: [AudioFile]? var chapters: [Chapter]? var tracks: [AudioTrack]? var size: Int64? diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index a079bebe..39f7da35 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -30,13 +30,13 @@ class LocalLibraryItem: Object { super.init() } - init(item: LibraryItem, localUrl: URL, server: ServerConnectionConfig) { + init(item: LibraryItem, localUrl: URL, server: ServerConnectionConfig, files: [LocalFile]) { super.init() self.id = item.id self.contentUrl = localUrl.absoluteString self.mediaType = item.mediaType self.media = LocalMediaType(mediaType: item.media) - // TODO: self.localFiles + self.localFiles.append(objectsIn: files) // TODO: self.coverContentURL // TODO: self.converAbsolutePath self.libraryItemId = item.id @@ -67,28 +67,24 @@ class LocalMediaType: Object { super.init() self.libraryItemId = mediaType.libraryItemId self.metadata = LocalMetadata(metadata: mediaType.metadata) + // TODO: self.coverPath self.tags.append(objectsIn: mediaType.tags ?? []) + self.audioFiles.append(objectsIn: mediaType.audioFiles!.enumerated().map() { + i, audioFile -> LocalAudioFile in LocalAudioFile(audioFile: audioFile) + }) + self.chapters.append(objectsIn: mediaType.chapters!.enumerated().map() { + i, chapter -> LocalChapter in LocalChapter(chapter: chapter) + }) + self.tracks.append(objectsIn: mediaType.tracks!.enumerated().map() { + i, track in LocalAudioTrack(track: track) + }) self.size = mediaType.size self.duration = mediaType.duration + // TODO: self.episodes self.autoDownloadEpisodes = mediaType.autoDownloadEpisodes } } -class LocalMediaItem: Object { - @Persisted var id: String - @Persisted var name: String - @Persisted var mediaType: String - @Persisted var folderId: String - @Persisted var contentUrl: String - @Persisted var simplePath: String - @Persisted var basePath: String - @Persisted var absolutePath: String - @Persisted var audioTracks: List - @Persisted var localFiles: List - @Persisted var coverContentUrl: String? = "" - @Persisted var coverAbsolutePath: String? = "" -} - class LocalMetadata: Object { @Persisted var title: String @Persisted var subtitle: String? = "" @@ -135,7 +131,7 @@ class LocalMetadata: Object { } } -class LocalPodcastEpisode: Object, Codable { +class LocalPodcastEpisode: Object { @Persisted var id: String @Persisted var index: Int @Persisted var episode: String? = "" @@ -154,6 +150,16 @@ class LocalAudioFile: Object, Codable { @Persisted var index: Int @Persisted var ino: String @Persisted var metadata: LocalFileMetadata? + + override init() { + super.init() + } + + init(audioFile: AudioFile) { + self.index = audioFile.index + self.ino = audioFile.ino + // TODO: self.metadata + } } class LocalAuthor: Object, Codable { @@ -181,7 +187,7 @@ class LocalChapter: Object, Codable { } } -class LocalAudioTrack: Object, Codable { +class LocalAudioTrack: Object { @Persisted var index: Int? = nil @Persisted var startOffset: Double? = nil @Persisted var duration: Double @@ -189,10 +195,25 @@ class LocalAudioTrack: Object, Codable { @Persisted var contentUrl: String? = "" @Persisted var mimeType: String @Persisted var metadata: LocalFileMetadata? = nil - @Persisted var isLocal: Bool + @Persisted var isLocal: Bool = true @Persisted var localFileId: String? = "" -// var audioProbeResult: AudioProbeResult? // Needed for local playback. Requires local FFMPEG? Not sure how doable this is on iOS @Persisted var serverIndex: Int? = nil + + override init() { + super.init() + } + + init(track: AudioTrack) { + self.index = track.index + self.startOffset = track.startOffset + self.duration = track.duration + self.title = track.title + self.contentUrl = track.contentUrl // TODO: Different URL + self.mimeType = track.mimeType + // TODO: self.metadata + // TODO: self.localFileId + self.serverIndex = track.serverIndex + } } class LocalFileMetadata: Object, Codable { @@ -211,6 +232,20 @@ class LocalFile: Object { @Persisted var simplePath: String @Persisted var mimeType: String? = "" @Persisted var size: Int64 + + override init() { + super.init() + } + + init(filename: String, localUrl: URL) { + self.filename = filename + self.contentUrl = localUrl.absoluteString + // TODO: self.baseUrl + self.absolutePath = localUrl.absoluteString + self.simplePath = localUrl.path + // TODO: self.mimeType + // TODO: self.size + } } class LocalMediaProgress: Object, Codable { From b7725c455bb76262db3f51e607a3ee7b1f504933 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 16 Jul 2022 20:25:04 -0400 Subject: [PATCH 010/103] Prototype response to app --- ios/App/App/plugins/AbsDatabase.swift | 8 +++++++- ios/App/Shared/models/LocalLibrary.swift | 21 ++++++++++++++------ ios/App/Shared/util/Extensions.swift | 25 ++++++++++++++++++------ 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index 673f002e..9d17805c 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -75,7 +75,13 @@ public class AbsDatabase: CAPPlugin { } @objc func getLocalLibraryItems(_ call: CAPPluginCall) { - call.resolve([ "value": [] ]) + do { + let items = Database.shared.getLocalLibraryItems() + call.resolve([ "value": try items.asDictionaryArray() ]) + } catch(let exception) { + NSLog("error while readling local library items") + debugPrint(exception) + } } @objc func getLocalLibraryItem(_ call: CAPPluginCall) { call.resolve() diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 39f7da35..7a66e093 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -9,7 +9,7 @@ import Foundation import RealmSwift -class LocalLibraryItem: Object { +class LocalLibraryItem: Object, Encodable { @Persisted(primaryKey: true) var id: String @Persisted var basePath: String = "" @Persisted var absolutePath: String = "" @@ -44,6 +44,15 @@ class LocalLibraryItem: Object { self.serverAddress = server.address self.serverUserId = server.userId } + + enum CodingKeys: CodingKey { + case id + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + } } class LocalMediaType: Object { @@ -146,7 +155,7 @@ class LocalPodcastEpisode: Object { // @Persisted var serverEpisodeId: String? } -class LocalAudioFile: Object, Codable { +class LocalAudioFile: Object { @Persisted var index: Int @Persisted var ino: String @Persisted var metadata: LocalFileMetadata? @@ -162,13 +171,13 @@ class LocalAudioFile: Object, Codable { } } -class LocalAuthor: Object, Codable { +class LocalAuthor: Object { @Persisted var id: String @Persisted var name: String @Persisted var coverPath: String? = "" } -class LocalChapter: Object, Codable { +class LocalChapter: Object { @Persisted var id: Int @Persisted var start: Double @Persisted var end: Double @@ -216,7 +225,7 @@ class LocalAudioTrack: Object { } } -class LocalFileMetadata: Object, Codable { +class LocalFileMetadata: Object { @Persisted var filename: String @Persisted var ext: String @Persisted var path: String @@ -248,7 +257,7 @@ class LocalFile: Object { } } -class LocalMediaProgress: Object, Codable { +class LocalMediaProgress: Object { @Persisted var id: String @Persisted var localLibraryItemId: String @Persisted var localEpisodeId: String? = "" diff --git a/ios/App/Shared/util/Extensions.swift b/ios/App/Shared/util/Extensions.swift index e4badb99..72088b9a 100644 --- a/ios/App/Shared/util/Extensions.swift +++ b/ios/App/Shared/util/Extensions.swift @@ -6,18 +6,31 @@ // import Foundation +import SwiftUI +import RealmSwift extension String: Error {} +typealias Dictionaryable = Encodable + extension Encodable { - func asDictionary() throws -> [String: Any] { - let data = try JSONEncoder().encode(self) - guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else { - throw NSError() + func asDictionary() throws -> [String: Any] { + let data = try JSONEncoder().encode(self) + guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else { + throw NSError() + } + return dictionary } - return dictionary - } } + +extension Collection where Iterator.Element: Encodable { + func asDictionaryArray() throws -> [[String: Any]] { + return try self.enumerated().map() { + i, element -> [String: Any] in try element.asDictionary() + } + } +} + extension DispatchQueue { static func runOnMainQueue(callback: @escaping (() -> Void)) { if Thread.isMainThread { From 52d0890032aa9693fabcb04eb4400327336e9e96 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 16 Jul 2022 21:46:49 -0400 Subject: [PATCH 011/103] Refactor to use extensions for objects --- ios/App/App.xcodeproj/project.pbxproj | 4 ++ ios/App/App/plugins/AbsDownloader.swift | 2 +- ios/App/Shared/models/LocalLibrary.swift | 39 ++------------ .../models/LocalLibraryExtensions.swift | 54 +++++++++++++++++++ 4 files changed, 63 insertions(+), 36 deletions(-) create mode 100644 ios/App/Shared/models/LocalLibraryExtensions.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 015d9deb..fcc0948b 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; C4B265F5285A5A6600E1B5C3 /* LocalLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */; }; C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; }; + E99C8C932883A00F00E3279A /* LocalLibraryExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -74,6 +75,7 @@ AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibrary.swift; sourceTree = ""; }; C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = ""; }; + E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibraryExtensions.swift; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -138,6 +140,7 @@ 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */, C4D0677428106D0C00B8F875 /* DataClasses.swift */, C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */, + E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */, 3A90295E280968E700E1D427 /* PlaybackReport.swift */, 4DF74911287105C600AC7814 /* DeviceSettings.swift */, ); @@ -338,6 +341,7 @@ C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */, 4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */, 3AB34055280832720039308B /* PlayerEvents.swift in Sources */, + E99C8C932883A00F00E3279A /* LocalLibraryExtensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 6db10669..8b8191b0 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -37,7 +37,7 @@ public class AbsDownloader: CAPPlugin { position, track -> LocalFile in startLibraryItemTrackDownload(item: item, position: position, track: track) } - let localLibraryItem = LocalLibraryItem(item: item, localUrl: documentsDirectory, server: Store.serverConfig!, files: files) + let localLibraryItem = LocalLibraryItem(item, localUrl: documentsDirectory, server: Store.serverConfig!, files: files) Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) } else { NSLog("No audio tracks for the supplied library item") diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 7a66e093..ffd98000 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -25,34 +25,6 @@ class LocalLibraryItem: Object, Encodable { @Persisted var serverAddress: String? = nil @Persisted var serverUserId: String? = nil @Persisted var libraryItemId: String? = nil - - override init() { - super.init() - } - - init(item: LibraryItem, localUrl: URL, server: ServerConnectionConfig, files: [LocalFile]) { - super.init() - self.id = item.id - self.contentUrl = localUrl.absoluteString - self.mediaType = item.mediaType - self.media = LocalMediaType(mediaType: item.media) - self.localFiles.append(objectsIn: files) - // TODO: self.coverContentURL - // TODO: self.converAbsolutePath - self.libraryItemId = item.id - self.serverConnectionConfigId = server.id - self.serverAddress = server.address - self.serverUserId = server.userId - } - - enum CodingKeys: CodingKey { - case id - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - } } class LocalMediaType: Object { @@ -113,13 +85,10 @@ class LocalMetadata: Object { @Persisted var narratorName: String? = "" @Persisted var seriesName: String? = "" @Persisted var feedUrl: String? = "" - - override init() { - super.init() - } - - init(metadata: Metadata) { - super.init() +} + +extension LocalMetadata { + convenience init(metadata: Metadata) { self.title = metadata.title self.subtitle = metadata.subtitle self.narrators.append(objectsIn: metadata.narrators ?? []) diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift new file mode 100644 index 00000000..7474e9cc --- /dev/null +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -0,0 +1,54 @@ +// +// LocalLibraryExtensions.swift +// App +// +// Created by Ron Heft on 7/16/22. +// + +import Foundation + +extension LocalLibraryItem { + enum CodingKeys: CodingKey { + case id + case basePath + case absolutePath + case contentUrl + case isInvalid + case mediaType + case media + case localFiles + case coverContentUrl + case coverAbsolutePath + case isLocal + case serverConnectionConfigId + case serverAddress + case serverUserId + case libraryItemId + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(basePath, forKey: .basePath) + try container.encode(absolutePath, forKey: .absolutePath) + try container.encode(contentUrl, forKey: .contentUrl) + try container.encode(isInvalid, forKey: .isInvalid) + try container.encode(mediaType, forKey: .mediaType) + //try container.encode(media, forKey: .media) + //try container.encode(localFiles, forKey: .localFiles) + } + + convenience init(_ item: LibraryItem, localUrl: URL, server: ServerConnectionConfig, files: [LocalFile]) { + self.init() + self.contentUrl = localUrl.absoluteString + self.mediaType = item.mediaType + self.media = LocalMediaType(mediaType: item.media) + self.localFiles.append(objectsIn: files) + // TODO: self.coverContentURL + // TODO: self.converAbsolutePath + self.libraryItemId = item.id + self.serverConnectionConfigId = server.id + self.serverAddress = server.address + self.serverUserId = server.userId + } +} From 2ab9dbc72d0b943f527ce4068a246ef5e6b72005 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sun, 17 Jul 2022 21:50:19 -0400 Subject: [PATCH 012/103] Set encoder and init methods --- ios/App/Shared/models/LocalLibrary.swift | 131 +------ .../models/LocalLibraryExtensions.swift | 360 +++++++++++++++++- 2 files changed, 373 insertions(+), 118 deletions(-) diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index ffd98000..98ea65c1 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -10,7 +10,7 @@ import RealmSwift class LocalLibraryItem: Object, Encodable { - @Persisted(primaryKey: true) var id: String + @Persisted(primaryKey: true) var id: String = UUID().uuidString @Persisted var basePath: String = "" @Persisted var absolutePath: String = "" @Persisted var contentUrl: String @@ -27,7 +27,7 @@ class LocalLibraryItem: Object, Encodable { @Persisted var libraryItemId: String? = nil } -class LocalMediaType: Object { +class LocalMediaType: Object, Encodable { @Persisted var libraryItemId: String? = "" @Persisted var metadata: LocalMetadata? @Persisted var coverPath: String? = "" @@ -39,34 +39,9 @@ class LocalMediaType: Object { @Persisted var duration: Double? = nil @Persisted var episodes: List @Persisted var autoDownloadEpisodes: Bool? = nil - - override init() { - super.init() - } - - init(mediaType: MediaType) { - super.init() - self.libraryItemId = mediaType.libraryItemId - self.metadata = LocalMetadata(metadata: mediaType.metadata) - // TODO: self.coverPath - self.tags.append(objectsIn: mediaType.tags ?? []) - self.audioFiles.append(objectsIn: mediaType.audioFiles!.enumerated().map() { - i, audioFile -> LocalAudioFile in LocalAudioFile(audioFile: audioFile) - }) - self.chapters.append(objectsIn: mediaType.chapters!.enumerated().map() { - i, chapter -> LocalChapter in LocalChapter(chapter: chapter) - }) - self.tracks.append(objectsIn: mediaType.tracks!.enumerated().map() { - i, track in LocalAudioTrack(track: track) - }) - self.size = mediaType.size - self.duration = mediaType.duration - // TODO: self.episodes - self.autoDownloadEpisodes = mediaType.autoDownloadEpisodes - } } -class LocalMetadata: Object { +class LocalMetadata: Object, Encodable { @Persisted var title: String @Persisted var subtitle: String? = "" @Persisted var authors: List @@ -87,30 +62,8 @@ class LocalMetadata: Object { @Persisted var feedUrl: String? = "" } -extension LocalMetadata { - convenience init(metadata: Metadata) { - self.title = metadata.title - self.subtitle = metadata.subtitle - self.narrators.append(objectsIn: metadata.narrators ?? []) - self.genres.append(objectsIn: metadata.genres) - self.publishedYear = metadata.publishedYear - self.publishedDate = metadata.publishedDate - self.publisher = metadata.publisher - self.desc = metadata.description - self.isbn = metadata.isbn - self.asin = metadata.asin - self.language = metadata.language - self.explicit = metadata.explicit - self.authorName = metadata.authorName - self.authorNameLF = metadata.authorNameLF - self.narratorName = metadata.narratorName - self.seriesName = metadata.seriesName - self.feedUrl = metadata.feedUrl - } -} - -class LocalPodcastEpisode: Object { - @Persisted var id: String +class LocalPodcastEpisode: Object, Encodable { + @Persisted var id: String = UUID().uuidString @Persisted var index: Int @Persisted var episode: String? = "" @Persisted var episodeType: String? = "" @@ -121,51 +74,29 @@ class LocalPodcastEpisode: Object { @Persisted var audioTrack: LocalAudioTrack? = nil @Persisted var duration: Double @Persisted var size: Int64 -// @Persisted var serverEpisodeId: String? + @Persisted var serverEpisodeId: String? } -class LocalAudioFile: Object { +class LocalAudioFile: Object, Encodable { @Persisted var index: Int @Persisted var ino: String @Persisted var metadata: LocalFileMetadata? - - override init() { - super.init() - } - - init(audioFile: AudioFile) { - self.index = audioFile.index - self.ino = audioFile.ino - // TODO: self.metadata - } } -class LocalAuthor: Object { - @Persisted var id: String +class LocalAuthor: Object, Encodable { + @Persisted var id: String = UUID().uuidString @Persisted var name: String @Persisted var coverPath: String? = "" } -class LocalChapter: Object { +class LocalChapter: Object, Encodable { @Persisted var id: Int @Persisted var start: Double @Persisted var end: Double @Persisted var title: String? = nil - - override init() { - super.init() - } - - init(chapter: Chapter) { - super.init() - self.id = chapter.id - self.start = chapter.start - self.end = chapter.end - self.title = chapter.title - } } -class LocalAudioTrack: Object { +class LocalAudioTrack: Object, Encodable { @Persisted var index: Int? = nil @Persisted var startOffset: Double? = nil @Persisted var duration: Double @@ -176,33 +107,17 @@ class LocalAudioTrack: Object { @Persisted var isLocal: Bool = true @Persisted var localFileId: String? = "" @Persisted var serverIndex: Int? = nil - - override init() { - super.init() - } - - init(track: AudioTrack) { - self.index = track.index - self.startOffset = track.startOffset - self.duration = track.duration - self.title = track.title - self.contentUrl = track.contentUrl // TODO: Different URL - self.mimeType = track.mimeType - // TODO: self.metadata - // TODO: self.localFileId - self.serverIndex = track.serverIndex - } } -class LocalFileMetadata: Object { +class LocalFileMetadata: Object, Encodable { @Persisted var filename: String @Persisted var ext: String @Persisted var path: String @Persisted var relPath: String } -class LocalFile: Object { - @Persisted var id: String +class LocalFile: Object, Encodable { + @Persisted var id: String = UUID().uuidString @Persisted var filename: String? = "" @Persisted var contentUrl: String @Persisted var basePath: String @@ -210,24 +125,10 @@ class LocalFile: Object { @Persisted var simplePath: String @Persisted var mimeType: String? = "" @Persisted var size: Int64 - - override init() { - super.init() - } - - init(filename: String, localUrl: URL) { - self.filename = filename - self.contentUrl = localUrl.absoluteString - // TODO: self.baseUrl - self.absolutePath = localUrl.absoluteString - self.simplePath = localUrl.path - // TODO: self.mimeType - // TODO: self.size - } } -class LocalMediaProgress: Object { - @Persisted var id: String +class LocalMediaProgress: Object, Encodable { + @Persisted var id: String = UUID().uuidString @Persisted var localLibraryItemId: String @Persisted var localEpisodeId: String? = "" @Persisted var duration: Double diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index 7474e9cc..bca58e30 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -34,15 +34,22 @@ extension LocalLibraryItem { try container.encode(contentUrl, forKey: .contentUrl) try container.encode(isInvalid, forKey: .isInvalid) try container.encode(mediaType, forKey: .mediaType) - //try container.encode(media, forKey: .media) - //try container.encode(localFiles, forKey: .localFiles) + try container.encode(media, forKey: .media) + try container.encode(localFiles, forKey: .localFiles) + try container.encode(coverContentUrl, forKey: .coverContentUrl) + try container.encode(coverAbsolutePath, forKey: .coverAbsolutePath) + try container.encode(isLocal, forKey: .isLocal) + try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId) + try container.encode(serverAddress, forKey: .serverAddress) + try container.encode(serverUserId, forKey: .serverUserId) + try container.encode(libraryItemId, forKey: .libraryItemId) } convenience init(_ item: LibraryItem, localUrl: URL, server: ServerConnectionConfig, files: [LocalFile]) { self.init() self.contentUrl = localUrl.absoluteString self.mediaType = item.mediaType - self.media = LocalMediaType(mediaType: item.media) + self.media = LocalMediaType(item.media) self.localFiles.append(objectsIn: files) // TODO: self.coverContentURL // TODO: self.converAbsolutePath @@ -52,3 +59,350 @@ extension LocalLibraryItem { self.serverUserId = server.userId } } + +extension LocalMediaType { + enum CodingKeys: CodingKey { + case libraryItemId + case metadata + case coverPath + case tags + case audioFiles + case chapters + case tracks + case size + case duration + case episodes + case autoDownloadEpisodes + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(libraryItemId, forKey: .libraryItemId) + try container.encode(metadata, forKey: .metadata) + try container.encode(coverPath, forKey: .coverPath) + try container.encode(tags, forKey: .tags) + try container.encode(audioFiles, forKey: .audioFiles) + try container.encode(chapters, forKey: .chapters) + try container.encode(tracks, forKey: .tracks) + try container.encode(size, forKey: .size) + try container.encode(duration, forKey: .duration) + try container.encode(episodes, forKey: .episodes) + try container.encode(autoDownloadEpisodes, forKey: .autoDownloadEpisodes) + } + + convenience init(_ mediaType: MediaType) { + self.init() + self.libraryItemId = mediaType.libraryItemId + self.metadata = LocalMetadata(mediaType.metadata) + // TODO: self.coverPath + self.tags.append(objectsIn: mediaType.tags ?? []) + self.audioFiles.append(objectsIn: mediaType.audioFiles!.enumerated().map() { + i, audioFile -> LocalAudioFile in LocalAudioFile(audioFile) + }) + self.chapters.append(objectsIn: mediaType.chapters!.enumerated().map() { + i, chapter -> LocalChapter in LocalChapter(chapter) + }) + self.tracks.append(objectsIn: mediaType.tracks!.enumerated().map() { + i, track in LocalAudioTrack(track) + }) + self.size = mediaType.size + self.duration = mediaType.duration + // TODO: self.episodes + self.autoDownloadEpisodes = mediaType.autoDownloadEpisodes + } +} + +extension LocalMetadata { + enum CodingKeys: CodingKey { + case title + case subtitle + case authors + case narrators + case genres + case publishedYear + case publishedDate + case publisher + case desc + case isbn + case asin + case language + case explicit + case authorName + case authorNameLF + case narratorName + case seriesName + case feedUrl + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(title, forKey: .title) + try container.encode(subtitle, forKey: .subtitle) + try container.encode(authors, forKey: .authors) + try container.encode(narrators, forKey: .narrators) + try container.encode(genres, forKey: .genres) + try container.encode(publishedYear, forKey: .publishedYear) + try container.encode(publishedDate, forKey: .publishedDate) + try container.encode(publisher, forKey: .publisher) + try container.encode(desc, forKey: .desc) + try container.encode(isbn, forKey: .isbn) + try container.encode(asin, forKey: .asin) + try container.encode(language, forKey: .language) + try container.encode(explicit, forKey: .explicit) + try container.encode(authorName, forKey: .authorName) + try container.encode(authorNameLF, forKey: .authorNameLF) + try container.encode(narratorName, forKey: .narratorName) + try container.encode(seriesName, forKey: .seriesName) + try container.encode(feedUrl, forKey: .feedUrl) + } + + convenience init(_ metadata: Metadata) { + self.init() + self.title = metadata.title + self.subtitle = metadata.subtitle + self.narrators.append(objectsIn: metadata.narrators ?? []) + self.genres.append(objectsIn: metadata.genres) + self.publishedYear = metadata.publishedYear + self.publishedDate = metadata.publishedDate + self.publisher = metadata.publisher + self.desc = metadata.description + self.isbn = metadata.isbn + self.asin = metadata.asin + self.language = metadata.language + self.explicit = metadata.explicit + self.authorName = metadata.authorName + self.authorNameLF = metadata.authorNameLF + self.narratorName = metadata.narratorName + self.seriesName = metadata.seriesName + self.feedUrl = metadata.feedUrl + } +} + +extension LocalPodcastEpisode { + enum CodingKeys: CodingKey { + case id + case index + case episode + case episodeType + case title + case subtitle + case desc + case audioFile + case audioTrack + case duration + case size + case serverEpisodeId + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(index, forKey: .index) + try container.encode(episode, forKey: .episode) + try container.encode(episodeType, forKey: .episodeType) + try container.encode(title, forKey: .title) + try container.encode(subtitle, forKey: .subtitle) + try container.encode(desc, forKey: .desc) + try container.encode(audioFile, forKey: .audioFile) + try container.encode(audioTrack, forKey: .audioTrack) + try container.encode(duration, forKey: .duration) + try container.encode(size, forKey: .size) + try container.encode(serverEpisodeId, forKey: .serverEpisodeId) + } +} + +extension LocalAudioFile { + enum CodingKeys: CodingKey { + case index + case ino + case metadata + } + + convenience init(_ audioFile: AudioFile) { + self.init() + self.index = audioFile.index + self.ino = audioFile.ino + // TODO: self.metadata + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(index, forKey: .index) + try container.encode(ino, forKey: .ino) + try container.encode(metadata, forKey: .metadata) + } +} + +extension LocalAuthor { + enum CodingKeys: CodingKey { + case id + case name + case coverPath + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(coverPath, forKey: .coverPath) + } +} + +extension LocalChapter { + enum CodingKeys: CodingKey { + case id + case start + case end + case title + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(start, forKey: .start) + try container.encode(end, forKey: .end) + try container.encode(title, forKey: .title) + } + + convenience init(_ chapter: Chapter) { + self.init() + self.id = chapter.id + self.start = chapter.start + self.end = chapter.end + self.title = chapter.title + } +} + +extension LocalAudioTrack { + enum CodingKeys: CodingKey { + case index + case startOffset + case duration + case title + case contentUrl + case mimeType + case metadata + case isLocal + case localFileId + case serverIndex + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(index, forKey: .index) + try container.encode(startOffset, forKey: .startOffset) + try container.encode(duration, forKey: .duration) + try container.encode(title, forKey: .title) + try container.encode(contentUrl, forKey: .contentUrl) + try container.encode(mimeType, forKey: .mimeType) + try container.encode(metadata, forKey: .metadata) + try container.encode(isLocal, forKey: .isLocal) + try container.encode(localFileId, forKey: .localFileId) + try container.encode(serverIndex, forKey: .serverIndex) + } + + convenience init(_ track: AudioTrack) { + self.init() + self.index = track.index + self.startOffset = track.startOffset + self.duration = track.duration + self.title = track.title + self.contentUrl = track.contentUrl // TODO: Different URL + self.mimeType = track.mimeType + // TODO: self.metadata + // TODO: self.localFileId + self.serverIndex = track.serverIndex + } +} + +extension LocalFileMetadata { + enum CodingKeys: CodingKey { + case filename + case ext + case path + case relPath + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(filename, forKey: .filename) + try container.encode(ext, forKey: .ext) + try container.encode(path, forKey: .path) + try container.encode(relPath, forKey: .relPath) + } +} + +extension LocalFile { + enum CodingKeys: CodingKey { + case id + case filename + case contentUrl + case basePath + case absolutePath + case simplePath + case mimeType + case size + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(filename, forKey: .filename) + try container.encode(contentUrl, forKey: .contentUrl) + try container.encode(basePath, forKey: .basePath) + try container.encode(absolutePath, forKey: .absolutePath) + try container.encode(simplePath, forKey: .simplePath) + try container.encode(mimeType, forKey: .mimeType) + try container.encode(size, forKey: .size) + } + + convenience init(filename: String, localUrl: URL) { + self.init() + self.filename = filename + self.contentUrl = localUrl.absoluteString + // TODO: self.baseUrl + self.absolutePath = localUrl.absoluteString + self.simplePath = localUrl.path + // TODO: self.mimeType + // TODO: self.size + } +} + +extension LocalMediaProgress { + enum CodingKeys: CodingKey { + case id + case localLibraryItemId + case localEpisodeId + case duration + case progress + case currentTime + case isFinished + case lastUpdate + case startedAt + case finishedAt + case serverConnectionConfigId + case serverAddress + case serverUserId + case libraryItemId + case episodeId + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(localLibraryItemId, forKey: .localLibraryItemId) + try container.encode(localEpisodeId, forKey: .localEpisodeId) + try container.encode(duration, forKey: .duration) + try container.encode(progress, forKey: .progress) + try container.encode(currentTime, forKey: .currentTime) + try container.encode(isFinished, forKey: .isFinished) + try container.encode(lastUpdate, forKey: .lastUpdate) + try container.encode(startedAt, forKey: .startedAt) + try container.encode(finishedAt, forKey: .finishedAt) + try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId) + try container.encode(serverAddress, forKey: .serverAddress) + try container.encode(serverUserId, forKey: .serverUserId) + try container.encode(libraryItemId, forKey: .libraryItemId) + try container.encode(episodeId, forKey: .episodeId) + } +} From fec1ec554bf92811ea509697e288a25fa6543fe5 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 30 Jul 2022 16:22:41 -0400 Subject: [PATCH 013/103] feat: Populate LocalFile with real info --- ios/App/App/plugins/AbsDownloader.swift | 2 +- ios/App/Shared/models/LocalLibrary.swift | 2 -- .../models/LocalLibraryExtensions.swift | 24 +++++++++++-------- ios/App/Shared/util/Extensions.swift | 23 ++++++++++++++++++ 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 8b8191b0..4231cc93 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -58,7 +58,7 @@ public class AbsDownloader: CAPPlugin { let localUrl = itemDirectory.appendingPathComponent("\(filename)") downloadTrack(serverUrl: serverUrl, localUrl: localUrl) - return LocalFile(filename: filename, localUrl: localUrl) + return LocalFile(filename, track.mimeType, localUrl) } private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 98ea65c1..64ed2c0b 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -120,9 +120,7 @@ class LocalFile: Object, Encodable { @Persisted var id: String = UUID().uuidString @Persisted var filename: String? = "" @Persisted var contentUrl: String - @Persisted var basePath: String @Persisted var absolutePath: String - @Persisted var simplePath: String @Persisted var mimeType: String? = "" @Persisted var size: Int64 } diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index bca58e30..8447abb4 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -337,9 +337,7 @@ extension LocalFile { case id case filename case contentUrl - case basePath case absolutePath - case simplePath case mimeType case size } @@ -349,22 +347,28 @@ extension LocalFile { try container.encode(id, forKey: .id) try container.encode(filename, forKey: .filename) try container.encode(contentUrl, forKey: .contentUrl) - try container.encode(basePath, forKey: .basePath) try container.encode(absolutePath, forKey: .absolutePath) - try container.encode(simplePath, forKey: .simplePath) try container.encode(mimeType, forKey: .mimeType) try container.encode(size, forKey: .size) } - convenience init(filename: String, localUrl: URL) { + convenience init(_ filename: String, _ mimeType: String, _ localUrl: URL) { self.init() + self.id = localUrl.absoluteString.toBase64() self.filename = filename self.contentUrl = localUrl.absoluteString - // TODO: self.baseUrl - self.absolutePath = localUrl.absoluteString - self.simplePath = localUrl.path - // TODO: self.mimeType - // TODO: self.size + self.absolutePath = localUrl.path + self.size = localUrl.fileSize + } + + func isAudioFile() -> Bool { + switch self.mimeType { + case "application/octet-stream", + "video/mp4": + return true + default: + return self.mimeType?.starts(with: "audio") ?? false + } } } diff --git a/ios/App/Shared/util/Extensions.swift b/ios/App/Shared/util/Extensions.swift index 72088b9a..36d14b6b 100644 --- a/ios/App/Shared/util/Extensions.swift +++ b/ios/App/Shared/util/Extensions.swift @@ -42,3 +42,26 @@ extension DispatchQueue { } } } + +extension URL { + var attributes: [FileAttributeKey : Any]? { + do { + return try FileManager.default.attributesOfItem(atPath: path) + } catch let error as NSError { + print("FileAttribute error: \(error)") + } + return nil + } + + var fileSize: Int64 { + return attributes?[.size] as? Int64 ?? Int64(0) + } + + var fileSizeString: String { + return ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file) + } + + var creationDate: Date? { + return attributes?[.creationDate] as? Date + } +} From 9eca03cfd75e6c35f0dd743801f1dbebf1b628ca Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 30 Jul 2022 17:21:50 -0400 Subject: [PATCH 014/103] Configure more local file data --- ios/App/App/plugins/AbsDownloader.swift | 4 +-- ios/App/Shared/models/LocalLibrary.swift | 1 - .../models/LocalLibraryExtensions.swift | 30 ++++++++++++------- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 4231cc93..37cd92c2 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -58,7 +58,7 @@ public class AbsDownloader: CAPPlugin { let localUrl = itemDirectory.appendingPathComponent("\(filename)") downloadTrack(serverUrl: serverUrl, localUrl: localUrl) - return LocalFile(filename, track.mimeType, localUrl) + return LocalFile(item.id, filename, track.mimeType, localUrl) } private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { @@ -75,7 +75,7 @@ public class AbsDownloader: CAPPlugin { // Create library item directory do { - try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: false) + try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: true) } catch { NSLog("Failed to CREATE LI DIRECTORY \(error)") } diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 64ed2c0b..3be6c823 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -8,7 +8,6 @@ import Foundation import RealmSwift - class LocalLibraryItem: Object, Encodable { @Persisted(primaryKey: true) var id: String = UUID().uuidString @Persisted var basePath: String = "" diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index 8447abb4..73d203e0 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -49,7 +49,7 @@ extension LocalLibraryItem { self.init() self.contentUrl = localUrl.absoluteString self.mediaType = item.mediaType - self.media = LocalMediaType(item.media) + self.media = LocalMediaType(item.media, coverPath: "", files: files) self.localFiles.append(objectsIn: files) // TODO: self.coverContentURL // TODO: self.converAbsolutePath @@ -90,11 +90,11 @@ extension LocalMediaType { try container.encode(autoDownloadEpisodes, forKey: .autoDownloadEpisodes) } - convenience init(_ mediaType: MediaType) { + convenience init(_ mediaType: MediaType, coverPath: String, files: [LocalFile]) { self.init() self.libraryItemId = mediaType.libraryItemId self.metadata = LocalMetadata(mediaType.metadata) - // TODO: self.coverPath + self.coverPath = coverPath self.tags.append(objectsIn: mediaType.tags ?? []) self.audioFiles.append(objectsIn: mediaType.audioFiles!.enumerated().map() { i, audioFile -> LocalAudioFile in LocalAudioFile(audioFile) @@ -103,11 +103,12 @@ extension LocalMediaType { i, chapter -> LocalChapter in LocalChapter(chapter) }) self.tracks.append(objectsIn: mediaType.tracks!.enumerated().map() { - i, track in LocalAudioTrack(track) + i, track in LocalAudioTrack(track, libraryItemId: self.libraryItemId ?? "", filename: files[i].filename ?? "") }) self.size = mediaType.size self.duration = mediaType.duration // TODO: self.episodes + // TODO: Handle podcast auto downloads self.autoDownloadEpisodes = mediaType.autoDownloadEpisodes } } @@ -222,7 +223,7 @@ extension LocalAudioFile { self.init() self.index = audioFile.index self.ino = audioFile.ino - // TODO: self.metadata + // self.metadata } func encode(to encoder: Encoder) throws { @@ -246,6 +247,13 @@ extension LocalAuthor { try container.encode(name, forKey: .name) try container.encode(coverPath, forKey: .coverPath) } + + convenience init(_ author: Author) { + self.init() + self.id = author.id + self.name = author.name + // self.coverPath + } } extension LocalChapter { @@ -301,16 +309,16 @@ extension LocalAudioTrack { try container.encode(serverIndex, forKey: .serverIndex) } - convenience init(_ track: AudioTrack) { + convenience init(_ track: AudioTrack, libraryItemId: String, filename: String) { self.init() self.index = track.index self.startOffset = track.startOffset self.duration = track.duration self.title = track.title - self.contentUrl = track.contentUrl // TODO: Different URL + self.contentUrl = "" // TODO: Different URL self.mimeType = track.mimeType // TODO: self.metadata - // TODO: self.localFileId + self.localFileId = "\(libraryItemId)_\(filename.toBase64())" self.serverIndex = track.serverIndex } } @@ -330,6 +338,8 @@ extension LocalFileMetadata { try container.encode(path, forKey: .path) try container.encode(relPath, forKey: .relPath) } + + /* TODO: Can we skip this object? */ } extension LocalFile { @@ -352,9 +362,9 @@ extension LocalFile { try container.encode(size, forKey: .size) } - convenience init(_ filename: String, _ mimeType: String, _ localUrl: URL) { + convenience init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: URL) { self.init() - self.id = localUrl.absoluteString.toBase64() + self.id = "\(libraryItemId)_\(filename.toBase64())" self.filename = filename self.contentUrl = localUrl.absoluteString self.absolutePath = localUrl.path From 32550a75ec9df4f1e92f5d5229b44f1e5dab4035 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 30 Jul 2022 18:25:20 -0400 Subject: [PATCH 015/103] Return local items on DB lookup --- ios/App/App/plugins/AbsDatabase.swift | 32 +++++++++++++++++-- .../models/LocalLibraryExtensions.swift | 10 +++--- ios/App/Shared/util/Database.swift | 13 +++++++- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index 8d35b708..e08ee0d9 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -81,14 +81,42 @@ public class AbsDatabase: CAPPlugin { } catch(let exception) { NSLog("error while readling local library items") debugPrint(exception) + call.resolve() } } + @objc func getLocalLibraryItem(_ call: CAPPluginCall) { - call.resolve() + do { + let item = Database.shared.getLocalLibraryItemByLLId(libraryItem: call.getString("id") ?? "") + switch item { + case .some(let foundItem): + call.resolve(try foundItem.asDictionary()) + default: + call.resolve() + } + } catch(let exception) { + NSLog("error while readling local library items") + debugPrint(exception) + call.resolve() + } } + @objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) { - call.resolve() + do { + let item = Database.shared.getLocalLibraryItemByLLId(libraryItem: call.getString("libraryItemId") ?? "") + switch item { + case .some(let foundItem): + call.resolve(try foundItem.asDictionary()) + default: + call.resolve() + } + } catch(let exception) { + NSLog("error while readling local library items") + debugPrint(exception) + call.resolve() + } } + @objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) { call.resolve([ "value": [] ]) } diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index 73d203e0..0a72f9ff 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -373,11 +373,11 @@ extension LocalFile { func isAudioFile() -> Bool { switch self.mimeType { - case "application/octet-stream", - "video/mp4": - return true - default: - return self.mimeType?.starts(with: "audio") ?? false + case "application/octet-stream", + "video/mp4": + return true + default: + return self.mimeType?.starts(with: "audio") ?? false } } } diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 08548e4d..58274b14 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -174,7 +174,7 @@ class Database { public func getLocalLibraryItemByLLId(libraryItem: String) -> LocalLibraryItem? { let items = getLocalLibraryItems() for item in items { - if (item.id == libraryItem) { + if (item.libraryItemId == libraryItem) { return item } } @@ -182,6 +182,17 @@ class Database { return nil } + public func getLocalLibraryItem(localLibraryItem: String) -> LocalLibraryItem? { + let items = getLocalLibraryItems() + for item in items { + if (item.id == localLibraryItem) { + return item + } + } + NSLog("Local library item with id \(localLibraryItem) not found") + return nil + } + public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) { Database.realmQueue.sync { do { From 76de92fe1ff3b01b2434b63691538a89c19ba9f0 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 30 Jul 2022 18:40:30 -0400 Subject: [PATCH 016/103] Fix id typos --- ios/App/App/plugins/AbsDatabase.swift | 2 +- ios/App/Shared/models/LocalLibrary.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index e08ee0d9..e7ed178c 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -87,7 +87,7 @@ public class AbsDatabase: CAPPlugin { @objc func getLocalLibraryItem(_ call: CAPPluginCall) { do { - let item = Database.shared.getLocalLibraryItemByLLId(libraryItem: call.getString("id") ?? "") + let item = Database.shared.getLocalLibraryItem(localLibraryItem: call.getString("id") ?? "") switch item { case .some(let foundItem): call.resolve(try foundItem.asDictionary()) diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 3be6c823..52d908c3 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -9,7 +9,7 @@ import Foundation import RealmSwift class LocalLibraryItem: Object, Encodable { - @Persisted(primaryKey: true) var id: String = UUID().uuidString + @Persisted(primaryKey: true) var id: String = "local_\(UUID().uuidString)" @Persisted var basePath: String = "" @Persisted var absolutePath: String = "" @Persisted var contentUrl: String From 0b46a9c9b17d56c29c15c70dbb2665dc89efa749 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sun, 31 Jul 2022 13:33:36 -0400 Subject: [PATCH 017/103] Begin handling playback of local media --- ios/App/App/plugins/AbsAudioPlayer.swift | 40 +++++++++------- ios/App/Shared/models/LocalLibrary.swift | 2 +- .../models/LocalLibraryExtensions.swift | 47 +++++++++++++++++++ ios/App/Shared/models/PlaybackSession.swift | 4 +- ios/App/Shared/util/Database.swift | 22 +++++++++ 5 files changed, 95 insertions(+), 20 deletions(-) diff --git a/ios/App/App/plugins/AbsAudioPlayer.swift b/ios/App/App/plugins/AbsAudioPlayer.swift index d7f00e28..a66d3b36 100644 --- a/ios/App/App/plugins/AbsAudioPlayer.swift +++ b/ios/App/App/plugins/AbsAudioPlayer.swift @@ -36,30 +36,36 @@ public class AbsAudioPlayer: CAPPlugin { NSLog("provide library item id") return call.resolve() } - if libraryItemId!.starts(with: "local") { - NSLog("local items are not implemnted") - return call.resolve() - } initialPlayWhenReady = playWhenReady initialPlaybackRate = playbackRate PlayerHandler.stopPlayback() - sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady) - ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in - do { - self.sendPlaybackSession(session: try session.asDictionary()) - call.resolve(try session.asDictionary()) - } catch(let exception) { - NSLog("failed to convert session to json") - debugPrint(exception) - call.resolve([:]) - } - - - PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate) + let isLocalItem = libraryItemId?.starts(with: "local_") ?? false + if (isLocalItem) { + let item = Database.shared.getLocalLibraryItem(localLibraryItem: libraryItemId!) + // TODO: Logic required for podcasts here + let playbackSession = item?.getPlaybackSession(episode: nil) + PlayerHandler.startPlayback(session: playbackSession!, playWhenReady: playWhenReady, playbackRate: playbackRate) self.sendMetadata() + call.resolve() + } else { // Playing from the server + sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady) + ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in + do { + self.sendPlaybackSession(session: try session.asDictionary()) + call.resolve(try session.asDictionary()) + } catch(let exception) { + NSLog("failed to convert session to json") + debugPrint(exception) + call.resolve([:]) + } + + + PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate) + self.sendMetadata() + } } } @objc func closePlayback(_ call: CAPPluginCall) { diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 52d908c3..fb5c4f36 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -125,7 +125,7 @@ class LocalFile: Object, Encodable { } class LocalMediaProgress: Object, Encodable { - @Persisted var id: String = UUID().uuidString + @Persisted(primaryKey: true) var id: String = UUID().uuidString @Persisted var localLibraryItemId: String @Persisted var localEpisodeId: String? = "" @Persisted var duration: Double diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index 0a72f9ff..c08337ce 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -58,6 +58,53 @@ extension LocalLibraryItem { self.serverAddress = server.address self.serverUserId = server.userId } + + func getDuration() -> Double { + var total = 0.0 + self.media?.tracks.forEach { track in total += track.duration } + return total + } + + func getPlaybackSession(episode: LocalPodcastEpisode?) -> PlaybackSession { + let localEpisodeId = episode?.id + let sessionId = "play_local_\(UUID().uuidString)" + + // Get current progress from local media + let mediaProgressId = (localEpisodeId != nil) ? "\(self.id)-\(localEpisodeId!)" : self.id + let mediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: mediaProgressId) + + // TODO: Clean up add mediaType methods for displayTitle and displayAuthor + let mediaMetadata = self.media?.metadata + let audioTracks = self.media?.tracks + let authorName = mediaMetadata?.authorName + + if let episode = episode { + // TODO: Implement podcast + } + + let dateNow = Date().timeIntervalSince1970 + return PlaybackSession( + id: sessionId, + userId: self.serverUserId, + libraryItemId: self.libraryItemId, + episodeId: episode?.serverEpisodeId, + mediaType: self.mediaType, + chapters: [], + displayTitle: mediaMetadata?.title, + displayAuthor: authorName, + coverPath: nil, + duration: self.getDuration(), + playMethod: 3, + startedAt: dateNow, + updatedAt: 0, + timeListening: 0.0, + audioTracks: [], + currentTime: mediaProgress?.currentTime ?? 0.0, + libraryItem: nil, + serverConnectionConfigId: self.serverConnectionConfigId, + serverAddress: self.serverAddress + ) + } } extension LocalMediaType { diff --git a/ios/App/Shared/models/PlaybackSession.swift b/ios/App/Shared/models/PlaybackSession.swift index 483da125..d5b75293 100644 --- a/ios/App/Shared/models/PlaybackSession.swift +++ b/ios/App/Shared/models/PlaybackSession.swift @@ -25,8 +25,8 @@ struct PlaybackSession: Decodable, Encodable { var timeListening: Double var audioTracks: [AudioTrack] var currentTime: Double - var libraryItem: LibraryItem - // var localLibraryItem: LocalLibraryItem? + var libraryItem: LibraryItem? + //var localLibraryItem: LocalLibraryItem? var serverConnectionConfigId: String? var serverAddress: String? } diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 58274b14..0ec073d9 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -229,4 +229,26 @@ class Database { } } } + + public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) { + Database.realmQueue.sync { + try! instance.write { instance.add(mediaProgress) } + } + } + + // For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}" + public func getLocalMediaProgress(localMediaProgressId: String) -> LocalMediaProgress? { + Database.realmQueue.sync { + instance.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) + } + } + + public func removeLocalMediaProgress(localMediaProgressId: String) { + Database.realmQueue.sync { + try! instance.write { + let progress = instance.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) + instance.delete(progress!) + } + } + } } From f6c43e479dee685599ca736b6c5a2983c01c73c2 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Mon, 1 Aug 2022 09:40:28 -0400 Subject: [PATCH 018/103] Rewrite of object model to use Unrealm This addresses issues with JSON serialization --- .../xcshareddata/IDEWorkspaceChecks.plist | 8 + ios/App/App/AppDelegate.swift | 28 +- ios/App/App/plugins/AbsDatabase.swift | 16 +- ios/App/Shared/models/DataClasses.swift | 174 ++++++-- ios/App/Shared/models/DeviceSettings.swift | 18 +- ios/App/Shared/models/LocalLibrary.swift | 196 ++++----- .../models/LocalLibraryExtensions.swift | 389 +----------------- .../models/ServerConnectionConfig.swift | 31 +- ios/App/Shared/util/ApiClient.swift | 2 + ios/App/Shared/util/Database.swift | 93 +---- ios/App/Shared/util/Store.swift | 2 +- package-lock.json | 2 +- 12 files changed, 297 insertions(+), 662 deletions(-) create mode 100644 ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 105aefab..5ffa1b7e 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -15,7 +15,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { migrationBlock: { migration, oldSchemaVersion in if (oldSchemaVersion < 1) { NSLog("Realm schema version was \(oldSchemaVersion)") - migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in + migration.enumerateObjects(ofType: DeviceSettings.rlmClassName()) { oldObject, newObject in newObject?["enableAltView"] = false } } @@ -23,6 +23,32 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) Realm.Configuration.defaultConfiguration = configuration + Realm.registerRealmables(DeviceSettings.self) + Realm.registerRealmables(ServerConnectionConfig.self) + Realm.registerRealmables(ServerConnectionConfigActiveIndex.self) + + // Data classes + Realm.registerRealmables(LibraryItem.self) + Realm.registerRealmables(MediaType.self) + Realm.registerRealmables(Metadata.self) + Realm.registerRealmables(PodcastEpisode.self) + Realm.registerRealmables(AudioFile.self) + Realm.registerRealmables(Author.self) + Realm.registerRealmables(Chapter.self) + Realm.registerRealmables(AudioTrack.self) + Realm.registerRealmables(FileMetadata.self) + Realm.registerRealmables(Library.self) + Realm.registerRealmables(Folder.self) + Realm.registerRealmables(LibraryFile.self) + Realm.registerRealmables(MediaProgress.self) + Realm.registerRealmables(PlaybackMetadata.self) + + // Local library + Realm.registerRealmables(LocalLibraryItem.self) + Realm.registerRealmables(LocalPodcastEpisode.self) + Realm.registerRealmables(LocalFile.self) + Realm.registerRealmables(LocalMediaProgress.self) + return true } diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index e7ed178c..518f79ff 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -35,18 +35,12 @@ public class AbsDatabase: CAPPlugin { let token = call.getString("token", "") let name = "\(address) (\(username))" - let config = ServerConnectionConfig() if id == nil { id = "\(address)@\(username)".toBase64() } - config.id = id! - config.name = name - config.address = address - config.userId = userId - config.username = username - config.token = token + let config = ServerConnectionConfig(id: id!, index: 0, name: name, address: address, userId: userId, username: username, token: token) Store.serverConfig = config call.resolve(convertServerConnectionConfigToJSON(config: config)) @@ -77,7 +71,7 @@ public class AbsDatabase: CAPPlugin { @objc func getLocalLibraryItems(_ call: CAPPluginCall) { do { let items = Database.shared.getLocalLibraryItems() - call.resolve([ "value": try items.asDictionaryArray() ]) + call.resolve([ "value": try items.asDictionaryArray()]) } catch(let exception) { NSLog("error while readling local library items") debugPrint(exception) @@ -128,11 +122,7 @@ public class AbsDatabase: CAPPlugin { let enableAltView = call.getBool("enableAltView") ?? false let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10 let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10 - let settings = DeviceSettings() - settings.disableAutoRewind = disableAutoRewind - settings.enableAltView = enableAltView - settings.jumpBackwardsTime = jumpBackwardsTime - settings.jumpForwardTime = jumpForwardTime + let settings = DeviceSettings(disableAutoRewind: disableAutoRewind, enableAltView: enableAltView, jumpBackwardsTime: jumpBackwardsTime, jumpForwardTime: jumpForwardTime) Database.shared.setDeviceSettings(deviceSettings: settings) diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index b29b7a6f..4130c5b6 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -7,22 +7,22 @@ import Foundation import CoreMedia -import RealmSwift +import Unrealm -struct LibraryItem: Codable { +struct LibraryItem: Realmable, Codable { var id: String - var ino:String + var ino: String var libraryId: String var folderId: String var path: String var relPath: String var isFile: Bool - var mtimeMs: Int64 - var ctimeMs: Int64 - var birthtimeMs: Int64 - var addedAt: Int64 - var updatedAt: Int64 - var lastScan: Int64? + var mtimeMs: Int + var ctimeMs: Int + var birthtimeMs: Int + var addedAt: Int + var updatedAt: Int + var lastScan: Int? var scanVersion: String? var isMissing: Bool var isInvalid: Bool @@ -30,9 +30,29 @@ struct LibraryItem: Codable { var media: MediaType var libraryFiles: [LibraryFile] var userMediaProgress: MediaProgress? + + init() { + id = "" + ino = "" + libraryId = "" + folderId = "" + path = "" + relPath = "" + isFile = true + mtimeMs = 0 + ctimeMs = 0 + birthtimeMs = 0 + addedAt = 0 + updatedAt = 0 + isMissing = false + isInvalid = false + mediaType = "" + media = MediaType() + libraryFiles = [] + } } -struct MediaType: Codable { +struct MediaType: Realmable, Codable { var libraryItemId: String? var metadata: Metadata var coverPath: String? @@ -40,13 +60,17 @@ struct MediaType: Codable { var audioFiles: [AudioFile]? var chapters: [Chapter]? var tracks: [AudioTrack]? - var size: Int64? + var size: Int? var duration: Double? var episodes: [PodcastEpisode]? var autoDownloadEpisodes: Bool? + + init() { + metadata = Metadata() + } } -struct Metadata: Codable { +struct Metadata: Realmable, Codable { var title: String var subtitle: String? var authors: [Author]? @@ -65,9 +89,15 @@ struct Metadata: Codable { var narratorName: String? var seriesName: String? var feedUrl: String? + + init() { + title = "Unknown" + genres = [] + explicit = false + } } -struct PodcastEpisode: Codable { +struct PodcastEpisode: Realmable, Codable { var id: String var index: Int var episode: String? @@ -78,30 +108,55 @@ struct PodcastEpisode: Codable { var audioFile: AudioFile? var audioTrack: AudioTrack? var duration: Double - var size: Int64 + var size: Int // var serverEpisodeId: String? + + init() { + id = "" + index = 0 + title = "Unknown" + duration = 0 + size = 0 + } } -struct AudioFile: Codable { +struct AudioFile: Realmable, Codable { var index: Int var ino: String var metadata: FileMetadata + + init() { + index = 0 + ino = "" + metadata = FileMetadata() + } } -struct Author: Codable { +struct Author: Realmable, Codable { var id: String var name: String var coverPath: String? + + init() { + id = "" + name = "Unknown" + } } -struct Chapter: Codable { +struct Chapter: Realmable, Codable { var id: Int var start: Double var end: Double var title: String? + + init() { + id = 0 + start = 0 + end = 0 + } } -struct AudioTrack: Codable { +struct AudioTrack: Realmable, Codable { var index: Int? var startOffset: Double? var duration: Double @@ -113,50 +168,101 @@ struct AudioTrack: Codable { // var localFileId: String? // var audioProbeResult: AudioProbeResult? Needed for local playback var serverIndex: Int? + + init() { + duration = 0 + mimeType = "" + } } -struct FileMetadata: Codable { +struct FileMetadata: Realmable, Codable { var filename: String var ext: String var path: String var relPath: String + + init() { + filename = "" + ext = "" + path = "" + relPath = "" + } } -struct Library: Codable { +struct Library: Realmable, Codable { var id: String var name: String var folders: [Folder] var icon: String var mediaType: String + + init() { + id = "" + name = "Unknown" + folders = [] + icon = "" + mediaType = "" + } } -struct Folder: Codable { +struct Folder: Realmable, Codable { var id: String var fullPath: String + + init() { + id = "" + fullPath = "" + } } -struct LibraryFile: Codable { +struct LibraryFile: Realmable, Codable { var ino: String var metadata: FileMetadata + + init() { + ino = "" + metadata = FileMetadata() + } } -struct MediaProgress:Codable { - var id:String - var libraryItemId:String - var episodeId:String? - var duration:Double - var progress:Double - var currentTime:Double - var isFinished:Bool - var lastUpdate:Int64 - var startedAt:Int64 - var finishedAt:Int64? +struct MediaProgress: Realmable, Codable { + var id: String + var libraryItemId: String + var episodeId: String? + var duration: Double + var progress: Double + var currentTime: Double + var isFinished: Bool + var lastUpdate: Int + var startedAt: Int + var finishedAt: Int? + + init() { + id = "" + libraryItemId = "" + duration = 0 + progress = 0 + currentTime = 0 + isFinished = false + lastUpdate = 0 + startedAt = 0 + } } -struct PlaybackMetadata: Codable { +struct PlaybackMetadata: Realmable, Codable { var duration: Double var currentTime: Double var playerState: PlayerState + + init() { + duration = 0 + currentTime = 0 + playerState = PlayerState.IDLE + } + + static func ignoredProperties() -> [String] { + return ["playerState"] + } } enum PlayerState: Codable { diff --git a/ios/App/Shared/models/DeviceSettings.swift b/ios/App/Shared/models/DeviceSettings.swift index cd823916..34d29bd6 100644 --- a/ios/App/Shared/models/DeviceSettings.swift +++ b/ios/App/Shared/models/DeviceSettings.swift @@ -7,21 +7,17 @@ import Foundation import RealmSwift +import Unrealm -class DeviceSettings: Object { - @Persisted var disableAutoRewind: Bool - @Persisted var enableAltView: Bool - @Persisted var jumpBackwardsTime: Int - @Persisted var jumpForwardTime: Int +struct DeviceSettings: Realmable { + var disableAutoRewind: Bool = false + var enableAltView: Bool = false + var jumpBackwardsTime: Int = 10 + var jumpForwardTime: Int = 10 } func getDefaultDeviceSettings() -> DeviceSettings { - let settings = DeviceSettings() - settings.disableAutoRewind = false - settings.enableAltView = false - settings.jumpForwardTime = 10 - settings.jumpBackwardsTime = 10 - return settings + return DeviceSettings() } func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary { diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index fb5c4f36..f0e49887 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -6,139 +6,81 @@ // import Foundation -import RealmSwift +import Unrealm -class LocalLibraryItem: Object, Encodable { - @Persisted(primaryKey: true) var id: String = "local_\(UUID().uuidString)" - @Persisted var basePath: String = "" - @Persisted var absolutePath: String = "" - @Persisted var contentUrl: String - @Persisted var isInvalid: Bool = false - @Persisted var mediaType: String - @Persisted var media: LocalMediaType? - @Persisted var localFiles: List - @Persisted var coverContentUrl: String? = nil - @Persisted var coverAbsolutePath: String? = nil - @Persisted var isLocal: Bool = true - @Persisted var serverConnectionConfigId: String? = nil - @Persisted var serverAddress: String? = nil - @Persisted var serverUserId: String? = nil - @Persisted var libraryItemId: String? = nil +struct LocalLibraryItem: Realmable, Codable { + var id: String = "local_\(UUID().uuidString)" + var basePath: String = "" + var absolutePath: String = "" + var contentUrl: String = "" + var isInvalid: Bool = false + var mediaType: String = "" + var media: MediaType? + var localFiles: [LocalFile] = [] + var coverContentUrl: String? + var coverAbsolutePath: String? + var isLocal: Bool = true + var serverConnectionConfigId: String? + var serverAddress: String? + var serverUserId: String? + var libraryItemId: String? + + static func primaryKey() -> String? { + return "id" + } } -class LocalMediaType: Object, Encodable { - @Persisted var libraryItemId: String? = "" - @Persisted var metadata: LocalMetadata? - @Persisted var coverPath: String? = "" - @Persisted var tags: List - @Persisted var audioFiles: List - @Persisted var chapters: List - @Persisted var tracks: List - @Persisted var size: Int64? = nil - @Persisted var duration: Double? = nil - @Persisted var episodes: List - @Persisted var autoDownloadEpisodes: Bool? = nil +struct LocalPodcastEpisode: Realmable, Codable { + var id: String = UUID().uuidString + var index: Int = 0 + var episode: String? + var episodeType: String? + var title: String = "Unknown" + var subtitle: String? + var desc: String? + var audioFile: AudioFile? + var audioTrack: AudioTrack? + var duration: Double = 0 + var size: Int = 0 + var serverEpisodeId: String? + + static func primaryKey() -> String? { + return "id" + } } -class LocalMetadata: Object, Encodable { - @Persisted var title: String - @Persisted var subtitle: String? = "" - @Persisted var authors: List - @Persisted var narrators: List - @Persisted var genres: List - @Persisted var publishedYear: String? = "" - @Persisted var publishedDate: String? = "" - @Persisted var publisher: String? = "" - @Persisted var desc: String? = "" - @Persisted var isbn: String? = "" - @Persisted var asin: String? = "" - @Persisted var language: String? = "" - @Persisted var explicit: Bool - @Persisted var authorName: String? = "" - @Persisted var authorNameLF: String? = "" - @Persisted var narratorName: String? = "" - @Persisted var seriesName: String? = "" - @Persisted var feedUrl: String? = "" +struct LocalFile: Realmable, Codable { + var id: String = UUID().uuidString + var filename: String? + var contentUrl: String = "" + var absolutePath: String = "" + var mimeType: String? + var size: Int = 0 + + static func primaryKey() -> String? { + return "id" + } } -class LocalPodcastEpisode: Object, Encodable { - @Persisted var id: String = UUID().uuidString - @Persisted var index: Int - @Persisted var episode: String? = "" - @Persisted var episodeType: String? = "" - @Persisted var title: String - @Persisted var subtitle: String? = "" - @Persisted var desc: String? = "" - @Persisted var audioFile: LocalAudioFile? = nil - @Persisted var audioTrack: LocalAudioTrack? = nil - @Persisted var duration: Double - @Persisted var size: Int64 - @Persisted var serverEpisodeId: String? -} - -class LocalAudioFile: Object, Encodable { - @Persisted var index: Int - @Persisted var ino: String - @Persisted var metadata: LocalFileMetadata? -} - -class LocalAuthor: Object, Encodable { - @Persisted var id: String = UUID().uuidString - @Persisted var name: String - @Persisted var coverPath: String? = "" -} - -class LocalChapter: Object, Encodable { - @Persisted var id: Int - @Persisted var start: Double - @Persisted var end: Double - @Persisted var title: String? = nil -} - -class LocalAudioTrack: Object, Encodable { - @Persisted var index: Int? = nil - @Persisted var startOffset: Double? = nil - @Persisted var duration: Double - @Persisted var title: String? = "" - @Persisted var contentUrl: String? = "" - @Persisted var mimeType: String - @Persisted var metadata: LocalFileMetadata? = nil - @Persisted var isLocal: Bool = true - @Persisted var localFileId: String? = "" - @Persisted var serverIndex: Int? = nil -} - -class LocalFileMetadata: Object, Encodable { - @Persisted var filename: String - @Persisted var ext: String - @Persisted var path: String - @Persisted var relPath: String -} - -class LocalFile: Object, Encodable { - @Persisted var id: String = UUID().uuidString - @Persisted var filename: String? = "" - @Persisted var contentUrl: String - @Persisted var absolutePath: String - @Persisted var mimeType: String? = "" - @Persisted var size: Int64 -} - -class LocalMediaProgress: Object, Encodable { - @Persisted(primaryKey: true) var id: String = UUID().uuidString - @Persisted var localLibraryItemId: String - @Persisted var localEpisodeId: String? = "" - @Persisted var duration: Double - @Persisted var progress: Double // 0 to 1 - @Persisted var currentTime: Double - @Persisted var isFinished: Bool - @Persisted var lastUpdate: Int64 - @Persisted var startedAt: Int64 - @Persisted var finishedAt: Int64? = nil +struct LocalMediaProgress: Realmable, Codable { + var id: String = UUID().uuidString + var localLibraryItemId: String = "" + var localEpisodeId: String? + var duration: Double = 0 + var progress: Double = 0 + var currentTime: Double = 0 + var isFinished: Bool = false + var lastUpdate: Int = 0 + var startedAt: Int = 0 + var finishedAt: Int? // For local lib items from server to support server sync - @Persisted var serverConnectionConfigId: String? = "" - @Persisted var serverAddress: String? = "" - @Persisted var serverUserId: String? = "" - @Persisted var libraryItemId: String? = "" - @Persisted var episodeId: String? = "" + var serverConnectionConfigId: String? + var serverAddress: String? + var serverUserId: String? + var libraryItemId: String? + var episodeId: String? + + static func primaryKey() -> String? { + return "id" + } } diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index c08337ce..e05c6be0 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -8,49 +8,12 @@ import Foundation extension LocalLibraryItem { - enum CodingKeys: CodingKey { - case id - case basePath - case absolutePath - case contentUrl - case isInvalid - case mediaType - case media - case localFiles - case coverContentUrl - case coverAbsolutePath - case isLocal - case serverConnectionConfigId - case serverAddress - case serverUserId - case libraryItemId - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(basePath, forKey: .basePath) - try container.encode(absolutePath, forKey: .absolutePath) - try container.encode(contentUrl, forKey: .contentUrl) - try container.encode(isInvalid, forKey: .isInvalid) - try container.encode(mediaType, forKey: .mediaType) - try container.encode(media, forKey: .media) - try container.encode(localFiles, forKey: .localFiles) - try container.encode(coverContentUrl, forKey: .coverContentUrl) - try container.encode(coverAbsolutePath, forKey: .coverAbsolutePath) - try container.encode(isLocal, forKey: .isLocal) - try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId) - try container.encode(serverAddress, forKey: .serverAddress) - try container.encode(serverUserId, forKey: .serverUserId) - try container.encode(libraryItemId, forKey: .libraryItemId) - } - - convenience init(_ item: LibraryItem, localUrl: URL, server: ServerConnectionConfig, files: [LocalFile]) { + init(_ item: LibraryItem, localUrl: URL, server: ServerConnectionConfig, files: [LocalFile]) { self.init() self.contentUrl = localUrl.absoluteString self.mediaType = item.mediaType - self.media = LocalMediaType(item.media, coverPath: "", files: files) - self.localFiles.append(objectsIn: files) + self.media = item.media + self.localFiles = files // TODO: self.coverContentURL // TODO: self.converAbsolutePath self.libraryItemId = item.id @@ -61,7 +24,7 @@ extension LocalLibraryItem { func getDuration() -> Double { var total = 0.0 - self.media?.tracks.forEach { track in total += track.duration } + self.media?.tracks?.forEach { track in total += track.duration } return total } @@ -107,315 +70,14 @@ extension LocalLibraryItem { } } -extension LocalMediaType { - enum CodingKeys: CodingKey { - case libraryItemId - case metadata - case coverPath - case tags - case audioFiles - case chapters - case tracks - case size - case duration - case episodes - case autoDownloadEpisodes - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(libraryItemId, forKey: .libraryItemId) - try container.encode(metadata, forKey: .metadata) - try container.encode(coverPath, forKey: .coverPath) - try container.encode(tags, forKey: .tags) - try container.encode(audioFiles, forKey: .audioFiles) - try container.encode(chapters, forKey: .chapters) - try container.encode(tracks, forKey: .tracks) - try container.encode(size, forKey: .size) - try container.encode(duration, forKey: .duration) - try container.encode(episodes, forKey: .episodes) - try container.encode(autoDownloadEpisodes, forKey: .autoDownloadEpisodes) - } - - convenience init(_ mediaType: MediaType, coverPath: String, files: [LocalFile]) { - self.init() - self.libraryItemId = mediaType.libraryItemId - self.metadata = LocalMetadata(mediaType.metadata) - self.coverPath = coverPath - self.tags.append(objectsIn: mediaType.tags ?? []) - self.audioFiles.append(objectsIn: mediaType.audioFiles!.enumerated().map() { - i, audioFile -> LocalAudioFile in LocalAudioFile(audioFile) - }) - self.chapters.append(objectsIn: mediaType.chapters!.enumerated().map() { - i, chapter -> LocalChapter in LocalChapter(chapter) - }) - self.tracks.append(objectsIn: mediaType.tracks!.enumerated().map() { - i, track in LocalAudioTrack(track, libraryItemId: self.libraryItemId ?? "", filename: files[i].filename ?? "") - }) - self.size = mediaType.size - self.duration = mediaType.duration - // TODO: self.episodes - // TODO: Handle podcast auto downloads - self.autoDownloadEpisodes = mediaType.autoDownloadEpisodes - } -} - -extension LocalMetadata { - enum CodingKeys: CodingKey { - case title - case subtitle - case authors - case narrators - case genres - case publishedYear - case publishedDate - case publisher - case desc - case isbn - case asin - case language - case explicit - case authorName - case authorNameLF - case narratorName - case seriesName - case feedUrl - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(title, forKey: .title) - try container.encode(subtitle, forKey: .subtitle) - try container.encode(authors, forKey: .authors) - try container.encode(narrators, forKey: .narrators) - try container.encode(genres, forKey: .genres) - try container.encode(publishedYear, forKey: .publishedYear) - try container.encode(publishedDate, forKey: .publishedDate) - try container.encode(publisher, forKey: .publisher) - try container.encode(desc, forKey: .desc) - try container.encode(isbn, forKey: .isbn) - try container.encode(asin, forKey: .asin) - try container.encode(language, forKey: .language) - try container.encode(explicit, forKey: .explicit) - try container.encode(authorName, forKey: .authorName) - try container.encode(authorNameLF, forKey: .authorNameLF) - try container.encode(narratorName, forKey: .narratorName) - try container.encode(seriesName, forKey: .seriesName) - try container.encode(feedUrl, forKey: .feedUrl) - } - - convenience init(_ metadata: Metadata) { - self.init() - self.title = metadata.title - self.subtitle = metadata.subtitle - self.narrators.append(objectsIn: metadata.narrators ?? []) - self.genres.append(objectsIn: metadata.genres) - self.publishedYear = metadata.publishedYear - self.publishedDate = metadata.publishedDate - self.publisher = metadata.publisher - self.desc = metadata.description - self.isbn = metadata.isbn - self.asin = metadata.asin - self.language = metadata.language - self.explicit = metadata.explicit - self.authorName = metadata.authorName - self.authorNameLF = metadata.authorNameLF - self.narratorName = metadata.narratorName - self.seriesName = metadata.seriesName - self.feedUrl = metadata.feedUrl - } -} - -extension LocalPodcastEpisode { - enum CodingKeys: CodingKey { - case id - case index - case episode - case episodeType - case title - case subtitle - case desc - case audioFile - case audioTrack - case duration - case size - case serverEpisodeId - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(index, forKey: .index) - try container.encode(episode, forKey: .episode) - try container.encode(episodeType, forKey: .episodeType) - try container.encode(title, forKey: .title) - try container.encode(subtitle, forKey: .subtitle) - try container.encode(desc, forKey: .desc) - try container.encode(audioFile, forKey: .audioFile) - try container.encode(audioTrack, forKey: .audioTrack) - try container.encode(duration, forKey: .duration) - try container.encode(size, forKey: .size) - try container.encode(serverEpisodeId, forKey: .serverEpisodeId) - } -} - -extension LocalAudioFile { - enum CodingKeys: CodingKey { - case index - case ino - case metadata - } - - convenience init(_ audioFile: AudioFile) { - self.init() - self.index = audioFile.index - self.ino = audioFile.ino - // self.metadata - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(index, forKey: .index) - try container.encode(ino, forKey: .ino) - try container.encode(metadata, forKey: .metadata) - } -} - -extension LocalAuthor { - enum CodingKeys: CodingKey { - case id - case name - case coverPath - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(name, forKey: .name) - try container.encode(coverPath, forKey: .coverPath) - } - - convenience init(_ author: Author) { - self.init() - self.id = author.id - self.name = author.name - // self.coverPath - } -} - -extension LocalChapter { - enum CodingKeys: CodingKey { - case id - case start - case end - case title - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(start, forKey: .start) - try container.encode(end, forKey: .end) - try container.encode(title, forKey: .title) - } - - convenience init(_ chapter: Chapter) { - self.init() - self.id = chapter.id - self.start = chapter.start - self.end = chapter.end - self.title = chapter.title - } -} - -extension LocalAudioTrack { - enum CodingKeys: CodingKey { - case index - case startOffset - case duration - case title - case contentUrl - case mimeType - case metadata - case isLocal - case localFileId - case serverIndex - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(index, forKey: .index) - try container.encode(startOffset, forKey: .startOffset) - try container.encode(duration, forKey: .duration) - try container.encode(title, forKey: .title) - try container.encode(contentUrl, forKey: .contentUrl) - try container.encode(mimeType, forKey: .mimeType) - try container.encode(metadata, forKey: .metadata) - try container.encode(isLocal, forKey: .isLocal) - try container.encode(localFileId, forKey: .localFileId) - try container.encode(serverIndex, forKey: .serverIndex) - } - - convenience init(_ track: AudioTrack, libraryItemId: String, filename: String) { - self.init() - self.index = track.index - self.startOffset = track.startOffset - self.duration = track.duration - self.title = track.title - self.contentUrl = "" // TODO: Different URL - self.mimeType = track.mimeType - // TODO: self.metadata - self.localFileId = "\(libraryItemId)_\(filename.toBase64())" - self.serverIndex = track.serverIndex - } -} - -extension LocalFileMetadata { - enum CodingKeys: CodingKey { - case filename - case ext - case path - case relPath - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(filename, forKey: .filename) - try container.encode(ext, forKey: .ext) - try container.encode(path, forKey: .path) - try container.encode(relPath, forKey: .relPath) - } - - /* TODO: Can we skip this object? */ -} - extension LocalFile { - enum CodingKeys: CodingKey { - case id - case filename - case contentUrl - case absolutePath - case mimeType - case size - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(filename, forKey: .filename) - try container.encode(contentUrl, forKey: .contentUrl) - try container.encode(absolutePath, forKey: .absolutePath) - try container.encode(mimeType, forKey: .mimeType) - try container.encode(size, forKey: .size) - } - - convenience init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: URL) { + init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: URL) { self.init() self.id = "\(libraryItemId)_\(filename.toBase64())" self.filename = filename self.contentUrl = localUrl.absoluteString self.absolutePath = localUrl.path - self.size = localUrl.fileSize + self.size = Int(localUrl.fileSize) } func isAudioFile() -> Bool { @@ -428,42 +90,3 @@ extension LocalFile { } } } - -extension LocalMediaProgress { - enum CodingKeys: CodingKey { - case id - case localLibraryItemId - case localEpisodeId - case duration - case progress - case currentTime - case isFinished - case lastUpdate - case startedAt - case finishedAt - case serverConnectionConfigId - case serverAddress - case serverUserId - case libraryItemId - case episodeId - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(localLibraryItemId, forKey: .localLibraryItemId) - try container.encode(localEpisodeId, forKey: .localEpisodeId) - try container.encode(duration, forKey: .duration) - try container.encode(progress, forKey: .progress) - try container.encode(currentTime, forKey: .currentTime) - try container.encode(isFinished, forKey: .isFinished) - try container.encode(lastUpdate, forKey: .lastUpdate) - try container.encode(startedAt, forKey: .startedAt) - try container.encode(finishedAt, forKey: .finishedAt) - try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId) - try container.encode(serverAddress, forKey: .serverAddress) - try container.encode(serverUserId, forKey: .serverUserId) - try container.encode(libraryItemId, forKey: .libraryItemId) - try container.encode(episodeId, forKey: .episodeId) - } -} diff --git a/ios/App/Shared/models/ServerConnectionConfig.swift b/ios/App/Shared/models/ServerConnectionConfig.swift index c8ace3e1..95ed644f 100644 --- a/ios/App/Shared/models/ServerConnectionConfig.swift +++ b/ios/App/Shared/models/ServerConnectionConfig.swift @@ -7,20 +7,29 @@ import Foundation import RealmSwift +import Unrealm -class ServerConnectionConfig: Object { - @Persisted(primaryKey: true) var id: String - @Persisted(indexed: true) var index: Int - @Persisted var name: String - @Persisted var address: String - @Persisted var userId: String - @Persisted var username: String - @Persisted var token: String +struct ServerConnectionConfig: Realmable { + var id: String = UUID().uuidString + var index: Int = 0 + var name: String = "" + var address: String = "" + var userId: String = "" + var username: String = "" + var token: String = "" + + static func primaryKey() -> String? { + return "id" + } + + static func indexedProperties() -> [String] { + return ["index"] + } } -class ServerConnectionConfigActiveIndex: Object { - // This could overflow, but you really would have to try - @Persisted var index: Int? +struct ServerConnectionConfigActiveIndex: Realmable { + // This could overflow, but you really would have to try + var index: Int? } func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary { diff --git a/ios/App/Shared/util/ApiClient.swift b/ios/App/Shared/util/ApiClient.swift index f25e4bb9..c9ed9e97 100644 --- a/ios/App/Shared/util/ApiClient.swift +++ b/ios/App/Shared/util/ApiClient.swift @@ -114,9 +114,11 @@ class ApiClient { callback(session) } } + public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) { try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil) } + public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) { var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress" if episodeId != nil { diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 0ec073d9..7bc6afc1 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -23,16 +23,12 @@ class Database { } public func setServerConnectionConfig(config: ServerConnectionConfig) { - var refrence: ThreadSafeReference? - if config.realm != nil { - refrence = ThreadSafeReference(to: config) - } - + var config = config Database.realmQueue.sync { - let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) + var existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) if config.index == 0 { - let lastConfig: ServerConnectionConfig? = instance.objects(ServerConnectionConfig.self).last + var lastConfig: ServerConnectionConfig? = instance.objects(ServerConnectionConfig.self).last if lastConfig != nil { config.index = lastConfig!.index + 1 @@ -46,15 +42,7 @@ class Database { if existing != nil { instance.delete(existing!) } - if refrence == nil { - instance.add(config) - } else { - guard let resolved = instance.resolve(refrence!) else { - throw "unable to resolve refrence" - } - - instance.add(resolved); - } + instance.add(config) } } catch(let exception) { NSLog("failed to save server config") @@ -83,25 +71,8 @@ class Database { } public func getServerConnectionConfigs() -> [ServerConnectionConfig] { - var refrences: [ThreadSafeReference] = [] - Database.realmQueue.sync { - let configs = instance.objects(ServerConnectionConfig.self) - refrences = configs.map { config in - return ThreadSafeReference(to: config) - } - } - - do { - let realm = try Realm() - - return refrences.map { refrence in - return realm.resolve(refrence)! - } - } catch(let exception) { - NSLog("error while readling configs") - debugPrint(exception) - return [] + return Array(instance.objects(ServerConnectionConfig.self)) } } @@ -113,7 +84,7 @@ class Database { public func setLastActiveConfigIndex(index: Int?) { let existing = instance.objects(ServerConnectionConfigActiveIndex.self) - let obj = ServerConnectionConfigActiveIndex() + var obj = ServerConnectionConfigActiveIndex() obj.index = index do { @@ -149,25 +120,8 @@ class Database { } public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] { - var localLibraryItems: [ThreadSafeReference] = [] - Database.realmQueue.sync { - let items = instance.objects(LocalLibraryItem.self) - localLibraryItems = items.map { item in - return ThreadSafeReference(to: item) - } - } - - do { - let realm = try Realm() - - return localLibraryItems.map { item in - return realm.resolve(item)! - } - } catch(let exception) { - NSLog("error while readling local library items") - debugPrint(exception) - return [] + Array(instance.objects(LocalLibraryItem.self)) } } @@ -183,26 +137,14 @@ class Database { } public func getLocalLibraryItem(localLibraryItem: String) -> LocalLibraryItem? { - let items = getLocalLibraryItems() - for item in items { - if (item.id == localLibraryItem) { - return item - } + Database.realmQueue.sync { + instance.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItem) } - NSLog("Local library item with id \(localLibraryItem) not found") - return nil } public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) { Database.realmQueue.sync { - do { - try instance.write { - instance.add(localLibraryItem); - } - } catch(let exception) { - NSLog("Unable to save local library item") - debugPrint(exception) - } + try! instance.write { instance.add(localLibraryItem) } } } @@ -213,19 +155,10 @@ class Database { } public func removeLocalLibraryItem(localLibraryItemId: String) { - let item = getLocalLibraryItemByLLId(libraryItem: localLibraryItemId) Database.realmQueue.sync { - do { - try instance.write { - if item != nil { - instance.delete(item!) - } else { - NSLog("Unable to find local library item to delete") - } - } - } catch (let exception) { - NSLog("Unable to delete local library item") - debugPrint(exception) + try! instance.write { + let item = getLocalLibraryItemByLLId(libraryItem: localLibraryItemId) + instance.delete(item!) } } } diff --git a/ios/App/Shared/util/Store.swift b/ios/App/Shared/util/Store.swift index 52fbd059..210664e1 100644 --- a/ios/App/Shared/util/Store.swift +++ b/ios/App/Shared/util/Store.swift @@ -9,7 +9,7 @@ import Foundation import RealmSwift class Store { - @ThreadSafe private static var _serverConfig: ServerConnectionConfig? + private static var _serverConfig: ServerConnectionConfig? public static var serverConfig: ServerConnectionConfig? { get { return _serverConfig diff --git a/package-lock.json b/package-lock.json index 13c38711..125a8b38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32982,4 +32982,4 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" } } -} \ No newline at end of file +} From a7424cc428666d6db28086ecea3d44dd3aba277d Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Tue, 2 Aug 2022 17:10:45 -0400 Subject: [PATCH 019/103] Fix persisting active config --- ios/App/App/plugins/AbsDatabase.swift | 2 +- .../Shared/models/ServerConnectionConfig.swift | 6 +++++- ios/App/Shared/util/Database.swift | 17 ++++++----------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index 518f79ff..fdc3bab6 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -40,7 +40,7 @@ public class AbsDatabase: CAPPlugin { id = "\(address)@\(username)".toBase64() } - let config = ServerConnectionConfig(id: id!, index: 0, name: name, address: address, userId: userId, username: username, token: token) + let config = ServerConnectionConfig(id: id!, index: 1, name: name, address: address, userId: userId, username: username, token: token) Store.serverConfig = config call.resolve(convertServerConnectionConfigToJSON(config: config)) diff --git a/ios/App/Shared/models/ServerConnectionConfig.swift b/ios/App/Shared/models/ServerConnectionConfig.swift index 95ed644f..117500fc 100644 --- a/ios/App/Shared/models/ServerConnectionConfig.swift +++ b/ios/App/Shared/models/ServerConnectionConfig.swift @@ -11,7 +11,7 @@ import Unrealm struct ServerConnectionConfig: Realmable { var id: String = UUID().uuidString - var index: Int = 0 + var index: Int = 1 var name: String = "" var address: String = "" var userId: String = "" @@ -30,6 +30,10 @@ struct ServerConnectionConfig: Realmable { struct ServerConnectionConfigActiveIndex: Realmable { // This could overflow, but you really would have to try var index: Int? + + static func primaryKey() -> String? { + return "index" + } } func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary { diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 7bc6afc1..fac3f342 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -76,21 +76,16 @@ class Database { } } - public func setLastActiveConfigIndexToNil() { - Database.realmQueue.sync { - setLastActiveConfigIndex(index: nil) - } + private func setLastActiveConfigIndexToNil() { + setLastActiveConfigIndex(index: nil) } - public func setLastActiveConfigIndex(index: Int?) { - let existing = instance.objects(ServerConnectionConfigActiveIndex.self) - var obj = ServerConnectionConfigActiveIndex() - obj.index = index - + private func setLastActiveConfigIndex(index: Int?) { do { try instance.write { - instance.delete(existing) - instance.add(obj) + var existing = instance.objects(ServerConnectionConfigActiveIndex.self).last ?? ServerConnectionConfigActiveIndex(index: index) + existing.index = index + instance.add(existing, update: .modified) } } catch(let exception) { NSLog("failed to save server config active index") From 37fa9316729473f58ddd76e6b47ab9916a0c2ec3 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Tue, 2 Aug 2022 17:32:40 -0400 Subject: [PATCH 020/103] Don't save in database until download is complete --- ios/App/App/plugins/AbsDownloader.swift | 25 ++++++++++++++++--------- ios/App/Shared/util/Database.swift | 6 ++++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 37cd92c2..b7f4f3a3 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -33,18 +33,20 @@ public class AbsDownloader: CAPPlugin { private func startLibraryItemDownload(item: LibraryItem) { let length = item.media.tracks?.count ?? 0 if length > 0 { + let downloadDispatch = DispatchGroup() let files = item.media.tracks!.enumerated().map { - position, track -> LocalFile in startLibraryItemTrackDownload(item: item, position: position, track: track) + position, track -> LocalFile in startLibraryItemTrackDownload(item: item, position: position, track: track, dispatch: downloadDispatch) + } + downloadDispatch.notify(queue: .main) { + let localLibraryItem = LocalLibraryItem(item, localUrl: self.documentsDirectory, server: Store.serverConfig!, files: files) + Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) } - - let localLibraryItem = LocalLibraryItem(item, localUrl: documentsDirectory, server: Store.serverConfig!, files: files) - Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) } else { NSLog("No audio tracks for the supplied library item") } } - private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) -> LocalFile { + private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack, dispatch: DispatchGroup) -> LocalFile { NSLog("TRACK \(track.contentUrl!)") // If we don't name metadata, then we can't proceed @@ -57,7 +59,7 @@ public class AbsDownloader: CAPPlugin { let itemDirectory = createLibraryItemFileDirectory(item: item) let localUrl = itemDirectory.appendingPathComponent("\(filename)") - downloadTrack(serverUrl: serverUrl, localUrl: localUrl) + downloadTrack(serverUrl: serverUrl, localUrl: localUrl, dispatch: dispatch) return LocalFile(item.id, filename, track.mimeType, localUrl) } @@ -83,10 +85,15 @@ public class AbsDownloader: CAPPlugin { return itemDirectory } - private func downloadTrack(serverUrl: URL, localUrl: URL) { + private func downloadTrack(serverUrl: URL, localUrl: URL, dispatch: DispatchGroup) { + dispatch.enter() + let downloadTask = URLSession.shared.downloadTask(with: serverUrl) { urlOrNil, responseOrNil, errorOrNil in - - guard let fileURL = urlOrNil else { return } + defer { dispatch.leave() } + + guard let fileURL = urlOrNil else { + return + } do { NSLog("Download TMP file URL \(fileURL)") diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index fac3f342..1dcdaf6a 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -76,8 +76,10 @@ class Database { } } - private func setLastActiveConfigIndexToNil() { - setLastActiveConfigIndex(index: nil) + public func setLastActiveConfigIndexToNil() { + Database.realmQueue.sync { + setLastActiveConfigIndex(index: nil) + } } private func setLastActiveConfigIndex(index: Int?) { From e02edb9f9e595a45457041a3fce65d59aa802212 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Tue, 2 Aug 2022 21:05:28 -0400 Subject: [PATCH 021/103] Fix field collision --- ios/App/Shared/models/DataClasses.swift | 39 +++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index 4130c5b6..426d6522 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -79,7 +79,7 @@ struct Metadata: Realmable, Codable { var publishedYear: String? var publishedDate: String? var publisher: String? - var description: String? + var desc: String? var isbn: String? var asin: String? var language: String? @@ -95,6 +95,27 @@ struct Metadata: Realmable, Codable { genres = [] explicit = false } + + private enum CodingKeys : String, CodingKey { + case title, + subtitle, + authors, + narrators, + genres, + publishedYear, + publishedDate, + publisher, + desc = "description", // Fixes a collision with the base Swift object's field "description" + isbn, + asin, + language, + explicit, + authorName, + authorNameLF, + narratorName, + seriesName, + feedUrl + } } struct PodcastEpisode: Realmable, Codable { @@ -104,7 +125,7 @@ struct PodcastEpisode: Realmable, Codable { var episodeType: String? var title: String var subtitle: String? - var description: String? + var desc: String? var audioFile: AudioFile? var audioTrack: AudioTrack? var duration: Double @@ -118,6 +139,20 @@ struct PodcastEpisode: Realmable, Codable { duration = 0 size = 0 } + + private enum CodingKeys : String, CodingKey { + case id, + index, + episode, + episodeType, + title, + subtitle, + desc = "description", // Fixes a collision with the base Swift object's field "description" + audioFile, + audioTrack, + duration, + size + } } struct AudioFile: Realmable, Codable { From 528854b2856e4bd73cee834a9f2601ead09c6ced Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 4 Aug 2022 16:24:41 -0400 Subject: [PATCH 022/103] Bump min ios version to support async --- ios/App/App.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 217f5a17..fa1fb216 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -490,7 +490,7 @@ CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = 7UFJ7D8V6A; INFOPLIST_FILE = App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 0.9.55; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; @@ -514,7 +514,7 @@ CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = 7UFJ7D8V6A; INFOPLIST_FILE = App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 0.9.55; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app; From 33041608f800bf25e1fafafa3b006e2b7c392f23 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 4 Aug 2022 18:25:27 -0400 Subject: [PATCH 023/103] Switch to async logic --- ios/App/App.xcodeproj/project.pbxproj | 4 + ios/App/App/plugins/AbsDownloader.swift | 93 ++-------------- .../util/LibraryItemDownloadSession.swift | 102 ++++++++++++++++++ 3 files changed, 116 insertions(+), 83 deletions(-) create mode 100644 ios/App/Shared/util/LibraryItemDownloadSession.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index fa1fb216..65920f9d 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ C4B265F5285A5A6600E1B5C3 /* LocalLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */; }; C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; }; E99C8C932883A00F00E3279A /* LocalLibraryExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */; }; + E9D38158289A0A6F0019EEED /* LibraryItemDownloadSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D38157289A0A6F0019EEED /* LibraryItemDownloadSession.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -76,6 +77,7 @@ C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibrary.swift; sourceTree = ""; }; C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = ""; }; E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibraryExtensions.swift; sourceTree = ""; }; + E9D38157289A0A6F0019EEED /* LibraryItemDownloadSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryItemDownloadSession.swift; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -156,6 +158,7 @@ 3AF1970B2806E2590096F747 /* ApiClient.swift */, 3AB34052280829BF0039308B /* Extensions.swift */, 3AB34054280832720039308B /* PlayerEvents.swift */, + E9D38157289A0A6F0019EEED /* LibraryItemDownloadSession.swift */, ); path = util; sourceTree = ""; @@ -341,6 +344,7 @@ C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */, 4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */, 3AB34055280832720039308B /* PlayerEvents.swift in Sources */, + E9D38158289A0A6F0019EEED /* LibraryItemDownloadSession.swift in Sources */, E99C8C932883A00F00E3279A /* LocalLibraryExtensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index b7f4f3a3..46c475b7 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -10,8 +10,6 @@ import Capacitor @objc(AbsDownloader) public class AbsDownloader: CAPPlugin { - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - @objc func downloadLibraryItem(_ call: CAPPluginCall) { let libraryItemId = call.getString("libraryItemId") let episodeId = call.getString("episodeId") @@ -24,90 +22,19 @@ public class AbsDownloader: CAPPlugin { call.resolve() } else { NSLog("Got library item from server \(libraryItem!.id)") - self.startLibraryItemDownload(item: libraryItem!) - call.resolve() + Task { + do { + let downloadSession = LibraryItemDownloadSession(libraryItem!) + let localLibraryItem = try await downloadSession.startDownload() + Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) + } catch { + NSLog("Failed to download \(error)") + } + call.resolve() + } } } } - - private func startLibraryItemDownload(item: LibraryItem) { - let length = item.media.tracks?.count ?? 0 - if length > 0 { - let downloadDispatch = DispatchGroup() - let files = item.media.tracks!.enumerated().map { - position, track -> LocalFile in startLibraryItemTrackDownload(item: item, position: position, track: track, dispatch: downloadDispatch) - } - downloadDispatch.notify(queue: .main) { - let localLibraryItem = LocalLibraryItem(item, localUrl: self.documentsDirectory, server: Store.serverConfig!, files: files) - Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) - } - } else { - NSLog("No audio tracks for the supplied library item") - } - } - - private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack, dispatch: DispatchGroup) -> LocalFile { - NSLog("TRACK \(track.contentUrl!)") - - // If we don't name metadata, then we can't proceed - guard let filename = track.metadata?.filename else { - NSLog("No metadata for track, unable to download") - return LocalFile() - } - - let serverUrl = urlForTrack(item: item, track: track) - let itemDirectory = createLibraryItemFileDirectory(item: item) - let localUrl = itemDirectory.appendingPathComponent("\(filename)") - - downloadTrack(serverUrl: serverUrl, localUrl: localUrl, dispatch: dispatch) - return LocalFile(item.id, filename, track.mimeType, localUrl) - } - - private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { - // filename needs to be encoded otherwise would just use contentUrl - let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) - let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" - return URL(string: urlstr)! - } - - private func createLibraryItemFileDirectory(item: LibraryItem) -> URL { - let itemDirectory = documentsDirectory.appendingPathComponent("\(item.id)") - - NSLog("ITEM DIR \(itemDirectory)") - - // Create library item directory - do { - try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: true) - } catch { - NSLog("Failed to CREATE LI DIRECTORY \(error)") - } - - return itemDirectory - } - - private func downloadTrack(serverUrl: URL, localUrl: URL, dispatch: DispatchGroup) { - dispatch.enter() - - let downloadTask = URLSession.shared.downloadTask(with: serverUrl) { urlOrNil, responseOrNil, errorOrNil in - defer { dispatch.leave() } - - guard let fileURL = urlOrNil else { - return - } - - do { - NSLog("Download TMP file URL \(fileURL)") - let audioData = try Data(contentsOf:fileURL) - try audioData.write(to: localUrl) - NSLog("Download written to \(localUrl)") - } catch { - NSLog("FILE ERROR: \(error)") - } - } - - // Start the download - downloadTask.resume() - } } struct DownloadItem: Codable { diff --git a/ios/App/Shared/util/LibraryItemDownloadSession.swift b/ios/App/Shared/util/LibraryItemDownloadSession.swift new file mode 100644 index 00000000..7a571117 --- /dev/null +++ b/ios/App/Shared/util/LibraryItemDownloadSession.swift @@ -0,0 +1,102 @@ +// +// LibraryItemDownloadSession.swift +// App +// +// Created by Ron Heft on 8/2/22. +// + +import Foundation + +enum LibraryItemDownloadError: String, Error { + case noTracks = "No tracks on library item" + case noMetadata = "No metadata for track, unable to download" + case failedDownload = "Failed to download item" +} + +class LibraryItemDownloadSession { + + let item: LibraryItem + + private let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + + init(_ item: LibraryItem) { + self.item = item + } + + public func startDownload() async throws -> LocalLibraryItem { + guard let tracks = item.media.tracks else { + throw LibraryItemDownloadError.noTracks + } + + return try await withThrowingTaskGroup(of: LocalFile.self, returning: LocalLibraryItem.self) { group in + for (position, track) in tracks.enumerated() { + group.addTask { try await self.startLibraryItemTrackDownload(item: self.item, position: position, track: track) } + } + + var files = [LocalFile]() + for try await file in group { + files.append(file) + } + + return LocalLibraryItem(self.item, localUrl: self.documentsDirectory, server: Store.serverConfig!, files: files) + } + } + + private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) async throws -> LocalFile { + NSLog("TRACK \(track.contentUrl!)") + + // If we don't name metadata, then we can't proceed + guard let filename = track.metadata?.filename else { + throw LibraryItemDownloadError.noMetadata + } + + let serverUrl = urlForTrack(item: item, track: track) + let itemDirectory = createLibraryItemFileDirectory(item: item) + let localUrl = itemDirectory.appendingPathComponent("\(filename)") + + try await downloadFile(serverUrl: serverUrl, localUrl: localUrl) + return LocalFile(item.id, filename, track.mimeType, localUrl) + } + + private func createLibraryItemFileDirectory(item: LibraryItem) -> URL { + let itemDirectory = documentsDirectory.appendingPathComponent("\(item.id)") + + NSLog("ITEM DIR \(itemDirectory)") + + // Create library item directory + do { + try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: true) + } catch { + NSLog("Failed to CREATE LI DIRECTORY \(error)") + } + + return itemDirectory + } + + private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { + // filename needs to be encoded otherwise would just use contentUrl + let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) + let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" + return URL(string: urlstr)! + } + + private func downloadFile(serverUrl: URL, localUrl: URL) async throws { + return try await withCheckedThrowingContinuation { continuation in + let downloadTask = URLSession.shared.downloadTask(with: serverUrl) { urlOrNil, responseOrNil, errorOrNil in + guard let tempUrl = urlOrNil else { + continuation.resume(throwing: errorOrNil!) + return + } + + do { + try FileManager.default.moveItem(at: tempUrl, to: localUrl) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + downloadTask.resume() + } + } + +} From d5d65e244b7c397325f571224338aeedff8cac95 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 6 Aug 2022 10:11:46 -0400 Subject: [PATCH 024/103] Rewrite downloader to use delegate and download item --- ios/App/App.xcodeproj/project.pbxproj | 8 +- ios/App/App/AppDelegate.swift | 4 + ios/App/App/plugins/AbsDownloader.swift | 126 +++++++++++++++--- ios/App/Shared/models/DownloadItem.swift | 81 +++++++++++ ios/App/Shared/util/Database.swift | 24 ++++ .../util/LibraryItemDownloadSession.swift | 102 -------------- 6 files changed, 222 insertions(+), 123 deletions(-) create mode 100644 ios/App/Shared/models/DownloadItem.swift delete mode 100644 ios/App/Shared/util/LibraryItemDownloadSession.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 65920f9d..2c008a44 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -38,7 +38,7 @@ C4B265F5285A5A6600E1B5C3 /* LocalLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */; }; C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; }; E99C8C932883A00F00E3279A /* LocalLibraryExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */; }; - E9D38158289A0A6F0019EEED /* LibraryItemDownloadSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D38157289A0A6F0019EEED /* LibraryItemDownloadSession.swift */; }; + E9D3815C289E0C9B0019EEED /* DownloadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D3815B289E0C9B0019EEED /* DownloadItem.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -77,7 +77,7 @@ C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibrary.swift; sourceTree = ""; }; C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = ""; }; E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibraryExtensions.swift; sourceTree = ""; }; - E9D38157289A0A6F0019EEED /* LibraryItemDownloadSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryItemDownloadSession.swift; sourceTree = ""; }; + E9D3815B289E0C9B0019EEED /* DownloadItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItem.swift; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -143,6 +143,7 @@ C4D0677428106D0C00B8F875 /* DataClasses.swift */, C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */, E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */, + E9D3815B289E0C9B0019EEED /* DownloadItem.swift */, 3A90295E280968E700E1D427 /* PlaybackReport.swift */, 4DF74911287105C600AC7814 /* DeviceSettings.swift */, ); @@ -158,7 +159,6 @@ 3AF1970B2806E2590096F747 /* ApiClient.swift */, 3AB34052280829BF0039308B /* Extensions.swift */, 3AB34054280832720039308B /* PlayerEvents.swift */, - E9D38157289A0A6F0019EEED /* LibraryItemDownloadSession.swift */, ); path = util; sourceTree = ""; @@ -335,6 +335,7 @@ 4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */, 3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */, 3AB34053280829BF0039308B /* Extensions.swift in Sources */, + E9D3815C289E0C9B0019EEED /* DownloadItem.swift in Sources */, 3AD4FCEB280443DD006DB301 /* Database.swift in Sources */, 3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */, 4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */, @@ -344,7 +345,6 @@ C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */, 4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */, 3AB34055280832720039308B /* PlayerEvents.swift in Sources */, - E9D38158289A0A6F0019EEED /* LibraryItemDownloadSession.swift in Sources */, E99C8C932883A00F00E3279A /* LocalLibraryExtensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 5ffa1b7e..e3225579 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -49,6 +49,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Realm.registerRealmables(LocalFile.self) Realm.registerRealmables(LocalMediaProgress.self) + // Download item + Realm.registerRealmables(DownloadItem.self) + Realm.registerRealmables(DownloadItemPart.self) + return true } diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 46c475b7..b7f7330c 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -9,37 +9,129 @@ import Foundation import Capacitor @objc(AbsDownloader) -public class AbsDownloader: CAPPlugin { +public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { + + private let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: .current) + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + NSLog("Finished downloading \(downloadTask.taskDescription ?? "Unknown Task")") + + guard let downloadItemPartId = downloadTask.taskDescription else { return } + + let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId) + guard let downloadItem = downloadItem else { + NSLog("Download item part (%@) not found! Unable to move file!", downloadItemPartId) + return + } + + NSLog("Found downloadItem(%@)", downloadItem.id) + self.moveLibraryItemToFileDirectory(tempUrl: location) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + NSLog("Error downloading \(task.taskDescription ?? "Unknown Task"): \(error ?? "Unknown Error")") + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + NSLog("Received download status \(downloadTask.taskDescription ?? "Unknown Task"): \(totalBytesWritten)") + } + @objc func downloadLibraryItem(_ call: CAPPluginCall) { let libraryItemId = call.getString("libraryItemId") let episodeId = call.getString("episodeId") NSLog("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "")") + guard let libraryItemId = libraryItemId else { call.resolve(); return; } - ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId!, episodeId: episodeId) { libraryItem in + ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { libraryItem in if (libraryItem == nil) { NSLog("Library item not found") - call.resolve() } else { NSLog("Got library item from server \(libraryItem!.id)") - Task { - do { - let downloadSession = LibraryItemDownloadSession(libraryItem!) - let localLibraryItem = try await downloadSession.startDownload() - Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) - } catch { - NSLog("Failed to download \(error)") - } - call.resolve() + do { + try self.startLibraryItemDownload(libraryItem!) + //Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) + } catch { + NSLog("Failed to download \(error)") } } + call.resolve() } } + + private func startLibraryItemDownload(_ item: LibraryItem) throws { + guard let tracks = item.media.tracks else { + throw LibraryItemDownloadError.noTracks + } + + // Queue up everything for downloading + var downloadItem = DownloadItem(libraryItem: item, server: Store.serverConfig!) + downloadItem.downloadItemParts = try tracks.enumerated().map({ i, track in + try startLibraryItemTrackDownload(item: item, position: i, track: track) + }) + + // Persist in the database before status start coming in + Database.shared.saveDownloadItem(downloadItem) + + // Start all the downloads + for downloadItemPart in downloadItem.downloadItemParts { + downloadItemPart.task.resume() + } + } + + private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) throws -> DownloadItemPart { + NSLog("TRACK \(track.contentUrl!)") + + // If we don't name metadata, then we can't proceed + guard let filename = track.metadata?.filename else { + throw LibraryItemDownloadError.noMetadata + } + + let serverUrl = urlForTrack(item: item, track: track) + let itemDirectory = try createLibraryItemFileDirectory(item: item) + let localUrl = itemDirectory.appendingPathComponent("\(filename)") + + let task = session.downloadTask(with: serverUrl) + var downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: nil) + + // Store the id on the task so the download item can be pulled from the database later + task.taskDescription = downloadItemPart.id + downloadItemPart.task = task + + return downloadItemPart + } + + private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { + // filename needs to be encoded otherwise would just use contentUrl + let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) + let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" + return URL(string: urlstr)! + } + + private func createLibraryItemFileDirectory(item: LibraryItem) throws -> URL { + let itemDirectory = documentsDirectory.appendingPathComponent("\(item.id)") + NSLog("ITEM DIR \(itemDirectory)") + + do { + try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: true) + } catch { + NSLog("Failed to CREATE LI DIRECTORY \(error)") + throw LibraryItemDownloadError.failedDirectory + } + + return itemDirectory + } + + private func moveLibraryItemToFileDirectory(tempUrl: URL) { + //try FileManager.default.moveItem(at: tempUrl, to: localUrl) + } + } -struct DownloadItem: Codable { - var isDownloading = false - var progress: Float = 0 - var resumeData: Data? -// var task: URLSessionDownloadTask? +enum LibraryItemDownloadError: String, Error { + case noTracks = "No tracks on library item" + case noMetadata = "No metadata for track, unable to download" + case failedDirectory = "Failed to create directory" + case failedDownload = "Failed to download item" } diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift new file mode 100644 index 00000000..c0c9923d --- /dev/null +++ b/ios/App/Shared/models/DownloadItem.swift @@ -0,0 +1,81 @@ +// +// DownloadItem.swift +// App +// +// Created by Ron Heft on 8/5/22. +// + +import Foundation +import Unrealm + +struct DownloadItem: Realmable, Codable { + var id: String = UUID().uuidString + var libraryItemId: String? + var episodeId: String? + var userMediaProgress: MediaProgress? + var serverConnectionConfigId: String? + var serverAddress: String? + var serverUserId: String? + var mediaType: String? + var itemTitle: String? + var media: MediaType? + var downloadItemParts: [DownloadItemPart] = [] + + static func primaryKey() -> String? { + return "id" + } + + static func indexedProperties() -> [String] { + ["libraryItemId"] + } +} + +extension DownloadItem { + init(libraryItem: LibraryItem, server: ServerConnectionConfig) { + self.libraryItemId = libraryItem.id + //self.episodeId // TODO + self.userMediaProgress = libraryItem.userMediaProgress + self.serverConnectionConfigId = server.id + self.serverAddress = server.address + self.serverUserId = server.userId + self.mediaType = libraryItem.mediaType + self.itemTitle = libraryItem.media.metadata.title + self.media = libraryItem.media + } +} + +struct DownloadItemPart: Realmable, Codable { + var id: String = UUID().uuidString + var filename: String? + var finalDestinationPath: String? + var itemTitle: String? + var serverPath: String? + var audioTrack: AudioTrack? + var episode: PodcastEpisode? + var completed: Bool = false + var moved: Bool = false + var failed: Bool = false + var uri: String? + var destinationUri: String? + var finalDestinationUri: String? + var downloadId: Int? + var progress: Int = 0 + var task: URLSessionDownloadTask! + + private enum CodingKeys : String, CodingKey { + case id, progress + } + + static func ignoredProperties() -> [String] { + ["task"] + } +} + +extension DownloadItemPart { + init(filename: String, destination: URL, itemTitle: String, serverPath: String, audioTrack:AudioTrack?, episode: PodcastEpisode?) { + var downloadUrl = "" // TODO: Set this + if (serverPath.hasSuffix("/cover")) { + downloadUrl += "&format=jpeg" // For cover images force to jpeg + } + } +} diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 1dcdaf6a..45f5a98b 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -145,6 +145,30 @@ class Database { } } + public func getDownloadItem(downloadItemId: String) -> DownloadItem? { + Database.realmQueue.sync { + instance.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) + } + } + + public func getDownloadItem(libraryItemId: String) -> DownloadItem? { + Database.realmQueue.sync { + instance.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first + } + } + + public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? { + Database.realmQueue.sync { + instance.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first + } + } + + public func saveDownloadItem(_ downloadItem: DownloadItem) { + Database.realmQueue.sync { + try! instance.write { instance.add(downloadItem) } + } + } + public func getDeviceSettings() -> DeviceSettings { return Database.realmQueue.sync { return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() diff --git a/ios/App/Shared/util/LibraryItemDownloadSession.swift b/ios/App/Shared/util/LibraryItemDownloadSession.swift deleted file mode 100644 index 7a571117..00000000 --- a/ios/App/Shared/util/LibraryItemDownloadSession.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// LibraryItemDownloadSession.swift -// App -// -// Created by Ron Heft on 8/2/22. -// - -import Foundation - -enum LibraryItemDownloadError: String, Error { - case noTracks = "No tracks on library item" - case noMetadata = "No metadata for track, unable to download" - case failedDownload = "Failed to download item" -} - -class LibraryItemDownloadSession { - - let item: LibraryItem - - private let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - - init(_ item: LibraryItem) { - self.item = item - } - - public func startDownload() async throws -> LocalLibraryItem { - guard let tracks = item.media.tracks else { - throw LibraryItemDownloadError.noTracks - } - - return try await withThrowingTaskGroup(of: LocalFile.self, returning: LocalLibraryItem.self) { group in - for (position, track) in tracks.enumerated() { - group.addTask { try await self.startLibraryItemTrackDownload(item: self.item, position: position, track: track) } - } - - var files = [LocalFile]() - for try await file in group { - files.append(file) - } - - return LocalLibraryItem(self.item, localUrl: self.documentsDirectory, server: Store.serverConfig!, files: files) - } - } - - private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) async throws -> LocalFile { - NSLog("TRACK \(track.contentUrl!)") - - // If we don't name metadata, then we can't proceed - guard let filename = track.metadata?.filename else { - throw LibraryItemDownloadError.noMetadata - } - - let serverUrl = urlForTrack(item: item, track: track) - let itemDirectory = createLibraryItemFileDirectory(item: item) - let localUrl = itemDirectory.appendingPathComponent("\(filename)") - - try await downloadFile(serverUrl: serverUrl, localUrl: localUrl) - return LocalFile(item.id, filename, track.mimeType, localUrl) - } - - private func createLibraryItemFileDirectory(item: LibraryItem) -> URL { - let itemDirectory = documentsDirectory.appendingPathComponent("\(item.id)") - - NSLog("ITEM DIR \(itemDirectory)") - - // Create library item directory - do { - try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: true) - } catch { - NSLog("Failed to CREATE LI DIRECTORY \(error)") - } - - return itemDirectory - } - - private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { - // filename needs to be encoded otherwise would just use contentUrl - let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) - let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" - return URL(string: urlstr)! - } - - private func downloadFile(serverUrl: URL, localUrl: URL) async throws { - return try await withCheckedThrowingContinuation { continuation in - let downloadTask = URLSession.shared.downloadTask(with: serverUrl) { urlOrNil, responseOrNil, errorOrNil in - guard let tempUrl = urlOrNil else { - continuation.resume(throwing: errorOrNil!) - return - } - - do { - try FileManager.default.moveItem(at: tempUrl, to: localUrl) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - downloadTask.resume() - } - } - -} From af2c6094051ffedc0f24440d9e1f7268a4535b06 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 6 Aug 2022 18:21:11 -0400 Subject: [PATCH 025/103] Report download progress to the UI --- ios/App/App/plugins/AbsDownloader.swift | 25 +++++++++++++++++++++--- ios/App/Shared/models/DownloadItem.swift | 15 ++++++++++---- ios/App/Shared/util/Database.swift | 14 +++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index b7f7330c..ab80a33c 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -34,7 +34,25 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - NSLog("Received download status \(downloadTask.taskDescription ?? "Unknown Task"): \(totalBytesWritten)") + // Find the download item + guard let downloadItemPartId = downloadTask.taskDescription else { return } + let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId) + guard let downloadItem = downloadItem else { + NSLog("Download item part (%@) not found!", downloadItemPartId) + return + } + + // Calculate the download percentage + let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100 + NSLog("Received download status \(downloadItemPartId): \(percentDownloaded)") + Database.shared.updateDownloadItemPartPercent(downloadItemPartId: downloadItemPartId, percent: percentDownloaded) + let downloadItemPart = downloadItem.downloadItemParts.filter { part in + part.id == downloadItemPartId + }.first + + // Notify the UI + NSLog("Download progress: \(downloadItemPart?.progress ?? 0)") + try! notifyListeners("onItemDownloadUpdate", data: downloadItem.asDictionary()) } @objc func downloadLibraryItem(_ call: CAPPluginCall) { @@ -47,16 +65,17 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { libraryItem in if (libraryItem == nil) { NSLog("Library item not found") + call.resolve(["error": "Library item not found"]) } else { NSLog("Got library item from server \(libraryItem!.id)") do { try self.startLibraryItemDownload(libraryItem!) - //Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) + call.resolve() } catch { NSLog("Failed to download \(error)") + call.resolve(["error": "Failed to download"]) } } - call.resolve() } } diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift index c0c9923d..afd534db 100644 --- a/ios/App/Shared/models/DownloadItem.swift +++ b/ios/App/Shared/models/DownloadItem.swift @@ -28,6 +28,10 @@ struct DownloadItem: Realmable, Codable { static func indexedProperties() -> [String] { ["libraryItemId"] } + + private enum CodingKeys : String, CodingKey { + case id, libraryItemId, episodeId, userMediaProgress, serverConnectionConfigId, serverAddress, serverUserId, mediaType, itemTitle, downloadItemParts + } } extension DownloadItem { @@ -58,17 +62,20 @@ struct DownloadItemPart: Realmable, Codable { var uri: String? var destinationUri: String? var finalDestinationUri: String? - var downloadId: Int? - var progress: Int = 0 + var progress: Double = 0 var task: URLSessionDownloadTask! - private enum CodingKeys : String, CodingKey { - case id, progress + static func primaryKey() -> String? { + return "id" } static func ignoredProperties() -> [String] { ["task"] } + + private enum CodingKeys : String, CodingKey { + case id, filename, completed, moved, failed, progress + } } extension DownloadItemPart { diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 45f5a98b..c5cb4313 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -169,6 +169,20 @@ class Database { } } + public func updateDownloadItemPartPercent(downloadItemPartId: String, percent: Double) { + Database.realmQueue.sync { + try! instance.write { + let part = instance.object(ofType: DownloadItemPart.self, forPrimaryKey: downloadItemPartId) + guard var part = part else { + NSLog("downloadItemPartId not found (\(downloadItemPartId)") + return + } + part.progress = percent + instance.add(part, update: .modified) + } + } + } + public func getDeviceSettings() -> DeviceSettings { return Database.realmQueue.sync { return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() From dc8bc2fccd397d2f47f47e712ee2fc5fffe8abbf Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 6 Aug 2022 18:31:59 -0400 Subject: [PATCH 026/103] Clean up progress logic --- ios/App/App/plugins/AbsDownloader.swift | 44 +++++++++++++++---------- ios/App/Shared/util/Database.swift | 8 +---- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index ab80a33c..aaa8908f 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -34,25 +34,30 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - // Find the download item - guard let downloadItemPartId = downloadTask.taskDescription else { return } - let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId) - guard let downloadItem = downloadItem else { - NSLog("Download item part (%@) not found!", downloadItemPartId) - return + do { + guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription } + + // Calculate the download percentage + let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100 + NSLog("Received download status \(downloadItemPartId): \(percentDownloaded)") + + // Find the download item + let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId) + guard let downloadItem = downloadItem else { throw LibraryItemDownloadError.downloadItemNotFound } + + // Find the download item part + let downloadItemPart = downloadItem.downloadItemParts.filter { $0.id == downloadItemPartId }.first + guard var downloadItemPart = downloadItemPart else { throw LibraryItemDownloadError.downloadItemPartNotFound } + + // Update the progress + downloadItemPart.progress = percentDownloaded + Database.shared.updateDownloadItemPart(downloadItemPart) + + // Notify the UI + try! notifyListeners("onItemDownloadUpdate", data: downloadItem.asDictionary()) + } catch { + NSLog("DownloadItemError: \(error)") } - - // Calculate the download percentage - let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100 - NSLog("Received download status \(downloadItemPartId): \(percentDownloaded)") - Database.shared.updateDownloadItemPartPercent(downloadItemPartId: downloadItemPartId, percent: percentDownloaded) - let downloadItemPart = downloadItem.downloadItemParts.filter { part in - part.id == downloadItemPartId - }.first - - // Notify the UI - NSLog("Download progress: \(downloadItemPart?.progress ?? 0)") - try! notifyListeners("onItemDownloadUpdate", data: downloadItem.asDictionary()) } @objc func downloadLibraryItem(_ call: CAPPluginCall) { @@ -153,4 +158,7 @@ enum LibraryItemDownloadError: String, Error { case noMetadata = "No metadata for track, unable to download" case failedDirectory = "Failed to create directory" case failedDownload = "Failed to download item" + case noTaskDescription = "No task description" + case downloadItemNotFound = "DownloadItem not found" + case downloadItemPartNotFound = "DownloadItemPart not found" } diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index c5cb4313..4e1bd1b1 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -169,15 +169,9 @@ class Database { } } - public func updateDownloadItemPartPercent(downloadItemPartId: String, percent: Double) { + public func updateDownloadItemPart(_ part: DownloadItemPart) { Database.realmQueue.sync { try! instance.write { - let part = instance.object(ofType: DownloadItemPart.self, forPrimaryKey: downloadItemPartId) - guard var part = part else { - NSLog("downloadItemPartId not found (\(downloadItemPartId)") - return - } - part.progress = percent instance.add(part, update: .modified) } } From 7fded5e1056c343ccbdd21a7b78373efb6a62e84 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 6 Aug 2022 21:12:21 -0400 Subject: [PATCH 027/103] Cleanup progress handling --- ios/App/App/plugins/AbsDownloader.swift | 72 ++++++++++++++++-------- ios/App/Shared/models/DownloadItem.swift | 33 +++++++++-- 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index aaa8908f..dbbd3902 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -11,36 +11,53 @@ import Capacitor @objc(AbsDownloader) public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { + typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadItemPart: inout DownloadItemPart) throws -> Void + private let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: .current) + private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: OperationQueue.main) public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - NSLog("Finished downloading \(downloadTask.taskDescription ?? "Unknown Task")") - - guard let downloadItemPartId = downloadTask.taskDescription else { return } - - let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId) - guard let downloadItem = downloadItem else { - NSLog("Download item part (%@) not found! Unable to move file!", downloadItemPartId) - return + handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in + downloadItemPart.progress = 1 + downloadItemPart.completed = true + + do { + // Move the downloaded file into place + guard let destinationUrl = downloadItemPart.destinationURL() else { + throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined + } + try? FileManager.default.removeItem(at: destinationUrl) + try FileManager.default.moveItem(at: location, to: destinationUrl) + downloadItemPart.moved = true + } catch { + downloadItemPart.failed = true + throw error + } } - - NSLog("Found downloadItem(%@)", downloadItem.id) - self.moveLibraryItemToFileDirectory(tempUrl: location) } public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - NSLog("Error downloading \(task.taskDescription ?? "Unknown Task"): \(error ?? "Unknown Error")") + handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in + if let error = error { + downloadItemPart.failed = true + throw error + } + } } public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - do { - guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription } - + handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in // Calculate the download percentage let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100 - NSLog("Received download status \(downloadItemPartId): \(percentDownloaded)") - + downloadItemPart.progress = percentDownloaded + } + } + + private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) { + do { + guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription } + NSLog("Received download update for \(downloadItemPartId)") + // Find the download item let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId) guard let downloadItem = downloadItem else { throw LibraryItemDownloadError.downloadItemNotFound } @@ -49,14 +66,22 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let downloadItemPart = downloadItem.downloadItemParts.filter { $0.id == downloadItemPartId }.first guard var downloadItemPart = downloadItemPart else { throw LibraryItemDownloadError.downloadItemPartNotFound } + // Call the progress handler + do { + try progressHandler(downloadItem, &downloadItemPart) + } catch { + NSLog("Error while processing progress") + debugPrint(error) + } + // Update the progress - downloadItemPart.progress = percentDownloaded Database.shared.updateDownloadItemPart(downloadItemPart) - + // Notify the UI try! notifyListeners("onItemDownloadUpdate", data: downloadItem.asDictionary()) } catch { - NSLog("DownloadItemError: \(error)") + NSLog("DownloadItemError") + debugPrint(error) } } @@ -147,10 +172,6 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { return itemDirectory } - private func moveLibraryItemToFileDirectory(tempUrl: URL) { - //try FileManager.default.moveItem(at: tempUrl, to: localUrl) - } - } enum LibraryItemDownloadError: String, Error { @@ -161,4 +182,5 @@ enum LibraryItemDownloadError: String, Error { case noTaskDescription = "No task description" case downloadItemNotFound = "DownloadItem not found" case downloadItemPartNotFound = "DownloadItemPart not found" + case downloadItemPartDestinationUrlNotDefined = "DownloadItemPart destination URL not defined" } diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift index afd534db..241671a7 100644 --- a/ios/App/Shared/models/DownloadItem.swift +++ b/ios/App/Shared/models/DownloadItem.swift @@ -51,7 +51,6 @@ extension DownloadItem { struct DownloadItemPart: Realmable, Codable { var id: String = UUID().uuidString var filename: String? - var finalDestinationPath: String? var itemTitle: String? var serverPath: String? var audioTrack: AudioTrack? @@ -61,7 +60,6 @@ struct DownloadItemPart: Realmable, Codable { var failed: Bool = false var uri: String? var destinationUri: String? - var finalDestinationUri: String? var progress: Double = 0 var task: URLSessionDownloadTask! @@ -74,15 +72,40 @@ struct DownloadItemPart: Realmable, Codable { } private enum CodingKeys : String, CodingKey { - case id, filename, completed, moved, failed, progress + case id, filename, itemTitle, completed, moved, failed, progress } } extension DownloadItemPart { - init(filename: String, destination: URL, itemTitle: String, serverPath: String, audioTrack:AudioTrack?, episode: PodcastEpisode?) { - var downloadUrl = "" // TODO: Set this + init(filename: String, destination: URL, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) { + self.filename = filename + self.itemTitle = itemTitle + self.serverPath = serverPath + self.audioTrack = audioTrack + self.episode = episode + + let config = Store.serverConfig! + var downloadUrl = "\(config.address)\(serverPath)?token=\(config.token)" if (serverPath.hasSuffix("/cover")) { downloadUrl += "&format=jpeg" // For cover images force to jpeg } + self.uri = downloadUrl + self.destinationUri = destination.path + } + + func downloadURL() -> URL? { + if let uri = self.uri { + return URL(string: uri) + } else { + return nil + } + } + + func destinationURL() -> URL? { + if let destinationUri = self.destinationUri { + return URL(fileURLWithPath: destinationUri) + } else { + return nil + } } } From 5495bcb945073d089f1a4e65e58a19ca36e11c66 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 6 Aug 2022 21:32:04 -0400 Subject: [PATCH 028/103] Starting logic for handling completed downloads --- ios/App/App/plugins/AbsDownloader.swift | 22 +++++++++++++++++++++- ios/App/Shared/models/DownloadItem.swift | 8 ++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index dbbd3902..198e913d 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -39,6 +39,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in if let error = error { + downloadItemPart.completed = true downloadItemPart.failed = true throw error } @@ -78,13 +79,32 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { Database.shared.updateDownloadItemPart(downloadItemPart) // Notify the UI - try! notifyListeners("onItemDownloadUpdate", data: downloadItem.asDictionary()) + try? notifyListeners("onItemDownloadUpdate", data: downloadItem.asDictionary()) + + // Handle a completed download + if ( downloadItem.isDoneDownloading() ) { + handleDownloadTaskCompleteFromDownloadItem(downloadItem) + } } catch { NSLog("DownloadItemError") debugPrint(error) } } + private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) { + var statusNotification = [String: String]() + + if ( downloadItem.didDownloadSuccessfully() ) { + ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in + //let localDirectory = documentsDirectory.appendingPathComponent("\(libraryItem.id)") + //let localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: <#T##[LocalFile]#>) + } + + } + + notifyListeners("onItemDownloadComplete", data: statusNotification) + } + @objc func downloadLibraryItem(_ call: CAPPluginCall) { let libraryItemId = call.getString("libraryItemId") let episodeId = call.getString("episodeId") diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift index 241671a7..e521c647 100644 --- a/ios/App/Shared/models/DownloadItem.swift +++ b/ios/App/Shared/models/DownloadItem.swift @@ -46,6 +46,14 @@ extension DownloadItem { self.itemTitle = libraryItem.media.metadata.title self.media = libraryItem.media } + + func isDoneDownloading() -> Bool { + self.downloadItemParts.allSatisfy({ $0.completed }) + } + + func didDownloadSuccessfully() -> Bool { + self.downloadItemParts.allSatisfy({ $0.failed = false }) + } } struct DownloadItemPart: Realmable, Codable { From b549528e23eacfec556fd0ab57d960af17e0aa02 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sun, 7 Aug 2022 10:27:08 -0400 Subject: [PATCH 029/103] Handle download complete lifecycle --- ios/App/App/plugins/AbsDownloader.swift | 29 +++++++++++++++---- ios/App/Shared/models/DownloadItem.swift | 9 ++++-- ios/App/Shared/models/LocalLibrary.swift | 2 +- .../models/LocalLibraryExtensions.swift | 25 ++++++++++++++++ ios/App/Shared/util/Database.swift | 6 ++++ 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 198e913d..5cf3552f 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -83,6 +83,8 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // Handle a completed download if ( downloadItem.isDoneDownloading() ) { + // Delete the download item on exit even if handling complete errors + defer { Database.shared.removeDownloadItem(downloadItem) } handleDownloadTaskCompleteFromDownloadItem(downloadItem) } } catch { @@ -92,17 +94,31 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) { - var statusNotification = [String: String]() - if ( downloadItem.didDownloadSuccessfully() ) { + ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in - //let localDirectory = documentsDirectory.appendingPathComponent("\(libraryItem.id)") - //let localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: <#T##[LocalFile]#>) + var statusNotification = [String: Any]() + statusNotification["libraryItemId"] = libraryItem?.id + + guard let libraryItem = libraryItem else { NSLog("LibraryItem not found"); return } + let localDirectory = self.documentsDirectory.appendingPathComponent("\(libraryItem.id)") + let files = downloadItem.downloadItemParts.map { part in LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationURL()!) } + let localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files) + + Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) + statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary() + + if let progress = libraryItem.userMediaProgress { + // TODO: Handle podcast + let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: nil, progress: progress) + Database.shared.saveLocalMediaProgress(localMediaProgress) + statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary() + } + + self.notifyListeners("onItemDownloadComplete", data: statusNotification) } } - - notifyListeners("onItemDownloadComplete", data: statusNotification) } @objc func downloadLibraryItem(_ call: CAPPluginCall) { @@ -203,4 +219,5 @@ enum LibraryItemDownloadError: String, Error { case downloadItemNotFound = "DownloadItem not found" case downloadItemPartNotFound = "DownloadItemPart not found" case downloadItemPartDestinationUrlNotDefined = "DownloadItemPart destination URL not defined" + case libraryItemNotFound = "LibraryItem not found for id" } diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift index e521c647..56e8ca4f 100644 --- a/ios/App/Shared/models/DownloadItem.swift +++ b/ios/App/Shared/models/DownloadItem.swift @@ -9,7 +9,7 @@ import Foundation import Unrealm struct DownloadItem: Realmable, Codable { - var id: String = UUID().uuidString + var id: String? var libraryItemId: String? var episodeId: String? var userMediaProgress: MediaProgress? @@ -36,6 +36,7 @@ struct DownloadItem: Realmable, Codable { extension DownloadItem { init(libraryItem: LibraryItem, server: ServerConnectionConfig) { + self.id = libraryItem.id self.libraryItemId = libraryItem.id //self.episodeId // TODO self.userMediaProgress = libraryItem.userMediaProgress @@ -52,7 +53,7 @@ extension DownloadItem { } func didDownloadSuccessfully() -> Bool { - self.downloadItemParts.allSatisfy({ $0.failed = false }) + self.downloadItemParts.allSatisfy({ $0.failed == false }) } } @@ -101,6 +102,10 @@ extension DownloadItemPart { self.destinationUri = destination.path } + func mimeType() -> String? { + audioTrack?.mimeType ?? episode?.audioTrack?.mimeType + } + func downloadURL() -> URL? { if let uri = self.uri { return URL(string: uri) diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index f0e49887..5f36ff99 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -63,7 +63,7 @@ struct LocalFile: Realmable, Codable { } struct LocalMediaProgress: Realmable, Codable { - var id: String = UUID().uuidString + var id: String = "" var localLibraryItemId: String = "" var localEpisodeId: String? var duration: Double = 0 diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index e05c6be0..93ca000b 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -75,6 +75,7 @@ extension LocalFile { self.init() self.id = "\(libraryItemId)_\(filename.toBase64())" self.filename = filename + self.mimeType = mimeType self.contentUrl = localUrl.absoluteString self.absolutePath = localUrl.path self.size = Int(localUrl.fileSize) @@ -90,3 +91,27 @@ extension LocalFile { } } } + +extension LocalMediaProgress { + init(localLibraryItem: LocalLibraryItem, episode: LocalPodcastEpisode?, progress: MediaProgress) { + self.id = localLibraryItem.id + self.localLibraryItemId = localLibraryItem.id + self.libraryItemId = localLibraryItem.libraryItemId + + if let episode = episode { + self.id += "-\(episode.id)" + self.episodeId = episode.id + } + + self.serverAddress = localLibraryItem.serverAddress + self.serverUserId = localLibraryItem.serverUserId + self.serverConnectionConfigId = localLibraryItem.serverConnectionConfigId + + self.duration = progress.duration + self.currentTime = progress.currentTime + self.isFinished = false + self.lastUpdate = progress.lastUpdate + self.startedAt = progress.startedAt + self.finishedAt = progress.finishedAt + } +} diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 4e1bd1b1..542af63a 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -177,6 +177,12 @@ class Database { } } + public func removeDownloadItem(_ downloadItem: DownloadItem) { + Database.realmQueue.sync { + try! instance.write { instance.delete(downloadItem) } + } + } + public func getDeviceSettings() -> DeviceSettings { return Database.realmQueue.sync { return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() From 5b7fcca800cd4c74c369be4133533c404f1e7885 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sun, 7 Aug 2022 11:23:50 -0400 Subject: [PATCH 030/103] Support downloading covers and podcast episodes --- ios/App/App/plugins/AbsDownloader.swift | 78 ++++++++++++++++++++---- ios/App/Shared/models/DownloadItem.swift | 8 ++- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 5cf3552f..4f2b0027 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -123,39 +123,79 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { @objc func downloadLibraryItem(_ call: CAPPluginCall) { let libraryItemId = call.getString("libraryItemId") - let episodeId = call.getString("episodeId") + var episodeId = call.getString("episodeId") + if ( episodeId == "null" ) { episodeId = nil } - NSLog("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "")") - guard let libraryItemId = libraryItemId else { call.resolve(); return; } + NSLog("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")") + guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) } + + // Verify the file isn't already downloading + let downloadItemId = episodeId != nil ? "\(libraryItemId)-\(episodeId!)" : libraryItemId + let downloadItem = Database.shared.getDownloadItem(downloadItemId: downloadItemId) + if ( downloadItem != nil ) { + return call.resolve(["error": "Download already started for this media entity"]) + } ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { libraryItem in - if (libraryItem == nil) { - NSLog("Library item not found") - call.resolve(["error": "Library item not found"]) - } else { - NSLog("Got library item from server \(libraryItem!.id)") + if let libraryItem = libraryItem { + NSLog("Got library item from server \(libraryItem.id)") do { - try self.startLibraryItemDownload(libraryItem!) + if let episodeId = episodeId { + // Download a podcast episode + guard libraryItem.mediaType == "podcast" else { throw LibraryItemDownloadError.libraryItemNotPodcast } + let episode = libraryItem.media.episodes?.first(where: { $0.id == episodeId }) + guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound } + try self.startLibraryItemDownload(libraryItem, episode: episode) + } else { + // Download a book + try self.startLibraryItemDownload(libraryItem) + } call.resolve() } catch { - NSLog("Failed to download \(error)") + debugPrint(error) call.resolve(["error": "Failed to download"]) } + } else { + call.resolve(["error": "Server request failed"]) } } } private func startLibraryItemDownload(_ item: LibraryItem) throws { - guard let tracks = item.media.tracks else { - throw LibraryItemDownloadError.noTracks + try startLibraryItemDownload(item, episode: nil) + } + + private func startLibraryItemDownload(_ item: LibraryItem, episode: PodcastEpisode?) throws { + var tracks: [AudioTrack] + var episodeId: String? + + // Handle the different media type downloads + switch item.mediaType { + case "book": + guard let bookTracks = item.media.tracks else { throw LibraryItemDownloadError.noTracks } + tracks = bookTracks + case "podcast": + guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound } + guard let podcastTrack = episode.audioTrack else { throw LibraryItemDownloadError.noTracks } + episodeId = episode.id + tracks = [podcastTrack] + default: + throw LibraryItemDownloadError.unknownMediaType } // Queue up everything for downloading - var downloadItem = DownloadItem(libraryItem: item, server: Store.serverConfig!) + var downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!) downloadItem.downloadItemParts = try tracks.enumerated().map({ i, track in try startLibraryItemTrackDownload(item: item, position: i, track: track) }) + // Also download the cover + if item.media.coverPath != nil && !item.media.coverPath!.isEmpty { + if let coverDownload = try? startLibraryItemCoverDownload(item: item) { + downloadItem.downloadItemParts.append(coverDownload) + } + } + // Persist in the database before status start coming in Database.shared.saveDownloadItem(downloadItem) @@ -187,6 +227,15 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { return downloadItemPart } + private func startLibraryItemCoverDownload(item: LibraryItem) throws -> DownloadItemPart { + let filename = "cover.jpg" + let serverPath = "/api/items/\(item.id)/cover" + let itemDirectory = try createLibraryItemFileDirectory(item: item) + let localUrl = itemDirectory.appendingPathComponent("\(filename)") + + return DownloadItemPart(filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil) + } + private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { // filename needs to be encoded otherwise would just use contentUrl let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) @@ -213,6 +262,9 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { enum LibraryItemDownloadError: String, Error { case noTracks = "No tracks on library item" case noMetadata = "No metadata for track, unable to download" + case libraryItemNotPodcast = "Library item is not a podcast but episode was requested" + case podcastEpisodeNotFound = "Invalid podcast episode not found" + case unknownMediaType = "Unknown media type" case failedDirectory = "Failed to create directory" case failedDownload = "Failed to download item" case noTaskDescription = "No task description" diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift index 56e8ca4f..b7977edc 100644 --- a/ios/App/Shared/models/DownloadItem.swift +++ b/ios/App/Shared/models/DownloadItem.swift @@ -35,10 +35,9 @@ struct DownloadItem: Realmable, Codable { } extension DownloadItem { - init(libraryItem: LibraryItem, server: ServerConnectionConfig) { + init(libraryItem: LibraryItem, episodeId: String?, server: ServerConnectionConfig) { self.id = libraryItem.id self.libraryItemId = libraryItem.id - //self.episodeId // TODO self.userMediaProgress = libraryItem.userMediaProgress self.serverConnectionConfigId = server.id self.serverAddress = server.address @@ -46,6 +45,11 @@ extension DownloadItem { self.mediaType = libraryItem.mediaType self.itemTitle = libraryItem.media.metadata.title self.media = libraryItem.media + + if let episodeId = episodeId { + self.id! += "-\(episodeId)" + self.episodeId = episodeId + } } func isDoneDownloading() -> Bool { From 849b6303e83d105661a1c782fa5554e7a5e4ac96 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sun, 7 Aug 2022 11:59:02 -0400 Subject: [PATCH 031/103] Display downloaded cover on items --- ios/App/App/plugins/AbsDownloader.swift | 29 ++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 4f2b0027..498244be 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -102,8 +102,24 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { guard let libraryItem = libraryItem else { NSLog("LibraryItem not found"); return } let localDirectory = self.documentsDirectory.appendingPathComponent("\(libraryItem.id)") - let files = downloadItem.downloadItemParts.map { part in LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationURL()!) } - let localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files) + var coverFile: URL? + + // Assemble the local library item + let files = downloadItem.downloadItemParts.compactMap { part -> LocalFile? in + if part.filename == "cover.jpg" { + coverFile = part.destinationURL() + return nil + } else { + return LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationURL()!) + } + } + var localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files) + + // Store the cover file + if let coverFile = coverFile { + localLibraryItem.coverContentUrl = coverFile.absoluteString + localLibraryItem.coverAbsolutePath = coverFile.path + } Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary() @@ -233,7 +249,14 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let itemDirectory = try createLibraryItemFileDirectory(item: item) let localUrl = itemDirectory.appendingPathComponent("\(filename)") - return DownloadItemPart(filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil) + var downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil) + let task = session.downloadTask(with: downloadItemPart.downloadURL()!) + + // Store the id on the task so the download item can be pulled from the database later + task.taskDescription = downloadItemPart.id + downloadItemPart.task = task + + return downloadItemPart } private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { From ad802c16ea0831e7adee013256ea0a4c5dc3fa8a Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sun, 7 Aug 2022 17:46:13 -0400 Subject: [PATCH 032/103] Remove blocking realmQueue --- ios/App/Shared/models/DeviceSettings.swift | 14 +- .../models/ServerConnectionConfig.swift | 20 +- ios/App/Shared/util/Database.swift | 207 ++++++++---------- ios/App/Shared/util/Store.swift | 4 +- 4 files changed, 102 insertions(+), 143 deletions(-) diff --git a/ios/App/Shared/models/DeviceSettings.swift b/ios/App/Shared/models/DeviceSettings.swift index 34d29bd6..16e1fd54 100644 --- a/ios/App/Shared/models/DeviceSettings.swift +++ b/ios/App/Shared/models/DeviceSettings.swift @@ -21,12 +21,10 @@ func getDefaultDeviceSettings() -> DeviceSettings { } func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary { - return Database.realmQueue.sync { - return [ - "disableAutoRewind": settings.disableAutoRewind, - "enableAltView": settings.enableAltView, - "jumpBackwardsTime": settings.jumpBackwardsTime, - "jumpForwardTime": settings.jumpForwardTime - ] - } + return [ + "disableAutoRewind": settings.disableAutoRewind, + "enableAltView": settings.enableAltView, + "jumpBackwardsTime": settings.jumpBackwardsTime, + "jumpForwardTime": settings.jumpForwardTime + ] } diff --git a/ios/App/Shared/models/ServerConnectionConfig.swift b/ios/App/Shared/models/ServerConnectionConfig.swift index 117500fc..3f579fed 100644 --- a/ios/App/Shared/models/ServerConnectionConfig.swift +++ b/ios/App/Shared/models/ServerConnectionConfig.swift @@ -37,15 +37,13 @@ struct ServerConnectionConfigActiveIndex: Realmable { } func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary { - return Database.realmQueue.sync { - return [ - "id": config.id, - "name": config.name, - "index": config.index, - "address": config.address, - "userId": config.userId, - "username": config.username, - "token": config.token, - ] - } + return [ + "id": config.id, + "name": config.name, + "index": config.index, + "address": config.address, + "userId": config.userId, + "username": config.username, + "token": config.token, + ] } diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 542af63a..9f13c8b6 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -9,85 +9,74 @@ import Foundation import RealmSwift class Database { - // All DB releated actions must be executed on "realm-queue" - public static let realmQueue: DispatchQueue = DispatchQueue(label: "realm-queue") public static var shared = { - realmQueue.sync { - return Database() - } + return Database() }() - private var instance: Realm - private init() { - self.instance = try! Realm(queue: Database.realmQueue) - } + private init() {} public func setServerConnectionConfig(config: ServerConnectionConfig) { var config = config - Database.realmQueue.sync { - var existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) + let realm = try! Realm() + let existing: ServerConnectionConfig? = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) + + if config.index == 0 { + let lastConfig: ServerConnectionConfig? = realm.objects(ServerConnectionConfig.self).last - if config.index == 0 { - var lastConfig: ServerConnectionConfig? = instance.objects(ServerConnectionConfig.self).last - - if lastConfig != nil { - config.index = lastConfig!.index + 1 - } else { - config.index = 1 - } + if lastConfig != nil { + config.index = lastConfig!.index + 1 + } else { + config.index = 1 } - - do { - try instance.write { - if existing != nil { - instance.delete(existing!) - } - instance.add(config) - } - } catch(let exception) { - NSLog("failed to save server config") - debugPrint(exception) - } - - setLastActiveConfigIndex(index: config.index) } + + do { + try realm.write { + if existing != nil { + realm.delete(existing!) + } + realm.add(config) + } + } catch(let exception) { + NSLog("failed to save server config") + debugPrint(exception) + } + + setLastActiveConfigIndex(index: config.index) } public func deleteServerConnectionConfig(id: String) { - Database.realmQueue.sync { - let config = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id) - - do { - try instance.write { - if config != nil { - instance.delete(config!) - } + let realm = try! Realm() + let config = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id) + + do { + try realm.write { + if config != nil { + realm.delete(config!) } - } catch(let exception) { - NSLog("failed to delete server config") - debugPrint(exception) } + } catch(let exception) { + NSLog("failed to delete server config") + debugPrint(exception) } } public func getServerConnectionConfigs() -> [ServerConnectionConfig] { - Database.realmQueue.sync { - return Array(instance.objects(ServerConnectionConfig.self)) - } + let realm = try! Realm() + return Array(realm.objects(ServerConnectionConfig.self)) } public func setLastActiveConfigIndexToNil() { - Database.realmQueue.sync { - setLastActiveConfigIndex(index: nil) - } + setLastActiveConfigIndex(index: nil) } private func setLastActiveConfigIndex(index: Int?) { + let realm = try! Realm() do { - try instance.write { - var existing = instance.objects(ServerConnectionConfigActiveIndex.self).last ?? ServerConnectionConfigActiveIndex(index: index) + try realm.write { + var existing = realm.objects(ServerConnectionConfigActiveIndex.self).last ?? ServerConnectionConfigActiveIndex(index: index) existing.index = index - instance.add(existing, update: .modified) + realm.add(existing, update: .modified) } } catch(let exception) { NSLog("failed to save server config active index") @@ -96,127 +85,103 @@ class Database { } public func getLastActiveConfigIndex() -> Int? { - return Database.realmQueue.sync { - return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil - } + let realm = try! Realm() + return realm.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil } public func setDeviceSettings(deviceSettings: DeviceSettings) { - Database.realmQueue.sync { - let existing = instance.objects(DeviceSettings.self) + let realm = try! Realm() + let existing = realm.objects(DeviceSettings.self) - do { - try instance.write { - instance.delete(existing) - instance.add(deviceSettings) - } - } catch(let exception) { - NSLog("failed to save device settings") + do { + try realm.write { + realm.delete(existing) + realm.add(deviceSettings) } + } catch { + NSLog("failed to save device settings") } } public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] { - Database.realmQueue.sync { - Array(instance.objects(LocalLibraryItem.self)) - } + let realm = try! Realm() + return Array(realm.objects(LocalLibraryItem.self)) } public func getLocalLibraryItemByLLId(libraryItem: String) -> LocalLibraryItem? { - let items = getLocalLibraryItems() - for item in items { - if (item.libraryItemId == libraryItem) { - return item - } - } - NSLog("Local library item with id \(libraryItem) not found") - return nil + let realm = try! Realm() + return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == libraryItem }) } public func getLocalLibraryItem(localLibraryItem: String) -> LocalLibraryItem? { - Database.realmQueue.sync { - instance.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItem) - } + let realm = try! Realm() + return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItem) } public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) { - Database.realmQueue.sync { - try! instance.write { instance.add(localLibraryItem) } - } + let realm = try! Realm() + try! realm.write { realm.add(localLibraryItem) } } public func getDownloadItem(downloadItemId: String) -> DownloadItem? { - Database.realmQueue.sync { - instance.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) - } + let realm = try! Realm() + return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) } public func getDownloadItem(libraryItemId: String) -> DownloadItem? { - Database.realmQueue.sync { - instance.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first - } + let realm = try! Realm() + return realm.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first } public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? { - Database.realmQueue.sync { - instance.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first - } + let realm = try! Realm() + return realm.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first } public func saveDownloadItem(_ downloadItem: DownloadItem) { - Database.realmQueue.sync { - try! instance.write { instance.add(downloadItem) } - } + let realm = try! Realm() + return try! realm.write { realm.add(downloadItem) } } public func updateDownloadItemPart(_ part: DownloadItemPart) { - Database.realmQueue.sync { - try! instance.write { - instance.add(part, update: .modified) - } - } + let realm = try! Realm() + return try! realm.write { realm.add(part, update: .modified) } } public func removeDownloadItem(_ downloadItem: DownloadItem) { - Database.realmQueue.sync { - try! instance.write { instance.delete(downloadItem) } - } + let realm = try! Realm() + return try! realm.write { realm.delete(downloadItem) } } public func getDeviceSettings() -> DeviceSettings { - return Database.realmQueue.sync { - return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() - } + let realm = try! Realm() + return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() } public func removeLocalLibraryItem(localLibraryItemId: String) { - Database.realmQueue.sync { - try! instance.write { - let item = getLocalLibraryItemByLLId(libraryItem: localLibraryItemId) - instance.delete(item!) - } + let realm = try! Realm() + try! realm.write { + let item = getLocalLibraryItemByLLId(libraryItem: localLibraryItemId) + realm.delete(item!) } } public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) { - Database.realmQueue.sync { - try! instance.write { instance.add(mediaProgress) } - } + let realm = try! Realm() + try! realm.write { realm.add(mediaProgress) } } // For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}" public func getLocalMediaProgress(localMediaProgressId: String) -> LocalMediaProgress? { - Database.realmQueue.sync { - instance.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) - } + let realm = try! Realm() + return realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) } public func removeLocalMediaProgress(localMediaProgressId: String) { - Database.realmQueue.sync { - try! instance.write { - let progress = instance.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) - instance.delete(progress!) - } + let realm = try! Realm() + try! realm.write { + let progress = realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) + realm.delete(progress!) } } } diff --git a/ios/App/Shared/util/Store.swift b/ios/App/Shared/util/Store.swift index 210664e1..e3eba891 100644 --- a/ios/App/Shared/util/Store.swift +++ b/ios/App/Shared/util/Store.swift @@ -21,9 +21,7 @@ class Store { Database.shared.setLastActiveConfigIndexToNil() } - Database.realmQueue.sync { - _serverConfig = updated - } + _serverConfig = updated } } } From b1b10e846d7dec06aef185080d2bf6e9c7e11b32 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sun, 7 Aug 2022 21:02:25 -0400 Subject: [PATCH 033/103] Somewhat working progress under heavy load --- ios/App/App/plugins/AbsDownloader.swift | 54 ++++++++++++++++++------- ios/App/Shared/util/Database.swift | 4 +- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 498244be..89bfe339 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -16,6 +16,10 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { private let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: OperationQueue.main) + private let progressStatusQueue = DispatchQueue(label: "progress-status") + private var progressStatusWorkItem: DispatchWorkItem? + private var downloadItemQueue = [String: DownloadItem]() + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in downloadItemPart.progress = 1 @@ -61,29 +65,29 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // Find the download item let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId) - guard let downloadItem = downloadItem else { throw LibraryItemDownloadError.downloadItemNotFound } + guard var downloadItem = downloadItem else { throw LibraryItemDownloadError.downloadItemNotFound } + downloadItem = self.downloadItemQueue[downloadItem.id!] ?? downloadItem // Find the download item part - let downloadItemPart = downloadItem.downloadItemParts.filter { $0.id == downloadItemPartId }.first - guard var downloadItemPart = downloadItemPart else { throw LibraryItemDownloadError.downloadItemPartNotFound } + let partIndex = downloadItem.downloadItemParts.firstIndex(where: { $0.id == downloadItemPartId }) + guard let partIndex = partIndex else { throw LibraryItemDownloadError.downloadItemPartNotFound } // Call the progress handler do { - try progressHandler(downloadItem, &downloadItemPart) + try progressHandler(downloadItem, &downloadItem.downloadItemParts[partIndex]) } catch { NSLog("Error while processing progress") debugPrint(error) } // Update the progress - Database.shared.updateDownloadItemPart(downloadItemPart) + self.downloadItemQueue.updateValue(downloadItem, forKey: downloadItem.id!) // Notify the UI - try? notifyListeners("onItemDownloadUpdate", data: downloadItem.asDictionary()) + self.notifyDownloadProgress() // Handle a completed download if ( downloadItem.isDoneDownloading() ) { - // Delete the download item on exit even if handling complete errors defer { Database.shared.removeDownloadItem(downloadItem) } handleDownloadTaskCompleteFromDownloadItem(downloadItem) } @@ -93,6 +97,35 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } } + // We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates + private func notifyDownloadProgress() { + // Return if the loop is running + guard self.progressStatusWorkItem == nil else { return } + + // Create a background thread to send the download status + self.progressStatusWorkItem = DispatchWorkItem { [weak self] in // Weak capture so the bg thread is pulling latest data + // Clean up the work item when done + defer { self?.progressStatusWorkItem = nil } + + while !(self?.downloadItemQueue.isEmpty ?? true) { + for item in self!.downloadItemQueue.values { + try? self!.notifyListeners("onItemDownloadUpdate", data: item.asDictionary()) + + // Clean up a completed download + if item.isDoneDownloading() { + self!.downloadItemQueue.removeValue(forKey: item.id!) + } + } + + // Wait 200ms before reporting status again + Thread.sleep(forTimeInterval: TimeInterval(0.2)) + } + } + + // Start the thread + self.progressStatusQueue.async(execute: self.progressStatusWorkItem!) + } + private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) { if ( downloadItem.didDownloadSuccessfully() ) { @@ -145,13 +178,6 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { NSLog("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")") guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) } - // Verify the file isn't already downloading - let downloadItemId = episodeId != nil ? "\(libraryItemId)-\(episodeId!)" : libraryItemId - let downloadItem = Database.shared.getDownloadItem(downloadItemId: downloadItemId) - if ( downloadItem != nil ) { - return call.resolve(["error": "Download already started for this media entity"]) - } - ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { libraryItem in if let libraryItem = libraryItem { NSLog("Got library item from server \(libraryItem.id)") diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 9f13c8b6..64ffc05e 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -120,7 +120,7 @@ class Database { public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) { let realm = try! Realm() - try! realm.write { realm.add(localLibraryItem) } + try! realm.write { realm.add(localLibraryItem, update: .modified) } } public func getDownloadItem(downloadItemId: String) -> DownloadItem? { @@ -140,7 +140,7 @@ class Database { public func saveDownloadItem(_ downloadItem: DownloadItem) { let realm = try! Realm() - return try! realm.write { realm.add(downloadItem) } + return try! realm.write { realm.add(downloadItem, update: .modified) } } public func updateDownloadItemPart(_ part: DownloadItemPart) { From 30ae98c3de5e78c953ec1eb338209d2899490ccd Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sun, 7 Aug 2022 21:17:13 -0400 Subject: [PATCH 034/103] Throttle downloads --- ios/App/App/plugins/AbsDownloader.swift | 46 +++++-------------------- 1 file changed, 8 insertions(+), 38 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 89bfe339..e9c7a861 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -14,11 +14,11 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadItemPart: inout DownloadItemPart) throws -> Void private let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: OperationQueue.main) - - private let progressStatusQueue = DispatchQueue(label: "progress-status") - private var progressStatusWorkItem: DispatchWorkItem? - private var downloadItemQueue = [String: DownloadItem]() + private lazy var session: URLSession = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 5 + return URLSession(configuration: .default, delegate: self, delegateQueue: queue) + }() public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in @@ -66,7 +66,6 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // Find the download item let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId) guard var downloadItem = downloadItem else { throw LibraryItemDownloadError.downloadItemNotFound } - downloadItem = self.downloadItemQueue[downloadItem.id!] ?? downloadItem // Find the download item part let partIndex = downloadItem.downloadItemParts.firstIndex(where: { $0.id == downloadItemPartId }) @@ -81,13 +80,14 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } // Update the progress - self.downloadItemQueue.updateValue(downloadItem, forKey: downloadItem.id!) + Database.shared.updateDownloadItemPart(downloadItem.downloadItemParts[partIndex]) // Notify the UI - self.notifyDownloadProgress() + try? self.notifyListeners("onItemDownloadUpdate", data: downloadItem.asDictionary()) // Handle a completed download if ( downloadItem.isDoneDownloading() ) { + // TODO: Prevent this block from firing multiple times defer { Database.shared.removeDownloadItem(downloadItem) } handleDownloadTaskCompleteFromDownloadItem(downloadItem) } @@ -97,38 +97,8 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } } - // We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates - private func notifyDownloadProgress() { - // Return if the loop is running - guard self.progressStatusWorkItem == nil else { return } - - // Create a background thread to send the download status - self.progressStatusWorkItem = DispatchWorkItem { [weak self] in // Weak capture so the bg thread is pulling latest data - // Clean up the work item when done - defer { self?.progressStatusWorkItem = nil } - - while !(self?.downloadItemQueue.isEmpty ?? true) { - for item in self!.downloadItemQueue.values { - try? self!.notifyListeners("onItemDownloadUpdate", data: item.asDictionary()) - - // Clean up a completed download - if item.isDoneDownloading() { - self!.downloadItemQueue.removeValue(forKey: item.id!) - } - } - - // Wait 200ms before reporting status again - Thread.sleep(forTimeInterval: TimeInterval(0.2)) - } - } - - // Start the thread - self.progressStatusQueue.async(execute: self.progressStatusWorkItem!) - } - private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) { if ( downloadItem.didDownloadSuccessfully() ) { - ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in var statusNotification = [String: Any]() statusNotification["libraryItemId"] = libraryItem?.id From 162eb9afadbc10b79ab6decfb56efff2aa5abca0 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Mon, 8 Aug 2022 12:39:55 -0400 Subject: [PATCH 035/103] Fix race condition of reporting finished download --- ios/App/App/plugins/AbsDownloader.swift | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index e9c7a861..d39be724 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -19,6 +19,11 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { queue.maxConcurrentOperationCount = 5 return URLSession(configuration: .default, delegate: self, delegateQueue: queue) }() + private let progressStatusQueue: OperationQueue = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + return queue + }() public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in @@ -87,9 +92,16 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // Handle a completed download if ( downloadItem.isDoneDownloading() ) { - // TODO: Prevent this block from firing multiple times - defer { Database.shared.removeDownloadItem(downloadItem) } - handleDownloadTaskCompleteFromDownloadItem(downloadItem) + // Prevent race condition when multiple parts finish downloading at the same time by using a queue + self.progressStatusQueue.addOperation { + // Remove the download item after the operation completes + defer { Database.shared.removeDownloadItem(downloadItem) } + // Fetch the latest download item, so we know if it was removed in another thread + let downloadItem = Database.shared.getDownloadItem(downloadItemId: downloadItem.id!) + // We already processed this download item on another thread, skip it + guard let downloadItem = downloadItem else { return } + self.handleDownloadTaskCompleteFromDownloadItem(downloadItem) + } } } catch { NSLog("DownloadItemError") @@ -136,7 +148,6 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { self.notifyListeners("onItemDownloadComplete", data: statusNotification) } - } } From 948cd3068acba7dac266cfb3933f2480428bef12 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Mon, 8 Aug 2022 13:47:37 -0400 Subject: [PATCH 036/103] Fix more race conditions with progress reporting --- ios/App/App/plugins/AbsDownloader.swift | 80 +++++++++++++++++------- ios/App/Shared/models/DownloadItem.swift | 2 +- 2 files changed, 59 insertions(+), 23 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index d39be724..22118889 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -19,11 +19,9 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { queue.maxConcurrentOperationCount = 5 return URLSession(configuration: .default, delegate: self, delegateQueue: queue) }() - private let progressStatusQueue: OperationQueue = { - let queue = OperationQueue() - queue.maxConcurrentOperationCount = 1 - return queue - }() + private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent) + private var progressStatusWorkItem: DispatchWorkItem? + private var downloadItemProgress = [String: DownloadItem]() public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in @@ -59,7 +57,10 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in // Calculate the download percentage let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100 - downloadItemPart.progress = percentDownloaded + // Only update the progress if we received accurate progress data + if percentDownloaded >= 0.0 && percentDownloaded <= 1.0 { + downloadItemPart.progress = percentDownloaded + } } } @@ -86,22 +87,9 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // Update the progress Database.shared.updateDownloadItemPart(downloadItem.downloadItemParts[partIndex]) - - // Notify the UI - try? self.notifyListeners("onItemDownloadUpdate", data: downloadItem.asDictionary()) - - // Handle a completed download - if ( downloadItem.isDoneDownloading() ) { - // Prevent race condition when multiple parts finish downloading at the same time by using a queue - self.progressStatusQueue.addOperation { - // Remove the download item after the operation completes - defer { Database.shared.removeDownloadItem(downloadItem) } - // Fetch the latest download item, so we know if it was removed in another thread - let downloadItem = Database.shared.getDownloadItem(downloadItemId: downloadItem.id!) - // We already processed this download item on another thread, skip it - guard let downloadItem = downloadItem else { return } - self.handleDownloadTaskCompleteFromDownloadItem(downloadItem) - } + self.progressStatusQueue.async(flags: .barrier) { + self.downloadItemProgress.updateValue(downloadItem, forKey: downloadItem.id!) + self.notifyDownloadProgress() } } catch { NSLog("DownloadItemError") @@ -109,6 +97,54 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } } + // We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates + private func notifyDownloadProgress() { + // Return if the loop is running + guard self.progressStatusWorkItem == nil else { return } + + // Create a background thread to send the download status + self.progressStatusWorkItem = DispatchWorkItem { [weak self] in + // Clean up the work item when done + defer { self?.progressStatusWorkItem = nil } + var downloadItemProgress = [String: DownloadItem]() + + // Get the latest progress + self?.progressStatusQueue.sync { + if let progressItems = self?.downloadItemProgress { + downloadItemProgress = progressItems + } + } + + while !downloadItemProgress.isEmpty { + for item in downloadItemProgress.values { + try? self?.notifyListeners("onItemDownloadUpdate", data: item.asDictionary()) + + // Clean up a completed download + if item.isDoneDownloading() { + self?.progressStatusQueue.async(flags: .barrier) { + self?.downloadItemProgress.removeValue(forKey: item.id!) + } + defer { Database.shared.removeDownloadItem(item) } + self?.handleDownloadTaskCompleteFromDownloadItem(item) + } + } + + // Wait 200ms before reporting status again + Thread.sleep(forTimeInterval: TimeInterval(0.2)) + + // Get the latest progress + self?.progressStatusQueue.sync { + if let progressItems = self?.downloadItemProgress { + downloadItemProgress = progressItems + } + } + } + } + + // Start the thread + DispatchQueue.global(qos: .userInteractive).async(execute: self.progressStatusWorkItem!) + } + private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) { if ( downloadItem.didDownloadSuccessfully() ) { ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift index b7977edc..5fa8e33f 100644 --- a/ios/App/Shared/models/DownloadItem.swift +++ b/ios/App/Shared/models/DownloadItem.swift @@ -30,7 +30,7 @@ struct DownloadItem: Realmable, Codable { } private enum CodingKeys : String, CodingKey { - case id, libraryItemId, episodeId, userMediaProgress, serverConnectionConfigId, serverAddress, serverUserId, mediaType, itemTitle, downloadItemParts + case id, libraryItemId, episodeId, serverConnectionConfigId, serverAddress, serverUserId, mediaType, itemTitle, downloadItemParts } } From 8e2be4704e613bc5da260c607733ba6311d05959 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Mon, 8 Aug 2022 17:21:13 -0400 Subject: [PATCH 037/103] Clean up progress logic --- ios/App/App/plugins/AbsDownloader.swift | 42 +++++++++++-------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 22118889..06af972e 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -106,38 +106,34 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { self.progressStatusWorkItem = DispatchWorkItem { [weak self] in // Clean up the work item when done defer { self?.progressStatusWorkItem = nil } - var downloadItemProgress = [String: DownloadItem]() - // Get the latest progress - self?.progressStatusQueue.sync { - if let progressItems = self?.downloadItemProgress { - downloadItemProgress = progressItems + // Fetch active downloads in a thread-safe way + func fetchActiveDownloads() -> [String: DownloadItem]? { + self?.progressStatusQueue.sync { self?.downloadItemProgress } + } + + // Remove a completed download item in a thread-safe way + func handleDoneDownloadItem(_ item: DownloadItem) { + self?.progressStatusQueue.async(flags: .barrier) { + defer { + Database.shared.removeDownloadItem(item) + self?.downloadItemProgress.removeValue(forKey: item.id!) + } + self?.handleDownloadTaskCompleteFromDownloadItem(item) } } - while !downloadItemProgress.isEmpty { - for item in downloadItemProgress.values { - try? self?.notifyListeners("onItemDownloadUpdate", data: item.asDictionary()) - - // Clean up a completed download - if item.isDoneDownloading() { - self?.progressStatusQueue.async(flags: .barrier) { - self?.downloadItemProgress.removeValue(forKey: item.id!) - } - defer { Database.shared.removeDownloadItem(item) } - self?.handleDownloadTaskCompleteFromDownloadItem(item) + // While there are active download items, emit status updates + while !(fetchActiveDownloads()?.isEmpty ?? false) { + if let activeDownloads = fetchActiveDownloads() { + for item in activeDownloads.values { + try? self?.notifyListeners("onItemDownloadUpdate", data: item.asDictionary()) + if item.isDoneDownloading() { handleDoneDownloadItem(item) } } } // Wait 200ms before reporting status again Thread.sleep(forTimeInterval: TimeInterval(0.2)) - - // Get the latest progress - self?.progressStatusQueue.sync { - if let progressItems = self?.downloadItemProgress { - downloadItemProgress = progressItems - } - } } } From e9961f64a9844552fc6bc901eb3407dbf4529ed8 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Mon, 8 Aug 2022 19:25:59 -0400 Subject: [PATCH 038/103] Handle a documents directory that can change Thanks iOS --- ios/App/App/plugins/AbsDownloader.swift | 41 ++++--- ios/App/Shared/models/DownloadItem.swift | 34 +++--- ios/App/Shared/models/LocalLibrary.swift | 101 +++++++++++++++++- .../models/LocalLibraryExtensions.swift | 14 ++- 4 files changed, 137 insertions(+), 53 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 06af972e..11248ad8 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -11,9 +11,10 @@ import Capacitor @objc(AbsDownloader) public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { + static let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadItemPart: inout DownloadItemPart) throws -> Void - private let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] private lazy var session: URLSession = { let queue = OperationQueue() queue.maxConcurrentOperationCount = 5 @@ -30,7 +31,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { do { // Move the downloaded file into place - guard let destinationUrl = downloadItemPart.destinationURL() else { + guard let destinationUrl = downloadItemPart.destinationURL else { throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined } try? FileManager.default.removeItem(at: destinationUrl) @@ -142,31 +143,25 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) { + var statusNotification = [String: Any]() + statusNotification["libraryItemId"] = downloadItem.libraryItemId + if ( downloadItem.didDownloadSuccessfully() ) { ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in - var statusNotification = [String: Any]() - statusNotification["libraryItemId"] = libraryItem?.id - guard let libraryItem = libraryItem else { NSLog("LibraryItem not found"); return } - let localDirectory = self.documentsDirectory.appendingPathComponent("\(libraryItem.id)") - var coverFile: URL? + let localDirectory = libraryItem.id + var coverFile: String? // Assemble the local library item let files = downloadItem.downloadItemParts.compactMap { part -> LocalFile? in if part.filename == "cover.jpg" { - coverFile = part.destinationURL() + coverFile = part.destinationUri return nil } else { - return LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationURL()!) + return LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize)) } } - var localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files) - - // Store the cover file - if let coverFile = coverFile { - localLibraryItem.coverContentUrl = coverFile.absoluteString - localLibraryItem.coverAbsolutePath = coverFile.path - } + let localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile) Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary() @@ -180,6 +175,8 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { self.notifyListeners("onItemDownloadComplete", data: statusNotification) } + } else { + self.notifyListeners("onItemDownloadComplete", data: statusNotification) } } @@ -270,7 +267,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let serverUrl = urlForTrack(item: item, track: track) let itemDirectory = try createLibraryItemFileDirectory(item: item) - let localUrl = itemDirectory.appendingPathComponent("\(filename)") + let localUrl = "\(itemDirectory)/\(filename)" let task = session.downloadTask(with: serverUrl) var downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: nil) @@ -286,10 +283,10 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let filename = "cover.jpg" let serverPath = "/api/items/\(item.id)/cover" let itemDirectory = try createLibraryItemFileDirectory(item: item) - let localUrl = itemDirectory.appendingPathComponent("\(filename)") + let localUrl = "\(itemDirectory)/\(filename)" var downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil) - let task = session.downloadTask(with: downloadItemPart.downloadURL()!) + let task = session.downloadTask(with: downloadItemPart.downloadURL!) // Store the id on the task so the download item can be pulled from the database later task.taskDescription = downloadItemPart.id @@ -305,12 +302,12 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { return URL(string: urlstr)! } - private func createLibraryItemFileDirectory(item: LibraryItem) throws -> URL { - let itemDirectory = documentsDirectory.appendingPathComponent("\(item.id)") + private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String { + let itemDirectory = item.id NSLog("ITEM DIR \(itemDirectory)") do { - try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: AbsDownloader.downloadsDirectory.appendingPathComponent(itemDirectory), withIntermediateDirectories: true) } catch { NSLog("Failed to CREATE LI DIRECTORY \(error)") throw LibraryItemDownloadError.failedDirectory diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift index 5fa8e33f..a9ce6c6e 100644 --- a/ios/App/Shared/models/DownloadItem.swift +++ b/ios/App/Shared/models/DownloadItem.swift @@ -72,7 +72,21 @@ struct DownloadItemPart: Realmable, Codable { var moved: Bool = false var failed: Bool = false var uri: String? + var downloadURL: URL? { + if let uri = self.uri { + return URL(string: uri) + } else { + return nil + } + } var destinationUri: String? + var destinationURL: URL? { + if let destinationUri = self.destinationUri { + return AbsDownloader.downloadsDirectory.appendingPathComponent(destinationUri) + } else { + return nil + } + } var progress: Double = 0 var task: URLSessionDownloadTask! @@ -90,7 +104,7 @@ struct DownloadItemPart: Realmable, Codable { } extension DownloadItemPart { - init(filename: String, destination: URL, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) { + init(filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) { self.filename = filename self.itemTitle = itemTitle self.serverPath = serverPath @@ -103,26 +117,10 @@ extension DownloadItemPart { downloadUrl += "&format=jpeg" // For cover images force to jpeg } self.uri = downloadUrl - self.destinationUri = destination.path + self.destinationUri = destination } func mimeType() -> String? { audioTrack?.mimeType ?? episode?.audioTrack?.mimeType } - - func downloadURL() -> URL? { - if let uri = self.uri { - return URL(string: uri) - } else { - return nil - } - } - - func destinationURL() -> URL? { - if let destinationUri = self.destinationUri { - return URL(fileURLWithPath: destinationUri) - } else { - return nil - } - } } diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 5f36ff99..c2e5841e 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -11,23 +11,87 @@ import Unrealm struct LocalLibraryItem: Realmable, Codable { var id: String = "local_\(UUID().uuidString)" var basePath: String = "" - var absolutePath: String = "" - var contentUrl: String = "" + dynamic var _contentUrl: String? var isInvalid: Bool = false var mediaType: String = "" var media: MediaType? var localFiles: [LocalFile] = [] - var coverContentUrl: String? - var coverAbsolutePath: String? + dynamic var _coverContentUrl: String? var isLocal: Bool = true var serverConnectionConfigId: String? var serverAddress: String? var serverUserId: String? var libraryItemId: String? + var contentUrl: String? { + set(url) { + _contentUrl = url + } + get { + if let path = _contentUrl { + return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString + } else { + return nil + } + } + } + + var coverContentUrl: String? { + set(url) { + _coverContentUrl = url + } + get { + if let path = self._coverContentUrl { + return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString + } else { + return nil + } + } + } + static func primaryKey() -> String? { return "id" } + + private enum CodingKeys : String, CodingKey { + case id, basePath, contentUrl, isInvalid, mediaType, media, localFiles, coverContentUrl, isLocal, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId + } + + init() {} + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + basePath = try values.decode(String.self, forKey: .basePath) + contentUrl = try values.decode(String.self, forKey: .contentUrl) + isInvalid = try values.decode(Bool.self, forKey: .isInvalid) + mediaType = try values.decode(String.self, forKey: .mediaType) + media = try values.decode(MediaType.self, forKey: .media) + localFiles = try values.decode([LocalFile].self, forKey: .localFiles) + coverContentUrl = try values.decode(String.self, forKey: .coverContentUrl) + isLocal = try values.decode(Bool.self, forKey: .isLocal) + serverConnectionConfigId = try values.decode(String.self, forKey: .serverConnectionConfigId) + serverAddress = try values.decode(String.self, forKey: .serverAddress) + serverUserId = try values.decode(String.self, forKey: .serverUserId) + libraryItemId = try values.decode(String.self, forKey: .libraryItemId) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(basePath, forKey: .basePath) + try container.encode(contentUrl, forKey: .contentUrl) + try container.encode(isInvalid, forKey: .isInvalid) + try container.encode(mediaType, forKey: .mediaType) + try container.encode(media, forKey: .media) + try container.encode(localFiles, forKey: .localFiles) + try container.encode(coverContentUrl, forKey: .coverContentUrl) + try container.encode(isLocal, forKey: .isLocal) + try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId) + try container.encode(serverAddress, forKey: .serverAddress) + try container.encode(serverUserId, forKey: .serverUserId) + try container.encode(libraryItemId, forKey: .libraryItemId) + } } struct LocalPodcastEpisode: Realmable, Codable { @@ -53,13 +117,40 @@ struct LocalFile: Realmable, Codable { var id: String = UUID().uuidString var filename: String? var contentUrl: String = "" - var absolutePath: String = "" + var absolutePath: String { + return AbsDownloader.downloadsDirectory.appendingPathComponent(self.contentUrl).absoluteString + } var mimeType: String? var size: Int = 0 static func primaryKey() -> String? { return "id" } + + private enum CodingKeys : String, CodingKey { + case id, filename, contentUrl, absolutePath, mimeType, size + } + + init() {} + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + filename = try values.decode(String.self, forKey: .filename) + contentUrl = try values.decode(String.self, forKey: .contentUrl) + mimeType = try values.decode(String.self, forKey: .mimeType) + size = try values.decode(Int.self, forKey: .size) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(filename, forKey: .filename) + try container.encode(contentUrl, forKey: .contentUrl) + try container.encode(absolutePath, forKey: .absolutePath) + try container.encode(mimeType, forKey: .mimeType) + try container.encode(size, forKey: .size) + } } struct LocalMediaProgress: Realmable, Codable { diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index 93ca000b..1111498e 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -8,14 +8,13 @@ import Foundation extension LocalLibraryItem { - init(_ item: LibraryItem, localUrl: URL, server: ServerConnectionConfig, files: [LocalFile]) { + init(_ item: LibraryItem, localUrl: String, server: ServerConnectionConfig, files: [LocalFile], coverPath: String?) { self.init() - self.contentUrl = localUrl.absoluteString + self.contentUrl = localUrl self.mediaType = item.mediaType self.media = item.media self.localFiles = files - // TODO: self.coverContentURL - // TODO: self.converAbsolutePath + self.coverContentUrl = coverPath self.libraryItemId = item.id self.serverConnectionConfigId = server.id self.serverAddress = server.address @@ -71,14 +70,13 @@ extension LocalLibraryItem { } extension LocalFile { - init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: URL) { + init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: String, fileSize: Int) { self.init() self.id = "\(libraryItemId)_\(filename.toBase64())" self.filename = filename self.mimeType = mimeType - self.contentUrl = localUrl.absoluteString - self.absolutePath = localUrl.path - self.size = Int(localUrl.fileSize) + self.contentUrl = localUrl + self.size = fileSize } func isAudioFile() -> Bool { From e275aa16997ec656bab57e315d4f1770e710f989 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Mon, 8 Aug 2022 20:05:09 -0400 Subject: [PATCH 039/103] More improvements to progress status --- ios/App/App/plugins/AbsDownloader.swift | 78 ++++++++++++------------- 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 11248ad8..a6d5e6a7 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -21,8 +21,8 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { return URLSession(configuration: .default, delegate: self, delegateQueue: queue) }() private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent) - private var progressStatusWorkItem: DispatchWorkItem? private var downloadItemProgress = [String: DownloadItem]() + private var isMonitoringProgress = false public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in @@ -59,7 +59,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // Calculate the download percentage let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100 // Only update the progress if we received accurate progress data - if percentDownloaded >= 0.0 && percentDownloaded <= 1.0 { + if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 { downloadItemPart.progress = percentDownloaded } } @@ -73,6 +73,9 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // Find the download item let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId) guard var downloadItem = downloadItem else { throw LibraryItemDownloadError.downloadItemNotFound } + self.progressStatusQueue.sync { + downloadItem = self.downloadItemProgress[downloadItem.id!] ?? downloadItem + } // Find the download item part let partIndex = downloadItem.downloadItemParts.firstIndex(where: { $0.id == downloadItemPartId }) @@ -87,11 +90,10 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } // Update the progress - Database.shared.updateDownloadItemPart(downloadItem.downloadItemParts[partIndex]) self.progressStatusQueue.async(flags: .barrier) { self.downloadItemProgress.updateValue(downloadItem, forKey: downloadItem.id!) - self.notifyDownloadProgress() } + self.notifyDownloadProgress() } catch { NSLog("DownloadItemError") debugPrint(error) @@ -100,46 +102,42 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates private func notifyDownloadProgress() { - // Return if the loop is running - guard self.progressStatusWorkItem == nil else { return } - - // Create a background thread to send the download status - self.progressStatusWorkItem = DispatchWorkItem { [weak self] in - // Clean up the work item when done - defer { self?.progressStatusWorkItem = nil } - - // Fetch active downloads in a thread-safe way - func fetchActiveDownloads() -> [String: DownloadItem]? { - self?.progressStatusQueue.sync { self?.downloadItemProgress } - } - - // Remove a completed download item in a thread-safe way - func handleDoneDownloadItem(_ item: DownloadItem) { - self?.progressStatusQueue.async(flags: .barrier) { - defer { - Database.shared.removeDownloadItem(item) - self?.downloadItemProgress.removeValue(forKey: item.id!) - } - self?.handleDownloadTaskCompleteFromDownloadItem(item) - } - } - - // While there are active download items, emit status updates - while !(fetchActiveDownloads()?.isEmpty ?? false) { - if let activeDownloads = fetchActiveDownloads() { - for item in activeDownloads.values { - try? self?.notifyListeners("onItemDownloadUpdate", data: item.asDictionary()) - if item.isDoneDownloading() { handleDoneDownloadItem(item) } - } + if !self.isMonitoringProgress { + self.isMonitoringProgress = true + DispatchQueue.global(qos: .userInteractive).async { + NSLog("Starting monitoring download progress...") + + // Fetch active downloads in a thread-safe way + func fetchActiveDownloads() -> [String: DownloadItem]? { + self.progressStatusQueue.sync { self.downloadItemProgress } } - // Wait 200ms before reporting status again - Thread.sleep(forTimeInterval: TimeInterval(0.2)) + // Remove a completed download item in a thread-safe way + func handleDoneDownloadItem(_ item: DownloadItem) { + self.progressStatusQueue.async(flags: .barrier) { + self.downloadItemProgress.removeValue(forKey: item.id!) + } + Database.shared.removeDownloadItem(item) + self.handleDownloadTaskCompleteFromDownloadItem(item) + } + + // While there are active download items, emit status updates + while !(fetchActiveDownloads()?.isEmpty ?? false) { + if let activeDownloads = fetchActiveDownloads() { + for item in activeDownloads.values { + try? self.notifyListeners("onItemDownloadUpdate", data: item.asDictionary()) + if item.isDoneDownloading() { handleDoneDownloadItem(item) } + } + } + + // Wait 200ms before reporting status again + Thread.sleep(forTimeInterval: TimeInterval(0.2)) + } + + NSLog("Finished monitoring download progress...") + self.isMonitoringProgress = false } } - - // Start the thread - DispatchQueue.global(qos: .userInteractive).async(execute: self.progressStatusWorkItem!) } private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) { From a3e458fcc4dd020d35c78e66deb21c5f99752302 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Wed, 10 Aug 2022 17:08:19 -0400 Subject: [PATCH 040/103] Make server config thread-safe --- ios/App/Shared/util/Store.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ios/App/Shared/util/Store.swift b/ios/App/Shared/util/Store.swift index e3eba891..56fe7ad1 100644 --- a/ios/App/Shared/util/Store.swift +++ b/ios/App/Shared/util/Store.swift @@ -21,7 +21,8 @@ class Store { Database.shared.setLastActiveConfigIndexToNil() } - _serverConfig = updated + // Make safe for accessing on all threads + _serverConfig = updated?.freeze() } } } From a9d7fbc08370c387cf73e7fa6eee7159ddd0e385 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Wed, 10 Aug 2022 17:09:49 -0400 Subject: [PATCH 041/103] Convert objects to realm-native --- ios/App/App.xcodeproj/project.pbxproj | 4 + ios/App/App/AppDelegate.swift | 32 +- ios/App/App/plugins/AbsDatabase.swift | 15 +- ios/App/App/plugins/AbsDownloader.swift | 29 +- ios/App/Shared/models/DataClasses.swift | 703 +++++++++++++----- ios/App/Shared/models/DeviceSettings.swift | 11 +- ios/App/Shared/models/DownloadItem.swift | 175 ++--- .../models/DownloadItemExtensions.swift | 77 ++ ios/App/Shared/models/LocalLibrary.swift | 253 ++++--- .../models/LocalLibraryExtensions.swift | 44 +- .../models/ServerConnectionConfig.swift | 33 +- ios/App/Shared/util/Database.swift | 16 +- 12 files changed, 896 insertions(+), 496 deletions(-) create mode 100644 ios/App/Shared/models/DownloadItemExtensions.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 2c008a44..3234ca6a 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; }; E99C8C932883A00F00E3279A /* LocalLibraryExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */; }; E9D3815C289E0C9B0019EEED /* DownloadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D3815B289E0C9B0019EEED /* DownloadItem.swift */; }; + E9D3815E28A2F00A0019EEED /* DownloadItemExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D3815D28A2F00A0019EEED /* DownloadItemExtensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -78,6 +79,7 @@ C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = ""; }; E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibraryExtensions.swift; sourceTree = ""; }; E9D3815B289E0C9B0019EEED /* DownloadItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItem.swift; sourceTree = ""; }; + E9D3815D28A2F00A0019EEED /* DownloadItemExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItemExtensions.swift; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -144,6 +146,7 @@ C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */, E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */, E9D3815B289E0C9B0019EEED /* DownloadItem.swift */, + E9D3815D28A2F00A0019EEED /* DownloadItemExtensions.swift */, 3A90295E280968E700E1D427 /* PlaybackReport.swift */, 4DF74911287105C600AC7814 /* DeviceSettings.swift */, ); @@ -321,6 +324,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E9D3815E28A2F00A0019EEED /* DownloadItemExtensions.swift in Sources */, 3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, 3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */, diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index e3225579..105aefab 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -15,7 +15,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { migrationBlock: { migration, oldSchemaVersion in if (oldSchemaVersion < 1) { NSLog("Realm schema version was \(oldSchemaVersion)") - migration.enumerateObjects(ofType: DeviceSettings.rlmClassName()) { oldObject, newObject in + migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in newObject?["enableAltView"] = false } } @@ -23,36 +23,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) Realm.Configuration.defaultConfiguration = configuration - Realm.registerRealmables(DeviceSettings.self) - Realm.registerRealmables(ServerConnectionConfig.self) - Realm.registerRealmables(ServerConnectionConfigActiveIndex.self) - - // Data classes - Realm.registerRealmables(LibraryItem.self) - Realm.registerRealmables(MediaType.self) - Realm.registerRealmables(Metadata.self) - Realm.registerRealmables(PodcastEpisode.self) - Realm.registerRealmables(AudioFile.self) - Realm.registerRealmables(Author.self) - Realm.registerRealmables(Chapter.self) - Realm.registerRealmables(AudioTrack.self) - Realm.registerRealmables(FileMetadata.self) - Realm.registerRealmables(Library.self) - Realm.registerRealmables(Folder.self) - Realm.registerRealmables(LibraryFile.self) - Realm.registerRealmables(MediaProgress.self) - Realm.registerRealmables(PlaybackMetadata.self) - - // Local library - Realm.registerRealmables(LocalLibraryItem.self) - Realm.registerRealmables(LocalPodcastEpisode.self) - Realm.registerRealmables(LocalFile.self) - Realm.registerRealmables(LocalMediaProgress.self) - - // Download item - Realm.registerRealmables(DownloadItem.self) - Realm.registerRealmables(DownloadItemPart.self) - return true } diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index fdc3bab6..cc940804 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -40,7 +40,14 @@ public class AbsDatabase: CAPPlugin { id = "\(address)@\(username)".toBase64() } - let config = ServerConnectionConfig(id: id!, index: 1, name: name, address: address, userId: userId, username: username, token: token) + let config = ServerConnectionConfig() + config.id = id ?? "" + config.index = 1 + config.name = name + config.address = address + config.userId = userId + config.username = username + config.token = token Store.serverConfig = config call.resolve(convertServerConnectionConfigToJSON(config: config)) @@ -122,7 +129,11 @@ public class AbsDatabase: CAPPlugin { let enableAltView = call.getBool("enableAltView") ?? false let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10 let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10 - let settings = DeviceSettings(disableAutoRewind: disableAutoRewind, enableAltView: enableAltView, jumpBackwardsTime: jumpBackwardsTime, jumpForwardTime: jumpForwardTime) + let settings = DeviceSettings() + settings.disableAutoRewind = disableAutoRewind + settings.enableAltView = enableAltView + settings.jumpBackwardsTime = jumpBackwardsTime + settings.jumpForwardTime = jumpForwardTime Database.shared.setDeviceSettings(deviceSettings: settings) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index a6d5e6a7..0b085de1 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -7,6 +7,7 @@ import Foundation import Capacitor +import RealmSwift @objc(AbsDownloader) public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { @@ -151,7 +152,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { var coverFile: String? // Assemble the local library item - let files = downloadItem.downloadItemParts.compactMap { part -> LocalFile? in + let files = downloadItem.downloadItemParts.enumerated().compactMap { _, part -> LocalFile? in if part.filename == "cover.jpg" { coverFile = part.destinationUri return nil @@ -193,7 +194,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { if let episodeId = episodeId { // Download a podcast episode guard libraryItem.mediaType == "podcast" else { throw LibraryItemDownloadError.libraryItemNotPodcast } - let episode = libraryItem.media.episodes?.first(where: { $0.id == episodeId }) + let episode = libraryItem.media?.episodes.enumerated().first(where: { $1.id == episodeId })?.element guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound } try self.startLibraryItemDownload(libraryItem, episode: episode) } else { @@ -216,31 +217,31 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } private func startLibraryItemDownload(_ item: LibraryItem, episode: PodcastEpisode?) throws { - var tracks: [AudioTrack] + var tracks = List() var episodeId: String? // Handle the different media type downloads switch item.mediaType { case "book": - guard let bookTracks = item.media.tracks else { throw LibraryItemDownloadError.noTracks } - tracks = bookTracks + guard item.media?.tracks.count ?? 0 > 0 else { throw LibraryItemDownloadError.noTracks } + tracks = item.media?.tracks ?? tracks case "podcast": guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound } guard let podcastTrack = episode.audioTrack else { throw LibraryItemDownloadError.noTracks } episodeId = episode.id - tracks = [podcastTrack] + tracks.append(podcastTrack) default: throw LibraryItemDownloadError.unknownMediaType } // Queue up everything for downloading - var downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!) - downloadItem.downloadItemParts = try tracks.enumerated().map({ i, track in - try startLibraryItemTrackDownload(item: item, position: i, track: track) - }) + let downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!) + for (i, track) in tracks.enumerated() { + downloadItem.downloadItemParts.append(try startLibraryItemTrackDownload(item: item, position: i, track: track)) + } // Also download the cover - if item.media.coverPath != nil && !item.media.coverPath!.isEmpty { + if item.media?.coverPath != nil && !(item.media?.coverPath!.isEmpty ?? true) { if let coverDownload = try? startLibraryItemCoverDownload(item: item) { downloadItem.downloadItemParts.append(coverDownload) } @@ -251,7 +252,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // Start all the downloads for downloadItemPart in downloadItem.downloadItemParts { - downloadItemPart.task.resume() + downloadItemPart.task?.resume() } } @@ -268,7 +269,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let localUrl = "\(itemDirectory)/\(filename)" let task = session.downloadTask(with: serverUrl) - var downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: nil) + let downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: nil) // Store the id on the task so the download item can be pulled from the database later task.taskDescription = downloadItemPart.id @@ -283,7 +284,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let itemDirectory = try createLibraryItemFileDirectory(item: item) let localUrl = "\(itemDirectory)/\(filename)" - var downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil) + let downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil) let task = session.downloadTask(with: downloadItemPart.downloadURL!) // Store the id on the task so the download item can be pulled from the database later diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index 426d6522..ff5d1211 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -7,94 +7,173 @@ import Foundation import CoreMedia -import Unrealm +import RealmSwift -struct LibraryItem: Realmable, Codable { - var id: String - var ino: String - var libraryId: String - var folderId: String - var path: String - var relPath: String - var isFile: Bool - var mtimeMs: Int - var ctimeMs: Int - var birthtimeMs: Int - var addedAt: Int - var updatedAt: Int - var lastScan: Int? - var scanVersion: String? - var isMissing: Bool - var isInvalid: Bool - var mediaType: String - var media: MediaType - var libraryFiles: [LibraryFile] - var userMediaProgress: MediaProgress? +class LibraryItem: Object, Codable { + @Persisted var id: String = "" + @Persisted var ino: String = "" + @Persisted var libraryId: String = "" + @Persisted var folderId: String = "" + @Persisted var path: String = "" + @Persisted var relPath: String = "" + @Persisted var isFile: Bool = true + @Persisted var mtimeMs: Int = 0 + @Persisted var ctimeMs: Int = 0 + @Persisted var birthtimeMs: Int = 0 + @Persisted var addedAt: Int = 0 + @Persisted var updatedAt: Int = 0 + @Persisted var lastScan: Int? + @Persisted var scanVersion: String? + @Persisted var isMissing: Bool = false + @Persisted var isInvalid: Bool = false + @Persisted var mediaType: String = "" + @Persisted var media: MediaType? + @Persisted var libraryFiles = List() + @Persisted var userMediaProgress: MediaProgress? - init() { - id = "" - ino = "" - libraryId = "" - folderId = "" - path = "" - relPath = "" - isFile = true - mtimeMs = 0 - ctimeMs = 0 - birthtimeMs = 0 - addedAt = 0 - updatedAt = 0 - isMissing = false - isInvalid = false - mediaType = "" - media = MediaType() - libraryFiles = [] + private enum CodingKeys : String, CodingKey { + case id, ino, libraryId, folderId, path, relPath, isFile, mtimeMs, ctimeMs, birthtimeMs, addedAt, updatedAt, lastScan, scanVersion, isMissing, isInvalid, mediaType, media, libraryFiles, userMediaProgress + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + ino = try values.decode(String.self, forKey: .ino) + libraryId = try values.decode(String.self, forKey: .libraryId) + folderId = try values.decode(String.self, forKey: .folderId) + path = try values.decode(String.self, forKey: .path) + relPath = try values.decode(String.self, forKey: .relPath) + isFile = try values.decode(Bool.self, forKey: .isFile) + mtimeMs = try values.decode(Int.self, forKey: .mtimeMs) + ctimeMs = try values.decode(Int.self, forKey: .ctimeMs) + birthtimeMs = try values.decode(Int.self, forKey: .birthtimeMs) + addedAt = try values.decode(Int.self, forKey: .addedAt) + updatedAt = try values.decode(Int.self, forKey: .updatedAt) + lastScan = try? values.decode(Int.self, forKey: .lastScan) + scanVersion = try? values.decode(String.self, forKey: .scanVersion) + isMissing = try values.decode(Bool.self, forKey: .isMissing) + isInvalid = try values.decode(Bool.self, forKey: .isInvalid) + mediaType = try values.decode(String.self, forKey: .mediaType) + media = try? values.decode(MediaType.self, forKey: .media) + if let files = try? values.decode([LibraryFile].self, forKey: .libraryFiles) { + libraryFiles.append(objectsIn: files) + } + userMediaProgress = try? values.decode(MediaProgress.self, forKey: .userMediaProgress) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(ino, forKey: .ino) + try container.encode(libraryId, forKey: .libraryId) + try container.encode(folderId, forKey: .folderId) + try container.encode(path, forKey: .path) + try container.encode(relPath, forKey: .relPath) + try container.encode(isFile, forKey: .isFile) + try container.encode(mtimeMs, forKey: .mtimeMs) + try container.encode(ctimeMs, forKey: .ctimeMs) + try container.encode(birthtimeMs, forKey: .birthtimeMs) + try container.encode(addedAt, forKey: .addedAt) + try container.encode(updatedAt, forKey: .updatedAt) + try container.encode(lastScan, forKey: .lastScan) + try container.encode(scanVersion, forKey: .scanVersion) + try container.encode(isMissing, forKey: .isMissing) + try container.encode(isInvalid, forKey: .isInvalid) + try container.encode(mediaType, forKey: .mediaType) + try container.encode(media, forKey: .media) + try container.encode(Array(libraryFiles), forKey: .libraryFiles) + try container.encode(userMediaProgress, forKey: .userMediaProgress) } } -struct MediaType: Realmable, Codable { - var libraryItemId: String? - var metadata: Metadata - var coverPath: String? - var tags: [String]? - var audioFiles: [AudioFile]? - var chapters: [Chapter]? - var tracks: [AudioTrack]? - var size: Int? - var duration: Double? - var episodes: [PodcastEpisode]? - var autoDownloadEpisodes: Bool? +class MediaType: Object, Codable { + @Persisted var libraryItemId: String? + @Persisted var metadata: Metadata? + @Persisted var coverPath: String? + @Persisted var tags = List() + @Persisted var audioFiles = List() + @Persisted var chapters = List() + @Persisted var tracks = List() + @Persisted var size: Int? + @Persisted var duration: Double? + @Persisted var episodes = List() + @Persisted var autoDownloadEpisodes: Bool? - init() { - metadata = Metadata() + private enum CodingKeys : String, CodingKey { + case libraryItemId, metadata, coverPath, tags, audioFiles, chapters, tracks, size, duration, episodes, autoDownloadEpisodes + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + libraryItemId = try? values.decode(String.self, forKey: .libraryItemId) + metadata = try? values.decode(Metadata.self, forKey: .metadata) + coverPath = try? values.decode(String.self, forKey: .coverPath) + if let tagList = try? values.decode([String].self, forKey: .tags) { + tags.append(objectsIn: tagList) + } + if let fileList = try? values.decode([AudioFile].self, forKey: .audioFiles) { + audioFiles.append(objectsIn: fileList) + } + if let chapterList = try? values.decode([Chapter].self, forKey: .chapters) { + chapters.append(objectsIn: chapterList) + } + if let trackList = try? values.decode([AudioTrack].self, forKey: .tracks) { + tracks.append(objectsIn: trackList) + } + size = try? values.decode(Int.self, forKey: .size) + duration = try? values.decode(Double.self, forKey: .duration) + if let episodeList = try? values.decode([PodcastEpisode].self, forKey: .episodes) { + episodes.append(objectsIn: episodeList) + } + autoDownloadEpisodes = try? values.decode(Bool.self, forKey: .autoDownloadEpisodes) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(libraryItemId, forKey: .libraryItemId) + try container.encode(metadata, forKey: .metadata) + try container.encode(coverPath, forKey: .coverPath) + try container.encode(Array(tags), forKey: .tags) + try container.encode(Array(audioFiles), forKey: .audioFiles) + try container.encode(Array(chapters), forKey: .chapters) + try container.encode(Array(tracks), forKey: .tracks) + try container.encode(size, forKey: .size) + try container.encode(duration, forKey: .duration) + try container.encode(Array(episodes), forKey: .episodes) + try container.encode(autoDownloadEpisodes, forKey: .autoDownloadEpisodes) } } -struct Metadata: Realmable, Codable { - var title: String - var subtitle: String? - var authors: [Author]? - var narrators: [String]? - var genres: [String] - var publishedYear: String? - var publishedDate: String? - var publisher: String? - var desc: String? - var isbn: String? - var asin: String? - var language: String? - var explicit: Bool - var authorName: String? - var authorNameLF: String? - var narratorName: String? - var seriesName: String? - var feedUrl: String? - - init() { - title = "Unknown" - genres = [] - explicit = false - } +class Metadata: Object, Codable { + @Persisted var title: String = "Unknown" + @Persisted var subtitle: String? + @Persisted var authors = List() + @Persisted var narrators = List() + @Persisted var genres = List() + @Persisted var publishedYear: String? + @Persisted var publishedDate: String? + @Persisted var publisher: String? + @Persisted var desc: String? + @Persisted var isbn: String? + @Persisted var asin: String? + @Persisted var language: String? + @Persisted var explicit: Bool = false + @Persisted var authorName: String? + @Persisted var authorNameLF: String? + @Persisted var narratorName: String? + @Persisted var seriesName: String? + @Persisted var feedUrl: String? private enum CodingKeys : String, CodingKey { case title, @@ -116,30 +195,77 @@ struct Metadata: Realmable, Codable { seriesName, feedUrl } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + title = try values.decode(String.self, forKey: .title) + subtitle = try? values.decode(String.self, forKey: .subtitle) + if let authorList = try? values.decode([Author].self, forKey: .authors) { + authors.append(objectsIn: authorList) + } + if let narratorList = try? values.decode([String].self, forKey: .narrators) { + narrators.append(objectsIn: narratorList) + } + if let genreList = try? values.decode([String].self, forKey: .genres) { + genres.append(objectsIn: genreList) + } + publishedYear = try? values.decode(String.self, forKey: .publishedYear) + publishedDate = try? values.decode(String.self, forKey: .publishedDate) + publisher = try? values.decode(String.self, forKey: .publisher) + desc = try? values.decode(String.self, forKey: .desc) + isbn = try? values.decode(String.self, forKey: .isbn) + asin = try? values.decode(String.self, forKey: .asin) + language = try? values.decode(String.self, forKey: .language) + explicit = try values.decode(Bool.self, forKey: .explicit) + authorName = try? values.decode(String.self, forKey: .authorName) + authorNameLF = try? values.decode(String.self, forKey: .authorNameLF) + narratorName = try? values.decode(String.self, forKey: .narratorName) + seriesName = try? values.decode(String.self, forKey: .seriesName) + feedUrl = try? values.decode(String.self, forKey: .feedUrl) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(title, forKey: .title) + try container.encode(subtitle, forKey: .subtitle) + try container.encode(Array(authors), forKey: .authors) + try container.encode(Array(narrators), forKey: .narrators) + try container.encode(Array(genres), forKey: .genres) + try container.encode(publishedYear, forKey: .publishedYear) + try container.encode(publishedDate, forKey: .publishedDate) + try container.encode(publisher, forKey: .publisher) + try container.encode(desc, forKey: .desc) + try container.encode(isbn, forKey: .isbn) + try container.encode(asin, forKey: .asin) + try container.encode(language, forKey: .language) + try container.encode(explicit, forKey: .explicit) + try container.encode(authorName, forKey: .authorName) + try container.encode(authorNameLF, forKey: .authorNameLF) + try container.encode(narratorName, forKey: .narratorName) + try container.encode(seriesName, forKey: .seriesName) + try container.encode(feedUrl, forKey: .feedUrl) + } } -struct PodcastEpisode: Realmable, Codable { - var id: String - var index: Int - var episode: String? - var episodeType: String? - var title: String - var subtitle: String? - var desc: String? - var audioFile: AudioFile? - var audioTrack: AudioTrack? - var duration: Double - var size: Int +class PodcastEpisode: Object, Codable { + @Persisted var id: String = "" + @Persisted var index: Int = 0 + @Persisted var episode: String? + @Persisted var episodeType: String? + @Persisted var title: String = "Unknown" + @Persisted var subtitle: String? + @Persisted var desc: String? + @Persisted var audioFile: AudioFile? + @Persisted var audioTrack: AudioTrack? + @Persisted var duration: Double = 0 + @Persisted var size: Int = 0 // var serverEpisodeId: String? - init() { - id = "" - index = 0 - title = "Unknown" - duration = 0 - size = 0 - } - private enum CodingKeys : String, CodingKey { case id, index, @@ -153,151 +279,322 @@ struct PodcastEpisode: Realmable, Codable { duration, size } + + // TODO: Encoding } -struct AudioFile: Realmable, Codable { - var index: Int - var ino: String - var metadata: FileMetadata +class AudioFile: Object, Codable { + @Persisted var index: Int = 0 + @Persisted var ino: String = "" + @Persisted var metadata: FileMetadata? - init() { - index = 0 - ino = "" - metadata = FileMetadata() + private enum CodingKeys : String, CodingKey { + case index, ino, metadata + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + index = try values.decode(Int.self, forKey: .index) + ino = try values.decode(String.self, forKey: .ino) + metadata = try? values.decode(FileMetadata.self, forKey: .metadata) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(index, forKey: .index) + try container.encode(ino, forKey: .ino) + try container.encode(metadata, forKey: .metadata) } } -struct Author: Realmable, Codable { - var id: String - var name: String - var coverPath: String? +class Author: Object, Codable { + @Persisted var id: String = "" + @Persisted var name: String = "Unknown" + @Persisted var coverPath: String? - init() { - id = "" - name = "Unknown" + private enum CodingKeys : String, CodingKey { + case id, name, coverPath + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + name = try values.decode(String.self, forKey: .name) + coverPath = try? values.decode(String.self, forKey: .coverPath) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(coverPath, forKey: .coverPath) } } -struct Chapter: Realmable, Codable { - var id: Int - var start: Double - var end: Double - var title: String? +class Chapter: Object, Codable { + @Persisted var id: Int = 0 + @Persisted var start: Double = 0 + @Persisted var end: Double = 0 + @Persisted var title: String? - init() { - id = 0 - start = 0 - end = 0 + private enum CodingKeys : String, CodingKey { + case id, start, end, title + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(Int.self, forKey: .id) + start = try values.decode(Double.self, forKey: .start) + end = try values.decode(Double.self, forKey: .end) + title = try? values.decode(String.self, forKey: .title) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(start, forKey: .start) + try container.encode(end, forKey: .end) + try container.encode(title, forKey: .title) } } -struct AudioTrack: Realmable, Codable { - var index: Int? - var startOffset: Double? - var duration: Double - var title: String? - var contentUrl: String? - var mimeType: String - var metadata: FileMetadata? +class AudioTrack: Object, Codable { + @Persisted var index: Int? + @Persisted var startOffset: Double? + @Persisted var duration: Double = 0 + @Persisted var title: String? + @Persisted var contentUrl: String? + @Persisted var mimeType: String = "" + @Persisted var metadata: FileMetadata? // var isLocal: Bool // var localFileId: String? // var audioProbeResult: AudioProbeResult? Needed for local playback - var serverIndex: Int? + @Persisted var serverIndex: Int? - init() { - duration = 0 - mimeType = "" + private enum CodingKeys : String, CodingKey { + case index, startOffset, duration, title, contentUrl, mimeType, metadata, serverIndex + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + index = try? values.decode(Int.self, forKey: .index) + startOffset = try? values.decode(Double.self, forKey: .startOffset) + duration = try values.decode(Double.self, forKey: .duration) + title = try? values.decode(String.self, forKey: .title) + contentUrl = try? values.decode(String.self, forKey: .contentUrl) + mimeType = try values.decode(String.self, forKey: .mimeType) + metadata = try? values.decode(FileMetadata.self, forKey: .metadata) + serverIndex = try? values.decode(Int.self, forKey: .serverIndex) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(index, forKey: .index) + try container.encode(startOffset, forKey: .startOffset) + try container.encode(duration, forKey: .duration) + try container.encode(title, forKey: .title) + try container.encode(contentUrl, forKey: .contentUrl) + try container.encode(mimeType, forKey: .mimeType) + try container.encode(metadata, forKey: .metadata) + try container.encode(serverIndex, forKey: .serverIndex) } } -struct FileMetadata: Realmable, Codable { - var filename: String - var ext: String - var path: String - var relPath: String +class FileMetadata: Object, Codable { + @Persisted var filename: String = "" + @Persisted var ext: String = "" + @Persisted var path: String = "" + @Persisted var relPath: String = "" - init() { - filename = "" - ext = "" - path = "" - relPath = "" + private enum CodingKeys : String, CodingKey { + case filename, ext, path, relPath + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + filename = try values.decode(String.self, forKey: .filename) + ext = try values.decode(String.self, forKey: .ext) + path = try values.decode(String.self, forKey: .path) + relPath = try values.decode(String.self, forKey: .relPath) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(filename, forKey: .filename) + try container.encode(ext, forKey: .ext) + try container.encode(path, forKey: .path) + try container.encode(relPath, forKey: .relPath) } } -struct Library: Realmable, Codable { - var id: String - var name: String - var folders: [Folder] - var icon: String - var mediaType: String +class Library: Object, Codable { + @Persisted var id: String = "" + @Persisted var name: String = "Unknown" + @Persisted var folders = List() + @Persisted var icon: String = "" + @Persisted var mediaType: String = "" - init() { - id = "" - name = "Unknown" - folders = [] - icon = "" - mediaType = "" + private enum CodingKeys : String, CodingKey { + case id, name, folders, icon, mediaType + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + name = try values.decode(String.self, forKey: .name) + if let folderList = try? values.decode([Folder].self, forKey: .folders) { + folders.append(objectsIn: folderList) + } + icon = try values.decode(String.self, forKey: .icon) + mediaType = try values.decode(String.self, forKey: .mediaType) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(folders, forKey: .folders) + try container.encode(icon, forKey: .icon) + try container.encode(mediaType, forKey: .mediaType) } } -struct Folder: Realmable, Codable { - var id: String - var fullPath: String +class Folder: Object, Codable { + @Persisted var id: String = "" + @Persisted var fullPath: String = "" - init() { - id = "" - fullPath = "" + private enum CodingKeys : String, CodingKey { + case id, fullPath + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + fullPath = try values.decode(String.self, forKey: .fullPath) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(fullPath, forKey: .fullPath) } } -struct LibraryFile: Realmable, Codable { - var ino: String - var metadata: FileMetadata +class LibraryFile: Object, Codable { + @Persisted var ino: String = "" + @Persisted var metadata: FileMetadata? - init() { - ino = "" - metadata = FileMetadata() + private enum CodingKeys : String, CodingKey { + case ino, metadata + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + ino = try values.decode(String.self, forKey: .ino) + metadata = try values.decode(FileMetadata.self, forKey: .metadata) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(ino, forKey: .ino) + try container.encode(metadata, forKey: .metadata) } } -struct MediaProgress: Realmable, Codable { - var id: String - var libraryItemId: String - var episodeId: String? - var duration: Double - var progress: Double - var currentTime: Double - var isFinished: Bool - var lastUpdate: Int - var startedAt: Int - var finishedAt: Int? +class MediaProgress: Object, Codable { + @Persisted var id: String = "" + @Persisted var libraryItemId: String = "" + @Persisted var episodeId: String? + @Persisted var duration: Double = 0 + @Persisted var progress: Double = 0 + @Persisted var currentTime: Double = 0 + @Persisted var isFinished: Bool = false + @Persisted var lastUpdate: Int = 0 + @Persisted var startedAt: Int = 0 + @Persisted var finishedAt: Int? - init() { - id = "" - libraryItemId = "" - duration = 0 - progress = 0 - currentTime = 0 - isFinished = false - lastUpdate = 0 - startedAt = 0 + private enum CodingKeys : String, CodingKey { + case id, libraryItemId, episodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + libraryItemId = try values.decode(String.self, forKey: .libraryItemId) + episodeId = try? values.decode(String.self, forKey: .episodeId) + duration = try values.decode(Double.self, forKey: .duration) + progress = try values.decode(Double.self, forKey: .progress) + currentTime = try values.decode(Double.self, forKey: .currentTime) + isFinished = try values.decode(Bool.self, forKey: .isFinished) + lastUpdate = try values.decode(Int.self, forKey: .lastUpdate) + startedAt = try values.decode(Int.self, forKey: .startedAt) + finishedAt = try? values.decode(Int.self, forKey: .finishedAt) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(libraryItemId, forKey: .libraryItemId) + try container.encode(episodeId, forKey: .episodeId) + try container.encode(duration, forKey: .duration) + try container.encode(progress, forKey: .progress) + try container.encode(currentTime, forKey: .currentTime) + try container.encode(isFinished, forKey: .isFinished) + try container.encode(lastUpdate, forKey: .lastUpdate) + try container.encode(startedAt, forKey: .startedAt) + try container.encode(finishedAt, forKey: .finishedAt) } } -struct PlaybackMetadata: Realmable, Codable { - var duration: Double - var currentTime: Double - var playerState: PlayerState - - init() { - duration = 0 - currentTime = 0 - playerState = PlayerState.IDLE - } - - static func ignoredProperties() -> [String] { - return ["playerState"] - } +class PlaybackMetadata: Codable { + var duration: Double = 0 + var currentTime: Double = 0 + var playerState: PlayerState = PlayerState.IDLE } enum PlayerState: Codable { diff --git a/ios/App/Shared/models/DeviceSettings.swift b/ios/App/Shared/models/DeviceSettings.swift index 16e1fd54..df6a7ab1 100644 --- a/ios/App/Shared/models/DeviceSettings.swift +++ b/ios/App/Shared/models/DeviceSettings.swift @@ -7,13 +7,12 @@ import Foundation import RealmSwift -import Unrealm -struct DeviceSettings: Realmable { - var disableAutoRewind: Bool = false - var enableAltView: Bool = false - var jumpBackwardsTime: Int = 10 - var jumpForwardTime: Int = 10 +class DeviceSettings: Object { + @Persisted var disableAutoRewind: Bool = false + @Persisted var enableAltView: Bool = false + @Persisted var jumpBackwardsTime: Int = 10 + @Persisted var jumpForwardTime: Int = 10 } func getDefaultDeviceSettings() -> DeviceSettings { diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift index a9ce6c6e..9e4eb221 100644 --- a/ios/App/Shared/models/DownloadItem.swift +++ b/ios/App/Shared/models/DownloadItem.swift @@ -6,121 +6,102 @@ // import Foundation -import Unrealm +import RealmSwift -struct DownloadItem: Realmable, Codable { - var id: String? - var libraryItemId: String? - var episodeId: String? - var userMediaProgress: MediaProgress? - var serverConnectionConfigId: String? - var serverAddress: String? - var serverUserId: String? - var mediaType: String? - var itemTitle: String? - var media: MediaType? - var downloadItemParts: [DownloadItemPart] = [] - - static func primaryKey() -> String? { - return "id" - } - - static func indexedProperties() -> [String] { - ["libraryItemId"] - } +class DownloadItem: Object, Codable { + @Persisted(primaryKey: true) var id: String? + @Persisted(indexed: true) var libraryItemId: String? + @Persisted var episodeId: String? + @Persisted var userMediaProgress: MediaProgress? + @Persisted var serverConnectionConfigId: String? + @Persisted var serverAddress: String? + @Persisted var serverUserId: String? + @Persisted var mediaType: String? + @Persisted var itemTitle: String? + @Persisted var media: MediaType? + @Persisted var downloadItemParts = List() private enum CodingKeys : String, CodingKey { case id, libraryItemId, episodeId, serverConnectionConfigId, serverAddress, serverUserId, mediaType, itemTitle, downloadItemParts } -} - -extension DownloadItem { - init(libraryItem: LibraryItem, episodeId: String?, server: ServerConnectionConfig) { - self.id = libraryItem.id - self.libraryItemId = libraryItem.id - self.userMediaProgress = libraryItem.userMediaProgress - self.serverConnectionConfigId = server.id - self.serverAddress = server.address - self.serverUserId = server.userId - self.mediaType = libraryItem.mediaType - self.itemTitle = libraryItem.media.metadata.title - self.media = libraryItem.media + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() - if let episodeId = episodeId { - self.id! += "-\(episodeId)" - self.episodeId = episodeId + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try? values.decode(String.self, forKey: .id) + libraryItemId = try? values.decode(String.self, forKey: .libraryItemId) + episodeId = try? values.decode(String.self, forKey: .episodeId) + serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId) + serverAddress = try? values.decode(String.self, forKey: .serverAddress) + serverUserId = try? values.decode(String.self, forKey: .serverUserId) + mediaType = try? values.decode(String.self, forKey: .mediaType) + itemTitle = try? values.decode(String.self, forKey: .itemTitle) + if let parts = try? values.decode([DownloadItemPart].self, forKey: .downloadItemParts) { + downloadItemParts.append(objectsIn: parts) } } - func isDoneDownloading() -> Bool { - self.downloadItemParts.allSatisfy({ $0.completed }) - } - - func didDownloadSuccessfully() -> Bool { - self.downloadItemParts.allSatisfy({ $0.failed == false }) + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(libraryItemId, forKey: .libraryItemId) + try container.encode(episodeId, forKey: .episodeId) + try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId) + try container.encode(serverAddress, forKey: .serverAddress) + try container.encode(serverUserId, forKey: .serverUserId) + try container.encode(mediaType, forKey: .mediaType) + try container.encode(itemTitle, forKey: .itemTitle) + try container.encode(Array(downloadItemParts), forKey: .downloadItemParts) } } -struct DownloadItemPart: Realmable, Codable { - var id: String = UUID().uuidString - var filename: String? - var itemTitle: String? - var serverPath: String? - var audioTrack: AudioTrack? - var episode: PodcastEpisode? - var completed: Bool = false - var moved: Bool = false - var failed: Bool = false - var uri: String? - var downloadURL: URL? { - if let uri = self.uri { - return URL(string: uri) - } else { - return nil - } - } - var destinationUri: String? - var destinationURL: URL? { - if let destinationUri = self.destinationUri { - return AbsDownloader.downloadsDirectory.appendingPathComponent(destinationUri) - } else { - return nil - } - } - var progress: Double = 0 +class DownloadItemPart: Object, Codable { + @Persisted(primaryKey: true) var id: String = UUID().uuidString + @Persisted var filename: String? + @Persisted var itemTitle: String? + @Persisted var serverPath: String? + @Persisted var audioTrack: AudioTrack? + @Persisted var episode: PodcastEpisode? + @Persisted var completed: Bool = false + @Persisted var moved: Bool = false + @Persisted var failed: Bool = false + @Persisted var uri: String? + @Persisted var destinationUri: String? + @Persisted var progress: Double = 0 var task: URLSessionDownloadTask! - static func primaryKey() -> String? { - return "id" - } - - static func ignoredProperties() -> [String] { - ["task"] - } - private enum CodingKeys : String, CodingKey { case id, filename, itemTitle, completed, moved, failed, progress } -} - -extension DownloadItemPart { - init(filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) { - self.filename = filename - self.itemTitle = itemTitle - self.serverPath = serverPath - self.audioTrack = audioTrack - self.episode = episode - - let config = Store.serverConfig! - var downloadUrl = "\(config.address)\(serverPath)?token=\(config.token)" - if (serverPath.hasSuffix("/cover")) { - downloadUrl += "&format=jpeg" // For cover images force to jpeg - } - self.uri = downloadUrl - self.destinationUri = destination + + override init() { + super.init() } - func mimeType() -> String? { - audioTrack?.mimeType ?? episode?.audioTrack?.mimeType + required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + filename = try? values.decode(String.self, forKey: .filename) + itemTitle = try? values.decode(String.self, forKey: .itemTitle) + completed = try values.decode(Bool.self, forKey: .completed) + moved = try values.decode(Bool.self, forKey: .moved) + failed = try values.decode(Bool.self, forKey: .failed) + progress = try values.decode(Double.self, forKey: .progress) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(filename, forKey: .filename) + try container.encode(itemTitle, forKey: .itemTitle) + try container.encode(completed, forKey: .completed) + try container.encode(moved, forKey: .moved) + try container.encode(failed, forKey: .failed) + try container.encode(progress, forKey: .progress) } } diff --git a/ios/App/Shared/models/DownloadItemExtensions.swift b/ios/App/Shared/models/DownloadItemExtensions.swift new file mode 100644 index 00000000..d875bb72 --- /dev/null +++ b/ios/App/Shared/models/DownloadItemExtensions.swift @@ -0,0 +1,77 @@ +// +// DownloadItemExtensions.swift +// App +// +// Created by Ron Heft on 8/9/22. +// + +import Foundation + +extension DownloadItem { + convenience init(libraryItem: LibraryItem, episodeId: String?, server: ServerConnectionConfig) { + self.init() + + self.id = libraryItem.id + self.libraryItemId = libraryItem.id + self.userMediaProgress = libraryItem.userMediaProgress + self.serverConnectionConfigId = server.id + self.serverAddress = server.address + self.serverUserId = server.userId + self.mediaType = libraryItem.mediaType + self.itemTitle = libraryItem.media?.metadata?.title + self.media = libraryItem.media + + if let episodeId = episodeId { + self.id! += "-\(episodeId)" + self.episodeId = episodeId + } + } + + func isDoneDownloading() -> Bool { + self.downloadItemParts.allSatisfy({ $0.completed }) + } + + func didDownloadSuccessfully() -> Bool { + self.downloadItemParts.allSatisfy({ $0.failed == false }) + } +} + +extension DownloadItemPart { + convenience init(filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) { + self.init() + + self.filename = filename + self.itemTitle = itemTitle + self.serverPath = serverPath + self.audioTrack = audioTrack + self.episode = episode + + let config = Store.serverConfig! + var downloadUrl = "\(config.address)\(serverPath)?token=\(config.token)" + if (serverPath.hasSuffix("/cover")) { + downloadUrl += "&format=jpeg" // For cover images force to jpeg + } + self.uri = downloadUrl + self.destinationUri = destination + } + + var downloadURL: URL? { + if let uri = self.uri { + return URL(string: uri) + } else { + return nil + } + } + + var destinationURL: URL? { + if let destinationUri = self.destinationUri { + return AbsDownloader.downloadsDirectory.appendingPathComponent(destinationUri) + } else { + return nil + } + } + + func mimeType() -> String? { + audioTrack?.mimeType ?? episode?.audioTrack?.mimeType + } +} diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index c2e5841e..8ffd6a12 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -6,76 +6,52 @@ // import Foundation -import Unrealm +import RealmSwift -struct LocalLibraryItem: Realmable, Codable { - var id: String = "local_\(UUID().uuidString)" - var basePath: String = "" - dynamic var _contentUrl: String? - var isInvalid: Bool = false - var mediaType: String = "" - var media: MediaType? - var localFiles: [LocalFile] = [] - dynamic var _coverContentUrl: String? - var isLocal: Bool = true - var serverConnectionConfigId: String? - var serverAddress: String? - var serverUserId: String? - var libraryItemId: String? - - var contentUrl: String? { - set(url) { - _contentUrl = url - } - get { - if let path = _contentUrl { - return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString - } else { - return nil - } - } - } - - var coverContentUrl: String? { - set(url) { - _coverContentUrl = url - } - get { - if let path = self._coverContentUrl { - return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString - } else { - return nil - } - } - } - - static func primaryKey() -> String? { - return "id" - } +class LocalLibraryItem: Object, Codable { + @Persisted(primaryKey: true) var id: String = "local_\(UUID().uuidString)" + @Persisted var basePath: String = "" + @Persisted var _contentUrl: String? + @Persisted var isInvalid: Bool = false + @Persisted var mediaType: String = "" + @Persisted var media: MediaType? + @Persisted var localFiles = List() + @Persisted var _coverContentUrl: String? + @Persisted var isLocal: Bool = true + @Persisted var serverConnectionConfigId: String? + @Persisted var serverAddress: String? + @Persisted var serverUserId: String? + @Persisted(indexed: true) var libraryItemId: String? private enum CodingKeys : String, CodingKey { case id, basePath, contentUrl, isInvalid, mediaType, media, localFiles, coverContentUrl, isLocal, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId } - init() {} + override init() { + super.init() + } - init(from decoder: Decoder) throws { + required init(from decoder: Decoder) throws { + super.init() + let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) basePath = try values.decode(String.self, forKey: .basePath) contentUrl = try values.decode(String.self, forKey: .contentUrl) isInvalid = try values.decode(Bool.self, forKey: .isInvalid) mediaType = try values.decode(String.self, forKey: .mediaType) - media = try values.decode(MediaType.self, forKey: .media) - localFiles = try values.decode([LocalFile].self, forKey: .localFiles) - coverContentUrl = try values.decode(String.self, forKey: .coverContentUrl) + media = try? values.decode(MediaType.self, forKey: .media) + if let files = try? values.decode([LocalFile].self, forKey: .localFiles) { + localFiles.append(objectsIn: files) + } + _coverContentUrl = try values.decode(String.self, forKey: .coverContentUrl) isLocal = try values.decode(Bool.self, forKey: .isLocal) - serverConnectionConfigId = try values.decode(String.self, forKey: .serverConnectionConfigId) - serverAddress = try values.decode(String.self, forKey: .serverAddress) - serverUserId = try values.decode(String.self, forKey: .serverUserId) - libraryItemId = try values.decode(String.self, forKey: .libraryItemId) + serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId) + serverAddress = try? values.decode(String.self, forKey: .serverAddress) + serverUserId = try? values.decode(String.self, forKey: .serverUserId) + libraryItemId = try? values.decode(String.self, forKey: .libraryItemId) } - + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) @@ -84,7 +60,7 @@ struct LocalLibraryItem: Realmable, Codable { try container.encode(isInvalid, forKey: .isInvalid) try container.encode(mediaType, forKey: .mediaType) try container.encode(media, forKey: .media) - try container.encode(localFiles, forKey: .localFiles) + try container.encode(Array(localFiles), forKey: .localFiles) try container.encode(coverContentUrl, forKey: .coverContentUrl) try container.encode(isLocal, forKey: .isLocal) try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId) @@ -94,54 +70,65 @@ struct LocalLibraryItem: Realmable, Codable { } } -struct LocalPodcastEpisode: Realmable, Codable { - var id: String = UUID().uuidString - var index: Int = 0 - var episode: String? - var episodeType: String? - var title: String = "Unknown" - var subtitle: String? - var desc: String? - var audioFile: AudioFile? - var audioTrack: AudioTrack? - var duration: Double = 0 - var size: Int = 0 - var serverEpisodeId: String? +class LocalPodcastEpisode: Object, Codable { + @Persisted(primaryKey: true) var id: String = UUID().uuidString + @Persisted var index: Int = 0 + @Persisted var episode: String? + @Persisted var episodeType: String? + @Persisted var title: String = "Unknown" + @Persisted var subtitle: String? + @Persisted var desc: String? + @Persisted var audioFile: AudioFile? + @Persisted var audioTrack: AudioTrack? + @Persisted var duration: Double = 0 + @Persisted var size: Int = 0 + @Persisted(indexed: true) var serverEpisodeId: String? - static func primaryKey() -> String? { - return "id" + private enum CodingKeys : String, CodingKey { + case id + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) } } -struct LocalFile: Realmable, Codable { - var id: String = UUID().uuidString - var filename: String? - var contentUrl: String = "" - var absolutePath: String { - return AbsDownloader.downloadsDirectory.appendingPathComponent(self.contentUrl).absoluteString - } - var mimeType: String? - var size: Int = 0 - - static func primaryKey() -> String? { - return "id" - } +class LocalFile: Object, Codable { + @Persisted(primaryKey: true) var id: String = UUID().uuidString + @Persisted var filename: String? + @Persisted var contentUrl: String = "" + @Persisted var mimeType: String? + @Persisted var size: Int = 0 private enum CodingKeys : String, CodingKey { case id, filename, contentUrl, absolutePath, mimeType, size } - init() {} - - init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - id = try values.decode(String.self, forKey: .id) - filename = try values.decode(String.self, forKey: .filename) - contentUrl = try values.decode(String.self, forKey: .contentUrl) - mimeType = try values.decode(String.self, forKey: .mimeType) - size = try values.decode(Int.self, forKey: .size) + override init() { + super.init() } + required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + filename = try? values.decode(String.self, forKey: .filename) + contentUrl = try values.decode(String.self, forKey: .contentUrl) + mimeType = try? values.decode(String.self, forKey: .mimeType) + size = try values.decode(Int.self, forKey: .size) + } + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) @@ -153,25 +140,69 @@ struct LocalFile: Realmable, Codable { } } -struct LocalMediaProgress: Realmable, Codable { - var id: String = "" - var localLibraryItemId: String = "" - var localEpisodeId: String? - var duration: Double = 0 - var progress: Double = 0 - var currentTime: Double = 0 - var isFinished: Bool = false - var lastUpdate: Int = 0 - var startedAt: Int = 0 - var finishedAt: Int? +class LocalMediaProgress: Object, Codable { + @Persisted(primaryKey: true) var id: String = "" + @Persisted(indexed: true) var localLibraryItemId: String = "" + @Persisted(indexed: true) var localEpisodeId: String? + @Persisted var duration: Double = 0 + @Persisted var progress: Double = 0 + @Persisted var currentTime: Double = 0 + @Persisted var isFinished: Bool = false + @Persisted var lastUpdate: Int = 0 + @Persisted var startedAt: Int = 0 + @Persisted var finishedAt: Int? // For local lib items from server to support server sync - var serverConnectionConfigId: String? - var serverAddress: String? - var serverUserId: String? - var libraryItemId: String? - var episodeId: String? + @Persisted var serverConnectionConfigId: String? + @Persisted var serverAddress: String? + @Persisted var serverUserId: String? + @Persisted(indexed: true) var libraryItemId: String? + @Persisted(indexed: true) var episodeId: String? - static func primaryKey() -> String? { - return "id" + private enum CodingKeys : String, CodingKey { + case id, localLibraryItemId, localEpisodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId, episodeId + } + + override init() { + super.init() + } + + required init(from decoder: Decoder) throws { + super.init() + + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + localLibraryItemId = try values.decode(String.self, forKey: .localLibraryItemId) + localEpisodeId = try? values.decode(String.self, forKey: .localEpisodeId) + duration = try values.decode(Double.self, forKey: .duration) + progress = try values.decode(Double.self, forKey: .progress) + currentTime = try values.decode(Double.self, forKey: .currentTime) + isFinished = try values.decode(Bool.self, forKey: .isFinished) + lastUpdate = try values.decode(Int.self, forKey: .lastUpdate) + startedAt = try values.decode(Int.self, forKey: .startedAt) + finishedAt = try? values.decode(Int.self, forKey: .finishedAt) + serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId) + serverAddress = try? values.decode(String.self, forKey: .serverAddress) + serverUserId = try? values.decode(String.self, forKey: .serverUserId) + libraryItemId = try? values.decode(String.self, forKey: .libraryItemId) + episodeId = try? values.decode(String.self, forKey: .episodeId) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(localLibraryItemId, forKey: .localLibraryItemId) + try container.encode(localEpisodeId, forKey: .localEpisodeId) + try container.encode(duration, forKey: .duration) + try container.encode(progress, forKey: .progress) + try container.encode(currentTime, forKey: .currentTime) + try container.encode(isFinished, forKey: .isFinished) + try container.encode(lastUpdate, forKey: .lastUpdate) + try container.encode(startedAt, forKey: .startedAt) + try container.encode(finishedAt, forKey: .finishedAt) + try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId) + try container.encode(serverAddress, forKey: .serverAddress) + try container.encode(serverUserId, forKey: .serverUserId) + try container.encode(libraryItemId, forKey: .libraryItemId) + try container.encode(episodeId, forKey: .episodeId) } } diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index 1111498e..68dfdf76 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -8,12 +8,13 @@ import Foundation extension LocalLibraryItem { - init(_ item: LibraryItem, localUrl: String, server: ServerConnectionConfig, files: [LocalFile], coverPath: String?) { + convenience init(_ item: LibraryItem, localUrl: String, server: ServerConnectionConfig, files: [LocalFile], coverPath: String?) { self.init() + self.contentUrl = localUrl self.mediaType = item.mediaType self.media = item.media - self.localFiles = files + self.localFiles.append(objectsIn: files) self.coverContentUrl = coverPath self.libraryItemId = item.id self.serverConnectionConfigId = server.id @@ -21,9 +22,35 @@ extension LocalLibraryItem { self.serverUserId = server.userId } + var contentUrl: String? { + set(url) { + _contentUrl = url + } + get { + if let path = _contentUrl { + return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString + } else { + return nil + } + } + } + + var coverContentUrl: String? { + set(url) { + _coverContentUrl = url + } + get { + if let path = self._coverContentUrl { + return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString + } else { + return nil + } + } + } + func getDuration() -> Double { var total = 0.0 - self.media?.tracks?.forEach { track in total += track.duration } + self.media?.tracks.enumerated().forEach { _, track in total += track.duration } return total } @@ -70,8 +97,9 @@ extension LocalLibraryItem { } extension LocalFile { - init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: String, fileSize: Int) { + convenience init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: String, fileSize: Int) { self.init() + self.id = "\(libraryItemId)_\(filename.toBase64())" self.filename = filename self.mimeType = mimeType @@ -79,6 +107,10 @@ extension LocalFile { self.size = fileSize } + var absolutePath: String { + return AbsDownloader.downloadsDirectory.appendingPathComponent(self.contentUrl).absoluteString + } + func isAudioFile() -> Bool { switch self.mimeType { case "application/octet-stream", @@ -91,7 +123,9 @@ extension LocalFile { } extension LocalMediaProgress { - init(localLibraryItem: LocalLibraryItem, episode: LocalPodcastEpisode?, progress: MediaProgress) { + convenience init(localLibraryItem: LocalLibraryItem, episode: LocalPodcastEpisode?, progress: MediaProgress) { + self.init() + self.id = localLibraryItem.id self.localLibraryItemId = localLibraryItem.id self.libraryItemId = localLibraryItem.libraryItemId diff --git a/ios/App/Shared/models/ServerConnectionConfig.swift b/ios/App/Shared/models/ServerConnectionConfig.swift index 3f579fed..be841da1 100644 --- a/ios/App/Shared/models/ServerConnectionConfig.swift +++ b/ios/App/Shared/models/ServerConnectionConfig.swift @@ -7,33 +7,20 @@ import Foundation import RealmSwift -import Unrealm -struct ServerConnectionConfig: Realmable { - var id: String = UUID().uuidString - var index: Int = 1 - var name: String = "" - var address: String = "" - var userId: String = "" - var username: String = "" - var token: String = "" - - static func primaryKey() -> String? { - return "id" - } - - static func indexedProperties() -> [String] { - return ["index"] - } +class ServerConnectionConfig: Object { + @Persisted(primaryKey: true) var id: String = UUID().uuidString + @Persisted(indexed: true) var index: Int = 1 + @Persisted var name: String = "" + @Persisted var address: String = "" + @Persisted var userId: String = "" + @Persisted var username: String = "" + @Persisted var token: String = "" } -struct ServerConnectionConfigActiveIndex: Realmable { +class ServerConnectionConfigActiveIndex: Object { // This could overflow, but you really would have to try - var index: Int? - - static func primaryKey() -> String? { - return "index" - } + @Persisted(primaryKey: true) var index: Int? } func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary { diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 64ffc05e..52533ef4 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -16,7 +16,7 @@ class Database { private init() {} public func setServerConnectionConfig(config: ServerConnectionConfig) { - var config = config + let config = config let realm = try! Realm() let existing: ServerConnectionConfig? = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) @@ -74,9 +74,17 @@ class Database { let realm = try! Realm() do { try realm.write { - var existing = realm.objects(ServerConnectionConfigActiveIndex.self).last ?? ServerConnectionConfigActiveIndex(index: index) - existing.index = index - realm.add(existing, update: .modified) + let existing = realm.objects(ServerConnectionConfigActiveIndex.self).last + + if ( existing?.index != index ) { + if let existing = existing { + realm.delete(existing) + } + + let activeConfig = ServerConnectionConfigActiveIndex() + activeConfig.index = index + realm.add(activeConfig) + } } } catch(let exception) { NSLog("failed to save server config active index") From 446e54cb91bb6e8f46a34e9388ae7838ffc98b38 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Wed, 10 Aug 2022 22:17:12 -0400 Subject: [PATCH 042/103] Fix data model issues --- ios/App/App/AppDelegate.swift | 1 - ios/App/App/plugins/AbsDownloader.swift | 8 +- ios/App/Shared/models/DataClasses.swift | 10 ++- ios/App/Shared/models/DownloadItem.swift | 10 ++- ios/App/Shared/models/LocalLibrary.swift | 74 ++++++------------- .../models/LocalLibraryExtensions.swift | 31 ++++++-- 6 files changed, 65 insertions(+), 69 deletions(-) diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index e3225579..ee0f4cbe 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -45,7 +45,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Local library Realm.registerRealmables(LocalLibraryItem.self) - Realm.registerRealmables(LocalPodcastEpisode.self) Realm.registerRealmables(LocalFile.self) Realm.registerRealmables(LocalMediaProgress.self) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index a6d5e6a7..96a6d1cf 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -154,10 +154,8 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let files = downloadItem.downloadItemParts.compactMap { part -> LocalFile? in if part.filename == "cover.jpg" { coverFile = part.destinationUri - return nil - } else { - return LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize)) } + return LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize)) } let localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile) @@ -165,8 +163,8 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary() if let progress = libraryItem.userMediaProgress { - // TODO: Handle podcast - let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: nil, progress: progress) + let episode = downloadItem.media?.episodes?.first(where: { $0.id == downloadItem.episodeId }) + let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode, progress: progress) Database.shared.saveLocalMediaProgress(localMediaProgress) statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary() } diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index 426d6522..f61ca385 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -199,8 +199,7 @@ struct AudioTrack: Realmable, Codable { var contentUrl: String? var mimeType: String var metadata: FileMetadata? - // var isLocal: Bool - // var localFileId: String? + var localFileId: String? // var audioProbeResult: AudioProbeResult? Needed for local playback var serverIndex: Int? @@ -208,6 +207,13 @@ struct AudioTrack: Realmable, Codable { duration = 0 mimeType = "" } + + mutating func setLocalInfo(filenameIdMap: [String: String], serverIndex: Int) { + if let localFileId = filenameIdMap[self.metadata?.filename ?? ""] { + self.localFileId = localFileId + self.serverIndex = serverIndex + } + } } struct FileMetadata: Realmable, Codable { diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift index a9ce6c6e..53695220 100644 --- a/ios/App/Shared/models/DownloadItem.swift +++ b/ios/App/Shared/models/DownloadItem.swift @@ -121,6 +121,14 @@ extension DownloadItemPart { } func mimeType() -> String? { - audioTrack?.mimeType ?? episode?.audioTrack?.mimeType + if let track = audioTrack { + return track.mimeType + } else if let podcastTrack = episode?.audioTrack { + return podcastTrack.mimeType + } else if serverPath?.hasSuffix("/cover") ?? false { + return "image/jpg" + } else { + return nil + } } } diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index c2e5841e..888f95ac 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -11,12 +11,12 @@ import Unrealm struct LocalLibraryItem: Realmable, Codable { var id: String = "local_\(UUID().uuidString)" var basePath: String = "" - dynamic var _contentUrl: String? + var _contentUrl: String? var isInvalid: Bool = false var mediaType: String = "" var media: MediaType? var localFiles: [LocalFile] = [] - dynamic var _coverContentUrl: String? + var _coverContentUrl: String? var isLocal: Bool = true var serverConnectionConfigId: String? var serverAddress: String? @@ -24,28 +24,18 @@ struct LocalLibraryItem: Realmable, Codable { var libraryItemId: String? var contentUrl: String? { - set(url) { - _contentUrl = url - } - get { - if let path = _contentUrl { - return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString - } else { - return nil - } + if let path = _contentUrl { + return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString + } else { + return nil } } var coverContentUrl: String? { - set(url) { - _coverContentUrl = url - } - get { - if let path = self._coverContentUrl { - return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString - } else { - return nil - } + if let path = self._coverContentUrl { + return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString + } else { + return nil } } @@ -63,17 +53,15 @@ struct LocalLibraryItem: Realmable, Codable { let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) basePath = try values.decode(String.self, forKey: .basePath) - contentUrl = try values.decode(String.self, forKey: .contentUrl) isInvalid = try values.decode(Bool.self, forKey: .isInvalid) mediaType = try values.decode(String.self, forKey: .mediaType) media = try values.decode(MediaType.self, forKey: .media) localFiles = try values.decode([LocalFile].self, forKey: .localFiles) - coverContentUrl = try values.decode(String.self, forKey: .coverContentUrl) isLocal = try values.decode(Bool.self, forKey: .isLocal) - serverConnectionConfigId = try values.decode(String.self, forKey: .serverConnectionConfigId) - serverAddress = try values.decode(String.self, forKey: .serverAddress) - serverUserId = try values.decode(String.self, forKey: .serverUserId) - libraryItemId = try values.decode(String.self, forKey: .libraryItemId) + serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId) + serverAddress = try? values.decode(String.self, forKey: .serverAddress) + serverUserId = try? values.decode(String.self, forKey: .serverUserId) + libraryItemId = try? values.decode(String.self, forKey: .libraryItemId) } func encode(to encoder: Encoder) throws { @@ -94,41 +82,23 @@ struct LocalLibraryItem: Realmable, Codable { } } -struct LocalPodcastEpisode: Realmable, Codable { - var id: String = UUID().uuidString - var index: Int = 0 - var episode: String? - var episodeType: String? - var title: String = "Unknown" - var subtitle: String? - var desc: String? - var audioFile: AudioFile? - var audioTrack: AudioTrack? - var duration: Double = 0 - var size: Int = 0 - var serverEpisodeId: String? - - static func primaryKey() -> String? { - return "id" - } -} - struct LocalFile: Realmable, Codable { var id: String = UUID().uuidString var filename: String? - var contentUrl: String = "" - var absolutePath: String { - return AbsDownloader.downloadsDirectory.appendingPathComponent(self.contentUrl).absoluteString - } + var _contentUrl: String = "" var mimeType: String? var size: Int = 0 + var contentUrl: String { + return AbsDownloader.downloadsDirectory.appendingPathComponent(_contentUrl).absoluteString + } + static func primaryKey() -> String? { return "id" } private enum CodingKeys : String, CodingKey { - case id, filename, contentUrl, absolutePath, mimeType, size + case id, filename, contentUrl, mimeType, size } init() {} @@ -137,8 +107,7 @@ struct LocalFile: Realmable, Codable { let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) filename = try values.decode(String.self, forKey: .filename) - contentUrl = try values.decode(String.self, forKey: .contentUrl) - mimeType = try values.decode(String.self, forKey: .mimeType) + mimeType = try? values.decode(String.self, forKey: .mimeType) size = try values.decode(Int.self, forKey: .size) } @@ -147,7 +116,6 @@ struct LocalFile: Realmable, Codable { try container.encode(id, forKey: .id) try container.encode(filename, forKey: .filename) try container.encode(contentUrl, forKey: .contentUrl) - try container.encode(absolutePath, forKey: .absolutePath) try container.encode(mimeType, forKey: .mimeType) try container.encode(size, forKey: .size) } diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index 1111498e..c6b38dbc 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -10,15 +10,32 @@ import Foundation extension LocalLibraryItem { init(_ item: LibraryItem, localUrl: String, server: ServerConnectionConfig, files: [LocalFile], coverPath: String?) { self.init() - self.contentUrl = localUrl + self._contentUrl = localUrl self.mediaType = item.mediaType - self.media = item.media self.localFiles = files - self.coverContentUrl = coverPath + self._coverContentUrl = coverPath self.libraryItemId = item.id self.serverConnectionConfigId = server.id self.serverAddress = server.address self.serverUserId = server.userId + + // Link the audio tracks and files + var media = item.media + let fileIdByFilename = Dictionary(uniqueKeysWithValues: files.map { ($0.filename ?? "", $0.id) } ) + if ( item.mediaType == "book" ) { + if let tracks = media.tracks { + for i in tracks.indices { + media.tracks?[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i) + } + } + } else if ( item.mediaType == "podcast" ) { + if let episodes = media.episodes { + for i in episodes.indices { + media.episodes?[i].audioTrack?.setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: 0) + } + } + } + self.media = media } func getDuration() -> Double { @@ -27,7 +44,7 @@ extension LocalLibraryItem { return total } - func getPlaybackSession(episode: LocalPodcastEpisode?) -> PlaybackSession { + func getPlaybackSession(episode: PodcastEpisode?) -> PlaybackSession { let localEpisodeId = episode?.id let sessionId = "play_local_\(UUID().uuidString)" @@ -49,7 +66,7 @@ extension LocalLibraryItem { id: sessionId, userId: self.serverUserId, libraryItemId: self.libraryItemId, - episodeId: episode?.serverEpisodeId, + episodeId: episode?.id, mediaType: self.mediaType, chapters: [], displayTitle: mediaMetadata?.title, @@ -75,7 +92,7 @@ extension LocalFile { self.id = "\(libraryItemId)_\(filename.toBase64())" self.filename = filename self.mimeType = mimeType - self.contentUrl = localUrl + self._contentUrl = localUrl self.size = fileSize } @@ -91,7 +108,7 @@ extension LocalFile { } extension LocalMediaProgress { - init(localLibraryItem: LocalLibraryItem, episode: LocalPodcastEpisode?, progress: MediaProgress) { + init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?, progress: MediaProgress) { self.id = localLibraryItem.id self.localLibraryItemId = localLibraryItem.id self.libraryItemId = localLibraryItem.libraryItemId From 7d730dcafb0c5fdc48ccffb22bf8fba459178aaf Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Wed, 10 Aug 2022 22:56:42 -0400 Subject: [PATCH 043/103] Fix optionals --- ios/App/Shared/models/DataClasses.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index f61ca385..8cd32b2d 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -120,7 +120,7 @@ struct Metadata: Realmable, Codable { struct PodcastEpisode: Realmable, Codable { var id: String - var index: Int + var index: Int? var episode: String? var episodeType: String? var title: String @@ -128,8 +128,8 @@ struct PodcastEpisode: Realmable, Codable { var desc: String? var audioFile: AudioFile? var audioTrack: AudioTrack? - var duration: Double - var size: Int + var duration: Double? + var size: Int? // var serverEpisodeId: String? init() { @@ -156,7 +156,7 @@ struct PodcastEpisode: Realmable, Codable { } struct AudioFile: Realmable, Codable { - var index: Int + var index: Int? var ino: String var metadata: FileMetadata From 81a4e4ff7bb6d3bf89d9cff83493cd246534a838 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Wed, 10 Aug 2022 23:20:56 -0400 Subject: [PATCH 044/103] Enable podcast downloading on iOS --- components/tables/podcast/EpisodeRow.vue | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/components/tables/podcast/EpisodeRow.vue b/components/tables/podcast/EpisodeRow.vue index 0110b1fd..2c409ae2 100644 --- a/components/tables/podcast/EpisodeRow.vue +++ b/components/tables/podcast/EpisodeRow.vue @@ -26,7 +26,7 @@ -
+
audio_file {{ downloadItem ? 'downloading' : 'download' }} download_done @@ -143,7 +143,12 @@ export default { }, downloadClick() { if (this.downloadItem) return - this.download() + if (this.isIos) { + // no local folders on iOS + this.startDownload() + } else { + this.download() + } }, async download(selectedLocalFolder = null) { var localFolder = selectedLocalFolder @@ -183,7 +188,14 @@ export default { } }, async startDownload(localFolder) { - var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id, episodeId: this.episode.id }) + var payload = { + libraryItemId: this.libraryItemId, + episodeId: this.episodeId + } + if (localFolder) { + this.localFolderId = localFolder.id + } + var downloadRes = await AbsDownloader.downloadLibraryItem(payload) if (downloadRes && downloadRes.error) { var errorMsg = downloadRes.error || 'Unknown error' console.error('Download error', errorMsg) From 599a8e23cbfe61b41f52a9fc9788eb0f3adad8ae Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 11:20:17 -0400 Subject: [PATCH 045/103] Fix podcast episode downloading --- ios/App/App/plugins/AbsDownloader.swift | 6 +++--- ios/App/Shared/models/DataClasses.swift | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 96a6d1cf..d5d0581f 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -234,7 +234,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // Queue up everything for downloading var downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!) downloadItem.downloadItemParts = try tracks.enumerated().map({ i, track in - try startLibraryItemTrackDownload(item: item, position: i, track: track) + try startLibraryItemTrackDownload(item: item, position: i, track: track, episode: episode) }) // Also download the cover @@ -253,7 +253,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } } - private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) throws -> DownloadItemPart { + private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPart { NSLog("TRACK \(track.contentUrl!)") // If we don't name metadata, then we can't proceed @@ -266,7 +266,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let localUrl = "\(itemDirectory)/\(filename)" let task = session.downloadTask(with: serverUrl) - var downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: nil) + var downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode) // Store the id on the task so the download item can be pulled from the database later task.taskDescription = downloadItemPart.id diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index 8cd32b2d..70a10055 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -161,7 +161,6 @@ struct AudioFile: Realmable, Codable { var metadata: FileMetadata init() { - index = 0 ino = "" metadata = FileMetadata() } From e52a5fd585210d7ccdf2afd4eeca168d1e709c3d Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 11:20:29 -0400 Subject: [PATCH 046/103] Fix incorrect episode id --- components/tables/podcast/EpisodeRow.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/tables/podcast/EpisodeRow.vue b/components/tables/podcast/EpisodeRow.vue index 2c409ae2..35d5f3ef 100644 --- a/components/tables/podcast/EpisodeRow.vue +++ b/components/tables/podcast/EpisodeRow.vue @@ -190,7 +190,7 @@ export default { async startDownload(localFolder) { var payload = { libraryItemId: this.libraryItemId, - episodeId: this.episodeId + episodeId: this.episode.id } if (localFolder) { this.localFolderId = localFolder.id From 5f4ff380354dc3f1e51b4832ae1dc0a0cbf0e73c Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 12:30:45 -0400 Subject: [PATCH 047/103] Support downloading new podcast episodes --- ios/App/App/plugins/AbsDownloader.swift | 12 +++++-- ios/App/Shared/models/DataClasses.swift | 4 ++- ios/App/Shared/models/LocalLibrary.swift | 4 ++- .../models/LocalLibraryExtensions.swift | 35 +++++++++++++------ ios/App/Shared/util/Database.swift | 2 +- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index d5d0581f..9220f744 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -157,14 +157,19 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } return LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize)) } - let localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile) + var localLibraryItem = Database.shared.getLocalLibraryItemByLLId(libraryItem: libraryItem.id) + if (localLibraryItem != nil && localLibraryItem!.isPodcast) { + try! localLibraryItem?.addFiles(files, item: libraryItem) + } else { + localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile) + } - Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) + Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem!) statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary() if let progress = libraryItem.userMediaProgress { let episode = downloadItem.media?.episodes?.first(where: { $0.id == downloadItem.episodeId }) - let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode, progress: progress) + let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress) Database.shared.saveLocalMediaProgress(localMediaProgress) statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary() } @@ -319,6 +324,7 @@ enum LibraryItemDownloadError: String, Error { case noMetadata = "No metadata for track, unable to download" case libraryItemNotPodcast = "Library item is not a podcast but episode was requested" case podcastEpisodeNotFound = "Invalid podcast episode not found" + case podcastOnlySupported = "Only podcasts are supported for this function" case unknownMediaType = "Unknown media type" case failedDirectory = "Failed to create directory" case failedDownload = "Failed to download item" diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index 70a10055..b525264f 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -207,11 +207,13 @@ struct AudioTrack: Realmable, Codable { mimeType = "" } - mutating func setLocalInfo(filenameIdMap: [String: String], serverIndex: Int) { + mutating func setLocalInfo(filenameIdMap: [String: String], serverIndex: Int) -> Bool { if let localFileId = filenameIdMap[self.metadata?.filename ?? ""] { self.localFileId = localFileId self.serverIndex = serverIndex + return true } + return false } } diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 888f95ac..49af3994 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -9,7 +9,7 @@ import Foundation import Unrealm struct LocalLibraryItem: Realmable, Codable { - var id: String = "local_\(UUID().uuidString)" + var id: String = "" var basePath: String = "" var _contentUrl: String? var isInvalid: Bool = false @@ -39,6 +39,8 @@ struct LocalLibraryItem: Realmable, Codable { } } + var isPodcast: Bool { self.mediaType == "podcast" } + static func primaryKey() -> String? { return "id" } diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index c6b38dbc..eb36275b 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -10,6 +10,7 @@ import Foundation extension LocalLibraryItem { init(_ item: LibraryItem, localUrl: String, server: ServerConnectionConfig, files: [LocalFile], coverPath: String?) { self.init() + self.id = "local_\(item.id)" self._contentUrl = localUrl self.mediaType = item.mediaType self.localFiles = files @@ -20,22 +21,36 @@ extension LocalLibraryItem { self.serverUserId = server.userId // Link the audio tracks and files - var media = item.media - let fileIdByFilename = Dictionary(uniqueKeysWithValues: files.map { ($0.filename ?? "", $0.id) } ) - if ( item.mediaType == "book" ) { - if let tracks = media.tracks { + linkLocalFiles(files, fromMedia: item.media) + } + + mutating func addFiles(_ files: [LocalFile], item: LibraryItem) throws { + guard self.isPodcast else { throw LibraryItemDownloadError.podcastOnlySupported } + self.localFiles.append(contentsOf: files.filter({ $0.isAudioFile() })) + linkLocalFiles(self.localFiles, fromMedia: item.media) + } + + mutating private func linkLocalFiles(_ files: [LocalFile], fromMedia: MediaType) { + var fromMedia = fromMedia + let fileMap = files.map { ($0.filename ?? "", $0.id) } + let fileIdByFilename = Dictionary(fileMap, uniquingKeysWith: { (_, last) in last }) + if ( self.mediaType == "book" ) { + if let tracks = fromMedia.tracks { for i in tracks.indices { - media.tracks?[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i) + _ = fromMedia.tracks?[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i) } } - } else if ( item.mediaType == "podcast" ) { - if let episodes = media.episodes { - for i in episodes.indices { - media.episodes?[i].audioTrack?.setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: 0) + } else if ( self.mediaType == "podcast" ) { + if let episodes = fromMedia.episodes { + fromMedia.episodes = episodes.compactMap { episode in + // Filter out episodes not downloaded + var episode = episode + let episodeIsDownloaded = episode.audioTrack?.setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: 0) ?? false + return episodeIsDownloaded ? episode : nil } } } - self.media = media + self.media = fromMedia } func getDuration() -> Double { diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 64ffc05e..849de8f8 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -168,7 +168,7 @@ class Database { public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) { let realm = try! Realm() - try! realm.write { realm.add(mediaProgress) } + try! realm.write { realm.add(mediaProgress, update: .modified) } } // For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}" From 2b07c81e546887675f023df45db5f1de75216831 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 12:32:58 -0400 Subject: [PATCH 048/103] Use book/podcast helper --- ios/App/Shared/models/LocalLibrary.swift | 1 + ios/App/Shared/models/LocalLibraryExtensions.swift | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 49af3994..71eda6d2 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -39,6 +39,7 @@ struct LocalLibraryItem: Realmable, Codable { } } + var isBook: Bool { self.mediaType == "book" } var isPodcast: Bool { self.mediaType == "podcast" } static func primaryKey() -> String? { diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index eb36275b..4e99606d 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -34,13 +34,13 @@ extension LocalLibraryItem { var fromMedia = fromMedia let fileMap = files.map { ($0.filename ?? "", $0.id) } let fileIdByFilename = Dictionary(fileMap, uniquingKeysWith: { (_, last) in last }) - if ( self.mediaType == "book" ) { + if ( self.isBook ) { if let tracks = fromMedia.tracks { for i in tracks.indices { _ = fromMedia.tracks?[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i) } } - } else if ( self.mediaType == "podcast" ) { + } else if ( self.isPodcast ) { if let episodes = fromMedia.episodes { fromMedia.episodes = episodes.compactMap { episode in // Filter out episodes not downloaded From 999d434fe909963bad7eb90507662a4608732872 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 12:58:47 -0400 Subject: [PATCH 049/103] Fix download item not clearing for podcasts --- ios/App/App/plugins/AbsDownloader.swift | 2 +- ios/App/Shared/models/DataClasses.swift | 40 +++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 9220f744..b9e86c00 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -142,7 +142,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) { var statusNotification = [String: Any]() - statusNotification["libraryItemId"] = downloadItem.libraryItemId + statusNotification["libraryItemId"] = downloadItem.id if ( downloadItem.didDownloadSuccessfully() ) { ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index b525264f..fb86b667 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -130,7 +130,7 @@ struct PodcastEpisode: Realmable, Codable { var audioTrack: AudioTrack? var duration: Double? var size: Int? -// var serverEpisodeId: String? + var serverEpisodeId: String { self.id } init() { id = "" @@ -140,6 +140,10 @@ struct PodcastEpisode: Realmable, Codable { size = 0 } + static func ignoredProperties() -> [String] { + ["serverEpisodeId"] + } + private enum CodingKeys : String, CodingKey { case id, index, @@ -151,7 +155,39 @@ struct PodcastEpisode: Realmable, Codable { audioFile, audioTrack, duration, - size + size, + serverEpisodeId + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + index = try? values.decode(Int.self, forKey: .index) + episode = try? values.decode(String.self, forKey: .episode) + episodeType = try? values.decode(String.self, forKey: .episodeType) + title = try values.decode(String.self, forKey: .title) + subtitle = try? values.decode(String.self, forKey: .subtitle) + desc = try? values.decode(String.self, forKey: .desc) + audioFile = try? values.decode(AudioFile.self, forKey: .audioFile) + audioTrack = try? values.decode(AudioTrack.self, forKey: .audioTrack) + duration = try? values.decode(Double.self, forKey: .duration) + size = try? values.decode(Int.self, forKey: .size) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(index, forKey: .index) + try container.encode(episode, forKey: .episode) + try container.encode(episodeType, forKey: .episodeType) + try container.encode(title, forKey: .title) + try container.encode(subtitle, forKey: .subtitle) + try container.encode(desc, forKey: .desc) + try container.encode(audioFile, forKey: .audioFile) + try container.encode(audioTrack, forKey: .audioTrack) + try container.encode(duration, forKey: .duration) + try container.encode(size, forKey: .size) + try container.encode(serverEpisodeId, forKey: .serverEpisodeId) } } From 02eabb82c1091bbcf83f6b815bedd1836d902219 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 13:16:25 -0400 Subject: [PATCH 050/103] Hide UI options that iOS does not need / support --- ios/App/App/plugins/AbsFileSystem.swift | 14 +++++------ pages/localMedia/item/_id.vue | 31 ++++++++++--------------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/ios/App/App/plugins/AbsFileSystem.swift b/ios/App/App/plugins/AbsFileSystem.swift index c2d59580..83610d75 100644 --- a/ios/App/App/plugins/AbsFileSystem.swift +++ b/ios/App/App/plugins/AbsFileSystem.swift @@ -16,7 +16,7 @@ public class AbsFileSystem: CAPPlugin { // TODO: Implement NSLog("Select Folder for media type \(mediaType ?? "UNSET")") - call.resolve() + call.unavailable("Not available on iOS") } @objc func checkFolderPermission(_ call: CAPPluginCall) { @@ -25,9 +25,7 @@ public class AbsFileSystem: CAPPlugin { // TODO: Is this even necessary on iOS? NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")") - call.resolve([ - "value": true - ]) + call.unavailable("Not available on iOS") } @objc func scanFolder(_ call: CAPPluginCall) { @@ -37,7 +35,7 @@ public class AbsFileSystem: CAPPlugin { // TODO: Implement NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)") - call.resolve() + call.unavailable("Not available on iOS") } @objc func removeFolder(_ call: CAPPluginCall) { @@ -46,7 +44,7 @@ public class AbsFileSystem: CAPPlugin { // TODO: Implement NSLog("removeFolder \(folderId ?? "UNSET")") - call.resolve() + call.unavailable("Not available on iOS") } @objc func removeLocalLibraryItem(_ call: CAPPluginCall) { @@ -55,7 +53,7 @@ public class AbsFileSystem: CAPPlugin { // TODO: Implement NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")") - call.resolve() + call.unavailable("Not available on iOS") } @objc func scanLocalLibraryItem(_ call: CAPPluginCall) { @@ -65,7 +63,7 @@ public class AbsFileSystem: CAPPlugin { // TODO: Implement NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)") - call.resolve() + call.unavailable("Not available on iOS") } @objc func deleteItem(_ call: CAPPluginCall) { diff --git a/pages/localMedia/item/_id.vue b/pages/localMedia/item/_id.vue index 0d825982..53714ad4 100644 --- a/pages/localMedia/item/_id.vue +++ b/pages/localMedia/item/_id.vue @@ -11,7 +11,7 @@ more_vert
-

Folder: {{ folderName }}

+

Folder: {{ folderName }}

{{ libraryItemId ? 'Linked to item on server ' + liServerAddress : 'Not linked to server item' }}

@@ -138,6 +138,9 @@ export default { } }, computed: { + isIos() { + return this.$platform === 'ios' + }, basePath() { return this.localLibraryItem ? this.localLibraryItem.basePath : null }, @@ -194,24 +197,14 @@ export default { } ] } else { - return [ - { - text: 'Scan', - value: 'scan' - }, - { - text: 'Force Re-Scan', - value: 'rescan' - }, - { - text: 'Remove', - value: 'remove' - }, - { - text: 'Remove & Delete Files', - value: 'delete' - } - ] + var options = [] + if ( !this.isIos ) { + options.push({ text: 'Scan', value: 'scan'}) + options.push({ text: 'Force Re-Scan', value: 'rescan'}) + options.push({ text: 'Remove', value: 'remove'}) + } + options.push({ text: 'Remove & Delete Files', value: 'delete'}) + return options } } }, From 8edeefc1cd29284c5e22a2c5abce393ced8f80d9 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 13:46:30 -0400 Subject: [PATCH 051/103] Prevent iCloud backups of downloads --- ios/App/App/plugins/AbsDownloader.swift | 28 +++++++++++++++++++----- ios/App/Shared/models/DownloadItem.swift | 2 +- ios/App/Shared/models/LocalLibrary.swift | 6 ++--- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index b9e86c00..06b6e131 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -11,7 +11,7 @@ import Capacitor @objc(AbsDownloader) public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { - static let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadItemPart: inout DownloadItemPart) throws -> Void @@ -307,16 +307,34 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let itemDirectory = item.id NSLog("ITEM DIR \(itemDirectory)") - do { - try FileManager.default.createDirectory(at: AbsDownloader.downloadsDirectory.appendingPathComponent(itemDirectory), withIntermediateDirectories: true) - } catch { - NSLog("Failed to CREATE LI DIRECTORY \(error)") + guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else { + NSLog("Failed to CREATE LI DIRECTORY \(itemDirectory)") throw LibraryItemDownloadError.failedDirectory } return itemDirectory } + static func itemDownloadFolder(path: String) -> URL? { + do { + var itemFolder = AbsDownloader.downloadsDirectory.appendingPathComponent(path) + + if !FileManager.default.fileExists(atPath: itemFolder.path) { + try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: true) + } + + // Make sure we don't backup download files to iCloud + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try itemFolder.setResourceValues(resourceValues) + + return itemFolder + } catch { + NSLog("Failed to CREATE LI DIRECTORY \(error)") + return nil + } + } + } enum LibraryItemDownloadError: String, Error { diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift index 53695220..8be65ae3 100644 --- a/ios/App/Shared/models/DownloadItem.swift +++ b/ios/App/Shared/models/DownloadItem.swift @@ -82,7 +82,7 @@ struct DownloadItemPart: Realmable, Codable { var destinationUri: String? var destinationURL: URL? { if let destinationUri = self.destinationUri { - return AbsDownloader.downloadsDirectory.appendingPathComponent(destinationUri) + return AbsDownloader.itemDownloadFolder(path: destinationUri)! } else { return nil } diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 71eda6d2..542f4dab 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -25,7 +25,7 @@ struct LocalLibraryItem: Realmable, Codable { var contentUrl: String? { if let path = _contentUrl { - return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString + return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString } else { return nil } @@ -33,7 +33,7 @@ struct LocalLibraryItem: Realmable, Codable { var coverContentUrl: String? { if let path = self._coverContentUrl { - return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString + return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString } else { return nil } @@ -93,7 +93,7 @@ struct LocalFile: Realmable, Codable { var size: Int = 0 var contentUrl: String { - return AbsDownloader.downloadsDirectory.appendingPathComponent(_contentUrl).absoluteString + return AbsDownloader.itemDownloadFolder(path: _contentUrl)!.absoluteString } static func primaryKey() -> String? { From 60cb5f398c54f0c64cd84fda5c20f9221147ad48 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 14:12:45 -0400 Subject: [PATCH 052/103] Handle deleting library items --- ios/App/App/plugins/AbsAudioPlayer.swift | 2 +- ios/App/App/plugins/AbsDatabase.swift | 4 ++-- ios/App/App/plugins/AbsDownloader.swift | 2 +- ios/App/App/plugins/AbsFileSystem.swift | 18 ++++++++++++++---- ios/App/Shared/models/LocalLibrary.swift | 8 ++++++++ ios/App/Shared/util/Database.swift | 24 ++++++++++++------------ pages/localMedia/item/_id.vue | 4 ++-- 7 files changed, 40 insertions(+), 22 deletions(-) diff --git a/ios/App/App/plugins/AbsAudioPlayer.swift b/ios/App/App/plugins/AbsAudioPlayer.swift index a66d3b36..d4e74869 100644 --- a/ios/App/App/plugins/AbsAudioPlayer.swift +++ b/ios/App/App/plugins/AbsAudioPlayer.swift @@ -44,7 +44,7 @@ public class AbsAudioPlayer: CAPPlugin { let isLocalItem = libraryItemId?.starts(with: "local_") ?? false if (isLocalItem) { - let item = Database.shared.getLocalLibraryItem(localLibraryItem: libraryItemId!) + let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!) // TODO: Logic required for podcasts here let playbackSession = item?.getPlaybackSession(episode: nil) PlayerHandler.startPlayback(session: playbackSession!, playWhenReady: playWhenReady, playbackRate: playbackRate) diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index fdc3bab6..1d3f6569 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -81,7 +81,7 @@ public class AbsDatabase: CAPPlugin { @objc func getLocalLibraryItem(_ call: CAPPluginCall) { do { - let item = Database.shared.getLocalLibraryItem(localLibraryItem: call.getString("id") ?? "") + let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "") switch item { case .some(let foundItem): call.resolve(try foundItem.asDictionary()) @@ -97,7 +97,7 @@ public class AbsDatabase: CAPPlugin { @objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) { do { - let item = Database.shared.getLocalLibraryItemByLLId(libraryItem: call.getString("libraryItemId") ?? "") + let item = Database.shared.getLocalLibraryItem(byServerLibraryItemId: call.getString("libraryItemId") ?? "") switch item { case .some(let foundItem): call.resolve(try foundItem.asDictionary()) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 06b6e131..bfb2bd60 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -157,7 +157,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } return LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize)) } - var localLibraryItem = Database.shared.getLocalLibraryItemByLLId(libraryItem: libraryItem.id) + var localLibraryItem = Database.shared.getLocalLibraryItem(byServerLibraryItemId: libraryItem.id) if (localLibraryItem != nil && localLibraryItem!.isPodcast) { try! localLibraryItem?.addFiles(files, item: libraryItem) } else { diff --git a/ios/App/App/plugins/AbsFileSystem.swift b/ios/App/App/plugins/AbsFileSystem.swift index 83610d75..a1a2763d 100644 --- a/ios/App/App/plugins/AbsFileSystem.swift +++ b/ios/App/App/plugins/AbsFileSystem.swift @@ -67,13 +67,23 @@ public class AbsFileSystem: CAPPlugin { } @objc func deleteItem(_ call: CAPPluginCall) { - let localLibraryItemId = call.getString("localLibraryItemId") + let localLibraryItemId = call.getString("id") let contentUrl = call.getString("contentUrl") - - // TODO: Implement + NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")") - call.resolve() + var success = false + do { + if let localLibraryItemId = localLibraryItemId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) { + try FileManager.default.removeItem(at: item.contentDirectory!) + Database.shared.removeLocalLibraryItem(localLibraryItemId: localLibraryItemId) + success = true + } + } catch { + NSLog("Failed to delete \(error)") + } + + call.resolve(["success": success]) } @objc func deleteTrackFromItem(_ call: CAPPluginCall) { diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 542f4dab..845434c8 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -31,6 +31,14 @@ struct LocalLibraryItem: Realmable, Codable { } } + var contentDirectory: URL? { + if let path = _contentUrl { + return AbsDownloader.itemDownloadFolder(path: path) + } else { + return nil + } + } + var coverContentUrl: String? { if let path = self._coverContentUrl { return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 849de8f8..a1b418ae 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -108,14 +108,14 @@ class Database { return Array(realm.objects(LocalLibraryItem.self)) } - public func getLocalLibraryItemByLLId(libraryItem: String) -> LocalLibraryItem? { + public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? { let realm = try! Realm() - return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == libraryItem }) + return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId }) } - public func getLocalLibraryItem(localLibraryItem: String) -> LocalLibraryItem? { + public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? { let realm = try! Realm() - return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItem) + return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId) } public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) { @@ -123,6 +123,14 @@ class Database { try! realm.write { realm.add(localLibraryItem, update: .modified) } } + public func removeLocalLibraryItem(localLibraryItemId: String) { + let realm = try! Realm() + try! realm.write { + let item = getLocalLibraryItem(localLibraryItemId: localLibraryItemId) + realm.delete(item!) + } + } + public func getDownloadItem(downloadItemId: String) -> DownloadItem? { let realm = try! Realm() return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) @@ -158,14 +166,6 @@ class Database { return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() } - public func removeLocalLibraryItem(localLibraryItemId: String) { - let realm = try! Realm() - try! realm.write { - let item = getLocalLibraryItemByLLId(libraryItem: localLibraryItemId) - realm.delete(item!) - } - } - public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) { let realm = try! Realm() try! realm.write { realm.add(mediaProgress, update: .modified) } diff --git a/pages/localMedia/item/_id.vue b/pages/localMedia/item/_id.vue index 53714ad4..94fcec33 100644 --- a/pages/localMedia/item/_id.vue +++ b/pages/localMedia/item/_id.vue @@ -322,13 +322,13 @@ export default { async deleteItem() { const { value } = await Dialog.confirm({ title: 'Confirm', - message: `Warning! This will delete the folder "${this.basePath}" and all contents. Are you sure?` + message: `Warning! This will delete "${this.media.metadata.title}" and all associated local files. Are you sure?` }) if (value) { var res = await AbsFileSystem.deleteItem(this.localLibraryItem) if (res && res.success) { this.$toast.success('Deleted Successfully') - this.$router.replace(`/localMedia/folders/${this.folderId}`) + this.$router.replace(this.isIos ? '/bookshelf' : `/localMedia/folders/${this.folderId}`) } else this.$toast.error('Failed to delete') } }, From 4e63d4367963eb1bc84019782912bb0606d07778 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 15:36:06 -0400 Subject: [PATCH 053/103] Fix progress done percent --- ios/App/App/plugins/AbsDownloader.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index bfb2bd60..2aa34eb0 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -26,7 +26,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in - downloadItemPart.progress = 1 + downloadItemPart.progress = 100 downloadItemPart.completed = true do { From 0a989e7811289cfc8e9a3b696f4b9d11aa351777 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 15:36:25 -0400 Subject: [PATCH 054/103] Handle deleting podcast episodes --- ios/App/App/plugins/AbsFileSystem.swift | 31 ++++++++++++++++++++---- ios/App/Shared/models/LocalLibrary.swift | 9 ++++--- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/ios/App/App/plugins/AbsFileSystem.swift b/ios/App/App/plugins/AbsFileSystem.swift index a1a2763d..7335fb13 100644 --- a/ios/App/App/plugins/AbsFileSystem.swift +++ b/ios/App/App/plugins/AbsFileSystem.swift @@ -81,19 +81,40 @@ public class AbsFileSystem: CAPPlugin { } } catch { NSLog("Failed to delete \(error)") + success = false } call.resolve(["success": success]) } @objc func deleteTrackFromItem(_ call: CAPPluginCall) { - let localLibraryItemId = call.getString("localLibraryItemId") + let localLibraryItemId = call.getString("id") let trackLocalFileId = call.getString("trackLocalFileId") - let contentUrl = call.getString("contentUrl") - // TODO: Implement - NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET") url \(contentUrl ?? "UNSET")") + NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")") - call.resolve() + var success = false + do { + if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, var item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) { + if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) { + try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath) + item.localFiles.remove(at: fileIndex) + if item.isPodcast, var media = item.media { + media.episodes = media.episodes?.filter { $0.audioTrack?.localFileId != trackLocalFileId } + item.media = media + } + Database.shared.saveLocalLibraryItem(localLibraryItem: item) + call.resolve(try item.asDictionary()) + success = true + } + } + } catch { + NSLog("Failed to delete \(error)") + success = false + } + + if !success { + call.resolve(["success": success]) + } } } diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 845434c8..fde755df 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -100,16 +100,16 @@ struct LocalFile: Realmable, Codable { var mimeType: String? var size: Int = 0 - var contentUrl: String { - return AbsDownloader.itemDownloadFolder(path: _contentUrl)!.absoluteString - } + var contentUrl: String { AbsDownloader.itemDownloadFolder(path: _contentUrl)!.absoluteString } + var contentPath: URL { AbsDownloader.itemDownloadFolder(path: _contentUrl)! } + var basePath: String? { self.filename } static func primaryKey() -> String? { return "id" } private enum CodingKeys : String, CodingKey { - case id, filename, contentUrl, mimeType, size + case id, filename, contentUrl, mimeType, size, basePath } init() {} @@ -129,6 +129,7 @@ struct LocalFile: Realmable, Codable { try container.encode(contentUrl, forKey: .contentUrl) try container.encode(mimeType, forKey: .mimeType) try container.encode(size, forKey: .size) + try container.encode(basePath, forKey: .basePath) } } From a107a25e4329cda03aefecdaa89a33c58e9e0ab9 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 16:11:53 -0400 Subject: [PATCH 055/103] Fix downloading in progress books --- ios/App/Shared/models/DataClasses.swift | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index fb86b667..578ee637 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -325,6 +325,56 @@ struct MediaProgress: Realmable, Codable { lastUpdate = 0 startedAt = 0 } + + private enum CodingKeys : String, CodingKey { + case id, libraryItemId, episodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + libraryItemId = try values.decode(String.self, forKey: .libraryItemId) + episodeId = try? values.decode(String.self, forKey: .episodeId) + duration = try MediaProgress.doubleOrStringDecoder(from: decoder, with: values, key: .duration) + progress = try MediaProgress.doubleOrStringDecoder(from: decoder, with: values, key: .progress) + currentTime = try MediaProgress.doubleOrStringDecoder(from: decoder, with: values, key: .currentTime) + isFinished = try values.decode(Bool.self, forKey: .isFinished) + lastUpdate = try MediaProgress.intOrStringDecoder(from: decoder, with: values, key: .lastUpdate) + startedAt = try MediaProgress.intOrStringDecoder(from: decoder, with: values, key: .startedAt) + finishedAt = try? MediaProgress.intOrStringDecoder(from: decoder, with: values, key: .finishedAt) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(libraryItemId, forKey: .libraryItemId) + try container.encode(episodeId, forKey: .episodeId) + try container.encode(duration, forKey: .duration) + try container.encode(progress, forKey: .progress) + try container.encode(currentTime, forKey: .currentTime) + try container.encode(isFinished, forKey: .isFinished) + try container.encode(lastUpdate, forKey: .lastUpdate) + try container.encode(startedAt, forKey: .startedAt) + try container.encode(finishedAt, forKey: .finishedAt) + } + + static private func doubleOrStringDecoder(from decoder: Decoder, with values: KeyedDecodingContainer, key: MediaProgress.CodingKeys) throws -> Double { + do { + return try values.decode(Double.self, forKey: key) + } catch { + let stringDuration = try values.decode(String.self, forKey: key) + return Double(stringDuration) ?? 0.0 + } + } + + static private func intOrStringDecoder(from decoder: Decoder, with values: KeyedDecodingContainer, key: MediaProgress.CodingKeys) throws -> Int { + do { + return try values.decode(Int.self, forKey: key) + } catch { + let stringDuration = try values.decode(String.self, forKey: key) + return Int(stringDuration) ?? 0 + } + } } struct PlaybackMetadata: Realmable, Codable { From 64707b0928ff5b4bcb07d89b7188cecad33f51cb Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 16:26:32 -0400 Subject: [PATCH 056/103] Support local media progress --- ios/App/App/plugins/AbsDatabase.swift | 10 +++++++++- ios/App/Shared/models/LocalLibraryExtensions.swift | 1 + ios/App/Shared/util/Database.swift | 5 +++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index 1d3f6569..6520c9b2 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -114,9 +114,17 @@ public class AbsDatabase: CAPPlugin { @objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) { call.resolve([ "value": [] ]) } + @objc func getAllLocalMediaProgress(_ call: CAPPluginCall) { - call.resolve([ "value": [] ]) + do { + call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ]) + } catch { + NSLog("Error while loading local media progress") + debugPrint(error) + call.resolve(["value": []]) + } } + @objc func updateDeviceSettings(_ call: CAPPluginCall) { let disableAutoRewind = call.getBool("disableAutoRewind") ?? false let enableAltView = call.getBool("enableAltView") ?? false diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index 4e99606d..7dbc256a 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -138,6 +138,7 @@ extension LocalMediaProgress { self.serverConnectionConfigId = localLibraryItem.serverConnectionConfigId self.duration = progress.duration + self.progress = progress.progress self.currentTime = progress.currentTime self.isFinished = false self.lastUpdate = progress.lastUpdate diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index a1b418ae..d8a30851 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -166,6 +166,11 @@ class Database { return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() } + public func getAllLocalMediaProgress() -> [LocalMediaProgress] { + let realm = try! Realm() + return Array(realm.objects(LocalMediaProgress.self)) + } + public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) { let realm = try! Realm() try! realm.write { realm.add(mediaProgress, update: .modified) } From 2e6946e1f70da5dd927fa8c547e63ceab0ec6e89 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 11 Aug 2022 16:39:59 -0400 Subject: [PATCH 057/103] Disable track reordering on iOS --- pages/localMedia/item/_id.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pages/localMedia/item/_id.vue b/pages/localMedia/item/_id.vue index 94fcec33..4b342c13 100644 --- a/pages/localMedia/item/_id.vue +++ b/pages/localMedia/item/_id.vue @@ -22,11 +22,11 @@

Audio Tracks ({{ audioTracks.length }})

- +