EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

ContentDirectoryClient.kt

756 lines · 35.4 KB

Changed in: 0.5.0

1package dev.stave.upnp
2
3import kotlinx.coroutines.async
4import kotlinx.coroutines.awaitAll
5import kotlinx.coroutines.coroutineScope
6import kotlinx.coroutines.suspendCancellableCoroutine
7import kotlinx.coroutines.sync.withPermit
8import org.jupnp.model.action.ActionInvocation
9import org.jupnp.model.message.UpnpResponse
10import org.jupnp.model.meta.RemoteDevice
11import org.jupnp.model.meta.Service
12import org.jupnp.model.types.UDAServiceType
13import org.jupnp.support.contentdirectory.callback.Browse
14import org.jupnp.support.contentdirectory.callback.Search
15import org.jupnp.support.model.BrowseFlag
16import org.jupnp.support.model.DIDLContent
17import org.jupnp.support.model.SortCriterion
18import kotlin.coroutines.resume
19
20class ContentDirectoryClient(
21 private val holder: UpnpServiceHolder,
22 private val parser: DidlParser,
23 private val settings: dev.stave.data.SettingsStore,
24) {
25
26 /** Browse the children of [containerId] on the server identified by [udn]. */
27 suspend fun browse(
28 udn: String,
29 containerId: String = ROOT,
30 ): BrowseResult {
31 val controlPoint = holder.controlPoint
32 ?: return BrowseResult.Failure("UPnP service not bound yet")
33 val device = holder.findDevice(udn)
34 ?: return BrowseResult.Failure("Server no longer in registry")
35 val service = device.cdService()
36 ?: return BrowseResult.Failure("Server does not expose a ContentDirectory service")
37
38 // 12 s guard on browse only (Android has it; search stays unbounded
39 // by the design decision).
40 return kotlinx.coroutines.withTimeoutOrNull(BROWSE_TIMEOUT_MS) { suspendCancellableCoroutine { cont ->
41 val callback = object : Browse(
42 service,
43 containerId,
44 BrowseFlag.DIRECT_CHILDREN,
45 "*",
46 0L,
47 null as Long?,
48 SortCriterion(true, "dc:title")
49 ) {
50 override fun received(invocation: ActionInvocation<*>?, didl: DIDLContent?) {
51 val items = didl?.let(parser::parse).orEmpty()
52 if (cont.isActive) cont.resume(BrowseResult.Success(items))
53 }
54
55 override fun updateStatus(status: Status?) { /* no-op */ }
56
57 override fun failure(
58 invocation: ActionInvocation<*>?,
59 operation: UpnpResponse?,
60 defaultMsg: String?
61 ) {
62 if (cont.isActive) cont.resume(
63 BrowseResult.Failure(defaultMsg ?: "Browse failed")
64 )
65 }
66 }
67 controlPoint.execute(callback)
68 } } ?: BrowseResult.Failure("Browse timed out")
69 }
70
71 /**
72 * Fetch a container's own parent ID via a BrowseFlag.METADATA lookup. Unlike a normal
73 * DIRECT_CHILDREN browse (which returns children), METADATA returns the object itself.
74 * Used by the DetailScreen "More" button to navigate from an album up to whatever the
75 * album's parent is — typically the artist container in MinimServer's default tree, but
76 * sometimes a genre or folder depending on how the user arrived. Returns null if the
77 * server rejects the call or the object has no parent (root).
78 */
79 suspend fun getContainerParent(udn: String, containerId: String): String? {
80 val controlPoint = holder.controlPoint ?: return null
81 val device = holder.findDevice(udn) ?: return null
82 val service = device.cdService() ?: return null
83
84 return suspendCancellableCoroutine { cont ->
85 val callback = object : Browse(
86 service,
87 containerId,
88 BrowseFlag.METADATA,
89 "*",
90 0L,
91 null as Long?,
92 SortCriterion(true, "dc:title")
93 ) {
94 override fun received(invocation: ActionInvocation<*>?, didl: DIDLContent?) {
95 val parent = didl?.containers?.firstOrNull()?.parentID
96 ?: didl?.items?.firstOrNull()?.parentID
97 // "-1" is the UPnP convention for "no parent" (root). Treat it as null.
98 val normalized = parent?.takeUnless { it.isBlank() || it == "-1" }
99 if (cont.isActive) cont.resume(normalized)
100 }
101
102 override fun updateStatus(status: Status?) { /* no-op */ }
103
104 override fun failure(
105 invocation: ActionInvocation<*>?,
106 operation: UpnpResponse?,
107 defaultMsg: String?
108 ) {
109 if (cont.isActive) cont.resume(null)
110 }
111 }
112 controlPoint.execute(callback)
113 }
114 }
115
116 /**
117 * Run a UPnP ContentDirectory Search() against [containerId] (root "0" by default).
118 * [criteria] is a UPnP Search expression, e.g.
119 * `upnp:class derivedfrom "object.container.album.musicAlbum" and upnp:artist = "Glenn Gould"`
120 */
121 suspend fun search(
122 udn: String,
123 criteria: String,
124 containerId: String = ROOT,
125 firstResult: Long = 0L,
126 maxResults: Long? = null,
127 filter: String = "*",
128 ): BrowseResult {
129 val controlPoint = holder.controlPoint
130 ?: return BrowseResult.Failure("UPnP service not bound yet")
131 val device = holder.findDevice(udn)
132 ?: return BrowseResult.Failure("Server no longer in registry")
133 val service = device.cdService()
134 ?: return BrowseResult.Failure("Server does not expose a ContentDirectory service")
135
136 return suspendCancellableCoroutine { cont ->
137 // Ported from Android (Android parity, 2026-10-29, D13b): jUPnP's Search
138 // callback constructor throws IllegalArgumentException when the
139 // server has no Search action (or lacks one of its arguments).
140 // Report it as this client's normal Failure instead of throwing —
141 // otherwise it escapes into Browse (spinner forever) and into the
142 // Now Playing enrichment flow (which then dies for the session).
143 val callback = try {
144 object : Search(service, containerId, criteria, filter, firstResult, maxResults) {
145 override fun received(invocation: ActionInvocation<*>?, didl: DIDLContent?) {
146 val items = didl?.let(parser::parse).orEmpty()
147 val total = readTotalMatches(invocation) ?: items.size.toLong()
148 if (cont.isActive) cont.resume(BrowseResult.Success(items, total))
149 }
150
151 override fun updateStatus(status: Status?) { /* no-op */ }
152
153 override fun failure(
154 invocation: ActionInvocation<*>?,
155 operation: UpnpResponse?,
156 defaultMsg: String?
157 ) {
158 if (cont.isActive) cont.resume(
159 BrowseResult.Failure(defaultMsg ?: "Search failed")
160 )
161 }
162 }
163 } catch (e: IllegalArgumentException) {
164 if (cont.isActive) cont.resume(
165 BrowseResult.Failure("$SEARCH_UNSUPPORTED_PREFIX${e.message ?: "no Search action"}")
166 )
167 return@suspendCancellableCoroutine
168 }
169 controlPoint.execute(callback)
170 }
171 }
172
173 private fun readTotalMatches(invocation: ActionInvocation<*>?): Long? {
174 val raw = invocation?.getOutput("TotalMatches")?.value ?: return null
175 return when (raw) {
176 is Long -> raw
177 is Number -> raw.toLong()
178 else -> raw.toString().toLongOrNull()
179 }
180 }
181
182 /** Convenience — find albums whose artist matches [artistName]. */
183 suspend fun searchAlbumsByArtist(udn: String, artistName: String): BrowseResult {
184 val escaped = artistName.upnpEscape()
185 val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
186 "and upnp:artist = \"$escaped\""
187 return search(udn, criteria)
188 }
189
190 /** Every track where artist/albumArtist/creator matches [artistName]. Different from
191 * searchAlbumsByArtist — this returns individual items rather than album containers,
192 * so "Tracks by Bach" lists every Bach piece across every album. */
193 suspend fun searchTracksByArtist(udn: String, artistName: String): BrowseResult {
194 val escaped = artistName.upnpEscape()
195 val criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
196 "and upnp:artist = \"$escaped\""
197 return search(udn, criteria)
198 }
199
200 /** Convenience — find albums whose genre contains [genreName]. */
201 suspend fun searchAlbumsByGenre(udn: String, genreName: String): BrowseResult {
202 val escaped = genreName.upnpEscape()
203 val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
204 "and upnp:genre contains \"$escaped\""
205 return search(udn, criteria)
206 }
207
208 /** Find albums whose title exactly matches [albumTitle]. */
209 suspend fun searchAlbumsByTitle(udn: String, albumTitle: String): BrowseResult {
210 val escaped = albumTitle.upnpEscape()
211 val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
212 "and dc:title = \"$escaped\""
213 return search(udn, criteria)
214 }
215
216 /** Convenience — find tracks (audioItems) whose upnp:album equals [albumTitle]
217 * (ported from Android). Used by the "more from this album" path: instead of
218 * relying on the track's parentId (which on tag-based servers like MinimServer
219 * points to a virtual aggregate container that holds thousands of items rather
220 * than just the album's tracks), we search by the album text tag — which the
221 * user's mental model of "this album" actually corresponds to. The server
222 * returns them in title order; callers sort by disc and track number. */
223 suspend fun searchTracksByAlbum(udn: String, albumTitle: String, artist: String? = null): BrowseResult {
224 val escaped = albumTitle.upnpEscape()
225 // `=` rather than `contains` — album titles are full names; we don't want
226 // "Lord of the Rings" to also match "Lord of the Rings: The Two Towers".
227 var criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
228 "and upnp:album = \"$escaped\""
229 // When an artist is provided, add it to the filter so "Greatest Hits"
230 // by Queen doesn't also pull in "Greatest Hits" by Fleetwood Mac.
231 if (!artist.isNullOrBlank()) {
232 val escapedArtist = artist.upnpEscape()
233 criteria += " and upnp:artist = \"$escapedArtist\""
234 }
235 return search(udn, criteria)
236 }
237
238 /** Find albums whose release date contains [year]. Works with MinimServer's
239 * YYYY-MM-DD format as well as bare-year dates. */
240 suspend fun searchAlbumsByYear(udn: String, year: String): BrowseResult {
241 val escaped = year.upnpEscape()
242 val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
243 "and dc:date contains \"$escaped\""
244 return search(udn, criteria)
245 }
246
247 suspend fun searchByTitle(udn: String, query: String): BrowseResult {
248 val escaped = query.upnpEscape()
249 val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
250 "and (dc:title contains \"$escaped\" or upnp:artist contains \"$escaped\")"
251 return search(udn, criteria)
252 }
253
254 /** Combined search: albums matching title/artist, plus individual tracks matching
255 * title. Results are merged into a single list with albums first, then tracks;
256 * within each group the best matches come first (Android's ranked sort: starts
257 * with the query, then a word starting with it, then anywhere; alphabetical
258 * within a rank). Partial matches work — "bach" finds "J.S. Bach" albums and
259 * "Goldberg" tracks by Bach. Used by the desktop search screen. */
260 suspend fun searchAll(udn: String, query: String): BrowseResult = coroutineScope {
261 val escaped = query.upnpEscape()
262 val albumCriteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
263 "and (dc:title contains \"$escaped\" or upnp:artist contains \"$escaped\")"
264 val trackCriteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
265 "and (dc:title contains \"$escaped\" or upnp:artist contains \"$escaped\")"
266 // Not runCatching: that would swallow CancellationException (a newer
267 // search replacing this one) and turn it into a Failure.
268 suspend fun guarded(criteria: String): BrowseResult = try {
269 search(udn, criteria)
270 } catch (c: kotlinx.coroutines.CancellationException) {
271 throw c
272 } catch (e: Exception) {
273 BrowseResult.Failure(e.message ?: "Search failed")
274 }
275 val (albumResult, trackResult) = listOf(
276 async { guarded(albumCriteria) },
277 async { guarded(trackCriteria) },
278 ).awaitAll()
279
280 val albums = (albumResult as? BrowseResult.Success)?.items.orEmpty()
281 val tracks = (trackResult as? BrowseResult.Success)?.items.orEmpty()
282 val combined = rankedSort(albums, query) + rankedSort(tracks, query)
283 if (combined.isEmpty() && albumResult is BrowseResult.Failure) albumResult
284 else BrowseResult.Success(combined)
285 }
286
287 /**
288 * Rank a string against a query for search-result ordering (verbatim from
289 * Android's ContentDirectoryClient).
290 * 0 = starts with query (case-insensitive; "the"/"a"/"an" stripped)
291 * 1 = a word inside starts with query
292 * 2 = query appears anywhere
293 * 3 = no match (safe fallback — server-filtered results shouldn't hit this)
294 */
295 private fun matchRank(s: String?, query: String): Int {
296 val q = query.lowercase().trim()
297 if (q.isEmpty()) return 2
298 val t = (s ?: "").lowercase()
299 val stripped = t.removePrefix("the ").removePrefix("a ").removePrefix("an ")
300 if (stripped.startsWith(q) || t.startsWith(q)) return 0
301 var i = 0
302 while (i < t.length) {
303 val idx = t.indexOf(q, i)
304 if (idx < 0) break
305 if (idx == 0 || !t[idx - 1].isLetterOrDigit()) return 1
306 i = idx + 1
307 }
308 return if (t.contains(q)) 2 else 3
309 }
310
311 // ---- Bucketed search (ported from Android) --------------------------------
312 //
313 // BubbleUPnP-style bucket search. Five class+property-scoped queries, one per
314 // bucket. The user sees counts in a compact five-row summary and opens
315 // whichever bucket they want. [searchAll] stays as the flat fallback for
316 // servers that can't count.
317
318 private fun bucketCriteria(kind: SearchBucketKind, query: String): String {
319 val e = query.upnpEscape()
320 return when (kind) {
321 SearchBucketKind.AlbumsByTitle ->
322 "upnp:class derivedfrom \"object.container.album.musicAlbum\" and dc:title contains \"$e\""
323 SearchBucketKind.AlbumsByArtist ->
324 "upnp:class derivedfrom \"object.container.album.musicAlbum\" and upnp:artist contains \"$e\""
325 SearchBucketKind.Artists ->
326 "upnp:class derivedfrom \"object.container.person.musicArtist\" and dc:title contains \"$e\""
327 SearchBucketKind.TracksByTitle ->
328 "upnp:class derivedfrom \"object.item.audioItem\" and dc:title contains \"$e\""
329 SearchBucketKind.TracksByArtist ->
330 "upnp:class derivedfrom \"object.item.audioItem\" and upnp:artist contains \"$e\""
331 }
332 }
333
334 /**
335 * Fast aggregate: fire all five bucket searches in parallel with
336 * maxResults=1 and read totalMatches from each response. The server
337 * evaluates each criteria but only serializes one item, which is the
338 * minimum round-trip for a count.
339 *
340 * As on Android, a bucket that couldn't be counted reads 0. Unlike Android,
341 * those buckets are also listed in [BucketCounts.uncounted] — a failed leg,
342 * or a server that returned matches but no TotalMatches (UPnP's "0 =
343 * unknown") — so the caller can fall back to the flat [searchAll] result
344 * instead of claiming "no matches".
345 */
346 suspend fun searchBucketCounts(udn: String, query: String): BucketCounts =
347 coroutineScope {
348 // Not runCatching: that would swallow CancellationException (a newer
349 // search replacing this one).
350 suspend fun countFor(kind: SearchBucketKind): Long? {
351 val r = try {
352 search(udn, bucketCriteria(kind, query), maxResults = 1L)
353 } catch (c: kotlinx.coroutines.CancellationException) {
354 throw c
355 } catch (e: Exception) {
356 System.err.println("[Earhorn/Search] count for $kind threw: $e")
357 null
358 }
359 val ok = r as? BrowseResult.Success ?: return null
360 return when {
361 ok.totalMatches > 0L -> ok.totalMatches
362 ok.items.isEmpty() -> 0L
363 else -> null // matches, but the server didn't say how many
364 }
365 }
366 val counts = SearchBucketKind.entries
367 .map { kind -> kind to async { countFor(kind) } }
368 .associate { (kind, d) -> kind to d.await() }
369 BucketCounts(
370 albumsByTitle = counts[SearchBucketKind.AlbumsByTitle] ?: 0L,
371 albumsByArtist = counts[SearchBucketKind.AlbumsByArtist] ?: 0L,
372 artists = counts[SearchBucketKind.Artists] ?: 0L,
373 tracksByTitle = counts[SearchBucketKind.TracksByTitle] ?: 0L,
374 tracksByArtist = counts[SearchBucketKind.TracksByArtist] ?: 0L,
375 uncounted = counts.filterValues { it == null }.keys,
376 )
377 }
378
379 /** Fetch the full items for one bucket. Sort key matches the user's
380 * intent: title-searched buckets rank by title, artist-searched
381 * buckets rank by the artist field (falling back to title). */
382 suspend fun expandSearchBucket(
383 udn: String,
384 kind: SearchBucketKind,
385 query: String,
386 ): BrowseResult {
387 val r = search(udn, bucketCriteria(kind, query))
388 if (r !is BrowseResult.Success) return r
389 val keyOf: (dev.stave.domain.model.DidlItem) -> String? = when (kind) {
390 SearchBucketKind.AlbumsByTitle,
391 SearchBucketKind.Artists,
392 SearchBucketKind.TracksByTitle -> { it -> it.title }
393 SearchBucketKind.AlbumsByArtist -> { it ->
394 (it as? dev.stave.domain.model.DidlItem.Container)?.artist ?: it.title
395 }
396 SearchBucketKind.TracksByArtist -> { it ->
397 (it as? dev.stave.domain.model.DidlItem.Track)?.let { t -> t.artist ?: t.albumArtist } ?: it.title
398 }
399 }
400 return BrowseResult.Success(
401 items = rankedSortBy(r.items, query, keyOf),
402 totalMatches = r.totalMatches,
403 )
404 }
405
406 /** Sort items by relevance rank, then alphabetically within rank (Android's
407 * `rankedSort`). The caller supplies [keyOf] so the same helper serves
408 * title-ranked buckets and artist-ranked buckets. */
409 private fun rankedSortBy(
410 items: List<dev.stave.domain.model.DidlItem>,
411 query: String,
412 keyOf: (dev.stave.domain.model.DidlItem) -> String?,
413 ): List<dev.stave.domain.model.DidlItem> =
414 items.sortedWith(
415 compareBy(
416 { matchRank(keyOf(it), query) },
417 { (keyOf(it) ?: it.title).lowercase() },
418 { it.title.lowercase() },
419 )
420 )
421
422 /** Sort by relevance, then alphabetically within a rank. searchAll matches on
423 * title OR artist, so an item ranks by whichever of the two matches better. */
424 private fun rankedSort(
425 items: List<dev.stave.domain.model.DidlItem>,
426 query: String,
427 ): List<dev.stave.domain.model.DidlItem> {
428 fun artistOf(it: dev.stave.domain.model.DidlItem): String? = when (it) {
429 is dev.stave.domain.model.DidlItem.Container -> it.artist
430 is dev.stave.domain.model.DidlItem.Track -> it.artist ?: it.albumArtist
431 }
432 return items.sortedWith(
433 compareBy(
434 { minOf(matchRank(it.title, query), matchRank(artistOf(it), query)) },
435 { it.title.lowercase() },
436 )
437 )
438 }
439
440 /**
441 * Fetch [count] scattered random albums. Two-phase: Phase 1 probes for the library
442 * size (tiny payload, cached 10 minutes); Phase 2 fires parallel Search RPCs for
443 * random offsets, throttled by a semaphore so we don't stampede the server.
444 */
445 suspend fun randomAlbums(udn: String, count: Int): BrowseResult =
446 randomFromFullList(udn, ALBUM_CRITERIA, count)
447
448 /** Fetch [count] scattered random tracks (same approach as [randomAlbums]). */
449 suspend fun randomTracks(udn: String, count: Int): BrowseResult =
450 randomFromFullList(udn, TRACK_CRITERIA, count)
451
452 /**
453 * Fetch [count] scattered random tracks tagged with [genreName] — the
454 * per-genre party action ("Play 42 random <Genre> tracks").
455 *
456 * `contains` rather than `=` because multi-genre tags (`"Jazz; Acid
457 * Jazz"`) are common on MinimServer-style backends and an exact match
458 * would silently miss them.
459 *
460 * The Settings genre-exclusion list deliberately does NOT apply here:
461 * the user explicitly chose this genre, so filtering it out would be
462 * contradictory. This was an explicit cross-platform product decision,
463 * not an oversight — the exclusions exist to keep unwanted genres out
464 * of *library-wide* random, and this action isn't that.
465 *
466 * The totalMatches probe cache in [randomFromFullList] is keyed per
467 * (udn, criteria), so each genre gets its own cached count — repeated
468 * dispatches for the same genre skip Phase 1 entirely.
469 */
470 suspend fun randomTracksByGenre(udn: String, genreName: String, count: Int): BrowseResult =
471 randomFromFullList(
472 udn,
473 "$TRACK_CRITERIA and upnp:genre contains \"${genreName.upnpEscape()}\"",
474 count,
475 applyExclusions = false,
476 )
477
478 /**
479 * Scattered-random strategy.
480 *
481 * Phase 1 — totalMatches probe. We need to know the library size to pick random
482 * offsets, but we don't need any items. A single Search with `filter="@id"` and
483 * `maxResults=1` costs almost nothing and returns the total. Result cached for
484 * 10 minutes per (udn, criteria), so subsequent Random dispatches skip Phase 1.
485 *
486 * Phase 2 — per-offset fetches. Pick [count] distinct random offsets and issue a
487 * one-item Search for each. Parallelised via coroutines but throttled by a
488 * Semaphore of [RANDOM_CONCURRENCY] permits — empirically 8 is the sweet spot on
489 * MinimServer: enough parallelism to keep 50-item Random in the low seconds, not
490 * so many that we jam the server's dispatcher (which is what made the naive
491 * unbounded-parallel earlier version hit 60+ seconds).
492 *
493 * A targeted filter on Phase 2 keeps each RPC's DIDL payload compact — we ask for
494 * just the metadata the UI actually uses, not the full "*" a room sink.
495 *
496 * Why not bulk-fetch the whole list and sample client-side? It also works and in
497 * some ways is simpler, but on a 5000-album library the DIDL payload is several
498 * megabytes of XML to serialize, ship, and parse — not free even if the server's
499 * search itself is fast. The two-phase approach has a cheap probe + small per-item
500 * payloads, which scales better.
501 */
502 private suspend fun randomFromFullList(
503 udn: String,
504 criteria: String,
505 count: Int,
506 /** False for user-directed criteria (e.g. an explicitly chosen genre)
507 * where filtering by the Settings exclusion list would contradict
508 * the user's own request. Default true preserves library-wide
509 * random behavior. */
510 applyExclusions: Boolean = true,
511 ): BrowseResult = coroutineScope {
512 if (count <= 0) return@coroutineScope BrowseResult.Success(emptyList(), 0L)
513
514 val cacheKey = "$udn|$criteria"
515 val now = System.currentTimeMillis()
516
517 // Phase 1: totalMatches. Cache hit → skip probe.
518 val total: Long = run {
519 val cached = synchronized(randomCacheLock) {
520 val entry = randomCountCache[cacheKey]
521 if (entry != null && now - entry.fetchedAt < RANDOM_CACHE_TTL_MS) entry.total else null
522 }
523 if (cached != null) return@run cached
524
525 val probe = search(
526 udn, criteria,
527 firstResult = 0L,
528 maxResults = 1L,
529 filter = "@id",
530 )
531 val t = when (probe) {
532 is BrowseResult.Failure -> return@coroutineScope probe
533 is BrowseResult.Success -> probe.totalMatches
534 }
535 synchronized(randomCacheLock) {
536 randomCountCache[cacheKey] = RandomCountCacheEntry(total = t, fetchedAt = now)
537 }
538 t
539 }
540
541 if (total <= 0L) return@coroutineScope BrowseResult.Success(emptyList(), 0L)
542
543 // Excluded-genre list, snapshotted once up-front — mid-fetch changes would
544 // produce confusingly-inconsistent results. Empty when the caller opted
545 // out (user-directed criteria — see the applyExclusions param), which
546 // makes matchesExcludedGenre a guaranteed no-op below.
547 val excludedGenres: Set<String> = if (!applyExclusions) emptySet() else
548 settings.randomExcludedGenres.value
549 .map { it.trim().lowercase() }
550 .filter { it.isNotEmpty() }
551 .toSet()
552
553 val target = count.toLong().coerceAtMost(total).toInt()
554 val rng = kotlin.random.Random
555 val semaphore = kotlinx.coroutines.sync.Semaphore(RANDOM_CONCURRENCY)
556
557 // Include upnp:genre so we have something to filter on. Without this the server
558 // won't send genre in the DIDL and excluded-genre filtering would be a no-op.
559 val filter = "dc:title,upnp:class,upnp:artist,dc:creator,upnp:album,upnp:genre," +
560 "upnp:albumArtURI,res,res@protocolInfo,res@duration,res@sampleFrequency," +
561 "res@bitsPerSample,res@bitrate,res@size,res@nrAudioChannels"
562
563 // Wave loop. Each wave over-requests 50% (minimum +2) to absorb exclusions.
564 // Track used offsets + seen item ids across waves to avoid re-picking the same
565 // offset or duplicate item (some servers return duplicates on adjacent offsets
566 // for reasons that are best not to investigate).
567 val usedOffsets = mutableSetOf<Long>()
568 val seenIds = mutableSetOf<String>()
569 val collected = mutableListOf<dev.stave.domain.model.DidlItem>()
570 var wave = 0
571 while (collected.size < target && wave < MAX_RANDOM_TOPUP_WAVES) {
572 val need = target - collected.size
573 val overRequest = need + (need / 2).coerceAtLeast(2)
574 val freshOffsets = mutableSetOf<Long>()
575 var attempts = 0
576 while (freshOffsets.size < overRequest && attempts < overRequest * 8) {
577 val o = rng.nextLong(0, total)
578 if (o !in usedOffsets) freshOffsets.add(o)
579 attempts++
580 }
581 if (freshOffsets.isEmpty()) break // offset space exhausted
582 usedOffsets.addAll(freshOffsets)
583
584 val waveItems = freshOffsets.map { offset ->
585 async {
586 semaphore.withPermit {
587 val r = search(
588 udn, criteria,
589 firstResult = offset,
590 maxResults = 1L,
591 filter = filter,
592 )
593 (r as? BrowseResult.Success)?.items?.firstOrNull()
594 }
595 }
596 }.awaitAll().filterNotNull()
597
598 for (item in waveItems) {
599 if (collected.size >= target) break
600 if (item.id in seenIds) continue
601 if (matchesExcludedGenre(item, excludedGenres)) continue
602 seenIds.add(item.id)
603 collected.add(item)
604 }
605 wave++
606 }
607
608 BrowseResult.Success(collected, total)
609 }
610
611 /** Parses the item's upnp:genre (handling all the separator conventions via
612 * [dev.stave.domain.model.parseGenres]) and checks if any token is in the
613 * lowercase excluded set. Empty exclusion set → never matches (fast path). */
614 private fun matchesExcludedGenre(
615 item: dev.stave.domain.model.DidlItem,
616 excludedLowercase: Set<String>,
617 ): Boolean {
618 if (excludedLowercase.isEmpty()) return false
619 val genreRaw = when (item) {
620 is dev.stave.domain.model.DidlItem.Track -> item.genre
621 is dev.stave.domain.model.DidlItem.Container -> item.extras["genre"]
622 }
623 return dev.stave.domain.model.parseGenres(genreRaw).any { token ->
624 token.lowercase() in excludedLowercase
625 }
626 }
627
628
629 /** Drop the random-count cache. Call when a rescan invalidates the cached library
630 * size for the current server. */
631 fun invalidateRandomCache() {
632 synchronized(randomCacheLock) { randomCountCache.clear() }
633 }
634
635 /** Cached library item count per (udn, criteria). We don't cache the items
636 * themselves — they're fetched fresh on every random request by offset. */
637 private data class RandomCountCacheEntry(
638 val total: Long,
639 val fetchedAt: Long,
640 )
641
642 private val randomCountCache = mutableMapOf<String, RandomCountCacheEntry>()
643 private val randomCacheLock = Any()
644
645 private fun String.upnpEscape(): String = replace("\\", "\\\\").replace("\"", "\\\"")
646
647 private fun RemoteDevice.cdService(): Service<RemoteDevice, *>? =
648 findService(UDAServiceType("ContentDirectory", 1))
649
650 companion object {
651 const val ROOT = "0"
652 /** Prefix of the Failure message when the server has no usable Search
653 * action — callers match on it to show plain-language copy. */
654 const val SEARCH_UNSUPPORTED_PREFIX = "Server does not support Search: "
655 private const val BROWSE_TIMEOUT_MS = 12_000L
656 private const val ALBUM_CRITERIA =
657 "upnp:class derivedfrom \"object.container.album.musicAlbum\""
658 private const val TRACK_CRITERIA =
659 "upnp:class derivedfrom \"object.item.audioItem\""
660 // Random-count cache TTL. totalMatches is stable enough that caching it for
661 // 10 minutes is safe; worst case is the user adds new items and they don't
662 // appear in random until TTL expires.
663 private const val RANDOM_CACHE_TTL_MS = 10L * 60L * 1000L
664 // Max parallel per-offset RPCs during random-sample phase. 8 is empirically
665 // the sweet spot — enough parallelism to make 50 items feel fast (~6s instead
666 // of 50s serial), not so many that we back up the server's dispatcher.
667 /** design decision: two in flight, not eight; fetch count unchanged. */
668 private const val RANDOM_CONCURRENCY = 2
669 // Max top-up waves when exclusions thin the sample below target. 3 waves × 1.5×
670 // over-request absorbs ~60% exclusion rate without infinite-looping on users
671 // who've excluded most of their library.
672 private const val MAX_RANDOM_TOPUP_WAVES = 3
673 }
674}
675
676/** The five buckets surfaced by [ContentDirectoryClient.searchBucketCounts]
677 * (ported from Android). Rendered as a five-row summary for a search. */
678enum class SearchBucketKind {
679 AlbumsByTitle, AlbumsByArtist, Artists, TracksByTitle, TracksByArtist;
680
681 /** Human-readable label used in the bucket-row UI. */
682 fun label(): String = when (this) {
683 AlbumsByTitle -> "Albums: title"
684 AlbumsByArtist -> "Albums: artist"
685 Artists -> "Artists"
686 TracksByTitle -> "Tracks: title"
687 TracksByArtist -> "Tracks: artist"
688 }
689
690 /** "1 album", "12 tracks"… for the bucket row's count line. */
691 fun countLabel(n: Long): String {
692 val noun = when (this) {
693 AlbumsByTitle, AlbumsByArtist -> "album"
694 Artists -> "artist"
695 TracksByTitle, TracksByArtist -> "track"
696 }
697 return if (n == 1L) "1 $noun" else "$n ${noun}s"
698 }
699}
700
701data class BucketCounts(
702 val albumsByTitle: Long,
703 val albumsByArtist: Long,
704 val artists: Long,
705 val tracksByTitle: Long,
706 val tracksByArtist: Long,
707 /** Buckets the server couldn't count (the search failed, or it returned
708 * matches without a total). Their count above reads 0. */
709 val uncounted: Set<SearchBucketKind> = emptySet(),
710) {
711 /** Count for [kind] — lets the UI iterate over SearchBucketKind.entries
712 * and ask this one source for each number. */
713 fun count(kind: SearchBucketKind): Long = when (kind) {
714 SearchBucketKind.AlbumsByTitle -> albumsByTitle
715 SearchBucketKind.AlbumsByArtist -> albumsByArtist
716 SearchBucketKind.Artists -> artists
717 SearchBucketKind.TracksByTitle -> tracksByTitle
718 SearchBucketKind.TracksByArtist -> tracksByArtist
719 }
720
721 /** True when every bucket returned zero matches. UI uses this to show
722 * "No matches" rather than five rows of (0). */
723 fun isAllEmpty(): Boolean = albumsByTitle == 0L && albumsByArtist == 0L &&
724 artists == 0L && tracksByTitle == 0L && tracksByArtist == 0L
725}
726
727/**
728 * Synthetic container id encoding a (bucket-kind, query) pair (ported from
729 * Android). The Browse infrastructure navigates by container id; this prefix
730 * lets a bucket-row click go through the same navigate-into path as a real
731 * container, with the ViewModel intercepting and expanding into a search
732 * result instead of calling the server's Browse action.
733 *
734 * Format: "earhorn-search:{Kind}:{query}"
735 *
736 * The trailing query is taken verbatim — it can contain colons; parsing splits
737 * at the first colon after the kind only. Real UPnP container ids don't start
738 * with "earhorn-search:" so collisions are impossible.
739 */
740object SearchBucketContainerId {
741 const val PREFIX = "earhorn-search:"
742
743 fun encode(kind: SearchBucketKind, query: String): String =
744 "$PREFIX${kind.name}:$query"
745
746 fun decode(id: String): Pair<SearchBucketKind, String>? {
747 if (!id.startsWith(PREFIX)) return null
748 val rest = id.removePrefix(PREFIX)
749 val colon = rest.indexOf(':')
750 if (colon < 0) return null
751 val kindName = rest.substring(0, colon)
752 val query = rest.substring(colon + 1)
753 val kind = SearchBucketKind.entries.firstOrNull { it.name == kindName } ?: return null
754 return kind to query
755 }
756}