ContentDirectoryClient.kt
721 lines · 33.0 KB
Changed in: 0.5.0
| 1 | package dev.stave.upnp |
| 2 | |
| 3 | import dev.stave.domain.model.DidlItem |
| 4 | import kotlinx.coroutines.async |
| 5 | import kotlinx.coroutines.awaitAll |
| 6 | import kotlinx.coroutines.coroutineScope |
| 7 | import kotlinx.coroutines.flow.first |
| 8 | import kotlinx.coroutines.suspendCancellableCoroutine |
| 9 | import kotlinx.coroutines.sync.withPermit |
| 10 | import kotlinx.coroutines.withTimeoutOrNull |
| 11 | import org.jupnp.model.action.ActionInvocation |
| 12 | import org.jupnp.model.message.UpnpResponse |
| 13 | import org.jupnp.model.meta.RemoteDevice |
| 14 | import org.jupnp.model.meta.Service |
| 15 | import org.jupnp.model.types.UDAServiceType |
| 16 | import org.jupnp.support.contentdirectory.callback.Browse |
| 17 | import org.jupnp.support.contentdirectory.callback.Search |
| 18 | import org.jupnp.support.model.BrowseFlag |
| 19 | import org.jupnp.support.model.DIDLContent |
| 20 | import org.jupnp.support.model.SortCriterion |
| 21 | import javax.inject.Inject |
| 22 | import javax.inject.Singleton |
| 23 | import kotlin.coroutines.resume |
| 24 | |
| 25 | @Singleton |
| 26 | class 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. */ |
| 656 | enum 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 | |
| 669 | data 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 | */ |
| 705 | object 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 | } |


