DidlParser.kt
package dev.stave.upnp
import dev.stave.domain.model.DidlItem
import org.jupnp.support.model.DIDLContent
import org.jupnp.support.model.DIDLObject
import org.jupnp.support.model.PersonWithRole
import org.jupnp.support.model.Res
import org.jupnp.support.model.container.Container
import org.jupnp.support.model.item.Item
import org.jupnp.support.model.item.MusicTrack
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@Singleton
class DidlParser @Inject constructor() {
fun parse(didl: DIDLContent): List<DidlItem> {
val result = mutableListOf<DidlItem>()
didl.containers?.forEach { container -> result += container.toDomain() }
didl.items?.forEach { item -> result += item.toDomain() }
return result
}
private fun Container.toDomain(): DidlItem.Container {
val clazz = clazz?.value.orEmpty()
// Album servers vary: MinimServer sets upnp:artist on album containers, some set
// dc:creator, some set both. Check artist first, fall back to creator.
val artistName = firstProperty("artist")
?: firstProperty("creator")
return DidlItem.Container(
id = id,
parentId = parentID,
title = title.orEmpty(),
childCount = childCount,
albumArtUri = firstAlbumArtUri(),
upnpClass = clazz,
artist = artistName?.takeIf { it.isNotBlank() },
isSynthetic = title.isSynthetic(),
extras = collectExtras()
)
}
private fun Item.toDomain(): DidlItem {
val bestRes = resources?.bestAudioRes()
val (mime, protocolDetails) = bestRes?.protocolInfo?.toString()?.parseProtocolInfo()
?: (null to emptyMap())
// Cling's `creator` surfaces as PersonWithRole or String depending on the item type; handle both.
val creatorName: String? = when (val c: Any? = creator) {
null -> null
is String -> c
is PersonWithRole -> c.name
else -> c.toString()
}
val artistString: String? = (this as? MusicTrack)?.firstArtistName ?: creatorName
return DidlItem.Track(
id = id,
parentId = parentID,
title = title.orEmpty(),
artist = artistString,
albumArtist = (this as? MusicTrack)?.albumArtistRole,
album = (this as? MusicTrack)?.album,
composer = firstProperty("composer"),
genre = firstProperty("genre"),
date = firstProperty("date"),
trackNumber = (this as? MusicTrack)?.originalTrackNumber,
discNumber = firstProperty("discNumber")?.toIntOrNull(),
duration = bestRes?.duration?.parseDidlDuration(),
albumArtUri = firstAlbumArtUri(),
resourceUrl = bestRes?.value.orEmpty(),
mimeType = mime,
sampleFrequency = bestRes?.sampleFrequency?.toInt(),
bitsPerSample = bestRes?.bitsPerSample?.toInt(),
channels = bestRes?.nrAudioChannels?.toInt(),
bitrate = bestRes?.bitrate?.toInt(),
size = bestRes?.size,
extras = collectExtras() + protocolDetails
)
}
/** Collect every property that doesn't have a first-class slot on our model, as a best-effort map. */
private fun DIDLObject.collectExtras(): Map<String, String> {
val out = mutableMapOf<String, String>()
properties?.forEach { prop ->
val key = prop.descriptorName ?: return@forEach
val value = prop.value?.toString().orEmpty()
if (value.isBlank()) return@forEach
// Skip ones we already surfaced through typed fields.
if (key in HANDLED_KEYS) return@forEach
out[key] = value
}
return out
}
private fun DIDLObject.firstProperty(name: String): String? =
properties?.firstOrNull { it.descriptorName?.equals(name, ignoreCase = true) == true }
?.value?.toString()
private fun DIDLObject.firstAlbumArtUri(): String? =
properties?.firstOrNull { it.descriptorName == "albumArtURI" }
?.value?.toString()
private val MusicTrack.firstArtistName: String?
get() = artists?.firstOrNull()?.name
private val MusicTrack.albumArtistRole: String?
get() = artists?.firstOrNull { it.role?.equals("AlbumArtist", ignoreCase = true) == true }?.name
/** MinimServer synthetic entries: "[folder view]", "[untagged]", ">> Show All", ">> Tag View", ">> Complete Album". */
private fun String?.isSynthetic(): Boolean {
if (this == null) return false
return startsWith(">>") || (startsWith("[") && endsWith("]"))
}
/** Pick the highest-quality audio resource we can actually stream. */
private fun List<Res>.bestAudioRes(): Res? =
filter { it.value?.startsWith("http", ignoreCase = true) == true }
.maxByOrNull { it.qualityScore() }
private fun Res.qualityScore(): Long {
val freq = sampleFrequency ?: 0L
val depth = bitsPerSample ?: 0L
val br = bitrate ?: 0L
return freq * depth.coerceAtLeast(16L) + br
}
/** `http-get:*:audio/flac:DLNA.ORG_PN=FLAC` → mime + map of DLNA tokens. */
private fun String.parseProtocolInfo(): Pair<String?, Map<String, String>> {
val parts = split(":")
val mime = parts.getOrNull(2)
val details = parts.getOrNull(3)
?.split(";")
?.mapNotNull { piece ->
val kv = piece.split("=", limit = 2)
if (kv.size == 2) kv[0] to kv[1] else null
}
?.toMap()
.orEmpty()
return mime to details
}
/** DIDL durations are "H:MM:SS.fff" — best-effort. */
private fun String.parseDidlDuration(): Duration? {
val parts = split(":")
return runCatching {
when (parts.size) {
3 -> {
val h = parts[0].toLong()
val m = parts[1].toLong()
val s = parts[2].toDouble()
((h * 3600 + m * 60) * 1000 + (s * 1000).toLong()).milliseconds
}
else -> null
}
}.getOrNull()
}
companion object {
private val HANDLED_KEYS = setOf(
"title", "creator", "artist", "album", "albumArtURI",
"originalTrackNumber", "albumArtist"
)
}
}