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

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"
        )
    }
}