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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tiny-dlna
3
- Version: 0.7.3
3
+ Version: 0.9.0
4
4
  Summary: a tiny DLNA sender & receiver
5
5
  Home-page: https://github.com/mitnk/tiny-dlna
6
6
  Author: mitnk
@@ -1,12 +1,14 @@
1
1
  # Tiny DLNA
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/tiny-dlna.svg)](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
- ## Tiny DLNA Cli
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
  ```
@@ -40,7 +40,7 @@ Play a video on the DLNA device having "TV" in its name.
40
40
 
41
41
  setup(
42
42
  name='tiny-dlna',
43
- version='0.7.3',
43
+ version='0.9.0',
44
44
  description='a tiny DLNA sender & receiver',
45
45
  long_description=DESC,
46
46
  url='https://github.com/mitnk/tiny-dlna',
@@ -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
- device = _parse_ssdp_response(data.decode('utf-8'))
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(f'running mpv: {cmd}')
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, render_port):
221
+ def __init__(self):
203
222
  super().__init__()
204
- self.render_port = render_port
205
223
 
206
224
  def run(self):
207
- ssdp_listener(self.render_port)
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, default='Tiny Render', help='Change render name')
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
- port = 59876
231
- ssdp = SSDPServer(port)
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.name
308
+ friendly_name = _get_friendly_name(args)
235
309
  app.config['FRIENDLY_NAME'] = friendly_name
236
- logging.info(f'Starting DLNA Receiver: {friendly_name}')
237
- app.run(host="0.0.0.0", port=port, debug=False)
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 get_uuid():
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
- config_file = os.path.join(app_data_dir, 'tiny-render.json')
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 = f'{uuid.uuid4()}'
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
- def ssdp_listener(render_port):
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
- sock.sendto(build_m_search_response(st, render_port), addr)
165
+ for render_port in _get_live_render_ports():
166
+ sock.sendto(build_m_search_response(st, render_port), addr)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tiny-dlna
3
- Version: 0.7.3
3
+ Version: 0.9.0
4
4
  Summary: a tiny DLNA sender & receiver
5
5
  Home-page: https://github.com/mitnk/tiny-dlna
6
6
  Author: mitnk
File without changes
File without changes