MediaCollector.kt
70 lines · 3.0 KB
Changed in: 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 | } |


