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
}
}
}