tiny-dlna 0.5.2__tar.gz → 0.7.0__tar.gz
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.
- {tiny-dlna-0.5.2 → tiny-dlna-0.7.0}/PKG-INFO +13 -1
- {tiny-dlna-0.5.2 → tiny-dlna-0.7.0}/README.md +21 -1
- {tiny-dlna-0.5.2 → tiny-dlna-0.7.0}/setup.py +17 -4
- tiny-dlna-0.7.0/tiny_dlna/__init__.py +0 -0
- tiny-dlna-0.7.0/tiny_dlna/tiny_cli.py +288 -0
- {tiny-dlna-0.5.2 → tiny-dlna-0.7.0/tiny_dlna}/tiny_render.py +63 -105
- tiny-dlna-0.7.0/tiny_dlna/tiny_ssdp.py +94 -0
- tiny-dlna-0.7.0/tiny_dlna/tiny_xmls.py +1020 -0
- {tiny-dlna-0.5.2 → tiny-dlna-0.7.0}/tiny_dlna.egg-info/PKG-INFO +13 -1
- {tiny-dlna-0.5.2 → tiny-dlna-0.7.0}/tiny_dlna.egg-info/SOURCES.txt +5 -3
- tiny-dlna-0.7.0/tiny_dlna.egg-info/entry_points.txt +3 -0
- tiny-dlna-0.7.0/tiny_dlna.egg-info/top_level.txt +1 -0
- tiny-dlna-0.5.2/tiny_cli.py +0 -96
- tiny-dlna-0.5.2/tiny_dlna.egg-info/entry_points.txt +0 -3
- tiny-dlna-0.5.2/tiny_dlna.egg-info/top_level.txt +0 -3
- tiny-dlna-0.5.2/tiny_ssdp.py +0 -63
- {tiny-dlna-0.5.2 → tiny-dlna-0.7.0}/setup.cfg +0 -0
- {tiny-dlna-0.5.2 → tiny-dlna-0.7.0}/tiny_dlna.egg-info/dependency_links.txt +0 -0
- {tiny-dlna-0.5.2 → tiny-dlna-0.7.0}/tiny_dlna.egg-info/requires.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: tiny-dlna
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: a tiny DLNA receiver
|
|
5
5
|
Home-page: https://github.com/mitnk/tiny-dlna
|
|
6
6
|
Author: mitnk
|
|
@@ -31,3 +31,15 @@ from apps like 虎牙直播, Bilibili, and other video platforms. Additionally,
|
|
|
31
31
|
can also use `nano-dlna` to play local videos (like in your RaspberryPi) on it.
|
|
32
32
|
|
|
33
33
|
Note: mpv needs to be installed on your system.
|
|
34
|
+
|
|
35
|
+
::
|
|
36
|
+
|
|
37
|
+
$ tiny-cli list
|
|
38
|
+
|
|
39
|
+
List available DLNA devices.
|
|
40
|
+
|
|
41
|
+
::
|
|
42
|
+
|
|
43
|
+
$ tiny-cli play ~/Movies/foo/bar.mp4 -q 'TV'
|
|
44
|
+
|
|
45
|
+
Play video on the DLNA device having "TV" in its name.
|
|
@@ -19,7 +19,7 @@ from apps like 虎牙直播, Bilibili, and other video platforms. Additionally,
|
|
|
19
19
|
can also use `nano-dlna` to play local videos (like in your RaspberryPi) on it.
|
|
20
20
|
|
|
21
21
|
Note that [mpv](https://mpv.io/) needs to be installed on your system. On Mac,
|
|
22
|
-
do following:
|
|
22
|
+
do following (for Windows, add the mpv's root into PATH):
|
|
23
23
|
|
|
24
24
|
```
|
|
25
25
|
$ ln -sf /Applications/mpv.app/Contents/MacOS/mpv /usr/local/bin/
|
|
@@ -32,6 +32,26 @@ List available DLNA devices:
|
|
|
32
32
|
$ tiny-cli list
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
Play a local video file on a DLNA device having "TV" in its name:
|
|
36
|
+
```
|
|
37
|
+
$ tiny-cli play ~/Movies/foo/bar.mp4 -q TV
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If there is a `bar.srt` in the same directory, it will be served as long as
|
|
41
|
+
the DLNA device supports subtitles.
|
|
42
|
+
|
|
43
|
+
## Dev
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
$ python -m tiny_dlna.tiny_cli -h
|
|
47
|
+
$ python -m tiny_dlna.tiny_render -h
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### More DLNA Actions, like Seek/Pause?
|
|
51
|
+
|
|
52
|
+
This repository will be kept minimal. For additional DLNA actions, consider
|
|
53
|
+
forking it.
|
|
54
|
+
|
|
35
55
|
## Related projects
|
|
36
56
|
|
|
37
57
|
- https://github.com/xfangfang/Macast
|
|
@@ -24,22 +24,35 @@ from apps like 虎牙直播, Bilibili, and other video platforms. Additionally,
|
|
|
24
24
|
can also use `nano-dlna` to play local videos (like in your RaspberryPi) on it.
|
|
25
25
|
|
|
26
26
|
Note: mpv needs to be installed on your system.
|
|
27
|
+
|
|
28
|
+
::
|
|
29
|
+
|
|
30
|
+
$ tiny-cli list
|
|
31
|
+
|
|
32
|
+
List available DLNA devices.
|
|
33
|
+
|
|
34
|
+
::
|
|
35
|
+
|
|
36
|
+
$ tiny-cli play ~/Movies/foo/bar.mp4 -q 'TV'
|
|
37
|
+
|
|
38
|
+
Play video on the DLNA device having "TV" in its name.
|
|
27
39
|
"""
|
|
28
40
|
|
|
29
41
|
setup(
|
|
30
42
|
name='tiny-dlna',
|
|
31
|
-
version='0.
|
|
43
|
+
version='0.7.0',
|
|
32
44
|
description='a tiny DLNA receiver',
|
|
33
45
|
long_description=DESC,
|
|
34
46
|
url='https://github.com/mitnk/tiny-dlna',
|
|
35
47
|
author='mitnk',
|
|
36
48
|
license='MIT',
|
|
37
49
|
keywords='dlna',
|
|
38
|
-
|
|
50
|
+
packages=['tiny_dlna'],
|
|
51
|
+
package_dir={'tiny_dlna': 'tiny_dlna'},
|
|
39
52
|
entry_points={
|
|
40
53
|
'console_scripts': [
|
|
41
|
-
'tiny-render=tiny_render:main',
|
|
42
|
-
'tiny-cli=tiny_cli:main',
|
|
54
|
+
'tiny-render=tiny_dlna.tiny_render:main',
|
|
55
|
+
'tiny-cli=tiny_dlna.tiny_cli:main',
|
|
43
56
|
],
|
|
44
57
|
},
|
|
45
58
|
install_requires=[
|
|
File without changes
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os.path
|
|
5
|
+
import random
|
|
6
|
+
import shutil
|
|
7
|
+
import signal
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request as urlreq
|
|
13
|
+
import xml.etree.ElementTree as ET
|
|
14
|
+
|
|
15
|
+
from flask import Flask, send_from_directory
|
|
16
|
+
from xml.sax.saxutils import escape as xmlescape
|
|
17
|
+
from .tiny_ssdp import SSDP_MULTICAST_IP, SSDP_PORT, get_host_ip
|
|
18
|
+
from .tiny_xmls import * # NOQA
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger('tiny_cli')
|
|
21
|
+
|
|
22
|
+
MSEARCH_MSG = (
|
|
23
|
+
'M-SEARCH * HTTP/1.1\r\n'
|
|
24
|
+
f'HOST: {SSDP_MULTICAST_IP}:{SSDP_PORT}\r\n'
|
|
25
|
+
'MAN: "ssdp:discover"\r\n'
|
|
26
|
+
'MX: 1\r\n'
|
|
27
|
+
'ST: urn:schemas-upnp-org:service:AVTransport:1\r\n'
|
|
28
|
+
'\r\n'
|
|
29
|
+
)
|
|
30
|
+
DIR_LINKS = '~/.config/tiny-dlna/symlinks'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_device_info(location):
|
|
34
|
+
p = urllib.parse.urlparse(location)
|
|
35
|
+
|
|
36
|
+
attrs = {}
|
|
37
|
+
namespace = {'ns': 'urn:schemas-upnp-org:device-1-0'}
|
|
38
|
+
namespaces = {
|
|
39
|
+
'': 'urn:schemas-upnp-org:device-1-0',
|
|
40
|
+
'dlna': 'urn:schemas-dlna-org:device-1-0'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
with urlreq.urlopen(location, timeout=1.0) as r:
|
|
44
|
+
xml = r.read()
|
|
45
|
+
root = ET.fromstring(xml.strip())
|
|
46
|
+
|
|
47
|
+
friendly_name = root.find('.//ns:friendlyName', namespace).text
|
|
48
|
+
if friendly_name:
|
|
49
|
+
attrs['friendly_name'] = friendly_name
|
|
50
|
+
|
|
51
|
+
elem = root.find(
|
|
52
|
+
".//service[serviceId='urn:upnp-org:serviceId:AVTransport']",
|
|
53
|
+
namespaces=namespaces,
|
|
54
|
+
)
|
|
55
|
+
if elem:
|
|
56
|
+
control_url = elem.find('controlURL', namespaces).text
|
|
57
|
+
if control_url:
|
|
58
|
+
attrs['control_url'] = f'http://{p.hostname}:{p.port}/{control_url}'
|
|
59
|
+
|
|
60
|
+
return attrs
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_ssdp_response(resp):
|
|
64
|
+
lines = resp.split('\r\n')
|
|
65
|
+
device = {}
|
|
66
|
+
|
|
67
|
+
if not lines[0].endswith('200 OK'):
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
for line in lines:
|
|
71
|
+
try:
|
|
72
|
+
k, v = [x.strip() for x in line.split(':', 1)]
|
|
73
|
+
except ValueError:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
k = k.lower()
|
|
77
|
+
if k == 'location':
|
|
78
|
+
device['location'] = v
|
|
79
|
+
elif k == 'usn':
|
|
80
|
+
device['usn'] = v
|
|
81
|
+
elif k == 'st':
|
|
82
|
+
device['st'] = v
|
|
83
|
+
|
|
84
|
+
if ':service:AVTransport:' not in device.get('st', ''):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
attrs = _get_device_info(device['location'])
|
|
88
|
+
device.update(attrs)
|
|
89
|
+
return device
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_dlna_devices():
|
|
93
|
+
# Create the UDP socket
|
|
94
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
95
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
96
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
|
97
|
+
sock.settimeout(1.2)
|
|
98
|
+
|
|
99
|
+
# Send the M-SEARCH message to the SSDP multicast address
|
|
100
|
+
logger.debug("Sending M-SEARCH...")
|
|
101
|
+
sock.sendto(MSEARCH_MSG.encode('utf-8'), (SSDP_MULTICAST_IP, SSDP_PORT))
|
|
102
|
+
|
|
103
|
+
devices = []
|
|
104
|
+
known_locations = set()
|
|
105
|
+
while True:
|
|
106
|
+
try:
|
|
107
|
+
data, addr = sock.recvfrom(1024)
|
|
108
|
+
device = _parse_ssdp_response(data.decode('utf-8'))
|
|
109
|
+
location = device.get('location')
|
|
110
|
+
logger.debug(f'got reply from {addr}, Location: {location}')
|
|
111
|
+
if device and location not in known_locations:
|
|
112
|
+
devices.append(device)
|
|
113
|
+
known_locations.add(location)
|
|
114
|
+
except socket.timeout:
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
return devices
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def list_dlna_devices():
|
|
121
|
+
devices = get_dlna_devices()
|
|
122
|
+
print(json.dumps({'devices': devices}, sort_keys=True, indent=2))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def post(url, action_data, headers):
|
|
126
|
+
action_data = action_data.encode("utf-8")
|
|
127
|
+
r = urlreq.Request(url, action_data, headers)
|
|
128
|
+
urlreq.urlopen(r)
|
|
129
|
+
logging.debug("Request sent")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
app = Flask(__name__)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.route('/videos/<name_video>')
|
|
136
|
+
def serve_video(name_video):
|
|
137
|
+
dir_base = os.path.expanduser(DIR_LINKS)
|
|
138
|
+
return send_from_directory(dir_base, name_video)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run_flask_server(port):
|
|
142
|
+
app.run(host='0.0.0.0', port=port)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def send_dlna_command(url_control, action_body, action_name):
|
|
146
|
+
st = "urn:schemas-upnp-org:service:AVTransport:1"
|
|
147
|
+
headers = {
|
|
148
|
+
'Content-Type': 'text/xml; charset="utf-8"',
|
|
149
|
+
'SOAPACTION': f'"{st}#{action_name}"',
|
|
150
|
+
"Connection": "close",
|
|
151
|
+
}
|
|
152
|
+
post(url_control, action_body, headers)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def send_set_av_transport(url_control, url_video, url_srt=None):
|
|
156
|
+
title = url_video.split('/')[-1]
|
|
157
|
+
meta_items = XML_VIDEO.format(title=title)
|
|
158
|
+
if url_srt:
|
|
159
|
+
meta_items += XML_SUBTITLE.format(url_srt=url_srt)
|
|
160
|
+
metadata = XML_META.format(items=meta_items)
|
|
161
|
+
xml = XML_SETAV.format(
|
|
162
|
+
url_video=url_video,
|
|
163
|
+
metadata=xmlescape(metadata),
|
|
164
|
+
)
|
|
165
|
+
send_dlna_command(url_control, xml, 'SetAVTransportURI')
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def send_play(url_control):
|
|
169
|
+
send_dlna_command(url_control, XML_PLAY, 'Play')
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def send_stop(url_control):
|
|
173
|
+
dir_links = os.path.expanduser(DIR_LINKS)
|
|
174
|
+
try:
|
|
175
|
+
shutil.rmtree(dir_links)
|
|
176
|
+
except: # NOQA
|
|
177
|
+
pass
|
|
178
|
+
send_dlna_command(url_control, XML_STOP, 'Stop')
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def create_link(path_file):
|
|
182
|
+
name_file = os.path.basename(path_file)
|
|
183
|
+
dir_links = os.path.expanduser(DIR_LINKS)
|
|
184
|
+
if not os.path.exists(dir_links):
|
|
185
|
+
os.makedirs(dir_links, exist_ok=True)
|
|
186
|
+
|
|
187
|
+
path_link = os.path.join(dir_links, name_file)
|
|
188
|
+
if os.path.exists(path_link):
|
|
189
|
+
if os.path.islink(path_link):
|
|
190
|
+
os.remove(path_link)
|
|
191
|
+
else:
|
|
192
|
+
logger.error('failed to create softlink')
|
|
193
|
+
exit(1)
|
|
194
|
+
|
|
195
|
+
os.symlink(path_file, path_link)
|
|
196
|
+
logger.info(f'created softlink: {path_link}')
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_control_url(args):
|
|
200
|
+
devices = get_dlna_devices()
|
|
201
|
+
for d in devices:
|
|
202
|
+
if args.query.lower() in d['friendly_name'].lower():
|
|
203
|
+
return d.get('control_url')
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def play_video(args):
|
|
207
|
+
path_video = args.video_file
|
|
208
|
+
if not os.path.isfile(path_video):
|
|
209
|
+
print(f'no such file: {path_video}')
|
|
210
|
+
exit(0)
|
|
211
|
+
|
|
212
|
+
url_control = get_control_url(args)
|
|
213
|
+
if not url_control:
|
|
214
|
+
print('no such DLNA device found')
|
|
215
|
+
exit(0)
|
|
216
|
+
|
|
217
|
+
ip = get_host_ip()
|
|
218
|
+
port = random.randint(50000, 58999)
|
|
219
|
+
|
|
220
|
+
logger.info(f'play video file: {path_video}')
|
|
221
|
+
create_link(path_video)
|
|
222
|
+
name_video = os.path.basename(path_video)
|
|
223
|
+
url_video = f"http://{ip}:{port}/videos/{name_video}"
|
|
224
|
+
|
|
225
|
+
path_srt = '.'.join(path_video.split('.')[:-1]) + '.srt'
|
|
226
|
+
if os.path.exists(path_srt):
|
|
227
|
+
create_link(path_srt)
|
|
228
|
+
name_srt = os.path.basename(path_srt)
|
|
229
|
+
url_srt = f"http://{ip}:{port}/videos/{name_srt}"
|
|
230
|
+
else:
|
|
231
|
+
path_srt = None
|
|
232
|
+
url_srt = None
|
|
233
|
+
|
|
234
|
+
server_thread = threading.Thread(target=run_flask_server, args=(port,))
|
|
235
|
+
server_thread.daemon = True
|
|
236
|
+
server_thread.start()
|
|
237
|
+
|
|
238
|
+
# Give some time for the server to start
|
|
239
|
+
time.sleep(1.6)
|
|
240
|
+
|
|
241
|
+
send_set_av_transport(url_control, url_video, url_srt)
|
|
242
|
+
send_play(url_control)
|
|
243
|
+
|
|
244
|
+
def signal_handler(sig, frame):
|
|
245
|
+
send_stop(url_control)
|
|
246
|
+
exit(0)
|
|
247
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
248
|
+
|
|
249
|
+
# Keep main thread running so the server stays up and we can catch signal
|
|
250
|
+
while True:
|
|
251
|
+
time.sleep(1)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main():
|
|
255
|
+
logging.basicConfig(
|
|
256
|
+
level=logging.ERROR,
|
|
257
|
+
format='[%(asctime)s][%(levelname)s] %(message)s',
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
parser = argparse.ArgumentParser(prog='tiny-cli')
|
|
261
|
+
subparsers = parser.add_subparsers(dest='command', required=True,
|
|
262
|
+
help='Choose a command')
|
|
263
|
+
|
|
264
|
+
greet_parser = subparsers.add_parser('list', help='List available DLNA devices')
|
|
265
|
+
greet_parser.add_argument('-v', dest='verbose', action='store_true',
|
|
266
|
+
help='Enable verbose logs')
|
|
267
|
+
|
|
268
|
+
play_parser = subparsers.add_parser('play', help='Play via via DLNA device')
|
|
269
|
+
play_parser.add_argument('video_file')
|
|
270
|
+
play_parser.add_argument('-v', dest='verbose', action='store_true',
|
|
271
|
+
help='Enable verbose logs')
|
|
272
|
+
play_parser.add_argument('-q', dest='query', type=str, required=True,
|
|
273
|
+
help='Specify Device by Friendly Name')
|
|
274
|
+
|
|
275
|
+
args = parser.parse_args()
|
|
276
|
+
if args.verbose:
|
|
277
|
+
logger.setLevel(logging.DEBUG)
|
|
278
|
+
|
|
279
|
+
if args.command == 'list':
|
|
280
|
+
list_dlna_devices()
|
|
281
|
+
elif args.command == 'play':
|
|
282
|
+
if not args.verbose:
|
|
283
|
+
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
|
284
|
+
play_video(args)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == '__main__':
|
|
288
|
+
main()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import argparse
|
|
1
2
|
import html
|
|
2
3
|
import logging
|
|
3
4
|
import re
|
|
@@ -7,89 +8,13 @@ import time
|
|
|
7
8
|
import xml.etree.ElementTree as ET
|
|
8
9
|
|
|
9
10
|
from flask import Flask, request, Response
|
|
10
|
-
from tiny_ssdp import
|
|
11
|
+
from .tiny_ssdp import get_uuid, ssdp_listener
|
|
12
|
+
from .tiny_xmls import * # NOQA
|
|
11
13
|
|
|
12
14
|
app = Flask(__name__)
|
|
13
15
|
logger = logging.getLogger('tiny_render')
|
|
14
16
|
|
|
15
17
|
|
|
16
|
-
XML_DESC_PTN = """<?xml version="1.0" encoding="UTF-8"?>
|
|
17
|
-
<root xmlns:dlna="urn:schemas-dlna-org:device-1-0" xmlns="urn:schemas-upnp-org:device-1-0">
|
|
18
|
-
<specVersion>
|
|
19
|
-
<major>1</major>
|
|
20
|
-
<minor>0</minor>
|
|
21
|
-
</specVersion>
|
|
22
|
-
<device>
|
|
23
|
-
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
|
24
|
-
<friendlyName>{}</friendlyName>
|
|
25
|
-
<manufacturer>mitnk</manufacturer>
|
|
26
|
-
<modelName>Tiny-Render</modelName>
|
|
27
|
-
<modelDescription>AVTransport Media Renderer</modelDescription>
|
|
28
|
-
<UDN>uuid:{}</UDN>
|
|
29
|
-
<dlna:X_DLNADOC xmlns:dlna="urn:schemas-dlna-org:device-1-0">DMR-1.50</dlna:X_DLNADOC>
|
|
30
|
-
<serviceList>
|
|
31
|
-
<service>
|
|
32
|
-
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
|
33
|
-
<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
|
|
34
|
-
<SCPDURL>AVTransport/scpd.xml</SCPDURL>
|
|
35
|
-
<controlURL>AVTransport/control</controlURL>
|
|
36
|
-
<eventSubURL>AVTransport/event</eventSubURL>
|
|
37
|
-
</service>
|
|
38
|
-
</serviceList>
|
|
39
|
-
</device>
|
|
40
|
-
</root>"""
|
|
41
|
-
|
|
42
|
-
XML_AVSET_DONE = """
|
|
43
|
-
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
44
|
-
<s:Body>
|
|
45
|
-
<u:SetAVTransportURIResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
|
|
46
|
-
</s:Body>
|
|
47
|
-
</s:Envelope>
|
|
48
|
-
"""
|
|
49
|
-
|
|
50
|
-
XML_PLAY_DONE = """
|
|
51
|
-
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
|
52
|
-
<s:Body>
|
|
53
|
-
<u:PlayResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
|
|
54
|
-
</s:Body>
|
|
55
|
-
</s:Envelope>
|
|
56
|
-
"""
|
|
57
|
-
|
|
58
|
-
XML_POSINFO = """
|
|
59
|
-
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
|
60
|
-
<s:Body>
|
|
61
|
-
<u:GetPositionInfoResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
|
62
|
-
<Track>0</Track>
|
|
63
|
-
<TrackDuration>00:00:00</TrackDuration>
|
|
64
|
-
<RelTime>{0}</RelTime>
|
|
65
|
-
<AbsTime>{0}</AbsTime>
|
|
66
|
-
<RelCount>2147483647</RelCount>
|
|
67
|
-
<AbsCount>2147483647</AbsCount>
|
|
68
|
-
</u:GetPositionInfoResponse>
|
|
69
|
-
</s:Body>
|
|
70
|
-
</s:Envelope>
|
|
71
|
-
"""
|
|
72
|
-
|
|
73
|
-
XML_TRANSINFO = """
|
|
74
|
-
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
|
75
|
-
<s:Body>
|
|
76
|
-
<u:GetTransportInfoResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
|
77
|
-
<CurrentTransportState>PLAYING</CurrentTransportState>
|
|
78
|
-
<CurrentTransportStatus>OK</CurrentTransportStatus>
|
|
79
|
-
<CurrentSpeed>1</CurrentSpeed>
|
|
80
|
-
</u:GetTransportInfoResponse>
|
|
81
|
-
</s:Body>
|
|
82
|
-
</s:Envelope>
|
|
83
|
-
"""
|
|
84
|
-
|
|
85
|
-
XML_STOP_DONE = """
|
|
86
|
-
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
|
87
|
-
<s:Body>
|
|
88
|
-
<u:StopResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
|
|
89
|
-
</s:Body>
|
|
90
|
-
</s:Envelope>
|
|
91
|
-
"""
|
|
92
|
-
|
|
93
18
|
class MPVRenderer:
|
|
94
19
|
def __init__(self):
|
|
95
20
|
self.process = None
|
|
@@ -120,11 +45,33 @@ _DATA = {
|
|
|
120
45
|
'STARTED_AT': 0,
|
|
121
46
|
}
|
|
122
47
|
|
|
48
|
+
|
|
123
49
|
@app.route('/description.xml')
|
|
124
50
|
def description():
|
|
125
51
|
friendly_name = app.config['FRIENDLY_NAME']
|
|
126
|
-
|
|
127
|
-
|
|
52
|
+
uuid_str = get_uuid()
|
|
53
|
+
xml = XML_DESC_PTN.format(friendly_name, uuid_str)
|
|
54
|
+
resp = Response(xml, mimetype="text/xml")
|
|
55
|
+
resp.headers['Server'] = 'UPnP/1.0 Werkzeug/3.0 TinyRender/0.7'
|
|
56
|
+
return resp
|
|
57
|
+
|
|
58
|
+
# these 3 dlna_ routings below are only here so that some dummy app
|
|
59
|
+
# could treat our "tiny render" as a proper dlna device. (Mouyu ..)
|
|
60
|
+
@app.route('/dlna/AVTransport.xml')
|
|
61
|
+
def dlna_avtransport():
|
|
62
|
+
resp = Response(XML_DLNA_AVT, mimetype="text/xml")
|
|
63
|
+
return resp
|
|
64
|
+
|
|
65
|
+
@app.route('/dlna/RenderingControl.xml')
|
|
66
|
+
def dlna_render_control():
|
|
67
|
+
resp = Response(XML_DLNA_RENDER_CTRL, mimetype="text/xml")
|
|
68
|
+
return resp
|
|
69
|
+
|
|
70
|
+
@app.route('/dlna/ConnectionManager.xml')
|
|
71
|
+
def dlna_conn_manager():
|
|
72
|
+
resp = Response(XML_DLNA_CONN_MANAGER, mimetype="text/xml")
|
|
73
|
+
return resp
|
|
74
|
+
|
|
128
75
|
|
|
129
76
|
def to_track_time(seconds):
|
|
130
77
|
hours = seconds // 3600
|
|
@@ -132,12 +79,12 @@ def to_track_time(seconds):
|
|
|
132
79
|
remaining_seconds = seconds % 60
|
|
133
80
|
return f"{hours}:{minutes:02}:{remaining_seconds:02}"
|
|
134
81
|
|
|
135
|
-
def is_stop(request):
|
|
136
|
-
return b'<u:Stop' in request.data
|
|
137
|
-
|
|
138
82
|
def is_play(request):
|
|
139
83
|
return b'<u:Play' in request.data
|
|
140
84
|
|
|
85
|
+
def is_stop(request):
|
|
86
|
+
return b'<u:Stop' in request.data
|
|
87
|
+
|
|
141
88
|
def is_setav(request):
|
|
142
89
|
return b'SetAVTransportURI' in request.data
|
|
143
90
|
|
|
@@ -157,27 +104,35 @@ def get_title_re(xml_data):
|
|
|
157
104
|
return match.group(1)
|
|
158
105
|
|
|
159
106
|
def get_metadata(request):
|
|
160
|
-
root = ET.fromstring(request.data)
|
|
107
|
+
root = ET.fromstring(request.data.strip())
|
|
161
108
|
current_uri = root.find('.//CurrentURI').text
|
|
162
109
|
metadata = root.find('.//CurrentURIMetaData').text
|
|
110
|
+
if not metadata:
|
|
111
|
+
logger.debug('no metadata')
|
|
112
|
+
return {'video': current_uri, 'title': ''}
|
|
163
113
|
|
|
164
|
-
current_srt = ''
|
|
165
114
|
title = ''
|
|
166
|
-
|
|
167
115
|
metadata = html.unescape(metadata)
|
|
168
116
|
try:
|
|
169
|
-
|
|
117
|
+
# HACK: replace all `&` to `&`
|
|
118
|
+
metadata = metadata.replace('&', '&')
|
|
119
|
+
metadata = ET.fromstring(metadata.strip())
|
|
170
120
|
except ET.ParseError:
|
|
171
121
|
# HACK: fall back to `re` to get title only (e.g. Huya)
|
|
172
122
|
logger.debug('** got xml.ParseError, fall back to re')
|
|
173
123
|
title = get_title_re(metadata)
|
|
174
124
|
return {'video': current_uri, 'title': title}
|
|
175
125
|
|
|
176
|
-
ns = {
|
|
177
|
-
|
|
178
|
-
|
|
126
|
+
ns = {
|
|
127
|
+
'dc': 'http://purl.org/dc/elements/1.1/',
|
|
128
|
+
'': 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/'
|
|
129
|
+
}
|
|
130
|
+
item = metadata.find('.//item', namespaces=ns)
|
|
131
|
+
obj = item.find('.//dc:title', namespaces=ns)
|
|
132
|
+
if obj is not None:
|
|
179
133
|
title = obj.text
|
|
180
134
|
|
|
135
|
+
current_srt = ''
|
|
181
136
|
for res in metadata.findall('.//{urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/}res'):
|
|
182
137
|
if res.get('type') == 'text/subtitle':
|
|
183
138
|
current_srt = res.text.strip()
|
|
@@ -186,7 +141,7 @@ def get_metadata(request):
|
|
|
186
141
|
return {
|
|
187
142
|
'video': current_uri,
|
|
188
143
|
'srt': current_srt,
|
|
189
|
-
'title':
|
|
144
|
+
'title': title,
|
|
190
145
|
}
|
|
191
146
|
|
|
192
147
|
|
|
@@ -199,7 +154,7 @@ def control():
|
|
|
199
154
|
video_title = metadata.get('title', '')
|
|
200
155
|
|
|
201
156
|
logger.debug(f'Action: SetAV: {current_uri}')
|
|
202
|
-
logger.debug(f'SRT: {current_srt}')
|
|
157
|
+
logger.debug(f'Title: {video_title} SRT: {current_srt}')
|
|
203
158
|
_DATA['CURRENT_URI'] = current_uri
|
|
204
159
|
_DATA['CURRENT_SRT'] = current_srt
|
|
205
160
|
_DATA['VIDEO_TITLE'] = video_title
|
|
@@ -211,11 +166,11 @@ def control():
|
|
|
211
166
|
srt = _DATA['CURRENT_SRT']
|
|
212
167
|
title = _DATA['VIDEO_TITLE']
|
|
213
168
|
logger.debug(f'action: Play: {url}')
|
|
214
|
-
logger.debug(f'title: {title} srt: {srt}')
|
|
215
169
|
renderer.play_media(url, title, srt)
|
|
216
170
|
return Response(XML_PLAY_DONE, mimetype="text/xml")
|
|
217
171
|
|
|
218
172
|
elif is_getpos(request):
|
|
173
|
+
# this is only a dummy impl; we need rpc to mpv process for real.
|
|
219
174
|
logger.debug('action: GetPositionInfo')
|
|
220
175
|
seconds = int(time.time() - _DATA['STARTED_AT'])
|
|
221
176
|
reltime = to_track_time(seconds)
|
|
@@ -236,47 +191,50 @@ def control():
|
|
|
236
191
|
return Response(XML_STOP_DONE, mimetype="text/xml")
|
|
237
192
|
|
|
238
193
|
elif is_seek(request):
|
|
239
|
-
logger.debug('seek')
|
|
240
|
-
return Response(
|
|
194
|
+
logger.debug('action:seek')
|
|
195
|
+
return Response(XML_SEEK_DONE, mimetype="text/xml")
|
|
241
196
|
|
|
242
197
|
logger.error(f'action not support: {request.data}')
|
|
243
198
|
return Response('Action Not Supported', status=500)
|
|
244
199
|
|
|
245
200
|
|
|
246
201
|
class SSDPServer(threading.Thread):
|
|
202
|
+
def __init__(self, render_port):
|
|
203
|
+
super().__init__()
|
|
204
|
+
self.render_port = render_port
|
|
205
|
+
|
|
247
206
|
def run(self):
|
|
248
|
-
ssdp_listener()
|
|
207
|
+
ssdp_listener(self.render_port)
|
|
249
208
|
|
|
250
209
|
|
|
251
210
|
def main():
|
|
252
|
-
import argparse
|
|
253
|
-
|
|
254
211
|
parser = argparse.ArgumentParser(prog='tiny-render')
|
|
255
|
-
parser.add_argument('--http-
|
|
212
|
+
parser.add_argument('--http-logs', action='store_true', help='Enable server logs')
|
|
256
213
|
parser.add_argument('--verbose', '-v', action='store_true', help='Enable debug logs')
|
|
257
214
|
parser.add_argument('--name', type=str, default='Tiny Render', help='Change render name')
|
|
258
215
|
|
|
259
216
|
args = parser.parse_args()
|
|
260
217
|
|
|
261
218
|
logging.basicConfig(
|
|
262
|
-
level=logging.
|
|
219
|
+
level=logging.ERROR,
|
|
263
220
|
format='[%(asctime)s][%(levelname)s] %(message)s',
|
|
264
221
|
)
|
|
265
222
|
|
|
266
|
-
if not args.
|
|
223
|
+
if not args.http_logs:
|
|
267
224
|
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
|
268
|
-
logging.getLogger('
|
|
225
|
+
logging.getLogger('tiny_ssdp').setLevel(logging.ERROR)
|
|
269
226
|
|
|
270
227
|
if args.verbose:
|
|
271
|
-
|
|
228
|
+
logger.setLevel(logging.DEBUG)
|
|
272
229
|
|
|
273
|
-
|
|
230
|
+
port = 59876
|
|
231
|
+
ssdp = SSDPServer(port)
|
|
274
232
|
ssdp.start()
|
|
275
233
|
|
|
276
234
|
friendly_name = args.name
|
|
277
235
|
app.config['FRIENDLY_NAME'] = friendly_name
|
|
278
236
|
logging.info(f'Starting DLNA Receiver: {friendly_name}')
|
|
279
|
-
app.run(host="0.0.0.0", port=
|
|
237
|
+
app.run(host="0.0.0.0", port=port, debug=False)
|
|
280
238
|
|
|
281
239
|
|
|
282
240
|
if __name__ == "__main__":
|