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

DidlLiteCodec.kt

210 lines · 10.3 KB

Changed in: 0.5.0

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