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

MediaCollector.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.upnp
2
3import 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 */
16class 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}