tiny-dlna 0.7.3__tar.gz → 0.9.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.7.3 → tiny-dlna-0.9.0}/PKG-INFO +1 -1
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/README.md +10 -2
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/setup.py +1 -1
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/tiny_dlna/tiny_cli.py +7 -1
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/tiny_dlna/tiny_render.py +106 -13
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/tiny_dlna/tiny_ssdp.py +79 -7
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/tiny_dlna.egg-info/PKG-INFO +1 -1
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/LICENSE +0 -0
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/setup.cfg +0 -0
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/tiny_dlna/__init__.py +0 -0
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/tiny_dlna/tiny_xmls.py +0 -0
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/tiny_dlna.egg-info/SOURCES.txt +0 -0
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/tiny_dlna.egg-info/dependency_links.txt +0 -0
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/tiny_dlna.egg-info/entry_points.txt +0 -0
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/tiny_dlna.egg-info/requires.txt +0 -0
- {tiny-dlna-0.7.3 → tiny-dlna-0.9.0}/tiny_dlna.egg-info/top_level.txt +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Tiny DLNA
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/tiny-dlna/)
|
|
4
|
+
|
|
3
5
|
## Install
|
|
4
6
|
|
|
5
7
|
```
|
|
6
8
|
$ pip install tiny-dlna
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
## Tiny DLNA Render
|
|
11
|
+
## Usage for Tiny DLNA Render
|
|
10
12
|
|
|
11
13
|
Just to support subtitles.
|
|
12
14
|
|
|
@@ -19,7 +21,13 @@ from apps like 虎牙直播, Bilibili, and other video platforms. Additionally,
|
|
|
19
21
|
can also use `tiny-cli play` (see below) to play local videos (like in your
|
|
20
22
|
RaspberryPi) on it.
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
### Save video streaming into a file (Stream Recording)
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
$ tiny-render --dump-to ~/Movie/lol-msi-2024.mp4
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage for Tiny DLNA Cli
|
|
23
31
|
|
|
24
32
|
List available DLNA devices:
|
|
25
33
|
```
|
|
@@ -13,6 +13,7 @@ import xml.etree.ElementTree as ET
|
|
|
13
13
|
|
|
14
14
|
from flask import Flask, send_from_directory
|
|
15
15
|
from xml.sax.saxutils import escape as xmlescape
|
|
16
|
+
from urllib.error import URLError
|
|
16
17
|
from .tiny_ssdp import SSDP_MULTICAST_IP, SSDP_PORT, get_host_ip
|
|
17
18
|
from .tiny_xmls import * # NOQA
|
|
18
19
|
|
|
@@ -104,7 +105,12 @@ def get_dlna_devices():
|
|
|
104
105
|
while True:
|
|
105
106
|
try:
|
|
106
107
|
data, addr = sock.recvfrom(1024)
|
|
107
|
-
|
|
108
|
+
try:
|
|
109
|
+
device = _parse_ssdp_response(data.decode('utf-8'))
|
|
110
|
+
except URLError:
|
|
111
|
+
# render url does not respond
|
|
112
|
+
continue
|
|
113
|
+
|
|
108
114
|
location = device.get('location')
|
|
109
115
|
logger.debug(f'got reply from {addr}, Location: {location}')
|
|
110
116
|
if device and location not in known_locations:
|
|
@@ -2,6 +2,8 @@ import argparse
|
|
|
2
2
|
import html
|
|
3
3
|
import logging
|
|
4
4
|
import re
|
|
5
|
+
import os.path
|
|
6
|
+
import signal
|
|
5
7
|
import subprocess
|
|
6
8
|
import threading
|
|
7
9
|
import time
|
|
@@ -9,25 +11,31 @@ import xml.etree.ElementTree as ET
|
|
|
9
11
|
|
|
10
12
|
from flask import Flask, request, Response
|
|
11
13
|
from .tiny_ssdp import get_uuid, ssdp_listener
|
|
14
|
+
from .tiny_ssdp import register_render, unregister_render
|
|
12
15
|
from .tiny_xmls import * # NOQA
|
|
13
16
|
|
|
14
17
|
app = Flask(__name__)
|
|
15
18
|
logger = logging.getLogger('tiny_render')
|
|
19
|
+
PORT_DEFAULT = 59876
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
class MPVRenderer:
|
|
19
23
|
def __init__(self):
|
|
20
24
|
self.process = None
|
|
21
25
|
|
|
22
|
-
def play_media(self, url, title=None, srt=None):
|
|
26
|
+
def play_media(self, url, title=None, srt=None, dump_to=None):
|
|
23
27
|
self.stop_media() # Stop any existing media
|
|
24
28
|
cmd = ['mpv', url]
|
|
29
|
+
|
|
30
|
+
if dump_to:
|
|
31
|
+
path_abs = os.path.abspath(dump_to)
|
|
32
|
+
cmd.append(f'--stream-record={path_abs}')
|
|
25
33
|
if title:
|
|
26
34
|
cmd.append('--title={}'.format(title))
|
|
27
35
|
if srt:
|
|
28
36
|
cmd.append('--sub-file={}'.format(srt))
|
|
29
37
|
|
|
30
|
-
logger.debug(
|
|
38
|
+
logger.debug('running: {}'.format(' '.join(cmd)))
|
|
31
39
|
self.process = subprocess.Popen(cmd, stderr=subprocess.DEVNULL)
|
|
32
40
|
|
|
33
41
|
def stop_media(self):
|
|
@@ -42,6 +50,7 @@ _DATA = {
|
|
|
42
50
|
'CURRENT_URI': '',
|
|
43
51
|
'CURRENT_SRT': '',
|
|
44
52
|
'VIDEO_TITLE': '',
|
|
53
|
+
'DUMP_TO': None,
|
|
45
54
|
'STARTED_AT': 0,
|
|
46
55
|
}
|
|
47
56
|
|
|
@@ -49,7 +58,7 @@ _DATA = {
|
|
|
49
58
|
@app.route('/description.xml')
|
|
50
59
|
def description():
|
|
51
60
|
friendly_name = app.config['FRIENDLY_NAME']
|
|
52
|
-
uuid_str = get_uuid()
|
|
61
|
+
uuid_str = get_uuid(app.config['PORT'])
|
|
53
62
|
xml = XML_DESC_PTN.format(friendly_name, uuid_str)
|
|
54
63
|
resp = Response(xml, mimetype="text/xml")
|
|
55
64
|
resp.headers['Server'] = 'UPnP/1.0 Werkzeug/3.0 TinyRender/0.7'
|
|
@@ -161,12 +170,17 @@ def control():
|
|
|
161
170
|
return Response(XML_AVSET_DONE, mimetype="text/xml")
|
|
162
171
|
|
|
163
172
|
elif is_play(request):
|
|
173
|
+
if _DATA['STARTED_AT'] > 0 and _DATA['DUMP_TO']:
|
|
174
|
+
app.config['STOP'] = True
|
|
175
|
+
exit(0)
|
|
176
|
+
|
|
164
177
|
_DATA['STARTED_AT'] = time.time()
|
|
165
178
|
url = _DATA['CURRENT_URI']
|
|
166
179
|
srt = _DATA['CURRENT_SRT']
|
|
167
180
|
title = _DATA['VIDEO_TITLE']
|
|
181
|
+
dump_to = _DATA['DUMP_TO']
|
|
168
182
|
logger.debug(f'action: Play: {url}')
|
|
169
|
-
renderer.play_media(url, title, srt)
|
|
183
|
+
renderer.play_media(url, title, srt, dump_to)
|
|
170
184
|
return Response(XML_PLAY_DONE, mimetype="text/xml")
|
|
171
185
|
|
|
172
186
|
elif is_getpos(request):
|
|
@@ -183,6 +197,11 @@ def control():
|
|
|
183
197
|
elif is_stop(request):
|
|
184
198
|
logger.debug('stopping')
|
|
185
199
|
|
|
200
|
+
if _DATA['STARTED_AT'] > 0 and _DATA['DUMP_TO']:
|
|
201
|
+
app.config['STOP'] = True
|
|
202
|
+
logger.info('stopping the recorder as a whole')
|
|
203
|
+
exit(0)
|
|
204
|
+
|
|
186
205
|
_DATA['CURRENT_URI'] = ''
|
|
187
206
|
_DATA['CURRENT_SRT'] = ''
|
|
188
207
|
_DATA['VIDEO_TITLE'] = ''
|
|
@@ -199,19 +218,60 @@ def control():
|
|
|
199
218
|
|
|
200
219
|
|
|
201
220
|
class SSDPServer(threading.Thread):
|
|
202
|
-
def __init__(self
|
|
221
|
+
def __init__(self):
|
|
203
222
|
super().__init__()
|
|
204
|
-
self.render_port = render_port
|
|
205
223
|
|
|
206
224
|
def run(self):
|
|
207
|
-
|
|
225
|
+
while True:
|
|
226
|
+
try:
|
|
227
|
+
ssdp_listener()
|
|
228
|
+
except OSError:
|
|
229
|
+
# another SSDP Server is running
|
|
230
|
+
time.sleep(0.05)
|
|
231
|
+
continue
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _get_friendly_name(args):
|
|
236
|
+
if not args.name:
|
|
237
|
+
return 'Tiny Recorder' if args.dump_to else 'Tiny Render'
|
|
238
|
+
|
|
239
|
+
if args.dump_to:
|
|
240
|
+
return args.name + f' (Recorder)'
|
|
241
|
+
|
|
242
|
+
return args.name
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def flask_app_monitor(uuid, port):
|
|
246
|
+
to_stop = app.config.get('STOP')
|
|
247
|
+
while app.config.get('STOP') is None:
|
|
248
|
+
time.sleep(0.05)
|
|
249
|
+
|
|
250
|
+
unregister_render(uuid)
|
|
251
|
+
logger.debug(f'unregistered render: {uuid}')
|
|
252
|
+
logger.info('killing render process. mpv process is left open')
|
|
253
|
+
pid = os.getpid()
|
|
254
|
+
os.kill(pid, signal.SIGTERM)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def signal_handler(signal, frame):
|
|
258
|
+
logger.debug('got killing signal')
|
|
259
|
+
uuid = get_uuid(app.config['PORT'])
|
|
260
|
+
unregister_render(uuid)
|
|
261
|
+
logger.debug(f'unregistered render: {uuid}')
|
|
262
|
+
exit(0)
|
|
263
|
+
|
|
264
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
265
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
208
266
|
|
|
209
267
|
|
|
210
268
|
def main():
|
|
211
269
|
parser = argparse.ArgumentParser(prog='tiny-render')
|
|
212
270
|
parser.add_argument('--http-logs', action='store_true', help='Enable server logs')
|
|
213
271
|
parser.add_argument('--verbose', '-v', action='store_true', help='Enable debug logs')
|
|
214
|
-
parser.add_argument('--name', type=str,
|
|
272
|
+
parser.add_argument('--name', type=str, help='Specify render name')
|
|
273
|
+
parser.add_argument('--port', type=int, default=0, help='Server Port')
|
|
274
|
+
parser.add_argument('--dump-to', type=str, help='dump streaming to a file')
|
|
215
275
|
|
|
216
276
|
args = parser.parse_args()
|
|
217
277
|
|
|
@@ -226,15 +286,48 @@ def main():
|
|
|
226
286
|
|
|
227
287
|
if args.verbose:
|
|
228
288
|
logger.setLevel(logging.DEBUG)
|
|
289
|
+
else:
|
|
290
|
+
logger.setLevel(logging.INFO)
|
|
291
|
+
|
|
292
|
+
port = PORT_DEFAULT
|
|
293
|
+
if args.dump_to:
|
|
294
|
+
file_dump = os.path.abspath(args.dump_to)
|
|
295
|
+
if os.path.exists(file_dump):
|
|
296
|
+
logger.error(f'target file exists: {file_dump}')
|
|
297
|
+
exit(1)
|
|
229
298
|
|
|
230
|
-
|
|
231
|
-
|
|
299
|
+
_DATA['DUMP_TO'] = args.dump_to
|
|
300
|
+
port += 1
|
|
301
|
+
|
|
302
|
+
if args.port:
|
|
303
|
+
port = args.port
|
|
304
|
+
|
|
305
|
+
ssdp = SSDPServer()
|
|
232
306
|
ssdp.start()
|
|
233
307
|
|
|
234
|
-
friendly_name = args
|
|
308
|
+
friendly_name = _get_friendly_name(args)
|
|
235
309
|
app.config['FRIENDLY_NAME'] = friendly_name
|
|
236
|
-
|
|
237
|
-
|
|
310
|
+
app.config['PORT'] = port
|
|
311
|
+
logger.info(f'Starting DLNA Receiver: {friendly_name}')
|
|
312
|
+
if args.dump_to:
|
|
313
|
+
logger.info(f'Recording stream to {args.dump_to}')
|
|
314
|
+
|
|
315
|
+
uuid = get_uuid(port)
|
|
316
|
+
register_render(uuid, friendly_name, port)
|
|
317
|
+
logger.debug(f'registered render {uuid}')
|
|
318
|
+
|
|
319
|
+
app_server = threading.Thread(
|
|
320
|
+
target=app.run,
|
|
321
|
+
kwargs={'host': '0.0.0.0', 'port': port},
|
|
322
|
+
)
|
|
323
|
+
app_server.start()
|
|
324
|
+
|
|
325
|
+
thread = threading.Thread(
|
|
326
|
+
target=flask_app_monitor,
|
|
327
|
+
kwargs={'uuid': uuid, 'port': port},
|
|
328
|
+
)
|
|
329
|
+
thread.start()
|
|
330
|
+
thread.join()
|
|
238
331
|
|
|
239
332
|
|
|
240
333
|
if __name__ == "__main__":
|
|
@@ -9,27 +9,85 @@ import uuid
|
|
|
9
9
|
SSDP_MULTICAST_IP = '239.255.255.250'
|
|
10
10
|
SSDP_PORT = 1900
|
|
11
11
|
logger = logging.getLogger('tiny_ssdp')
|
|
12
|
+
logger.setLevel(logging.DEBUG)
|
|
13
|
+
|
|
12
14
|
ST_VALUE_MEDIARENDERER = 'mediarenderer'
|
|
13
15
|
ST_VALUE_AVTRANSPORT = 'avtransport'
|
|
14
16
|
|
|
15
17
|
|
|
16
|
-
def
|
|
18
|
+
def get_config_file(file_name):
|
|
17
19
|
home_dir = os.path.expanduser('~')
|
|
18
20
|
app_data_dir = os.path.join(home_dir, '.config', 'tiny-dlna')
|
|
19
21
|
os.makedirs(app_data_dir, exist_ok=True)
|
|
20
|
-
|
|
22
|
+
return os.path.join(app_data_dir, file_name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register_render(uuid, name, port):
|
|
26
|
+
config_file = get_config_file('live-renders.json')
|
|
27
|
+
data = {'renders': []}
|
|
28
|
+
if os.path.exists(config_file):
|
|
29
|
+
with open(config_file) as f:
|
|
30
|
+
data = json.load(f)
|
|
31
|
+
|
|
32
|
+
found = False
|
|
33
|
+
for render in data.get('renders', []):
|
|
34
|
+
if render['uuid'] == uuid:
|
|
35
|
+
found = True
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
if found:
|
|
39
|
+
return
|
|
21
40
|
|
|
41
|
+
if 'renders' not in data:
|
|
42
|
+
data['renders'] = []
|
|
43
|
+
|
|
44
|
+
data['renders'].append({'uuid': uuid, 'name': name, 'port': port})
|
|
45
|
+
with open(config_file, 'w') as f:
|
|
46
|
+
json.dump(data, f, sort_keys=True, indent=4)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def unregister_render(uuid):
|
|
50
|
+
config_file = get_config_file('live-renders.json')
|
|
51
|
+
if os.path.exists(config_file):
|
|
52
|
+
with open(config_file) as f:
|
|
53
|
+
data = json.load(f)
|
|
54
|
+
else:
|
|
55
|
+
data = {'renders': []}
|
|
56
|
+
|
|
57
|
+
if 'renders' not in data:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
found = False
|
|
61
|
+
others = []
|
|
62
|
+
for render in data.get('renders', []):
|
|
63
|
+
if render['uuid'] == uuid:
|
|
64
|
+
found = True
|
|
65
|
+
else:
|
|
66
|
+
others.append(render)
|
|
67
|
+
|
|
68
|
+
if not found:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
data = {'renders': others}
|
|
72
|
+
with open(config_file, 'w') as f:
|
|
73
|
+
json.dump(data, f, sort_keys=True, indent=4)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_uuid(port):
|
|
77
|
+
config_file = get_config_file('tiny-render.json')
|
|
22
78
|
if os.path.exists(config_file):
|
|
23
79
|
with open(config_file) as f:
|
|
24
80
|
configs = json.load(f)
|
|
25
81
|
if 'UUID' in configs and configs['UUID']:
|
|
26
|
-
return configs['UUID']
|
|
82
|
+
return configs['UUID'] + f'-{port}'
|
|
27
83
|
|
|
28
|
-
uuid_str =
|
|
84
|
+
uuid_str = str(uuid.uuid4()).rsplit('-', 1)[0]
|
|
29
85
|
with open(config_file, 'w') as f:
|
|
30
86
|
configs = {'UUID': uuid_str}
|
|
31
87
|
json.dump(configs, f)
|
|
32
88
|
|
|
89
|
+
return uuid_str + f'-{port}'
|
|
90
|
+
|
|
33
91
|
|
|
34
92
|
def get_host_ip():
|
|
35
93
|
ips = []
|
|
@@ -55,7 +113,7 @@ def build_m_search_response(st, render_port):
|
|
|
55
113
|
date_str = now.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
|
56
114
|
render_ip = get_host_ip()
|
|
57
115
|
location = f'http://{render_ip}:{render_port}/description.xml'
|
|
58
|
-
uuid_str = get_uuid()
|
|
116
|
+
uuid_str = get_uuid(render_port)
|
|
59
117
|
|
|
60
118
|
text = 'HTTP/1.1 200 OK\r\n'
|
|
61
119
|
if st == ST_VALUE_MEDIARENDERER:
|
|
@@ -71,16 +129,29 @@ def build_m_search_response(st, render_port):
|
|
|
71
129
|
text += f'Date: {date_str}\r\n\r\n'
|
|
72
130
|
return text.encode('utf-8')
|
|
73
131
|
|
|
132
|
+
|
|
74
133
|
def get_search_target(data):
|
|
75
134
|
if b':device:MediaRenderer:1' in data:
|
|
76
135
|
return ST_VALUE_MEDIARENDERER
|
|
77
136
|
else:
|
|
78
137
|
return ST_VALUE_AVTRANSPORT
|
|
79
138
|
|
|
80
|
-
|
|
139
|
+
|
|
140
|
+
def _get_live_render_ports():
|
|
141
|
+
config_file = get_config_file('live-renders.json')
|
|
142
|
+
if not os.path.exists(config_file):
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
with open(config_file) as f:
|
|
146
|
+
configs = json.load(f)
|
|
147
|
+
return [x['port'] for x in configs.get('renders', [])]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ssdp_listener():
|
|
81
151
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
82
152
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
83
153
|
sock.bind(('0.0.0.0', SSDP_PORT))
|
|
154
|
+
logger.debug(f'SSDP server running at {SSDP_PORT}')
|
|
84
155
|
|
|
85
156
|
# Join the SSDP multicast group
|
|
86
157
|
mreq = socket.inet_aton(SSDP_MULTICAST_IP) + socket.inet_aton('0.0.0.0')
|
|
@@ -91,4 +162,5 @@ def ssdp_listener(render_port):
|
|
|
91
162
|
if b'M-SEARCH' in data and b'ssdp:discover' in data:
|
|
92
163
|
st = get_search_target(data)
|
|
93
164
|
logger.info(f'Received M-SEARCH from {addr}, sending response...')
|
|
94
|
-
|
|
165
|
+
for render_port in _get_live_render_ports():
|
|
166
|
+
sock.sendto(build_m_search_response(st, render_port), addr)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|