From 708ba3bf579d27a5cd183fc5d041c0f0bea312ff Mon Sep 17 00:00:00 2001 From: Yatharth Sood Date: Sun, 5 Apr 2026 21:00:27 +0530 Subject: [PATCH] implemented dlna. Testing on my own server now --- copyparty/dlna.py | 561 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 528 insertions(+), 33 deletions(-) diff --git a/copyparty/dlna.py b/copyparty/dlna.py index b77d7b75..90223f6a 100644 --- a/copyparty/dlna.py +++ b/copyparty/dlna.py @@ -1,13 +1,18 @@ # coding: utf-8 from __future__ import print_function, unicode_literals +import base64 import errno +import os import re import select import socket +import stat import time +import xml.etree.ElementTree as ET from .__init__ import TYPE_CHECKING +from .authsrv import LEELOO_DALLAS from .multicast import MC_Sck, MCast from .util import CachedSet, formatdate, html_escape, min_ex @@ -22,6 +27,71 @@ if True: # pylint: disable=using-constant-test GRP = "239.255.255.250" +# DLNA Protocol Info for media types with DLNA.ORG_PN and OP flags +DLNA_PROTOCOL_INFO = { + # photo + ".jpg": "http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_SM;DLNA.ORG_OP=01", + ".jpeg": "http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_SM;DLNA.ORG_OP=01", + ".png": "http-get:*:image/png:DLNA.ORG_PN=PNG_LRG;DLNA.ORG_OP=01", + # audio + ".mp3": "http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01", + ".flac": "http-get:*:audio/flac:*", + ".wav": "http-get:*:audio/wav:*", + ".ogg": "http-get:*:audio/ogg:*", + ".aac": "http-get:*:audio/mp4:DLNA.ORG_PN=AAC_ISO_320;DLNA.ORG_OP=01", + ".m4a": "http-get:*:audio/mp4:DLNA.ORG_PN=AAC_ISO_320;DLNA.ORG_OP=01", + # video + ".mp4": "http-get:*:video/mp4:DLNA.ORG_PN=AVC_MP4_MP_SD;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000", + ".m4v": "http-get:*:video/mp4:DLNA.ORG_PN=AVC_MP4_MP_SD;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000", + ".mkv": "http-get:*:video/x-matroska:DLNA.ORG_PN=MATROSKA;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000", + ".webm": "http-get:*:video/x-matroska:DLNA.ORG_PN=MATROSKA;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000", + ".avi": "http-get:*:video/x-msvideo:*", + ".wmv": "http-get:*:video/x-ms-wmv:*", +} + +CONTAINER_MIME = "object.container.storageFolder" +IMAGE_MIME = "object.item.imageItem.photo" +AUDIO_MIME = "object.item.audioItem.musicTrack" +VIDEO_MIME = "object.item.videoItem" + + +def _esc_xml(s): + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """).replace("'", "'") + + +def _oid_encode(vpath): + if not vpath: + return "0" + return base64.urlsafe_b64encode(vpath.encode("utf-8")).decode("ascii").rstrip("=") + + +def _oid_decode(oid): + if oid == "0": + return "" + padded = oid + "=" * (4 - len(oid) % 4) if len(oid) % 4 else oid + return base64.urlsafe_b64decode(padded).decode("utf-8", errors="replace") + + +def _mime_for_ext(ext): + return DLNA_PROTOCOL_INFO.get(ext.lower(), "http-get:*:application/octet-stream:*") + + +def _dlna_class_for_ext(ext): + ext = ext.lower() + if ext in (".mp4", ".mkv", ".avi", ".wmv", ".webm", ".mov", ".ts", ".mpg", ".mpeg"): + return VIDEO_MIME + if ext in (".mp3", ".flac", ".wav", ".ogg", ".aac", ".m4a", ".wma"): + return AUDIO_MIME + if ext in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"): + return IMAGE_MIME + return "object.item" + + +def _url_quote(s): + from urllib.parse import quote as _q + + return _q(s, safe="") + class DLNA_Sck(MC_Sck): def __init__(self, *a): @@ -39,40 +109,66 @@ class DLNAr(object): def reply(self, hc: "HttpCli") -> bool: if hc.vpath.endswith("device.xml"): return self.tx_device(hc) + + if hc.vpath.endswith("ConnectionMgr.xml"): + return self.tx_connmgr_scpd(hc) + + if hc.vpath.endswith("ContentDir.xml"): + return self.tx_contentdir_scpd(hc) + + if "/ctl/ConnectionMgr" in hc.vpath: + return self.tx_connmgr_ctl(hc) + + if "/ctl/ContentDir" in hc.vpath: + return self.tx_contentdir_ctl(hc) + + if "/ctl/" in hc.vpath: + return self.tx_soap_fault(hc, 401, "Invalid Action") hc.reply(b"unknown request", 400) return False + # device description (MediaServer:1 + ConnectionManager:1 and ContentDirectory:1 services) def tx_device(self, hc: "HttpCli") -> bool: + '''UPnP-spec taken from running miniDLNA instance''' zs = """ - - - - 1 - 0 - - {} - - {} - urn:schemas-upnp-org:device:Basic:1 - {} - file server - ed - https://ocv.me/ - copyparty - https://github.com/9001/copyparty/ - {} - - - urn:schemas-upnp-org:device:Basic:1 - urn:schemas-upnp-org:device:Basic - /.cpr/dlna/services.xml - /.cpr/dlna/services.xml - /.cpr/dlna/services.xml - - - -""" + + + + 1 + 0 + + {} + + urn:schemas-upnp-org:device:MediaServer:1 + {} + file server + ed + https://ocv.me/ + copyparty + 1.0 + https://github.com/9001/copyparty/ + {} + DMS-1.50 + + + urn:schemas-upnp-org:service:ConnectionManager:1 + urn:upnp-org:serviceId:ConnectionManager + /.cpr/dlna/ctl/ConnectionMgr + /.cpr/dlna/evt/ConnectionMgr + /.cpr/dlna/ConnectionMgr.xml + + + urn:schemas-upnp-org:service:ContentDirectory:1 + urn:upnp-org:serviceId:ContentDirectory + /.cpr/dlna/ctl/ContentDir + /.cpr/dlna/evt/ContentDir + /.cpr/dlna/ContentDir.xml + + + + + """ c = html_escape sip, sport = hc.s.getsockname()[:2] @@ -82,19 +178,418 @@ class DLNAr(object): zsl = self.args.zsl url = zsl if "://" in zsl else ubase + "/" + zsl.lstrip("/") name = self.args.doctitle - zs = zs.strip().format(c(ubase), c(url), c(name), c(self.args.zsid)) + zs = zs.strip().format(c(ubase), c(name), c(self.args.zsid)) hc.reply(zs.encode("utf-8", "replace")) return False # close connection + # SCPD stuff + + def tx_connmgr_scpd(self, hc: "HttpCli") -> bool: + '''UPnP-spec taken from running miniDLNA instance''' + zs = """ + + + 10 + + + GetProtocolInfo + + SourceoutSourceProtocolInfo + SinkoutSinkProtocolInfo + + + + GetCurrentConnectionIDs + + ConnectionIDsoutCurrentConnectionIDs + + + + GetCurrentConnectionInfo + + ConnectionIDinA_ARG_TYPE_ConnectionID + RcsIDoutA_ARG_TYPE_RcsID + AVTransportIDoutA_ARG_TYPE_AVTransportID + ProtocolInfooutA_ARG_TYPE_ProtocolInfo + PeerConnectionManageroutA_ARG_TYPE_ConnectionManager + PeerConnectionIDoutA_ARG_TYPE_ConnectionID + DirectionoutA_ARG_TYPE_Direction + StatusoutA_ARG_TYPE_ConnectionStatus + + + + + SourceProtocolInfostring + SinkProtocolInfostring + CurrentConnectionIDsstring + A_ARG_TYPE_ConnectionStatusstringOKContentFormatMismatchInsufficientBandwidthUnreliableChannelUnknown + A_ARG_TYPE_ConnectionManagerstring + A_ARG_TYPE_DirectionstringInputOutput + A_ARG_TYPE_ProtocolInfostring + A_ARG_TYPE_ConnectionIDi4 + A_ARG_TYPE_AVTransportIDi4 + A_ARG_TYPE_RcsIDi4 + + + """ + + hc.reply(zs.encode("utf-8", "replace")) + return False + + def tx_contentdir_scpd(self, hc: "HttpCli") -> bool: + zs = """ + + + 10 + + + Browse + + ObjectIDinA_ARG_TYPE_ObjectID + BrowseFlaginA_ARG_TYPE_BrowseFlag + FilterinA_ARG_TYPE_Filter + StartingIndexinA_ARG_TYPE_Index + RequestedCountinA_ARG_TYPE_Count + SortCriteriainA_ARG_TYPE_SortCriteria + ResultoutA_ARG_TYPE_Result + NumberReturnedoutA_ARG_TYPE_Count + TotalMatchesoutA_ARG_TYPE_Count + UpdateIDoutA_ARG_TYPE_UpdateID + + + + GetSystemUpdateID + + IdoutSystemUpdateID + + + + GetSortCapabilities + + SortCapsoutSortCapabilities + + + + GetSearchCapabilities + + SearchCapsoutSearchCapabilities + + + + + SystemUpdateIDui4 + SortCapabilitiesstring + SearchCapabilitiesstring + A_ARG_TYPE_ObjectIDstring + A_ARG_TYPE_BrowseFlagstringBrowseMetadataBrowseDirectChildren + A_ARG_TYPE_Filterstring + A_ARG_TYPE_Indexui4 + A_ARG_TYPE_Countui4 + A_ARG_TYPE_SortCriteriastring + A_ARG_TYPE_Resultstring + A_ARG_TYPE_UpdateIDui4 + + +""" + + hc.reply(zs.encode("utf-8", "replace")) + return False + + # SOAP stuff + + # Helpers for SOAP + def _read_soap_body(self, hc): + '''to SOAP XML from the HTTP request''' + try: + clen = int(hc.headers.get("content-length", 0)) + if clen <= 0 or clen >= 1_000_000: + return None + data = b"" + while len(data) < clen: + chunk = hc.sr.recv(min(clen - len(data), 65536)) + if not chunk: + break + data += chunk + return data + except Exception: + return None + + def _parse_soap_action(self, body_bytes): + '''parses SOAP XML, returns (service_type, action_name, params_dict)''' + try: + root = ET.fromstring(body_bytes) + except ET.ParseError: + return None, None, {} + + # find first element in the body + ns_soap = "http://schemas.xmlsoap.org/soap/envelope/" + body = root.find(f"{{{ns_soap}}}Body") + if body is None: + return None, None, {} + + # find action element in the body + action_elem = None + for child in body: + action_elem = child + break + + if action_elem is None: + return None, None, {} + + # then find service type from action element + service_type = "" + tag = action_elem.tag + if "{" in tag: + service_type = tag.split("}")[0].lstrip("{") + + # action name is the local part + action_name = tag.split("}")[-1] if "}" in tag else tag + + # get params + params = {} + for param in action_elem: + local_name = param.tag.split("}")[-1] if "}" in param.tag else param.tag + params[local_name] = param.text or "" + + return service_type, action_name, params + + def _soap_response(self, service_type, action_name, body_xml): + """Wrap body_xml in a SOAP envelope response.""" + return ( + '\n' + '\n' + " \n" + " \n" + " {2}\n" + " \n" + " \n" + "" + ).format(action_name, service_type, body_xml) + + def _soap_fault(self, code, desc): + """Generate a SOAP fault response.""" + return ( + '\n' + '\n' + " \n" + " \n" + " s:Client\n" + " UPnPError\n" + " \n" + ' \n' + " {}\n" + " {}\n" + " \n" + " \n" + " \n" + " \n" + "" + ).format(code, desc) + + def _reply_soap(self, hc, xml_str, status=200): + """Send a SOAP XML response.""" + body = xml_str.encode("utf-8", "replace") + hc.reply(body, status, "text/xml; charset=utf-8", {"EXT": ""}) + return False + + def tx_soap_fault(self, hc, code=401, desc="Invalid Action"): + return self._reply_soap(hc, self._soap_fault(code, desc), 500) + + + # ConnectionManager control + def tx_connmgr_ctl(self, hc: "HttpCli") -> bool: + svc = "urn:schemas-upnp-org:service:ConnectionManager:1" + + soap_body = self._read_soap_body(hc) + if not soap_body: + return self.tx_soap_fault(hc, 401, "Invalid Action") + + _, action, params = self._parse_soap_action(soap_body) + + if action == "GetProtocolInfo": + sources = [] + for ext, info in DLNA_PROTOCOL_INFO.items(): + sources.append(info) + sources.append("http-get:*:application/octet-stream:*") + resp_body = "{}\n ".format(",".join(sources)) + resp = self._soap_response(svc, action, resp_body) + return self._reply_soap(hc, resp) + + if action == "GetCurrentConnectionIDs": + resp_body = "" + resp = self._soap_response(svc, action, resp_body) + return self._reply_soap(hc, resp) + + if action == "GetCurrentConnectionInfo": + resp_body = ( + "0\n" + " 0\n" + " \n" + " \n" + " -1\n" + " Output\n" + " Unknown" + ) + resp = self._soap_response(svc, action, resp_body) + return self._reply_soap(hc, resp) + + return self.tx_soap_fault(hc, 401, "Invalid Action") + + # ContentDirectory control + def tx_contentdir_ctl(self, hc: "HttpCli") -> bool: + svc = "urn:schemas-upnp-org:service:ContentDirectory:1" + + soap_body = self._read_soap_body(hc) + if not soap_body: + return self.tx_soap_fault(hc, 401, "Invalid Action") + + _, action, params = self._parse_soap_action(soap_body) + + if action == "GetSystemUpdateID": + resp_body = "0" + resp = self._soap_response(svc, action, resp_body) + return self._reply_soap(hc, resp) + + if action == "GetSortCapabilities": + resp_body = "" + resp = self._soap_response(svc, action, resp_body) + return self._reply_soap(hc, resp) + + if action == "GetSearchCapabilities": + resp_body = "" + resp = self._soap_response(svc, action, resp_body) + return self._reply_soap(hc, resp) + + if action == "Browse": + return self._handle_browse(hc, svc, params) + + return self.tx_soap_fault(hc, 401, "Invalid Action") + + def _handle_browse(self, hc, svc, params): + oid = params.get("ObjectID", "0") + start = int(params.get("StartingIndex", "0")) + count = int(params.get("RequestedCount", "0")) + + vp = _oid_decode(oid) + base = self._base_url(hc) + + try: + vfs, rem = self.broker.asrv.vfs.get(vp, LEELOO_DALLAS, True, False) + except Exception: + return self._reply_soap(hc, self._soap_fault(701, "No such object"), 500) + + dirs, files = self._collect(vfs, rem, vp) + + all_items = [] + for nm, coid in dirs: + all_items.append(("dir", nm, coid, "", 0, 0)) + for nm, coid, ext, sz, mt in files: + all_items.append(("file", nm, coid, ext, sz, mt)) + + n_total = len(all_items) + end = n_total if count == 0 else min(start + count, n_total) + page = all_items[start:end] + + xml = self._build_didl(page, oid, vp, base) + + resp = self._soap_response(svc, "Browse", + "{}\n {}\n" + " {}\n 0" + .format(_esc_xml(xml), len(page), n_total)) + return self._reply_soap(hc, resp) + + def _base_url(self, hc): + '''constructs http://ip:port from the socket info''' + sip, sport = hc.s.getsockname()[:2] + sip = sip.replace("::ffff:", "") + proto = "https" if self.args.https_only else "http" + return "{}://{}:{}".format(proto, sip, sport) + + def _collect(self, vfs, rem, vp): + '''walks vfs, returns (dirs, files) in lists''' + dirs = [] + files = [] + + try: + _, entries, vnodes = vfs._ls(rem, LEELOO_DALLAS, True, [[True]]) + except Exception: + entries, vnodes = [], {} + + if not vp: + for nm, vn in sorted(self.broker.asrv.vfs.nodes.items()): + # root level: lists volumes from vfs.nodes + dirs.append((nm, _oid_encode(nm))) + + for nm in sorted(vnodes): + c_vp = "{}/{}".format(vp, nm) if vp else nm + dirs.append((nm, _oid_encode(c_vp))) + + for fname, st in entries: + if fname.startswith("."): + continue + c_vp = "{}/{}".format(vp, fname) if vp else fname + c_oid = _oid_encode(c_vp) + if stat.S_ISDIR(st.st_mode): + dirs.append((fname, c_oid)) + else: + ext = os.path.splitext(fname)[1] + files.append((fname, c_oid, ext, st.st_size, int(st.st_mtime))) + + return dirs, files + + def _build_didl(self, page, parent_oid, vp, base): + '''build didl-lite xml for actual browsing''' + ns = 'xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"' + ns += ' xmlns:dc="http://purl.org/dc/elements/1.1/"' + ns += ' xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"' + ns += ' xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"' + + parts = ['\n\n'.format(ns)] + + for kind, nm, coid, ext, sz, mt in page: + esc = _esc_xml(nm) + if kind == "dir": + parts.append( + ' \n' + " {}\n" + " {}\n" + " \n".format(coid, parent_oid, esc, CONTAINER_MIME) + ) + else: + fpath = "{}/{}".format(vp, nm) if vp else nm + url = "{}/{}".format(base, "/".join(_url_quote(s) for s in fpath.split("/"))) + proto = _mime_for_ext(ext) + cls = _dlna_class_for_ext(ext) + date = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(mt)) if mt else "" + + parts.append( + ' \n' + " {}\n" + " {}\n".format(coid, parent_oid, esc, cls) + ) + if date: + parts.append(" {}\n".format(date)) + parts.append( + ' {}\n' + " \n".format(proto, sz, _esc_xml(url)) + ) + + parts.append("") + return "".join(parts) + + + class DLNAd(MCast): - """communicates with dlna clients over multicast""" + """communicates with dlna clients over multicast, same as SSDP""" def __init__(self, hub: "SvcHub", ngen: int) -> None: al = hub.args - vinit = al.zsv and not al.zmv + vinit = al.zdv and not al.zmv super(DLNAd, self).__init__( - hub, DLNA_Sck, al.zs_on, al.zs_off, GRP, "", 1900, vinit + hub, DLNA_Sck, getattr(al, "zs_on", []), getattr(al, "zs_off", []), GRP, "", 1900, vinit ) self.srv: dict[socket.socket, DLNA_Sck] = {} self.logsrc = "DLNA-{}".format(ngen) @@ -209,7 +704,7 @@ class DLNAd(MCast): if not self.ptn_st.search(buf): return - if self.args.zsv: + if self.args.zdv: t = "{} [{}] \033[36m{} \033[0m|{}|" self.log(t.format(srv.name, srv.ip, cip, len(buf)), "90")