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

MediaCollector.kt

package dev.stave.upnp

import dev.stave.domain.model.DidlItem

/**
 * Recursively walks a UPnP ContentDirectory container and flattens every descendant [DidlItem.Track]
 * into a single list. Needed because "Queue this artist" or "Play all Jazz" can't map to a single
 * Browse() call — the tracks live two or three levels down (Artist → Albums → Tracks, Genre →
 * Artists → Albums → Tracks).
 *
 * Intentionally sequential: parallel browsing would saturate a single-threaded MinimServer install
 * on an older NAS and interleave output in ways that corrupt track order within an album. Order
 * matters — users expect an album's tracks to arrive in disc/track order, not in whatever order
 * HTTP responses race back.
 */
class MediaCollector(
    private val cd: ContentDirectoryClient,
) {

    /**
     * Gather every playable track under [containerId], walking up to [maxDepth] levels deep.
     */
    suspend fun collectTracks(
        udn: String,
        containerId: String,
        maxDepth: Int = 8,
    ): List<DidlItem.Track> = when (val r = collect(udn, containerId, maxDepth)) {
        is Collected.Tracks -> r.tracks
        Collected.Failed -> emptyList()
    }

    /** Outcome of [collect]: lets callers tell "the server failed" ("Couldn't load
     *  tracks from the server") from "there is nothing playable here" ("Nothing to
     *  play there") — [collectTracks] returns an empty list for both. */
    sealed interface Collected {
        data class Tracks(val tracks: List<DidlItem.Track>) : Collected
        data object Failed : Collected
    }

    /** Same walk as [collectTracks], reporting a failed Browse as [Collected.Failed]. */
    suspend fun collect(
        udn: String,
        containerId: String,
        maxDepth: Int = 8,
    ): Collected {
        val out = mutableListOf<DidlItem.Track>()
        val ok = collect(udn, containerId, depth = 0, maxDepth, out)
        return if (ok) Collected.Tracks(out) else Collected.Failed
    }

    private suspend fun collect(
        udn: String,
        containerId: String,
        depth: Int,
        maxDepth: Int,
        out: MutableList<DidlItem.Track>,
    ): Boolean {
        if (depth > maxDepth) return true  // treat depth cap as a soft stop, not a failure
        return when (val result = cd.browse(udn, containerId)) {
            is BrowseResult.Success -> {
                for (item in result.items) {
                    when (item) {
                        is DidlItem.Track -> out += item
                        is DidlItem.Container -> {
                            if (item.isSynthetic) continue
                            val childOk = collect(udn, item.id, depth + 1, maxDepth, out)
                            if (!childOk) return false
                        }
                    }
                }
                true
            }
            is BrowseResult.Failure -> false
        }
    }
}