MediaCollector.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.upnp |
| 2 | ||
| 3 | import dev.stave.domain.model.DidlItem | |
| 4 | ||
| 5 | /** | |
| 6 | * Recursively walks a UPnP ContentDirectory container and flattens every descendant [DidlItem.Track] | |
| 7 | * into a single list. Needed because "Queue this artist" or "Play all Jazz" can't map to a single | |
| 8 | * Browse() call — the tracks live two or three levels down (Artist → Albums → Tracks, Genre → | |
| 9 | * Artists → Albums → Tracks). | |
| 10 | * | |
| 11 | * Intentionally sequential: parallel browsing would saturate a single-threaded MinimServer install | |
| 12 | * on an older NAS and interleave output in ways that corrupt track order within an album. Order | |
| 13 | * matters — users expect an album's tracks to arrive in disc/track order, not in whatever order | |
| 14 | * HTTP responses race back. | |
| 15 | */ | |
| 16 | class MediaCollector( | |
| 17 | private val cd: ContentDirectoryClient, | |
| 18 | ) { | |
| 19 | ||
| 20 | /** | |
| 21 | * Gather every playable track under [containerId], walking up to [maxDepth] levels deep. | |
| 22 | */ | |
| 23 | suspend fun collectTracks( | |
| 24 | udn: String, | |
| 25 | containerId: String, | |
| 26 | maxDepth: Int = 8, | |
| 27 | ): List<DidlItem.Track> = when (val r = collect(udn, containerId, maxDepth)) { | |
| 28 | is Collected.Tracks -> r.tracks | |
| 29 | Collected.Failed -> emptyList() | |
| 30 | } | |
| 31 | ||
| 32 | /** Outcome of [collect]: lets callers tell "the server failed" ("Couldn't load | |
| 33 | * tracks from the server") from "there is nothing playable here" ("Nothing to | |
| 34 | * play there") — [collectTracks] returns an empty list for both. */ | |
| 35 | sealed interface Collected { | |
| 36 | data class Tracks(val tracks: List<DidlItem.Track>) : Collected | |
| 37 | data object Failed : Collected | |
| 38 | } | |
| 39 | ||
| 40 | /** Same walk as [collectTracks], reporting a failed Browse as [Collected.Failed]. */ | |
| 41 | suspend fun collect( | |
| 42 | udn: String, | |
| 43 | containerId: String, | |
| 44 | maxDepth: Int = 8, | |
| 45 | ): Collected { | |
| 46 | val out = mutableListOf<DidlItem.Track>() | |
| 47 | val ok = collect(udn, containerId, depth = 0, maxDepth, out) | |
| 48 | return if (ok) Collected.Tracks(out) else Collected.Failed | |
| 49 | } | |
| 50 | ||
| 51 | private suspend fun collect( | |
| 52 | udn: String, | |
| 53 | containerId: String, | |
| 54 | depth: Int, | |
| 55 | maxDepth: Int, | |
| 56 | out: MutableList<DidlItem.Track>, | |
| 57 | ): Boolean { | |
| 58 | if (depth > maxDepth) return true // treat depth cap as a soft stop, not a failure | |
| 59 | return when (val result = cd.browse(udn, containerId)) { | |
| 60 | is BrowseResult.Success -> { | |
| 61 | for (item in result.items) { | |
| 62 | when (item) { | |
| 63 | is DidlItem.Track -> out += item | |
| 64 | is DidlItem.Container -> { | |
| 65 | if (item.isSynthetic) continue | |
| 66 | val childOk = collect(udn, item.id, depth + 1, maxDepth, out) | |
| 67 | if (!childOk) return false | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | true | |
| 72 | } | |
| 73 | is BrowseResult.Failure -> false | |
| 74 | } | |
| 75 | } | |
| 76 | } |


