From a3ce1ecde597d48e3c91b751522f9f21188ce9ae Mon Sep 17 00:00:00 2001 From: Yatharth Sood Date: Sun, 5 Apr 2026 10:16:11 +0530 Subject: [PATCH] dna scaffold --- copyparty/__main__.py | 9 ++ copyparty/dlna.py | 240 ++++++++++++++++++++++++++++++++++++++++++ copyparty/httpcli.py | 15 +++ copyparty/httpsrv.py | 7 ++ copyparty/svchub.py | 21 +++- copyparty/util.py | 2 + 6 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 copyparty/dlna.py diff --git a/copyparty/__main__.py b/copyparty/__main__.py index e0a46e79..d6497c7b 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1452,6 +1452,14 @@ def add_zc_ssdp(ap): ap2.add_argument("--zsl", metavar="PATH", type=u, default="/?hc", help="location to include in the url (or a complete external URL), for example [\033[32mpriv/?pw=hunter2\033[0m] (goes directly to /priv/ with password hunter2) or [\033[32m?hc=priv&pw=hunter2\033[0m] (shows mounting options for /priv/ with password)") ap2.add_argument("--zsid", metavar="UUID", type=u, default=zsid, help="USN (device identifier) to announce") +def add_zc_dlna(ap): + ap2 = ap.add_argument_group("DLNA options") + ap2.add_argument("--zd", action="store_true", help="announce MediaServer:1 over SSDP for DLNA device support") + # ap2.add_argument("--dn-on", metavar="NETS", type=u, default="", help="enable SSDP ONLY on the comma-separated list of subnets and/or interface names/indexes") + # ap2.add_argument("--dn-off", metavar="NETS", type=u, default="", help="disable SSDP on the comma-separated list of subnets and/or interface names/indexes") + ap2.add_argument("--zdv", action="store_true", help="verbose DLNA") + # ap2.add_argument("--dnl", metavar="PATH", type=u, default="/?hc", help="location to include in the url (or a complete external URL), for example [\033[32mpriv/?pw=hunter2\033[0m] (goes directly to /priv/ with password hunter2) or [\033[32m?hc=priv&pw=hunter2\033[0m] (shows mounting options for /priv/ with password)") + # ap2.add_argument("--dnid", metavar="UUID", type=u, default=zsid, help="USN (device identifier) to announce") def add_sftp(ap): ap2 = ap.add_argument_group("SFTP options") @@ -1990,6 +1998,7 @@ def run_argparse( add_zeroconf(ap) add_zc_mdns(ap) add_zc_ssdp(ap) + add_zc_dlna(ap) add_fs(ap) add_share(ap) add_upload(ap) diff --git a/copyparty/dlna.py b/copyparty/dlna.py new file mode 100644 index 00000000..b77d7b75 --- /dev/null +++ b/copyparty/dlna.py @@ -0,0 +1,240 @@ +# coding: utf-8 +from __future__ import print_function, unicode_literals + +import errno +import re +import select +import socket +import time + +from .__init__ import TYPE_CHECKING +from .multicast import MC_Sck, MCast +from .util import CachedSet, formatdate, html_escape, min_ex + +if TYPE_CHECKING: + from .broker_util import BrokerCli + from .httpcli import HttpCli + from .svchub import SvcHub + +if True: # pylint: disable=using-constant-test + from typing import Optional, Union + + +GRP = "239.255.255.250" + + +class DLNA_Sck(MC_Sck): + def __init__(self, *a): + super(DLNA_Sck, self).__init__(*a) + self.hport = 0 + + +class DLNAr(object): + """generates http responses for httpcli""" + + def __init__(self, broker: "BrokerCli") -> None: + self.broker = broker + self.args = broker.args + + def reply(self, hc: "HttpCli") -> bool: + if hc.vpath.endswith("device.xml"): + return self.tx_device(hc) + + hc.reply(b"unknown request", 400) + return False + + def tx_device(self, hc: "HttpCli") -> bool: + 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 + + + +""" + + c = html_escape + sip, sport = hc.s.getsockname()[:2] + sip = sip.replace("::ffff:", "") + proto = "https" if self.args.https_only else "http" + ubase = "{}://{}:{}".format(proto, sip, sport) + 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)) + hc.reply(zs.encode("utf-8", "replace")) + return False # close connection + + +class DLNAd(MCast): + """communicates with dlna clients over multicast""" + + def __init__(self, hub: "SvcHub", ngen: int) -> None: + al = hub.args + vinit = al.zsv and not al.zmv + super(DLNAd, self).__init__( + hub, DLNA_Sck, al.zs_on, al.zs_off, GRP, "", 1900, vinit + ) + self.srv: dict[socket.socket, DLNA_Sck] = {} + self.logsrc = "DLNA-{}".format(ngen) + self.ngen = ngen + + self.rxc = CachedSet(0.7) + self.txc = CachedSet(5) # win10: every 3 sec + self.ptn_st = re.compile(b"\nst: *upnp:rootdevice", re.I) + + def log(self, msg: str, c: Union[int, str] = 0) -> None: + self.log_func(self.logsrc, msg, c) + + def run(self) -> None: + try: + bound = self.create_servers() + except: + t = "no server IP matches the dlna config\n{}" + self.log(t.format(min_ex()), 1) + bound = [] + + if not bound: + self.log("failed to announce copyparty services on the network", 3) + return + + # find http port for this listening ip + for srv in self.srv.values(): + tcps = self.hub.tcpsrv.bound + hp = next((x[1] for x in tcps if x[0] in ("0.0.0.0", srv.ip)), 0) + hp = hp or next((x[1] for x in tcps if x[0] == "::"), 0) + if not hp: + hp = tcps[0][1] + self.log("assuming port {} for {}".format(hp, srv.ip), 3) + srv.hport = hp + + self.log("listening") + try: + self.run2() + except OSError as ex: + if ex.errno != errno.EBADF: + raise + + self.log("stopping due to {}".format(ex), "90") + + self.log("stopped", 2) + + def run2(self) -> None: + try: + if self.args.no_poll: + raise Exception() + fd2sck = {} + srvpoll = select.poll() + for sck in self.srv: + fd = sck.fileno() + fd2sck[fd] = sck + srvpoll.register(fd, select.POLLIN) + except Exception as ex: + srvpoll = None + if not self.args.no_poll: + t = "WARNING: failed to poll(), will use select() instead: %r" + self.log(t % (ex,), 3) + + while self.running: + if srvpoll: + pr = srvpoll.poll((self.args.z_chk or 180) * 1000) + rx = [fd2sck[x[0]] for x in pr if x[1] & select.POLLIN] + else: + rdy = select.select(self.srv, [], [], self.args.z_chk or 180) + rx: list[socket.socket] = rdy[0] # type: ignore + + self.rxc.cln() + buf = b"" + addr = ("0", 0) + for sck in rx: + try: + buf, addr = sck.recvfrom(4096) + self.eat(buf, addr) + except: + if not self.running: + break + + t = "{} {} \033[33m|{}| {}\n{}".format( + self.srv[sck].name, addr, len(buf), repr(buf)[2:-1], min_ex() + ) + self.log(t, 6) + + def stop(self) -> None: + self.running = False + for srv in self.srv.values(): + try: + srv.sck.close() + except: + pass + + self.srv.clear() + + def eat(self, buf: bytes, addr: tuple[str, int]) -> None: + cip = addr[0] + if cip.startswith("169.254") and not self.ll_ok: + return + + if buf in self.rxc.c: + return + + srv: Optional[DLNA_Sck] = self.map_client(cip) # type: ignore + if not srv: + return + + self.rxc.add(buf) + if not buf.startswith(b"M-SEARCH * HTTP/1."): + return + + if not self.ptn_st.search(buf): + return + + if self.args.zsv: + t = "{} [{}] \033[36m{} \033[0m|{}|" + self.log(t.format(srv.name, srv.ip, cip, len(buf)), "90") + + zs = """ +HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: {0} +EXT: +LOCATION: http://{1}:{2}/.cpr/dlna/device.xml +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: {3} +SERVER: UPnP/1.0 +ST: upnp:rootdevice +USN: {3}::upnp:rootdevice +BOOTID.UPNP.ORG: 0 +CONFIGID.UPNP.ORG: 1 + +""" + v4 = srv.ip.replace("::ffff:", "") + zs = zs.format(formatdate(), v4, srv.hport, self.args.zsid) + zb = zs[1:].replace("\n", "\r\n").encode("utf-8", "replace") + srv.sck.sendto(zb, addr[:2]) + + if cip not in self.txc.c: + self.log("{} [{}] --> {}".format(srv.name, srv.ip, cip), 6) + + self.txc.add(cip) + self.txc.cln() diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 8c74664b..b10f9ba3 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -1382,6 +1382,13 @@ class HttpCli(object): self.reply(b"ssdp is disabled in server config", 404) return False + if self.vpath.startswith(".cpr/dlna"): + if self.conn.hsrv.dlna: + return self.conn.hsrv.dlna.reply(self) + else: + self.reply(b"dlna is disabled in server config", 404) + return False + if self.vpath == ".cpr/metrics": return self.conn.hsrv.metrics.tx(self) @@ -2280,6 +2287,14 @@ class HttpCli(object): except: raise Pebkac(400, "client d/c before 100 continue") + # DLNA/SSDP control requests (SOAP) + if self.vpath.startswith(".cpr/dlna/ctl/"): + if self.conn.hsrv.dlna: + return self.conn.hsrv.dlna.reply(self) + else: + self.reply(b"dlna is disabled in server config", 404) + return False + if "raw" in self.uparam: return self.handle_stash(False) diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index f6ec1e56..ae535467 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -91,6 +91,7 @@ if TYPE_CHECKING: from .authsrv import VFS from .broker_util import BrokerCli from .ssdp import SSDPr + from .dlna import DLNAr if True: # pylint: disable=using-constant-test from typing import Any, Optional @@ -129,6 +130,7 @@ class HttpSrv(object): self.magician = Magician() self.nm = NetMap([], []) self.ssdp: Optional["SSDPr"] = None + self.dlna: Optional["DLNAr"] = None self.gpwd = Garda(self.args.ban_pw) self.gpwc = Garda(self.args.ban_pwc) self.g404 = Garda(self.args.ban_404) @@ -226,6 +228,11 @@ class HttpSrv(object): self.ssdp = SSDPr(broker) + if getattr(self.args, "zd", False): + from .dlna import DLNAr + + self.dlna = DLNAr(broker) + if self.tp_q: self.start_threads(4) diff --git a/copyparty/svchub.py b/copyparty/svchub.py index d5a59722..93c52969 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -93,6 +93,8 @@ if TYPE_CHECKING: try: from .mdns import MDNS from .ssdp import SSDPd + from .dlna import DLNAd + from .dlna import DLNAd except: pass @@ -477,6 +479,7 @@ class SvcHub(object): self.zc_ngen = 0 self.mdns: Optional["MDNS"] = None self.ssdp: Optional["SSDPd"] = None + self.dlna: Optional["DLNAd"] = None # decide which worker impl to use if self.check_mp_enable(): @@ -1039,7 +1042,7 @@ class SvcHub(object): have_tcp = True if not have_tcp: zb = False - zs = "z zm zm4 zm6 zmv zmvv zs zsv zv" + zs = "z zm zm4 zm6 zmv zmvv zs zsv zv zd zdv" for zs in zs.split(): if getattr(al, zs, False): setattr(al, zs, False) @@ -1468,6 +1471,18 @@ class SvcHub(object): except: self.log("root", "ssdp startup failed;\n" + min_ex(), 3) + if getattr(self.args, "zd", False): + try: + from .dlna import DLNAd + + if self.dlna: + self.dlna.stop() + + self.dlna = DLNAd(self, self.zc_ngen) + Daemon(self.dlna.run, "dlna") + except: + self.log("root", "dlna startup failed;\n" + min_ex(), 3) + def reload(self, rescan_all_vols: bool, up2k: bool) -> str: t = "users, volumes, and volflags have been reloaded" with self.reload_mutex: @@ -1566,6 +1581,10 @@ class SvcHub(object): tasks.append(Daemon(self.ssdp.stop, "ssdp")) slp = time.time() + 0.5 + if self.dlna: + tasks.append(Daemon(self.dlna.stop, "dlna")) + slp = time.time() + 0.5 + self.broker.shutdown() self.tcpsrv.shutdown() self.up2k.shutdown() diff --git a/copyparty/util.py b/copyparty/util.py index 656a329f..8d510ebd 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -423,8 +423,10 @@ IMPLICATIONS = [ ["smbv", "smb"], ["zv", "zmv"], ["zv", "zsv"], + ["zv", "zdv"], ["z", "zm"], ["z", "zs"], + ["z", "zd"], ["zmvv", "zmv"], ["zm4", "zm"], ["zm6", "zm"],