-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathapp.py
More file actions
429 lines (366 loc) · 14.2 KB
/
app.py
File metadata and controls
429 lines (366 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
"""Zero Configuration DLNA Server - A simple DLNA media server."""
import hashlib
import os
import socket
import threading
import time
from http.server import ThreadingHTTPServer
import argparse
try: # Hacky but needed to support both package and module imports
from .constants import (
SERVER_NAME,
SERVER_DESCRIPTION,
SERVER_VERSION,
SERVER_MANUFACTURER,
is_supported_media_file,
)
from .dlna import DLNAHandler
from .ssdp import SSDPServer
except ImportError:
from constants import (
SERVER_NAME,
SERVER_DESCRIPTION,
SERVER_VERSION,
SERVER_MANUFACTURER,
is_supported_media_file,
)
from dlna import DLNAHandler
from ssdp import SSDPServer
class ZeroConfigDLNA:
"""
Zero Configuration DLNA Server class.
Provides a DLNA/UPnP media server with automatic discovery.
Serves media files from a specified directory to DLNA-compatible devices.
"""
def __init__(
self,
media_directory=None,
port=8200,
verbose=False,
server_name=None,
fast=False,
):
self.server_name = server_name
self.version = SERVER_VERSION
self.author = SERVER_MANUFACTURER
self.description = SERVER_DESCRIPTION
self.media_directory = media_directory or os.getcwd()
self.port = port
self.server = None
self.server_thread = None
self.verbose = verbose
self.fast = fast
socket.setdefaulttimeout(60) # 60 seconds timeout
# Generate UUID based on directory content hash
# This forces client cache refresh only when content actually changes
self.device_uuid = self._generate_content_hash_uuid()
self.server_ip = self.get_local_ip()
self.running = False
# Track directory content hash to detect changes
self._content_hash = None
self._last_hash_check = 0
# Simple counter that increments on root folder access to force refresh
self._system_update_id = (
int(time.time()) % 1000000
) # Start with timestamp-based ID
self.ssdp_server = SSDPServer(self, verbose=self.verbose)
self.now_playing = None # Track currently playing media
self.now_playing_timestamp = None # Track when media was last accessed
def get_now_playing(self):
"""Get the currently playing media file from the server."""
import time
if self.now_playing:
time_since_access = None
if self.now_playing_timestamp:
time_since_access = time.time() - self.now_playing_timestamp
return {
"filename": self.now_playing,
"status": "playing",
"server_running": self.running,
"last_accessed": self.now_playing_timestamp,
"seconds_since_access": time_since_access,
}
else:
return {
"filename": None,
"status": "no media playing",
"server_running": self.running,
"last_accessed": None,
"seconds_since_access": None,
}
def set_now_playing(self, filename):
"""Set the currently playing media file."""
import time
self.now_playing = filename
self.now_playing_timestamp = time.time()
if self.verbose:
print(f"Server now playing: {filename}")
def clear_now_playing(self):
"""Clear the currently playing media status."""
if self.verbose and self.now_playing:
print(f"Clearing now playing: {self.now_playing}")
self.now_playing = None
self.now_playing_timestamp = None
def find_a_port(self):
"""Find an available port starting from the specified port"""
test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
port = self.port
while True:
try:
test_socket.bind((self.server_ip, port))
test_socket.close()
return port
except OSError:
if self.verbose:
print(f"Port {port} is in use, trying next port...")
port += 1
def get_local_ip(self):
"""Get the local IP address"""
try:
# Connect to a remote address to determine local IP
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception:
return "127.0.0.1"
def create_handler(self):
"""
Create a handler class with server instance
"""
server_ref = self
class Handler(DLNAHandler): # Create a subclass of DLNAHandler
"""Custom DLNA handler with server instance reference."""
server_instance = server_ref
verbose = server_ref.verbose
server_name = self.server_name
fast = server_ref.fast
return Handler
def start(self):
"""Start the DLNA server"""
try:
print(f"{self.server_name} v{self.version} is starting...")
print(f"Media directory: {os.path.abspath(self.media_directory)}")
print(f"Server IP: {self.server_ip}")
if not os.path.exists(self.media_directory):
print(
f"Error: Media directory '{self.media_directory}' does not exist!"
)
return False
# Find an available port starting from the specified port
self.port = self.find_a_port()
print(f"Port: {self.port}")
# Count media files
media_count = 0
def count_media_files(directory):
nonlocal media_count
for item in os.listdir(directory):
item_path = os.path.join(directory, item)
if os.path.isfile(item_path):
if is_supported_media_file(item_path):
media_count += 1
elif os.path.isdir(item_path):
# Recursively count files in subdirectories
count_media_files(item_path)
count_media_files(self.media_directory)
print(f"Found {media_count} media files to serve")
# Use ThreadingHTTPServer to handle concurrent requests
self.server = ThreadingHTTPServer(
(self.server_ip, self.port), self.create_handler()
)
# Set server-side timeout to ensure we don't block forever on client operations
self.server.timeout = (
7200 # Very long timeout for media streaming / pausing etc
)
self.server_thread = threading.Thread(target=self.server.serve_forever)
self.server_thread.daemon = True
self.server_thread.start()
self.running = True
print(f"DLNA Server running at http://{self.server_ip}:{self.port}/")
print(
f"Device description: http://{self.server_ip}:{self.port}/description.xml"
)
print(f"Browse media: http://{self.server_ip}:{self.port}/browse")
print("Press Ctrl+C to stop the server")
# Start SSDP server for UPnP discovery
self.ssdp_server.start()
return True
except Exception as e:
print(f"Error starting server: {e}")
return False
def stop(self):
"""Stop the DLNA server"""
print(f"{self.server_name} is stopping...")
self.running = False
if self.server:
self.server.shutdown()
self.server.server_close()
if self.server_thread:
self.server_thread.join(timeout=5)
# Stop SSDP server
self.ssdp_server.stop()
print("Server stopped")
def run(self):
"""Run the server and handle keyboard interrupt"""
if self.start():
try:
while self.running:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
self.stop()
def refresh_cache_on_root_access(self):
"""
Increment SystemUpdateID when clients access the root media folder.
Also check if content has changed and update UUID if needed.
"""
self._system_update_id += 1
# Check if content has changed and update UUID if needed
content_changed = self.has_content_changed()
if self.verbose:
print(
f"Root folder accessed - refreshed SystemUpdateID to {self._system_update_id}"
)
if content_changed:
print("Content change detected - UUID updated")
def get_system_update_id(self):
"""Get the current system update ID."""
return self._system_update_id
def _get_directory_content_hash(self):
"""
Generate a hash of all directory contents (files and subdirectories).
This changes when files are added, removed, renamed, or modified.
"""
try:
content_items = []
for root, dirs, files in os.walk(self.media_directory):
# Sort for consistent ordering
dirs.sort()
files.sort()
for file in files:
file_path = os.path.join(root, file)
try:
# Get relative path for portability
rel_path = os.path.relpath(file_path, self.media_directory)
stat_info = os.stat(file_path)
# Include path, size, and modification time
content_items.append(
f"{rel_path}:{stat_info.st_size}:{int(stat_info.st_mtime)}"
)
except OSError:
continue
# Create hash from all content info
content_string = "\n".join(content_items)
return hashlib.md5(content_string.encode()).hexdigest()[:12]
except Exception as e:
if self.verbose:
print(f"Error calculating content hash: {e}")
# Fallback to timestamp
return hashlib.md5(str(int(time.time())).encode()).hexdigest()[:12]
def _generate_content_hash_uuid(self):
"""
Generate a UUID that changes only when directory content changes.
Much more efficient than time-based refresh.
"""
# Directory path for basic stability
path_hash = hashlib.md5(
os.path.abspath(self.media_directory).encode()
).hexdigest()[:8]
# Content hash that changes when files change
content_hash = self._get_directory_content_hash()
# Store the content hash for later comparison
self._content_hash = content_hash
uuid_string = (
f"65da942e-1984-3309-{content_hash[:4]}-{content_hash[4:8]}{path_hash[:4]}"
)
if self.verbose:
print(f"Generated content-hash UUID: {uuid_string}")
print(f"Content hash: {content_hash}")
return uuid_string
def has_content_changed(self):
"""
Check if directory content has changed since last check.
Only recalculates hash every 30 seconds to avoid excessive disk I/O.
"""
current_time = time.time()
# Don't check too frequently to avoid performance issues
if current_time - self._last_hash_check < 30:
return False
self._last_hash_check = current_time
current_hash = self._get_directory_content_hash()
if current_hash != self._content_hash:
if self.verbose:
print(f"Content changed: {self._content_hash} -> {current_hash}")
self._content_hash = current_hash
# Regenerate UUID with new content hash
old_uuid = self.device_uuid
self.device_uuid = self._generate_content_hash_uuid()
if self.verbose:
print(f"UUID updated: {old_uuid} -> {self.device_uuid}")
return True
return False
def get_media_info(self, filename=None):
"""Get detailed information about currently playing or specified media."""
target_file = filename or self.now_playing
if not target_file:
return None
file_path = os.path.join(self.media_directory, target_file)
if not os.path.exists(file_path):
return None
try:
stat_info = os.stat(file_path)
return {
"filename": target_file,
"full_path": file_path,
"size": stat_info.st_size,
"modified": stat_info.st_mtime,
"is_supported": is_supported_media_file(file_path),
}
except OSError:
return None
def main():
"""Main entry point for the DLNA server application."""
parser = argparse.ArgumentParser(description=SERVER_DESCRIPTION)
parser.add_argument(
"-d",
"--directory",
default=os.getcwd(),
help="Directory to serve media files from (default: current directory)",
)
parser.add_argument(
"-p",
"--port",
type=int,
default=8200,
help="Port to run server on (default: 8200)",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Enable verbose output",
)
parser.add_argument(
"-n",
"--server_name",
default=SERVER_NAME,
help="Set the DLNA server name (default: ZeroConfigDLNA_<hostname> or value from DLNA_HOSTNAME env var)",
)
parser.add_argument(
"-f",
"--fast",
action="store_true",
help="Disable ffprobe and mediainfo subprocess calls for faster operation and wider compatibility",
)
args = parser.parse_args()
server = ZeroConfigDLNA(
media_directory=args.directory,
port=args.port,
verbose=args.verbose,
server_name=args.server_name,
fast=args.fast,
)
server.run()
if __name__ == "__main__":
main()