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/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