Назад к блогу
Transport16 min read

DLNA Cast: Создание UPnP-отправителя и получателя с нуля

Как PlainApp реализует DLNA/UPnP AV-кастинг с обеих сторон: сканирование и управление телевизорами через SOAP в роли отправителя, и превращение самого телефона в UPnP MediaRenderer в роли получателя — с SSDP-обнаружением, DIDL-Lite метаданными, медиасервером с поддержкой Range-запросов, GENA-колбэками событий и моделью доверия на основе разрешённых/запрещённых IP-адресов отправителей, всё на чистом Kotlin Multiplatform.

DLNA (построенный на UPnP AV) — это протокол, стоящий за кнопкой "Cast to TV" на современных телевизорах. Он работает полностью в локальной сети, не требует облачного аккаунта и никаких шагов по сопряжению. PlainApp реализует оба направления этого протокола: он может отправлять локальное видео, музыку или фото на любой DLNA-совместимый телевизор, а также превращать сам телефон в MediaRenderer, чтобы приложение пульта телевизора, VLC или другой PlainApp могли кастить на него.

В этой статье рассматриваются проводной протокол (SSDP + SOAP + DIDL-Lite), локальный HTTP-сервер, который стримит медиа с заголовками, требуемыми телевизорами, подписка на события GENA для отслеживания состояния воспроизведения и модель доверия на основе IP-отправителя, которая позволяет безопасно использовать неаутентифицированный протокол 90-х годов на современном телефоне.

Содержание

Высокоуровневая архитектура

Diagram 1
1

В DLNA/UPnP AV нет центрального сервера и облачных компонентов — всё происходит через UDP-мультикаст в локальной сети (обнаружение) и HTTP (управление + медиа). PlainApp реализует две независимые роли, которые используют один и тот же код протокола в commonMain:

РольЧто делаетКлючевые классы
Отправитель ("Cast to TV")Сканирует реендеры, указывает одному загрузить URL, управляет воспроизведениемDlnaDeviceScanner, DlnaTransportController, CastPlayer
Получатель ("Wireless Cast")Анонсирует себя как MediaRenderer, принимает управление от любого UPnP-контроллераDlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel

Обе роли используют DlnaSoap (построители SOAP-конвертов) и DlnaDevice (модель UPnP-устройства) из features/dlna/common/. В спецификации DLNA нет никакой аутентификации — любой в локальной сети, знающий управляющий URL реендера, может отправлять ему команды. Это определяет почти все описанные ниже проектные решения, особенно модель доверия для получателя.

SSDP-обнаружение: поиск устройств без сервера

Обнаружение использует SSDP (Simple Service Discovery Protocol) — тонкий слой поверх UDP-мультикаста на 239.255.255.250:1900. Здесь нет сервера каталогов — устройства анонсируют себя и отвечают на поисковые запросы напрямую.

Diagram 2
2

В роли отправителя, DlnaDeviceScanner отправляет широковещательную дейтаграмму M-SEARCH с целью urn:schemas-upnp-org:service:AVTransport:1 и собирает одноадресные ответы 200 OK, каждый из которых содержит заголовок LOCATION, указывающий на description.xml реендера. Сканер удаляет дубликаты по hostAddress — он сам не парсит XML устройства; это делегировано CastViewModel.searchAsync(), который загружает LOCATION, вызывает device.update(xml) и показывает только те устройства, у которых device.isAVTransport() возвращает true:

fun search(): Flow<DlnaDevice> = searchDlnaDevicesRaw().transform { ssdp ->
    if (devices.none { it.hostAddress == ssdp.hostAddress }) {
        val device = DlnaDevice(ssdp.hostAddress, ssdp.header)
        devices.add(device)
        emit(device)
    }
}

В роли получателя, DlnaReceiverEngine.runSsdpLoop() отправляет три дейтаграммы NOTIFY ssdp:alive при запуске (корневое устройство, тип устройства MediaRenderer:1, тип сервиса AVTransport:1), повторяет анонс каждые 30 секунд (CACHE-CONTROL: max-age=1800) и отвечает на входящие M-SEARCH запросы одноадресными ответами. При stop() он немедленно отправляет ssdp:byebye, не дожидаясь истечения 30-минутного кэша — чтобы приложение пульта перестало показывать PlainApp, как только пользователь выключит "Wireless Cast".

Резервный порт

HTTP-сервер получателя сначала пробует порт 7878, затем 7879, затем 7880, отдавая предпочтение порту, который работал в последний раз:

private val CANDIDATE_PORTS = listOf(7878, 7879, 7880)
private fun openServerSocket(): DlnaServerSocket? {
    val candidates = lastPort
        ?.let { listOf(it) + CANDIDATE_PORTS.filter { p -> p != it } }
        ?: CANDIDATE_PORTS
    for (port in candidates) {
        val ss = createDlnaServerSocket(port)
        if (ss != null) return ss
    }
    return null
}

Если все три порта заняты (редко, но возможно при работе других DLNA-приложений), устанавливается startError и отображается в интерфейсе, а не происходит тихая ошибка.

Управление AVTransport: протокол SOAP

После обнаружения реендера воспроизведением управляет UPnP AVTransport — сервис SOAP поверх HTTP. Каждое действие — это POST на управляющий URL реендера с заголовком SOAPAction и XML-телом, обёрнутым в SOAP-конверт.

Diagram 3
3

DlnaTransportController строит каждый запрос с помощью общего вспомогательного метода:

private suspend fun executeAVTransportCommand(
    device: DlnaDevice,
    action: String,
    parameters: String = "<InstanceID>0</InstanceID>",
): String {
    val st = device.getAVTransportService()?.serviceType ?: return ""
    return executeSOAPRequest(device, action, "<u:$action xmlns:u=\"$st\">$parameters</u:$action>")
}

executeSOAPRequest устанавливает заголовок SOAPAction: "<serviceType>#<action>" и отправляет DlnaSoap.requestEnvelope(soapBody) — те же константы конверта используются на стороне получателя для построения ответов, поэтому формат проводного протокола нужно определить только один раз в commonMain.

DlnaHttpRouter.handleSoap() на стороне получателя зеркально отражает это: он читает заголовок soapaction, извлекает имя действия после # и диспетчеризует его — SetAVTransportURI, Play, Pause, Stop, Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo, GetDeviceCapabilities. RenderingControl (громкость) реализован как заглушка, всегда возвращающая 100 — PlainApp не предоставляет управление громкостью устройства через UPnP.

DIDL-Lite метаданные и особенность двойного экранирования

SetAVTransportURI передаёт два параметра: CurrentURI (URL медиа) и CurrentURIMetaData — фрагмент XML в формате DIDL-Lite, описывающий название, класс медиа и обложку альбома, встроенный как XML-экранированная строка внутрь внешнего SOAP-тела:

private fun buildDidlLiteMetadata(mediaUrl: String, title: String, albumArtUri: String): String {
    val upnpClass = when {
        ext in setOf("mp3", "m4a", "flac", ...) -> "object.item.audioItem.musicTrack"
        ext in setOf("jpg", "jpeg", "png", ...) -> "object.item.imageItem"
        else -> "object.item.videoItem"
    }
    val didl = """<DIDL-Lite xmlns="..."><item id="0" parentID="-1" restricted="0">
        <dc:title>$escapedTitle</dc:title><upnp:class>$upnpClass</upnp:class>$albumArtTag</item></DIDL-Lite>"""
    return didl.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}

DIDL-Lite XML экранируется дважды: один раз для самого текста названия (чтобы песня под названием Fire & Ice не сломала DIDL-Lite теги), и второй раз для всего DIDL-Lite документа (чтобы его собственные </> не сломали внешний SOAP-конверт, в который он встроен как текст). Это известная особенность UPnP, а не ошибка — CurrentURIMetaData определён как строковое содержимое, а не как вложенные XML-элементы.

На стороне получателя DlnaSoapHandler выполняет обратную операцию: parseSoapAction сначала делает одно экранирование SOAP-тела, чтобы получить текст DIDL-Lite, затем extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/ extractAlbumArtUriFromDidlMeta выполняют второй проход экранирования сущностей и извлечения тегов на этой внутренней строке:

fun extractMediaTypeFromDidlMeta(meta: String, fallbackUri: String = ""): DlnaMediaType {
    val cls = meta.substring(classStart + 12, classEnd).lowercase()
    return when {
        "audioitem" in cls || "musictrack" in cls -> DlnaMediaType.AUDIO
        "imageitem" in cls || "photo" in cls -> DlnaMediaType.IMAGE
        "videoitem" in cls -> DlnaMediaType.VIDEO
        else -> DlnaMediaType.UNKNOWN
    }
}

Если <upnp:class> отсутствует (некоторые отправители его опускают), cleanMediaTitle() возвращается к расширению файла из самого URI — определение типа медиа никогда не приводит к жёсткой ошибке, оно просто понижается до UNKNOWN, который направляется в видеоплеер как безопасное значение по умолчанию.

Передача медиа на телевизор: Range-запросы и DLNA-заголовки

Вызов SetAVTransportURI только сообщает реендеру, откуда загружать медиа — фактические байты передаются собственным локальным HTTP-сервером PlainApp по адресу /media/{id}.

Diagram 4
4

UrlHelper.getMediaHttpUrl(path) регистрирует реальный путь (который может быть URI content://, удалённым URL или обычным путём к файлу) под коротким идентификатором и возвращает http://<ip-устройства>:<порт>/media/<id>.<ext>. Затем маршрут ветвится в зависимости от типа источника:

when {
    path.isUrl() -> call.proxyUrl(path)                 // удалённый URL: стримим ответ вышестоящего сервера
    isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
    path.isImageFast() -> call.respondFile(path)         // изображения: простая статическая отдача
    else -> call.respondDlnaFile(path)                   // аудио/видео: DLNA-совместимая отдача
}

respondDlnaFile — самый интересный случай. Многие современные телевизоры и DLNA-реендеры отказываются воспроизводить поток, если он не выглядит как ответ от настоящего DLNA-медиасервера:

override suspend fun respondDlnaFile(path: String): Boolean {
    val file = java.io.File(path)
    if (!file.exists()) return false
    applicationCall.response.run {
        header("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*")
        header("contentFeatures.dlna.org", "")
        header("transferMode.dlna.org", "Streaming")
        header("Connection", "keep-alive")
        header("Server", "DLNADOC/1.50 UPnP/1.0 Plain/1.0 Android/${android.os.Build.VERSION.RELEASE}")
        status(HttpStatusCode.PartialContent) // некоторые ОС телевизоров принимают только 206
    }
    applicationCall.respond(LocalFileContent(file))
    return true
}

Обратите внимание: статус всегда 206 Partial Content, а не 200 OK — некоторые прошивки телевизоров воспринимают обычный ответ 200 как "неподдерживающий перемотку" и отказываются воспроизводить, даже при GET-запросе на полный файл. Этот единственный выбор кода статуса — разница между "воспроизводится отлично" и "телевизор показывает крутилку вечно" на нескольких реальных устройствах.

Тот же маршрут также отдаёт обложку альбома для аудиоэлементов кастинга: UrlHelper.getAlbumArtHttpUrl() отображает URI вида content://media/.../albumart/<id> в тот же путь /media/{id}, так что content:// стриминг и DLNA-обслуживание файлов используют один и тот же код, независимо от того, является ли "медиа" песней или её изображением обложки.

События GENA: колбэки состояния воспроизведения

После начала воспроизведения отправитель подписывается на сервис событий AVTransport реендера (GENA — General Event Notification Architecture), чтобы узнавать об изменениях состояния без опроса:

suspend fun subscribeEvent(device: DlnaDevice, callbackUrl: String): String {
    val service = device.getAVTransportService() ?: return ""
    val response = createHttpClient().subscribe(baseUrl + eventSubURL) {
        headers { set("NT", "upnp:event"); set("TIMEOUT", "Second-3600"); set("CALLBACK", "<$callbackUrl>") }
    }
    return response.headers["SID"].orEmpty()
}

SUBSCRIBE/RENEW/UNSUBSCRIBE — это пользовательские HTTP-методы (не входящие в стандартный набор глаголов), обрабатываемые через универсальный построитель запросов HttpMethod("SUBSCRIBE") в Ktor. Затем реендер отправляет NOTIFY на callbackUrl — собственный маршрут PlainApp /callback/cast — всякий раз, когда изменяется состояние транспорта, позиция или длительность:

if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
    // переход к следующему элементу плейлиста
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
    CastPlayer.isPlaying.value = true
}

Защита от дублирующихся колбэков

Некоторые реендеры отправляют два колбэка NOTIFY подряд для одного и того же перехода STOPPED — второй случайно содержит AVTransportURIMetaData, а первый нет. Переключение плейлиста при обоих приводило бы к пропуску трека каждый раз, когда воспроизведение естественно останавливается. Проверка !xml.contains("AVTransportURIMetaData") — это намеренный, узкий фильтр: только первое уведомление STOPPED (без метаданных) вызывает авто-переключение. Задача startPositionUpdater() также опрашивает GetPositionInfo каждую секунду как запасной вариант, поскольку собственное подтверждение SUBSCRIBE от PlainApp не отправляет события обратно другим контроллерам — только сторона отправителя потребляет GENA-колбэки от телевизоров.

Режим получателя: превращение в UPnP MediaRenderer

Развернём направление: любой DLNA-контроллер (приложение пульта телевизора, VLC, другой PlainApp) может отправлять медиа на сам телефон. DlnaReceiverEngine открывает тот же тип HTTP + SSDP сервера, описанный выше, но теперь в роли управляемого MediaRenderer, а не контроллера.

DlnaHttpRouter.route() отдаёт description.xml (построенный DlnaXmlTemplates.deviceDescription(), перечисляющий сервис AVTransport и заглушку RenderingControl) и диспетчеризует SOAP-действия в DlnaSoapHandler. Вызов SetAVTransportURI не начинает воспроизведение немедленно — он сохраняет PendingCastRequest и ждёт:

if (uri.isNotEmpty()) {
    DlnaRendererState.rawPendingCastRequest.value =
        PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)
    DlnaRendererState.pendingPlayQueued.value = false
}

Play, поступивший до разрешения отложенного запроса, тоже не запускает воспроизведение — он устанавливает pendingPlayQueued = true, чтобы команда автоматически воспроизвелась после принятия запроса на кастинг, а не была тихо потеряна:

val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||
    DlnaRendererState.pendingCastRequest.value != null
if (hasPending) {
    DlnaRendererState.pendingPlayQueued.value = true
} else {
    DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)
}

Принятые команды проходят через единый Channel<DlnaCommand>, который потребляется DlnaReceiverViewModel, разделяя корутину обработки сырых сокетов и состояние UI/плеера — HTTP-обработчик никогда не касается ExoPlayer напрямую. Воспроизведение затем направляется в один из трёх полноэкранных composable в зависимости от DlnaMediaType: DlnaReceiverAudioPlayerContent (градиентный фон, обложка альбома, ползунок позиции), просмотрщик изображений или DlnaReceiverVideoPlayerContent (ExoPlayer).

Безопасность: доверие отправителям через списки разрешений/запретов

В DLNA нет аутентификации по замыслу — любое устройство в локальной сети может отправить реендеру команду SetAVTransportURI. Превращение личного телефона в неаутентифицированный MediaRenderer позволило бы любому в той же Wi-Fi-сети (общий офисный Wi-Fi, дом друга, враждебная гостевая сеть) отправлять на него произвольные медиа-URL. PlainApp закрывает эту брешь с помощью списка доверия по IP-адресу отправителя, проверяя каждый входящий запрос на кастинг:

Diagram 5
5

DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
    val allowed = DlnaAllowedSendersPreference.getAsync()
    val denied = DlnaDeniedSendersPreference.getAsync()
    when {
        DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
            // Автопринятие: отправлять команды напрямую, без показа диалога
        }
        DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
            // Автоотклонение: тихо игнорировать
        }
        else -> {
            // Неизвестный отправитель: перевести в состояние, видимое в UI, для решения пользователя
            DlnaRendererState.pendingCastRequest.value = pending
        }
    }
}

Запрос на кастинг от неизвестного отправителя вызывает диалог подтверждения с опциональным флагом "запомнить этот выбор"; если пользователь решает запомнить, IP-адрес отправителя записывается в список разрешений или запретов, так что будущие запросы с того же адреса пропускают диалог. Вся эта логика реализована в DlnaReceiverViewModel, поверх проводного протокола — сам SOAP-обработчик всегда возвращает 200 OK независимо от решения о доверии (согласно спецификации UPnP, транспортный вызов выполнен успешно; будет ли медиа фактически воспроизведено — это отдельное локальное решение).

Разделение платформ: commonMain для оркестрации, androidMain для сокетов

Diagram 6
6

Следуя тому же шаблону, что и другие сетевые функции PlainApp, только сырой байтовый ввод/вывод через сокеты является платформозависимым. DlnaServerSocket и DlnaSsdpSocket — это интерфейсы expect; реализации actual на Android напрямую оборачивают java.net.ServerSocket и java.net.MulticastSocket. Всё остальное — выбор порта, ритм SSDP alive/byebye, HTTP-маршрутизация, SOAP-парсинг, DIDL-Lite метаданные и все четыре перехода состояния DlnaCommand — находится в commonMain и может быть протестировано модульно на JVM без Android-устройства.

iOS получает SOAP-управление на стороне отправителя (это обычный HTTP-клиент, не требует реализации сокетов), но получатель намеренно реализован как пустышка:

actual fun startDlnaRenderer() {}

iOS не предоставляет достаточно надёжного способа запустить фоновый UDP-мультикаст слушатель, чтобы быть достойным MediaRenderer, поэтому "Wireless Cast" (приём) доступен только на Android; "Cast to TV" (отправка) работает на обеих платформах.

Заметки по инженерии на чистом Kotlin

Несколько деталей реализации существуют специально для того, чтобы избежать платформенных зависимостей, которые нарушили бы совместное использование Kotlin Multiplatform:

  • Генерация UUID v4. DlnaReceiverEngine.randomUuid() вручную реализует UUID v4 по RFC 4122 через Random.nextBytes(16) вместо java.util.UUID.randomUUID(), чтобы генератор идентификаторов устройств был идентичным на всех платформах.
  • Процентное декодирование. Приватный метод percentDecode() в DlnaSoapHandler заменяет java.net.URLDecoder.decode() для названий, поступающих в URL-кодированном виде в медиа-URI.
  • Чтение HTTP-тела на уровне байтов. Content-Length — это количество байтов, а не символов. AndroidDlnaClientConnection.readHttpRequest() читает тело через readBodyBytes(bis, contentLength) напрямую из сырого BufferedInputStream, никогда не используя BufferedReader/CharArray — название, содержащее многобайтовые символы UTF-8 (например, китайские иероглифы, по 3 байта каждый), иначе недочитало бы тело и зависло в ожидании байтов, которые уже пришли, тихо ломая SetAVTransportURI для не-ASCII названий.
  • Парсинг базового URL без java.net.URL. DlnaDevice.getBaseUrl() извлекает схему/хост/порт из заголовка LOCATION с помощью простой нарезки строк (substringAfter("://"), substringBefore('/')) вместо создания java.net.URL.

Обзор паттернов проектирования

ПаттернГдеПочему
Общий протокол, разделённый ввод/выводDlnaSoap/DlnaXmlTemplates в commonMain, сокеты в androidMainФормат проводного протокола определён один раз, платформа только подаёт байты на вход/выход
Отложенный → Проверка правил → ПродвижениеrawPendingCastRequestpendingCastRequestРазделяет "запрос поступил" и "запрос требует решения человека"
Разделение через очередь командChannel<DlnaCommand>Корутина HTTP-обработчика никогда не касается ExoPlayer/состояния UI напрямую
Воспроизведение отложенной командыpendingPlayQueuedPlay, опередивший одобрение SetAVTransportURI, не теряется
Намеренно выбранный код статусаrespondDlnaFile всегда 206Соответствует ожиданиям реальных прошивок телевизоров, а не только минимальному 200 по спецификации
Узкий фильтр дубликатов!xml.contains("AVTransportURIMetaData")Различает два колбэка STOPPED, которые некоторые реендеры отправляют, без общего механизма дедупликации
Немедленный Byebyestop() отправляет ssdp:byebye перед отменойИзбегает устаревшей 30-минутной записи в SSDP-кэше после того, как пользователь отключил функцию
Открыт для неизвестных, закрыт по умолчаниюсписок разрешений/запретов + диалогНи автоматическое доверие, ни автоматическая блокировка первого отправителя — человек решает один раз

Дополнительные материалы

  • UPnP Device Architecture — базовая спецификация SSDP/GENA/SOAP.
  • О функции низколатентного зеркалирования экрана на основе WebSocket (другой путь кастинга, не связанный с DLNA), см. Screen Mirror.