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:
1 parent
48faa6682f
commit
7cdfbef160
16 files changed
+2006
-730
No files matched your search
@@ -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() },
|
||||
|
||||
Reference in new issue
Block a user