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

ContentDirectoryClient.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.upnp
2
3import dev.stave.domain.model.DidlItem
4import kotlinx.coroutines.async
5import kotlinx.coroutines.awaitAll
6import kotlinx.coroutines.coroutineScope
7import kotlinx.coroutines.flow.first
8import kotlinx.coroutines.suspendCancellableCoroutine
9import kotlinx.coroutines.sync.withPermit
10import kotlinx.coroutines.withTimeoutOrNull
11import org.jupnp.model.action.ActionInvocation
12import org.jupnp.model.message.UpnpResponse
13import org.jupnp.model.meta.RemoteDevice
14import org.jupnp.model.meta.Service
15import org.jupnp.model.types.UDAServiceType
16import org.jupnp.support.contentdirectory.callback.Browse
17import org.jupnp.support.contentdirectory.callback.Search
18import org.jupnp.support.model.BrowseFlag
19import org.jupnp.support.model.DIDLContent
20import org.jupnp.support.model.SortCriterion
21import javax.inject.Inject
22import javax.inject.Singleton
23import kotlin.coroutines.resume
24
25@Singleton
26class ContentDirectoryClient @Inject constructor(
27 private val holder: UpnpServiceHolder,
28 private val parser: DidlParser,
29 private val settings: dev.stave.data.SettingsStore,
30) {
31
32 /** Browse the children of [containerId] on the server identified by [udn].
33 *
34 * Wrapped in a 12-second timeout because some servers (or stale container
35 * IDs from a previous server session) cause the SOAP callback to never
36 * fire — neither received() nor failure() — and without a timeout the
37 * caller waits on jUPnP's internal HTTP timeout (60s+). 12s is generous
38 * for a real response on slow networks but short enough to surface a
39 * stuck call as an error before the user gives up. */
40 suspend fun browse(
41 udn: String,
42 containerId: String = ROOT,
43 ): BrowseResult {
44 val controlPoint = holder.controlPoint
45 ?: return BrowseResult.Failure("UPnP service not bound yet")
46 val device = holder.findDevice(udn)
47 ?: return BrowseResult.Failure("Server no longer in registry")
48 val service = device.cdService()
49 ?: return BrowseResult.Failure("Server does not expose a ContentDirectory service")
50
51 val result = withTimeoutOrNull(BROWSE_TIMEOUT_MS) {
52 suspendCancellableCoroutine<BrowseResult> { cont ->
53 val callback = object : Browse(
54 service,
55 containerId,
56 BrowseFlag.DIRECT_CHILDREN,
57 "*",
58 0L,
59 null as Long?,
60 SortCriterion(true, "dc:title")
61 ) {
62 override fun received(invocation: ActionInvocation<*>?, didl: DIDLContent?) {
63 val items = didl?.let(parser::parse).orEmpty()
64 if (cont.isActive) cont.resume(BrowseResult.Success(items))
65 }
66
67 override fun updateStatus(status: Status?) { /* no-op */ }
68
69 override fun failure(
70 invocation: ActionInvocation<*>?,
71 operation: UpnpResponse?,
72 defaultMsg: String?
73 ) {
74 if (cont.isActive) cont.resume(
75 BrowseResult.Failure(defaultMsg ?: "Browse failed")
76 )
77 }
78 }
79 controlPoint.execute(callback)
80 }
81 }
82 return result ?: BrowseResult.Failure(
83 "Server didn't respond in ${BROWSE_TIMEOUT_MS / 1000}s — the container may have moved or the server may be busy."
84 )
85 }
86
87 /**
88 * Fetch a container's own parent ID via a BrowseFlag.METADATA lookup. Unlike a normal
89 * DIRECT_CHILDREN browse (which returns children), METADATA returns the object itself.
90 * Used by the DetailScreen "More" button to navigate from an album up to whatever the
91 * album's parent is — typically the artist container in MinimServer's default tree, but
92 * sometimes a genre or folder depending on how the user arrived. Returns null if the
93 * server rejects the call or the object has no parent (root).
94 */
95 suspend fun getContainerParent(udn: String, containerId: String): String? {
96 val controlPoint = holder.controlPoint ?: return null
97 val device = holder.findDevice(udn) ?: return null
98 val service = device.cdService() ?: return null
99
100 return suspendCancellableCoroutine { cont ->
101 val callback = object : Browse(
102 service,
103 containerId,
104 BrowseFlag.METADATA,
105 "*",
106 0L,
107 null as Long?,
108 SortCriterion(true, "dc:title")
109 ) {
110 override fun received(invocation: ActionInvocation<*>?, didl: DIDLContent?) {
111 // METADATA returns a single-element response — the object itself — placed
112 // in either containers or items. Read parentID off whichever is populated.
113 val parent = didl?.containers?.firstOrNull()?.parentID
114 ?: didl?.items?.firstOrNull()?.parentID
115 // "-1" is the UPnP convention for "no parent" (root). Treat it as null.
116 val normalized = parent?.takeUnless { it.isBlank() || it == "-1" }
117 if (cont.isActive) cont.resume(normalized)
118 }
119
120 override fun updateStatus(status: Status?) { /* no-op */ }
121
122 override fun failure(
123 invocation: ActionInvocation<*>?,
124 operation: UpnpResponse?,
125 defaultMsg: String?
126 ) {
127 if (cont.isActive) cont.resume(null)
128 }
129 }
130 controlPoint.execute(callback)
131 }
132 }
133
134 /**
135 * Run a UPnP ContentDirectory Search() against [containerId] (root "0" by default).
136 * [criteria] is a UPnP Search expression, e.g.
137 * `upnp:class derivedfrom "object.container.album.musicAlbum" and upnp:artist = "Glenn Gould"`
138 *
139 * [firstResult] and [maxResults] correspond to UPnP's pagination args. maxResults=null
140 * means "server default" (often unlimited). We read totalMatches out of the action
141 * response so Random can pick a random offset without fetching every match.
142 *
143 * Most servers support Search; MinimServer does. Returns items in the same shape as
144 * browse(), so callers can render with the existing container/track rows.
145 */
146 suspend fun search(
147 udn: String,
148 criteria: String,
149 containerId: String = ROOT,
150 firstResult: Long = 0L,
151 maxResults: Long? = null,
152 filter: String = "*",
153 ): BrowseResult {
154 val controlPoint = holder.controlPoint
155 ?: return BrowseResult.Failure("UPnP service not bound yet")
156 val device = holder.findDevice(udn)
157 ?: return BrowseResult.Failure("Server no longer in registry")
158 val service = device.cdService()
159 ?: return BrowseResult.Failure("Server does not expose a ContentDirectory service")
160
161 return suspendCancellableCoroutine { cont ->
162 // Maintainer, 2026-10-29: jUPnP's Search callback constructor throws
163 // IllegalArgumentException when the server has no Search action (or
164 // lacks one of its arguments); report it as this client's normal
165 // Failure instead of throwing (D13b).
166 val callback = try {
167 object : Search(service, containerId, criteria, filter, firstResult, maxResults) {
168 override fun received(invocation: ActionInvocation<*>?, didl: DIDLContent?) {
169 val items = didl?.let(parser::parse).orEmpty()
170 // TotalMatches is declared ui4 in UPnP but jUPnP's Java surface varies —
171 // sometimes Long, sometimes UnsignedIntegerFourBytes, sometimes String.
172 // Fall through coercions rather than cast to a single type.
173 val total = readTotalMatches(invocation) ?: items.size.toLong()
174 if (cont.isActive) cont.resume(BrowseResult.Success(items, total))
175 }
176
177 override fun updateStatus(status: Status?) { /* no-op */ }
178
179 override fun failure(
180 invocation: ActionInvocation<*>?,
181 operation: UpnpResponse?,
182 defaultMsg: String?
183 ) {
184 if (cont.isActive) cont.resume(
185 BrowseResult.Failure(defaultMsg ?: "Search failed")
186 )
187 }
188 }
189 } catch (e: IllegalArgumentException) {
190 if (cont.isActive) cont.resume(
191 BrowseResult.Failure("Server does not support Search: ${e.message ?: "no Search action"}")
192 )
193 return@suspendCancellableCoroutine
194 }
195 controlPoint.execute(callback)
196 }
197 }
198
199 private fun readTotalMatches(invocation: ActionInvocation<*>?): Long? {
200 val raw = invocation?.getOutput("TotalMatches")?.value ?: return null
201 return when (raw) {
202 is Long -> raw
203 is Number -> raw.toLong()
204 else -> raw.toString().toLongOrNull()
205 }
206 }
207
208 /** Convenience — find albums whose artist matches [artistName].
209 *
210 * Uses `upnp:artist` (not `upnp:albumArtist`) — the latter isn't a standard DIDL-Lite
211 * property and MinimServer silently ignores it, returning every album in the library
212 * (matching only the class filter). `upnp:artist` on an album container holds the
213 * album artist value, which is the semantic we want.
214 *
215 * Equality rather than `contains` so "Smith" doesn't also match "Will Smith" or
216 * "Smith Westerns". We have the exact name from track metadata, so exact match works.
217 */
218 suspend fun searchAlbumsByArtist(udn: String, artistName: String): BrowseResult {
219 val escaped = artistName.upnpEscape()
220 val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
221 "and upnp:artist = \"$escaped\""
222 return search(udn, criteria)
223 }
224
225 /** Every track where artist/albumArtist/creator matches [artistName]. Different from
226 * searchAlbumsByArtist — this returns individual items rather than album containers,
227 * so "Tracks by Bach" lists every Bach piece across every album. */
228 suspend fun searchTracksByArtist(udn: String, artistName: String): BrowseResult {
229 val escaped = artistName.upnpEscape()
230 val criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
231 "and upnp:artist = \"$escaped\""
232 return search(udn, criteria)
233 }
234
235 /** Convenience — find tracks (audioItems) whose upnp:album equals [albumTitle].
236 * Used by the "More tracks from this album" path: instead of relying on the
237 * track's parentId (which on tag-based servers like MinimServer points to a
238 * virtual aggregate container that holds thousands of items rather than just
239 * the album's tracks), we search by the album text tag — which the user's
240 * mental model of "this album" actually corresponds to. */
241 suspend fun searchTracksByAlbum(udn: String, albumTitle: String, artist: String? = null): BrowseResult {
242 val escaped = albumTitle.upnpEscape()
243 // `=` rather than `contains` — album titles are full names; we don't want
244 // "Lord of the Rings" to also match "Lord of the Rings: The Two Towers".
245 var criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
246 "and upnp:album = \"$escaped\""
247 // When an artist is provided, add it to the filter so "Greatest Hits"
248 // by Queen doesn't also pull in "Greatest Hits" by Fleetwood Mac.
249 if (!artist.isNullOrBlank()) {
250 val escapedArtist = artist.upnpEscape()
251 criteria += " and upnp:artist = \"$escapedArtist\""
252 }
253 return search(udn, criteria)
254 }
255
256 /** Convenience — find albums whose genre contains [genreName]. */
257 suspend fun searchAlbumsByGenre(udn: String, genreName: String): BrowseResult {
258 val escaped = genreName.upnpEscape()
259 // `contains` rather than `=` because tracks can have compound genre tags like
260 // "Jazz / Fusion" that equality search would miss.
261 val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
262 "and upnp:genre contains \"$escaped\""
263 return search(udn, criteria)
264 }
265
266 /**
267 * BubbleUPnP-style bucket search. Five parallel class+property-scoped
268 * queries, one per bucket. User sees counts in a compact 5-row summary
269 * and drills into whichever bucket they want.
270 */
271 suspend fun searchBucketAlbumsByTitle(udn: String, query: String): BrowseResult {
272 val escaped = query.upnpEscape()
273 val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
274 "and dc:title contains \"$escaped\""
275 return search(udn, criteria)
276 }
277
278 suspend fun searchBucketAlbumsByArtist(udn: String, query: String): BrowseResult {
279 val escaped = query.upnpEscape()
280 val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
281 "and upnp:artist contains \"$escaped\""
282 return search(udn, criteria)
283 }
284
285 suspend fun searchBucketArtists(udn: String, query: String): BrowseResult {
286 val escaped = query.upnpEscape()
287 val criteria = "upnp:class derivedfrom \"object.container.person.musicArtist\" " +
288 "and dc:title contains \"$escaped\""
289 return search(udn, criteria)
290 }
291
292 suspend fun searchBucketTracksByTitle(udn: String, query: String): BrowseResult {
293 val escaped = query.upnpEscape()
294 val criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
295 "and dc:title contains \"$escaped\""
296 return search(udn, criteria)
297 }
298
299 suspend fun searchBucketTracksByArtist(udn: String, query: String): BrowseResult {
300 val escaped = query.upnpEscape()
301 val criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
302 "and upnp:artist contains \"$escaped\""
303 return search(udn, criteria)
304 }
305
306 /**
307 * Fast aggregate for the live-pane: fire all five bucket searches in
308 * parallel with maxResults=1 and read totalMatches from each response.
309 * The server evaluates each criteria but only serializes one item,
310 * which is the minimum round-trip for a count.
311 *
312 * On failure in any leg, that bucket's count is 0. UI surfaces the
313 * counts that came back; an all-zero result tells the user there's
314 * no match rather than a cryptic error.
315 */
316 suspend fun searchBucketCounts(udn: String, query: String): BucketCounts =
317 coroutineScope {
318 suspend fun countFor(crit: String): Long {
319 val r = runCatching { search(udn, crit, maxResults = 1L) }.getOrNull()
320 return (r as? BrowseResult.Success)?.totalMatches ?: 0L
321 }
322 val e = query.upnpEscape()
323 val a1 = async {
324 countFor(
325 "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
326 "and dc:title contains \"$e\""
327 )
328 }
329 val a2 = async {
330 countFor(
331 "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
332 "and upnp:artist contains \"$e\""
333 )
334 }
335 val a3 = async {
336 countFor(
337 "upnp:class derivedfrom \"object.container.person.musicArtist\" " +
338 "and dc:title contains \"$e\""
339 )
340 }
341 val a4 = async {
342 countFor(
343 "upnp:class derivedfrom \"object.item.audioItem\" " +
344 "and dc:title contains \"$e\""
345 )
346 }
347 val a5 = async {
348 countFor(
349 "upnp:class derivedfrom \"object.item.audioItem\" " +
350 "and upnp:artist contains \"$e\""
351 )
352 }
353 BucketCounts(
354 albumsByTitle = a1.await(),
355 albumsByArtist = a2.await(),
356 artists = a3.await(),
357 tracksByTitle = a4.await(),
358 tracksByArtist = a5.await(),
359 )
360 }
361
362 /**
363 * Rank a string against a query for search-result ordering.
364 * 0 = starts with query (case-insensitive; "the"/"a"/"an" stripped)
365 * 1 = a word inside starts with query
366 * 2 = query appears anywhere
367 * 3 = no match (safe fallback — server-filtered results shouldn't hit this)
368 */
369 private fun matchRank(s: String?, query: String): Int {
370 val q = query.lowercase().trim()
371 if (q.isEmpty()) return 2
372 val t = (s ?: "").lowercase()
373 val stripped = t.removePrefix("the ").removePrefix("a ").removePrefix("an ")
374 if (stripped.startsWith(q) || t.startsWith(q)) return 0
375 var i = 0
376 while (i < t.length) {
377 val idx = t.indexOf(q, i)
378 if (idx < 0) break
379 if (idx == 0 || !t[idx - 1].isLetterOrDigit()) return 1
380 i = idx + 1
381 }
382 return if (t.contains(q)) 2 else 3
383 }
384
385 /** Sort items by relevance rank, then alphabetically within rank.
386 * The caller supplies [keyOf] so the same helper serves title-ranked
387 * buckets and artist-ranked buckets. */
388 private fun rankedSort(
389 items: List<DidlItem>,
390 query: String,
391 keyOf: (DidlItem) -> String?,
392 ): List<DidlItem> =
393 items.sortedWith(
394 compareBy(
395 { matchRank(keyOf(it), query) },
396 { (keyOf(it) ?: it.title).lowercase() },
397 { it.title.lowercase() },
398 )
399 )
400
401 /** Fetch the full items for one bucket. Sort key matches the user's
402 * intent: title-searched buckets rank by title, artist-searched
403 * buckets rank by the artist field (falling back to title). */
404 suspend fun expandSearchBucket(
405 udn: String,
406 kind: SearchBucketKind,
407 query: String,
408 ): BrowseResult {
409 val r = when (kind) {
410 SearchBucketKind.AlbumsByTitle -> searchBucketAlbumsByTitle(udn, query)
411 SearchBucketKind.AlbumsByArtist -> searchBucketAlbumsByArtist(udn, query)
412 SearchBucketKind.Artists -> searchBucketArtists(udn, query)
413 SearchBucketKind.TracksByTitle -> searchBucketTracksByTitle(udn, query)
414 SearchBucketKind.TracksByArtist -> searchBucketTracksByArtist(udn, query)
415 }
416 if (r !is BrowseResult.Success) return r
417 val keyOf: (DidlItem) -> String? = when (kind) {
418 SearchBucketKind.AlbumsByTitle,
419 SearchBucketKind.Artists,
420 SearchBucketKind.TracksByTitle -> { it -> it.title }
421 SearchBucketKind.AlbumsByArtist -> { it ->
422 (it as? DidlItem.Container)?.artist ?: it.title
423 }
424 SearchBucketKind.TracksByArtist -> { it ->
425 (it as? DidlItem.Track)?.let { t -> t.artist ?: t.albumArtist } ?: it.title
426 }
427 }
428 return BrowseResult.Success(
429 items = rankedSort(r.items, query, keyOf),
430 totalMatches = r.totalMatches,
431 )
432 }
433
434 /**
435 * Fetch [count] scattered random albums. Probes for TotalMatches, picks [count] unique
436 * random offsets, fires all the tiny single-item fetches in parallel. Per-item fetch
437 * rather than a random slice because slicing returns consecutive title-sorted albums —
438 * "random" in the coarse sense but the user wants truly scattered results.
439 */
440 suspend fun randomAlbums(udn: String, count: Int): BrowseResult =
441 randomFromFullList(udn, ALBUM_CRITERIA, count)
442
443 /** Fetch [count] scattered random tracks (same approach as [randomAlbums]). */
444 suspend fun randomTracks(udn: String, count: Int): BrowseResult =
445 randomFromFullList(udn, TRACK_CRITERIA, count)
446
447 /** Fetch [count] random tracks whose genre tag contains [genreName].
448 *
449 * Uses a targeted Search criteria rather than the full-library cache
450 * that [randomTracks] uses, because genre-scoped results are a subset
451 * and the full cache doesn't filter by genre. The trade-off: first
452 * call for a new genre hits the server; repeated calls use the random
453 * cache keyed by udn + genre criteria.
454 *
455 * 42 is the default count — the Hitchhiker's Guide "party mode" number
456 * already established in the desktop build. */
457 suspend fun randomTracksByGenre(udn: String, genreName: String, count: Int = 42): BrowseResult {
458 val escaped = genreName.upnpEscape()
459 val criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
460 "and upnp:genre contains \"$escaped\""
461 return randomFromFullList(udn, criteria, count)
462 }
463
464 /**
465 * Uniform random sample of [count] items matching [criteria], without
466 * ever loading the library: one Search for the total (cached 10 minutes,
467 * keyed by `udn + criteria`), then one single-item Search per random
468 * offset across the whole range — so every one of 70,000 tracks is
469 * equally likely — with [RANDOM_CONCURRENCY] requests in flight at a
470 * time. Offsets are over-requested by 50 % to leave spares for the
471 * genre exclusions, in up to [MAX_RANDOM_TOPUP_WAVES] rounds.
472 *
473 * (Until 2026-09-02 this comment described a load-the-whole-list design
474 * that the code never implemented, and the concurrency was 8; the
475 * maintainer ruled: keep the per-offset design and the fetch count, lower
476 * the concurrency to 2, tell the truth here.)
477 *
478 * Invalidate via [invalidateRandomCache] on server switch.
479 */
480 private suspend fun randomFromFullList(
481 udn: String,
482 criteria: String,
483 count: Int,
484 ): BrowseResult = coroutineScope {
485 if (count <= 0) return@coroutineScope BrowseResult.Success(emptyList(), 0L)
486
487 val cacheKey = "$udn|$criteria"
488 val now = System.currentTimeMillis()
489
490 // Phase 1: determine totalMatches (cached). We only need the count, not items.
491 // Cache hit → skip straight to Phase 2.
492 val total: Long = run {
493 val cached = synchronized(randomCacheLock) {
494 val entry = randomCountCache[cacheKey]
495 if (entry != null && now - entry.fetchedAt < RANDOM_CACHE_TTL_MS) entry.total else null
496 }
497 if (cached != null) return@run cached
498
499 // Probe the server for totalMatches. Most servers return the true total
500 // in the first response regardless of maxResults, but MinimServer in
501 // particular can report a lower number if it couldn't fulfill the request.
502 // Using filter="@id" makes the payload tiny — we're just after the count.
503 val probe = search(
504 udn, criteria,
505 firstResult = 0L,
506 maxResults = 1L,
507 filter = "@id",
508 )
509 val t = when (probe) {
510 is BrowseResult.Failure -> return@coroutineScope probe
511 is BrowseResult.Success -> probe.totalMatches
512 }
513 synchronized(randomCacheLock) {
514 randomCountCache[cacheKey] = RandomCountCacheEntry(total = t, fetchedAt = now)
515 }
516 t
517 }
518
519 if (total <= 0L) return@coroutineScope BrowseResult.Success(emptyList(), 0L)
520
521 // Load exclusion list once up-front. Case-insensitive matching later.
522 val excludedGenres = settings.randomExcludedGenres.first()
523 .map { it.trim().lowercase() }
524 .filter { it.isNotEmpty() }
525 .toSet()
526
527 val target = count.toLong().coerceAtMost(total).toInt()
528 val rng = kotlin.random.Random
529 val semaphore = kotlinx.coroutines.sync.Semaphore(RANDOM_CONCURRENCY)
530 // Include upnp:genre so we can filter by it after fetch. Without this the
531 // server won't send genre in the DIDL and we'd have no way to exclude.
532 val filter = "dc:title,upnp:class,upnp:artist,dc:creator,upnp:album,upnp:genre," +
533 "upnp:albumArtURI,res,res@protocolInfo,res@duration,res@sampleFrequency," +
534 "res@bitsPerSample,res@bitrate,res@size,res@nrAudioChannels"
535
536 // Collect sampled items across one or more waves of offset fetches. Each wave
537 // asks for up to [target - have.size] fresh offsets; filters out already-used
538 // offsets, excluded-genre items, and anything the server returned twice (shouldn't
539 // happen but we dedupe defensively). Bail after MAX_WAVES if we still fall short
540 // — usually the user's exclusion list eliminates more than the library can top up.
541 val usedOffsets = mutableSetOf<Long>()
542 val collected = mutableListOf<DidlItem>()
543 val seenIds = mutableSetOf<String>()
544
545 var wave = 0
546 while (collected.size < target && wave < MAX_RANDOM_TOPUP_WAVES) {
547 val need = target - collected.size
548 val freshOffsets = mutableSetOf<Long>()
549 // Over-request 50% to account for exclusions; minimum +2 so tiny requests
550 // still get a replacement buffer.
551 val overRequest = need + (need / 2).coerceAtLeast(2)
552 var attempts = 0
553 while (freshOffsets.size < overRequest && attempts < overRequest * 8) {
554 val o = rng.nextLong(0, total)
555 if (o !in usedOffsets) freshOffsets.add(o)
556 attempts++
557 }
558 if (freshOffsets.isEmpty()) break // entire offset space exhausted
559 usedOffsets.addAll(freshOffsets)
560
561 val waveItems = freshOffsets.map { offset ->
562 async {
563 semaphore.withPermit {
564 val r = search(
565 udn, criteria,
566 firstResult = offset,
567 maxResults = 1L,
568 filter = filter,
569 )
570 (r as? BrowseResult.Success)?.items?.firstOrNull()
571 }
572 }
573 }.awaitAll().filterNotNull()
574
575 for (item in waveItems) {
576 if (collected.size >= target) break
577 if (item.id in seenIds) continue
578 if (matchesExcludedGenre(item, excludedGenres)) continue
579 seenIds.add(item.id)
580 collected.add(item)
581 }
582 wave++
583 }
584
585 BrowseResult.Success(collected, total)
586 }
587
588 /** True if the item's upnp:genre matches any entry in [excludedLowercase]. Uses
589 * [parseGenres] to tokenize, then does a case-insensitive membership check against
590 * the excluded set. Consistent with how the NowPlaying genre picker splits. */
591 private fun matchesExcludedGenre(
592 item: DidlItem,
593 excludedLowercase: Set<String>,
594 ): Boolean {
595 if (excludedLowercase.isEmpty()) return false
596 val genreRaw = when (item) {
597 is DidlItem.Track -> item.genre
598 is DidlItem.Container -> item.extras["genre"]
599 }
600 return dev.stave.domain.model.parseGenres(genreRaw).any { token ->
601 token.lowercase() in excludedLowercase
602 }
603 }
604
605 /** Drop the random-count cache. Call when the user switches servers or rescans,
606 * since cached counts belong to the previous library. */
607 fun invalidateRandomCache() {
608 synchronized(randomCacheLock) { randomCountCache.clear() }
609 }
610
611 /** Cached library item count per (udn, criteria). We don't cache the items
612 * themselves — they're fetched fresh on every random request by offset. */
613 private data class RandomCountCacheEntry(
614 val total: Long,
615 val fetchedAt: Long,
616 )
617
618 private val randomCountCache = mutableMapOf<String, RandomCountCacheEntry>()
619 private val randomCacheLock = Any()
620
621 /** Escape backslashes and double-quotes for UPnP Search string literals.
622 * Order matters: backslashes first so we don't re-escape our own escapes. */
623 private fun String.upnpEscape(): String = replace("\\", "\\\\").replace("\"", "\\\"")
624
625 private fun RemoteDevice.cdService(): Service<RemoteDevice, *>? =
626 findService(UDAServiceType("ContentDirectory", 1))
627
628 companion object {
629 const val ROOT = "0"
630 // Per-Browse-call timeout. Prevents stuck SOAP responses (server crashed
631 // mid-call, container ID stale from previous server session) from
632 // wedging the UI on jUPnP's 60s+ HTTP timeout. 12s is generous for a
633 // real response on slow networks.
634 private const val BROWSE_TIMEOUT_MS = 12_000L
635 private const val ALBUM_CRITERIA =
636 "upnp:class derivedfrom \"object.container.album.musicAlbum\""
637 private const val TRACK_CRITERIA =
638 "upnp:class derivedfrom \"object.item.audioItem\""
639 // Random-count cache TTL. totalMatches is stable enough that caching it for
640 // 10 minutes is safe; worst case is the user adds new items and they don't
641 // appear in random until TTL expires.
642 private const val RANDOM_CACHE_TTL_MS = 10L * 60L * 1000L
643 // Max parallel per-offset RPCs during random-sample phase. 2 (was 8;
644 // design decision 2026-09-02) — MinimServer on a NAS does not like
645 // eight at once, and a few seconds per Music Now press is fine.
646 private const val RANDOM_CONCURRENCY = 2
647 // Max number of top-up waves when exclusions reduce the sample below target.
648 // 3 waves × 1.5× over-request is enough to replace roughly 60% exclusion-rate
649 // without infinite-looping when the user has excluded most of their library.
650 private const val MAX_RANDOM_TOPUP_WAVES = 3
651 }
652}
653
654/** The five buckets surfaced by [ContentDirectoryClient.searchBucketCounts].
655 * Rendered as a five-row summary in the live-search pane. */
656enum class SearchBucketKind {
657 AlbumsByTitle, AlbumsByArtist, Artists, TracksByTitle, TracksByArtist;
658
659 /** Human-readable label used in the bucket-row UI. */
660 fun label(): String = when (this) {
661 AlbumsByTitle -> "Albums: title"
662 AlbumsByArtist -> "Albums: artist"
663 Artists -> "Artists"
664 TracksByTitle -> "Tracks: title"
665 TracksByArtist -> "Tracks: artist"
666 }
667}
668
669data class BucketCounts(
670 val albumsByTitle: Long,
671 val albumsByArtist: Long,
672 val artists: Long,
673 val tracksByTitle: Long,
674 val tracksByArtist: Long,
675) {
676 /** Count for [kind] — lets the UI iterate over SearchBucketKind.values()
677 * and ask this one source for each number. */
678 fun count(kind: SearchBucketKind): Long = when (kind) {
679 SearchBucketKind.AlbumsByTitle -> albumsByTitle
680 SearchBucketKind.AlbumsByArtist -> albumsByArtist
681 SearchBucketKind.Artists -> artists
682 SearchBucketKind.TracksByTitle -> tracksByTitle
683 SearchBucketKind.TracksByArtist -> tracksByArtist
684 }
685
686 /** True when every bucket returned zero matches. UI uses this to show
687 * "No matches" rather than five rows of (0). */
688 fun isAllEmpty(): Boolean = albumsByTitle == 0L && albumsByArtist == 0L &&
689 artists == 0L && tracksByTitle == 0L && tracksByArtist == 0L
690}
691
692/**
693 * Synthetic container id encoding a (bucket-kind, query) pair. The Browse
694 * infrastructure navigates by container id; this prefix lets us route a
695 * bucket-row click through the same onContainerClick path as a real
696 * container, with the ViewModel intercepting and expanding into a
697 * search result instead of calling the server's Browse action.
698 *
699 * Format: "earhorn-search:{Kind}:{query}"
700 *
701 * The trailing query is taken verbatim — it can contain colons; parsing
702 * uses splitN(3) so only the first two colons split. Real UPnP container
703 * ids don't start with "earhorn-search:" so collisions are impossible.
704 */
705object SearchBucketContainerId {
706 private const val PREFIX = "earhorn-search:"
707
708 fun encode(kind: SearchBucketKind, query: String): String =
709 "$PREFIX${kind.name}:$query"
710
711 fun decode(id: String): Pair<SearchBucketKind, String>? {
712 if (!id.startsWith(PREFIX)) return null
713 val rest = id.removePrefix(PREFIX)
714 val colon = rest.indexOf(':')
715 if (colon < 0) return null
716 val kindName = rest.substring(0, colon)
717 val query = rest.substring(colon + 1)
718 val kind = runCatching { SearchBucketKind.valueOf(kindName) }.getOrNull() ?: return null
719 return kind to query
720 }
721}