Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -25,6 +25,21 @@ struct DownloadRecord: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// What gets downloaded, Plexamp-style: a whole playlist, album or artist, kept in step with the server.
|
||||
/// Its songs are ordinary [DownloadRecord]s, shared when two groups hold the same song.
|
||||
struct DownloadGroup: Codable, Identifiable, Equatable {
|
||||
enum Kind: String, Codable { case playlist, stemPlaylist, album, artist }
|
||||
var kind: Kind
|
||||
var key: String
|
||||
var title: String
|
||||
var subtitle = ""
|
||||
var thumb: String?
|
||||
var trackKeys: [String] = []
|
||||
var added = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
var id: String { Self.id(kind, key) }
|
||||
static func id(_ kind: Kind, _ key: String) -> String { "\(kind.rawValue):\(key)" }
|
||||
}
|
||||
|
||||
/// Offline copies: the track's stems (or the original file) fully fetched into
|
||||
/// [MediaCache] and pinned, plus the metadata needed to browse them with no server.
|
||||
@Observable
|
||||
@@ -35,6 +50,7 @@ final class Downloads: @unchecked Sendable {
|
||||
@ObservationIgnored private let cache: MediaCache
|
||||
@ObservationIgnored private let net: Connectivity
|
||||
@ObservationIgnored private let file: URL
|
||||
@ObservationIgnored private let groupFile: URL
|
||||
@ObservationIgnored private let lock = NSCondition()
|
||||
@ObservationIgnored private var state: [String: DownloadRecord] = [:]
|
||||
@ObservationIgnored private var prog: [String: Float] = [:]
|
||||
@@ -47,6 +63,8 @@ final class Downloads: @unchecked Sendable {
|
||||
static let waveBuckets = 256
|
||||
|
||||
private(set) var records: [String: DownloadRecord] = [:]
|
||||
/// Group id -> group. Only touched on the main thread.
|
||||
private(set) var groups: [String: DownloadGroup] = [:]
|
||||
/// ratingKey -> 0..1 for queued and running downloads.
|
||||
private(set) var progress: [String: Float] = [:]
|
||||
|
||||
@@ -57,10 +75,24 @@ final class Downloads: @unchecked Sendable {
|
||||
self.cache = cache
|
||||
self.net = net
|
||||
file = dir.appendingPathComponent("downloads.json")
|
||||
groupFile = dir.appendingPathComponent("download-groups.json")
|
||||
if let data = try? Data(contentsOf: file), let list = try? JSONDecoder().decode([Lenient<DownloadRecord>].self, from: data) {
|
||||
for r in list.compactMap(\.value) { state[r.track.ratingKey] = r }
|
||||
}
|
||||
records = state
|
||||
if let data = try? Data(contentsOf: groupFile), let list = try? JSONDecoder().decode([DownloadGroup].self, from: data) {
|
||||
groups = Dictionary(list.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
} else {
|
||||
// Downloads from before groups: gather them back into their albums.
|
||||
for (k, rs) in Dictionary(grouping: state.values.filter { $0.track.albumKey != nil }, by: { $0.track.albumKey! }) {
|
||||
let t = rs[0].track
|
||||
let gr = DownloadGroup(kind: .album, key: k, title: t.album, subtitle: t.artist, thumb: t.thumb,
|
||||
trackKeys: rs.sorted { (($0.track.disc ?? 0), ($0.track.index ?? 0)) < (($1.track.disc ?? 0), ($1.track.index ?? 0)) }.map(\.track.ratingKey),
|
||||
added: rs.map(\.added).min() ?? 0)
|
||||
groups[gr.id] = gr
|
||||
}
|
||||
saveGroups()
|
||||
}
|
||||
for _ in 0..<2 {
|
||||
let t = Thread { [weak self] in self?.work() }
|
||||
t.qualityOfService = .utility
|
||||
@@ -72,6 +104,61 @@ final class Downloads: @unchecked Sendable {
|
||||
|
||||
func isDownloaded(_ ratingKey: String) -> Bool { records[ratingKey]?.done == true }
|
||||
|
||||
func group(_ kind: DownloadGroup.Kind, _ key: String) -> DownloadGroup? { groups[DownloadGroup.id(kind, key)] }
|
||||
|
||||
/// Songs no group holds (single downloads from older versions).
|
||||
var looseRecords: [DownloadRecord] {
|
||||
let held = Set(groups.values.flatMap(\.trackKeys))
|
||||
return records.values.filter { !held.contains($0.track.ratingKey) }
|
||||
}
|
||||
|
||||
/// Downloads [gr] with these songs, or brings an existing group up to date: new songs are fetched,
|
||||
/// songs that left it are removed unless another group still holds them.
|
||||
func download(_ gr: DownloadGroup, _ tracks: [Track]) {
|
||||
var gr = gr
|
||||
if let old = groups[gr.id] { gr.added = old.added }
|
||||
let before = Set(groups[gr.id]?.trackKeys ?? [])
|
||||
gr.trackKeys = tracks.map(\.ratingKey)
|
||||
groups[gr.id] = gr
|
||||
saveGroups()
|
||||
download(tracks)
|
||||
prune(before.subtracting(gr.trackKeys))
|
||||
}
|
||||
|
||||
func removeGroup(_ id: String) {
|
||||
guard let gr = groups.removeValue(forKey: id) else { return }
|
||||
saveGroups()
|
||||
prune(Set(gr.trackKeys))
|
||||
}
|
||||
|
||||
/// Removes the songs among [keys] that no group holds any more.
|
||||
private func prune(_ keys: Set<String>) {
|
||||
let held = Set(groups.values.flatMap(\.trackKeys))
|
||||
let gone = keys.subtracting(held)
|
||||
if !gone.isEmpty { remove(gone.compactMap { records[$0]?.track }) }
|
||||
}
|
||||
|
||||
/// Brings every group in line with the server: playlists gain and lose songs, artists gain albums.
|
||||
@MainActor func refresh() async {
|
||||
for gr in groups.values {
|
||||
let tracks: [Track]?
|
||||
switch gr.kind {
|
||||
case .playlist: tracks = try? await plex.playlistTracks(gr.key)
|
||||
case .album: tracks = try? await plex.albumTracks(gr.key)
|
||||
case .artist: tracks = try? await plex.artistTracks(gr.key)
|
||||
case .stemPlaylist: tracks = AppGraph.shared.stemPlaylists.lists.first { $0.id == gr.key }?.tracks
|
||||
}
|
||||
if let tracks, !tracks.isEmpty, let cur = groups[gr.id], cur.trackKeys != tracks.map(\.ratingKey) { download(cur, tracks) }
|
||||
}
|
||||
}
|
||||
|
||||
private func saveGroups() {
|
||||
let list = groups.values.sorted { $0.added < $1.added }
|
||||
writeQueue.async { [groupFile] in
|
||||
if let data = try? JSONEncoder().encode(list) { try? data.write(to: groupFile, options: .atomic) }
|
||||
}
|
||||
}
|
||||
|
||||
func download(_ tracks: [Track]) {
|
||||
lock.lock()
|
||||
let add = tracks.filter { t in
|
||||
|
||||
Reference in new issue
Block a user