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

DidlLiteCodec.kt

package dev.stave.upnp.renderer

import dev.stave.domain.model.DidlItem
import dev.stave.net.UrlPolicy
import org.jupnp.support.contentdirectory.DIDLParser
import org.jupnp.support.model.DIDLObject
import org.jupnp.support.model.item.Item
import org.jupnp.support.model.item.MusicTrack
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds

/**
 * DIDL-Lite codec between [DidlItem.Track] and the XML payload UPnP control points
 * exchange with us via AVTransport.
 *
 * Two halves:
 *
 *  - [encodeTrack] generates DIDL-Lite XML by hand-assembled string-builder. We don't
 *    use jUPnP's [MusicTrack] constructor here because its overload signatures vary
 *    between jUPnP versions, and the Java-style builder-returning setters trip up
 *    Kotlin's property-style assignment. Hand-rolling the XML keeps the output stable
 *    against jUPnP API changes and produces a known-good spec-compliant document.
 *
 *  - [decodeFirstTrack] uses [DIDLParser] (which is stable across jUPnP versions and
 *    the same parser the existing [dev.stave.upnp.DidlParser] is built on top of)
 *    to parse incoming setAVTransportURI metadata. Property navigation mirrors the
 *    existing parser — convenience methods like `firstArtist` don't exist on jUPnP's
 *    [Item], so we walk `properties` by descriptor name for fields beyond what
 *    [MusicTrack] exposes directly.
 *
 * Both directions tolerate sparse input. Many control points send minimal metadata
 * (`<item><res>$url</res></item>` and nothing else) and rely on the renderer to fill
 * blanks at play time from id3/Vorbis tags. We do the same — empty / null fields are
 * normal and don't break the flow.
 */
internal object DidlLiteCodec {

    private val parser = DIDLParser()

    // ---- Encode -------------------------------------------------------------

    /**
     * Generate a DIDL-Lite XML document for [track]. Returns the empty string when
     * [track] is null — a valid AVTransport metadata value meaning "no media".
     *
     * Hand-rolled XML rather than going through jUPnP's [MusicTrack] builder for the
     * reasons in the class kdoc. Produces a single-item document with the standard
     * upnp:musicTrack class and one `<res>` element pointing at [DidlItem.Track.resourceUrl].
     */
    fun encodeTrack(track: DidlItem.Track?): String {
        if (track == null) return ""
        return buildString {
            append("""<?xml version="1.0" encoding="UTF-8"?>""")
            append("""<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" """)
            append("""xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" """)
            append("""xmlns:dc="http://purl.org/dc/elements/1.1/">""")

            val itemId = track.id.ifBlank { "earhorn-current" }
            val parentId = track.parentId.ifBlank { "0" }
            append("""<item id="${esc(itemId)}" parentID="${esc(parentId)}" restricted="1">""")

            append("""<dc:title>${esc(track.title)}</dc:title>""")
            (track.albumArtist ?: track.artist)?.takeIf { it.isNotBlank() }?.let {
                append("""<dc:creator>${esc(it)}</dc:creator>""")
            }
            track.artist?.takeIf { it.isNotBlank() }?.let {
                append("""<upnp:artist>${esc(it)}</upnp:artist>""")
            }
            track.albumArtist?.takeIf { it.isNotBlank() }?.let {
                append("""<upnp:artist role="AlbumArtist">${esc(it)}</upnp:artist>""")
            }
            track.album?.takeIf { it.isNotBlank() }?.let {
                append("""<upnp:album>${esc(it)}</upnp:album>""")
            }
            track.albumArtUri?.takeIf { it.isNotBlank() }?.let {
                append("""<upnp:albumArtURI>${esc(it)}</upnp:albumArtURI>""")
            }
            track.genre?.takeIf { it.isNotBlank() }?.let {
                append("""<upnp:genre>${esc(it)}</upnp:genre>""")
            }
            track.trackNumber?.let {
                append("""<upnp:originalTrackNumber>$it</upnp:originalTrackNumber>""")
            }

            append("""<upnp:class>object.item.audioItem.musicTrack</upnp:class>""")

            // Resource element. Required `protocolInfo` attribute carries the MIME
            // for sink-matching by control points. Optional attributes are emitted
            // only when we have a non-null value, since jUPnP rejects empty quoted
            // values for some of them.
            val mime = track.mimeType?.takeIf { it.isNotBlank() } ?: "audio/mpeg"
            append("""<res protocolInfo="http-get:*:${esc(mime)}:*"""")
            track.duration?.let { append(""" duration="${formatDurationAsUpnpClock(it)}"""") }
            track.size?.let { append(""" size="$it"""") }
            track.bitrate?.let { append(""" bitrate="$it"""") }
            track.sampleFrequency?.let { append(""" sampleFrequency="$it"""") }
            track.bitsPerSample?.let { append(""" bitsPerSample="$it"""") }
            track.channels?.let { append(""" nrAudioChannels="$it"""") }
            append(">")
            append(esc(track.resourceUrl))
            append("</res>")

            append("</item>")
            append("</DIDL-Lite>")
        }
    }

    // ---- Decode -------------------------------------------------------------

    /**
     * Parse a single DIDL-Lite item out of [didl] (the `CurrentURIMetaData` argument
     * to setAVTransportURI) and return a [DidlItem.Track] populated from it.
     *
     * [fallbackUri] is the `CurrentURI` argument the control point also sent — used
     * if the metadata lacks a `<res>` element or to anchor a Track when metadata is
     * completely empty. We favor metadata-provided URLs when present (they may
     * differ from the play-target URI for transcoding-proxy setups).
     *
     * Returns null when no parseable item is found AND no fallback URI is provided.
     *
     * Safety (the payload comes from any device on the LAN, unauthenticated):
     *  - A document carrying a `<!DOCTYPE` or `<!ENTITY` is not parsed at all.
     *    DIDL-Lite never needs a DTD, and refusing it here closes XXE / entity-
     *    expansion regardless of how the underlying SAX factory is configured. The
     *    bare [fallbackUri] is still considered.
     *  - With [requirePlayableUrl] (the default), only http(s) URLs to a
     *    non-loopback host are accepted ([UrlPolicy.isPlayableRemoteUrl]). Otherwise
     *    a peer could make libvlc open `file://`, `smb://`, `alsa://`, `v4l2://` or a
     *    service on this machine. When the metadata's `<res>` is refused but the
     *    `CurrentURI` is acceptable, the latter is used; if neither is, null.
     *  - `albumArtURI` passes the same check, or is dropped.
     */
    fun decodeFirstTrack(
        didl: String?,
        fallbackUri: String,
        requirePlayableUrl: Boolean = true,
    ): DidlItem.Track? {
        val safeDidl = didl?.takeUnless {
            it.contains("<!DOCTYPE", ignoreCase = true) || it.contains("<!ENTITY", ignoreCase = true)
        }
        if (safeDidl == null && !didl.isNullOrBlank()) {
            System.err.println("[EarhornRenderer/DIDL] ignored metadata carrying a DOCTYPE/ENTITY declaration")
        }
        val item: Item? = if (!safeDidl.isNullOrBlank()) {
            try {
                parser.parse(safeDidl).items?.firstOrNull()
            } catch (_: Exception) {
                null
            }
        } else null

        if (item == null && fallbackUri.isBlank()) return null

        val res = item?.resources?.firstOrNull()
        val candidates = listOfNotNull(res?.value?.takeIf { it.isNotBlank() }, fallbackUri.takeIf { it.isNotBlank() })
        if (candidates.isEmpty()) return null
        val resolvedUri = if (requirePlayableUrl) {
            candidates.firstOrNull { UrlPolicy.isPlayableRemoteUrl(it) } ?: run {
                System.err.println("[EarhornRenderer/DIDL] rejected URI: only http(s) to a non-loopback host is accepted")
                return null
            }
        } else candidates.first()

        // Cast to MusicTrack for the music-specific accessors. Non-music items
        // (generic audioItem with no album / artist / trackNumber) fall through
        // to the property-based extraction below.
        val musicTrack = item as? MusicTrack
        val mime = res?.protocolInfo?.toString()?.split(":")?.getOrNull(2)
            ?.takeIf { it.isNotBlank() }

        return DidlItem.Track(
            id = item?.id?.takeIf { it.isNotBlank() } ?: "remote:${resolvedUri.hashCode()}",
            parentId = item?.parentID?.takeIf { it.isNotBlank() } ?: "0",
            title = item?.title?.takeIf { it.isNotBlank() }
                ?: resolvedUri.substringAfterLast('/').substringBeforeLast('.'),
            artist = musicTrack?.artists?.firstOrNull()?.name?.takeIf { it.isNotBlank() }
                ?: item?.creator?.takeIf { it.isNotBlank() },
            albumArtist = musicTrack?.artists
                ?.firstOrNull { it.role?.equals("AlbumArtist", ignoreCase = true) == true }
                ?.name,
            album = musicTrack?.album?.takeIf { it.isNotBlank() },
            composer = item?.firstPropertyByName("composer"),
            genre = item?.firstPropertyByName("genre"),
            date = item?.firstPropertyByName("date"),
            trackNumber = musicTrack?.originalTrackNumber,
            discNumber = item?.firstPropertyByName("discNumber")?.toIntOrNull(),
            duration = parseDurationFromUpnpClock(res?.duration),
            albumArtUri = item?.firstPropertyByName("albumArtURI")
                ?.takeIf { !requirePlayableUrl || UrlPolicy.isPlayableRemoteUrl(it) },
            resourceUrl = resolvedUri,
            mimeType = mime,
            sampleFrequency = res?.sampleFrequency?.toInt(),
            bitsPerSample = res?.bitsPerSample?.toInt(),
            channels = res?.nrAudioChannels?.toInt(),
            bitrate = res?.bitrate?.toInt(),
            size = res?.size,
            extras = emptyMap(),
        )
    }

    // ---- Helpers ------------------------------------------------------------

    /** Generic property lookup by descriptor name. Same pattern the existing
     *  [dev.stave.upnp.DidlParser] uses — convenience methods like `firstArtist`
     *  don't exist on jUPnP 3.0.2 [Item]. */
    private fun DIDLObject.firstPropertyByName(name: String): String? =
        properties?.firstOrNull { it.descriptorName?.equals(name, ignoreCase = true) == true }
            ?.value?.toString()
            ?.takeIf { it.isNotBlank() }

    /** XML attribute / text escaping. Covers the five mandatory characters; we
     *  don't worry about other Unicode since the XML doc is UTF-8 and jUPnP's
     *  parser handles entities natively. */
    private fun esc(s: String): String = s
        .replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace("\"", "&quot;")
        .replace("'", "&apos;")

    /** "H:MM:SS.mmm" — the UPnP clock-value format AVTransport uses for durations. */
    private fun formatDurationAsUpnpClock(duration: Duration): String {
        val totalMs = duration.inWholeMilliseconds.coerceAtLeast(0)
        val ms = totalMs % 1000
        val total = totalMs / 1000
        val h = total / 3600
        val m = (total % 3600) / 60
        val s = total % 60
        return "%d:%02d:%02d.%03d".format(h, m, s, ms)
    }

    /** Inverse of [formatDurationAsUpnpClock]. Returns null on parse failure rather
     *  than throwing — sparse / malformed metadata is normal. */
    private fun parseDurationFromUpnpClock(clock: String?): Duration? {
        if (clock.isNullOrBlank() || clock == "NOT_IMPLEMENTED") return null
        val parts = clock.split(":")
        if (parts.size != 3) return null
        val h = parts[0].toLongOrNull() ?: return null
        val m = parts[1].toLongOrNull() ?: return null
        val s = parts[2].toDoubleOrNull() ?: return null
        val ms = ((h * 3600 + m * 60) * 1000 + (s * 1000).toLong())
        return ms.milliseconds
    }
}