package dev.stave.upnp.renderer
import dev.stave.domain.model.DidlItem
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.
*/
fun decodeFirstTrack(didl: String?, fallbackUri: String): DidlItem.Track? {
val item: Item? = if (!didl.isNullOrBlank()) {
runCatching { parser.parse(didl).items?.firstOrNull() }.getOrNull()
} else null
if (item == null && fallbackUri.isBlank()) return null
val res = item?.resources?.firstOrNull()
val resolvedUri = res?.value?.takeIf { it.isNotBlank() } ?: fallbackUri
if (resolvedUri.isBlank()) return null
// 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"),
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("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
/** "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
}
}