Merge branch 'ios-downloads' into ios-downloads-realm-native

This commit is contained in:
ronaldheft
2022-08-14 17:48:31 -04:00
20 changed files with 850 additions and 208 deletions
+15 -3
View File
@@ -26,7 +26,7 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" /> <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
<div v-if="!isIos && userCanDownload"> <div v-if="userCanDownload">
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span> <span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span> <span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span> <span v-else class="material-icons px-2 text-success text-xl">download_done</span>
@@ -143,7 +143,12 @@ export default {
}, },
downloadClick() { downloadClick() {
if (this.downloadItem) return if (this.downloadItem) return
this.download() if (this.isIos) {
// no local folders on iOS
this.startDownload()
} else {
this.download()
}
}, },
async download(selectedLocalFolder = null) { async download(selectedLocalFolder = null) {
var localFolder = selectedLocalFolder var localFolder = selectedLocalFolder
@@ -183,7 +188,14 @@ export default {
} }
}, },
async startDownload(localFolder) { 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.episode.id
}
if (localFolder) {
this.localFolderId = localFolder.id
}
var downloadRes = await AbsDownloader.downloadLibraryItem(payload)
if (downloadRes && downloadRes.error) { if (downloadRes && downloadRes.error) {
var errorMsg = downloadRes.error || 'Unknown error' var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg) console.error('Download error', errorMsg)
+4
View File
@@ -34,18 +34,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidEnterBackground(_ application: UIApplication) { func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
NSLog("Audiobookself is now in the background")
} }
func applicationWillEnterForeground(_ application: UIApplication) { func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
NSLog("Audiobookself is now in the foreground")
} }
func applicationDidBecomeActive(_ application: UIApplication) { func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
NSLog("Audiobookself is now active")
} }
func applicationWillTerminate(_ application: UIApplication) { func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
NSLog("Audiobookself is terminating")
} }
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
+24 -5
View File
@@ -21,6 +21,7 @@ public class AbsAudioPlayer: CAPPlugin {
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
self.bridge?.webView?.allowsBackForwardNavigationGestures = true; self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
@@ -44,12 +45,20 @@ public class AbsAudioPlayer: CAPPlugin {
let isLocalItem = libraryItemId?.starts(with: "local_") ?? false let isLocalItem = libraryItemId?.starts(with: "local_") ?? false
if (isLocalItem) { if (isLocalItem) {
let item = Database.shared.getLocalLibraryItem(localLibraryItem: libraryItemId!) let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
// TODO: Logic required for podcasts here let episode = item?.getPodcastEpisode(episodeId: episodeId)
let playbackSession = item?.getPlaybackSession(episode: nil) let playbackSession = item?.getPlaybackSession(episode: episode)
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
do {
self.sendPlaybackSession(session: try playbackSession.asDictionary())
call.resolve(try playbackSession.asDictionary())
} catch(let exception) {
NSLog("failed to convert session to json")
debugPrint(exception)
call.resolve([:])
}
PlayerHandler.startPlayback(session: playbackSession!, playWhenReady: playWhenReady, playbackRate: playbackRate) PlayerHandler.startPlayback(session: playbackSession!, playWhenReady: playWhenReady, playbackRate: playbackRate)
self.sendMetadata() self.sendMetadata()
call.resolve()
} else { // Playing from the server } else { // Playing from the server
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady) sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
@@ -62,12 +71,12 @@ public class AbsAudioPlayer: CAPPlugin {
call.resolve([:]) call.resolve([:])
} }
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate) PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
self.sendMetadata() self.sendMetadata()
} }
} }
} }
@objc func closePlayback(_ call: CAPPluginCall) { @objc func closePlayback(_ call: CAPPluginCall) {
NSLog("Close playback") NSLog("Close playback")
@@ -171,12 +180,21 @@ public class AbsAudioPlayer: CAPPlugin {
"value": PlayerHandler.getCurrentTime() "value": PlayerHandler.getCurrentTime()
]) ])
} }
@objc func sendSleepTimerSet() { @objc func sendSleepTimerSet() {
self.notifyListeners("onSleepTimerSet", data: [ self.notifyListeners("onSleepTimerSet", data: [
"value": PlayerHandler.remainingSleepTime "value": PlayerHandler.remainingSleepTime
]) ])
} }
@objc func onLocalMediaProgressUpdate() {
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
NSLog("Sending local progress back to the UI")
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
}
@objc func onPlaybackFailed() { @objc func onPlaybackFailed() {
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) { if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
let playbackSession = PlayerHandler.getPlaybackSession() let playbackSession = PlayerHandler.getPlaybackSession()
@@ -211,6 +229,7 @@ public class AbsAudioPlayer: CAPPlugin {
"playWhenReady": playWhenReady, "playWhenReady": playWhenReady,
]) ])
} }
@objc func sendPlaybackSession(session: [String: Any]) { @objc func sendPlaybackSession(session: [String: Any]) {
self.notifyListeners("onPlaybackSession", data: session) self.notifyListeners("onPlaybackSession", data: session)
} }
+4 -1
View File
@@ -20,6 +20,9 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(syncLocalMediaProgressWithServer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
) )
+102 -3
View File
@@ -8,6 +8,7 @@
import Foundation import Foundation
import Capacitor import Capacitor
import RealmSwift import RealmSwift
import SwiftUI
extension String { extension String {
@@ -88,7 +89,7 @@ public class AbsDatabase: CAPPlugin {
@objc func getLocalLibraryItem(_ call: CAPPluginCall) { @objc func getLocalLibraryItem(_ call: CAPPluginCall) {
do { do {
let item = Database.shared.getLocalLibraryItem(localLibraryItem: call.getString("id") ?? "") let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
switch item { switch item {
case .some(let foundItem): case .some(let foundItem):
call.resolve(try foundItem.asDictionary()) call.resolve(try foundItem.asDictionary())
@@ -104,7 +105,7 @@ public class AbsDatabase: CAPPlugin {
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) { @objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
do { do {
let item = Database.shared.getLocalLibraryItemByLLId(libraryItem: call.getString("libraryItemId") ?? "") let item = Database.shared.getLocalLibraryItem(byServerLibraryItemId: call.getString("libraryItemId") ?? "")
switch item { switch item {
case .some(let foundItem): case .some(let foundItem):
call.resolve(try foundItem.asDictionary()) call.resolve(try foundItem.asDictionary())
@@ -121,9 +122,107 @@ public class AbsDatabase: CAPPlugin {
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) { @objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
call.resolve([ "value": [] ]) call.resolve([ "value": [] ])
} }
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) { @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 removeLocalMediaProgress(_ call: CAPPluginCall) {
let localMediaProgressId = call.getString("localMediaProgressId")
guard let localMediaProgressId = localMediaProgressId else {
call.reject("localMediaProgressId not specificed")
return
}
Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId)
call.resolve()
}
@objc func syncLocalMediaProgressWithServer(_ call: CAPPluginCall) {
guard Store.serverConfig != nil else {
call.reject("syncLocalMediaProgressWithServer not connected to server")
return
}
ApiClient.syncMediaProgress { results in
do {
call.resolve(try results.asDictionary())
} catch {
call.reject("Failed to report synced media progress")
}
}
}
@objc func syncServerMediaProgressWithLocalMediaProgress(_ call: CAPPluginCall) {
let serverMediaProgress = call.getJson("mediaProgress", type: MediaProgress.self)
let localLibraryItemId = call.getString("localLibraryItemId")
let localEpisodeId = call.getString("localEpisodeId")
let localMediaProgressId = call.getString("localMediaProgressId")
do {
guard let serverMediaProgress = serverMediaProgress else {
return call.reject("serverMediaProgress not specified")
}
guard localLibraryItemId != nil || localMediaProgressId != nil else {
return call.reject("localLibraryItemId or localMediaProgressId must be specified")
}
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
guard var localMediaProgress = localMediaProgress else {
call.reject("Local media progress not found or created")
return
}
localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
NSLog("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress")
Database.shared.saveLocalMediaProgress(localMediaProgress)
call.resolve(try localMediaProgress.asDictionary())
} catch {
call.reject("Failed to sync media progress")
debugPrint(error)
}
}
@objc func updateLocalMediaProgressFinished(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
let localEpisodeId = call.getString("localEpisodeId")
let localMediaProgressId = call.getString("localMediaProgressId")
let isFinished = call.getBool("isFinished", false)
NSLog("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)")
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
guard var localMediaProgress = localMediaProgress else {
call.resolve(["error": "Library Item not found"])
return
}
// Update finished status
localMediaProgress.updateIsFinished(isFinished)
Database.shared.saveLocalMediaProgress(localMediaProgress)
// Build API response
let progressDictionary = try? localMediaProgress.asDictionary()
var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""]
// Send update to the server if logged in
let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil
let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId
if hasLinkedServer && loggedIntoServer {
response["server"] = true
let payload = ["isFinished": isFinished]
ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) {
call.resolve(response)
}
} else {
call.resolve(response)
}
}
@objc func updateDeviceSettings(_ call: CAPPluginCall) { @objc func updateDeviceSettings(_ call: CAPPluginCall) {
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
let enableAltView = call.getBool("enableAltView") ?? false let enableAltView = call.getBool("enableAltView") ?? false
+67 -43
View File
@@ -12,7 +12,7 @@ import RealmSwift
@objc(AbsDownloader) @objc(AbsDownloader)
public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { 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 typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadItemPart: inout DownloadItemPart) throws -> Void
@@ -23,11 +23,11 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
}() }()
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent) private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
private var downloadItemProgress = [String: DownloadItem]() private var downloadItemProgress = [String: DownloadItem]()
private var isMonitoringProgress = false private var monitoringProgressTimer: Timer?
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
downloadItemPart.progress = 1 downloadItemPart.progress = 100
downloadItemPart.completed = true downloadItemPart.completed = true
do { do {
@@ -103,47 +103,49 @@ 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 // 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() { private func notifyDownloadProgress() {
if !self.isMonitoringProgress { if self.monitoringProgressTimer == nil {
self.isMonitoringProgress = true NSLog("Already monitoring progress, no need to start timer again")
DispatchQueue.global(qos: .userInteractive).async { } else {
NSLog("Starting monitoring download progress...") DispatchQueue.runOnMainQueue {
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { t in
NSLog("Starting monitoring download progress...")
// Fetch active downloads in a thread-safe way // Fetch active downloads in a thread-safe way
func fetchActiveDownloads() -> [String: DownloadItem]? { func fetchActiveDownloads() -> [String: DownloadItem]? {
self.progressStatusQueue.sync { self.downloadItemProgress } self.progressStatusQueue.sync {
} let activeDownloads = self.downloadItemProgress
if activeDownloads.isEmpty {
// Remove a completed download item in a thread-safe way NSLog("Finishing monitoring download progress...")
func handleDoneDownloadItem(_ item: DownloadItem) { t.invalidate()
self.progressStatusQueue.async(flags: .barrier) { }
self.downloadItemProgress.removeValue(forKey: item.id!) return activeDownloads
}
} }
Database.shared.removeDownloadItem(item)
self.handleDownloadTaskCompleteFromDownloadItem(item)
}
// While there are active download items, emit status updates // Remove a completed download item in a thread-safe way
while !(fetchActiveDownloads()?.isEmpty ?? false) { func handleDoneDownloadItem(_ item: DownloadItem) {
self.progressStatusQueue.async(flags: .barrier) {
self.downloadItemProgress.removeValue(forKey: item.id!)
}
Database.shared.removeDownloadItem(item)
self.handleDownloadTaskCompleteFromDownloadItem(item)
}
// Emit status for active downloads
if let activeDownloads = fetchActiveDownloads() { if let activeDownloads = fetchActiveDownloads() {
for item in activeDownloads.values { for item in activeDownloads.values {
try? self.notifyListeners("onItemDownloadUpdate", data: item.asDictionary()) try? self.notifyListeners("onItemDownloadUpdate", data: item.asDictionary())
if item.isDoneDownloading() { handleDoneDownloadItem(item) } 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
} }
} }
} }
private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) { private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) {
var statusNotification = [String: Any]() var statusNotification = [String: Any]()
statusNotification["libraryItemId"] = downloadItem.libraryItemId statusNotification["libraryItemId"] = downloadItem.id
if ( downloadItem.didDownloadSuccessfully() ) { if ( downloadItem.didDownloadSuccessfully() ) {
ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in
@@ -155,19 +157,22 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
let files = downloadItem.downloadItemParts.enumerated().compactMap { _, part -> LocalFile? in let files = downloadItem.downloadItemParts.enumerated().compactMap { _, part -> LocalFile? in
if part.filename == "cover.jpg" { if part.filename == "cover.jpg" {
coverFile = part.destinationUri 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))
}
var localLibraryItem = Database.shared.getLocalLibraryItem(byServerLibraryItemId: 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)
} }
let 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() statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary()
if let progress = libraryItem.userMediaProgress { if let progress = libraryItem.userMediaProgress {
// TODO: Handle podcast let episode = downloadItem.media?.episodes?.first(where: { $0.id == downloadItem.episodeId })
let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: nil, progress: progress) let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress)
Database.shared.saveLocalMediaProgress(localMediaProgress) Database.shared.saveLocalMediaProgress(localMediaProgress)
statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary() statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary()
} }
@@ -237,7 +242,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
// Queue up everything for downloading // Queue up everything for downloading
let downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!) let downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!)
for (i, track) in tracks.enumerated() { for (i, track) in tracks.enumerated() {
downloadItem.downloadItemParts.append(try startLibraryItemTrackDownload(item: item, position: i, track: track)) downloadItem.downloadItemParts.append(try startLibraryItemTrackDownload(item: item, position: i, track: track, episode: episode))
} }
// Also download the cover // Also download the cover
@@ -256,7 +261,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!)") NSLog("TRACK \(track.contentUrl!)")
// If we don't name metadata, then we can't proceed // If we don't name metadata, then we can't proceed
@@ -269,7 +274,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
let localUrl = "\(itemDirectory)/\(filename)" let localUrl = "\(itemDirectory)/\(filename)"
let task = session.downloadTask(with: serverUrl) let task = session.downloadTask(with: serverUrl)
let 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 // Store the id on the task so the download item can be pulled from the database later
task.taskDescription = downloadItemPart.id task.taskDescription = downloadItemPart.id
@@ -305,16 +310,34 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
let itemDirectory = item.id let itemDirectory = item.id
NSLog("ITEM DIR \(itemDirectory)") NSLog("ITEM DIR \(itemDirectory)")
do { guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
try FileManager.default.createDirectory(at: AbsDownloader.downloadsDirectory.appendingPathComponent(itemDirectory), withIntermediateDirectories: true) NSLog("Failed to CREATE LI DIRECTORY \(itemDirectory)")
} catch {
NSLog("Failed to CREATE LI DIRECTORY \(error)")
throw LibraryItemDownloadError.failedDirectory throw LibraryItemDownloadError.failedDirectory
} }
return itemDirectory 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 { enum LibraryItemDownloadError: String, Error {
@@ -322,6 +345,7 @@ enum LibraryItemDownloadError: String, Error {
case noMetadata = "No metadata for track, unable to download" case noMetadata = "No metadata for track, unable to download"
case libraryItemNotPodcast = "Library item is not a podcast but episode was requested" case libraryItemNotPodcast = "Library item is not a podcast but episode was requested"
case podcastEpisodeNotFound = "Invalid podcast episode not found" case podcastEpisodeNotFound = "Invalid podcast episode not found"
case podcastOnlySupported = "Only podcasts are supported for this function"
case unknownMediaType = "Unknown media type" case unknownMediaType = "Unknown media type"
case failedDirectory = "Failed to create directory" case failedDirectory = "Failed to create directory"
case failedDownload = "Failed to download item" case failedDownload = "Failed to download item"
+45 -22
View File
@@ -13,79 +13,102 @@ public class AbsFileSystem: CAPPlugin {
@objc func selectFolder(_ call: CAPPluginCall) { @objc func selectFolder(_ call: CAPPluginCall) {
let mediaType = call.getString("mediaType") let mediaType = call.getString("mediaType")
// TODO: Implement
NSLog("Select Folder for media type \(mediaType ?? "UNSET")") NSLog("Select Folder for media type \(mediaType ?? "UNSET")")
call.resolve() call.unavailable("Not available on iOS")
} }
@objc func checkFolderPermission(_ call: CAPPluginCall) { @objc func checkFolderPermission(_ call: CAPPluginCall) {
let folderUrl = call.getString("folderUrl") let folderUrl = call.getString("folderUrl")
// TODO: Is this even necessary on iOS?
NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")") NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
call.resolve([ call.unavailable("Not available on iOS")
"value": true
])
} }
@objc func scanFolder(_ call: CAPPluginCall) { @objc func scanFolder(_ call: CAPPluginCall) {
let folderId = call.getString("folderId") let folderId = call.getString("folderId")
let forceAudioProbe = call.getBool("forceAudioProbe", false) let forceAudioProbe = call.getBool("forceAudioProbe", false)
// TODO: Implement
NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)") NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
call.resolve() call.unavailable("Not available on iOS")
} }
@objc func removeFolder(_ call: CAPPluginCall) { @objc func removeFolder(_ call: CAPPluginCall) {
let folderId = call.getString("folderId") let folderId = call.getString("folderId")
// TODO: Implement
NSLog("removeFolder \(folderId ?? "UNSET")") NSLog("removeFolder \(folderId ?? "UNSET")")
call.resolve() call.unavailable("Not available on iOS")
} }
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) { @objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId") let localLibraryItemId = call.getString("localLibraryItemId")
// TODO: Implement
NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")") NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
call.resolve() call.unavailable("Not available on iOS")
} }
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) { @objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId") let localLibraryItemId = call.getString("localLibraryItemId")
let forceAudioProbe = call.getBool("forceAudioProbe", false) let forceAudioProbe = call.getBool("forceAudioProbe", false)
// TODO: Implement
NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)") NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
call.resolve() call.unavailable("Not available on iOS")
} }
@objc func deleteItem(_ call: CAPPluginCall) { @objc func deleteItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId") let localLibraryItemId = call.getString("id")
let contentUrl = call.getString("contentUrl") let contentUrl = call.getString("contentUrl")
// TODO: Implement
NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")") 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)")
success = false
}
call.resolve(["success": success])
} }
@objc func deleteTrackFromItem(_ call: CAPPluginCall) { @objc func deleteTrackFromItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId") let localLibraryItemId = call.getString("id")
let trackLocalFileId = call.getString("trackLocalFileId") let trackLocalFileId = call.getString("trackLocalFileId")
let contentUrl = call.getString("contentUrl")
// TODO: Implement NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET") url \(contentUrl ?? "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])
}
} }
} }
+59 -14
View File
@@ -264,7 +264,7 @@ class PodcastEpisode: Object, Codable {
@Persisted var audioTrack: AudioTrack? @Persisted var audioTrack: AudioTrack?
@Persisted var duration: Double = 0 @Persisted var duration: Double = 0
@Persisted var size: Int = 0 @Persisted var size: Int = 0
// var serverEpisodeId: String? var serverEpisodeId: String { self.id }
private enum CodingKeys : String, CodingKey { private enum CodingKeys : String, CodingKey {
case id, case id,
@@ -277,14 +277,44 @@ class PodcastEpisode: Object, Codable {
audioFile, audioFile,
audioTrack, audioTrack,
duration, duration,
size size,
serverEpisodeId
} }
// TODO: Encoding 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)
}
} }
class AudioFile: Object, Codable { class AudioFile: Object, Codable {
@Persisted var index: Int = 0 @Persisted var index: Int?
@Persisted var ino: String = "" @Persisted var ino: String = ""
@Persisted var metadata: FileMetadata? @Persisted var metadata: FileMetadata?
@@ -299,7 +329,7 @@ class AudioFile: Object, Codable {
required init(from decoder: Decoder) throws { required init(from decoder: Decoder) throws {
super.init() super.init()
let values = try decoder.container(keyedBy: CodingKeys.self) let values = try decoder.container(keyedBy: CodingKeys.self)
index = try values.decode(Int.self, forKey: .index) index = try? values.decode(Int.self, forKey: .index)
ino = try values.decode(String.self, forKey: .ino) ino = try values.decode(String.self, forKey: .ino)
metadata = try? values.decode(FileMetadata.self, forKey: .metadata) metadata = try? values.decode(FileMetadata.self, forKey: .metadata)
} }
@@ -339,6 +369,9 @@ class Author: Object, Codable {
try container.encode(name, forKey: .name) try container.encode(name, forKey: .name)
try container.encode(coverPath, forKey: .coverPath) try container.encode(coverPath, forKey: .coverPath)
} }
override init() {
super.init()
} }
class Chapter: Object, Codable { class Chapter: Object, Codable {
@@ -381,8 +414,7 @@ class AudioTrack: Object, Codable {
@Persisted var contentUrl: String? @Persisted var contentUrl: String?
@Persisted var mimeType: String = "" @Persisted var mimeType: String = ""
@Persisted var metadata: FileMetadata? @Persisted var metadata: FileMetadata?
// var isLocal: Bool var localFileId: String?
// var localFileId: String?
// var audioProbeResult: AudioProbeResult? Needed for local playback // var audioProbeResult: AudioProbeResult? Needed for local playback
@Persisted var serverIndex: Int? @Persisted var serverIndex: Int?
@@ -418,6 +450,20 @@ class AudioTrack: Object, Codable {
try container.encode(metadata, forKey: .metadata) try container.encode(metadata, forKey: .metadata)
try container.encode(serverIndex, forKey: .serverIndex) try container.encode(serverIndex, forKey: .serverIndex)
} }
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
}
func getLocalFile() -> LocalFile? {
guard let localFileId = self.localFileId else { return nil }
return Database.shared.getLocalFile(localFileId: localFileId)
}
} }
class FileMetadata: Object, Codable { class FileMetadata: Object, Codable {
@@ -562,18 +608,17 @@ class MediaProgress: Object, Codable {
} }
required init(from decoder: Decoder) throws { required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self) let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id) id = try values.decode(String.self, forKey: .id)
libraryItemId = try values.decode(String.self, forKey: .libraryItemId) libraryItemId = try values.decode(String.self, forKey: .libraryItemId)
episodeId = try? values.decode(String.self, forKey: .episodeId) episodeId = try? values.decode(String.self, forKey: .episodeId)
duration = try values.decode(Double.self, forKey: .duration) duration = try values.doubleOrStringDecoder(key: .duration)
progress = try values.decode(Double.self, forKey: .progress) progress = try values.doubleOrStringDecoder(key: .progress)
currentTime = try values.decode(Double.self, forKey: .currentTime) currentTime = try values.doubleOrStringDecoder(key: .currentTime)
isFinished = try values.decode(Bool.self, forKey: .isFinished) isFinished = try values.decode(Bool.self, forKey: .isFinished)
lastUpdate = try values.decode(Int.self, forKey: .lastUpdate) lastUpdate = try values.intOrStringDecoder(key: .lastUpdate)
startedAt = try values.decode(Int.self, forKey: .startedAt) startedAt = try values.intOrStringDecoder(key: .startedAt)
finishedAt = try? values.decode(Int.self, forKey: .finishedAt) finishedAt = try? values.intOrStringDecoder(key: .finishedAt)
} }
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
+62
View File
@@ -73,6 +73,35 @@ class DownloadItemPart: Object, Codable {
@Persisted var uri: String? @Persisted var uri: String?
@Persisted var destinationUri: String? @Persisted var destinationUri: String?
@Persisted var progress: Double = 0 @Persisted var progress: Double = 0
var task: URLSessionDownloadTask?
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.itemDownloadFolder(path: destinationUri)!
} else {
return nil
}
}
var progress: Double = 0
var task: URLSessionDownloadTask! var task: URLSessionDownloadTask!
private enum CodingKeys : String, CodingKey { private enum CodingKeys : String, CodingKey {
@@ -105,3 +134,36 @@ class DownloadItemPart: Object, Codable {
try container.encode(progress, forKey: .progress) try container.encode(progress, forKey: .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 = ""
if (serverPath.hasSuffix("/cover")) {
downloadUrl += "\(config.address)\(serverPath)?token=\(config.token)"
downloadUrl += "&format=jpeg" // For cover images force to jpeg
} else {
downloadUrl = destination
}
self.uri = downloadUrl
self.destinationUri = destination
}
func mimeType() -> String? {
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
}
}
}
+35 -5
View File
@@ -23,6 +23,33 @@ class LocalLibraryItem: Object, Codable {
@Persisted var serverUserId: String? @Persisted var serverUserId: String?
@Persisted(indexed: true) var libraryItemId: String? @Persisted(indexed: true) var libraryItemId: String?
var contentUrl: String? {
if let path = _contentUrl {
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
} else {
return nil
}
}
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
} else {
return nil
}
}
var isBook: Bool { self.mediaType == "book" }
var isPodcast: Bool { self.mediaType == "podcast" }
private enum CodingKeys : String, CodingKey { private enum CodingKeys : String, CodingKey {
case id, basePath, contentUrl, isInvalid, mediaType, media, localFiles, coverContentUrl, isLocal, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId case id, basePath, contentUrl, isInvalid, mediaType, media, localFiles, coverContentUrl, isLocal, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId
} }
@@ -37,14 +64,12 @@ class LocalLibraryItem: Object, Codable {
let values = try decoder.container(keyedBy: CodingKeys.self) let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id) id = try values.decode(String.self, forKey: .id)
basePath = try values.decode(String.self, forKey: .basePath) basePath = try values.decode(String.self, forKey: .basePath)
contentUrl = try values.decode(String.self, forKey: .contentUrl)
isInvalid = try values.decode(Bool.self, forKey: .isInvalid) isInvalid = try values.decode(Bool.self, forKey: .isInvalid)
mediaType = try values.decode(String.self, forKey: .mediaType) mediaType = try values.decode(String.self, forKey: .mediaType)
media = try? values.decode(MediaType.self, forKey: .media) media = try? values.decode(MediaType.self, forKey: .media)
if let files = try? values.decode([LocalFile].self, forKey: .localFiles) { if let files = try? values.decode([LocalFile].self, forKey: .localFiles) {
localFiles.append(objectsIn: files) localFiles.append(objectsIn: files)
} }
_coverContentUrl = try values.decode(String.self, forKey: .coverContentUrl)
isLocal = try values.decode(Bool.self, forKey: .isLocal) isLocal = try values.decode(Bool.self, forKey: .isLocal)
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId) serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
serverAddress = try? values.decode(String.self, forKey: .serverAddress) serverAddress = try? values.decode(String.self, forKey: .serverAddress)
@@ -112,8 +137,12 @@ class LocalFile: Object, Codable {
@Persisted var mimeType: String? @Persisted var mimeType: String?
@Persisted var size: Int = 0 @Persisted var size: Int = 0
var contentUrl: String { AbsDownloader.itemDownloadFolder(path: _contentUrl)!.absoluteString }
var contentPath: URL { AbsDownloader.itemDownloadFolder(path: _contentUrl)! }
var basePath: String? { self.filename }
private enum CodingKeys : String, CodingKey { private enum CodingKeys : String, CodingKey {
case id, filename, contentUrl, absolutePath, mimeType, size case id, filename, contentUrl, mimeType, size, basePath
} }
override init() { override init() {
@@ -124,7 +153,6 @@ class LocalFile: Object, Codable {
let values = try decoder.container(keyedBy: CodingKeys.self) let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id) id = try values.decode(String.self, forKey: .id)
filename = try? values.decode(String.self, forKey: .filename) 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) size = try values.decode(Int.self, forKey: .size)
} }
@@ -134,9 +162,9 @@ class LocalFile: Object, Codable {
try container.encode(id, forKey: .id) try container.encode(id, forKey: .id)
try container.encode(filename, forKey: .filename) try container.encode(filename, forKey: .filename)
try container.encode(contentUrl, forKey: .contentUrl) try container.encode(contentUrl, forKey: .contentUrl)
try container.encode(absolutePath, forKey: .absolutePath)
try container.encode(mimeType, forKey: .mimeType) try container.encode(mimeType, forKey: .mimeType)
try container.encode(size, forKey: .size) try container.encode(size, forKey: .size)
try container.encode(basePath, forKey: .basePath)
} }
} }
@@ -158,6 +186,8 @@ class LocalMediaProgress: Object, Codable {
@Persisted(indexed: true) var libraryItemId: String? @Persisted(indexed: true) var libraryItemId: String?
@Persisted(indexed: true) var episodeId: String? @Persisted(indexed: true) var episodeId: String?
var progressPercent: Int { Int(self.progress * 100) }
private enum CodingKeys : String, CodingKey { private enum CodingKeys : String, CodingKey {
case id, localLibraryItemId, localEpisodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId, episodeId case id, localLibraryItemId, localEpisodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId, episodeId
} }
@@ -13,39 +13,44 @@ extension LocalLibraryItem {
self.contentUrl = localUrl self.contentUrl = localUrl
self.mediaType = item.mediaType self.mediaType = item.mediaType
self.media = item.media
self.localFiles.append(objectsIn: files) self.localFiles.append(objectsIn: files)
self.coverContentUrl = coverPath self._coverContentUrl = coverPath
self.libraryItemId = item.id self.libraryItemId = item.id
self.serverConnectionConfigId = server.id self.serverConnectionConfigId = server.id
self.serverAddress = server.address self.serverAddress = server.address
self.serverUserId = server.userId self.serverUserId = server.userId
// Link the audio tracks and files
linkLocalFiles(files, fromMedia: item.media)
} }
var contentUrl: String? { mutating func addFiles(_ files: [LocalFile], item: LibraryItem) throws {
set(url) { guard self.isPodcast else { throw LibraryItemDownloadError.podcastOnlySupported }
_contentUrl = url self.localFiles.append(contentsOf: files.filter({ $0.isAudioFile() }))
} linkLocalFiles(self.localFiles, fromMedia: item.media)
get {
if let path = _contentUrl {
return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString
} else {
return nil
}
}
} }
var coverContentUrl: String? { mutating private func linkLocalFiles(_ files: [LocalFile], fromMedia: MediaType) {
set(url) { var fromMedia = fromMedia
_coverContentUrl = url let fileMap = files.map { ($0.filename ?? "", $0.id) }
} let fileIdByFilename = Dictionary(fileMap, uniquingKeysWith: { (_, last) in last })
get { if ( self.isBook ) {
if let path = self._coverContentUrl { if let tracks = fromMedia.tracks {
return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString for i in tracks.indices {
} else { _ = fromMedia.tracks?[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i)
return nil }
}
} else if ( self.isPodcast ) {
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 = fromMedia
} }
func getDuration() -> Double { func getDuration() -> Double {
@@ -54,7 +59,13 @@ extension LocalLibraryItem {
return total return total
} }
func getPlaybackSession(episode: LocalPodcastEpisode?) -> PlaybackSession { func getPodcastEpisode(episodeId: String?) -> PodcastEpisode? {
guard self.isPodcast else { return nil }
guard let episodes = self.media?.episodes else { return nil }
return episodes.first(where: { $0.id == episodeId })
}
func getPlaybackSession(episode: PodcastEpisode?) -> PlaybackSession {
let localEpisodeId = episode?.id let localEpisodeId = episode?.id
let sessionId = "play_local_\(UUID().uuidString)" let sessionId = "play_local_\(UUID().uuidString)"
@@ -62,13 +73,13 @@ extension LocalLibraryItem {
let mediaProgressId = (localEpisodeId != nil) ? "\(self.id)-\(localEpisodeId!)" : self.id let mediaProgressId = (localEpisodeId != nil) ? "\(self.id)-\(localEpisodeId!)" : self.id
let mediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: mediaProgressId) let mediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: mediaProgressId)
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
let mediaMetadata = self.media?.metadata let mediaMetadata = self.media?.metadata
let audioTracks = self.media?.tracks let chapters = self.media?.chapters
let authorName = mediaMetadata?.authorName var audioTracks = self.media?.tracks
let authorName = mediaMetadata?.authorDisplayName
if let episode = episode { if let episode = episode, let track = episode.audioTrack {
// TODO: Implement podcast audioTracks = [track]
} }
let dateNow = Date().timeIntervalSince1970 let dateNow = Date().timeIntervalSince1970
@@ -78,18 +89,19 @@ extension LocalLibraryItem {
libraryItemId: self.libraryItemId, libraryItemId: self.libraryItemId,
episodeId: episode?.serverEpisodeId, episodeId: episode?.serverEpisodeId,
mediaType: self.mediaType, mediaType: self.mediaType,
chapters: [], chapters: chapters ?? [],
displayTitle: mediaMetadata?.title, displayTitle: mediaMetadata?.title,
displayAuthor: authorName, displayAuthor: authorName,
coverPath: nil, coverPath: self.coverContentUrl,
duration: self.getDuration(), duration: self.getDuration(),
playMethod: 3, playMethod: PlayMethod.local.rawValue,
startedAt: dateNow, startedAt: dateNow,
updatedAt: 0, updatedAt: 0,
timeListening: 0.0, timeListening: 0.0,
audioTracks: [], audioTracks: audioTracks ?? [],
currentTime: mediaProgress?.currentTime ?? 0.0, currentTime: mediaProgress?.currentTime ?? 0.0,
libraryItem: nil, libraryItem: nil,
localLibraryItem: self,
serverConnectionConfigId: self.serverConnectionConfigId, serverConnectionConfigId: self.serverConnectionConfigId,
serverAddress: self.serverAddress serverAddress: self.serverAddress
) )
@@ -103,7 +115,7 @@ extension LocalFile {
self.id = "\(libraryItemId)_\(filename.toBase64())" self.id = "\(libraryItemId)_\(filename.toBase64())"
self.filename = filename self.filename = filename
self.mimeType = mimeType self.mimeType = mimeType
self.contentUrl = localUrl self._contentUrl = localUrl
self.size = fileSize self.size = fileSize
} }
@@ -123,27 +135,84 @@ extension LocalFile {
} }
extension LocalMediaProgress { extension LocalMediaProgress {
convenience init(localLibraryItem: LocalLibraryItem, episode: LocalPodcastEpisode?, progress: MediaProgress) { convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?) {
self.init() self.init()
self.id = localLibraryItem.id self.id = localLibraryItem.id
self.localLibraryItemId = localLibraryItem.id self.localLibraryItemId = localLibraryItem.id
self.libraryItemId = localLibraryItem.libraryItemId self.libraryItemId = localLibraryItem.libraryItemId
if let episode = episode {
self.id += "-\(episode.id)"
self.episodeId = episode.id
}
self.serverAddress = localLibraryItem.serverAddress self.serverAddress = localLibraryItem.serverAddress
self.serverUserId = localLibraryItem.serverUserId self.serverUserId = localLibraryItem.serverUserId
self.serverConnectionConfigId = localLibraryItem.serverConnectionConfigId self.serverConnectionConfigId = localLibraryItem.serverConnectionConfigId
self.duration = progress.duration self.duration = localLibraryItem.getDuration()
self.currentTime = progress.currentTime self.progress = 0.0
self.currentTime = 0.0
self.isFinished = false self.isFinished = false
self.lastUpdate = Int(Date().timeIntervalSince1970)
self.startedAt = 0
self.finishedAt = nil
if let episode = episode {
self.id += "-\(episode.id)"
self.episodeId = episode.id
self.duration = episode.duration ?? 0.0
}
}
init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?, progress: MediaProgress) {
self.init(localLibraryItem: localLibraryItem, episode: episode)
self.duration = progress.duration
self.progress = progress.progress
self.currentTime = progress.currentTime
self.isFinished = progress.isFinished
self.lastUpdate = progress.lastUpdate self.lastUpdate = progress.lastUpdate
self.startedAt = progress.startedAt self.startedAt = progress.startedAt
self.finishedAt = progress.finishedAt self.finishedAt = progress.finishedAt
} }
mutating func updateIsFinished(_ finished: Bool) {
if self.isFinished != finished {
self.progress = finished ? 1.0 : 0.0
}
if self.startedAt == 0 && finished {
self.startedAt = Int(Date().timeIntervalSince1970)
}
self.isFinished = finished
self.lastUpdate = Int(Date().timeIntervalSince1970)
self.finishedAt = finished ? lastUpdate : nil
}
mutating func updateFromPlaybackSession(_ playbackSession: PlaybackSession) {
self.currentTime = playbackSession.currentTime
self.progress = playbackSession.progress
self.lastUpdate = Int(Date().timeIntervalSince1970)
self.isFinished = playbackSession.progress >= 100.0
self.finishedAt = self.isFinished ? self.lastUpdate : nil
}
mutating func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) {
self.isFinished = serverMediaProgress.isFinished
self.progress = serverMediaProgress.progress
self.currentTime = serverMediaProgress.currentTime
self.duration = serverMediaProgress.duration
self.lastUpdate = serverMediaProgress.lastUpdate
self.finishedAt = serverMediaProgress.finishedAt
self.startedAt = serverMediaProgress.startedAt
}
static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) -> LocalMediaProgress? {
if let localMediaProgressId = localMediaProgressId {
return Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId)
} else if let localLibraryItemId = localLibraryItemId {
guard let localLibraryItem = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) else { return nil }
let episode = localLibraryItem.getPodcastEpisode(episodeId: localEpisodeId)
return LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode)
} else {
return nil
}
}
} }
+20 -2
View File
@@ -7,7 +7,7 @@
import Foundation import Foundation
struct PlaybackSession: Decodable, Encodable { struct PlaybackSession: Codable {
var id: String var id: String
var userId: String? var userId: String?
var libraryItemId: String? var libraryItemId: String?
@@ -26,7 +26,25 @@ struct PlaybackSession: Decodable, Encodable {
var audioTracks: [AudioTrack] var audioTracks: [AudioTrack]
var currentTime: Double var currentTime: Double
var libraryItem: LibraryItem? var libraryItem: LibraryItem?
//var localLibraryItem: LocalLibraryItem? var localLibraryItem: LocalLibraryItem?
var serverConnectionConfigId: String? var serverConnectionConfigId: String?
var serverAddress: String? var serverAddress: String?
var isLocal: Bool { self.localLibraryItem != nil }
var localMediaProgressId: String {
if let episodeId = episodeId {
return "\(localLibraryItem!.id)-\(episodeId)"
} else {
return localLibraryItem!.id
}
}
var totalDuration: Double {
var total = 0.0
self.audioTracks.forEach { total += $0.duration }
return total
}
var progress: Double { self.currentTime / self.totalDuration }
} }
+12
View File
@@ -267,6 +267,7 @@ class AudioPlayer: NSObject {
let startOffset = audioTrack.startOffset ?? 0.0 let startOffset = audioTrack.startOffset ?? 0.0
return startOffset + currentTrackTime return startOffset + currentTrackTime
} }
public func getPlayMethod() -> Int { public func getPlayMethod() -> Int {
return self.playbackSession.playMethod return self.playbackSession.playMethod
} }
@@ -286,6 +287,17 @@ class AudioPlayer: NSObject {
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)! let url = URL(string: urlstr)!
return AVURLAsset(url: url) return AVURLAsset(url: url)
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
guard let localFile = track.getLocalFile() else {
// Worst case we can stream the file
NSLog("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
let filename = track.metadata?.filename ?? ""
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
return AVURLAsset(url: url)
}
return AVURLAsset(url: localFile.contentPath)
} else { // HLS Transcode } else { // HLS Transcode
let headers: [String: String] = [ let headers: [String: String] = [
"Authorization": "Bearer \(Store.serverConfig!.token)" "Authorization": "Bearer \(Store.serverConfig!.token)"
+74 -17
View File
@@ -11,7 +11,7 @@ class PlayerHandler {
private static var player: AudioPlayer? private static var player: AudioPlayer?
private static var session: PlaybackSession? private static var session: PlaybackSession?
private static var timer: Timer? private static var timer: Timer?
private static var lastSyncTime:Double = 0.0 private static var lastSyncTime: Double = 0.0
private static var _remainingSleepTime: Int? = nil private static var _remainingSleepTime: Int? = nil
public static var remainingSleepTime: Int? { public static var remainingSleepTime: Int? {
@@ -52,6 +52,22 @@ class PlayerHandler {
} }
} }
public static func startTickTimer() {
DispatchQueue.runOnMainQueue {
NSLog("Starting the tick timer")
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tick()
}
}
}
public static func stopTickTimer() {
NSLog("Stopping the tick timer")
timer?.invalidate()
timer = nil
}
public static func startPlayback(session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) { public static func startPlayback(session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) {
if player != nil { if player != nil {
player?.destroy() player?.destroy()
@@ -63,31 +79,34 @@ class PlayerHandler {
self.session = session self.session = session
player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate) player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
DispatchQueue.runOnMainQueue { startTickTimer()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tick()
}
}
} }
public static func stopPlayback() { public static func stopPlayback() {
// Pause playback first, so we can sync our current progress
player?.pause()
// Stop updating progress before we destory the player, so we don't receive bad data
stopTickTimer()
player?.destroy() player?.destroy()
player = nil player = nil
timer?.invalidate()
timer = nil
NowPlayingInfo.shared.reset() NowPlayingInfo.shared.reset()
} }
public static func getCurrentTime() -> Double? { public static func getCurrentTime() -> Double? {
self.player?.getCurrentTime() self.player?.getCurrentTime()
} }
public static func setPlaybackSpeed(speed: Float) { public static func setPlaybackSpeed(speed: Float) {
self.player?.setPlaybackRate(speed) self.player?.setPlaybackRate(speed)
} }
public static func getPlayMethod() -> Int? { public static func getPlayMethod() -> Int? {
self.player?.getPlayMethod() self.player?.getPlayMethod()
} }
public static func getPlaybackSession() -> PlaybackSession? { public static func getPlaybackSession() -> PlaybackSession? {
self.player?.getPlaybackSession() self.player?.getPlaybackSession()
} }
@@ -100,6 +119,7 @@ class PlayerHandler {
let destinationTime = player.getCurrentTime() + amount let destinationTime = player.getCurrentTime() + amount
player.seek(destinationTime, from: "handler") player.seek(destinationTime, from: "handler")
} }
public static func seekBackward(amount: Double) { public static func seekBackward(amount: Double) {
guard let player = player else { guard let player = player else {
return return
@@ -108,9 +128,11 @@ class PlayerHandler {
let destinationTime = player.getCurrentTime() - amount let destinationTime = player.getCurrentTime() - amount
player.seek(destinationTime, from: "handler") player.seek(destinationTime, from: "handler")
} }
public static func seek(amount: Double) { public static func seek(amount: Double) {
player?.seek(amount, from: "handler") player?.seek(amount, from: "handler")
} }
public static func getMetdata() -> [String: Any] { public static func getMetdata() -> [String: Any] {
DispatchQueue.main.async { DispatchQueue.main.async {
syncProgress() syncProgress()
@@ -140,15 +162,15 @@ class PlayerHandler {
remainingSleepTime! -= 1 remainingSleepTime! -= 1
} }
} }
public static func syncProgress() { public static func syncProgress() {
if session == nil { return } if session == nil { return }
guard let player = player else { return } guard let player = player else { return }
// Prevent a sync at the current time
let playerCurrentTime = player.getCurrentTime() let playerCurrentTime = player.getCurrentTime()
if (lastSyncReport != nil && lastSyncReport?.currentTime == playerCurrentTime) { let hasSyncAtCurrentTime = lastSyncReport?.currentTime.isEqual(to: playerCurrentTime) ?? false
// No need to syncProgress if hasSyncAtCurrentTime { return }
return
}
// Prevent multiple sync requests // Prevent multiple sync requests
let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime
@@ -157,16 +179,51 @@ class PlayerHandler {
return return
} }
lastSyncTime = Date().timeIntervalSince1970 // seconds // Prevent a sync if we got junk data from the player (occurs when exiting out of memory
guard !playerCurrentTime.isNaN else { return }
lastSyncTime = Date().timeIntervalSince1970 // seconds
let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync) let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync)
session!.currentTime = playerCurrentTime session!.currentTime = playerCurrentTime
listeningTimePassedSinceLastSync = 0 listeningTimePassedSinceLastSync = 0
lastSyncReport = report lastSyncReport = report
// TODO: check if online let sessionIsLocal = session!.isLocal
NSLog("sending playback report") if !sessionIsLocal {
ApiClient.reportPlaybackProgress(report: report, sessionId: session!.id) if Connectivity.isConnectedToInternet {
NSLog("sending playback report")
ApiClient.reportPlaybackProgress(report: report, sessionId: session!.id)
}
} else {
if let localMediaProgress = syncLocalProgress() {
if Connectivity.isConnectedToInternet {
ApiClient.reportLocalMediaProgress(localMediaProgress) { success in
NSLog("Synced local media progress: \(success)")
}
}
}
}
}
private static func syncLocalProgress() -> LocalMediaProgress? {
guard let session = session else { return nil }
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: session.localMediaProgressId, localLibraryItemId: session.localLibraryItem?.id, localEpisodeId: session.episodeId)
guard var localMediaProgress = localMediaProgress else {
// Local media progress should have been created
// If we're here, it means a library id is invalid
return nil
}
localMediaProgress.updateFromPlaybackSession(session)
Database.shared.saveLocalMediaProgress(localMediaProgress)
NSLog("Local progress saved to the database")
// Send the local progress back to front-end
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
return localMediaProgress
} }
} }
+121 -4
View File
@@ -37,7 +37,29 @@ class ApiClient {
} }
} }
} }
public static func postResource(endpoint: String, parameters: [String: String], callback: ((_ success: Bool) -> Void)?) {
public static func postResource<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).responseDecodable(of: decodable) { response in
switch response.result {
case .success(let obj):
callback?(obj)
case .failure(let error):
NSLog("api request to \(endpoint) failed")
print(error)
}
}
}
public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
NSLog("Server config not set") NSLog("Server config not set")
callback?(false) callback?(false)
@@ -50,7 +72,7 @@ class ApiClient {
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in
switch response.result { switch response.result {
case .success(let _): case .success(_):
callback?(true) callback?(true)
case .failure(let error): case .failure(let error):
NSLog("api request to \(endpoint) failed") NSLog("api request to \(endpoint) failed")
@@ -60,6 +82,30 @@ class ApiClient {
} }
} }
} }
public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
callback?(false)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .patch, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in
switch response.result {
case .success(_):
callback?(true)
case .failure(let error):
NSLog("api request to \(endpoint) failed")
print(error)
callback?(false)
}
}
}
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) { public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
NSLog("Server config not set") NSLog("Server config not set")
@@ -96,7 +142,7 @@ class ApiClient {
} }
} }
ApiClient.postResource(endpoint: endpoint, parameters: [ let parameters: [String: Any] = [
"forceDirectPlay": !forceTranscode ? "1" : "", "forceDirectPlay": !forceTranscode ? "1" : "",
"forceTranscode": forceTranscode ? "1" : "", "forceTranscode": forceTranscode ? "1" : "",
"mediaPlayer": "AVPlayer", "mediaPlayer": "AVPlayer",
@@ -105,7 +151,8 @@ class ApiClient {
"model": modelCode, "model": modelCode,
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String "clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
] ]
], decodable: PlaybackSession.self) { obj in ]
ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in
var session = obj var session = obj
session.serverConnectionConfigId = Store.serverConfig!.id session.serverConnectionConfigId = Store.serverConfig!.id
@@ -119,6 +166,44 @@ class ApiClient {
try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil) try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil)
} }
public static func reportLocalMediaProgress(_ localMediaProgress: LocalMediaProgress, callback: @escaping (_ success: Bool) -> Void) {
postResource(endpoint: "api/session/local", parameters: localMediaProgress, callback: callback)
}
public static func syncMediaProgress(callback: @escaping (_ results: LocalMediaProgressSyncResultsPayload) -> Void) {
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
$0.serverConnectionConfigId == Store.serverConfig?.id
}
if ( !localMediaProgressList.isEmpty ) {
let payload = LocalMediaProgressSyncPayload(localMediaProgress: localMediaProgressList)
NSLog("Sending sync local progress request with \(localMediaProgressList.count) progress items")
postResource(endpoint: "api/me/sync-local-progress", parameters: payload, decodable: MediaProgressSyncResponsePayload.self) { response in
let resultsPayload = LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: localMediaProgressList.count, numServerProgressUpdates: response.numServerProgressUpdates, numLocalProgressUpdates: response.localProgressUpdates?.count)
NSLog("Media Progress Sync | \(String(describing: try? resultsPayload.asDictionary()))")
if let updates = response.localProgressUpdates {
for update in updates {
Database.shared.saveLocalMediaProgress(update)
}
}
callback(resultsPayload)
}
} else {
NSLog("No local media progress to sync")
callback(LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: 0, numServerProgressUpdates: 0, numLocalProgressUpdates: 0))
}
}
public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) {
NSLog("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
patchResource(endpoint: endpoint, parameters: payload) { success in
callback()
}
}
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) { public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress" var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
if episodeId != nil { if episodeId != nil {
@@ -130,3 +215,35 @@ class ApiClient {
} }
} }
} }
struct LocalMediaProgressSyncPayload: Codable {
var localMediaProgress: [LocalMediaProgress]
}
struct MediaProgressSyncResponsePayload: Decodable {
var numServerProgressUpdates: Int?
var localProgressUpdates: [LocalMediaProgress]?
private enum CodingKeys : String, CodingKey {
case numServerProgressUpdates, localProgressUpdates
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
numServerProgressUpdates = try? values.intOrStringDecoder(key: .numServerProgressUpdates)
localProgressUpdates = try? values.decode([LocalMediaProgress].self, forKey: .localProgressUpdates)
}
}
struct LocalMediaProgressSyncResultsPayload: Codable {
var numLocalMediaProgressForServer: Int?
var numServerProgressUpdates: Int?
var numLocalProgressUpdates: Int?
}
struct Connectivity {
static private let sharedInstance = NetworkReachabilityManager()!
static var isConnectedToInternet:Bool {
return self.sharedInstance.isReachable
}
}
+20 -10
View File
@@ -116,14 +116,14 @@ class Database {
return Array(realm.objects(LocalLibraryItem.self)) return Array(realm.objects(LocalLibraryItem.self))
} }
public func getLocalLibraryItemByLLId(libraryItem: String) -> LocalLibraryItem? { public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? {
let realm = try! Realm() 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() 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) { public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
@@ -131,6 +131,19 @@ class Database {
try! realm.write { realm.add(localLibraryItem, update: .modified) } 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 getLocalFile(localFileId: String) -> LocalFile? {
let realm = try! Realm()
return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId)
}
public func getDownloadItem(downloadItemId: String) -> DownloadItem? { public func getDownloadItem(downloadItemId: String) -> DownloadItem? {
let realm = try! Realm() let realm = try! Realm()
return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId)
@@ -166,17 +179,14 @@ class Database {
return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
} }
public func removeLocalLibraryItem(localLibraryItemId: String) { public func getAllLocalMediaProgress() -> [LocalMediaProgress] {
let realm = try! Realm() let realm = try! Realm()
try! realm.write { return Array(realm.objects(LocalMediaProgress.self))
let item = getLocalLibraryItemByLLId(libraryItem: localLibraryItemId)
realm.delete(item!)
}
} }
public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) { public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) {
let realm = try! Realm() 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}" // For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
+30 -1
View File
@@ -6,8 +6,9 @@
// //
import Foundation import Foundation
import SwiftUI
import RealmSwift import RealmSwift
import Capacitor
import CoreMedia
extension String: Error {} extension String: Error {}
@@ -31,6 +32,34 @@ extension Collection where Iterator.Element: Encodable {
} }
} }
extension KeyedDecodingContainer {
func doubleOrStringDecoder(key: KeyedDecodingContainer<K>.Key) throws -> Double {
do {
return try decode(Double.self, forKey: key)
} catch {
let stringValue = try decode(String.self, forKey: key)
return Double(stringValue) ?? 0.0
}
}
func intOrStringDecoder(key: KeyedDecodingContainer<K>.Key) throws -> Int {
do {
return try decode(Int.self, forKey: key)
} catch {
let stringValue = try decode(String.self, forKey: key)
return Int(stringValue) ?? 0
}
}
}
extension CAPPluginCall {
func getJson<T: Decodable>(_ key: String, type: T.Type) -> T? {
guard let value = getObject(key) else { return nil }
guard let json = try? JSONSerialization.data(withJSONObject: value) else { return nil }
return try? JSONDecoder().decode(type, from: json)
}
}
extension DispatchQueue { extension DispatchQueue {
static func runOnMainQueue(callback: @escaping (() -> Void)) { static func runOnMainQueue(callback: @escaping (() -> Void)) {
if Thread.isMainThread { if Thread.isMainThread {
+25 -10
View File
@@ -15,6 +15,10 @@ struct NowPlayingMetadata {
var title: String var title: String
var author: String? var author: String?
var series: String? var series: String?
var coverUrl: URL? {
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(itemId)/cover?token=\(Store.serverConfig!.token)") else { return nil }
return url
}
} }
class NowPlayingInfo { class NowPlayingInfo {
@@ -30,18 +34,27 @@ class NowPlayingInfo {
public func setSessionMetadata(metadata: NowPlayingMetadata) { public func setSessionMetadata(metadata: NowPlayingMetadata) {
setMetadata(artwork: nil, metadata: metadata) setMetadata(artwork: nil, metadata: metadata)
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else { let isLocalItem = metadata.itemId.starts(with: "local_")
return if isLocalItem {
} guard let artworkUrl = metadata.artworkUrl else { return }
ApiClient.getData(from: url) { [self] image in let coverImage = UIImage(contentsOfFile: artworkUrl)
guard let downloadedImage = image else { guard let coverImage = coverImage else { return }
return let artwork = MPMediaItemArtwork(boundsSize: coverImage.size) { _ -> UIImage in
return coverImage
} }
let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
return downloadedImage
})
self.setMetadata(artwork: artwork, metadata: metadata) self.setMetadata(artwork: artwork, metadata: metadata)
} else {
guard let url = metadata.coverUrl else { return }
ApiClient.getData(from: url) { [self] image in
guard let downloadedImage = image else {
return
}
let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
return downloadedImage
})
self.setMetadata(artwork: artwork, metadata: metadata)
}
} }
} }
public func update(duration: Double, currentTime: Double, rate: Float) { public func update(duration: Double, currentTime: Double, rate: Float) {
@@ -52,6 +65,7 @@ class NowPlayingInfo {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
} }
public func reset() { public func reset() {
nowPlayingInfo = [:] nowPlayingInfo = [:]
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
@@ -76,6 +90,7 @@ class NowPlayingInfo {
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown" nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
} }
private func shouldFetchCover(id: String) -> Bool { private func shouldFetchCover(id: String) -> Bool {
nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil
} }
+1
View File
@@ -13,4 +13,5 @@ enum PlayerEvents: String {
case sleepSet = "com.audiobookshelf.app.player.sleep.set" case sleepSet = "com.audiobookshelf.app.player.sleep.set"
case sleepEnded = "com.audiobookshelf.app.player.sleep.ended" case sleepEnded = "com.audiobookshelf.app.player.sleep.ended"
case failed = "com.audiobookshelf.app.player.failed" case failed = "com.audiobookshelf.app.player.failed"
case localProgress = "com.audiobookshelf.app.player.localProgress"
} }
+17 -24
View File
@@ -11,7 +11,7 @@
<span class="material-icons" @click="showItemDialog">more_vert</span> <span class="material-icons" @click="showItemDialog">more_vert</span>
</div> </div>
<p class="px-2 text-sm mb-0.5 text-white text-opacity-75">Folder: {{ folderName }}</p> <p v-if="!isIos" class="px-2 text-sm mb-0.5 text-white text-opacity-75">Folder: {{ folderName }}</p>
<p class="px-2 mb-4 text-xs text-gray-400">{{ libraryItemId ? 'Linked to item on server ' + liServerAddress : 'Not linked to server item' }}</p> <p class="px-2 mb-4 text-xs text-gray-400">{{ libraryItemId ? 'Linked to item on server ' + liServerAddress : 'Not linked to server item' }}</p>
@@ -22,11 +22,11 @@
<div v-if="!isPodcast" class="w-full"> <div v-if="!isPodcast" class="w-full">
<p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p> <p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p>
<draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate"> <draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate" :disabled="isIos">
<transition-group type="transition" :name="!drag ? 'dragtrack' : null"> <transition-group type="transition" :name="!drag ? 'dragtrack' : null">
<template v-for="track in audioTracksCopy"> <template v-for="track in audioTracksCopy">
<div :key="track.localFileId" class="flex items-center my-1 item"> <div :key="track.localFileId" class="flex items-center my-1 item">
<div class="w-8 h-12 flex items-center justify-center" style="min-width: 32px"> <div v-if="!isIos" class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span> <span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
</div> </div>
<div class="w-8 h-12 flex items-center justify-center" style="min-width: 32px"> <div class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
@@ -39,7 +39,7 @@
<p class="text-xs">{{ track.mimeType }}</p> <p class="text-xs">{{ track.mimeType }}</p>
<p class="text-sm">{{ $elapsedPretty(track.duration) }}</p> <p class="text-sm">{{ $elapsedPretty(track.duration) }}</p>
</div> </div>
<div class="w-12 h-12 flex items-center justify-center" style="min-width: 48px"> <div v-if="!isIos" class="w-12 h-12 flex items-center justify-center" style="min-width: 48px">
<span class="material-icons" @click="showTrackDialog(track)">more_vert</span> <span class="material-icons" @click="showTrackDialog(track)">more_vert</span>
</div> </div>
</div> </div>
@@ -138,6 +138,9 @@ export default {
} }
}, },
computed: { computed: {
isIos() {
return this.$platform === 'ios'
},
basePath() { basePath() {
return this.localLibraryItem ? this.localLibraryItem.basePath : null return this.localLibraryItem ? this.localLibraryItem.basePath : null
}, },
@@ -194,24 +197,14 @@ export default {
} }
] ]
} else { } else {
return [ var options = []
{ if ( !this.isIos ) {
text: 'Scan', options.push({ text: 'Scan', value: 'scan'})
value: 'scan' options.push({ text: 'Force Re-Scan', value: 'rescan'})
}, options.push({ text: 'Remove', value: 'remove'})
{ }
text: 'Force Re-Scan', options.push({ text: 'Remove & Delete Files', value: 'delete'})
value: 'rescan' return options
},
{
text: 'Remove',
value: 'remove'
},
{
text: 'Remove & Delete Files',
value: 'delete'
}
]
} }
} }
}, },
@@ -329,13 +322,13 @@ export default {
async deleteItem() { async deleteItem() {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: '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) { if (value) {
var res = await AbsFileSystem.deleteItem(this.localLibraryItem) var res = await AbsFileSystem.deleteItem(this.localLibraryItem)
if (res && res.success) { if (res && res.success) {
this.$toast.success('Deleted Successfully') 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') } else this.$toast.error('Failed to delete')
} }
}, },