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

DidlLiteCodec.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.upnp.renderer
2
3import dev.stave.domain.model.DidlItem
4import dev.stave.net.UrlPolicy
5import org.jupnp.support.contentdirectory.DIDLParser
6import org.jupnp.support.model.DIDLObject
7import org.jupnp.support.model.item.Item
8import org.jupnp.support.model.item.MusicTrack
9import kotlin.time.Duration
10import kotlin.time.Duration.Companion.milliseconds
11
12/**
13 * DIDL-Lite codec between [DidlItem.Track] and the XML payload UPnP control points
14 * exchange with us via AVTransport.
15 *
16 * Two halves:
17 *
18 * - [encodeTrack] generates DIDL-Lite XML by hand-assembled string-builder. We don't
19 * use jUPnP's [MusicTrack] constructor here because its overload signatures vary
20 * between jUPnP versions, and the Java-style builder-returning setters trip up
21 * Kotlin's property-style assignment. Hand-rolling the XML keeps the output stable
22 * against jUPnP API changes and produces a known-good spec-compliant document.
23 *
24 * - [decodeFirstTrack] uses [DIDLParser] (which is stable across jUPnP versions and
25 * the same parser the existing [dev.stave.upnp.DidlParser] is built on top of)
26 * to parse incoming setAVTransportURI metadata. Property navigation mirrors the
27 * existing parser — convenience methods like `firstArtist` don't exist on jUPnP's
28 * [Item], so we walk `properties` by descriptor name for fields beyond what
29 * [MusicTrack] exposes directly.
30 *
31 * Both directions tolerate sparse input. Many control points send minimal metadata
32 * (`<item><res>$url</res></item>` and nothing else) and rely on the renderer to fill
33 * blanks at play time from id3/Vorbis tags. We do the same — empty / null fields are
34 * normal and don't break the flow.
35 */
36internal object DidlLiteCodec {
37
38 private val parser = DIDLParser()
39
40 // ---- Encode -------------------------------------------------------------
41
42 /**
43 * Generate a DIDL-Lite XML document for [track]. Returns the empty string when
44 * [track] is null — a valid AVTransport metadata value meaning "no media".
45 *
46 * Hand-rolled XML rather than going through jUPnP's [MusicTrack] builder for the
47 * reasons in the class kdoc. Produces a single-item document with the standard
48 * upnp:musicTrack class and one `<res>` element pointing at [DidlItem.Track.resourceUrl].
49 */
50 fun encodeTrack(track: DidlItem.Track?): String {
51 if (track == null) return ""
52 return buildString {
53 append("""<?xml version="1.0" encoding="UTF-8"?>""")
54 append("""<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" """)
55 append("""xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" """)
56 append("""xmlns:dc="http://purl.org/dc/elements/1.1/">""")
57
58 val itemId = track.id.ifBlank { "earhorn-current" }
59 val parentId = track.parentId.ifBlank { "0" }
60 append("""<item id="${esc(itemId)}" parentID="${esc(parentId)}" restricted="1">""")
61
62 append("""<dc:title>${esc(track.title)}</dc:title>""")
63 (track.albumArtist ?: track.artist)?.takeIf { it.isNotBlank() }?.let {
64 append("""<dc:creator>${esc(it)}</dc:creator>""")
65 }
66 track.artist?.takeIf { it.isNotBlank() }?.let {
67 append("""<upnp:artist>${esc(it)}</upnp:artist>""")
68 }
69 track.albumArtist?.takeIf { it.isNotBlank() }?.let {
70 append("""<upnp:artist role="AlbumArtist">${esc(it)}</upnp:artist>""")
71 }
72 track.album?.takeIf { it.isNotBlank() }?.let {
73 append("""<upnp:album>${esc(it)}</upnp:album>""")
74 }
75 track.albumArtUri?.takeIf { it.isNotBlank() }?.let {
76 append("""<upnp:albumArtURI>${esc(it)}</upnp:albumArtURI>""")
77 }
78 track.genre?.takeIf { it.isNotBlank() }?.let {
79 append("""<upnp:genre>${esc(it)}</upnp:genre>""")
80 }
81 track.trackNumber?.let {
82 append("""<upnp:originalTrackNumber>$it</upnp:originalTrackNumber>""")
83 }
84
85 append("""<upnp:class>object.item.audioItem.musicTrack</upnp:class>""")
86
87 // Resource element. Required `protocolInfo` attribute carries the MIME
88 // for sink-matching by control points. Optional attributes are emitted
89 // only when we have a non-null value, since jUPnP rejects empty quoted
90 // values for some of them.
91 val mime = track.mimeType?.takeIf { it.isNotBlank() } ?: "audio/mpeg"
92 append("""<res protocolInfo="http-get:*:${esc(mime)}:*"""")
93 track.duration?.let { append(""" duration="${formatDurationAsUpnpClock(it)}"""") }
94 track.size?.let { append(""" size="$it"""") }
95 track.bitrate?.let { append(""" bitrate="$it"""") }
96 track.sampleFrequency?.let { append(""" sampleFrequency="$it"""") }
97 track.bitsPerSample?.let { append(""" bitsPerSample="$it"""") }
98 track.channels?.let { append(""" nrAudioChannels="$it"""") }
99 append(">")
100 append(esc(track.resourceUrl))
101 append("</res>")
102
103 append("</item>")
104 append("</DIDL-Lite>")
105 }
106 }
107
108 // ---- Decode -------------------------------------------------------------
109
110 /**
111 * Parse a single DIDL-Lite item out of [didl] (the `CurrentURIMetaData` argument
112 * to setAVTransportURI) and return a [DidlItem.Track] populated from it.
113 *
114 * [fallbackUri] is the `CurrentURI` argument the control point also sent — used
115 * if the metadata lacks a `<res>` element or to anchor a Track when metadata is
116 * completely empty. We favor metadata-provided URLs when present (they may
117 * differ from the play-target URI for transcoding-proxy setups).
118 *
119 * Returns null when no parseable item is found AND no fallback URI is provided.
120 *
121 * Safety (the payload comes from any device on the LAN, unauthenticated):
122 * - A document carrying a `<!DOCTYPE` or `<!ENTITY` is not parsed at all.
123 * DIDL-Lite never needs a DTD, and refusing it here closes XXE / entity-
124 * expansion regardless of how the underlying SAX factory is configured. The
125 * bare [fallbackUri] is still considered.
126 * - With [requirePlayableUrl] (the default), only http(s) URLs to a
127 * non-loopback host are accepted ([UrlPolicy.isPlayableRemoteUrl]). Otherwise
128 * a peer could make libvlc open `file://`, `smb://`, `alsa://`, `v4l2://` or a
129 * service on this machine. When the metadata's `<res>` is refused but the
130 * `CurrentURI` is acceptable, the latter is used; if neither is, null.
131 * - `albumArtURI` passes the same check, or is dropped.
132 */
133 fun decodeFirstTrack(
134 didl: String?,
135 fallbackUri: String,
136 requirePlayableUrl: Boolean = true,
137 ): DidlItem.Track? {
138 val safeDidl = didl?.takeUnless {
139 it.contains("<!DOCTYPE", ignoreCase = true) || it.contains("<!ENTITY", ignoreCase = true)
140 }
141 if (safeDidl == null && !didl.isNullOrBlank()) {
142 System.err.println("[EarhornRenderer/DIDL] ignored metadata carrying a DOCTYPE/ENTITY declaration")
143 }
144 val item: Item? = if (!safeDidl.isNullOrBlank()) {
145 try {
146 parser.parse(safeDidl).items?.firstOrNull()
147 } catch (_: Exception) {
148 null
149 }
150 } else null
151
152 if (item == null && fallbackUri.isBlank()) return null
153
154 val res = item?.resources?.firstOrNull()
155 val candidates = listOfNotNull(res?.value?.takeIf { it.isNotBlank() }, fallbackUri.takeIf { it.isNotBlank() })
156 if (candidates.isEmpty()) return null
157 val resolvedUri = if (requirePlayableUrl) {
158 candidates.firstOrNull { UrlPolicy.isPlayableRemoteUrl(it) } ?: run {
159 System.err.println("[EarhornRenderer/DIDL] rejected URI: only http(s) to a non-loopback host is accepted")
160 return null
161 }
162 } else candidates.first()
163
164 // Cast to MusicTrack for the music-specific accessors. Non-music items
165 // (generic audioItem with no album / artist / trackNumber) fall through
166 // to the property-based extraction below.
167 val musicTrack = item as? MusicTrack
168 val mime = res?.protocolInfo?.toString()?.split(":")?.getOrNull(2)
169 ?.takeIf { it.isNotBlank() }
170
171 return DidlItem.Track(
172 id = item?.id?.takeIf { it.isNotBlank() } ?: "remote:${resolvedUri.hashCode()}",
173 parentId = item?.parentID?.takeIf { it.isNotBlank() } ?: "0",
174 title = item?.title?.takeIf { it.isNotBlank() }
175 ?: resolvedUri.substringAfterLast('/').substringBeforeLast('.'),
176 artist = musicTrack?.artists?.firstOrNull()?.name?.takeIf { it.isNotBlank() }
177 ?: item?.creator?.takeIf { it.isNotBlank() },
178 albumArtist = musicTrack?.artists
179 ?.firstOrNull { it.role?.equals("AlbumArtist", ignoreCase = true) == true }
180 ?.name,
181 album = musicTrack?.album?.takeIf { it.isNotBlank() },
182 composer = item?.firstPropertyByName("composer"),
183 genre = item?.firstPropertyByName("genre"),
184 date = item?.firstPropertyByName("date"),
185 trackNumber = musicTrack?.originalTrackNumber,
186 discNumber = item?.firstPropertyByName("discNumber")?.toIntOrNull(),
187 duration = parseDurationFromUpnpClock(res?.duration),
188 albumArtUri = item?.firstPropertyByName("albumArtURI")
189 ?.takeIf { !requirePlayableUrl || UrlPolicy.isPlayableRemoteUrl(it) },
190 resourceUrl = resolvedUri,
191 mimeType = mime,
192 sampleFrequency = res?.sampleFrequency?.toInt(),
193 bitsPerSample = res?.bitsPerSample?.toInt(),
194 channels = res?.nrAudioChannels?.toInt(),
195 bitrate = res?.bitrate?.toInt(),
196 size = res?.size,
197 extras = emptyMap(),
198 )
199 }
200
201 // ---- Helpers ------------------------------------------------------------
202
203 /** Generic property lookup by descriptor name. Same pattern the existing
204 * [dev.stave.upnp.DidlParser] uses — convenience methods like `firstArtist`
205 * don't exist on jUPnP 3.0.2 [Item]. */
206 private fun DIDLObject.firstPropertyByName(name: String): String? =
207 properties?.firstOrNull { it.descriptorName?.equals(name, ignoreCase = true) == true }
208 ?.value?.toString()
209 ?.takeIf { it.isNotBlank() }
210
211 /** XML attribute / text escaping. Covers the five mandatory characters; we
212 * don't worry about other Unicode since the XML doc is UTF-8 and jUPnP's
213 * parser handles entities natively. */
214 private fun esc(s: String): String = s
215 .replace("&", "&amp;")
216 .replace("<", "&lt;")
217 .replace(">", "&gt;")
218 .replace("\"", "&quot;")
219 .replace("'", "&apos;")
220
221 /** "H:MM:SS.mmm" — the UPnP clock-value format AVTransport uses for durations. */
222 private fun formatDurationAsUpnpClock(duration: Duration): String {
223 val totalMs = duration.inWholeMilliseconds.coerceAtLeast(0)
224 val ms = totalMs % 1000
225 val total = totalMs / 1000
226 val h = total / 3600
227 val m = (total % 3600) / 60
228 val s = total % 60
229 return "%d:%02d:%02d.%03d".format(h, m, s, ms)
230 }
231
232 /** Inverse of [formatDurationAsUpnpClock]. Returns null on parse failure rather
233 * than throwing — sparse / malformed metadata is normal. */
234 private fun parseDurationFromUpnpClock(clock: String?): Duration? {
235 if (clock.isNullOrBlank() || clock == "NOT_IMPLEMENTED") return null
236 val parts = clock.split(":")
237 if (parts.size != 3) return null
238 val h = parts[0].toLongOrNull() ?: return null
239 val m = parts[1].toLongOrNull() ?: return null
240 val s = parts[2].toDoubleOrNull() ?: return null
241 val ms = ((h * 3600 + m * 60) * 1000 + (s * 1000).toLong())
242 return ms.milliseconds
243 }
244}