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.Res import org.jupnp.support.model.container.Container 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 → domain DidlItem parser. Pure Kotlin on top of jUPnP types. * * Migration note: the Android project had @Singleton @Inject constructor() here. * Koin wires this with `singleOf(::DidlParser)` on both platforms; no constructor args. */ class DidlParser { fun parse(didl: DIDLContent): List { val result = mutableListOf() 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()) // jUPnP types `Item.creator` as String? (unlike older Cling, which returned // PersonWithRole for some item types). Direct use — no branching needed. val creatorName: String? = creator?.takeIf { it.isNotBlank() } 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 { val out = mutableMapOf() properties?.forEach { prop -> val key = prop.descriptorName ?: return@forEach val value = prop.value?.toString().orEmpty() if (value.isBlank()) return@forEach 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?.equals("albumArtURI", ignoreCase = true) == true } ?.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.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> { 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", // typed fields the metadata screen already shows — without these // here they appeared twice (Android N.80) "genre", "date", "composer", "discNumber", "originalTrackNumber", "originalTrackNumber", "albumArtist" ) } }