NavidromeApp/watchOS/App/WatchOfflineStore.swift
2026-04-11 16:15:27 -07:00

279 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import Combine
import WatchKit
import WatchConnectivity
/// Manages offline songs stored directly on the Apple Watch.
/// Downloads use URLSession background configuration for reliability.
/// Progress tracked per-song; haptic feedback on completion.
class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate {
static let shared = WatchOfflineStore()
@Published var songs: [WatchOfflineSong] = []
@Published var totalSize: Int64 = 0
@Published var downloadProgress: [String: Double] = [:] // songId 0.01.0
@Published var downloadComplete: [String: Bool] = [:] // songId true when done
struct WatchOfflineSong: Codable, Identifiable {
let id: String
let song: Song
let localPath: String
let fileSize: Int64
let dateAdded: Date
}
private let catalogKey = "watch_offline_songs"
private let fileManager = FileManager.default
// Both pendingSongs and taskToSongId are accessed from the URLSession delegate queue
// (background) AND from the main thread. Protect them with a dedicated serial queue.
private let storeQueue = DispatchQueue(label: "com.navidromeplayer.watch.offlinestore")
private var pendingSongs: [String: Song] = [:] // access via storeQueue only
private var taskToSongId: [Int: String] = [:] // access via storeQueue only
// Internal so NavidromeWatchDelegate can touch it to re-attach the delegate
// after a background wake (WKURLSessionRefreshBackgroundTask handler).
lazy var backgroundSession: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "com.navidromeplayer.watch.download")
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
var musicDirectory: URL {
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let dir = docs.appendingPathComponent("OfflineMusic", isDirectory: true)
if !fileManager.fileExists(atPath: dir.path) {
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
}
return dir
}
override init() {
super.init()
loadCatalog()
}
// MARK: - Song Management
func addSong(_ song: Song, localPath: String) {
guard !songs.contains(where: { $0.id == song.id }) else { return }
let fileSize: Int64 = (try? fileManager.attributesOfItem(atPath: localPath)[.size] as? Int64) ?? 0
let offlineSong = WatchOfflineSong(id: song.id, song: song, localPath: localPath, fileSize: fileSize, dateAdded: Date())
songs.append(offlineSong)
totalSize += fileSize
saveCatalog()
}
/// Update the embedded Song metadata for an offline song in-place.
/// Called when iOS notifies the watch of tag changes via WatchConnectivity.
func updateSongMetadata(id: String, title: String?, artist: String?,
album: String?, albumArtist: String?) {
guard let idx = songs.firstIndex(where: { $0.id == id }) else { return }
let old = songs[idx]
let updated = Song(
id: old.song.id,
parent: old.song.parent, isDir: old.song.isDir,
title: title ?? old.song.title,
album: album ?? old.song.album,
artist: artist ?? old.song.artist,
albumArtist: albumArtist ?? old.song.albumArtist,
track: old.song.track, year: old.song.year, genre: old.song.genre,
coverArt: old.song.coverArt,
size: old.song.size, contentType: old.song.contentType,
suffix: old.song.suffix,
transcodedContentType: old.song.transcodedContentType,
transcodedSuffix: old.song.transcodedSuffix,
duration: old.song.duration, bitRate: old.song.bitRate,
path: old.song.path, playCount: old.song.playCount,
discNumber: old.song.discNumber, created: old.song.created,
albumId: old.song.albumId, artistId: old.song.artistId,
type: old.song.type, starred: old.song.starred,
bpm: old.song.bpm, musicBrainzId: old.song.musicBrainzId
)
songs[idx] = WatchOfflineSong(
id: old.id, song: updated,
localPath: old.localPath, fileSize: old.fileSize, dateAdded: old.dateAdded
)
saveCatalog()
print("[Watch] Updated metadata for \(id): title=\(title ?? "-")")
}
func removeSong(_ songId: String) {
guard let idx = songs.firstIndex(where: { $0.id == songId }) else { return }
let song = songs[idx]
let filename = (song.localPath as NSString).lastPathComponent
let url = musicDirectory.appendingPathComponent(filename)
try? fileManager.removeItem(at: url)
totalSize -= song.fileSize
songs.remove(at: idx)
saveCatalog()
notifyiOS(songId: songId, downloaded: false)
}
func removeAll() {
for song in songs {
let filename = (song.localPath as NSString).lastPathComponent
try? fileManager.removeItem(at: musicDirectory.appendingPathComponent(filename))
}
songs.removeAll()
totalSize = 0
saveCatalog()
}
func localURL(for songId: String) -> URL? {
guard let song = songs.first(where: { $0.id == songId }) else { return nil }
let filename = (song.localPath as NSString).lastPathComponent
let url = musicDirectory.appendingPathComponent(filename)
return fileManager.fileExists(atPath: url.path) ? url : nil
}
func isSongAvailable(_ songId: String) -> Bool { localURL(for: songId) != nil }
// MARK: - Direct Server Download (Background URLSession)
func downloadFromServer(song: Song) {
guard !songs.contains(where: { $0.id == song.id }) else { return }
guard downloadProgress[song.id] == nil else { return }
guard let url = WatchSessionManager.shared.streamURL(songId: song.id, maxBitRate: 128) else {
print("[Watch] No stream URL for \(song.id)")
return
}
DispatchQueue.main.async {
self.downloadProgress[song.id] = 0.0
self.downloadComplete[song.id] = false
}
let task = backgroundSession.downloadTask(with: url)
// Protect dictionary mutations with storeQueue delegate reads on background queue
storeQueue.sync {
self.pendingSongs[song.id] = song
self.taskToSongId[task.taskIdentifier] = song.id
}
task.resume()
print("[Watch] Download started: \(song.title)")
}
func downloadAlbum(_ album: AlbumWithSongs) {
guard let albumSongs = album.song else { return }
for song in albumSongs {
downloadFromServer(song: song)
}
}
// MARK: - URLSession Download Delegate
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
let songId: String? = storeQueue.sync { taskToSongId[downloadTask.taskIdentifier] }
guard let songId else { return }
let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0
DispatchQueue.main.async {
self.downloadProgress[songId] = progress
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
let (songId, song): (String?, Song?) = storeQueue.sync {
let sid = taskToSongId[downloadTask.taskIdentifier]
return (sid, sid.flatMap { pendingSongs[$0] })
}
guard let songId, let song else { return }
let filename = "\(songId).mp3"
let destURL = musicDirectory.appendingPathComponent(filename)
do {
if fileManager.fileExists(atPath: destURL.path) {
try fileManager.removeItem(at: destURL)
}
try fileManager.moveItem(at: location, to: destURL)
DispatchQueue.main.async {
self.addSong(song, localPath: destURL.path)
self.downloadProgress.removeValue(forKey: songId)
self.downloadComplete[songId] = true
self.storeQueue.async {
self.pendingSongs.removeValue(forKey: songId)
self.taskToSongId.removeValue(forKey: downloadTask.taskIdentifier)
}
WKInterfaceDevice.current().play(.success)
self.notifyiOS(songId: songId, downloaded: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.downloadComplete.removeValue(forKey: songId)
}
}
print("[Watch] Download complete: \(song.title)")
} catch {
print("[Watch] Save failed: \(error)")
DispatchQueue.main.async {
self.downloadProgress.removeValue(forKey: songId)
self.storeQueue.async {
self.pendingSongs.removeValue(forKey: songId)
}
}
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error else { return }
let songId: String? = storeQueue.sync { taskToSongId[task.taskIdentifier] }
guard let songId else { return }
print("[Watch] Download error for \(songId): \(error.localizedDescription)")
DispatchQueue.main.async {
self.downloadProgress.removeValue(forKey: songId)
self.storeQueue.async {
self.pendingSongs.removeValue(forKey: songId)
self.taskToSongId.removeValue(forKey: task.taskIdentifier)
}
}
}
// MARK: - iOS Notification
private func notifyiOS(songId: String, downloaded: Bool) {
guard WCSession.default.isReachable else { return }
WCSession.default.sendMessage([
"type": "watchDownloadStatus",
"songId": songId,
"downloaded": downloaded
], replyHandler: nil, errorHandler: nil)
}
// MARK: - Grouped Access
var songsByAlbum: [String: [WatchOfflineSong]] {
Dictionary(grouping: songs) { $0.song.album ?? "Unknown Album" }
}
var formattedSize: String {
ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
}
// MARK: - Persistence
private func saveCatalog() {
if let data = try? JSONEncoder().encode(songs) {
UserDefaults.standard.set(data, forKey: catalogKey)
}
}
private func loadCatalog() {
if let data = UserDefaults.standard.data(forKey: catalogKey),
let decoded = try? JSONDecoder().decode([WatchOfflineSong].self, from: data) {
songs = decoded.filter { song in
let filename = (song.localPath as NSString).lastPathComponent
let url = musicDirectory.appendingPathComponent(filename)
return fileManager.fileExists(atPath: url.path)
}
totalSize = songs.reduce(0) { $0 + $1.fileSize }
}
}
// MARK: - Cache Management
var cacheSize: String {
ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
}
}