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() {