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

ContentDirectoryClient.kt

package dev.stave.upnp

import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.withPermit
import org.jupnp.model.action.ActionInvocation
import org.jupnp.model.message.UpnpResponse
import org.jupnp.model.meta.RemoteDevice
import org.jupnp.model.meta.Service
import org.jupnp.model.types.UDAServiceType
import org.jupnp.support.contentdirectory.callback.Browse
import org.jupnp.support.contentdirectory.callback.Search
import org.jupnp.support.model.BrowseFlag
import org.jupnp.support.model.DIDLContent
import org.jupnp.support.model.SortCriterion
import kotlin.coroutines.resume

class ContentDirectoryClient(
    private val holder: UpnpServiceHolder,
    private val parser: DidlParser,
    private val settings: dev.stave.data.SettingsStore,
) {

    /** Browse the children of [containerId] on the server identified by [udn]. */
    suspend fun browse(
        udn: String,
        containerId: String = ROOT,
    ): BrowseResult {
        val controlPoint = holder.controlPoint
            ?: return BrowseResult.Failure("UPnP service not bound yet")
        val device = holder.findDevice(udn)
            ?: return BrowseResult.Failure("Server no longer in registry")
        val service = device.cdService()
            ?: return BrowseResult.Failure("Server does not expose a ContentDirectory service")

        // 12 s guard on browse only (Android has it; search stays unbounded
        // by the design decision).
        return kotlinx.coroutines.withTimeoutOrNull(BROWSE_TIMEOUT_MS) { suspendCancellableCoroutine { cont ->
            val callback = object : Browse(
                service,
                containerId,
                BrowseFlag.DIRECT_CHILDREN,
                "*",
                0L,
                null as Long?,
                SortCriterion(true, "dc:title")
            ) {
                override fun received(invocation: ActionInvocation<*>?, didl: DIDLContent?) {
                    val items = didl?.let(parser::parse).orEmpty()
                    if (cont.isActive) cont.resume(BrowseResult.Success(items))
                }

                override fun updateStatus(status: Status?) { /* no-op */ }

                override fun failure(
                    invocation: ActionInvocation<*>?,
                    operation: UpnpResponse?,
                    defaultMsg: String?
                ) {
                    if (cont.isActive) cont.resume(
                        BrowseResult.Failure(defaultMsg ?: "Browse failed")
                    )
                }
            }
            controlPoint.execute(callback)
        } } ?: BrowseResult.Failure("Browse timed out")
    }

    /**
     * Fetch a container's own parent ID via a BrowseFlag.METADATA lookup. Unlike a normal
     * DIRECT_CHILDREN browse (which returns children), METADATA returns the object itself.
     * Used by the DetailScreen "More" button to navigate from an album up to whatever the
     * album's parent is — typically the artist container in MinimServer's default tree, but
     * sometimes a genre or folder depending on how the user arrived. Returns null if the
     * server rejects the call or the object has no parent (root).
     */
    suspend fun getContainerParent(udn: String, containerId: String): String? {
        val controlPoint = holder.controlPoint ?: return null
        val device = holder.findDevice(udn) ?: return null
        val service = device.cdService() ?: return null

        return suspendCancellableCoroutine { cont ->
            val callback = object : Browse(
                service,
                containerId,
                BrowseFlag.METADATA,
                "*",
                0L,
                null as Long?,
                SortCriterion(true, "dc:title")
            ) {
                override fun received(invocation: ActionInvocation<*>?, didl: DIDLContent?) {
                    val parent = didl?.containers?.firstOrNull()?.parentID
                        ?: didl?.items?.firstOrNull()?.parentID
                    // "-1" is the UPnP convention for "no parent" (root). Treat it as null.
                    val normalized = parent?.takeUnless { it.isBlank() || it == "-1" }
                    if (cont.isActive) cont.resume(normalized)
                }

                override fun updateStatus(status: Status?) { /* no-op */ }

                override fun failure(
                    invocation: ActionInvocation<*>?,
                    operation: UpnpResponse?,
                    defaultMsg: String?
                ) {
                    if (cont.isActive) cont.resume(null)
                }
            }
            controlPoint.execute(callback)
        }
    }

    /**
     * Run a UPnP ContentDirectory Search() against [containerId] (root "0" by default).
     * [criteria] is a UPnP Search expression, e.g.
     *   `upnp:class derivedfrom "object.container.album.musicAlbum" and upnp:artist = "Glenn Gould"`
     */
    suspend fun search(
        udn: String,
        criteria: String,
        containerId: String = ROOT,
        firstResult: Long = 0L,
        maxResults: Long? = null,
        filter: String = "*",
    ): BrowseResult {
        val controlPoint = holder.controlPoint
            ?: return BrowseResult.Failure("UPnP service not bound yet")
        val device = holder.findDevice(udn)
            ?: return BrowseResult.Failure("Server no longer in registry")
        val service = device.cdService()
            ?: return BrowseResult.Failure("Server does not expose a ContentDirectory service")

        return suspendCancellableCoroutine { cont ->
            // Ported from Android (Android parity, 2026-10-29, D13b): jUPnP's Search
            // callback constructor throws IllegalArgumentException when the
            // server has no Search action (or lacks one of its arguments).
            // Report it as this client's normal Failure instead of throwing —
            // otherwise it escapes into Browse (spinner forever) and into the
            // Now Playing enrichment flow (which then dies for the session).
            val callback = try {
                object : Search(service, containerId, criteria, filter, firstResult, maxResults) {
                    override fun received(invocation: ActionInvocation<*>?, didl: DIDLContent?) {
                        val items = didl?.let(parser::parse).orEmpty()
                        val total = readTotalMatches(invocation) ?: items.size.toLong()
                        if (cont.isActive) cont.resume(BrowseResult.Success(items, total))
                    }

                    override fun updateStatus(status: Status?) { /* no-op */ }

                    override fun failure(
                        invocation: ActionInvocation<*>?,
                        operation: UpnpResponse?,
                        defaultMsg: String?
                    ) {
                        if (cont.isActive) cont.resume(
                            BrowseResult.Failure(defaultMsg ?: "Search failed")
                        )
                    }
                }
            } catch (e: IllegalArgumentException) {
                if (cont.isActive) cont.resume(
                    BrowseResult.Failure("$SEARCH_UNSUPPORTED_PREFIX${e.message ?: "no Search action"}")
                )
                return@suspendCancellableCoroutine
            }
            controlPoint.execute(callback)
        }
    }

    private fun readTotalMatches(invocation: ActionInvocation<*>?): Long? {
        val raw = invocation?.getOutput("TotalMatches")?.value ?: return null
        return when (raw) {
            is Long -> raw
            is Number -> raw.toLong()
            else -> raw.toString().toLongOrNull()
        }
    }

    /** Convenience — find albums whose artist matches [artistName]. */
    suspend fun searchAlbumsByArtist(udn: String, artistName: String): BrowseResult {
        val escaped = artistName.upnpEscape()
        val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
            "and upnp:artist = \"$escaped\""
        return search(udn, criteria)
    }

    /** Every track where artist/albumArtist/creator matches [artistName]. Different from
     *  searchAlbumsByArtist — this returns individual items rather than album containers,
     *  so "Tracks by Bach" lists every Bach piece across every album. */
    suspend fun searchTracksByArtist(udn: String, artistName: String): BrowseResult {
        val escaped = artistName.upnpEscape()
        val criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
            "and upnp:artist = \"$escaped\""
        return search(udn, criteria)
    }

    /** Convenience — find albums whose genre contains [genreName]. */
    suspend fun searchAlbumsByGenre(udn: String, genreName: String): BrowseResult {
        val escaped = genreName.upnpEscape()
        val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
            "and upnp:genre contains \"$escaped\""
        return search(udn, criteria)
    }

    /** Find albums whose title exactly matches [albumTitle]. */
    suspend fun searchAlbumsByTitle(udn: String, albumTitle: String): BrowseResult {
        val escaped = albumTitle.upnpEscape()
        val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
            "and dc:title = \"$escaped\""
        return search(udn, criteria)
    }

    /** Convenience — find tracks (audioItems) whose upnp:album equals [albumTitle]
     *  (ported from Android). Used by the "more from this album" path: instead of
     *  relying on the track's parentId (which on tag-based servers like MinimServer
     *  points to a virtual aggregate container that holds thousands of items rather
     *  than just the album's tracks), we search by the album text tag — which the
     *  user's mental model of "this album" actually corresponds to. The server
     *  returns them in title order; callers sort by disc and track number. */
    suspend fun searchTracksByAlbum(udn: String, albumTitle: String, artist: String? = null): BrowseResult {
        val escaped = albumTitle.upnpEscape()
        // `=` rather than `contains` — album titles are full names; we don't want
        // "Lord of the Rings" to also match "Lord of the Rings: The Two Towers".
        var criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
            "and upnp:album = \"$escaped\""
        // When an artist is provided, add it to the filter so "Greatest Hits"
        // by Queen doesn't also pull in "Greatest Hits" by Fleetwood Mac.
        if (!artist.isNullOrBlank()) {
            val escapedArtist = artist.upnpEscape()
            criteria += " and upnp:artist = \"$escapedArtist\""
        }
        return search(udn, criteria)
    }

    /** Find albums whose release date contains [year]. Works with MinimServer's
     *  YYYY-MM-DD format as well as bare-year dates. */
    suspend fun searchAlbumsByYear(udn: String, year: String): BrowseResult {
        val escaped = year.upnpEscape()
        val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
            "and dc:date contains \"$escaped\""
        return search(udn, criteria)
    }

    suspend fun searchByTitle(udn: String, query: String): BrowseResult {
        val escaped = query.upnpEscape()
        val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
            "and (dc:title contains \"$escaped\" or upnp:artist contains \"$escaped\")"
        return search(udn, criteria)
    }

    /** Combined search: albums matching title/artist, plus individual tracks matching
     *  title. Results are merged into a single list with albums first, then tracks;
     *  within each group the best matches come first (Android's ranked sort: starts
     *  with the query, then a word starting with it, then anywhere; alphabetical
     *  within a rank). Partial matches work — "bach" finds "J.S. Bach" albums and
     *  "Goldberg" tracks by Bach. Used by the desktop search screen. */
    suspend fun searchAll(udn: String, query: String): BrowseResult = coroutineScope {
        val escaped = query.upnpEscape()
        val albumCriteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
            "and (dc:title contains \"$escaped\" or upnp:artist contains \"$escaped\")"
        val trackCriteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
            "and (dc:title contains \"$escaped\" or upnp:artist contains \"$escaped\")"
        // Not runCatching: that would swallow CancellationException (a newer
        // search replacing this one) and turn it into a Failure.
        suspend fun guarded(criteria: String): BrowseResult = try {
            search(udn, criteria)
        } catch (c: kotlinx.coroutines.CancellationException) {
            throw c
        } catch (e: Exception) {
            BrowseResult.Failure(e.message ?: "Search failed")
        }
        val (albumResult, trackResult) = listOf(
            async { guarded(albumCriteria) },
            async { guarded(trackCriteria) },
        ).awaitAll()

        val albums = (albumResult as? BrowseResult.Success)?.items.orEmpty()
        val tracks = (trackResult as? BrowseResult.Success)?.items.orEmpty()
        val combined = rankedSort(albums, query) + rankedSort(tracks, query)
        if (combined.isEmpty() && albumResult is BrowseResult.Failure) albumResult
        else BrowseResult.Success(combined)
    }

    /**
     * Rank a string against a query for search-result ordering (verbatim from
     * Android's ContentDirectoryClient).
     *   0 = starts with query (case-insensitive; "the"/"a"/"an" stripped)
     *   1 = a word inside starts with query
     *   2 = query appears anywhere
     *   3 = no match (safe fallback — server-filtered results shouldn't hit this)
     */
    private fun matchRank(s: String?, query: String): Int {
        val q = query.lowercase().trim()
        if (q.isEmpty()) return 2
        val t = (s ?: "").lowercase()
        val stripped = t.removePrefix("the ").removePrefix("a ").removePrefix("an ")
        if (stripped.startsWith(q) || t.startsWith(q)) return 0
        var i = 0
        while (i < t.length) {
            val idx = t.indexOf(q, i)
            if (idx < 0) break
            if (idx == 0 || !t[idx - 1].isLetterOrDigit()) return 1
            i = idx + 1
        }
        return if (t.contains(q)) 2 else 3
    }

    // ---- Bucketed search (ported from Android) --------------------------------
    //
    // BubbleUPnP-style bucket search. Five class+property-scoped queries, one per
    // bucket. The user sees counts in a compact five-row summary and opens
    // whichever bucket they want. [searchAll] stays as the flat fallback for
    // servers that can't count.

    private fun bucketCriteria(kind: SearchBucketKind, query: String): String {
        val e = query.upnpEscape()
        return when (kind) {
            SearchBucketKind.AlbumsByTitle ->
                "upnp:class derivedfrom \"object.container.album.musicAlbum\" and dc:title contains \"$e\""
            SearchBucketKind.AlbumsByArtist ->
                "upnp:class derivedfrom \"object.container.album.musicAlbum\" and upnp:artist contains \"$e\""
            SearchBucketKind.Artists ->
                "upnp:class derivedfrom \"object.container.person.musicArtist\" and dc:title contains \"$e\""
            SearchBucketKind.TracksByTitle ->
                "upnp:class derivedfrom \"object.item.audioItem\" and dc:title contains \"$e\""
            SearchBucketKind.TracksByArtist ->
                "upnp:class derivedfrom \"object.item.audioItem\" and upnp:artist contains \"$e\""
        }
    }

    /**
     * Fast aggregate: fire all five bucket searches in parallel with
     * maxResults=1 and read totalMatches from each response. The server
     * evaluates each criteria but only serializes one item, which is the
     * minimum round-trip for a count.
     *
     * As on Android, a bucket that couldn't be counted reads 0. Unlike Android,
     * those buckets are also listed in [BucketCounts.uncounted] — a failed leg,
     * or a server that returned matches but no TotalMatches (UPnP's "0 =
     * unknown") — so the caller can fall back to the flat [searchAll] result
     * instead of claiming "no matches".
     */
    suspend fun searchBucketCounts(udn: String, query: String): BucketCounts =
        coroutineScope {
            // Not runCatching: that would swallow CancellationException (a newer
            // search replacing this one).
            suspend fun countFor(kind: SearchBucketKind): Long? {
                val r = try {
                    search(udn, bucketCriteria(kind, query), maxResults = 1L)
                } catch (c: kotlinx.coroutines.CancellationException) {
                    throw c
                } catch (e: Exception) {
                    System.err.println("[Earhorn/Search] count for $kind threw: $e")
                    null
                }
                val ok = r as? BrowseResult.Success ?: return null
                return when {
                    ok.totalMatches > 0L -> ok.totalMatches
                    ok.items.isEmpty() -> 0L
                    else -> null // matches, but the server didn't say how many
                }
            }
            val counts = SearchBucketKind.entries
                .map { kind -> kind to async { countFor(kind) } }
                .associate { (kind, d) -> kind to d.await() }
            BucketCounts(
                albumsByTitle = counts[SearchBucketKind.AlbumsByTitle] ?: 0L,
                albumsByArtist = counts[SearchBucketKind.AlbumsByArtist] ?: 0L,
                artists = counts[SearchBucketKind.Artists] ?: 0L,
                tracksByTitle = counts[SearchBucketKind.TracksByTitle] ?: 0L,
                tracksByArtist = counts[SearchBucketKind.TracksByArtist] ?: 0L,
                uncounted = counts.filterValues { it == null }.keys,
            )
        }

    /** Fetch the full items for one bucket. Sort key matches the user's
     *  intent: title-searched buckets rank by title, artist-searched
     *  buckets rank by the artist field (falling back to title). */
    suspend fun expandSearchBucket(
        udn: String,
        kind: SearchBucketKind,
        query: String,
    ): BrowseResult {
        val r = search(udn, bucketCriteria(kind, query))
        if (r !is BrowseResult.Success) return r
        val keyOf: (dev.stave.domain.model.DidlItem) -> String? = when (kind) {
            SearchBucketKind.AlbumsByTitle,
            SearchBucketKind.Artists,
            SearchBucketKind.TracksByTitle -> { it -> it.title }
            SearchBucketKind.AlbumsByArtist -> { it ->
                (it as? dev.stave.domain.model.DidlItem.Container)?.artist ?: it.title
            }
            SearchBucketKind.TracksByArtist -> { it ->
                (it as? dev.stave.domain.model.DidlItem.Track)?.let { t -> t.artist ?: t.albumArtist } ?: it.title
            }
        }
        return BrowseResult.Success(
            items = rankedSortBy(r.items, query, keyOf),
            totalMatches = r.totalMatches,
        )
    }

    /** Sort items by relevance rank, then alphabetically within rank (Android's
     *  `rankedSort`). The caller supplies [keyOf] so the same helper serves
     *  title-ranked buckets and artist-ranked buckets. */
    private fun rankedSortBy(
        items: List<dev.stave.domain.model.DidlItem>,
        query: String,
        keyOf: (dev.stave.domain.model.DidlItem) -> String?,
    ): List<dev.stave.domain.model.DidlItem> =
        items.sortedWith(
            compareBy(
                { matchRank(keyOf(it), query) },
                { (keyOf(it) ?: it.title).lowercase() },
                { it.title.lowercase() },
            )
        )

    /** Sort by relevance, then alphabetically within a rank. searchAll matches on
     *  title OR artist, so an item ranks by whichever of the two matches better. */
    private fun rankedSort(
        items: List<dev.stave.domain.model.DidlItem>,
        query: String,
    ): List<dev.stave.domain.model.DidlItem> {
        fun artistOf(it: dev.stave.domain.model.DidlItem): String? = when (it) {
            is dev.stave.domain.model.DidlItem.Container -> it.artist
            is dev.stave.domain.model.DidlItem.Track -> it.artist ?: it.albumArtist
        }
        return items.sortedWith(
            compareBy(
                { minOf(matchRank(it.title, query), matchRank(artistOf(it), query)) },
                { it.title.lowercase() },
            )
        )
    }

    /**
     * Fetch [count] scattered random albums. Two-phase: Phase 1 probes for the library
     * size (tiny payload, cached 10 minutes); Phase 2 fires parallel Search RPCs for
     * random offsets, throttled by a semaphore so we don't stampede the server.
     */
    suspend fun randomAlbums(udn: String, count: Int): BrowseResult =
        randomFromFullList(udn, ALBUM_CRITERIA, count)

    /** Fetch [count] scattered random tracks (same approach as [randomAlbums]). */
    suspend fun randomTracks(udn: String, count: Int): BrowseResult =
        randomFromFullList(udn, TRACK_CRITERIA, count)

    /**
     * Fetch [count] scattered random tracks tagged with [genreName] — the
     * per-genre party action ("Play 42 random <Genre> tracks").
     *
     * `contains` rather than `=` because multi-genre tags (`"Jazz; Acid
     * Jazz"`) are common on MinimServer-style backends and an exact match
     * would silently miss them.
     *
     * The Settings genre-exclusion list deliberately does NOT apply here:
     * the user explicitly chose this genre, so filtering it out would be
     * contradictory. This was an explicit cross-platform product decision,
     * not an oversight — the exclusions exist to keep unwanted genres out
     * of *library-wide* random, and this action isn't that.
     *
     * The totalMatches probe cache in [randomFromFullList] is keyed per
     * (udn, criteria), so each genre gets its own cached count — repeated
     * dispatches for the same genre skip Phase 1 entirely.
     */
    suspend fun randomTracksByGenre(udn: String, genreName: String, count: Int): BrowseResult =
        randomFromFullList(
            udn,
            "$TRACK_CRITERIA and upnp:genre contains \"${genreName.upnpEscape()}\"",
            count,
            applyExclusions = false,
        )

    /**
     * Scattered-random strategy.
     *
     * Phase 1 — totalMatches probe. We need to know the library size to pick random
     * offsets, but we don't need any items. A single Search with `filter="@id"` and
     * `maxResults=1` costs almost nothing and returns the total. Result cached for
     * 10 minutes per (udn, criteria), so subsequent Random dispatches skip Phase 1.
     *
     * Phase 2 — per-offset fetches. Pick [count] distinct random offsets and issue a
     * one-item Search for each. Parallelised via coroutines but throttled by a
     * Semaphore of [RANDOM_CONCURRENCY] permits — empirically 8 is the sweet spot on
     * MinimServer: enough parallelism to keep 50-item Random in the low seconds, not
     * so many that we jam the server's dispatcher (which is what made the naive
     * unbounded-parallel earlier version hit 60+ seconds).
     *
     * A targeted filter on Phase 2 keeps each RPC's DIDL payload compact — we ask for
     * just the metadata the UI actually uses, not the full "*" a room sink.
     *
     * Why not bulk-fetch the whole list and sample client-side? It also works and in
     * some ways is simpler, but on a 5000-album library the DIDL payload is several
     * megabytes of XML to serialize, ship, and parse — not free even if the server's
     * search itself is fast. The two-phase approach has a cheap probe + small per-item
     * payloads, which scales better.
     */
    private suspend fun randomFromFullList(
        udn: String,
        criteria: String,
        count: Int,
        /** False for user-directed criteria (e.g. an explicitly chosen genre)
         *  where filtering by the Settings exclusion list would contradict
         *  the user's own request. Default true preserves library-wide
         *  random behavior. */
        applyExclusions: Boolean = true,
    ): BrowseResult = coroutineScope {
        if (count <= 0) return@coroutineScope BrowseResult.Success(emptyList(), 0L)

        val cacheKey = "$udn|$criteria"
        val now = System.currentTimeMillis()

        // Phase 1: totalMatches. Cache hit → skip probe.
        val total: Long = run {
            val cached = synchronized(randomCacheLock) {
                val entry = randomCountCache[cacheKey]
                if (entry != null && now - entry.fetchedAt < RANDOM_CACHE_TTL_MS) entry.total else null
            }
            if (cached != null) return@run cached

            val probe = search(
                udn, criteria,
                firstResult = 0L,
                maxResults = 1L,
                filter = "@id",
            )
            val t = when (probe) {
                is BrowseResult.Failure -> return@coroutineScope probe
                is BrowseResult.Success -> probe.totalMatches
            }
            synchronized(randomCacheLock) {
                randomCountCache[cacheKey] = RandomCountCacheEntry(total = t, fetchedAt = now)
            }
            t
        }

        if (total <= 0L) return@coroutineScope BrowseResult.Success(emptyList(), 0L)

        // Excluded-genre list, snapshotted once up-front — mid-fetch changes would
        // produce confusingly-inconsistent results. Empty when the caller opted
        // out (user-directed criteria — see the applyExclusions param), which
        // makes matchesExcludedGenre a guaranteed no-op below.
        val excludedGenres: Set<String> = if (!applyExclusions) emptySet() else
            settings.randomExcludedGenres.value
                .map { it.trim().lowercase() }
                .filter { it.isNotEmpty() }
                .toSet()

        val target = count.toLong().coerceAtMost(total).toInt()
        val rng = kotlin.random.Random
        val semaphore = kotlinx.coroutines.sync.Semaphore(RANDOM_CONCURRENCY)

        // Include upnp:genre so we have something to filter on. Without this the server
        // won't send genre in the DIDL and excluded-genre filtering would be a no-op.
        val filter = "dc:title,upnp:class,upnp:artist,dc:creator,upnp:album,upnp:genre," +
            "upnp:albumArtURI,res,res@protocolInfo,res@duration,res@sampleFrequency," +
            "res@bitsPerSample,res@bitrate,res@size,res@nrAudioChannels"

        // Wave loop. Each wave over-requests 50% (minimum +2) to absorb exclusions.
        // Track used offsets + seen item ids across waves to avoid re-picking the same
        // offset or duplicate item (some servers return duplicates on adjacent offsets
        // for reasons that are best not to investigate).
        val usedOffsets = mutableSetOf<Long>()
        val seenIds = mutableSetOf<String>()
        val collected = mutableListOf<dev.stave.domain.model.DidlItem>()
        var wave = 0
        while (collected.size < target && wave < MAX_RANDOM_TOPUP_WAVES) {
            val need = target - collected.size
            val overRequest = need + (need / 2).coerceAtLeast(2)
            val freshOffsets = mutableSetOf<Long>()
            var attempts = 0
            while (freshOffsets.size < overRequest && attempts < overRequest * 8) {
                val o = rng.nextLong(0, total)
                if (o !in usedOffsets) freshOffsets.add(o)
                attempts++
            }
            if (freshOffsets.isEmpty()) break   // offset space exhausted
            usedOffsets.addAll(freshOffsets)

            val waveItems = freshOffsets.map { offset ->
                async {
                    semaphore.withPermit {
                        val r = search(
                            udn, criteria,
                            firstResult = offset,
                            maxResults = 1L,
                            filter = filter,
                        )
                        (r as? BrowseResult.Success)?.items?.firstOrNull()
                    }
                }
            }.awaitAll().filterNotNull()

            for (item in waveItems) {
                if (collected.size >= target) break
                if (item.id in seenIds) continue
                if (matchesExcludedGenre(item, excludedGenres)) continue
                seenIds.add(item.id)
                collected.add(item)
            }
            wave++
        }

        BrowseResult.Success(collected, total)
    }

    /** Parses the item's upnp:genre (handling all the separator conventions via
     *  [dev.stave.domain.model.parseGenres]) and checks if any token is in the
     *  lowercase excluded set. Empty exclusion set → never matches (fast path). */
    private fun matchesExcludedGenre(
        item: dev.stave.domain.model.DidlItem,
        excludedLowercase: Set<String>,
    ): Boolean {
        if (excludedLowercase.isEmpty()) return false
        val genreRaw = when (item) {
            is dev.stave.domain.model.DidlItem.Track -> item.genre
            is dev.stave.domain.model.DidlItem.Container -> item.extras["genre"]
        }
        return dev.stave.domain.model.parseGenres(genreRaw).any { token ->
            token.lowercase() in excludedLowercase
        }
    }


    /** Drop the random-count cache. Call when a rescan invalidates the cached library
     *  size for the current server. */
    fun invalidateRandomCache() {
        synchronized(randomCacheLock) { randomCountCache.clear() }
    }

    /** Cached library item count per (udn, criteria). We don't cache the items
     *  themselves — they're fetched fresh on every random request by offset. */
    private data class RandomCountCacheEntry(
        val total: Long,
        val fetchedAt: Long,
    )

    private val randomCountCache = mutableMapOf<String, RandomCountCacheEntry>()
    private val randomCacheLock = Any()

    private fun String.upnpEscape(): String = replace("\\", "\\\\").replace("\"", "\\\"")

    private fun RemoteDevice.cdService(): Service<RemoteDevice, *>? =
        findService(UDAServiceType("ContentDirectory", 1))

    companion object {
        const val ROOT = "0"
        /** Prefix of the Failure message when the server has no usable Search
         *  action — callers match on it to show plain-language copy. */
        const val SEARCH_UNSUPPORTED_PREFIX = "Server does not support Search: "
        private const val BROWSE_TIMEOUT_MS = 12_000L
        private const val ALBUM_CRITERIA =
            "upnp:class derivedfrom \"object.container.album.musicAlbum\""
        private const val TRACK_CRITERIA =
            "upnp:class derivedfrom \"object.item.audioItem\""
        // Random-count cache TTL. totalMatches is stable enough that caching it for
        // 10 minutes is safe; worst case is the user adds new items and they don't
        // appear in random until TTL expires.
        private const val RANDOM_CACHE_TTL_MS = 10L * 60L * 1000L
        // Max parallel per-offset RPCs during random-sample phase. 8 is empirically
        // the sweet spot — enough parallelism to make 50 items feel fast (~6s instead
        // of 50s serial), not so many that we back up the server's dispatcher.
        /** design decision: two in flight, not eight; fetch count unchanged. */
        private const val RANDOM_CONCURRENCY = 2
        // Max top-up waves when exclusions thin the sample below target. 3 waves × 1.5×
        // over-request absorbs ~60% exclusion rate without infinite-looping on users
        // who've excluded most of their library.
        private const val MAX_RANDOM_TOPUP_WAVES = 3
    }
}

/** The five buckets surfaced by [ContentDirectoryClient.searchBucketCounts]
 *  (ported from Android). Rendered as a five-row summary for a search. */
enum class SearchBucketKind {
    AlbumsByTitle, AlbumsByArtist, Artists, TracksByTitle, TracksByArtist;

    /** Human-readable label used in the bucket-row UI. */
    fun label(): String = when (this) {
        AlbumsByTitle -> "Albums: title"
        AlbumsByArtist -> "Albums: artist"
        Artists -> "Artists"
        TracksByTitle -> "Tracks: title"
        TracksByArtist -> "Tracks: artist"
    }

    /** "1 album", "12 tracks"… for the bucket row's count line. */
    fun countLabel(n: Long): String {
        val noun = when (this) {
            AlbumsByTitle, AlbumsByArtist -> "album"
            Artists -> "artist"
            TracksByTitle, TracksByArtist -> "track"
        }
        return if (n == 1L) "1 $noun" else "$n ${noun}s"
    }
}

data class BucketCounts(
    val albumsByTitle: Long,
    val albumsByArtist: Long,
    val artists: Long,
    val tracksByTitle: Long,
    val tracksByArtist: Long,
    /** Buckets the server couldn't count (the search failed, or it returned
     *  matches without a total). Their count above reads 0. */
    val uncounted: Set<SearchBucketKind> = emptySet(),
) {
    /** Count for [kind] — lets the UI iterate over SearchBucketKind.entries
     *  and ask this one source for each number. */
    fun count(kind: SearchBucketKind): Long = when (kind) {
        SearchBucketKind.AlbumsByTitle -> albumsByTitle
        SearchBucketKind.AlbumsByArtist -> albumsByArtist
        SearchBucketKind.Artists -> artists
        SearchBucketKind.TracksByTitle -> tracksByTitle
        SearchBucketKind.TracksByArtist -> tracksByArtist
    }

    /** True when every bucket returned zero matches. UI uses this to show
     *  "No matches" rather than five rows of (0). */
    fun isAllEmpty(): Boolean = albumsByTitle == 0L && albumsByArtist == 0L &&
        artists == 0L && tracksByTitle == 0L && tracksByArtist == 0L
}

/**
 * Synthetic container id encoding a (bucket-kind, query) pair (ported from
 * Android). The Browse infrastructure navigates by container id; this prefix
 * lets a bucket-row click go through the same navigate-into path as a real
 * container, with the ViewModel intercepting and expanding into a search
 * result instead of calling the server's Browse action.
 *
 * Format: "earhorn-search:{Kind}:{query}"
 *
 * The trailing query is taken verbatim — it can contain colons; parsing splits
 * at the first colon after the kind only. Real UPnP container ids don't start
 * with "earhorn-search:" so collisions are impossible.
 */
object SearchBucketContainerId {
    const val PREFIX = "earhorn-search:"

    fun encode(kind: SearchBucketKind, query: String): String =
        "$PREFIX${kind.name}:$query"

    fun decode(id: String): Pair<SearchBucketKind, String>? {
        if (!id.startsWith(PREFIX)) return null
        val rest = id.removePrefix(PREFIX)
        val colon = rest.indexOf(':')
        if (colon < 0) return null
        val kindName = rest.substring(0, colon)
        val query = rest.substring(colon + 1)
        val kind = SearchBucketKind.entries.firstOrNull { it.name == kindName } ?: return null
        return kind to query
    }
}