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