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

+116
View File
@@ -0,0 +1,116 @@
import Foundation
/// One music server the app reads from. The first one ever added has an empty [id], so its
/// keys stay bare and everything saved before multi-server support (downloads, mixes, stem
/// playlists) still matches. Every other server's keys carry "<id>~" in front.
struct MediaServer: Codable, Hashable, Identifiable {
enum Kind: String, Codable { case plex, jellyfin }
var id = ""
var kind = Kind.plex
var name = ""
var url = ""
var token = ""
var user = ""
/// Jellyfin user id; Plex leaves it empty.
var userId = ""
var machineId = ""
var sections: [String] = []
var sectionTitles = ""
var signedIn: Bool { !token.isEmpty }
var ready: Bool { signedIn && !sections.isEmpty }
var base: String { url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
var label: String { name.isEmpty ? (kind == .plex ? "Plex" : "Jellyfin") : name }
var kindLabel: String { kind == .plex ? "Plex" : "Jellyfin" }
static func newId() -> String { "s" + String(UUID().uuidString.prefix(6)).lowercased() }
}
/// What a single server can do. [PlexClient] pools these and tags their keys.
protocol MediaSource: AnyObject, Sendable {
var server: MediaServer { get }
func artists(sort: String) async throws -> [Artist]
func albums(sort: String) async throws -> [Album]
func recentlyAdded(count: Int) async throws -> [Album]
func recentlyPlayed(count: Int) async throws -> [Album]
func recentlyAddedTracks(count: Int) async throws -> [Track]
func randomAlbums(_ count: Int) async throws -> [Album]
func tracksPage(start: Int, size: Int, sort: String) async throws -> ([Track], Int)
func randomTracks(_ count: Int) async throws -> [Track]
func album(_ key: String) async throws -> Album?
func albumTracks(_ key: String) async throws -> [Track]
func artistAlbums(_ key: String) async throws -> [Album]
func artistTracks(_ key: String) async throws -> [Track]
func searchTracks(_ title: String) async throws -> [Track]
func loudness(_ trackKey: String, samples: Int) async throws -> [Float]?
func search(_ query: String) async throws -> SearchResults
func playlists(sort: String) async throws -> [Playlist]
func playlistTracks(_ key: String) async throws -> [Track]
func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist?
func addToPlaylist(_ key: String, trackKeys: [String]) async throws
func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws
func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws
func renamePlaylist(_ key: String, title: String) async throws
func deletePlaylist(_ key: String) async throws
func scrobble(_ trackKey: String) async
func timeline(_ track: Track, state: String, positionMs: Int64) async
func artUrl(_ thumb: String?, px: Int) -> URL?
func partUrl(_ partKey: String) -> URL?
/// Music libraries on the server, for the picker in Settings.
func musicSections() async throws -> [MusicSection]
}
// ---- key tagging -----------------------------------------------------------------
extension String {
func tagged(_ id: String) -> String { id.isEmpty ? self : id + "~" + self }
}
extension Optional where Wrapped == String {
func tagged(_ id: String) -> String? { map { $0.tagged(id) } }
}
extension Track {
func tagged(_ id: String) -> Track {
guard !id.isEmpty else { return self }
var t = self
t.ratingKey = ratingKey.tagged(id); t.albumKey = albumKey.tagged(id); t.artistKey = artistKey.tagged(id)
t.thumb = thumb.tagged(id); t.partKey = partKey.tagged(id)
return t
}
}
extension Album {
func tagged(_ id: String) -> Album {
guard !id.isEmpty else { return self }
var a = self
a.ratingKey = ratingKey.tagged(id); a.artistKey = artistKey.tagged(id); a.thumb = thumb.tagged(id)
return a
}
}
extension Artist {
func tagged(_ id: String) -> Artist {
guard !id.isEmpty else { return self }
var a = self
a.ratingKey = ratingKey.tagged(id); a.thumb = thumb.tagged(id)
return a
}
}
extension Playlist {
func tagged(_ id: String) -> Playlist {
guard !id.isEmpty else { return self }
var p = self
p.ratingKey = ratingKey.tagged(id); p.thumb = thumb.tagged(id)
return p
}
}
extension SearchResults {
func tagged(_ id: String) -> SearchResults {
SearchResults(artists: artists.map { $0.tagged(id) }, albums: albums.map { $0.tagged(id) },
tracks: tracks.map { $0.tagged(id) }, playlists: playlists.map { $0.tagged(id) })
}
}