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