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

MediaCollector.kt

70 lines · 3.0 KB

Changed in: 0.5.0

1package dev.stave.upnp
2
3import dev.stave.domain.model.DidlItem
4import javax.inject.Inject
5import javax.inject.Singleton
6
7/**
8 * Recursively walks a UPnP ContentDirectory container and flattens every descendant [DidlItem.Track]
9 * into a single list. Needed because "Queue this artist" or "Play all Jazz" can't map to a single
10 * Browse() call — the tracks live two or three levels down (Artist → Albums → Tracks, Genre →
11 * Artists → Albums → Tracks).
12 *
13 * Intentionally sequential: parallel browsing would saturate a single-threaded MinimServer install
14 * on an older NAS and interleave output in ways that corrupt track order within an album. Order
15 * matters — users expect an album's tracks to arrive in disc/track order, not in whatever order
16 * HTTP responses race back.
17 */
18@Singleton
19class MediaCollector @Inject constructor(
20 private val cd: ContentDirectoryClient,
21) {
22
23 /**
24 * Gather every playable track under [containerId], walking up to [maxDepth] levels deep.
25 * Depth is bounded because some pathological server configurations (or self-referential
26 * playlists) can loop, and a user triggering "Play genre" should never hang the app.
27 *
28 * Returns an empty list on any Browse failure — partial results would be confusing ("why
29 * did it play 40 of 60 tracks?") so we prefer all-or-nothing per top-level call.
30 */
31 suspend fun collectTracks(
32 udn: String,
33 containerId: String,
34 maxDepth: Int = 8,
35 ): List<DidlItem.Track> {
36 val out = mutableListOf<DidlItem.Track>()
37 val ok = collect(udn, containerId, depth = 0, maxDepth, out)
38 return if (ok) out else emptyList()
39 }
40
41 private suspend fun collect(
42 udn: String,
43 containerId: String,
44 depth: Int,
45 maxDepth: Int,
46 out: MutableList<DidlItem.Track>,
47 ): Boolean {
48 if (depth > maxDepth) return true // treat depth cap as a soft stop, not a failure
49 return when (val result = cd.browse(udn, containerId)) {
50 is BrowseResult.Success -> {
51 for (item in result.items) {
52 when (item) {
53 is DidlItem.Track -> out += item
54 is DidlItem.Container -> {
55 // MinimServer's synthetic entries (">> Show All", "[folder view]",
56 // "[untagged]") expose the same tracks that are already reachable
57 // through the tagged hierarchy. Following them would double- or
58 // triple-count tracks in the collected list.
59 if (item.isSynthetic) continue
60 val childOk = collect(udn, item.id, depth + 1, maxDepth, out)
61 if (!childOk) return false
62 }
63 }
64 }
65 true
66 }
67 is BrowseResult.Failure -> false
68 }
69 }
70}