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
+59 -14
View File
@@ -264,7 +264,7 @@ class PodcastEpisode: Object, Codable {
@Persisted var audioTrack: AudioTrack?
@Persisted var duration: Double = 0
@Persisted var size: Int = 0
// var serverEpisodeId: String?
var serverEpisodeId: String { self.id }
private enum CodingKeys : String, CodingKey {
case id,
@@ -277,14 +277,44 @@ class PodcastEpisode: Object, Codable {
audioFile,
audioTrack,
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 {
@Persisted var index: Int = 0
@Persisted var index: Int?
@Persisted var ino: String = ""
@Persisted var metadata: FileMetadata?
@@ -299,7 +329,7 @@ class AudioFile: Object, Codable {
required init(from decoder: Decoder) throws {
super.init()
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)
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(coverPath, forKey: .coverPath)
}
override init() {
super.init()
}
class Chapter: Object, Codable {
@@ -381,8 +414,7 @@ class AudioTrack: Object, Codable {
@Persisted var contentUrl: String?
@Persisted var mimeType: String = ""
@Persisted var metadata: FileMetadata?
// var isLocal: Bool
// var localFileId: String?
var localFileId: String?
// var audioProbeResult: AudioProbeResult? Needed for local playback
@Persisted var serverIndex: Int?
@@ -418,6 +450,20 @@ class AudioTrack: Object, Codable {
try container.encode(metadata, forKey: .metadata)
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 {
@@ -562,18 +608,17 @@ class MediaProgress: Object, Codable {
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
libraryItemId = try values.decode(String.self, forKey: .libraryItemId)
episodeId = try? values.decode(String.self, forKey: .episodeId)
duration = try values.decode(Double.self, forKey: .duration)
progress = try values.decode(Double.self, forKey: .progress)
currentTime = try values.decode(Double.self, forKey: .currentTime)
duration = try values.doubleOrStringDecoder(key: .duration)
progress = try values.doubleOrStringDecoder(key: .progress)
currentTime = try values.doubleOrStringDecoder(key: .currentTime)
isFinished = try values.decode(Bool.self, forKey: .isFinished)
lastUpdate = try values.decode(Int.self, forKey: .lastUpdate)
startedAt = try values.decode(Int.self, forKey: .startedAt)
finishedAt = try? values.decode(Int.self, forKey: .finishedAt)
lastUpdate = try values.intOrStringDecoder(key: .lastUpdate)
startedAt = try values.intOrStringDecoder(key: .startedAt)
finishedAt = try? values.intOrStringDecoder(key: .finishedAt)
}
func encode(to encoder: Encoder) throws {
+62
View File
@@ -73,6 +73,35 @@ class DownloadItemPart: Object, Codable {
@Persisted var uri: String?
@Persisted var destinationUri: String?
@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!
private enum CodingKeys : String, CodingKey {
@@ -105,3 +134,36 @@ class DownloadItemPart: Object, Codable {
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
@@ -22,6 +22,33 @@ class LocalLibraryItem: Object, Codable {
@Persisted var serverAddress: String?
@Persisted var serverUserId: 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 {
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)
id = try values.decode(String.self, forKey: .id)
basePath = try values.decode(String.self, forKey: .basePath)
contentUrl = try values.decode(String.self, forKey: .contentUrl)
isInvalid = try values.decode(Bool.self, forKey: .isInvalid)
mediaType = try values.decode(String.self, forKey: .mediaType)
media = try? values.decode(MediaType.self, forKey: .media)
if let files = try? values.decode([LocalFile].self, forKey: .localFiles) {
localFiles.append(objectsIn: files)
}
_coverContentUrl = try values.decode(String.self, forKey: .coverContentUrl)
isLocal = try values.decode(Bool.self, forKey: .isLocal)
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
serverAddress = try? values.decode(String.self, forKey: .serverAddress)
@@ -111,9 +136,13 @@ class LocalFile: Object, Codable {
@Persisted var contentUrl: String = ""
@Persisted var mimeType: String?
@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 {
case id, filename, contentUrl, absolutePath, mimeType, size
case id, filename, contentUrl, mimeType, size, basePath
}
override init() {
@@ -124,7 +153,6 @@ class LocalFile: Object, Codable {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
filename = try? values.decode(String.self, forKey: .filename)
contentUrl = try values.decode(String.self, forKey: .contentUrl)
mimeType = try? values.decode(String.self, forKey: .mimeType)
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(filename, forKey: .filename)
try container.encode(contentUrl, forKey: .contentUrl)
try container.encode(absolutePath, forKey: .absolutePath)
try container.encode(mimeType, forKey: .mimeType)
try container.encode(size, forKey: .size)
try container.encode(basePath, forKey: .basePath)
}
}
@@ -157,6 +185,8 @@ class LocalMediaProgress: Object, Codable {
@Persisted var serverUserId: String?
@Persisted(indexed: true) var libraryItemId: String?
@Persisted(indexed: true) var episodeId: String?
var progressPercent: Int { Int(self.progress * 100) }
private enum CodingKeys : String, CodingKey {
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.mediaType = item.mediaType
self.media = item.media
self.localFiles.append(objectsIn: files)
self.coverContentUrl = coverPath
self._coverContentUrl = coverPath
self.libraryItemId = item.id
self.serverConnectionConfigId = server.id
self.serverAddress = server.address
self.serverUserId = server.userId
// Link the audio tracks and files
linkLocalFiles(files, fromMedia: item.media)
}
var contentUrl: String? {
set(url) {
_contentUrl = url
}
get {
if let path = _contentUrl {
return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString
} else {
return nil
}
}
mutating func addFiles(_ files: [LocalFile], item: LibraryItem) throws {
guard self.isPodcast else { throw LibraryItemDownloadError.podcastOnlySupported }
self.localFiles.append(contentsOf: files.filter({ $0.isAudioFile() }))
linkLocalFiles(self.localFiles, fromMedia: item.media)
}
var coverContentUrl: String? {
set(url) {
_coverContentUrl = url
}
get {
if let path = self._coverContentUrl {
return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString
} else {
return nil
mutating private func linkLocalFiles(_ files: [LocalFile], fromMedia: MediaType) {
var fromMedia = fromMedia
let fileMap = files.map { ($0.filename ?? "", $0.id) }
let fileIdByFilename = Dictionary(fileMap, uniquingKeysWith: { (_, last) in last })
if ( self.isBook ) {
if let tracks = fromMedia.tracks {
for i in tracks.indices {
_ = fromMedia.tracks?[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i)
}
}
} 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 {
@@ -54,7 +59,13 @@ extension LocalLibraryItem {
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 sessionId = "play_local_\(UUID().uuidString)"
@@ -62,13 +73,13 @@ extension LocalLibraryItem {
let mediaProgressId = (localEpisodeId != nil) ? "\(self.id)-\(localEpisodeId!)" : self.id
let mediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: mediaProgressId)
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
let mediaMetadata = self.media?.metadata
let audioTracks = self.media?.tracks
let authorName = mediaMetadata?.authorName
let chapters = self.media?.chapters
var audioTracks = self.media?.tracks
let authorName = mediaMetadata?.authorDisplayName
if let episode = episode {
// TODO: Implement podcast
if let episode = episode, let track = episode.audioTrack {
audioTracks = [track]
}
let dateNow = Date().timeIntervalSince1970
@@ -78,18 +89,19 @@ extension LocalLibraryItem {
libraryItemId: self.libraryItemId,
episodeId: episode?.serverEpisodeId,
mediaType: self.mediaType,
chapters: [],
chapters: chapters ?? [],
displayTitle: mediaMetadata?.title,
displayAuthor: authorName,
coverPath: nil,
coverPath: self.coverContentUrl,
duration: self.getDuration(),
playMethod: 3,
playMethod: PlayMethod.local.rawValue,
startedAt: dateNow,
updatedAt: 0,
timeListening: 0.0,
audioTracks: [],
audioTracks: audioTracks ?? [],
currentTime: mediaProgress?.currentTime ?? 0.0,
libraryItem: nil,
localLibraryItem: self,
serverConnectionConfigId: self.serverConnectionConfigId,
serverAddress: self.serverAddress
)
@@ -103,7 +115,7 @@ extension LocalFile {
self.id = "\(libraryItemId)_\(filename.toBase64())"
self.filename = filename
self.mimeType = mimeType
self.contentUrl = localUrl
self._contentUrl = localUrl
self.size = fileSize
}
@@ -123,27 +135,84 @@ extension LocalFile {
}
extension LocalMediaProgress {
convenience init(localLibraryItem: LocalLibraryItem, episode: LocalPodcastEpisode?, progress: MediaProgress) {
convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?) {
self.init()
self.id = localLibraryItem.id
self.localLibraryItemId = localLibraryItem.id
self.libraryItemId = localLibraryItem.libraryItemId
if let episode = episode {
self.id += "-\(episode.id)"
self.episodeId = episode.id
}
self.serverAddress = localLibraryItem.serverAddress
self.serverUserId = localLibraryItem.serverUserId
self.serverConnectionConfigId = localLibraryItem.serverConnectionConfigId
self.duration = progress.duration
self.currentTime = progress.currentTime
self.duration = localLibraryItem.getDuration()
self.progress = 0.0
self.currentTime = 0.0
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.startedAt = progress.startedAt
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
struct PlaybackSession: Decodable, Encodable {
struct PlaybackSession: Codable {
var id: String
var userId: String?
var libraryItemId: String?
@@ -26,7 +26,25 @@ struct PlaybackSession: Decodable, Encodable {
var audioTracks: [AudioTrack]
var currentTime: Double
var libraryItem: LibraryItem?
//var localLibraryItem: LocalLibraryItem?
var localLibraryItem: LocalLibraryItem?
var serverConnectionConfigId: 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
return startOffset + currentTrackTime
}
public func getPlayMethod() -> Int {
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 url = URL(string: urlstr)!
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
let headers: [String: String] = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
+74 -17
View File
@@ -11,7 +11,7 @@ class PlayerHandler {
private static var player: AudioPlayer?
private static var session: PlaybackSession?
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
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) {
if player != nil {
player?.destroy()
@@ -63,31 +79,34 @@ class PlayerHandler {
self.session = session
player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
DispatchQueue.runOnMainQueue {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tick()
}
}
startTickTimer()
}
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 = nil
timer?.invalidate()
timer = nil
NowPlayingInfo.shared.reset()
}
public static func getCurrentTime() -> Double? {
self.player?.getCurrentTime()
}
public static func setPlaybackSpeed(speed: Float) {
self.player?.setPlaybackRate(speed)
}
public static func getPlayMethod() -> Int? {
self.player?.getPlayMethod()
}
public static func getPlaybackSession() -> PlaybackSession? {
self.player?.getPlaybackSession()
}
@@ -100,6 +119,7 @@ class PlayerHandler {
let destinationTime = player.getCurrentTime() + amount
player.seek(destinationTime, from: "handler")
}
public static func seekBackward(amount: Double) {
guard let player = player else {
return
@@ -108,9 +128,11 @@ class PlayerHandler {
let destinationTime = player.getCurrentTime() - amount
player.seek(destinationTime, from: "handler")
}
public static func seek(amount: Double) {
player?.seek(amount, from: "handler")
}
public static func getMetdata() -> [String: Any] {
DispatchQueue.main.async {
syncProgress()
@@ -140,15 +162,15 @@ class PlayerHandler {
remainingSleepTime! -= 1
}
}
public static func syncProgress() {
if session == nil { return }
guard let player = player else { return }
// Prevent a sync at the current time
let playerCurrentTime = player.getCurrentTime()
if (lastSyncReport != nil && lastSyncReport?.currentTime == playerCurrentTime) {
// No need to syncProgress
return
}
let hasSyncAtCurrentTime = lastSyncReport?.currentTime.isEqual(to: playerCurrentTime) ?? false
if hasSyncAtCurrentTime { return }
// Prevent multiple sync requests
let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime
@@ -157,16 +179,51 @@ class PlayerHandler {
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)
session!.currentTime = playerCurrentTime
listeningTimePassedSinceLastSync = 0
lastSyncReport = report
// TODO: check if online
NSLog("sending playback report")
ApiClient.reportPlaybackProgress(report: report, sessionId: session!.id)
let sessionIsLocal = session!.isLocal
if !sessionIsLocal {
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) {
NSLog("Server config not set")
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
switch response.result {
case .success(let _):
case .success(_):
callback?(true)
case .failure(let error):
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)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
@@ -96,7 +142,7 @@ class ApiClient {
}
}
ApiClient.postResource(endpoint: endpoint, parameters: [
let parameters: [String: Any] = [
"forceDirectPlay": !forceTranscode ? "1" : "",
"forceTranscode": forceTranscode ? "1" : "",
"mediaPlayer": "AVPlayer",
@@ -105,7 +151,8 @@ class ApiClient {
"model": modelCode,
"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
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)
}
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) {
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
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))
}
public func getLocalLibraryItemByLLId(libraryItem: String) -> LocalLibraryItem? {
public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? {
let realm = try! Realm()
return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == libraryItem })
return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId })
}
public func getLocalLibraryItem(localLibraryItem: String) -> LocalLibraryItem? {
public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? {
let realm = try! Realm()
return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItem)
return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId)
}
public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
@@ -131,6 +131,19 @@ class Database {
try! realm.write { realm.add(localLibraryItem, update: .modified) }
}
public func removeLocalLibraryItem(localLibraryItemId: String) {
let realm = try! Realm()
try! realm.write {
let item = getLocalLibraryItem(localLibraryItemId: localLibraryItemId)
realm.delete(item!)
}
}
public func getLocalFile(localFileId: String) -> LocalFile? {
let realm = try! Realm()
return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId)
}
public func getDownloadItem(downloadItemId: String) -> DownloadItem? {
let realm = try! Realm()
return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId)
@@ -166,17 +179,14 @@ class Database {
return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
}
public func removeLocalLibraryItem(localLibraryItemId: String) {
public func getAllLocalMediaProgress() -> [LocalMediaProgress] {
let realm = try! Realm()
try! realm.write {
let item = getLocalLibraryItemByLLId(libraryItem: localLibraryItemId)
realm.delete(item!)
}
return Array(realm.objects(LocalMediaProgress.self))
}
public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) {
let realm = try! Realm()
try! realm.write { realm.add(mediaProgress) }
try! realm.write { realm.add(mediaProgress, update: .modified) }
}
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
+30 -1
View File
@@ -6,8 +6,9 @@
//
import Foundation
import SwiftUI
import RealmSwift
import Capacitor
import CoreMedia
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 {
static func runOnMainQueue(callback: @escaping (() -> Void)) {
if Thread.isMainThread {
+25 -10
View File
@@ -15,6 +15,10 @@ struct NowPlayingMetadata {
var title: String
var author: 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 {
@@ -30,18 +34,27 @@ class NowPlayingInfo {
public func setSessionMetadata(metadata: NowPlayingMetadata) {
setMetadata(artwork: nil, metadata: metadata)
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else {
return
}
ApiClient.getData(from: url) { [self] image in
guard let downloadedImage = image else {
return
let isLocalItem = metadata.itemId.starts(with: "local_")
if isLocalItem {
guard let artworkUrl = metadata.artworkUrl else { return }
let coverImage = UIImage(contentsOfFile: artworkUrl)
guard let coverImage = coverImage else { 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)
} 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) {
@@ -52,6 +65,7 @@ class NowPlayingInfo {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
public func reset() {
nowPlayingInfo = [:]
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
@@ -76,6 +90,7 @@ class NowPlayingInfo {
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
}
private func shouldFetchCover(id: String) -> Bool {
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 sleepEnded = "com.audiobookshelf.app.player.sleep.ended"
case failed = "com.audiobookshelf.app.player.failed"
case localProgress = "com.audiobookshelf.app.player.localProgress"
}