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

DidlParser.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.upnp
2
3import dev.stave.domain.model.DidlItem
4import org.jupnp.support.model.DIDLContent
5import org.jupnp.support.model.DIDLObject
6import org.jupnp.support.model.Res
7import org.jupnp.support.model.container.Container
8import org.jupnp.support.model.item.Item
9import org.jupnp.support.model.item.MusicTrack
10import kotlin.time.Duration
11import kotlin.time.Duration.Companion.milliseconds
12
13/**
14 * DIDL-Lite → domain DidlItem parser. Pure Kotlin on top of jUPnP types.
15 *
16 * Migration note: the Android project had @Singleton @Inject constructor() here.
17 * Koin wires this with `singleOf(::DidlParser)` on both platforms; no constructor args.
18 */
19class DidlParser {
20
21 fun parse(didl: DIDLContent): List<DidlItem> {
22 val result = mutableListOf<DidlItem>()
23 didl.containers?.forEach { container -> result += container.toDomain() }
24 didl.items?.forEach { item -> result += item.toDomain() }
25 return result
26 }
27
28 private fun Container.toDomain(): DidlItem.Container {
29 val clazz = clazz?.value.orEmpty()
30 // Album servers vary: MinimServer sets upnp:artist on album containers, some set
31 // dc:creator, some set both. Check artist first, fall back to creator.
32 val artistName = firstProperty("artist")
33 ?: firstProperty("creator")
34 return DidlItem.Container(
35 id = id,
36 parentId = parentID,
37 title = title.orEmpty(),
38 childCount = childCount,
39 albumArtUri = firstAlbumArtUri(),
40 upnpClass = clazz,
41 artist = artistName?.takeIf { it.isNotBlank() },
42 isSynthetic = title.isSynthetic(),
43 extras = collectExtras()
44 )
45 }
46
47 private fun Item.toDomain(): DidlItem {
48 val bestRes = resources?.bestAudioRes()
49 val (mime, protocolDetails) = bestRes?.protocolInfo?.toString()?.parseProtocolInfo()
50 ?: (null to emptyMap())
51
52 // jUPnP types `Item.creator` as String? (unlike older Cling, which returned
53 // PersonWithRole for some item types). Direct use — no branching needed.
54 val creatorName: String? = creator?.takeIf { it.isNotBlank() }
55 val artistString: String? = (this as? MusicTrack)?.firstArtistName ?: creatorName
56
57 return DidlItem.Track(
58 id = id,
59 parentId = parentID,
60 title = title.orEmpty(),
61 artist = artistString,
62 albumArtist = (this as? MusicTrack)?.albumArtistRole,
63 album = (this as? MusicTrack)?.album,
64 composer = firstProperty("composer"),
65 genre = firstProperty("genre"),
66 date = firstProperty("date"),
67 trackNumber = (this as? MusicTrack)?.originalTrackNumber,
68 discNumber = firstProperty("discNumber")?.toIntOrNull(),
69 duration = bestRes?.duration?.parseDidlDuration(),
70 albumArtUri = firstAlbumArtUri(),
71 resourceUrl = bestRes?.value.orEmpty(),
72 mimeType = mime,
73 sampleFrequency = bestRes?.sampleFrequency?.toInt(),
74 bitsPerSample = bestRes?.bitsPerSample?.toInt(),
75 channels = bestRes?.nrAudioChannels?.toInt(),
76 bitrate = bestRes?.bitrate?.toInt(),
77 size = bestRes?.size,
78 extras = collectExtras() + protocolDetails
79 )
80 }
81
82 /** Collect every property that doesn't have a first-class slot on our model, as a best-effort map. */
83 private fun DIDLObject.collectExtras(): Map<String, String> {
84 val out = mutableMapOf<String, String>()
85 properties?.forEach { prop ->
86 val key = prop.descriptorName ?: return@forEach
87 val value = prop.value?.toString().orEmpty()
88 if (value.isBlank()) return@forEach
89 if (key in HANDLED_KEYS) return@forEach
90 out[key] = value
91 }
92 return out
93 }
94
95 private fun DIDLObject.firstProperty(name: String): String? =
96 properties?.firstOrNull { it.descriptorName?.equals(name, ignoreCase = true) == true }
97 ?.value?.toString()
98
99 private fun DIDLObject.firstAlbumArtUri(): String? =
100 properties?.firstOrNull { it.descriptorName?.equals("albumArtURI", ignoreCase = true) == true }
101 ?.value?.toString()
102
103 private val MusicTrack.firstArtistName: String?
104 get() = artists?.firstOrNull()?.name
105
106 private val MusicTrack.albumArtistRole: String?
107 get() = artists?.firstOrNull { it.role?.equals("AlbumArtist", ignoreCase = true) == true }?.name
108
109 /** MinimServer synthetic entries: "[folder view]", "[untagged]", ">> Show All", ">> Tag View", ">> Complete Album". */
110 private fun String?.isSynthetic(): Boolean {
111 if (this == null) return false
112 return startsWith(">>") || (startsWith("[") && endsWith("]"))
113 }
114
115 /** Pick the highest-quality audio resource we can actually stream. */
116 private fun List<Res>.bestAudioRes(): Res? =
117 filter { it.value?.startsWith("http", ignoreCase = true) == true }
118 .maxByOrNull { it.qualityScore() }
119
120 private fun Res.qualityScore(): Long {
121 val freq = sampleFrequency ?: 0L
122 val depth = bitsPerSample ?: 0L
123 val br = bitrate ?: 0L
124 return freq * depth.coerceAtLeast(16L) + br
125 }
126
127 /** `http-get:*:audio/flac:DLNA.ORG_PN=FLAC` → mime + map of DLNA tokens. */
128 private fun String.parseProtocolInfo(): Pair<String?, Map<String, String>> {
129 val parts = split(":")
130 val mime = parts.getOrNull(2)
131 val details = parts.getOrNull(3)
132 ?.split(";")
133 ?.mapNotNull { piece ->
134 val kv = piece.split("=", limit = 2)
135 if (kv.size == 2) kv[0] to kv[1] else null
136 }
137 ?.toMap()
138 .orEmpty()
139 return mime to details
140 }
141
142 /** DIDL durations are "H:MM:SS.fff" — best-effort. */
143 private fun String.parseDidlDuration(): Duration? {
144 val parts = split(":")
145 return runCatching {
146 when (parts.size) {
147 3 -> {
148 val h = parts[0].toLong()
149 val m = parts[1].toLong()
150 val s = parts[2].toDouble()
151 ((h * 3600 + m * 60) * 1000 + (s * 1000).toLong()).milliseconds
152 }
153 else -> null
154 }
155 }.getOrNull()
156 }
157
158 companion object {
159 private val HANDLED_KEYS = setOf(
160 "title", "creator", "artist", "album", "albumArtURI",
161 // typed fields the metadata screen already shows — without these
162 // here they appeared twice (Android N.80)
163 "genre", "date", "composer", "discNumber", "originalTrackNumber",
164 "originalTrackNumber", "albumArtist"
165 )
166 }
167}