132 lines
5.3 KiB
Swift
132 lines
5.3 KiB
Swift
|
|
import Foundation
|
||
|
|
|
||
|
|
/// Pre-fetches the next few tracks in the queue so playback is instant.
|
||
|
|
/// Uses OfflineManager's download system but marks pre-fetched files as temporary
|
||
|
|
/// (not user-requested downloads).
|
||
|
|
class AudioPreFetcher {
|
||
|
|
static let shared = AudioPreFetcher()
|
||
|
|
|
||
|
|
private let maxPrefetch = 3
|
||
|
|
private var prefetchedIds: Set<String> = []
|
||
|
|
private var prefetchTasks: [String: Task<Void, Never>] = [:]
|
||
|
|
|
||
|
|
private init() {}
|
||
|
|
|
||
|
|
/// Call when queue changes or track advances.
|
||
|
|
/// Pre-caches the next `maxPrefetch` tracks that aren't already downloaded.
|
||
|
|
func prefetchUpcoming(queue: [Song], currentIndex: Int) {
|
||
|
|
guard !queue.isEmpty else { return }
|
||
|
|
|
||
|
|
let start = currentIndex + 1
|
||
|
|
let end = min(start + maxPrefetch, queue.count)
|
||
|
|
guard start < end else { return }
|
||
|
|
|
||
|
|
let upcoming = Array(queue[start..<end])
|
||
|
|
let offline = OfflineManager.shared
|
||
|
|
|
||
|
|
// Cancel prefetches for songs no longer upcoming
|
||
|
|
let upcomingIds = Set(upcoming.map { $0.id })
|
||
|
|
for (id, task) in prefetchTasks where !upcomingIds.contains(id) {
|
||
|
|
task.cancel()
|
||
|
|
prefetchTasks[id] = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
for song in upcoming {
|
||
|
|
// Skip if already downloaded or already prefetching
|
||
|
|
if offline.isSongDownloaded(song.id) { continue }
|
||
|
|
if prefetchTasks[song.id] != nil { continue }
|
||
|
|
|
||
|
|
// Prefetch by downloading to the streaming cache
|
||
|
|
prefetchTasks[song.id] = Task {
|
||
|
|
do {
|
||
|
|
let url = streamURL(for: song)
|
||
|
|
guard let url else { return }
|
||
|
|
|
||
|
|
let (data, response) = try await URLSession.shared.data(from: url)
|
||
|
|
guard let http = response as? HTTPURLResponse,
|
||
|
|
(200...299).contains(http.statusCode) else { return }
|
||
|
|
|
||
|
|
// Write to streaming cache
|
||
|
|
let cacheDir = Self.prefetchCacheDir()
|
||
|
|
let file = cacheDir.appendingPathComponent("\(song.id).\(song.suffix ?? "mp3")")
|
||
|
|
try data.write(to: file, options: .atomic)
|
||
|
|
|
||
|
|
prefetchedIds.insert(song.id)
|
||
|
|
DebugLogger.shared.log("Prefetched: \(song.title) (\(Self.formatBytes(data.count)))", category: "Prefetch")
|
||
|
|
} catch {
|
||
|
|
if !Task.isCancelled {
|
||
|
|
DebugLogger.shared.log("Prefetch failed: \(song.title) — \(error.localizedDescription)", category: "Prefetch")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
prefetchTasks[song.id] = nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Returns the local prefetch URL if available
|
||
|
|
func prefetchedURL(for songId: String, suffix: String? = nil) -> URL? {
|
||
|
|
let ext = suffix ?? "mp3"
|
||
|
|
let file = Self.prefetchCacheDir().appendingPathComponent("\(songId).\(ext)")
|
||
|
|
return FileManager.default.fileExists(atPath: file.path) ? file : nil
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Clean up old prefetch files (call on app launch)
|
||
|
|
func cleanOldPrefetches(keeping currentQueue: [Song]) {
|
||
|
|
let keepIds = Set(currentQueue.map { $0.id })
|
||
|
|
let dir = Self.prefetchCacheDir()
|
||
|
|
guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return }
|
||
|
|
|
||
|
|
for file in files {
|
||
|
|
let name = file.deletingPathExtension().lastPathComponent
|
||
|
|
if !keepIds.contains(name) {
|
||
|
|
try? FileManager.default.removeItem(at: file)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
prefetchedIds = prefetchedIds.intersection(keepIds)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Total size of prefetch cache
|
||
|
|
var cacheSize: Int64 {
|
||
|
|
let dir = Self.prefetchCacheDir()
|
||
|
|
guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey]) else { return 0 }
|
||
|
|
return files.reduce(0) { sum, url in
|
||
|
|
sum + Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func clearCache() {
|
||
|
|
let dir = Self.prefetchCacheDir()
|
||
|
|
try? FileManager.default.removeItem(at: dir)
|
||
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||
|
|
prefetchedIds.removeAll()
|
||
|
|
prefetchTasks.values.forEach { $0.cancel() }
|
||
|
|
prefetchTasks.removeAll()
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Private
|
||
|
|
|
||
|
|
private static func prefetchCacheDir() -> URL {
|
||
|
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||
|
|
let dir = caches.appendingPathComponent("AudioPrefetch", isDirectory: true)
|
||
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||
|
|
return dir
|
||
|
|
}
|
||
|
|
|
||
|
|
private func streamURL(for song: Song) -> URL? {
|
||
|
|
let quality = StreamingQuality.shared
|
||
|
|
return ServerManager.shared.client.streamURL(
|
||
|
|
songId: song.id,
|
||
|
|
format: quality.format,
|
||
|
|
maxBitRate: quality.maxBitRate
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
private static func formatBytes(_ bytes: Int) -> String {
|
||
|
|
if bytes < 1024 * 1024 {
|
||
|
|
return "\(bytes / 1024)KB"
|
||
|
|
}
|
||
|
|
return String(format: "%.1fMB", Double(bytes) / 1_048_576.0)
|
||
|
|
}
|
||
|
|
}
|