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

ContentDirectoryClient.kt

package dev.stave.upnp

import dev.stave.domain.model.DidlItem
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeoutOrNull
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 javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume

@Singleton
class ContentDirectoryClient @Inject constructor(
    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].
     *
     *  Wrapped in a 12-second timeout because some servers (or stale container
     *  IDs from a previous server session) cause the SOAP callback to never
     *  fire — neither received() nor failure() — and without a timeout the
     *  caller waits on jUPnP's internal HTTP timeout (60s+). 12s is generous
     *  for a real response on slow networks but short enough to surface a
     *  stuck call as an error before the user gives up. */
    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")

        val result = withTimeoutOrNull(BROWSE_TIMEOUT_MS) {
            suspendCancellableCoroutine<BrowseResult> { 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)
            }
        }
        return result ?: BrowseResult.Failure(
            "Server didn't respond in ${BROWSE_TIMEOUT_MS / 1000}s — the container may have moved or the server may be busy."
        )
    }

    /**
     * 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?) {
                    // METADATA returns a single-element response — the object itself — placed
                    // in either containers or items. Read parentID off whichever is populated.
                    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"`
     *
     * [firstResult] and [maxResults] correspond to UPnP's pagination args. maxResults=null
     * means "server default" (often unlimited). We read totalMatches out of the action
     * response so Random can pick a random offset without fetching every match.
     *
     * Most servers support Search; MinimServer does. Returns items in the same shape as
     * browse(), so callers can render with the existing container/track rows.
     */
    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 ->
            // Maintainer, 2026-10-29: 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 (D13b).
            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()
                        // TotalMatches is declared ui4 in UPnP but jUPnP's Java surface varies —
                        // sometimes Long, sometimes UnsignedIntegerFourBytes, sometimes String.
                        // Fall through coercions rather than cast to a single type.
                        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("Server does not support Search: ${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].
     *
     *  Uses `upnp:artist` (not `upnp:albumArtist`) — the latter isn't a standard DIDL-Lite
     *  property and MinimServer silently ignores it, returning every album in the library
     *  (matching only the class filter). `upnp:artist` on an album container holds the
     *  album artist value, which is the semantic we want.
     *
     *  Equality rather than `contains` so "Smith" doesn't also match "Will Smith" or
     *  "Smith Westerns". We have the exact name from track metadata, so exact match works.
     */
    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 tracks (audioItems) whose upnp:album equals [albumTitle].
     *  Used by the "More tracks 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. */
    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)
    }

    /** Convenience — find albums whose genre contains [genreName]. */
    suspend fun searchAlbumsByGenre(udn: String, genreName: String): BrowseResult {
        val escaped = genreName.upnpEscape()
        // `contains` rather than `=` because tracks can have compound genre tags like
        // "Jazz / Fusion" that equality search would miss.
        val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
            "and upnp:genre contains \"$escaped\""
        return search(udn, criteria)
    }

    /**
     * BubbleUPnP-style bucket search. Five parallel class+property-scoped
     * queries, one per bucket. User sees counts in a compact 5-row summary
     * and drills into whichever bucket they want.
     */
    suspend fun searchBucketAlbumsByTitle(udn: String, query: String): BrowseResult {
        val escaped = query.upnpEscape()
        val criteria = "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
            "and dc:title contains \"$escaped\""
        return search(udn, criteria)
    }

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

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

    suspend fun searchBucketTracksByTitle(udn: String, query: String): BrowseResult {
        val escaped = query.upnpEscape()
        val criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
            "and dc:title contains \"$escaped\""
        return search(udn, criteria)
    }

    suspend fun searchBucketTracksByArtist(udn: String, query: String): BrowseResult {
        val escaped = query.upnpEscape()
        val criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
            "and upnp:artist contains \"$escaped\""
        return search(udn, criteria)
    }

    /**
     * Fast aggregate for the live-pane: 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.
     *
     * On failure in any leg, that bucket's count is 0. UI surfaces the
     * counts that came back; an all-zero result tells the user there's
     * no match rather than a cryptic error.
     */
    suspend fun searchBucketCounts(udn: String, query: String): BucketCounts =
        coroutineScope {
            suspend fun countFor(crit: String): Long {
                val r = runCatching { search(udn, crit, maxResults = 1L) }.getOrNull()
                return (r as? BrowseResult.Success)?.totalMatches ?: 0L
            }
            val e = query.upnpEscape()
            val a1 = async {
                countFor(
                    "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
                        "and dc:title contains \"$e\""
                )
            }
            val a2 = async {
                countFor(
                    "upnp:class derivedfrom \"object.container.album.musicAlbum\" " +
                        "and upnp:artist contains \"$e\""
                )
            }
            val a3 = async {
                countFor(
                    "upnp:class derivedfrom \"object.container.person.musicArtist\" " +
                        "and dc:title contains \"$e\""
                )
            }
            val a4 = async {
                countFor(
                    "upnp:class derivedfrom \"object.item.audioItem\" " +
                        "and dc:title contains \"$e\""
                )
            }
            val a5 = async {
                countFor(
                    "upnp:class derivedfrom \"object.item.audioItem\" " +
                        "and upnp:artist contains \"$e\""
                )
            }
            BucketCounts(
                albumsByTitle = a1.await(),
                albumsByArtist = a2.await(),
                artists = a3.await(),
                tracksByTitle = a4.await(),
                tracksByArtist = a5.await(),
            )
        }

    /**
     * Rank a string against a query for search-result ordering.
     *   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
    }

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

    /** 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 = when (kind) {
            SearchBucketKind.AlbumsByTitle -> searchBucketAlbumsByTitle(udn, query)
            SearchBucketKind.AlbumsByArtist -> searchBucketAlbumsByArtist(udn, query)
            SearchBucketKind.Artists -> searchBucketArtists(udn, query)
            SearchBucketKind.TracksByTitle -> searchBucketTracksByTitle(udn, query)
            SearchBucketKind.TracksByArtist -> searchBucketTracksByArtist(udn, query)
        }
        if (r !is BrowseResult.Success) return r
        val keyOf: (DidlItem) -> String? = when (kind) {
            SearchBucketKind.AlbumsByTitle,
            SearchBucketKind.Artists,
            SearchBucketKind.TracksByTitle -> { it -> it.title }
            SearchBucketKind.AlbumsByArtist -> { it ->
                (it as? DidlItem.Container)?.artist ?: it.title
            }
            SearchBucketKind.TracksByArtist -> { it ->
                (it as? DidlItem.Track)?.let { t -> t.artist ?: t.albumArtist } ?: it.title
            }
        }
        return BrowseResult.Success(
            items = rankedSort(r.items, query, keyOf),
            totalMatches = r.totalMatches,
        )
    }

    /**
     * Fetch [count] scattered random albums. Probes for TotalMatches, picks [count] unique
     * random offsets, fires all the tiny single-item fetches in parallel. Per-item fetch
     * rather than a random slice because slicing returns consecutive title-sorted albums —
     * "random" in the coarse sense but the user wants truly scattered results.
     */
    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] random tracks whose genre tag contains [genreName].
     *
     *  Uses a targeted Search criteria rather than the full-library cache
     *  that [randomTracks] uses, because genre-scoped results are a subset
     *  and the full cache doesn't filter by genre. The trade-off: first
     *  call for a new genre hits the server; repeated calls use the random
     *  cache keyed by udn + genre criteria.
     *
     *  42 is the default count — the Hitchhiker's Guide "party mode" number
     *  already established in the desktop build. */
    suspend fun randomTracksByGenre(udn: String, genreName: String, count: Int = 42): BrowseResult {
        val escaped = genreName.upnpEscape()
        val criteria = "upnp:class derivedfrom \"object.item.audioItem\" " +
            "and upnp:genre contains \"$escaped\""
        return randomFromFullList(udn, criteria, count)
    }

    /**
     * Uniform random sample of [count] items matching [criteria], without
     * ever loading the library: one Search for the total (cached 10 minutes,
     * keyed by `udn + criteria`), then one single-item Search per random
     * offset across the whole range — so every one of 70,000 tracks is
     * equally likely — with [RANDOM_CONCURRENCY] requests in flight at a
     * time. Offsets are over-requested by 50 % to leave spares for the
     * genre exclusions, in up to [MAX_RANDOM_TOPUP_WAVES] rounds.
     *
     * (Until 2026-09-02 this comment described a load-the-whole-list design
     * that the code never implemented, and the concurrency was 8; the
     * maintainer ruled: keep the per-offset design and the fetch count, lower
     * the concurrency to 2, tell the truth here.)
     *
     * Invalidate via [invalidateRandomCache] on server switch.
     */
    private suspend fun randomFromFullList(
        udn: String,
        criteria: String,
        count: Int,
    ): BrowseResult = coroutineScope {
        if (count <= 0) return@coroutineScope BrowseResult.Success(emptyList(), 0L)

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

        // Phase 1: determine totalMatches (cached). We only need the count, not items.
        // Cache hit → skip straight to Phase 2.
        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

            // Probe the server for totalMatches. Most servers return the true total
            // in the first response regardless of maxResults, but MinimServer in
            // particular can report a lower number if it couldn't fulfill the request.
            // Using filter="@id" makes the payload tiny — we're just after the count.
            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)

        // Load exclusion list once up-front. Case-insensitive matching later.
        val excludedGenres = settings.randomExcludedGenres.first()
            .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 can filter by it after fetch. Without this the
        // server won't send genre in the DIDL and we'd have no way to exclude.
        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"

        // Collect sampled items across one or more waves of offset fetches. Each wave
        // asks for up to [target - have.size] fresh offsets; filters out already-used
        // offsets, excluded-genre items, and anything the server returned twice (shouldn't
        // happen but we dedupe defensively). Bail after MAX_WAVES if we still fall short
        // — usually the user's exclusion list eliminates more than the library can top up.
        val usedOffsets = mutableSetOf<Long>()
        val collected = mutableListOf<DidlItem>()
        val seenIds = mutableSetOf<String>()

        var wave = 0
        while (collected.size < target && wave < MAX_RANDOM_TOPUP_WAVES) {
            val need = target - collected.size
            val freshOffsets = mutableSetOf<Long>()
            // Over-request 50% to account for exclusions; minimum +2 so tiny requests
            // still get a replacement buffer.
            val overRequest = need + (need / 2).coerceAtLeast(2)
            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  // entire 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)
    }

    /** True if the item's upnp:genre matches any entry in [excludedLowercase]. Uses
     *  [parseGenres] to tokenize, then does a case-insensitive membership check against
     *  the excluded set. Consistent with how the NowPlaying genre picker splits. */
    private fun matchesExcludedGenre(
        item: DidlItem,
        excludedLowercase: Set<String>,
    ): Boolean {
        if (excludedLowercase.isEmpty()) return false
        val genreRaw = when (item) {
            is DidlItem.Track -> item.genre
            is 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 the user switches servers or rescans,
     *  since cached counts belong to the previous library. */
    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()

    /** Escape backslashes and double-quotes for UPnP Search string literals.
     *  Order matters: backslashes first so we don't re-escape our own escapes. */
    private fun String.upnpEscape(): String = replace("\\", "\\\\").replace("\"", "\\\"")

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

    companion object {
        const val ROOT = "0"
        // Per-Browse-call timeout. Prevents stuck SOAP responses (server crashed
        // mid-call, container ID stale from previous server session) from
        // wedging the UI on jUPnP's 60s+ HTTP timeout. 12s is generous for a
        // real response on slow networks.
        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. 2 (was 8;
        // design decision 2026-09-02) — MinimServer on a NAS does not like
        // eight at once, and a few seconds per Music Now press is fine.
        private const val RANDOM_CONCURRENCY = 2
        // Max number of top-up waves when exclusions reduce the sample below target.
        // 3 waves × 1.5× over-request is enough to replace roughly 60% exclusion-rate
        // without infinite-looping when the user has excluded most of their library.
        private const val MAX_RANDOM_TOPUP_WAVES = 3
    }
}

/** The five buckets surfaced by [ContentDirectoryClient.searchBucketCounts].
 *  Rendered as a five-row summary in the live-search pane. */
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"
    }
}

data class BucketCounts(
    val albumsByTitle: Long,
    val albumsByArtist: Long,
    val artists: Long,
    val tracksByTitle: Long,
    val tracksByArtist: Long,
) {
    /** Count for [kind] — lets the UI iterate over SearchBucketKind.values()
     *  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. The Browse
 * infrastructure navigates by container id; this prefix lets us route a
 * bucket-row click through the same onContainerClick 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
 * uses splitN(3) so only the first two colons split. Real UPnP container
 * ids don't start with "earhorn-search:" so collisions are impossible.
 */
object SearchBucketContainerId {
    private 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 = runCatching { SearchBucketKind.valueOf(kindName) }.getOrNull() ?: return null
        return kind to query
    }
}