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/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""PyDLNA — a tiny DLNA/UPnP media server and control point library."""
|
|
2
|
+
|
|
3
|
+
from pydlna.client import DLNAClient, DLNAController, PlaybackPosition, UPnPServiceInfo
|
|
4
|
+
from pydlna.discovery import DLNADiscovery
|
|
5
|
+
from pydlna.errors import UPnPError
|
|
6
|
+
from pydlna.file import MediaFile, MediaItem
|
|
7
|
+
from pydlna.server import DLNAServer, MediaLibrary
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from pydlna._version import __version__
|
|
11
|
+
except ImportError:
|
|
12
|
+
__version__ = "0.0.0.dev0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DLNAClient",
|
|
16
|
+
"DLNAController",
|
|
17
|
+
"DLNADiscovery",
|
|
18
|
+
"DLNAServer",
|
|
19
|
+
"MediaFile",
|
|
20
|
+
"MediaItem",
|
|
21
|
+
"MediaLibrary",
|
|
22
|
+
"PlaybackPosition",
|
|
23
|
+
"UPnPError",
|
|
24
|
+
"UPnPServiceInfo",
|
|
25
|
+
]
|
pydlna/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 4)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
pydlna/client.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""DLNA clients (discovered devices) and the control point that drives them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Mapping, Optional, Union
|
|
10
|
+
from urllib.parse import urljoin
|
|
11
|
+
from xml.etree import ElementTree as ET
|
|
12
|
+
|
|
13
|
+
from pydlna.errors import UPnPError
|
|
14
|
+
from pydlna.file import MediaFile, MediaItem
|
|
15
|
+
from pydlna.formatters import XMLFormatter
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
_DEVICE_NS = {"upnp": "urn:schemas-upnp-org:device-1-0"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class UPnPServiceInfo:
|
|
24
|
+
"""One service entry from a device's description document."""
|
|
25
|
+
|
|
26
|
+
service_type: str
|
|
27
|
+
service_id: str = ""
|
|
28
|
+
control_url: str = ""
|
|
29
|
+
scpd_url: str = ""
|
|
30
|
+
event_url: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class PlaybackPosition:
|
|
35
|
+
"""A snapshot of what a renderer is playing right now."""
|
|
36
|
+
|
|
37
|
+
position: str = ""
|
|
38
|
+
duration: str = ""
|
|
39
|
+
title: str = ""
|
|
40
|
+
uri: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class DLNAClient:
|
|
45
|
+
"""A DLNA/UPnP device discovered on the network."""
|
|
46
|
+
|
|
47
|
+
name: str = ""
|
|
48
|
+
address: Optional[str] = None
|
|
49
|
+
location: Optional[str] = None
|
|
50
|
+
udn: str = ""
|
|
51
|
+
server: str = ""
|
|
52
|
+
st: str = ""
|
|
53
|
+
usn: str = ""
|
|
54
|
+
device_type: str = ""
|
|
55
|
+
manufacturer: str = ""
|
|
56
|
+
manufacturer_url: str = ""
|
|
57
|
+
model_name: str = ""
|
|
58
|
+
model_number: str = ""
|
|
59
|
+
model_description: str = ""
|
|
60
|
+
serial_number: str = ""
|
|
61
|
+
icons: list[str] = field(default_factory=list)
|
|
62
|
+
services: dict[str, str] = field(default_factory=dict)
|
|
63
|
+
service_details: list[UPnPServiceInfo] = field(default_factory=list)
|
|
64
|
+
_protocol_cache: Optional[dict] = field(default=None, repr=False)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_ssdp(cls, address: str, headers: Mapping[str, str]) -> "DLNAClient":
|
|
68
|
+
"""Build a client from the sender address and headers of an SSDP message."""
|
|
69
|
+
return cls(
|
|
70
|
+
address=address,
|
|
71
|
+
location=headers.get("location"),
|
|
72
|
+
server=headers.get("server", ""),
|
|
73
|
+
st=headers.get("st", ""),
|
|
74
|
+
usn=headers.get("usn", ""),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_renderer(self) -> bool:
|
|
79
|
+
return "MediaRenderer" in (self.device_type or self.st)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_server(self) -> bool:
|
|
83
|
+
return "MediaServer" in (self.device_type or self.st)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def supported_protocols(self) -> list[str]:
|
|
87
|
+
"""Raw ``protocolInfo`` entries the renderer accepts (its Sink list)."""
|
|
88
|
+
sink = self._protocols().get("Sink", "")
|
|
89
|
+
return [entry for entry in sink.split(",") if entry.strip()]
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def playback_formats(self) -> list[str]:
|
|
93
|
+
"""MIME types the renderer can play, parsed from its Sink list."""
|
|
94
|
+
return [p.split(":")[2] for p in self.supported_protocols if p.count(":") >= 2]
|
|
95
|
+
|
|
96
|
+
def _protocols(self) -> dict[str, str]:
|
|
97
|
+
"""Cached ConnectionManager ``GetProtocolInfo`` call (best effort)."""
|
|
98
|
+
if self._protocol_cache is None:
|
|
99
|
+
try:
|
|
100
|
+
self._protocol_cache = self.controller().get_protocol_info()
|
|
101
|
+
except (UPnPError, OSError):
|
|
102
|
+
self._protocol_cache = {}
|
|
103
|
+
return self._protocol_cache
|
|
104
|
+
|
|
105
|
+
def fetch_description(self, timeout: float = 5.0) -> bool:
|
|
106
|
+
"""Fetch and parse the device description document (best effort)."""
|
|
107
|
+
if not self.location:
|
|
108
|
+
return False
|
|
109
|
+
try:
|
|
110
|
+
with urllib.request.urlopen(self.location, timeout=timeout) as response:
|
|
111
|
+
root = ET.fromstring(response.read())
|
|
112
|
+
except (OSError, ET.ParseError) as exc:
|
|
113
|
+
logger.debug("Could not fetch %s: %s", self.location, exc)
|
|
114
|
+
return False
|
|
115
|
+
device = root.find("upnp:device", _DEVICE_NS)
|
|
116
|
+
if device is None:
|
|
117
|
+
return False
|
|
118
|
+
self.name = device.findtext("upnp:friendlyName", "", _DEVICE_NS)
|
|
119
|
+
self.udn = device.findtext("upnp:UDN", "", _DEVICE_NS)
|
|
120
|
+
self.device_type = device.findtext("upnp:deviceType", "", _DEVICE_NS)
|
|
121
|
+
self.manufacturer = device.findtext("upnp:manufacturer", "", _DEVICE_NS)
|
|
122
|
+
self.manufacturer_url = device.findtext("upnp:manufacturerURL", "", _DEVICE_NS)
|
|
123
|
+
self.model_name = device.findtext("upnp:modelName", "", _DEVICE_NS)
|
|
124
|
+
self.model_number = device.findtext("upnp:modelNumber", "", _DEVICE_NS)
|
|
125
|
+
self.model_description = device.findtext("upnp:modelDescription", "", _DEVICE_NS)
|
|
126
|
+
self.serial_number = device.findtext("upnp:serialNumber", "", _DEVICE_NS)
|
|
127
|
+
self.icons.clear()
|
|
128
|
+
self.services.clear()
|
|
129
|
+
self.service_details.clear()
|
|
130
|
+
for icon in device.findall("upnp:iconList/upnp:icon", _DEVICE_NS):
|
|
131
|
+
url = icon.findtext("upnp:url", "", _DEVICE_NS)
|
|
132
|
+
if url:
|
|
133
|
+
self.icons.append(urljoin(self.location, url))
|
|
134
|
+
for service in device.findall("upnp:serviceList/upnp:service", _DEVICE_NS):
|
|
135
|
+
detail = UPnPServiceInfo(
|
|
136
|
+
service_type=service.findtext("upnp:serviceType", "", _DEVICE_NS),
|
|
137
|
+
service_id=service.findtext("upnp:serviceId", "", _DEVICE_NS),
|
|
138
|
+
control_url=self._absolute(service.findtext("upnp:controlURL", "", _DEVICE_NS)),
|
|
139
|
+
scpd_url=self._absolute(service.findtext("upnp:SCPDURL", "", _DEVICE_NS)),
|
|
140
|
+
event_url=self._absolute(service.findtext("upnp:eventSubURL", "", _DEVICE_NS)),
|
|
141
|
+
)
|
|
142
|
+
self.service_details.append(detail)
|
|
143
|
+
if detail.service_type and detail.control_url:
|
|
144
|
+
self.services[detail.service_type] = detail.control_url
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
def _absolute(self, url: str) -> str:
|
|
148
|
+
"""Resolve a possibly-relative URL against the description location."""
|
|
149
|
+
return urljoin(self.location, url) if url else ""
|
|
150
|
+
|
|
151
|
+
def controller(self) -> "DLNAController":
|
|
152
|
+
return DLNAController(self)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class DLNAController:
|
|
156
|
+
"""A UPnP control point: calls SOAP actions on a ``DLNAClient``."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, client: Optional[DLNAClient] = None) -> None:
|
|
159
|
+
self.client = client or DLNAClient()
|
|
160
|
+
|
|
161
|
+
def __enter__(self) -> "DLNAController":
|
|
162
|
+
"""The controller doubles as a ``with`` session for readability."""
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def __exit__(self, *exc: object) -> None:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
# -- Generic action call -----------------------------------------------
|
|
169
|
+
|
|
170
|
+
def _request(self, service: str, action: str, **args: object) -> dict[str, str]:
|
|
171
|
+
service_type = f"urn:schemas-upnp-org:service:{service}:1"
|
|
172
|
+
url = self.client.services.get(service_type)
|
|
173
|
+
if url is None and self.client.fetch_description():
|
|
174
|
+
url = self.client.services.get(service_type)
|
|
175
|
+
if url is None:
|
|
176
|
+
raise UPnPError(401, f"Device does not expose {service}")
|
|
177
|
+
envelope = XMLFormatter.soap_envelope(action, service_type, args)
|
|
178
|
+
request = urllib.request.Request(
|
|
179
|
+
url,
|
|
180
|
+
data=envelope.encode("utf-8"),
|
|
181
|
+
headers={
|
|
182
|
+
"Content-Type": 'text/xml; charset="utf-8"',
|
|
183
|
+
"SOAPAction": f'"{service_type}#{action}"',
|
|
184
|
+
"User-Agent": "PyDLNA/0.1 UPnP/1.1",
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
try:
|
|
188
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
189
|
+
return XMLFormatter.parse_soap_response(response.read(), action)
|
|
190
|
+
except urllib.error.HTTPError as exc:
|
|
191
|
+
# UPnP devices answer failures with HTTP 500 + a SOAP fault body.
|
|
192
|
+
return XMLFormatter.parse_soap_response(exc.read(), action)
|
|
193
|
+
|
|
194
|
+
# -- ContentDirectory ----------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def browse(self, object_id: str = "0", browse_flag: str = "BrowseDirectChildren",
|
|
197
|
+
filter: str = "*", start: int = 0, count: int = 0,
|
|
198
|
+
sort: str = "") -> dict[str, str]:
|
|
199
|
+
"""Browse a media server; returns the raw response fields."""
|
|
200
|
+
return self._request(
|
|
201
|
+
"ContentDirectory", "Browse",
|
|
202
|
+
ObjectID=object_id, BrowseFlag=browse_flag, Filter=filter,
|
|
203
|
+
StartingIndex=str(start), RequestedCount=str(count), SortCriteria=sort,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def browse_items(self, object_id: str = "0", **kwargs: object) -> list[MediaItem]:
|
|
207
|
+
"""Browse a media server and parse the DIDL-Lite result."""
|
|
208
|
+
response = self.browse(object_id, **kwargs)
|
|
209
|
+
return XMLFormatter.parse_didl_lite(response.get("Result", ""))
|
|
210
|
+
|
|
211
|
+
# -- AVTransport (renderers) ----------------------------------------------
|
|
212
|
+
|
|
213
|
+
def set_av_transport_uri(self, uri: str, metadata: str = "") -> dict[str, str]:
|
|
214
|
+
return self._request("AVTransport", "SetAVTransportURI",
|
|
215
|
+
InstanceID="0", CurrentURI=uri, CurrentURIMetaData=metadata)
|
|
216
|
+
|
|
217
|
+
def play(self, media: Optional[Union[MediaFile, str]] = None,
|
|
218
|
+
speed: str = "1") -> dict[str, str]:
|
|
219
|
+
"""Start playback, optionally loading a ``MediaFile`` or URL first."""
|
|
220
|
+
if isinstance(media, MediaFile):
|
|
221
|
+
url = media.url_for(self.client.address)
|
|
222
|
+
if not url:
|
|
223
|
+
raise ValueError("MediaFile is not attached to a server library")
|
|
224
|
+
base_url = url.rsplit("/media/", 1)[0]
|
|
225
|
+
self.set_av_transport_uri(url, XMLFormatter.didl_items([media], base_url))
|
|
226
|
+
elif isinstance(media, str):
|
|
227
|
+
self.set_av_transport_uri(media)
|
|
228
|
+
return self._request("AVTransport", "Play", InstanceID="0", Speed=speed)
|
|
229
|
+
|
|
230
|
+
def is_playing(self) -> bool:
|
|
231
|
+
"""True while the renderer reports ``PLAYING`` (best effort)."""
|
|
232
|
+
try:
|
|
233
|
+
return self.get_transport_info().get("CurrentTransportState") == "PLAYING"
|
|
234
|
+
except (UPnPError, OSError):
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def currently_playing(self) -> PlaybackPosition:
|
|
239
|
+
"""Position, duration and title of the current track (best effort)."""
|
|
240
|
+
try:
|
|
241
|
+
info = self.get_position_info()
|
|
242
|
+
except (UPnPError, OSError):
|
|
243
|
+
return PlaybackPosition()
|
|
244
|
+
title = ""
|
|
245
|
+
metadata = info.get("TrackMetaData", "")
|
|
246
|
+
if metadata:
|
|
247
|
+
tracks = XMLFormatter.parse_didl_lite(metadata)
|
|
248
|
+
if tracks:
|
|
249
|
+
title = tracks[0].title
|
|
250
|
+
return PlaybackPosition(
|
|
251
|
+
position=info.get("RelTime", ""),
|
|
252
|
+
duration=info.get("TrackDuration", ""),
|
|
253
|
+
title=title,
|
|
254
|
+
uri=info.get("TrackURI", ""),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def pause(self) -> dict[str, str]:
|
|
258
|
+
return self._request("AVTransport", "Pause", InstanceID="0")
|
|
259
|
+
|
|
260
|
+
def stop(self) -> dict[str, str]:
|
|
261
|
+
return self._request("AVTransport", "Stop", InstanceID="0")
|
|
262
|
+
|
|
263
|
+
# -- RenderingControl --------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def get_volume(self, channel: str = "Master") -> int:
|
|
266
|
+
response = self._request("RenderingControl", "GetVolume",
|
|
267
|
+
InstanceID="0", Channel=channel)
|
|
268
|
+
return int(response.get("CurrentVolume") or 0)
|
|
269
|
+
|
|
270
|
+
def set_volume(self, volume: int, channel: str = "Master") -> dict[str, str]:
|
|
271
|
+
return self._request("RenderingControl", "SetVolume",
|
|
272
|
+
InstanceID="0", Channel=channel, DesiredVolume=str(volume))
|
|
273
|
+
|
|
274
|
+
# -- Status queries ---------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def get_transport_info(self) -> dict[str, str]:
|
|
277
|
+
"""Playback state: CurrentTransportState, CurrentTransportStatus, Speed."""
|
|
278
|
+
return self._request("AVTransport", "GetTransportInfo", InstanceID="0")
|
|
279
|
+
|
|
280
|
+
def get_media_info(self) -> dict[str, str]:
|
|
281
|
+
"""The renderer's currently loaded media (CurrentURI, duration, ...)."""
|
|
282
|
+
return self._request("AVTransport", "GetMediaInfo", InstanceID="0")
|
|
283
|
+
|
|
284
|
+
def get_position_info(self) -> dict[str, str]:
|
|
285
|
+
"""Position within the current track (RelTime, TrackDuration, ...)."""
|
|
286
|
+
return self._request("AVTransport", "GetPositionInfo", InstanceID="0")
|
|
287
|
+
|
|
288
|
+
def get_protocol_info(self) -> dict[str, str]:
|
|
289
|
+
"""Source/Sink protocol lists; a renderer's Sink is its format support."""
|
|
290
|
+
return self._request("ConnectionManager", "GetProtocolInfo")
|
pydlna/discovery.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""SSDP discovery: find DLNA devices on the LAN, and advertise our server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from pydlna.client import DLNAClient
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
SSDP_ADDR = "239.255.255.250"
|
|
15
|
+
SSDP_PORT = 1900
|
|
16
|
+
|
|
17
|
+
MEDIA_SERVER = "urn:schemas-upnp-org:device:MediaServer:1"
|
|
18
|
+
MEDIA_RENDERER = "urn:schemas-upnp-org:device:MediaRenderer:1"
|
|
19
|
+
|
|
20
|
+
_SERVER_HEADER = "PyDLNA/0.1 UPnP/1.0 DLNADOC/1.50"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DeviceList(list):
|
|
24
|
+
"""The result of a discovery run; usable as a context manager.
|
|
25
|
+
|
|
26
|
+
``with DLNADiscovery().discover() as devices:`` works purely for
|
|
27
|
+
symmetry with the server/controller — there is nothing to close.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __enter__(self) -> "DeviceList":
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def __exit__(self, *exc: object) -> None:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DLNADiscovery:
|
|
38
|
+
"""Finds DLNA/UPnP devices on the local network via SSDP M-SEARCH."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, st: str = "ssdp:all") -> None:
|
|
41
|
+
self.st = st
|
|
42
|
+
self.clients: list[DLNAClient] = []
|
|
43
|
+
|
|
44
|
+
def discover(self, timeout: float = 3.0, resolve_names: bool = True) -> list[DLNAClient]:
|
|
45
|
+
"""Send an M-SEARCH multicast and collect the devices that answer."""
|
|
46
|
+
request = _msearch(self.st, mx=max(1, min(int(timeout), 5)))
|
|
47
|
+
found: dict[str, DLNAClient] = {}
|
|
48
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
49
|
+
try:
|
|
50
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
|
51
|
+
sock.settimeout(0.2)
|
|
52
|
+
sock.sendto(request.encode("ascii"), (SSDP_ADDR, SSDP_PORT))
|
|
53
|
+
deadline = time.monotonic() + timeout
|
|
54
|
+
while time.monotonic() < deadline:
|
|
55
|
+
try:
|
|
56
|
+
data, address = sock.recvfrom(4096)
|
|
57
|
+
except socket.timeout:
|
|
58
|
+
continue
|
|
59
|
+
headers = _parse_headers(data.decode("latin-1"))
|
|
60
|
+
usn = headers.get("usn") or headers.get("location", "")
|
|
61
|
+
# A device announces once per ST as "udn::nt"; dedupe on the UDN.
|
|
62
|
+
key = usn.split("::")[0]
|
|
63
|
+
if not key or key in found:
|
|
64
|
+
continue
|
|
65
|
+
found[key] = DLNAClient.from_ssdp(address[0], headers)
|
|
66
|
+
finally:
|
|
67
|
+
sock.close()
|
|
68
|
+
self.clients = DeviceList(found.values())
|
|
69
|
+
if resolve_names:
|
|
70
|
+
for client in self.clients:
|
|
71
|
+
client.fetch_description(timeout=1.5) # best effort
|
|
72
|
+
return self.clients
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SSDPResponder(threading.Thread):
|
|
76
|
+
"""Answers M-SEARCH requests and multicasts alive/byebye notifications."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, udn: str, location: str, interval: float = 60.0) -> None:
|
|
79
|
+
super().__init__(name="pydlna-ssdp", daemon=True)
|
|
80
|
+
self.udn = udn
|
|
81
|
+
self.location = location
|
|
82
|
+
self.interval = interval
|
|
83
|
+
self._stop_event = threading.Event()
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def targets(self) -> list[tuple[str, str]]:
|
|
87
|
+
"""Every ``(NT, USN)`` pair this device advertises."""
|
|
88
|
+
content_directory = "urn:schemas-upnp-org:service:ContentDirectory:1"
|
|
89
|
+
connection_manager = "urn:schemas-upnp-org:service:ConnectionManager:1"
|
|
90
|
+
return [
|
|
91
|
+
("upnp:rootdevice", f"{self.udn}::upnp:rootdevice"),
|
|
92
|
+
(self.udn, self.udn),
|
|
93
|
+
(MEDIA_SERVER, f"{self.udn}::{MEDIA_SERVER}"),
|
|
94
|
+
(content_directory, f"{self.udn}::{content_directory}"),
|
|
95
|
+
(connection_manager, f"{self.udn}::{connection_manager}"),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
def stop(self) -> None:
|
|
99
|
+
self._stop_event.set()
|
|
100
|
+
|
|
101
|
+
def run(self) -> None:
|
|
102
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
103
|
+
try:
|
|
104
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
105
|
+
sock.bind(("", SSDP_PORT))
|
|
106
|
+
membership = socket.inet_aton(SSDP_ADDR) + socket.inet_aton("0.0.0.0")
|
|
107
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
|
|
108
|
+
sock.settimeout(1.0)
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
logger.warning("SSDP responder disabled: %s", exc)
|
|
111
|
+
sock.close()
|
|
112
|
+
return
|
|
113
|
+
try:
|
|
114
|
+
self._announce(sock, "ssdp:alive")
|
|
115
|
+
next_announce = time.monotonic() + self.interval
|
|
116
|
+
while not self._stop_event.is_set():
|
|
117
|
+
try:
|
|
118
|
+
data, address = sock.recvfrom(2048)
|
|
119
|
+
except socket.timeout:
|
|
120
|
+
pass
|
|
121
|
+
except OSError:
|
|
122
|
+
break
|
|
123
|
+
else:
|
|
124
|
+
self._maybe_respond(sock, data, address)
|
|
125
|
+
if time.monotonic() >= next_announce:
|
|
126
|
+
self._announce(sock, "ssdp:alive")
|
|
127
|
+
next_announce = time.monotonic() + self.interval
|
|
128
|
+
finally:
|
|
129
|
+
try:
|
|
130
|
+
self._announce(sock, "ssdp:byebye")
|
|
131
|
+
except OSError:
|
|
132
|
+
pass
|
|
133
|
+
sock.close()
|
|
134
|
+
|
|
135
|
+
def _maybe_respond(self, sock: socket.socket, data: bytes, address: tuple) -> None:
|
|
136
|
+
message = data.decode("latin-1", errors="replace")
|
|
137
|
+
if not message.startswith("M-SEARCH"):
|
|
138
|
+
return
|
|
139
|
+
st = _parse_headers(message).get("st", "")
|
|
140
|
+
if st == "ssdp:all":
|
|
141
|
+
matches = self.targets
|
|
142
|
+
else:
|
|
143
|
+
matches = [(nt, usn) for nt, usn in self.targets if nt == st]
|
|
144
|
+
for nt, usn in matches:
|
|
145
|
+
response = (
|
|
146
|
+
"HTTP/1.1 200 OK\r\n"
|
|
147
|
+
"CACHE-CONTROL: max-age=1800\r\n"
|
|
148
|
+
"EXT:\r\n"
|
|
149
|
+
f"LOCATION: {self.location}\r\n"
|
|
150
|
+
f"SERVER: {_SERVER_HEADER}\r\n"
|
|
151
|
+
f"ST: {nt}\r\n"
|
|
152
|
+
f"USN: {usn}\r\n\r\n"
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
sock.sendto(response.encode("ascii"), address)
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
def _announce(self, sock: socket.socket, nts: str) -> None:
|
|
160
|
+
for nt, usn in self.targets:
|
|
161
|
+
message = (
|
|
162
|
+
"NOTIFY * HTTP/1.1\r\n"
|
|
163
|
+
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
|
|
164
|
+
"CACHE-CONTROL: max-age=1800\r\n"
|
|
165
|
+
f"LOCATION: {self.location}\r\n"
|
|
166
|
+
f"NT: {nt}\r\n"
|
|
167
|
+
f"NTS: {nts}\r\n"
|
|
168
|
+
f"SERVER: {_SERVER_HEADER}\r\n"
|
|
169
|
+
f"USN: {usn}\r\n\r\n"
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
sock.sendto(message.encode("ascii"), (SSDP_ADDR, SSDP_PORT))
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _msearch(st: str, mx: int) -> str:
|
|
178
|
+
return (
|
|
179
|
+
"M-SEARCH * HTTP/1.1\r\n"
|
|
180
|
+
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
|
|
181
|
+
'MAN: "ssdp:discover"\r\n'
|
|
182
|
+
f"MX: {mx}\r\n"
|
|
183
|
+
f"ST: {st}\r\n"
|
|
184
|
+
f"USER-AGENT: {_SERVER_HEADER}\r\n\r\n"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _parse_headers(message: str) -> dict[str, str]:
|
|
189
|
+
"""Parse the header block of an SSDP message into a lowercase-keyed dict."""
|
|
190
|
+
headers = {}
|
|
191
|
+
for line in message.split("\r\n")[1:]:
|
|
192
|
+
key, separator, value = line.partition(":")
|
|
193
|
+
if separator:
|
|
194
|
+
headers[key.strip().lower()] = value.strip()
|
|
195
|
+
return headers
|
pydlna/errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Exceptions raised by PyDLNA."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UPnPError(Exception):
|
|
5
|
+
"""A UPnP-level error.
|
|
6
|
+
|
|
7
|
+
Raised by the server to abort a SOAP action (the HTTP layer turns it
|
|
8
|
+
into a SOAP fault) and by the client when a remote device answers an
|
|
9
|
+
action call with a fault.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, code: int, description: str = "") -> None:
|
|
13
|
+
super().__init__(f"UPnP error {code}: {description or 'unknown'}")
|
|
14
|
+
self.code = code
|
|
15
|
+
self.description = description
|
pydlna/file.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Media models shared by the PyDLNA server and client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Optional
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pydlna.server import MediaLibrary
|
|
12
|
+
|
|
13
|
+
# Best-effort DLNA profile names keyed by MIME type. Anything not listed is
|
|
14
|
+
# advertised with a wildcard profile, which most renderers still accept.
|
|
15
|
+
_DLNA_PROFILES = {
|
|
16
|
+
"audio/mpeg": "MP3",
|
|
17
|
+
"audio/mp4": "AAC_ISO",
|
|
18
|
+
"audio/x-m4a": "AAC_ISO",
|
|
19
|
+
"audio/wav": "LPCM",
|
|
20
|
+
"audio/x-wav": "LPCM",
|
|
21
|
+
"image/jpeg": "JPEG_LRG",
|
|
22
|
+
"image/png": "PNG_LRG",
|
|
23
|
+
"video/mp4": "AVC_MP4_MP_HD_720p_AAC",
|
|
24
|
+
"video/mpeg": "MPEG_PS_PAL",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class MediaFile:
|
|
30
|
+
"""A local file exposed by the media server."""
|
|
31
|
+
|
|
32
|
+
path: Path
|
|
33
|
+
title: Optional[str] = None
|
|
34
|
+
mime_type: Optional[str] = None
|
|
35
|
+
id: str = ""
|
|
36
|
+
library: Optional["MediaLibrary"] = field(default=None, repr=False)
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
self.path = Path(self.path)
|
|
40
|
+
if self.title is None:
|
|
41
|
+
self.title = self.path.stem
|
|
42
|
+
if self.mime_type is None:
|
|
43
|
+
self.mime_type = mimetypes.guess_type(self.path.name)[0] or "application/octet-stream"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def size(self) -> int:
|
|
47
|
+
try:
|
|
48
|
+
return self.path.stat().st_size
|
|
49
|
+
except OSError:
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def dlna_profile(self) -> str:
|
|
54
|
+
return _DLNA_PROFILES.get(self.mime_type or "", "*")
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def upnp_class(self) -> str:
|
|
58
|
+
top = (self.mime_type or "").split("/", 1)[0]
|
|
59
|
+
return {
|
|
60
|
+
"audio": "object.item.audioItem.musicTrack",
|
|
61
|
+
"video": "object.item.videoItem",
|
|
62
|
+
"image": "object.item.imageItem.photo",
|
|
63
|
+
}.get(top, "object.item")
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def protocol_info(self) -> str:
|
|
67
|
+
"""The ``protocolInfo`` attribute used in DIDL-Lite ``<res>`` elements."""
|
|
68
|
+
profile = f"DLNA.ORG_PN={self.dlna_profile};" if self.dlna_profile != "*" else ""
|
|
69
|
+
return f"http-get:*:{self.mime_type}:{profile}DLNA.ORG_OP=01;DLNA.ORG_CI=0"
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def url(self) -> str:
|
|
73
|
+
"""Stream URL of this file, once it lives in a server's library."""
|
|
74
|
+
return self.url_for()
|
|
75
|
+
|
|
76
|
+
def url_for(self, target: Optional[str] = None) -> str:
|
|
77
|
+
"""Stream URL whose local IP is chosen to be reachable from ``target``.
|
|
78
|
+
|
|
79
|
+
On multi-homed machines (VPN, Hyper-V/WSL adapters) the default LAN
|
|
80
|
+
IP guess can pick the wrong interface; passing the renderer's address
|
|
81
|
+
picks the local IP that would actually route to it.
|
|
82
|
+
"""
|
|
83
|
+
server = self.library.server if self.library else None
|
|
84
|
+
if server is None or not self.id:
|
|
85
|
+
return ""
|
|
86
|
+
host = server.local_ip_for(target) if target else server.local_ip
|
|
87
|
+
return f"http://{host}:{server.port}/media/{self.id}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class MediaItem:
|
|
92
|
+
"""A media entry parsed from a DIDL-Lite document (client side)."""
|
|
93
|
+
|
|
94
|
+
id: str
|
|
95
|
+
parent_id: str = "0"
|
|
96
|
+
title: str = ""
|
|
97
|
+
upnp_class: str = ""
|
|
98
|
+
is_container: bool = False
|
|
99
|
+
url: str = ""
|
|
100
|
+
mime_type: str = ""
|
|
101
|
+
size: int = 0
|