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 | import javax.inject.Inject | |
| 5 | import 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 | |
| 19 | class 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 | } |


