import urllib.parse import xml.etree.ElementTree as ET import requests from urllib.parse import urlparse from core import AppLogger class DLNAController: """ DLNA 媒体渲染器控制器 只负责控制已发现的设备,不包含发现功能 """ def __init__(self, device_info): """ 初始化控制器 Args: device_info: 设备信息字典,必须包含: - 'location': 设备描述文档的URL - 'friendly_name': 设备友好名称 (可选) - 'ip': 设备IP地址 (可选) """ if 'location' not in device_info: raise ValueError("device_info 必须包含 'location' 字段") self.device_info = device_info self.location = device_info['location'] self.base_url = None self.av_transport_url = None self.rendering_control_url = None self.connection_manager_url = None # 解析设备信息 self._parse_device_info() self.logger = AppLogger('DLNAController') def _parse_device_info(self): """ 解析设备描述文档,提取控制URL """ try: # 获取基础URL parsed = urlparse(self.location) self.base_url = f"{parsed.scheme}://{parsed.netloc}" # 获取设备描述文档 response = requests.get(self.location, timeout=5) response.raise_for_status() # 解析XML root = ET.fromstring(response.content) ns = {'upnp': 'urn:schemas-upnp-org:device-1-0'} # 查找服务列表 service_list = root.find('.//upnp:device/upnp:serviceList', ns) if service_list is None: raise ValueError("设备描述中未找到服务列表") # 遍历服务,提取控制URL for service in service_list.findall('upnp:service', ns): service_type_elem = service.find('upnp:serviceType', ns) control_url_elem = service.find('upnp:controlURL', ns) if service_type_elem is None or control_url_elem is None: continue service_type = service_type_elem.text control_url = control_url_elem.text # 确保URL是完整的 if control_url.startswith('/'): control_url = f"{self.base_url}{control_url}" if 'AVTransport' in service_type: self.av_transport_url = control_url elif 'RenderingControl' in service_type: self.rendering_control_url = control_url elif 'ConnectionManager' in service_type: self.connection_manager_url = control_url # 验证必要的服务是否存在 if not self.av_transport_url: raise ValueError("设备不支持 AVTransport 服务") except requests.exceptions.RequestException as e: raise ConnectionError(f"无法连接设备: {e}") except ET.ParseError as e: raise ValueError(f"设备描述文档格式错误: {e}") except Exception as e: raise RuntimeError(f"解析设备信息失败: {e}") def _send_soap_request(self, service_url, action, body): """ 发送SOAP请求到设备 Args: service_url: 服务控制URL action: SOAP Action body: SOAP Body内容 Returns: tuple: (success, response_xml) - 成功标志和响应XML """ soap_envelope = f""" {body} """ headers = { 'Content-Type': 'text/xml; charset="utf-8"', 'SOAPAction': f'"{action}"', 'User-Agent': 'Python DLNA Controller' } try: response = requests.post(service_url, data=soap_envelope, headers=headers, timeout=5) response.raise_for_status() return True, response.text except requests.exceptions.Timeout: return False, "请求超时" except requests.exceptions.ConnectionError: return False, "连接失败" except Exception as e: return False, str(e) # ================== AVTransport 服务方法 ================== def set_av_transport_uri(self, media_url, title = "", metadata=""): """ 设置播放URI Args: media_url: 媒体文件的URL title: 媒体文件名称 metadata: 媒体元数据(可选) Returns: bool: 是否成功 """ if not self.av_transport_url: return False if title and not metadata: # 转义title中的XML特殊字符 title_escaped = title.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') # 构建简单的DIDL-Lite元数据 metadata = f""" {title_escaped} """ # 转义XML特殊字符 media_url_escaped = media_url.replace('&', '&').replace('<', '<').replace('>', '>') media_url_escaped = urllib.parse.quote(media_url_escaped,safe=':/') metadata_escaped = metadata.replace('&', '&').replace('<', '<').replace('>', '>') soap_body = f""" 0 {media_url_escaped} {metadata_escaped} """ action = "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI" success, response = self._send_soap_request(self.av_transport_url, action, soap_body) self.logger.info(f"设置播放URI:{media_url_escaped}") self.logger.info(f"设置播放URI:{success}:{response}") return success def play(self, speed = 1.0): """ 开始播放 Args: speed: 播放速度,默认为1 Returns: bool: 是否成功 """ if not self.av_transport_url: return False soap_body = f""" 0 {speed} """ action = "urn:schemas-upnp-org:service:AVTransport:1#Play" success, response = self._send_soap_request(self.av_transport_url, action, soap_body) self.logger.info(f"播放结果:{success}:{response}") return success def pause(self): """ 暂停播放 Returns: bool: 是否成功 """ if not self.av_transport_url: return False soap_body = """ 0 """ action = "urn:schemas-upnp-org:service:AVTransport:1#Pause" success, response = self._send_soap_request(self.av_transport_url, action, soap_body) self.logger.info(f"暂停播放结果:{success}:{response}") return success def stop(self): """ 停止播放 Returns: bool: 是否成功 """ if not self.av_transport_url: return False soap_body = """ 0 """ action = "urn:schemas-upnp-org:service:AVTransport:1#Stop" success, response = self._send_soap_request(self.av_transport_url, action, soap_body) self.logger.info(f"停止播放结果:{success}:{response}") return success def seek(self, unit, target): """ 跳转到指定位置 Args: unit: 时间单位,如 "REL_TIME"(相对时间)或 "TRACK_NR"(曲目号) target: 目标位置,如 "00:05:30" 或 "2"(第2首) Returns: bool: 是否成功 """ if not self.av_transport_url: return False target_escaped = target.replace('&', '&').replace('<', '<').replace('>', '>') soap_body = f""" 0 {unit} {target_escaped} """ action = "urn:schemas-upnp-org:service:AVTransport:1#Seek" success, response = self._send_soap_request(self.av_transport_url, action, soap_body) self.logger.info(f"跳转到指定位置结果:{success}:{response}") return success def get_transport_info(self): """ 获取传输状态信息 Returns: dict: 传输状态信息,包含: - state: 当前传输状态 - status: 当前传输状态 - speed: 当前播放速度 None: 获取失败 """ if not self.av_transport_url: return None soap_body = """ 0 """ action = "urn:schemas-upnp-org:service:AVTransport:1#GetTransportInfo" success, response = self._send_soap_request(self.av_transport_url, action, soap_body) if not success: return None try: # 解析响应XML root = ET.fromstring(response) ns = {'s': 'http://schemas.xmlsoap.org/soap/envelope/', 'u': 'urn:schemas-upnp-org:service:AVTransport:1'} # 查找Body中的响应 body = root.find('.//s:Body', ns) if body is None: return None # 获取状态信息 current_transport_state = body.find('.//CurrentTransportState') current_transport_status = body.find('.//CurrentTransportStatus') current_speed = body.find('.//CurrentSpeed') return { 'state': current_transport_state.text if current_transport_state is not None else '未知', 'status': current_transport_status.text if current_transport_status is not None else '未知', 'speed': current_speed.text if current_speed is not None else '未知' } except ET.ParseError: return None def get_position_info(self): """ 获取当前位置信息 Returns: dict: 位置信息,包含: - track: 当前曲目 - track_duration: 曲目时长 - track_meta_data: 曲目元数据 - track_uri: 曲目URI - rel_time: 相对时间 - abs_time: 绝对时间 - rel_count: 相对计数 - abs_count: 绝对计数 None: 获取失败 """ if not self.av_transport_url: return None soap_body = """ 0 """ action = "urn:schemas-upnp-org:service:AVTransport:1#GetPositionInfo" success, response = self._send_soap_request(self.av_transport_url, action, soap_body) if not success: return None try: root = ET.fromstring(response) ns = {'s': 'http://schemas.xmlsoap.org/soap/envelope/', 'u': 'urn:schemas-upnp-org:service:AVTransport:1'} body = root.find('.//s:Body', ns) if body is None: return None # 提取位置信息 info = {} fields = [ 'Track', 'TrackDuration', 'TrackMetaData', 'TrackURI', 'RelTime', 'AbsTime', 'RelCount', 'AbsCount' ] for field in fields: elem = body.find(f'.//{field}') info[field.lower()] = elem.text if elem is not None else '' return info except ET.ParseError: return None # ================== RenderingControl 服务方法 ================== def set_volume(self, volume, channel='Master'): """ 设置音量 Args: volume: 音量值 (0-100) channel: 声道,默认为 'Master' Returns: bool: 是否成功 """ if not self.rendering_control_url: return False # 确保音量在0-100范围内 volume = max(0, min(100, int(volume))) soap_body = f""" 0 {channel} {volume} """ action = "urn:schemas-upnp-org:service:RenderingControl:1#SetVolume" success, response = self._send_soap_request(self.rendering_control_url, action, soap_body) self.logger.info(f"音量设置结果:{success}:{response}") return success def get_volume(self, channel='Master'): """ 获取当前音量 Args: channel: 声道,默认为 'Master' Returns: int: 音量值 (0-100),None表示获取失败 """ if not self.rendering_control_url: return None soap_body = f""" 0 {channel} """ action = "urn:schemas-upnp-org:service:RenderingControl:1#GetVolume" success, response = self._send_soap_request(self.rendering_control_url, action, soap_body) if not success: return None try: root = ET.fromstring(response) ns = {'s': 'http://schemas.xmlsoap.org/soap/envelope/', 'u': 'urn:schemas-upnp-org:service:RenderingControl:1'} body = root.find('.//s:Body', ns) if body is None: return None current_volume = body.find('.//CurrentVolume') if current_volume is not None and current_volume.text: return int(current_volume.text) except (ET.ParseError, ValueError): pass return None def set_mute(self, mute, channel='Master'): """ 设置静音 Args: mute: 是否静音 (True/False) channel: 声道,默认为 'Master' Returns: bool: 是否成功 """ if not self.rendering_control_url: return False desired_mute = "1" if mute else "0" soap_body = f""" 0 {channel} {desired_mute} """ action = "urn:schemas-upnp-org:service:RenderingControl:1#SetMute" success, response = self._send_soap_request(self.rendering_control_url, action, soap_body) self.logger.info(f"静音设置结果:{success}:{response}") return success def get_mute(self, channel='Master'): """ 获取静音状态 Args: channel: 声道,默认为 'Master' Returns: bool: 是否静音,None表示获取失败 """ if not self.rendering_control_url: return None soap_body = f""" 0 {channel} """ action = "urn:schemas-upnp-org:service:RenderingControl:1#GetMute" success, response = self._send_soap_request(self.rendering_control_url, action, soap_body) if not success: return None try: root = ET.fromstring(response) ns = {'s': 'http://schemas.xmlsoap.org/soap/envelope/', 'u': 'urn:schemas-upnp-org:service:RenderingControl:1'} body = root.find('.//s:Body', ns) if body is None: return None current_mute = body.find('.//CurrentMute') if current_mute is not None and current_mute.text: return current_mute.text == "1" except ET.ParseError: pass return None # ================== ConnectionManager 服务方法 ================== def get_protocol_info(self): """ 获取设备支持的协议信息 Returns: dict: 协议信息,包含: - source: 源协议信息 - sink: 接收协议信息 None: 获取失败 """ if not self.connection_manager_url: return None soap_body = """ """ action = "urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo" success, response = self._send_soap_request(self.connection_manager_url, action, soap_body) if not success: return None try: root = ET.fromstring(response) ns = {'s': 'http://schemas.xmlsoap.org/soap/envelope/', 'u': 'urn:schemas-upnp-org:service:ConnectionManager:1'} body = root.find('.//s:Body', ns) if body is None: return None source = body.find('.//Source') sink = body.find('.//Sink') return { 'source': source.text if source is not None else '', 'sink': sink.text if sink is not None else '' } except ET.ParseError: return None