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:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+87
View File
@@ -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