返回部落格
Transport16 min read

DLNA 投放:從零實作 UPnP 傳送端與接收端

詳解 PlainApp 如何在 DLNA/UPnP AV 協定上同時實作傳送端與接收端:作為傳送端透過 SOAP 掃描並控制智慧電視,作為接收端把手機本身變成一臺 UPnP MediaRenderer——涵蓋 SSDP 發現、DIDL-Lite 元資料、支援 Range 請求的媒體服務、GENA 事件回呼,以及基於傳送方 IP 的允許/拒絕信任模型,全部用純 Kotlin Multiplatform 實作。

DLNA(基於 UPnP AV 建構)是電視上「投放」按鈕背後的協定。它完全運作在區域網路內,不需要雲端帳號,也無需任何配對步驟。PlainApp 同時實作了它的兩個方向:既可以把本機的影片、音樂或照片推送到任意支援 DLNA 的智慧電視,也可以把手機本身變成一臺 MediaRenderer,讓電視遙控 App、VLC 或另一臺 PlainApp 反過來向它投放。

本文涵蓋底層協定(SSDP + SOAP + DIDL-Lite)、真正滿足電視端要求的本機 HTTP 媒體伺服器、用於播放狀態回呼的 GENA 事件訂閱,以及讓這個誕生於上世紀 90 年代、沒有任何鑑別機制的協定在現代手機上安全運行的傳送方信任模型。

目錄

總體架構

Diagram 1
1

DLNA/UPnP AV 沒有中心伺服器,也沒有雲端元件——一切都發生在區域網路的 UDP 多播(發現)與 HTTP(控制 + 媒體)之上。PlainApp 實作了兩個互相獨立、但共享同一份 commonMain 協定程式碼的角色:

角色作用關鍵類別
傳送端(「投放至電視」)掃描 Renderer,讓其擷取一個 URL,控制播放DlnaDeviceScannerDlnaTransportControllerCastPlayer
接收端(「無線投放」)把自己廣播為一臺 MediaRenderer,接受任意 UPnP 控制點的控制DlnaReceiverEngineDlnaHttpRouterDlnaSoapHandlerDlnaReceiverViewModel

兩個角色都複用了 features/dlna/common/ 下的 DlnaSoap(SOAP 信封建構器)與 DlnaDevice(UPnP 裝置模型)。DLNA 規範本身不含任何鑑別機制——區域網路內任何知道 Renderer 控制 URL 的裝置都能向它傳送控制命令。這一點決定了下文幾乎所有的設計決策,尤其是接收端的信任模型。

SSDP 發現:無需伺服器找到裝置

發現階段使用 SSDP(Simple Service Discovery Protocol),它只是在 UDP 多播位址 239.255.255.250:1900 上薄薄的一層協定。沒有目錄伺服器——裝置直接廣播自身並應答搜尋請求。

Diagram 2
2

作為傳送端DlnaDeviceScanner 廣播一個針對 urn:schemas-upnp-org:service:AVTransport:1M-SEARCH 資料報,並收集單播的 200 OK 回覆,每個回覆都帶有指向 Renderer description.xmlLOCATION 標頭。掃描器僅按 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 分鐘快取自然過期——這樣使用者一關閉「無線投放」,電視遙控 App 就會立刻不再列出 PlainApp。

連接埠回退

接收端的 HTTP 伺服器優先嘗試連接埠 7878,其次 78797880,並且優先複用上一次成功使用過的連接埠:

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 並在 UI 中展示出來,而不是靜默失敗。

AVTransport 控制:SOAP 協定

找到 Renderer 之後,播放控制透過 UPnP AVTransport 完成——一個基於 HTTP 的 SOAP 服務。每個動作都是向 Renderer 控制 URL 發起的一次 POST,帶有 SOAPAction 標頭和包在 SOAP 信封裡的 XML 請求主體。

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>",並 POST 出 DlnaSoap.requestEnvelope(soapBody)——接收端建構回應時用的也是同一套信封常數,因此整個協定格式只需要在 commonMain 裡定義一次。

接收端的 DlnaHttpRouter.handleSoap() 在另一端做了鏡像的事情:讀取 soapaction 標頭,提取 # 後面的動作名並據此分發——SetAVTransportURIPlayPauseStopSeekGetTransportInfoGetPositionInfoGetMediaInfoGetDeviceCapabilitiesRenderingControl(音量)被打樁為固定回傳 100——PlainApp 並沒有透過 UPnP 暴露裝置音量控制。

DIDL-Lite 元資料與二次跳脫細節

SetAVTransportURI 攜帶兩個參數:CurrentURI(媒體 URL)和 CurrentURIMetaData——一段描述標題、媒體類型和專輯封面的 DIDL-Lite XML 片段,以 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 慣例,不是 bug——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,而 UNKNOWN 會安全地預設路由到影片播放器。

向電視提供媒體:Range 請求與 DLNA 專用標頭

SetAVTransportURI 只是告訴 Renderer 從哪裡擷取媒體——真正的位元組資料由 PlainApp 自己的本機 HTTP 伺服器在 /media/{id} 上提供。

Diagram 4
4

UrlHelper.getMediaHttpUrl(path) 把真實路徑(可能是 content:// URI、遠端 URL 或普通檔案路徑)註冊為一個短 id,並回傳 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 Renderer 除非回應看起來像一個標準的 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()content://media/.../albumart/<id> 這樣的 URI 對應到同樣的 /media/{id} 路徑上,所以無論「媒體」指的是歌曲本身還是它的封面圖,content:// 串流傳輸與 DLNA 檔案服務都走同一條程式碼路徑。

GENA 事件:播放狀態回呼

開始播放之後,傳送端會訂閱 Renderer 的 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 方法(不在標準動詞集合裡),透過 Ktor 的通用 HttpMethod("SUBSCRIBE") 請求建構器來處理。之後每當傳輸狀態、播放位置或時長發生變化,Renderer 就會向 callbackUrl——也就是 PlainApp 自己的 /callback/cast 路由——傳送 NOTIFY

if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
    // 前進到播放清單中的下一項
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
    CastPlayer.isPlaying.value = true
}

重複回呼的防護

部分 Renderer 會針對同一次 STOPPED 狀態轉換連續傳送兩次 NOTIFY 回呼——第二次恰好攜帶 AVTransportURIMetaData,第一次則沒有。如果兩次都觸發「前進到下一曲」,每次播放自然停止時都會多跳過一首歌。!xml.contains("AVTransportURIMetaData") 這個判斷是刻意設計的、範圍很窄的過濾條件:只有第一條(不帶元資料的)STOPPED 通知才會觸發自動前進。startPositionUpdater() 作業還會每秒輪詢一次 GetPositionInfo 作為備援方案,因為 PlainApp 自身對 SUBSCRIBE 的確認並不會向其他控制點推送事件——只有傳送端這一側會消費來自電視的 GENA 回呼。

接收模式:化身 UPnP MediaRenderer

反過來看:任何 DLNA 控制點(電視遙控 App、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。播放隨後會按 DlnaMediaType 路由到三個全螢幕 Composable 之一:DlnaReceiverAudioPlayerContent(漸層背景、專輯封面、進度條)、圖片檢視器,或 DlnaReceiverVideoPlayerContent(ExoPlayer)。

安全:基於允許/拒絕清單的傳送方信任

DLNA 在設計上沒有任何鑑別機制——區域網路內的任何裝置都能向一臺 Renderer 傳送 SetAVTransportURI。如果把一部私人手機變成一臺無鑑別的 MediaRenderer,那麼同一 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 其他網路功能的同一套模式,只有最底層位元組級別的通訊端 I/O 是平臺相關的。DlnaServerSocketDlnaSsdpSocketexpect 介面;Android 端的 actual 實作直接封裝 java.net.ServerSocketjava.net.MulticastSocket。其餘的一切——連接埠選擇、SSDP alive/byebye 的節奏、HTTP 路由、SOAP 解析、DIDL-Lite 元資料處理,以及全部四種 DlnaCommand 的狀態轉換——都放在 commonMain 裡,可以在沒有 Android 裝置的情況下直接在 JVM 上做單元測試。

iOS 擁有傳送端的 SOAP 控制能力(它只是一個普通 HTTP 用戶端,不需要實作通訊端),但接收端被有意設計為空實作:

actual fun startDlnaRenderer() {}

iOS 沒有一種足夠可靠的方式在背景持續執行 UDP 多播監聽,無法勝任一個合格的 MediaRenderer,因此「無線投放」(接收)功能僅限 Android;「投放至電視」(傳送)在兩個平臺上皆可使用。

純 Kotlin 工程細節

有幾處實作細節專門是為了避免會破壞 Kotlin Multiplatform 共享的平臺依賴:

  • UUID v4 生成。 DlnaReceiverEngine.randomUuid()Random.nextBytes(16) 手寫實作了一個符合 RFC 4122 v4 規範的 UUID,而不是使用 java.util.UUID.randomUUID(),這樣裝置身份產生器在每個平臺上都完全一致。
  • 百分比解碼。 DlnaSoapHandler 裡的私有函式 percentDecode() 替代了 java.net.URLDecoder.decode(),用於處理媒體 URI 中以 URL 編碼形式出現的標題。
  • 位元組層級的 HTTP 請求主體讀取。 Content-Length 是一個位元組計數,而不是字元計數。AndroidDlnaClientConnection.readHttpRequest() 透過 readBodyBytes(bis, contentLength) 直接對原始 BufferedInputStream 讀取請求主體,絕不使用 BufferedReader/CharArray——一個包含多位元組 UTF-8 字元(例如中文,每個字元 3 位元組)的標題,否則會導致請求主體讀取不足,卡在等待其實已經到達的位元組上,從而在標題含非 ASCII 字元時靜默地破壞 SetAVTransportURI
  • 不依賴 java.net.URL 的 Base URL 解析。 DlnaDevice.getBaseUrl() 用純字串切片(substringAfter("://")substringBefore('/'))從 LOCATION 標頭裡提取協定/主機/連接埠,而不是建構一個 java.net.URL

設計模式回顧

模式位置原因
協定共享,I/O 拆分DlnaSoap/DlnaXmlTemplates 在 commonMain,通訊端在 androidMain協定格式只定義一次,平臺層只負責收發位元組
待定 → 規則檢查 → 提升rawPendingCastRequestpendingCastRequest把「請求到達了」與「請求需要人來決定」這兩件事分開
命令佇列解耦Channel<DlnaCommand>HTTP 處理協程永遠不直接觸碰 ExoPlayer/UI 狀態
排隊命令重放pendingPlayQueued搶在 SetAVTransportURI 裁決完成之前到達的 Play 不會被丟棄
刻意選擇的狀態碼respondDlnaFile 始終回傳 206匹配真實電視韌體的預期,而不只是滿足規範的最低要求 200
窄範圍的重複過濾!xml.contains("AVTransportURIMetaData")在沒有通用去重機制的情況下,區分某些 Renderer 傳送的兩次 STOPPED 回呼
立即傳送 byebyestop() 在取消前傳送 ssdp:byebye避免使用者關閉功能後殘留一條長達 30 分鐘的過期 SSDP 快取條目
未知即開放確認,預設關閉允許/拒絕清單 + 對話框既不自動信任也不自動攔截首次出現的傳送方——由人來決定一次

延伸閱讀

  • UPnP Device Architecture —— 底層 SSDP/GENA/SOAP 規範。
  • 關於基於 WebSocket 的低延遲螢幕鏡像功能(另一條與 DLNA 無關的投放路徑),參見 Screen Mirror