Web player overhaul; iOS random sort, stems-only filter, pooled libraries

Web: live Stem Now status that tracks every queued song (albums too),
right-click menus with Stem Now, Songs and Genres pages, a Stems Only
filter in place of the sidebar section, Random sort, several pooled
Plex servers and libraries, themes plus custom CSS, a reorganised
settings page that loads without a refresh, saved mixes, karaoke and
synced lyrics, stem playlists and offline downloads.

iOS: Random in every sort menu, a Stems Only toggle (sort menu and
Settings), and several music libraries pooled from the signed-in server.
Settings now persists karaoke and speed across launches.

stemd writes a status file that stemserve serves at /api/queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-20 18:29:45 -07:00
1 parent 48faa6682f
commit 7cdfbef160
16 files changed
+2006 -730

No files matched your search

+50 -11
View File
@@ -36,7 +36,8 @@ final class PlexClient: @unchecked Sendable {
var signedIn: Bool { !settings.plexToken.isEmpty }
private var base: String { settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
private var token: String { settings.plexToken }
private var section: String { settings.musicSection }
/// The chosen music libraries; listings pool them, in the order they were picked.
private var sections: [String] { settings.musicSection.split(separator: ",").map(String.init) }
private func request(_ url: URL, withToken: Bool = true, method: String = "GET") -> URLRequest {
var r = URLRequest(url: url)
@@ -97,6 +98,33 @@ final class PlexClient: @unchecked Sendable {
return out
}
/// Every section's listing, one after another.
private func pooled(_ params: [(String, Any?)]) async throws -> [PlexItem] {
var out: [PlexItem] = []
for sec in sections { out += try await all("/library/sections/\(sec)/all", params) }
return out
}
/// A page of a listing that runs on through each section in turn.
private func pooledPage(_ params: [(String, Any?)], start: Int, size: Int) async throws -> ([PlexItem], Int) {
if sections.count == 1 {
let c = try await get("/library/sections/\(sections[0])/all", params, start: start, size: size)
return (c.metadata, c.totalSize ?? c.size)
}
var out: [PlexItem] = []
var offset = 0, total = 0
for sec in sections {
let n = try await get("/library/sections/\(sec)/all", params, start: 0, size: 0)
let count = n.totalSize ?? n.size
let from = max(start + out.count - offset, 0)
if out.count < size, from < count {
out += try await get("/library/sections/\(sec)/all", params, start: from, size: size - out.count).metadata
}
offset += count; total += count
}
return (out, total)
}
// ---- server -------------------------------------------------------------------
func machineId() async throws -> String {
@@ -114,27 +142,36 @@ final class PlexClient: @unchecked Sendable {
// ---- library ------------------------------------------------------------------
func artists(sort: String = "titleSort") async throws -> [Artist] {
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", sort)]).map { $0.toArtist() }
try await pooled([("type", 8), ("sort", sort)]).map { $0.toArtist() }
}
func albums(sort: String = "titleSort") async throws -> [Album] {
try await all("/library/sections/\(section)/all", [("type", 9), ("sort", sort)]).map { $0.toAlbum() }
try await pooled([("type", 9), ("sort", sort)]).map { $0.toAlbum() }
}
func recentlyAdded(count: Int = 20) async throws -> [Album] {
try await get("/library/sections/\(section)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count)
.metadata.map { $0.toAlbum() }
var out: [Album] = []
for sec in sections {
out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count)
.metadata.map { $0.toAlbum() }
}
return out
}
/// One page of every track. Returns the page and the library's track count.
func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) {
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", sort)], start: start, size: size)
return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size)
let (page, total) = try await pooledPage([("type", 10), ("sort", sort)], start: start, size: size)
return (page.map { $0.toTrack() }, total)
}
/// Random tracks from the whole library, for shuffling it without loading every page.
func randomTracks(_ count: Int) async throws -> [Track] {
try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "random")], start: 0, size: count).metadata.map { $0.toTrack() }
var out: [Track] = []
for sec in sections {
out += try await get("/library/sections/\(sec)/all", [("type", 10), ("sort", "random")], start: 0, size: count / max(sections.count, 1) + 1)
.metadata.map { $0.toTrack() }
}
return Array(out.shuffled().prefix(count))
}
func album(_ key: String) async throws -> Album? {
@@ -148,7 +185,7 @@ final class PlexClient: @unchecked Sendable {
// Not /library/metadata/<artist>/children: that leaves out compilations, singles and live
// albums (an artist with only "The Best of Kansas" came back empty).
func artistAlbums(_ key: String) async throws -> [Album] {
try await all("/library/sections/\(section)/all", [("type", 9), ("artist.id", key)]).map { $0.toAlbum() }
try await pooled([("type", 9), ("artist.id", key)]).map { $0.toAlbum() }
}
func artistTracks(_ key: String) async throws -> [Track] {
@@ -161,7 +198,9 @@ final class PlexClient: @unchecked Sendable {
/// Title search inside the music section; used to give stem-only entries Plex metadata.
func searchTracks(_ title: String) async throws -> [Track] {
try await get("/library/sections/\(section)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() }
var out: [Track] = []
for sec in sections { out += try await get("/library/sections/\(sec)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() } }
return out
}
/// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB.
@@ -178,7 +217,7 @@ final class PlexClient: @unchecked Sendable {
}
func search(_ query: String) async throws -> SearchResults {
let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", section)]).hubs
let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", sections.count == 1 ? sections[0] : nil)]).hubs
func hub(_ type: String) -> [PlexItem] { hubs.first { $0.type == type }?.metadata ?? [] }
return SearchResults(
artists: hub("artist").map { $0.toArtist() },