ブログに戻る
Transport16 min read

DLNA キャスト:UPnP 送信側と受信側をスクラッチから構築する

PlainApp が DLNA/UPnP AV キャストを両方向で実装する方法:送信側として SOAP 経由でテレビをスキャン・制御し、受信側としてスマホ自体を UPnP MediaRenderer に変える——SSDP ディスカバリー、DIDL-Lite メタデータ、Range リクエスト対応のメディア配信、GENA イベントコールバック、送信元 IP による許可/拒否トラストモデルまで、すべて純粋な Kotlin Multiplatform で実現。

DLNA(UPnP AV をベースに構築)は、スマート TV の「キャスト」ボタンの背後にあるプロトコルです。ローカルネットワーク上だけで完結し、クラウドアカウントもペアリングも不要です。PlainApp はこのプロトコルの双方向を実装しています:ローカルの動画、音楽、写真を任意の DLNA 対応スマート TV にプッシュできるだけでなく、スマホ自体を MediaRenderer に変えて、テレビリモコンアプリや VLC、別の PlainApp からのキャストを受信することもできます。

この記事では、ワイヤープロトコル(SSDP + SOAP + DIDL-Lite)、テレビが必要とするヘッダーを備えたメディアストリーミングを提供するローカル HTTP サーバー、再生状態の GENA イベントサブスクリプション、そして認証のない 1990 年代のプロトコルを現代のスマホで安全に動作させるための送信元 IP トラストモデルについて解説します。

目次

全体アーキテクチャ

Diagram 1
1

DLNA/UPnP AV には中央サーバーもクラウドコンポーネントもありません——すべてはローカルネットワーク上の UDP マルチキャスト(ディスカバリー)と HTTP(制御 + メディア)で行われます。PlainApp は共通の commonMain プロトコルコードを共有する 2 つの独立したロールを実装しています:

ロール役割主要クラス
送信側("テレビにキャスト")レンダラーをスキャンし、URL を取得させて再生を制御するDlnaDeviceScannerDlnaTransportControllerCastPlayer
受信側("ワイヤレスキャスト")自身を MediaRenderer としてアドバタイズし、任意の UPnP コントローラーの制御を受け付けるDlnaReceiverEngineDlnaHttpRouterDlnaSoapHandlerDlnaReceiverViewModel

両ロールとも features/dlna/common/DlnaSoap(SOAP エンベロープビルダー)と DlnaDevice(UPnP デバイスモデル)を再利用しています。DLNA 仕様には一切の認証機構がありません——LAN 上の誰でもレンダラーの制御 URL を知っていればコマンドを送信できます。この事実が、以下で説明するほぼすべての設計判断、特に受信側のトラストモデルを形作っています。

SSDP ディスカバリー:サーバーレスでデバイスを発見する

ディスカバリーには SSDP(Simple Service Discovery Protocol)を使用します。これは UDP マルチキャスト 239.255.255.250:1900 上の薄いプロトコル層です。ディレクトリサーバーは存在せず——デバイスは自分自身を直接アナウンスし、検索クエリに直接応答します。

Diagram 2
2

送信側としてDlnaDeviceScannerurn:schemas-upnp-org:service:AVTransport:1 をターゲットとした M-SEARCH データグラムをブロードキャストし、ユニキャストの 200 OK 応答を収集します。各応答にはレンダラーの description.xml を指す LOCATION ヘッダーが含まれています。スキャナーは 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() は起動時に 3 つの NOTIFY ssdp:alive データグラム(ルートデバイス、MediaRenderer:1 デバイスタイプ、AVTransport:1 サービスタイプ)を送信し、30 秒ごとに再アナウンスします(CACHE-CONTROL: max-age=1800)。また、受信した M-SEARCH リクエストにはユニキャスト応答で返答します。stop() では、30 分のキャッシュ期限切れを待たずに直ちに ssdp:byebye を送信します——これにより、ユーザーが「ワイヤレスキャスト」をオフにした瞬間に、テレビリモコンアプリが 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
}

3 つのポートすべてが使用中の場合(稀ですが、他の DLNA アプリが動作している場合など)、startError が設定され、サイレントに失敗するのではなく UI に表示されます。

AVTransport 制御:SOAP プロトコル

レンダラーが見つかると、再生制御は UPnP AVTransport を通じて行われます。これは SOAP-over-HTTP サービスです。すべてのアクションは、SOAPAction ヘッダーと SOAP エンベロープに包まれた XML ボディを伴う、レンダラーの制御 URL への POST です。

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

executeSOAPRequestSOAPAction: "<serviceType>#<action>" を設定し、DlnaSoap.requestEnvelope(soapBody) を POST します——受信側が応答を構築する際にも同じエンベロープ定数が使われるため、ワイヤーフォーマットは commonMain で一度だけ定義すれば済みます。

受信側の DlnaHttpRouter.handleSoap() はこの逆を他端で行います:soapaction ヘッダーを読み取り、# 以降のアクション名を抽出してディスパッチします——SetAVTransportURIPlayPauseStopSeekGetTransportInfoGetPositionInfoGetMediaInfoGetDeviceCapabilitiesRenderingControl(音量)は固定値 100 を返すスタブです——PlainApp は UPnP 経由でデバイス音量を公開していません。

DIDL-Lite メタデータと二重エスケープの癖

SetAVTransportURI は 2 つのパラメータを持ちます:CurrentURI(メディア URL)と CurrentURIMetaData——タイトル、メディアクラス、アルバムアートを記述する DIDL-Lite XML フラグメントで、外側の SOAP ボディ内に XML エスケープされた文字列として埋め込まれています:

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 の呼び出しは、レンダラーにメディアをどこから取得するかを伝えるだけです——実際のバイトデータは、PlainApp 自身のローカル HTTP サーバーが /media/{id} で提供します。

Diagram 4
4

UrlHelper.getMediaHttpUrl(path) は実際のパス(content:// URI、リモート URL、またはプレーンなファイルパス)を短い id の下に登録し、http://<device-ip>:<port>/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 が興味深いケースです——多くのスマート TV や 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) // 一部の TV は 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 ファイル配信が 1 つのコードパスを共有します。

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 メソッド(標準の動詞セットには含まれません)で、Ktor の汎用 HttpMethod("SUBSCRIBE") リクエストビルダーで処理されます。その後、トランスポート状態、位置、または再生時間が変更されるたびに、レンダラーは 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
}

重複コールバックのガード

一部のレンダラーは、同じ STOPPED 遷移に対して2 回NOTIFY コールバックを連続して送信します——2 回目には偶然 AVTransportURIMetaData が含まれ、1 回目には含まれません。両方でプレイリストを進めると、再生が自然に停止するたびに 1 トラックが飛ばされてしまいます。!xml.contains("AVTransportURIMetaData") チェックは意図的で狭い範囲のフィルターです:最初の(メタデータなしの)STOPPED 通知だけが自動進行をトリガーします。startPositionUpdater() ジョブはフォールバックとして毎秒 GetPositionInfo をポーリングします。PlainApp 自身の SUBSCRIBE 確認応答は他のコントローラーにイベントをプッシュしないため——GENA コールバックを消費するのは送信側だけです。

受信モード:UPnP MediaRenderer になる

方向を反転させます:任意の DLNA コントローラー(テレビリモコンアプリ、VLC、別の PlainApp)がスマホ自体にメディアをプッシュできます。DlnaReceiverEngine は前述と同じ種類の HTTP + SSDP サーバーを開きますが、今回はコントローラーではなく、制御される側の MediaRenderer として動作します。

DlnaHttpRouter.route()description.xmlDlnaXmlTemplates.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 に応じて 3 つの全画面 Composable のいずれかにルーティングされます:DlnaReceiverAudioPlayerContent(グラデーション背景、アルバムアート、シークバー)、画像ビューアー、または DlnaReceiverVideoPlayerContent(ExoPlayer)。

セキュリティ:許可/拒否リストによる送信元トラスト

DLNA には設計上認証がありません——LAN 上のどのデバイスでもレンダラーに 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 メタデータ、および 4 つすべての DlnaCommand 状態遷移——は commonMain にあり、Android デバイスなしで JVM 上でユニットテスト可能です。

iOS は送信側の SOAP 制御を持ちます(プレーンな HTTP クライアントであり、実装すべきソケットはありません)が、受信側は意図的に空の実装になっています:

actual fun startDlnaRenderer() {}

iOS は、バックグラウンドで UDP マルチキャストリスナーを信頼できる形で実行する方法を十分に提供しておらず、優れた MediaRenderer としての動作が難しいため、「ワイヤレスキャスト」(受信)は Android のみです。「テレビにキャスト」(送信)は両方のプラットフォームで動作します。

Pure-Kotlin エンジニアリングノート

Kotlin Multiplatform の共有を壊すようなプラットフォーム依存を避けるために、いくつかの実装上の詳細が存在します:

  • UUID v4 生成。 DlnaReceiverEngine.randomUuid()java.util.UUID.randomUUID() の代わりに Random.nextBytes(16) から RFC 4122 v4 UUID を手書きで生成するため、デバイス ID ジェネレーターがすべてのプラットフォームで同一になります。
  • パーセントデコード。 DlnaSoapHandler のプライベート関数 percentDecode() は、メディア URI 内で URL エンコードされて到着するタイトルに対して java.net.URLDecoder.decode() を置き換えます。
  • バイトレベルの HTTP ボディ読み取り。 Content-Lengthバイト数であり、文字数ではありません。AndroidDlnaClientConnection.readHttpRequest()readBodyBytes(bis, contentLength) を使って生の BufferedInputStream に対してボディを読み取り、BufferedReader/CharArray は決して使用しません——マルチバイト UTF-8(例えば日本語文字、1 文字あたり 3〜4 バイト)を含むタイトルの場合、そうしないとボディの読み取りが不足し、実際には到着しているバイトを待って停止し、非 ASCII タイトルで SetAVTransportURI がサイレントに壊れてしまいます。
  • java.net.URL なしのベース URL パース。 DlnaDevice.getBaseUrl()java.net.URL を構築する代わりに、プレーンな文字列スライシング(substringAfter("://")substringBefore('/'))で LOCATION ヘッダーからスキーム/ホスト/ポートを抽出します。

デザインパターンまとめ

パターン場所理由
プロトコル共有、I/O 分割DlnaSoap/DlnaXmlTemplates は commonMain、ソケットは androidMainワイヤーフォーマットを一度定義し、プラットフォームはバイトの入出力のみを提供
保留 → ルールチェック → 昇格rawPendingCastRequestpendingCastRequest「リクエストが到着した」ことと「リクエストに人間の判断が必要」なことを分離
コマンドキューの分離Channel<DlnaCommand>HTTP ハンドラーコルーチンが ExoPlayer/UI 状態を直接触らない
キューイングされたコマンドの再生pendingPlayQueuedSetAVTransportURI の承認より先に到着した Play が破棄されない
意図的なステータスコードrespondDlnaFile → 常に 206仕様の最低限である 200 ではなく、実際のテレビファームウェアが期待するものに合わせる
狭い範囲の重複フィルター!xml.contains("AVTransportURIMetaData")一部のレンダラーが送信する 2 回の STOPPED コールバックを、汎用の重複排除機構なしで区別
即時 byebyestop() がキャンセル前に ssdp:byebye を送信ユーザーが機能をオフにした後、30 分間の古い SSDP キャッシュエントリが残るのを防止
未知は確認、デフォルトは閉鎖許可/拒否プリファレンス + ダイアログ初回の送信元を自動的に信頼もブロックもせず——人間が一度判断

さらに読む

  • UPnP Device Architecture —— 基盤となる SSDP/GENA/SOAP 仕様。
  • WebSocket ベースの低遅延スクリーンミラーリング機能(DLNA とは異なるキャストパス)については、Screen Mirror を参照してください。