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