feat: usable logs on iOS

- enable logs page on iOS
- centralize all logging
- wrap long identifiers in logs UI
- update editorconfig for swift files
- update podfile
This commit is contained in:
Clay Jensen-Reimann
2025-09-07 16:31:59 -05:00
parent 00ee7b662e
commit b773ec7690
21 changed files with 431 additions and 211 deletions
+17
View File
@@ -0,0 +1,17 @@
//
// LogEntry.swift
// Audiobookshelf
//
// Created by Christopher Jensen-Reimann on 9/3/25.
//
import Foundation
import RealmSwift
class LogEntry: Object, Codable {
@Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted var tag: String = ""
@Persisted var level: String = ""
@Persisted var message: String = ""
@Persisted var timestamp: Int = 0
}
+25 -28
View File
@@ -26,7 +26,6 @@ enum PlayerStatus: Int {
class AudioPlayer: NSObject {
internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue")
internal let logger = AppLogger(category: "AudioPlayer")
private var status: PlayerStatus
internal var rateManager: AudioPlayerRateManager
@@ -81,7 +80,7 @@ class AudioPlayer: NSObject {
let playbackSession = self.getPlaybackSession()
guard let playbackSession = playbackSession else {
logger.error("Failed to fetch playback session. Player will not initialize")
AbsLogger.error(message:"Failed to fetch playback session. Player will not initialize")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
return
}
@@ -115,10 +114,10 @@ class AudioPlayer: NSObject {
}
self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
logger.log("Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
AbsLogger.info(message:"Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count]
logger.log("Setting player items \(playerItems.count)")
AbsLogger.info(message:"Setting player items \(playerItems.count)")
for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
@@ -128,7 +127,7 @@ class AudioPlayer: NSObject {
setupQueueObserver()
setupQueueItemStatusObserver()
logger.log("Audioplayer ready")
AbsLogger.info(message:"Audioplayer ready")
}
deinit {
@@ -146,8 +145,7 @@ class AudioPlayer: NSObject {
do {
try AVAudioSession.sharedInstance().setActive(false)
} catch {
logger.error("Failed to set AVAudioSession inactive")
logger.error(error)
AbsLogger.error(message: "Failed to set AVAudioSession inactive", error: error)
}
self.removeAudioSessionNotifications()
@@ -253,14 +251,14 @@ class AudioPlayer: NSObject {
self.audioPlayer.currentItem.map { item in
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
if (self.currentTrackIndex != prevTrackIndex) {
self.logger.log("New Current track index \(self.currentTrackIndex)")
AbsLogger.info(message:"New Current track index \(self.currentTrackIndex)")
}
}
}
}
private func setupQueueItemStatusObserver() {
logger.log("queueStatusObserver: Setting up")
AbsLogger.info(message:"queueStatusObserver: Setting up")
// Listen for player item updates
self.queueItemStatusObserver?.invalidate()
@@ -275,13 +273,13 @@ class AudioPlayer: NSObject {
}
private func handleQueueItemStatus(playerItem: AVPlayerItem) {
logger.log("queueStatusObserver: Current item status changed")
AbsLogger.info(message:"queueStatusObserver: Current item status changed")
guard let playbackSession = self.getPlaybackSession() else {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
return
}
if (playerItem.status == .readyToPlay) {
logger.log("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
AbsLogger.info(message:"queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
let firstReady = self.status == .uninitialized
@@ -300,7 +298,7 @@ class AudioPlayer: NSObject {
self.status = .paused
}
} else if (playerItem.status == .failed) {
logger.error("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
AbsLogger.error(message:"queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
}
}
@@ -332,7 +330,7 @@ class AudioPlayer: NSObject {
}
private func resumePlayback() {
logger.log("PLAY: Resuming playback")
AbsLogger.info(message:"PLAY: Resuming playback")
self.markAudioSessionAs(active: true)
DispatchQueue.runOnMainQueue {
@@ -351,7 +349,7 @@ class AudioPlayer: NSObject {
public func pause() {
guard self.isInitialized() else { return }
logger.log("PAUSE: Pausing playback")
AbsLogger.info(message:"PAUSE: Pausing playback")
DispatchQueue.runOnMainQueue {
self.audioPlayer.pause()
}
@@ -371,7 +369,7 @@ class AudioPlayer: NSObject {
public func startFadeOut() {
guard self.isInitialized() else { return }
guard let currentTime = self.getCurrentTime() else { return }
logger.log("fadeOut: Fading out playback")
AbsLogger.info(message:"fadeOut: Fading out playback")
// Define fade parameters.
let fadeDuration: Float = 60.0 // total fade duration in seconds
@@ -409,7 +407,7 @@ class AudioPlayer: NSObject {
// Ensure volume is exactly zero and end fade.
self.audioPlayer.volume = targetVolume
t.invalidate()
self.logger.log("Fadeout: Fade complete, pausing playback")
AbsLogger.info(message:"Fadeout: Fade complete, pausing playback")
self.pause()
self.audioPlayer.volume = initialVolume
self.seek(currentTime, from: "fadeOut")
@@ -419,12 +417,12 @@ class AudioPlayer: NSObject {
}
public func seek(_ to: Double, from: String) {
logger.log("SEEK: Seek to \(to) from \(from)")
AbsLogger.info(message:"SEEK: Seek to \(to) from \(from)")
guard let playbackSession = self.getPlaybackSession() else { return }
let indexOfSeek = getItemIndexForTime(time: to)
logger.log("SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
AbsLogger.info(message:"SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
if self.audioPlayer.currentItem == nil {
self.currentTrackIndex = indexOfSeek
@@ -488,16 +486,16 @@ class AudioPlayer: NSObject {
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
let ctso = currentTrack.startOffset ?? 0.0
let trackEnd = ctso + currentTrack.duration
logger.log("SEEK: Seeking in current item \(to) (track START = \(ctso) END = \(trackEnd))")
AbsLogger.info(message:"SEEK: Seeking in current item \(to) (track START = \(ctso) END = \(trackEnd))")
let boundedTime = min(max(to, ctso), trackEnd)
let seekTime = boundedTime - ctso
DispatchQueue.runOnMainQueue {
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in
self?.logger.log("SEEK: Completion handler called")
AbsLogger.info(message:"SEEK: Completion handler called")
guard completed else {
self?.logger.log("SEEK: WARNING: seeking not completed (to \(seekTime)")
AbsLogger.info(message:"SEEK: WARNING: seeking not completed (to \(seekTime)")
return
}
guard let self = self else { return }
@@ -574,7 +572,7 @@ class AudioPlayer: NSObject {
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
guard let localFile = track.getLocalFile() else {
// Worst case we can stream the file
logger.log("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
AbsLogger.info(message:"Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
return AVURLAsset(url: url)
@@ -594,8 +592,7 @@ class AudioPlayer: NSObject {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
} catch {
logger.error("Failed to set AVAudioSession category")
logger.error(error)
AbsLogger.error(message: "Failed to set AVAudioSession category", error: error)
}
}
@@ -603,7 +600,7 @@ class AudioPlayer: NSObject {
do {
try AVAudioSession.sharedInstance().setActive(active)
} catch {
logger.error("Failed to set audio session as active=\(active)")
AbsLogger.error(message:"Failed to set audio session as active=\(active)")
}
}
@@ -620,7 +617,7 @@ class AudioPlayer: NSObject {
let reasonValue = userInfo[AVAudioSessionInterruptionReasonKey] as? UInt ?? 0
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
if (reason == .appWasSuspended) {
logger.log("AVAudioSession was suspended")
AbsLogger.info(message:"AVAudioSession was suspended")
return
}
}
@@ -812,14 +809,14 @@ class AudioPlayer: NSObject {
if context == &playerContext {
if keyPath == #keyPath(AVPlayer.currentItem) {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
logger.log("WARNING: Item ended")
AbsLogger.info(message:"WARNING: Item ended")
if audioPlayer.currentItem == nil {
// if the queue is rebuilding, we expect the current item may be nil
if self.isRebuildingQueue {
return
}
logger.log("Player ended or next item is nil, marking ended")
AbsLogger.info(message:"Player ended or next item is nil, marking ended")
self.markAudioSessionAs(active: false)
}
}
@@ -31,7 +31,7 @@ extension AudioPlayer {
}
public func setSleepTimer(secondsUntilSleep: Double) {
logger.log("SLEEP TIMER: Sleeping in \(secondsUntilSleep) seconds")
AbsLogger.info(message: "SLEEP TIMER: Sleeping in \(secondsUntilSleep) seconds")
self.removeSleepTimer()
self.sleepTimeRemaining = secondsUntilSleep
@@ -53,7 +53,7 @@ extension AudioPlayer {
guard let currentTime = self.getCurrentTime() else { return }
guard stopAt >= currentTime else { return }
logger.log("SLEEP TIMER: Scheduling for chapter end \(stopAt)")
AbsLogger.info(message: "SLEEP TIMER: Scheduling for chapter end \(stopAt)")
// Schedule the observation time
self.sleepTimeChapterStopAt = stopAt
@@ -138,7 +138,7 @@ extension AudioPlayer {
}
private func handleSleepEnd() {
logger.log("SLEEP TIMER: Pausing audio")
AbsLogger.info(message: "SLEEP TIMER: Pausing audio")
self.pause()
self.removeSleepTimer()
}
@@ -10,8 +10,7 @@ import AVFoundation
@available(iOS 16.0, *)
class DefaultedAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
internal let logger = AppLogger(category: "DefaultedAudioPlayerRateManager")
internal var audioPlayer: AVPlayer
// MARK: - AudioPlayerRateManager
@@ -9,7 +9,6 @@ import Foundation
import AVFoundation
class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
internal let logger = AppLogger(category: "AudioPlayer")
internal var audioPlayer: AVPlayer
@@ -54,7 +53,7 @@ class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate && !(observed && rate == 1)
if self.audioPlayer.rate != rate {
logger.log("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
AbsLogger.info(message: "setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
DispatchQueue.runOnMainQueue {
self.audioPlayer.rate = rate
}
@@ -73,7 +72,7 @@ class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &managerContext {
if keyPath == #keyPath(AVPlayer.rate) {
logger.log("playerContext observer player rate")
AbsLogger.info(message: "playerContext observer player rate")
self.handlePlaybackRateChange(change?[.newKey] as? Float ?? 1.0, observed: true)
}
} else {
+10 -13
View File
@@ -14,8 +14,6 @@ class PlayerProgress {
private static var TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 15.0
private let logger = AppLogger(category: "PlayerProgress")
private init() {}
@@ -30,8 +28,7 @@ class PlayerProgress {
try await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping)
}
} catch {
logger.error("Failed to syncFromPlayer")
logger.error(error)
AbsLogger.error(message: "Failed to syncFromPlayer \(error)", error: error)
}
await UIApplication.shared.endBackgroundTask(backgroundToken)
}
@@ -76,7 +73,7 @@ class PlayerProgress {
try localMediaProgress.updateFromPlaybackSession(session)
logger.log("Local progress saved to the database")
AbsLogger.info(message:"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)
@@ -126,7 +123,7 @@ class PlayerProgress {
session = session.freeze()
guard safeToSync else { return }
logger.log("Sending sessionId(\(session.id)) to server with currentTime(\(session.currentTime))")
AbsLogger.info(message:"Sending sessionId(\(session.id)) to server with currentTime(\(session.currentTime))")
var success = false
if session.isLocal {
@@ -155,25 +152,25 @@ class PlayerProgress {
// TODO: Unused for now
private func updateLocalSessionFromServerMediaProgress() async throws {
logger.log("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
guard let session = try Realm(queue: nil).objects(PlaybackSession.self).last(where: {
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
})?.freeze() else {
logger.log("updateLocalSessionFromServerMediaProgress: Failed to get session")
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Failed to get session")
return
}
// Fetch the current progress
let progress = await ApiClient.getMediaProgress(libraryItemId: session.libraryItemId!, episodeId: session.episodeId)
guard let progress = progress else {
logger.log("updateLocalSessionFromServerMediaProgress: No progress object")
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: No progress object")
return
}
// Determine which session is newer
let serverLastUpdate = progress.lastUpdate
guard let localLastUpdate = session.updatedAt else {
logger.log("updateLocalSessionFromServerMediaProgress: No local session updatedAt")
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: No local session updatedAt")
return
}
let serverCurrentTime = progress.currentTime
@@ -184,16 +181,16 @@ class PlayerProgress {
// Update the session, if needed
if serverIsNewerThanLocal && currentTimeIsDifferent {
logger.log("updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)")
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)")
guard let session = session.thaw() else { return }
try session.update {
session.currentTime = serverCurrentTime
session.updatedAt = serverLastUpdate
}
logger.log("updateLocalSessionFromServerMediaProgress: Updated session currentTime newCurrentTime=\(serverCurrentTime) previousCurrentTime=\(localCurrentTime)")
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Updated session currentTime newCurrentTime=\(serverCurrentTime) previousCurrentTime=\(localCurrentTime)")
PlayerHandler.seek(amount: session.currentTime)
} else {
logger.log("updateLocalSessionFromServerMediaProgress: Local session does not need updating; local has latest progress")
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Local session does not need updating; local has latest progress")
}
}
+51 -52
View File
@@ -9,7 +9,6 @@ import Foundation
import Alamofire
class ApiClient {
private static let logger = AppLogger(category: "ApiClient")
private static let secureStorage = SecureStorage()
public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
@@ -22,7 +21,7 @@ class ApiClient {
public static func postResource<T: Decodable>(endpoint: String, parameters: [String: Any], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
return
}
@@ -35,7 +34,7 @@ class ApiClient {
case .success(let obj):
callback?(obj)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
}
}
@@ -43,7 +42,7 @@ class ApiClient {
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) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
return
}
@@ -56,7 +55,7 @@ class ApiClient {
case .success(let obj):
callback?(obj)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
}
}
@@ -72,7 +71,7 @@ class ApiClient {
public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
callback?(false)
return
}
@@ -86,7 +85,7 @@ class ApiClient {
case .success(_):
callback?(true)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(false)
@@ -96,7 +95,7 @@ class ApiClient {
public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
callback?(false)
return
}
@@ -110,7 +109,7 @@ class ApiClient {
case .success(_):
callback?(true)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(false)
}
@@ -127,7 +126,7 @@ class ApiClient {
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
callback?(nil)
return
}
@@ -141,7 +140,7 @@ class ApiClient {
case .success(let obj):
callback?(obj)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
}
}
@@ -160,22 +159,22 @@ class ApiClient {
*/
private static func handleTokenRefresh<T: Decodable>(originalRequest: DataRequest, endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, callback: ((_ param: T?) -> Void)?) {
guard let serverConfig = Store.serverConfig else {
logger.error("handleTokenRefresh: No server config available")
AbsLogger.error(message: "handleTokenRefresh: No server config available")
callback?(nil)
return
}
logger.log("handleTokenRefresh: Attempting to refresh auth tokens for server \(serverConfig.name)")
AbsLogger.info(message: "handleTokenRefresh: Attempting to refresh auth tokens for server \(serverConfig.name)")
// Get refresh token from secure storage
guard let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConfig.id) else {
logger.error("handleTokenRefresh: No refresh token available for server \(serverConfig.name)")
AbsLogger.error(message: "handleTokenRefresh: No refresh token available for server \(serverConfig.name)")
handleRefreshFailure()
callback?(nil)
return
}
logger.log("handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
AbsLogger.info(message: "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
// Create refresh token request
let refreshHeaders: HTTPHeaders = [
@@ -190,23 +189,23 @@ class ApiClient {
case .success(let refreshResponse):
guard let user = refreshResponse.user,
!user.accessToken.isEmpty else {
logger.error("handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
AbsLogger.error(message: "handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
handleRefreshFailure()
callback?(nil)
return
}
logger.log("handleTokenRefresh: Successfully obtained new access token")
AbsLogger.info(message: "handleTokenRefresh: Successfully obtained new access token")
// Update tokens in secure storage and store
updateTokens(newAccessToken: user.accessToken, newRefreshToken: user.refreshToken ?? refreshToken, serverConnectionConfigId: serverConfig.id)
// Retry the original request with the new access token
logger.log("handleTokenRefresh: Retrying original request with new token")
AbsLogger.info(message: "handleTokenRefresh: Retrying original request with new token")
retryOriginalRequest(endpoint: endpoint, method: method, parameters: parameters, decodable: decodable, newAccessToken: user.accessToken, callback: callback)
case .failure(let error):
logger.error("handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
AbsLogger.error(message: "handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
handleRefreshFailure()
callback?(nil)
}
@@ -220,14 +219,14 @@ class ApiClient {
// Update the refresh token in secure storage if it's new
if newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) {
let hasStored = secureStorage.storeRefreshToken(serverConnectionConfigId: serverConnectionConfigId, refreshToken: newRefreshToken)
logger.log("updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)")
AbsLogger.info(message: "updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)")
}
// Update access token on server connection config
Database.shared.updateServerConnectionConfigToken(newToken: newAccessToken)
logger.log("updateTokens: Updated access token in server connection config")
AbsLogger.info(message: "updateTokens: Updated access token in server connection config")
logger.log("updateTokens: Successfully refreshed auth tokens for server \(Store.serverConfig?.name ?? "unknown")")
AbsLogger.info(message: "updateTokens: Successfully refreshed auth tokens for server \(Store.serverConfig?.name ?? "unknown")")
// Notify webview frontend about token refresh
if let callback = AbsDatabase.tokenRefreshCallback {
@@ -241,7 +240,7 @@ class ApiClient {
*/
private static func retryOriginalRequest<T: Decodable>(endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, newAccessToken: String, callback: ((_ param: T?) -> Void)?) {
guard let serverConfig = Store.serverConfig else {
logger.error("retryOriginalRequest: No server config available")
AbsLogger.error(message: "retryOriginalRequest: No server config available")
callback?(nil)
return
}
@@ -270,7 +269,7 @@ class ApiClient {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, headers: headers)
}
default:
logger.error("retryOriginalRequest: Unsupported method \(method)")
AbsLogger.error(message: "retryOriginalRequest: Unsupported method \(method)")
callback?(nil)
return
}
@@ -282,7 +281,7 @@ class ApiClient {
if let data = response.data, !data.isEmpty {
// If it is a string return nil (e.g. express returns OK for 200 status codes)
if let responseString = String(data: data, encoding: .utf8) {
logger.log("retryOriginalRequest: Got string response '\(responseString)'")
AbsLogger.info(message: "retryOriginalRequest: Got string response '\(responseString)'")
callback?(nil)
return
}
@@ -292,16 +291,16 @@ class ApiClient {
let decodedObject = try JSONDecoder().decode(decodable, from: data)
callback?(decodedObject)
} catch {
logger.error("retryOriginalRequest: JSON decode failed: \(error)")
AbsLogger.error(message: "retryOriginalRequest: JSON decode failed: \(error)", error: error)
callback?(nil)
}
} else {
// Empty response
logger.log("retryOriginalRequest: Empty response with success status \(statusCode)")
AbsLogger.info(message: "retryOriginalRequest: Empty response with success status \(statusCode)")
callback?(nil)
}
} else {
logger.error("retryOriginalRequest: Request failed with status \(response.response?.statusCode ?? 0)")
AbsLogger.error(message: "retryOriginalRequest: Request failed with status \(response.response?.statusCode ?? 0)")
callback?(nil)
}
}
@@ -312,7 +311,7 @@ class ApiClient {
* This will clear the current server connection and notify webview
*/
private static func handleRefreshFailure() {
logger.log("handleRefreshFailure: Token refresh failed, clearing session")
AbsLogger.info(message: "handleRefreshFailure: Token refresh failed, clearing session")
// Clear the current server connection
Store.serverConfig = nil
@@ -332,7 +331,7 @@ class ApiClient {
public static func getResourceWithTokenRefresh<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
callback?(nil)
return
}
@@ -345,14 +344,14 @@ class ApiClient {
request.responseDecodable(of: decodable) { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("getResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
AbsLogger.info(message: "getResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .get, parameters: nil, decodable: decodable, callback: callback)
} else {
switch response.result {
case .success(let obj):
callback?(obj)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(nil)
}
@@ -362,7 +361,7 @@ class ApiClient {
public static func postResourceWithTokenRefresh<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U?) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
callback?(nil)
return
}
@@ -375,14 +374,14 @@ class ApiClient {
request.responseDecodable(of: decodable) { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
AbsLogger.info(message: "postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: decodable, callback: callback)
} else {
switch response.result {
case .success(let obj):
callback?(obj)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(nil)
}
@@ -395,7 +394,7 @@ class ApiClient {
*/
public static func postResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
callback?(false)
return
}
@@ -408,7 +407,7 @@ class ApiClient {
request.response { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
AbsLogger.info(message: "postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: EmptyResponse.self) { result in
callback?(result != nil)
}
@@ -417,7 +416,7 @@ class ApiClient {
case .success(_):
callback?(true)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(false)
}
@@ -427,7 +426,7 @@ class ApiClient {
public static func patchResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
callback?(false)
return
}
@@ -440,7 +439,7 @@ class ApiClient {
request.response { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("patchResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
AbsLogger.info(message: "patchResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .patch, parameters: parameters, decodable: EmptyResponse.self) { result in
callback?(result != nil)
}
@@ -449,7 +448,7 @@ class ApiClient {
case .success(_):
callback?(true)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(false)
}
@@ -489,7 +488,7 @@ class ApiClient {
// Use the new token refresh-enabled method
postResourceWithTokenRefresh(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { session in
guard let session = session else {
logger.error("startPlaybackSession: Failed to create playback session")
AbsLogger.error(message: "startPlaybackSession: Failed to create playback session")
callback(PlaybackSession()) // Return empty session on failure
return
}
@@ -533,14 +532,14 @@ class ApiClient {
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
$0.serverConnectionConfigId == Store.serverConfig?.id
}.map { $0.freeze() }
logger.log("syncLocalSessionsWithServer: Found \(localMediaProgressList.count) local media progress for server")
AbsLogger.info(message: "syncLocalSessionsWithServer: Found \(localMediaProgressList.count) local media progress for server")
if (localMediaProgressList.isEmpty) {
logger.log("syncLocalSessionsWithServer: No local progress to sync")
AbsLogger.info(message: "syncLocalSessionsWithServer: No local progress to sync")
} else {
let currentUser = await ApiClient.getCurrentUser()
guard let currentUser = currentUser else {
logger.log("syncLocalSessionsWithServer: No User")
AbsLogger.info(message: "syncLocalSessionsWithServer: No User")
return
}
try currentUser.mediaProgress.forEach { mediaProgress in
@@ -552,12 +551,12 @@ class ApiClient {
}
}
if (localMediaProgress != nil && mediaProgress.lastUpdate > localMediaProgress!.lastUpdate) {
logger.log("syncLocalSessionsWithServer: Updating local media progress \(localMediaProgress!.id) with server media progress")
AbsLogger.info(message: "syncLocalSessionsWithServer: Updating local media progress \(localMediaProgress!.id) with server media progress")
if let localMediaProgress = localMediaProgress?.thaw() {
try localMediaProgress.updateFromServerMediaProgress(mediaProgress)
}
} else if (localMediaProgress != nil) {
logger.log("syncLocalSessionsWithServer: Local progress for \(localMediaProgress!.id) is more recent then server progress")
AbsLogger.info(message: "syncLocalSessionsWithServer: Local progress for \(localMediaProgress!.id) is more recent then server progress")
}
}
}
@@ -566,13 +565,13 @@ class ApiClient {
let playbackSessions = Database.shared.getAllPlaybackSessions().filter {
$0.serverConnectionConfigId == Store.serverConfig?.id
}.map { $0.freeze() }
logger.log("syncLocalSessionsWithServer: Found \(playbackSessions.count) playback sessions for server (first sync: \(isFirstSync))")
AbsLogger.info(message: "syncLocalSessionsWithServer: Found \(playbackSessions.count) playback sessions for server (first sync: \(isFirstSync))")
if (!playbackSessions.isEmpty) {
let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions)
if (success) {
// Remove sessions from db
try playbackSessions.forEach { session in
logger.log("syncLocalSessionsWithServer: Handling \(session.displayTitle ?? "") (\(session.id)) \(session.isActiveSession)")
AbsLogger.info(message: "syncLocalSessionsWithServer: Handling \(session.displayTitle ?? "") (\(session.id)) \(session.isActiveSession)")
// On first sync then remove all sessions
if (!session.isActiveSession || isFirstSync) {
if let session = session.thaw() {
@@ -589,7 +588,7 @@ class ApiClient {
}
public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) {
logger.log("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
AbsLogger.info(message: "updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
patchResourceWithTokenRefresh(endpoint: endpoint, parameters: payload) { _ in
callback()
@@ -597,7 +596,7 @@ class ApiClient {
}
public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? {
logger.log("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")")
AbsLogger.info(message: "getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")")
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
return await withCheckedContinuation { continuation in
getResourceWithTokenRefresh(endpoint: endpoint, decodable: MediaProgress.self) { result in
@@ -607,7 +606,7 @@ class ApiClient {
}
public static func getCurrentUser() async -> User? {
logger.log("getCurrentUser")
AbsLogger.info(message: "getCurrentUser")
return await withCheckedContinuation { continuation in
getResourceWithTokenRefresh(endpoint: "api/me", decodable: User.self) { result in
continuation.resume(returning: result)
+62 -8
View File
@@ -12,10 +12,14 @@ class Database {
public static var shared = {
return Database()
}()
private let logger = AppLogger(category: "Database")
private init() {}
private init() {
do {
try cleanExpiredLogs()
} catch {
debugPrint(error)
}
}
public func setServerConnectionConfig(config: ServerConnectionConfig) {
let config = config
@@ -33,7 +37,7 @@ class Database {
existing.token = config.token
}
} catch {
logger.error("failed to update server config")
AbsLogger.error("setServerConn", message: "failed to update server config")
debugPrint(error)
}
@@ -54,7 +58,7 @@ class Database {
realm.add(config)
}
} catch(let exception) {
logger.error("failed to save server config")
AbsLogger.error(message: "failed to save server config")
debugPrint(exception)
}
@@ -86,7 +90,7 @@ class Database {
}
}
} catch(let exception) {
logger.error("failed to delete server config")
AbsLogger.error(message: "failed to delete server config")
debugPrint(exception)
}
}
@@ -117,7 +121,7 @@ class Database {
}
}
} catch(let exception) {
logger.error("failed to save server config active index")
AbsLogger.error(message: "failed to save server config active index")
debugPrint(exception)
}
}
@@ -137,7 +141,7 @@ class Database {
realm.add(deviceSettings)
}
} catch {
logger.error("failed to save device settings")
AbsLogger.error(message: "failed to save device settings")
}
}
@@ -275,4 +279,54 @@ class Database {
return nil
}
}
public func saveLog(_ log: LogEntry) throws {
let realm = try Realm()
return try realm.write { realm.add(log) }
}
public func getAllLogs() -> [LogEntry] {
do {
let realm = try Realm()
return realm.objects(LogEntry.self).toArray()
} catch {
debugPrint(error)
return []
}
}
public func clearLogs() throws {
do {
let realm = try! Realm()
try realm.write {
realm.objects(LogEntry.self).forEach { log in
realm.delete(log)
}
}
} catch {
AbsLogger.error(message: "\(error)", error: error)
throw error
}
}
private func cleanExpiredLogs() throws {
let realm = try Realm()
let numberOfHoursToKeep = 48
let keepLogCutoff = Date().addingTimeInterval(TimeInterval(-1 * numberOfHoursToKeep * 3600))
let allLogs = getAllLogs()
var logsRemoved = 0
try? realm.write {
allLogs.forEach { log in
if log.timestamp < Int(keepLogCutoff.timeIntervalSince1970) {
realm.delete(log)
logsRemoved += 1
}
}
}
if logsRemoved > 0 {
AbsLogger.info(message: "cleanLogs: Removed \(logsRemoved) logs older than \(numberOfHoursToKeep) hours")
}
}
}
+78 -2
View File
@@ -65,8 +65,7 @@ extension CAPPluginCall {
let json = try JSONSerialization.data(withJSONObject: value)
return try JSONDecoder().decode(type, from: json)
} catch {
AppLogger().error("Failed to get json for \(key)")
debugPrint(error)
AbsLogger.error(message: "Failed to get json for \(key)", error: error)
return nil
}
}
@@ -106,3 +105,80 @@ extension URL {
return attributes?[.creationDate] as? Date
}
}
// MARK: - RealmSwift helpers
// From https://github.com/realm/realm-swift/issues/5859#issuecomment-589026869
extension Results {
func toArray<T: Object>() -> [T] {
var array = [T]()
for i in 0 ..< self.count {
if let result = self[i] as? T {
array.append(result.detached())
}
}
return array
}
}
extension List {
func toArray<T: Object>() -> [T] {
var array = [T]()
for i in 0 ..< self.count {
if let result = self[i] as? T {
array.append(result.detached())
}
}
return array
}
}
protocol DetachableObject: AnyObject {
func detached() -> Self
}
extension Object: DetachableObject {
func detached() -> Self {
let detached = type(of: self).init()
for property in objectSchema.properties {
guard let value = value(forKey: property.name) else { continue }
if property.isArray == true {
//Realm List property support
let detachable = value as? DetachableObject
detached.setValue(detachable?.detached(), forKey: property.name)
} else if property.type == .object {
//Realm Object property support
let detachable = value as? DetachableObject
detached.setValue(detachable?.detached(), forKey: property.name)
} else {
detached.setValue(value, forKey: property.name)
}
}
return detached
}
}
extension List: DetachableObject {
func detached() -> List<Element> {
let result = List<Element>()
forEach {
if let detachable = $0 as? DetachableObject {
let detached = detachable.detached() as! Element
result.append(detached)
} else {
result.append($0) //Primtives are pass by value; don't need to recreate
}
}
return result
}
func toArray() -> [Element] {
return Array(self.detached())
}
}