PyDLNA 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pydlna/__init__.py +25 -0
- pydlna/_version.py +24 -0
- pydlna/client.py +290 -0
- pydlna/discovery.py +195 -0
- pydlna/errors.py +15 -0
- pydlna/file.py +101 -0
- pydlna/formatters.py +325 -0
- pydlna/handlers.py +174 -0
- pydlna/server.py +168 -0
- pydlna/services.py +126 -0
- pydlna-0.1.4.dist-info/METADATA +92 -0
- pydlna-0.1.4.dist-info/RECORD +15 -0
- pydlna-0.1.4.dist-info/WHEEL +5 -0
- pydlna-0.1.4.dist-info/licenses/LICENSE.md +21 -0
- pydlna-0.1.4.dist-info/top_level.txt +1 -0
pydlna/formatters.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""XML builders and parsers for every UPnP/DLNA document PyDLNA speaks.
|
|
2
|
+
|
|
3
|
+
Everything on the wire (SSDP aside) is XML: the device description, the
|
|
4
|
+
service descriptions (SCPD), the SOAP envelopes used for actions, and the
|
|
5
|
+
DIDL-Lite metadata describing media. Keeping all of it in one place makes
|
|
6
|
+
the wire format easy to audit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Iterable, Mapping, Optional
|
|
12
|
+
from xml.etree import ElementTree as ET
|
|
13
|
+
from xml.sax.saxutils import escape
|
|
14
|
+
|
|
15
|
+
from pydlna.errors import UPnPError
|
|
16
|
+
from pydlna.file import MediaFile, MediaItem
|
|
17
|
+
|
|
18
|
+
SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/"
|
|
19
|
+
|
|
20
|
+
_DIDL_OPEN = (
|
|
21
|
+
'<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" '
|
|
22
|
+
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
|
|
23
|
+
'xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" '
|
|
24
|
+
'xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# SCPD tables: (action, [(argument, direction, related state variable), ...])
|
|
28
|
+
_CONTENT_DIRECTORY_ACTIONS = [
|
|
29
|
+
("GetSearchCapabilities", [("SearchCaps", "out", "SearchCapabilities")]),
|
|
30
|
+
("GetSortCapabilities", [("SortCaps", "out", "SortCapabilities")]),
|
|
31
|
+
("GetSystemUpdateID", [("Id", "out", "SystemUpdateID")]),
|
|
32
|
+
("Browse", [
|
|
33
|
+
("ObjectID", "in", "A_ARG_TYPE_ObjectID"),
|
|
34
|
+
("BrowseFlag", "in", "A_ARG_TYPE_BrowseFlag"),
|
|
35
|
+
("Filter", "in", "A_ARG_TYPE_Filter"),
|
|
36
|
+
("StartingIndex", "in", "A_ARG_TYPE_Index"),
|
|
37
|
+
("RequestedCount", "in", "A_ARG_TYPE_Count"),
|
|
38
|
+
("SortCriteria", "in", "A_ARG_TYPE_SortCriteria"),
|
|
39
|
+
("Result", "out", "A_ARG_TYPE_Result"),
|
|
40
|
+
("NumberReturned", "out", "A_ARG_TYPE_Count"),
|
|
41
|
+
("TotalMatches", "out", "A_ARG_TYPE_Count"),
|
|
42
|
+
("UpdateID", "out", "A_ARG_TYPE_UpdateID"),
|
|
43
|
+
]),
|
|
44
|
+
("Search", [
|
|
45
|
+
("ContainerID", "in", "A_ARG_TYPE_ObjectID"),
|
|
46
|
+
("SearchCriteria", "in", "A_ARG_TYPE_SearchCriteria"),
|
|
47
|
+
("Filter", "in", "A_ARG_TYPE_Filter"),
|
|
48
|
+
("StartingIndex", "in", "A_ARG_TYPE_Index"),
|
|
49
|
+
("RequestedCount", "in", "A_ARG_TYPE_Count"),
|
|
50
|
+
("SortCriteria", "in", "A_ARG_TYPE_SortCriteria"),
|
|
51
|
+
("Result", "out", "A_ARG_TYPE_Result"),
|
|
52
|
+
("NumberReturned", "out", "A_ARG_TYPE_Count"),
|
|
53
|
+
("TotalMatches", "out", "A_ARG_TYPE_Count"),
|
|
54
|
+
("UpdateID", "out", "A_ARG_TYPE_UpdateID"),
|
|
55
|
+
]),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# SCPD state variables: (name, data type, sends events, allowed values)
|
|
59
|
+
_CONTENT_DIRECTORY_VARIABLES = [
|
|
60
|
+
("A_ARG_TYPE_ObjectID", "string", False, None),
|
|
61
|
+
("A_ARG_TYPE_Result", "string", False, None),
|
|
62
|
+
("A_ARG_TYPE_SearchCriteria", "string", False, None),
|
|
63
|
+
("A_ARG_TYPE_BrowseFlag", "string", False, ["BrowseMetadata", "BrowseDirectChildren"]),
|
|
64
|
+
("A_ARG_TYPE_Filter", "string", False, None),
|
|
65
|
+
("A_ARG_TYPE_SortCriteria", "string", False, None),
|
|
66
|
+
("A_ARG_TYPE_Index", "ui4", False, None),
|
|
67
|
+
("A_ARG_TYPE_Count", "ui4", False, None),
|
|
68
|
+
("A_ARG_TYPE_UpdateID", "ui4", False, None),
|
|
69
|
+
("SearchCapabilities", "string", False, None),
|
|
70
|
+
("SortCapabilities", "string", False, None),
|
|
71
|
+
("SystemUpdateID", "ui4", True, None),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
_CONNECTION_MANAGER_ACTIONS = [
|
|
75
|
+
("GetProtocolInfo", [
|
|
76
|
+
("Source", "out", "SourceProtocolInfo"),
|
|
77
|
+
("Sink", "out", "SinkProtocolInfo"),
|
|
78
|
+
]),
|
|
79
|
+
("GetCurrentConnectionIDs", [("ConnectionIDs", "out", "CurrentConnectionIDs")]),
|
|
80
|
+
("GetCurrentConnectionInfo", [
|
|
81
|
+
("ConnectionID", "in", "A_ARG_TYPE_ConnectionID"),
|
|
82
|
+
("RcsID", "out", "A_ARG_TYPE_RcsID"),
|
|
83
|
+
("AVTransportID", "out", "A_ARG_TYPE_AVTransportID"),
|
|
84
|
+
("ProtocolInfo", "out", "A_ARG_TYPE_ProtocolInfo"),
|
|
85
|
+
("PeerConnectionManager", "out", "A_ARG_TYPE_ConnectionManager"),
|
|
86
|
+
("PeerConnectionID", "out", "A_ARG_TYPE_ConnectionID"),
|
|
87
|
+
("Direction", "out", "A_ARG_TYPE_Direction"),
|
|
88
|
+
("Status", "out", "A_ARG_TYPE_ConnectionStatus"),
|
|
89
|
+
]),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
_CONNECTION_MANAGER_VARIABLES = [
|
|
93
|
+
("SourceProtocolInfo", "string", True, None),
|
|
94
|
+
("SinkProtocolInfo", "string", True, None),
|
|
95
|
+
("CurrentConnectionIDs", "string", True, None),
|
|
96
|
+
("A_ARG_TYPE_ConnectionStatus", "string", False,
|
|
97
|
+
["OK", "ContentFormatMismatch", "InsufficientBandwidth", "UnreliableChannel"]),
|
|
98
|
+
("A_ARG_TYPE_ConnectionManager", "string", False, None),
|
|
99
|
+
("A_ARG_TYPE_Direction", "string", False, ["Input", "Output"]),
|
|
100
|
+
("A_ARG_TYPE_ProtocolInfo", "string", False, None),
|
|
101
|
+
("A_ARG_TYPE_ConnectionID", "i4", False, None),
|
|
102
|
+
("A_ARG_TYPE_AVTransportID", "i4", False, None),
|
|
103
|
+
("A_ARG_TYPE_RcsID", "i4", False, None),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class XMLFormatter:
|
|
108
|
+
"""Builds and parses every XML document PyDLNA speaks."""
|
|
109
|
+
|
|
110
|
+
# -- Device & service descriptions -----------------------------------
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def device_description(udn: str, friendly_name: str) -> str:
|
|
114
|
+
services = "".join(
|
|
115
|
+
"<service>"
|
|
116
|
+
f"<serviceType>urn:schemas-upnp-org:service:{name}:1</serviceType>"
|
|
117
|
+
f"<serviceId>urn:upnp-org:serviceId:{name}</serviceId>"
|
|
118
|
+
f"<SCPDURL>/scpd/{name}.xml</SCPDURL>"
|
|
119
|
+
f"<controlURL>/control/{name}</controlURL>"
|
|
120
|
+
f"<eventSubURL>/events/{name}</eventSubURL>"
|
|
121
|
+
"</service>"
|
|
122
|
+
for name in ("ContentDirectory", "ConnectionManager")
|
|
123
|
+
)
|
|
124
|
+
return (
|
|
125
|
+
'<?xml version="1.0" encoding="utf-8"?>'
|
|
126
|
+
'<root xmlns="urn:schemas-upnp-org:device-1-0">'
|
|
127
|
+
"<specVersion><major>1</major><minor>0</minor></specVersion>"
|
|
128
|
+
"<device>"
|
|
129
|
+
"<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>"
|
|
130
|
+
f"<friendlyName>{escape(friendly_name)}</friendlyName>"
|
|
131
|
+
"<manufacturer>PyDLNA</manufacturer>"
|
|
132
|
+
"<modelName>PyDLNA Media Server</modelName>"
|
|
133
|
+
"<modelNumber>0.1</modelNumber>"
|
|
134
|
+
f"<UDN>{escape(udn)}</UDN>"
|
|
135
|
+
'<dlna:X_DLNADOC xmlns:dlna="urn:schemas-dlna-org:device-1-0">DMS-1.50</dlna:X_DLNADOC>'
|
|
136
|
+
f"<serviceList>{services}</serviceList>"
|
|
137
|
+
"</device></root>"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def scpd_content_directory() -> str:
|
|
142
|
+
return XMLFormatter._scpd(_CONTENT_DIRECTORY_ACTIONS, _CONTENT_DIRECTORY_VARIABLES)
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def scpd_connection_manager() -> str:
|
|
146
|
+
return XMLFormatter._scpd(_CONNECTION_MANAGER_ACTIONS, _CONNECTION_MANAGER_VARIABLES)
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _scpd(actions: list, variables: list) -> str:
|
|
150
|
+
action_xml = "".join(
|
|
151
|
+
"<action>"
|
|
152
|
+
f"<name>{name}</name>"
|
|
153
|
+
"<argumentList>"
|
|
154
|
+
+ "".join(
|
|
155
|
+
f"<argument><name>{arg}</name><direction>{direction}</direction>"
|
|
156
|
+
f"<relatedStateVariable>{related}</relatedStateVariable></argument>"
|
|
157
|
+
for arg, direction, related in arguments
|
|
158
|
+
)
|
|
159
|
+
+ "</argumentList></action>"
|
|
160
|
+
for name, arguments in actions
|
|
161
|
+
)
|
|
162
|
+
variable_xml = "".join(
|
|
163
|
+
f'<stateVariable sendEvents="{"yes" if events else "no"}">'
|
|
164
|
+
f"<name>{name}</name><dataType>{data_type}</dataType>"
|
|
165
|
+
+ (
|
|
166
|
+
"<allowedValueList>"
|
|
167
|
+
+ "".join(f"<allowedValue>{value}</allowedValue>" for value in allowed)
|
|
168
|
+
+ "</allowedValueList>"
|
|
169
|
+
if allowed
|
|
170
|
+
else ""
|
|
171
|
+
)
|
|
172
|
+
+ "</stateVariable>"
|
|
173
|
+
for name, data_type, events, allowed in variables
|
|
174
|
+
)
|
|
175
|
+
return (
|
|
176
|
+
'<?xml version="1.0" encoding="utf-8"?>'
|
|
177
|
+
'<scpd xmlns="urn:schemas-upnp-org:service-1-0">'
|
|
178
|
+
"<specVersion><major>1</major><minor>0</minor></specVersion>"
|
|
179
|
+
f"<actionList>{action_xml}</actionList>"
|
|
180
|
+
f"<serviceStateTable>{variable_xml}</serviceStateTable>"
|
|
181
|
+
"</scpd>"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# -- SOAP envelopes ----------------------------------------------------
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def soap_envelope(action: str, service_type: str, args: Mapping[str, object]) -> str:
|
|
188
|
+
args_xml = "".join(
|
|
189
|
+
f"<{key}>{escape(str(value))}</{key}>" for key, value in args.items()
|
|
190
|
+
)
|
|
191
|
+
return (
|
|
192
|
+
'<?xml version="1.0" encoding="utf-8"?>'
|
|
193
|
+
f'<s:Envelope xmlns:s="{SOAP_NS}" '
|
|
194
|
+
's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
|
|
195
|
+
f'<s:Body><u:{action} xmlns:u="{service_type}">{args_xml}</u:{action}></s:Body>'
|
|
196
|
+
"</s:Envelope>"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def soap_response(action: str, service_type: str, fields: Mapping[str, str]) -> str:
|
|
201
|
+
return XMLFormatter.soap_envelope(f"{action}Response", service_type, fields)
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def soap_fault(code: int, description: str) -> str:
|
|
205
|
+
return (
|
|
206
|
+
'<?xml version="1.0" encoding="utf-8"?>'
|
|
207
|
+
f'<s:Envelope xmlns:s="{SOAP_NS}" '
|
|
208
|
+
's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
|
|
209
|
+
"<s:Body><s:Fault>"
|
|
210
|
+
"<faultcode>s:Client</faultcode><faultstring>UPnPError</faultstring>"
|
|
211
|
+
'<detail><UPnPError xmlns="urn:schemas-upnp-org:control-1-0">'
|
|
212
|
+
f"<errorCode>{code}</errorCode>"
|
|
213
|
+
f"<errorDescription>{escape(description)}</errorDescription>"
|
|
214
|
+
"</UPnPError></detail>"
|
|
215
|
+
"</s:Fault></s:Body></s:Envelope>"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def parse_soap_action(body: bytes) -> tuple[str, dict[str, str]]:
|
|
220
|
+
"""Extract ``(action, arguments)`` from an incoming SOAP request."""
|
|
221
|
+
element = _body_child(ET.fromstring(body))
|
|
222
|
+
if element is None:
|
|
223
|
+
raise ValueError("SOAP body is empty")
|
|
224
|
+
return _local_name(element.tag), {
|
|
225
|
+
_local_name(child.tag): (child.text or "") for child in element
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def parse_soap_response(body: bytes, action: str) -> dict[str, str]:
|
|
230
|
+
"""Parse a SOAP response body into a dict; raise on SOAP faults."""
|
|
231
|
+
element = _body_child(ET.fromstring(body))
|
|
232
|
+
if element is None:
|
|
233
|
+
raise ValueError("SOAP body is empty")
|
|
234
|
+
if _local_name(element.tag) == "Fault":
|
|
235
|
+
code, description = XMLFormatter.parse_soap_fault(body)
|
|
236
|
+
raise UPnPError(code, description)
|
|
237
|
+
return {_local_name(child.tag): (child.text or "") for child in element}
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def parse_soap_fault(body: bytes) -> tuple[int, str]:
|
|
241
|
+
code, description = 0, "Unknown UPnP error"
|
|
242
|
+
for element in ET.fromstring(body).iter():
|
|
243
|
+
name = _local_name(element.tag)
|
|
244
|
+
if name == "errorCode":
|
|
245
|
+
try:
|
|
246
|
+
code = int(element.text or 0)
|
|
247
|
+
except ValueError:
|
|
248
|
+
pass
|
|
249
|
+
elif name == "errorDescription":
|
|
250
|
+
description = element.text or description
|
|
251
|
+
return code, description
|
|
252
|
+
|
|
253
|
+
# -- DIDL-Lite -----------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def didl_root_container(title: str, child_count: int) -> str:
|
|
257
|
+
return (
|
|
258
|
+
_DIDL_OPEN
|
|
259
|
+
+ f'<container id="0" parentID="-1" childCount="{child_count}" '
|
|
260
|
+
'restricted="1" searchable="1">'
|
|
261
|
+
f"<dc:title>{escape(title)}</dc:title>"
|
|
262
|
+
"<upnp:class>object.container.storageFolder</upnp:class>"
|
|
263
|
+
"</container></DIDL-Lite>"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@staticmethod
|
|
267
|
+
def didl_items(files: Iterable[MediaFile], base_url: str) -> str:
|
|
268
|
+
items = []
|
|
269
|
+
for media in files:
|
|
270
|
+
url = f"{base_url}/media/{media.id}"
|
|
271
|
+
items.append(
|
|
272
|
+
f'<item id="{escape(media.id)}" parentID="0" restricted="1">'
|
|
273
|
+
f"<dc:title>{escape(media.title or '')}</dc:title>"
|
|
274
|
+
f"<upnp:class>{media.upnp_class}</upnp:class>"
|
|
275
|
+
f'<res protocolInfo="{escape(media.protocol_info)}" '
|
|
276
|
+
f'size="{media.size}">{escape(url)}</res>'
|
|
277
|
+
"</item>"
|
|
278
|
+
)
|
|
279
|
+
return _DIDL_OPEN + "".join(items) + "</DIDL-Lite>"
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def parse_didl_lite(xml_text: str) -> list[MediaItem]:
|
|
283
|
+
"""Parse a DIDL-Lite document (e.g. a Browse result) into media items."""
|
|
284
|
+
if not xml_text.strip():
|
|
285
|
+
return []
|
|
286
|
+
items = []
|
|
287
|
+
for element in ET.fromstring(xml_text):
|
|
288
|
+
kind = _local_name(element.tag)
|
|
289
|
+
if kind not in ("item", "container"):
|
|
290
|
+
continue
|
|
291
|
+
entry = MediaItem(
|
|
292
|
+
id=element.get("id", ""),
|
|
293
|
+
parent_id=element.get("parentID", "0"),
|
|
294
|
+
is_container=(kind == "container"),
|
|
295
|
+
)
|
|
296
|
+
for child in element:
|
|
297
|
+
name = _local_name(child.tag)
|
|
298
|
+
if name == "title":
|
|
299
|
+
entry.title = child.text or ""
|
|
300
|
+
elif name == "class":
|
|
301
|
+
entry.upnp_class = child.text or ""
|
|
302
|
+
elif name == "res":
|
|
303
|
+
entry.url = (child.text or "").strip()
|
|
304
|
+
parts = child.get("protocolInfo", "").split(":")
|
|
305
|
+
if len(parts) >= 3:
|
|
306
|
+
entry.mime_type = parts[2]
|
|
307
|
+
try:
|
|
308
|
+
entry.size = int(child.get("size") or 0)
|
|
309
|
+
except ValueError:
|
|
310
|
+
pass
|
|
311
|
+
items.append(entry)
|
|
312
|
+
return items
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _local_name(tag: str) -> str:
|
|
316
|
+
"""Strip the ``{namespace}`` prefix from an ElementTree tag."""
|
|
317
|
+
return tag.rsplit("}", 1)[-1]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _body_child(envelope: ET.Element) -> Optional[ET.Element]:
|
|
321
|
+
"""The first element inside the SOAP ``Body`` of an envelope."""
|
|
322
|
+
for child in envelope:
|
|
323
|
+
if _local_name(child.tag) == "Body":
|
|
324
|
+
return next(iter(child), None)
|
|
325
|
+
return None
|
pydlna/handlers.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""HTTP request handlers for the PyDLNA media server.
|
|
2
|
+
|
|
3
|
+
One handler class routes everything a DLNA device can send:
|
|
4
|
+
|
|
5
|
+
* ``GET``/``HEAD`` — device description, SCPD documents, and media bytes
|
|
6
|
+
with byte-range support (renderers rely on ranges for seeking),
|
|
7
|
+
* ``POST`` — SOAP control actions, dispatched to the UPnP services,
|
|
8
|
+
* ``SUBSCRIBE``/``UNSUBSCRIBE`` — GENA eventing stubs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import uuid
|
|
15
|
+
from http.server import BaseHTTPRequestHandler
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
from xml.etree.ElementTree import ParseError
|
|
18
|
+
|
|
19
|
+
from pydlna.errors import UPnPError
|
|
20
|
+
from pydlna.formatters import XMLFormatter
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_CHUNK = 64 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DLNARequestHandler(BaseHTTPRequestHandler):
|
|
28
|
+
"""Routes HTTP requests to the services and media of a ``DLNAServer``."""
|
|
29
|
+
|
|
30
|
+
server_version = "PyDLNA/0.1"
|
|
31
|
+
protocol_version = "HTTP/1.1"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def app(self):
|
|
35
|
+
"""The ``DLNAServer`` instance behind this request."""
|
|
36
|
+
return self.server.app # type: ignore[attr-defined]
|
|
37
|
+
|
|
38
|
+
def log_message(self, fmt: str, *args: object) -> None:
|
|
39
|
+
logger.debug("%s - %s", self.address_string(), fmt % args)
|
|
40
|
+
|
|
41
|
+
# -- GET / HEAD ------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def do_GET(self) -> None:
|
|
44
|
+
self._route_get(send_body=True)
|
|
45
|
+
|
|
46
|
+
def do_HEAD(self) -> None:
|
|
47
|
+
self._route_get(send_body=False)
|
|
48
|
+
|
|
49
|
+
def _route_get(self, send_body: bool) -> None:
|
|
50
|
+
path = urlsplit(self.path).path
|
|
51
|
+
if path in ("/", "/device.xml"):
|
|
52
|
+
self._send_xml(self.app.device_description(), send_body=send_body)
|
|
53
|
+
elif path == "/scpd/ContentDirectory.xml":
|
|
54
|
+
self._send_xml(XMLFormatter.scpd_content_directory(), send_body=send_body)
|
|
55
|
+
elif path == "/scpd/ConnectionManager.xml":
|
|
56
|
+
self._send_xml(XMLFormatter.scpd_connection_manager(), send_body=send_body)
|
|
57
|
+
elif path.startswith("/media/"):
|
|
58
|
+
self._send_media(path.rsplit("/", 1)[-1], send_body)
|
|
59
|
+
else:
|
|
60
|
+
self.send_error(404, "Not Found")
|
|
61
|
+
|
|
62
|
+
# -- POST (SOAP control) ----------------------------------------------
|
|
63
|
+
|
|
64
|
+
_CONTROL_SERVICES = {
|
|
65
|
+
"/control/ContentDirectory": "content_directory",
|
|
66
|
+
"/control/ConnectionManager": "connection_manager",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def do_POST(self) -> None:
|
|
70
|
+
attribute = self._CONTROL_SERVICES.get(urlsplit(self.path).path)
|
|
71
|
+
if attribute is None:
|
|
72
|
+
self.send_error(404, "Not Found")
|
|
73
|
+
return
|
|
74
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
75
|
+
payload = self.rfile.read(length)
|
|
76
|
+
try:
|
|
77
|
+
action, args = XMLFormatter.parse_soap_action(payload)
|
|
78
|
+
except (ParseError, ValueError):
|
|
79
|
+
self.send_error(400, "Malformed SOAP request")
|
|
80
|
+
return
|
|
81
|
+
service = getattr(self.app, attribute)
|
|
82
|
+
try:
|
|
83
|
+
result = service.handle(action, args, base_url=self._request_base())
|
|
84
|
+
except UPnPError as exc:
|
|
85
|
+
self._send_xml(XMLFormatter.soap_fault(exc.code, exc.description), status=500)
|
|
86
|
+
return
|
|
87
|
+
self._send_xml(XMLFormatter.soap_response(action, service.service_type, result))
|
|
88
|
+
|
|
89
|
+
# -- SUBSCRIBE / UNSUBSCRIBE (GENA) -------------------------------------
|
|
90
|
+
# Eventing is stubbed: subscriptions are accepted so renderers stay
|
|
91
|
+
# happy, but no NOTIFY messages are sent (the library is static).
|
|
92
|
+
|
|
93
|
+
def do_SUBSCRIBE(self) -> None:
|
|
94
|
+
if "CALLBACK" not in self.headers and "SID" not in self.headers:
|
|
95
|
+
self.send_error(412, "Precondition Failed")
|
|
96
|
+
return
|
|
97
|
+
self.send_response(200)
|
|
98
|
+
self.send_header("SID", f"uuid:{uuid.uuid4()}")
|
|
99
|
+
self.send_header("TIMEOUT", "Second-1800")
|
|
100
|
+
self.send_header("Content-Length", "0")
|
|
101
|
+
self.end_headers()
|
|
102
|
+
|
|
103
|
+
def do_UNSUBSCRIBE(self) -> None:
|
|
104
|
+
self.send_response(200)
|
|
105
|
+
self.send_header("Content-Length", "0")
|
|
106
|
+
self.end_headers()
|
|
107
|
+
|
|
108
|
+
# -- Internals -----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def _request_base(self) -> str:
|
|
111
|
+
host = self.headers.get("Host")
|
|
112
|
+
return f"http://{host}" if host else self.app.base_url
|
|
113
|
+
|
|
114
|
+
def _send_xml(self, text: str, status: int = 200, send_body: bool = True) -> None:
|
|
115
|
+
payload = text.encode("utf-8")
|
|
116
|
+
self.send_response(status)
|
|
117
|
+
self.send_header("Content-Type", 'text/xml; charset="utf-8"')
|
|
118
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
119
|
+
self.end_headers()
|
|
120
|
+
if send_body:
|
|
121
|
+
self.wfile.write(payload)
|
|
122
|
+
|
|
123
|
+
def _send_media(self, item_id: str, send_body: bool) -> None:
|
|
124
|
+
media = self.app.library.get(item_id)
|
|
125
|
+
if media is None:
|
|
126
|
+
self.send_error(404, "Not Found")
|
|
127
|
+
return
|
|
128
|
+
try:
|
|
129
|
+
size = media.path.stat().st_size
|
|
130
|
+
except OSError:
|
|
131
|
+
self.send_error(404, "Not Found")
|
|
132
|
+
return
|
|
133
|
+
start, end, status = self._resolve_range(size)
|
|
134
|
+
length = max(0, end - start + 1)
|
|
135
|
+
self.send_response(status)
|
|
136
|
+
self.send_header("Content-Type", media.mime_type or "application/octet-stream")
|
|
137
|
+
self.send_header("Content-Length", str(length))
|
|
138
|
+
self.send_header("Accept-Ranges", "bytes")
|
|
139
|
+
self.send_header("contentFeatures.dlna.org", "DLNA.ORG_OP=01;DLNA.ORG_CI=0")
|
|
140
|
+
self.send_header("transferMode.dlna.org", "Streaming")
|
|
141
|
+
if status == 206:
|
|
142
|
+
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
|
143
|
+
self.end_headers()
|
|
144
|
+
if not send_body:
|
|
145
|
+
return
|
|
146
|
+
try:
|
|
147
|
+
with media.path.open("rb") as stream:
|
|
148
|
+
stream.seek(start)
|
|
149
|
+
remaining = length
|
|
150
|
+
while remaining > 0:
|
|
151
|
+
chunk = stream.read(min(_CHUNK, remaining))
|
|
152
|
+
if not chunk:
|
|
153
|
+
break
|
|
154
|
+
self.wfile.write(chunk)
|
|
155
|
+
remaining -= len(chunk)
|
|
156
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
157
|
+
pass # The renderer wandered off mid-stream; nothing to fix.
|
|
158
|
+
|
|
159
|
+
def _resolve_range(self, size: int) -> tuple[int, int, int]:
|
|
160
|
+
"""Translate a ``Range`` header into ``(start, end, http_status)``."""
|
|
161
|
+
header = self.headers.get("Range", "")
|
|
162
|
+
if header.startswith("bytes="):
|
|
163
|
+
first, _, last = header[6:].split(",", 1)[0].partition("-")
|
|
164
|
+
try:
|
|
165
|
+
if first:
|
|
166
|
+
start = int(first)
|
|
167
|
+
end = min(int(last), size - 1) if last else size - 1
|
|
168
|
+
else: # Suffix range: the last N bytes.
|
|
169
|
+
start, end = max(0, size - int(last)), size - 1
|
|
170
|
+
if 0 <= start <= end:
|
|
171
|
+
return start, end, 206
|
|
172
|
+
except ValueError:
|
|
173
|
+
pass
|
|
174
|
+
return 0, size - 1, 200
|
pydlna/server.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""The PyDLNA media server: HTTP, UPnP services and SSDP glued together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import mimetypes
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
import uuid
|
|
10
|
+
from http.server import ThreadingHTTPServer
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator, Optional, Union
|
|
13
|
+
|
|
14
|
+
from pydlna.discovery import SSDPResponder
|
|
15
|
+
from pydlna.file import MediaFile
|
|
16
|
+
from pydlna.formatters import XMLFormatter
|
|
17
|
+
from pydlna.handlers import DLNARequestHandler
|
|
18
|
+
from pydlna.services import ConnectionManagerService, ContentDirectoryService
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_MEDIA_TOP_TYPES = ("audio", "video", "image")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MediaLibrary:
|
|
26
|
+
"""A flat collection of media files, each with a stable DLNA object id."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, server: Optional["DLNAServer"] = None) -> None:
|
|
29
|
+
self.server = server
|
|
30
|
+
self._items: dict[str, MediaFile] = {}
|
|
31
|
+
|
|
32
|
+
def add(self, path: Union[str, Path, MediaFile]) -> MediaFile:
|
|
33
|
+
"""Add a single file and return it with its new object id."""
|
|
34
|
+
media = path if isinstance(path, MediaFile) else MediaFile(Path(path))
|
|
35
|
+
media.id = f"item-{len(self._items)}"
|
|
36
|
+
media.library = self
|
|
37
|
+
self._items[media.id] = media
|
|
38
|
+
return media
|
|
39
|
+
|
|
40
|
+
def add_directory(self, root: Union[str, Path], recursive: bool = True) -> int:
|
|
41
|
+
"""Add every audio/video/image file under a directory; returns the count."""
|
|
42
|
+
root = Path(root).expanduser()
|
|
43
|
+
pattern = "**/*" if recursive else "*"
|
|
44
|
+
added = 0
|
|
45
|
+
for path in sorted(root.glob(pattern)):
|
|
46
|
+
if not path.is_file():
|
|
47
|
+
continue
|
|
48
|
+
mime_type = mimetypes.guess_type(path.name)[0] or ""
|
|
49
|
+
if mime_type.split("/", 1)[0] in _MEDIA_TOP_TYPES:
|
|
50
|
+
self.add(path)
|
|
51
|
+
added += 1
|
|
52
|
+
return added
|
|
53
|
+
|
|
54
|
+
def get(self, item_id: str) -> Optional[MediaFile]:
|
|
55
|
+
return self._items.get(item_id)
|
|
56
|
+
|
|
57
|
+
def __getitem__(self, index: int) -> MediaFile:
|
|
58
|
+
return self.files[index]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def files(self) -> list[MediaFile]:
|
|
62
|
+
return list(self._items.values())
|
|
63
|
+
|
|
64
|
+
def __len__(self) -> int:
|
|
65
|
+
return len(self._items)
|
|
66
|
+
|
|
67
|
+
def __iter__(self) -> Iterator[MediaFile]:
|
|
68
|
+
return iter(self._items.values())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DLNAServer:
|
|
72
|
+
"""A DLNA Digital Media Server.
|
|
73
|
+
|
|
74
|
+
Serves device/service descriptions and media bytes over HTTP, answers
|
|
75
|
+
SOAP actions on its UPnP services, and advertises itself over SSDP.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, name: str = "PyDLNA Media Server", host: str = "0.0.0.0",
|
|
79
|
+
port: int = 8200, udn: Optional[str] = None) -> None:
|
|
80
|
+
self.name = name
|
|
81
|
+
self.host = host
|
|
82
|
+
self.port = port
|
|
83
|
+
self.udn = udn or f"uuid:{uuid.uuid4()}"
|
|
84
|
+
self.library = MediaLibrary(server=self)
|
|
85
|
+
self.content_directory = ContentDirectoryService(self.library, root_title=name)
|
|
86
|
+
self.connection_manager = ConnectionManagerService(self.library)
|
|
87
|
+
self._httpd: Optional[ThreadingHTTPServer] = None
|
|
88
|
+
self._ssdp: Optional[SSDPResponder] = None
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> "DLNAServer":
|
|
91
|
+
"""Start the server; usable as ``with DLNAServer(...) as server:``."""
|
|
92
|
+
self.start()
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def __exit__(self, *exc: object) -> None:
|
|
96
|
+
self.stop()
|
|
97
|
+
|
|
98
|
+
# -- Properties -----------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def local_ip(self) -> str:
|
|
102
|
+
"""Best-effort guess of the LAN IP to advertise."""
|
|
103
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
104
|
+
try:
|
|
105
|
+
sock.connect(("239.255.255.250", 1900)) # UDP: no traffic is sent
|
|
106
|
+
return sock.getsockname()[0]
|
|
107
|
+
except OSError:
|
|
108
|
+
return "127.0.0.1"
|
|
109
|
+
finally:
|
|
110
|
+
sock.close()
|
|
111
|
+
|
|
112
|
+
def local_ip_for(self, target: str) -> str:
|
|
113
|
+
"""The local IP that traffic to ``target`` would leave from."""
|
|
114
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
115
|
+
try:
|
|
116
|
+
sock.connect((target, 80)) # UDP: no traffic is sent
|
|
117
|
+
return sock.getsockname()[0]
|
|
118
|
+
except OSError:
|
|
119
|
+
return self.local_ip
|
|
120
|
+
finally:
|
|
121
|
+
sock.close()
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def base_url(self) -> str:
|
|
125
|
+
return f"http://{self.local_ip}:{self.port}"
|
|
126
|
+
|
|
127
|
+
# -- Descriptions ------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def device_description(self) -> str:
|
|
130
|
+
return XMLFormatter.device_description(self.udn, self.name)
|
|
131
|
+
|
|
132
|
+
# -- Lifecycle ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def start(self, advertise: bool = True) -> None:
|
|
135
|
+
"""Start the HTTP server (and SSDP advertisements) in the background."""
|
|
136
|
+
if self._httpd is not None:
|
|
137
|
+
return
|
|
138
|
+
self._httpd = ThreadingHTTPServer((self.host, self.port), DLNARequestHandler)
|
|
139
|
+
self._httpd.daemon_threads = True
|
|
140
|
+
self._httpd.app = self # type: ignore[attr-defined]
|
|
141
|
+
self.port = self._httpd.server_address[1] # resolves port=0
|
|
142
|
+
threading.Thread(
|
|
143
|
+
target=self._httpd.serve_forever, name="pydlna-http", daemon=True
|
|
144
|
+
).start()
|
|
145
|
+
if advertise:
|
|
146
|
+
self._ssdp = SSDPResponder(udn=self.udn, location=f"{self.base_url}/device.xml")
|
|
147
|
+
self._ssdp.start()
|
|
148
|
+
logger.info("Serving '%s' at %s", self.name, self.base_url)
|
|
149
|
+
|
|
150
|
+
def serve_forever(self) -> None:
|
|
151
|
+
"""Start (if needed) and block until ``stop()`` or Ctrl+C."""
|
|
152
|
+
self.start()
|
|
153
|
+
try:
|
|
154
|
+
threading.Event().wait()
|
|
155
|
+
except KeyboardInterrupt:
|
|
156
|
+
pass
|
|
157
|
+
finally:
|
|
158
|
+
self.stop()
|
|
159
|
+
|
|
160
|
+
def stop(self) -> None:
|
|
161
|
+
if self._ssdp is not None:
|
|
162
|
+
self._ssdp.stop()
|
|
163
|
+
self._ssdp.join(timeout=2)
|
|
164
|
+
self._ssdp = None
|
|
165
|
+
if self._httpd is not None:
|
|
166
|
+
self._httpd.shutdown()
|
|
167
|
+
self._httpd.server_close()
|
|
168
|
+
self._httpd = None
|