吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 3081|回复: 20
上一主题 下一主题
收起左侧

[Python 原创] [Python/Rust] 基于 "投屏" 的视频抓包

  [复制链接]
跳转到指定楼层
楼主
三滑稽甲苯 发表于 2025-6-13 22:05 回帖奖励
本帖最后由 三滑稽甲苯 于 2025-6-13 22:03 编辑

💬 前言

众所周知,众多流媒体平台不再提供网页端的服务,只能在 APP 内看视频,而这就在一定程度上妨碍了我们抓包、获取网址、下载视频的工作流。为了让用户能够在智能电视上看视频,这些平台又往往会提供一个投屏到电视的选项。

虽然大家都约定俗成地说是“投屏”,但实际上这个称呼是有歧义的,既可以指“将屏幕镜像到另一个设备”,也可以指“将某媒体流投送到另一个设备”。前者需要在局域网内实时传输画面,而后者往往只需要将一个网址传输给目标设备。这里我们仅针对第二种情况展开讨论。

🪟 Windows Media Player

既然电视上能够播放投屏的媒体流,那么理论上我们可以用电脑模拟成一个电视,让电脑接收媒体流的网址。如果我们能够得到这个网址,那么下载媒体也就不在话下了。经过一番搜索,我发现 Windows Media Player 可以接受媒体流推送,那么让我们尝试利用它来获取媒体流网址。

启用媒体流

首先打开控制面板,进入“网络共享中心”,点击左侧栏的“媒体流式处理选项”:

然后单击“启用媒体流”按钮:

若出现下图所示“媒体流未启用”的提示:

那么点击下方的“Windows 服务管理工具”,然后找到“Windows Media Player Network Sharing Service”服务:

右键,选择“属性”,将其启动类型设置为“自动”或者“自动(延迟启动)”,点击“应用”后再次点击“启动”:

若提示下述信息:

Windows 无法启用 Windows Media Player Network Sharing Service 服务 (位于 本地计算机 上)
错误 1068: 依赖服务或组无法启动。

则先用同样方法启动“依存关系”中的五个服务 (ref):

  • Windows Search
  • Background Tasks Infrastructure Service
  • Remote Procedure Call (RPC)
  • DCOM Server Process Launcher
  • RPC Endpoint Mapper

启动成功后即可关闭“服务”窗口,然后 重新进入 控制面板的“媒体流选项”。

随后,在所有网络中允许访问共享媒体:

完成上述步骤后即可关闭控制面板。

允许远程控制我的播放器

打开 Windows Media Player (找不到可以搜索) 软件,在“媒体流”选项中勾选“允许远程控制我的播放器”:

注意,这一步之后不能关闭 Windows Media Player,否则会导致后续找不到设备。

开始投屏

将手机或其他设备连接到同一局域网,打开一个支持 DLNA 的 APP (如哔哩哔哩),并打开任意视频 (例如 BV1GJ411x7h7),在播放界面点击投屏按钮,选择你的电脑:

如果一切正常,Windows Media Player 就会开始播放此视频:

查看网址

右键画面,点击“显示播放列表”:

随后在播放列表中找到刚才投屏的视频,右键点击,选择“属性”:

在弹出的属性窗口中的“文件”选项卡中即可看到视频的 URL,使用鼠标从头到尾选中后即可复制:

随后可以将此 URL 粘贴到浏览器或下载工具中进行下载。

DLNA

显然,上面的方法还是太吃操作了,那么有没有更自动化的方式呢?答案是有的,但需要编写一个程序扮演 DLNA DMR (Digital Media Renderer) 的角色,自动接收媒体流并提取 URL。首先,我们简单介绍一下 DLNA 协议:

Digital Living Network Alliance (DLNA) is a set of interoperability standards for sharing home digital media among multimedia devices.

这个标准主要包括下面五个角色:

  • Digital Media Server (DMS): 数位媒体伺服器,存储并提供媒体文件。
  • Digital Media Renderer (DMR): 数位媒体渲染器,可接收并播放从 DMC 推送过来的媒体档案。(接收并执行 DMC 发送的指令)
  • Digital Media Controller (DMC): 数位媒体控制器,作为遥控装置使用,可寻找 DMS 上的多媒体档案,并指定可播放该多媒体档案的 DMR 进行播放或是控制多媒体档案上下传到 DMS 的装置。
  • Digital Media Player (DMP): 数位媒体播放器,寻找并播放或输出 DMS 所提供的媒体文件。(DMP≈DMR+DMC)
  • Digital Media Printer (DMPr): 数位媒体印表机,可以在 DLNA 网路架构下提供列印功能。

在使用哔哩哔哩软件投屏至电视的场景下,哔哩哔哩公司的服务器就是 DMS,手机上的哔哩哔哩软件即为 DMC,而智能电视就是 DMR。因此,为了达成“DLNA 捕获”的目的,我们需要解决两个关键问题:

  1. 告知 DMC:“我是 DMR,我可以接受媒体流然后渲染”
  2. 接收发给自己的控制指令,然后输出指令的内容,从而获得网址
    • 由于我们做的是假 DMR (dummy DMR),因此不需要实现指令的执行

DMR 实际上由两个服务器构成:一个是 SSDP 服务器,负责通过 SSDP 协议 控制设备的发现、更改与离开;一个是 HTTP 服务器,负责托管各种描述文件,以及指令的接收和结果的返回。

SSDP

Simple Service Discovery Protocol (SSDP) 是基于文本的协议。除了它使用 UDP 外,其它方面,例如报文结构,和 HTTP 不能说十分相似,只能说一模一样。例如,一个经典的广播消息可以长这样:

NOTIFY * HTTP/1.1
Host: 239.255.255.250:1900
NT: upnp:rootdevice
NTS: ssdp:alive
Location: http://192.168.14.122:2869/upnphost/udhisapi.dll?content=uuid:04f976f9-14d8-4205-a6c4-950dad87c430
USN: uuid:04f976f9-14d8-4205-a6c4-950dad87c430::upnp:rootdevice
Cache-Control: max-age=1800
Server: Microsoft-Windows/10.0 UPnP/1.0 UPnP-Device-Host/1.0

通过 SSDP 告知自己是 DMC 主要有两种途径:

途径 HTTP Method (?) 主/被动 备注
广播 NOTIFY 主动 向所有设备广播自己的状态 (ssdp:alive)
应答 M-SEARCH 的应答 被动 当有设备寻找 DMR 时,向其发送应答

广播

简单来说,广播的主要是下面这些信息:

  • 通知类型 (NT, Notification Type)
  • 通知子类 (NTS, Notification Sub Type)
  • 唯一服务名 (USN, Unique Service Name)
  • 设备描述所在地址 (Location)

DMR 实际上注册了多个服务,同时还需要告知自己的角色等信息,而这些都是分开广播的,因此需要广播多次。对于设备描述所在地址 (Location),你猜的没错,这个文件就是需要 HTTP 服务器 host 的。

应答

一个典型的 M-SEARCH 消息可以长这样:

M-SEARCH * HTTP/1.1
MX: 1
ST: upnp:rootdevice
MAN: "ssdp:discover"
User-Agent: Linux/3.0.0 UPnP/1.0 Platinum/1.0.5.13
Connection: close
Host: 239.255.255.250:1900

既然我们做的是 dummy DMR,可以偷个懒 - 只要看到 M-SEARCH 消息就回应,而不必处理它的搜索目标是不是我们的 DMR,因此此处不展开。对上述搜索消息的回应是:

HTTP/1.1 200 OK
ST: upnp:rootdevice
USN: uuid:04f976f9-14d8-4205-a6c4-950dad87c430::upnp:rootdevice
Location: http://192.168.14.122:2869/upnphost/udhisapi.dll?content=uuid:04f976f9-14d8-4205-a6c4-950dad87c430
Cache-Control: max-age=1800
Server: Microsoft-Windows/10.0 UPnP/1.0 UPnP-Device-Host/1.0
Ext:
Date: Wed, 28 May 2025 03:51:18 GMT

HTTP

因为 Windows Media Player 的网址路由难以理解,接下来我将使用我重映射的路由进行讲解。最关键的主要有以下三个路由:

  • /DeviceSpec: 设备描述路由
  • /AVTransport: AVTransport 服务路由
  • /RenderingControl: RenderingControl 服务路由

DeviceSpec

这个路由只接受 GET 方法,返回设备描述文件。设备描述文件用 XML 定义,主要包含设备类型、名称、制造商、序列号、UUID 以及包含的服务等信息。下面的两个路由都是从这里定义的。示例设备描述文件模板

AVTransport

这个路由在 GET 方法下返回 AVTransport 服务的服务描述文件,在 POST 方法下调用此服务的某个动作。主要包括查询更改播放内容、播放进度、循环模式,开始、暂停、停止播放等动作。示例 AVTransport 服务描述文件。一个示例 POST 请求体如下:

<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
    <s:Body>
        <u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
            <InstanceID>0</InstanceID>
            <CurrentURI>http://example.com/sample.mp4?param1=a¶m2=b</CurrentURI>
            <CurrentURIMetaData/>
        </u:SetAVTransportURI>
    </s:Body>
</s:Envelope>

它就是调用了 AVTransportSetAVTransportURI,其中参数 InstanceID0,参数 CurrentURIhttp://example.com/sample.mp4?param1=a¶m2=b (注意解码),参数 CurrentURIMetaData 为空。意思就是设置播放的网址为 http://example.com/sample.mp4?param1=a¶m2=b

RenderingControl

AVTransport 类似,这个路由在 GET 方法下返回 RenderingControl 服务的服务描述文件,在 POST 方法下调用此服务的某个动作。主要包括查询更改静音、音量等动作。示例 RenderingControl 服务描述文件

代码实现

🐍 Python POC

最后,将上述内容结合起来,我们可以用 Python 做一个最小的 dummy DMR,用于验证我们的猜想:

import socket
from http.server import BaseHTTPRequestHandler, HTTPServer
import threading
import uuid
from datetime import datetime, timezone

# Configuration - Protocol and Network
IP = socket.gethostbyname(socket.gethostname())
SSDP_PORT = 1900
HTTP_PORT = 8000

# Configuration - Device Information
FRIENDLY_NAME = "Dummy Renderer"
MODEL_NAME = "Dummy Model"
MODEL_DESCRIPTION = "A dummy UPnP renderer for testing purposes"
MODEL_URL = "http://dummy-renderer.com/model"
MANUFACTURER = "Dummy Manufacturer"
MANUFACTURER_URL = "http://dummy-manufacturer.com"
SERIAL_NUMBER = "12345678-1234-5678-1234-567812345678"
UUID = str(uuid.uuid5(uuid.NAMESPACE_URL, 'DummyRenderer'))

# Constants
SSDP_IP = "239.255.255.250"
SSDP_HOST = "239.255.255.250:1900"
SERVER_NAME = "DummyRenderer/1.0 UPnP/1.0 UPnP-Device-Host/1.0"

# XMLs - Get them from https://github.com/PRO-2684/dlna-dmr/blob/c0f8c6f2b8c13c270fa7f12e8199b3a86459df68/src/template/
with open("./xml/DeviceSpec.tmpl.xml", "r") as f:
    DEVICE_SPEC_XML = f.read() \
        .replace("{{friendlyName}}", FRIENDLY_NAME) \
        .replace("{{modelName}}", MODEL_NAME) \
        .replace("{{modelDescription}}", MODEL_DESCRIPTION) \
        .replace("{{modelURL}}", MODEL_URL) \
        .replace("{{manufacturer}}", MANUFACTURER) \
        .replace("{{manufacturerURL}}", MANUFACTURER_URL) \
        .replace("{{serialNumber}}", SERIAL_NUMBER) \
        .replace("{{uuid}}", UUID) \
        .encode('utf-8')
with open("./xml/RenderingControl.xml", "rb") as f:
    RENDERING_CONTROL_XML = f.read()
with open("./xml/AVTransport.xml", "rb") as f:
    AV_TRANSPORT_XML = f.read()

def gmt_time():
    """Return the current time in GMT format."""
    return datetime.now(timezone.utc).strftime('%a, %d %b %Y %H:%M:%S GMT')

class SSDPServer(threading.Thread):
    def __init__(self):
        super().__init__()
        self.daemon = True
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Create a UDP socket
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Allow reuse of the address
        self.sock.bind(('', SSDP_PORT)) # Bind to all interfaces on SSDP_PORT
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) # Allow receiving broadcast messages
        self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, socket.inet_aton(SSDP_IP) + socket.inet_aton(IP)) # Join the multicast group

    def _notify(self, nt: str, nts: str, usn: str):
        """Send a SSDP notify message with given Notification Type, Notification Sub Type and Unique Service Name.

        Parameters:

        - nt: Notification Type (NT)
        - nts: Notification Sub Type (NTS)
        - usn: Unique Service Name (USN)
        """
        message = (
            "NOTIFY * HTTP/1.1\r\n"
            f"HOST: {SSDP_HOST}\r\n"
            f"NT: {nt}\r\n"
            f"NTS: {nts}\r\n"
            f"USN: {usn}\r\n"
            f"Location: http://{IP}:{HTTP_PORT}/DeviceSpec\r\n"
            "Cache-Control: max-age=1800\r\n"
            f"Server: {SERVER_NAME}\r\n"
            'OPT:"http://schemas.upnp.org/upnp/1/0/"; ns=01\r\n'
            "\r\n"
        )
        self.sock.sendto(message.encode('utf-8'), (SSDP_IP, SSDP_PORT))

    def _notify_service(self, service: str, nts: str):
        """Broadcast a notify message for given `service` with given Notification Sub Type.

        Parameters:

        - service: The UPnP service name (e.g., "RenderingControl", "AVTransport", "ConnectionManager")
        - nts: Notification Sub Type (NTS)
        """
        self._notify(
            nt=f"urn:schemas-upnp-org:service:{service}:1",
            nts=nts,
            usn=f"uuid:{UUID}::urn:schemas-upnp-org:service:{service}:1",
        )

    def _notify_all(self, nts: str):
        """Broadcast multiple relevant notify messages with given Notification Sub Type.

        Parameters:

        - nts: Notification Sub Type (NTS)
        """
        self._notify(
            nt="upnp:rootdevice",
            nts=nts,
            usn=f"uuid:{UUID}::upnp:rootdevice",
        )
        self._notify(
            nt=f"uuid:{UUID}",
            nts=nts,
            usn=f"uuid:{UUID}",
        )
        for service in ["RenderingControl", "AVTransport", "ConnectionManager"]:
            self._notify_service(service, nts=nts)

    def alive(self):
        """Broadcast multiple relevant `ssdp:alive` messages."""
        self._notify_all(nts="ssdp:alive")

    def bye(self):
        """Broadcast multiple relevant `ssdp:byebye` message."""
        self._notify_all(nts="ssdp:byebye")

    def answer(self, ip: str, port: int, message: str):
        """Answer a SSDP message from given IP and port."""
        if message.startswith("M-SEARCH"):
            self._answer_discover(ip, port)
        elif message.startswith("NOTIFY"):
            print(f"Received NOTIFY message from {ip}:{port}:\n{message}")
        else:
            print(f"Unhandled SSDP message: {message}")

    def _answer_discover(self, ip: str, port: int):
        """Answer a `ssdp:discover` request from given IP and port, like:

        M-SEARCH * HTTP/1.1
        MX: 1
        ST: upnp:rootdevice
        MAN: "ssdp:discover"
        User-Agent: Linux/3.0.0 UPnP/1.0 Platinum/1.0.5.13
        Connection: close
        Host: 239.255.255.250:1900
        """
        message = (
            "HTTP/1.1 200 OK\r\n"
            f"ST: upnp:rootdevice\r\n"
            f"USN: uuid:{UUID}::upnp:rootdevice\r\n"
            f"Location: http://{IP}:{HTTP_PORT}/DeviceSpec\r\n"
            'OPT:"http://schemas.upnp.org/upnp/1/0/"; ns=01\r\n'
            "Cache-Control: max-age=900\r\n"
            f"Server: {SERVER_NAME}\r\n"
            "EXT:\r\n"
            f"Date: {gmt_time()}\r\n"
            "\r\n"
        )
        self.sock.sendto(message.encode('utf-8'), (ip, port))

    def run(self):
        print(f"SSDP server running on {IP}:{SSDP_PORT}")
        while True:
            try:
                data, addr = self.sock.recvfrom(4096)
                message = data.decode('utf-8')
                print(f"[SSDP] Received message from {addr[0]}:{addr[1]}:\n{message}")
                self.answer(addr[0], addr[1], message)
            except Exception as e:
                print(f"Error: {e}")

class HTTPRequestHandler(BaseHTTPRequestHandler):
    """HTTP request handler for the dummy UPnP renderer. Endpoints:

    - /RenderingControl
    - /DeviceSpec
    - /AVTransport
    - /Ignore
    """
    def do_GET(self):
        print(f"GET request from {self.client_address[0]}:{self.client_address[1]} to {self.path}")
        print(f"Headers: {self.headers}")
        if self.path == "/DeviceSpec":
            self.send_response(200)
            self.send_header("Content-Type", "text/xml; charset=utf-8")
            self.end_headers()
            self.wfile.write(DEVICE_SPEC_XML)
        elif self.path == "/RenderingControl":
            self.send_response(200)
            self.send_header("Content-Type", "text/xml; charset=utf-8")
            self.end_headers()
            self.wfile.write(RENDERING_CONTROL_XML)
        elif self.path == "/AVTransport":
            self.send_response(200)
            self.send_header("Content-Type", "text/xml; charset=utf-8")
            self.end_headers()
            self.wfile.write(AV_TRANSPORT_XML)
        elif self.path == "/Ignore":
            self.send_response(204)  # No Content
            self.end_headers()
        else:
            self.send_error(404, "Not Found")

    def do_POST(self):
        # Log the request details
        print(f"POST request from {self.client_address[0]}:{self.client_address[1]} to {self.path}")
        print(f"Headers: {self.headers}")
        print(f"Body: {self.rfile.read(int(self.headers.get('Content-Length', 0))).decode('utf-8')}")
        if self.path == "/DeviceSpec":
            # Errors for now
            self.send_error(718, "Invalid InstanceID") # UPnP error code for "Invalid InstanceID"
            # See 5.4.32 Common Error Codes of AVTransport-v3-Service
        elif self.path == "/RenderingControl":
            self.send_error(718, "Invalid InstanceID")
        elif self.path == "/AVTransport":
            self.send_error(718, "Invalid InstanceID")
        elif self.path == "/Ignore":
            self.send_error(718, "Invalid InstanceID")
        else:
            self.send_error(404, "Not Found")

if __name__ == "__main__":
    # Start SSDP server
    ssdp_server = SSDPServer()
    ssdp_server.start()
    ssdp_server.alive()

    # Start HTTP server
    httpd = HTTPServer((IP, HTTP_PORT), HTTPRequestHandler)
    print(f"HTTP server running on {IP}:{HTTP_PORT}")

    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("Shutting down the server...")
        ssdp_server.bye()
        httpd.server_close()
        print("Server stopped.")

其中所需的 XML 模板文件可以从 此处 下载。它打印了所有 GETPOST 请求,但是仍然需要自己在一大堆日志内找到网址然后自行解码。无论如何,对于局域网内的其它设备,它确实是一个货真价实的 DMR。

🦀 Rust

最终我将这份代码迁移到了 Rust,核心代码如下:

// ssdp.rs
#[derive(Debug)]
pub struct SSDPServer {
    socket: UdpSocket,
    address: SocketAddrV4,
    uuid: String,
    http_port: u16,
}

impl SSDPServer {
    /// The multicast address used for SSDP discovery.
    const SSDP_MULTICAST_ADDR: SocketAddrV4 =
        SocketAddrV4::new(Ipv4Addr::new(239, 255, 255, 250), 1900);
    /// The SSDP server's name.
    const SSDP_SERVER_NAME: &'static str = "CustomSSDP/1.0";
    /// Interval for sending keep-alive messages.
    const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(60);

    /// Creates a new SSDP server bound to the specified address with the given UUID and HTTP port.
    pub async fn new(address: SocketAddrV4, uuid: String, http_port: u16) -> Result<Self> {
        let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
        socket.set_nonblocking(true)?;
        socket.set_reuse_address(true)?;
        socket.bind(&SockAddr::from(SocketAddrV4::new(
            Ipv4Addr::UNSPECIFIED,
            address.port(),
        )))?;
        // Set the socket to allow broadcast.
        socket.set_broadcast(true)?;
        // Join the SSDP multicast group.
        socket.join_multicast_v4(
            Self::SSDP_MULTICAST_ADDR.ip(), // Multicast address
            address.ip(),                   // Use the unspecified address for the local interface
        )?;
        // Convert the socket to a Tokio UdpSocket.
        let socket = UdpSocket::from_std(socket.into())?;

        Ok(Self {
            socket,
            address,
            uuid,
            http_port,
        })
    }

    /// Send a SSDP notify message with given Notification Type, Notification Sub Type and Unique Service Name.
    async fn notify(&self, nt: &str, nts: &str, usn: &str) -> Result<()> {
        let message = format!(
            "NOTIFY * HTTP/1.1\r\n\
             HOST: {}\r\n\
             NT: {}\r\n\
             NTS: {}\r\n\
             USN: {}\r\n\
             LOCATION: http://{}/description.xml\r\n\
             CACHE-CONTROL: max-age=1800\r\n\
             SERVER: {}\r\n\
             \r\n",
            Self::SSDP_MULTICAST_ADDR,
            nt,
            nts,
            usn,
            self.address,
            Self::SSDP_SERVER_NAME
        );
        self.socket
            .send_to(message.as_bytes(), &Self::SSDP_MULTICAST_ADDR)
            .await?;
        Ok(())
    }

    /// Broadcast a notify message for given `service` with given Notification Sub Type.
    async fn notify_service(&self, service: &str, nts: &str) -> Result<()> {
        self.notify(
            &format!("urn:schemas-upnp-org:service:{service}:1"),
            nts,
            &format!(
                "uuid:{uuid}::urn:schemas-upnp-org:service:{service}:1",
                uuid = self.uuid
            ),
        )
        .await
    }

    /// Broadcast multiple relevant notify messages with given Notification Sub Type.
    async fn notify_all(&self, nts: &str) -> Result<()> {
        let uuid_with_prefix = format!("uuid:{}", self.uuid);

        self.notify(
            "upnp:rootdevice",
            nts,
            &format!("{uuid_with_prefix}::upnp:rootdevice"),
        )
        .await?;
        self.notify(&uuid_with_prefix, nts, &uuid_with_prefix)
            .await?;
        for service in ["RenderingControl", "AVTransport", "ConnectionManager"] {
            self.notify_service(service, nts).await?;
        }

        Ok(())
    }

    /// Broadcast multiple relevant `ssdp:alive` messages.
    async fn alive(&self) -> Result<()> {
        self.notify_all("ssdp:alive").await
    }

    /// Broadcast multiple relevant `ssdp:alive` messages periodically. (Keep-alive / Heartbeat)
    pub async fn keep_alive(&self) {
        info!("Starting SSDP keep-alive thread");
        loop {
            if let Err(e) = self.alive().await {
                error!("Failed to send SSDP alive message: {e}");
            } else {
                trace!("SSDP alive message sent");
            }
            sleep(Self::KEEP_ALIVE_INTERVAL).await;
        }
    }

    /// Broadcast multiple relevant `ssdp:byebye` messages.
    async fn byebye(&self) -> Result<()> {
        self.notify_all("ssdp:byebye").await
    }

    /// Answer a SSDP message from given address.
    async fn answer(&self, address: SocketAddrV4, message: &str) -> Result<()> {
        if message.starts_with("M-SEARCH") {
            self.answer_search(address, message).await
        } else if message.starts_with("NOTIFY") {
            Ok(())
        } else {
            Err(Error::new(
                ErrorKind::InvalidData,
                format!("Received unknown SSDP message: {message}"),
            ))
        }
    }

    /// Answer a M-SEARCH request.
    async fn answer_search(&self, address: SocketAddrV4, _message: &str) -> Result<()> {
        let response = format!(
            "HTTP/1.1 200 OK\r\n\
             ST: upnp:rootdevice\r\n\
             USN: uuid:{}::upnp:rootdevice\r\n\
             Location: http://{}:{}/DeviceSpec\r\n\
             OPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n\
             Cache-Control: max-age=900\r\n\
             Server: {}\r\n\
             EXT:\r\n\
             Date: {}\r\n\
            \r\n",
            self.uuid,
            self.address.ip(),
            self.http_port,
            Self::SSDP_SERVER_NAME,
            chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT")
        );
        trace!("Sending SSDP response to {address}: {response}");
        self.socket.send_to(response.as_bytes(), address).await?;

        Ok(())
    }

    /// Starts the SSDP server.
    pub async fn run(&self) {
        info!("SSDP server running on {}", self.address);

        let mut buf = [0u8; 4096];
        loop {
            match self.socket.recv_from(&mut buf).await {
                Ok((size, addr)) => {
                    let message = String::from_utf8_lossy(&buf[..size]);
                    let SocketAddr::V4(ipv4) = addr else {
                        error!("Received non-IPv4 address: {addr:?}");
                        continue;
                    };
                    trace!("Received SSDP message from {ipv4}: {message}");
                    if let Err(e) = self.answer(ipv4, &message).await {
                        error!("Error answering SSDP message: {e}");
                    }
                }
                Err(e) if e.kind() == ErrorKind::WouldBlock => {} // Non-blocking mode, just do nothing.
                Err(e) => {
                    error!("Error receiving SSDP message: {e}");
                }
            }
        }
    }

    /// Stops the SSDP server.
    pub async fn stop(&self) {
        if let Err(e) = self.byebye().await {
            error!("Failed to send SSDP byebye message: {e}");
        } else {
            info!("SSDP server stopped");
        }
    }
}
// http.rs
pub trait HTTPServer: Sync {
    /// Create and run a HTTP server with the given options.
    fn run_http(&'static self, options: Arc<DMROptions>) -> impl Future<Output = IoResult<()>> + Send {async {
        let ip = options.ip;
        let http_port = options.http_port;
        let listener = tokio::net::TcpListener::bind(SocketAddrV4::new(ip, http_port)).await?;
        info!("HTTP server listening on {ip}:{http_port}");

        let app = Router::new()
            .route(
                "/DeviceSpec",
                get(async || Self::get_device_spec(options).await).post(Self::post_device_spec),
            )
            .route(
                "/RenderingControl",
                get(Self::get_rendering_control).post(async |s: String| {
                    self.post_rendering_control(RenderingControl::from_str(&s))
                        .await
                }),
            )
            .route(
                "/AVTransport",
                get(Self::get_av_transport).post(async |s: String| {
                    self.post_av_transport(AVTransport::from_str(&s)).await
                }),
            )
            .route(
                "/Ignore",
                get(Self::get_ignore).post(async || self.post_ignore().await),
            );

        axum::serve(listener, app).await
    } }

    // POST Request handlers for specific endpoints.

    /// Handles POST requests for `/DeviceSpec`.
    fn post_device_spec() -> impl Future<Output = impl IntoResponse> + Send {
        async { StatusCode::METHOD_NOT_ALLOWED }
    }

    /// Handles POST requests for `/RenderingControl`.
    fn post_rendering_control(
        &self,
        rendering_control: Result<RenderingControl, DeError>,
    ) -> impl Future<Output = impl IntoResponse> + Send {
        async { StatusCode::METHOD_NOT_ALLOWED }
    }

    /// Handles POST requests for `/AVTransport`.
    fn post_av_transport(
        &self,
        av_transport: Result<AVTransport, DeError>,
    ) -> impl Future<Output = impl IntoResponse> + Send {
        async { StatusCode::METHOD_NOT_ALLOWED }
    }

    /// Handles POST requests for `/Ignore`.
    fn post_ignore(&self) -> impl Future<Output = impl IntoResponse> + Send {
        async { StatusCode::NO_CONTENT }
    }

    // GET Request handlers for specific endpoints.

    /// Handles GET requests for `/DeviceSpec`.
    fn get_device_spec(options: Arc<DMROptions>) -> impl Future<Output = impl IntoResponse> + Send {
        async move {
            /// Escapes given field under `options`.
            macro_rules! e {
                ($i:ident) => {
                    escape(&options.$i)
                };
            }
            let xml = format!(
                include_str!("./template/DeviceSpec.tmpl.xml"),
                friendlyName = e!(friendly_name),
                modelName = e!(model_name),
                modelDescription = e!(model_description),
                modelURL = e!(model_url),
                manufacturer = e!(manufacturer),
                manufacturerURL = e!(manufacturer_url),
                serialNumber = e!(serial_number),
                uuid = e!(uuid),
            );
            (
                StatusCode::OK,
                [("Content-Type", r#"text/xml; charset="utf-8""#)],
                xml,
            )
        }
    }

    /// Handles GET requests for `/RenderingControl`.
    fn get_rendering_control() -> impl Future<Output = impl IntoResponse> + Send {
        async {
            (
                StatusCode::OK,
                [("Content-Type", r#"text/xml; charset="utf-8""#)],
                include_str!("./template/RenderingControl.xml"),
            )
        }
    }

    /// Handles GET requests for `/AVTransport`.
    fn get_av_transport() -> impl Future<Output = impl IntoResponse> + Send {
        async {
            (
                StatusCode::OK,
                [("Content-Type", r#"text/xml; charset="utf-8""#)],
                include_str!("./template/AVTransport.xml"),
            )
        }
    }

    /// Handles GET requests for `/Ignore`.
    fn get_ignore() -> impl Future<Output = impl IntoResponse> + Send {
        async { StatusCode::NO_CONTENT }
    }
}

示例日志输出如下:

$ dlna-dmr
[2025-05-30T14:49:48Z INFO  dlna_dmr] DMR started
[2025-05-30T14:49:48Z INFO  dlna_dmr::ssdp] SSDP server running on 172.31.117.144:1900
[2025-05-30T14:49:48Z INFO  dlna_dmr::http] HTTP server listening on 172.31.117.144:8080
[2025-05-30T14:50:11Z INFO  dlna_dmr::http] RenderingControl::SetMute channel: Master, desired_mute: false
[2025-05-30T14:50:38Z INFO  dlna_dmr::http] AVTransport::SetAvTransportUri current_uri: http://example.com/sample.mp4?param1=a¶m2=b
^C
[2025-05-30T14:50:46Z INFO  dlna_dmr::http] HTTP server stopped
[2025-05-30T14:50:46Z INFO  dlna_dmr::ssdp] SSDP server stopped
[2025-05-30T14:50:46Z INFO  dlna_dmr] DMR stopped

这就已经很明晰了。为了减少输出,我只输出了设置状态的指令,而忽略查询状态的指令。代码仓库和预编译的可执行文件就在 PRO-2684/dlna-dmr,欢迎大家尝试。

参考资料

免费评分

参与人数 9威望 +1 吾爱币 +30 热心值 +8 收起 理由
smile1110 + 2 + 1 热心回复!
RobinMaas + 2 + 1 用心讨论,共获提升!
wizarrr + 1 + 1 热心回复!
苏紫方璇 + 1 + 20 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
oldbabyjsx + 1 + 1 有点用 但不多的 情景
wkdxz + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
FitContent + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
danielau + 1 + 1 谢谢@Thanks!
popdes + 1 + 1 谢谢@Thanks!

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
1颗温暖的心〝 发表于 2026-2-22 21:26
https://github.com/PRO-2684/dlna-dmr  刚才在看你的git  看到本尊了
沙发
xixicoco 发表于 2025-6-13 22:14
3#
crystalZ 发表于 2025-6-13 22:33
4#
yibaoshutiao 发表于 2025-6-13 23:05
思路不错,但是好像有优酷这种会员只能手机上看的不给投屏
5#
senooo 发表于 2025-6-13 23:05
学习了,很强大,大佬讲解的很细致。而且python和rust都非常棒
6#
m_h 发表于 2025-6-14 00:10
好复杂,牛!
7#
popdes 发表于 2025-6-14 07:19
有用的知识又增加了
8#
daoye9988 发表于 2025-6-14 08:07
学习了,非常详细教程
9#
 楼主| 三滑稽甲苯 发表于 2025-6-14 08:19 |楼主
xixicoco 发表于 2025-6-13 22:14
其实主要讲的还是投屏,支持

写代码实际上没花多少时间,理解协议是最费时间的
10#
wkdxz 发表于 2025-6-14 08:20
楼主好人做到底,把“所需的 XML 模板文件”传下网盘,github打不开
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-7-16 21:33

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表