discstation 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/arduino/c6/DiscStation_C6.ino +914 -0
- package/arduino/v1/DiscStation.ino +957 -0
- package/discstation.env.example +19 -0
- package/docs/PLATFORM_SUPPORT.md +39 -0
- package/install-macos.sh +80 -0
- package/install-windows.ps1 +24 -0
- package/install.sh +56 -0
- package/package.json +48 -0
- package/requirements.txt +20 -0
- package/scripts/setup.mjs +78 -0
- package/src/discstation.py +3984 -0
- package/src/discstation_burn.py +1697 -0
- package/src/discstation_host.py +380 -0
- package/src/discstation_meta.py +150 -0
- package/src/static/app.js +268 -0
- package/src/static/index.html +111 -0
- package/src/static/style.css +328 -0
- package/systemd/discstation.service +13 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Host-specific discovery and paths for DiscStation.
|
|
2
|
+
|
|
3
|
+
The workflows use this module instead of assuming Linux device names or
|
|
4
|
+
home-directory layout. Optical burning commands remain backend-specific.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from serial.tools import list_ports
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def system_name():
|
|
18
|
+
return platform.system().lower()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def user_home():
|
|
22
|
+
return Path.home()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def config_dir():
|
|
26
|
+
override = os.environ.get("DISCSTATION_CONFIG_DIR")
|
|
27
|
+
if override:
|
|
28
|
+
return Path(override).expanduser()
|
|
29
|
+
system = system_name()
|
|
30
|
+
if system == "windows":
|
|
31
|
+
return Path(os.environ.get("APPDATA", user_home())) / "DiscStation"
|
|
32
|
+
if system == "darwin":
|
|
33
|
+
return user_home() / "Library" / "Application Support" / "DiscStation"
|
|
34
|
+
return user_home() / ".local" / "share" / "discstation"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cache_dir():
|
|
38
|
+
override = os.environ.get("DISCSTATION_CACHE_DIR")
|
|
39
|
+
if override:
|
|
40
|
+
return Path(override).expanduser()
|
|
41
|
+
system = system_name()
|
|
42
|
+
if system == "windows":
|
|
43
|
+
return Path(os.environ.get("LOCALAPPDATA", user_home())) / "DiscStation" / "cache"
|
|
44
|
+
if system == "darwin":
|
|
45
|
+
return user_home() / "Library" / "Caches" / "DiscStation"
|
|
46
|
+
return Path(os.environ.get("XDG_CACHE_HOME", user_home() / ".cache")) / "discstation"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def serial_port():
|
|
50
|
+
override = os.environ.get("DISC_PORT")
|
|
51
|
+
if override:
|
|
52
|
+
path = Path(override).expanduser()
|
|
53
|
+
if path.exists():
|
|
54
|
+
return str(path)
|
|
55
|
+
|
|
56
|
+
ports = list(list_ports.comports())
|
|
57
|
+
preferred = []
|
|
58
|
+
for port in ports:
|
|
59
|
+
vid_pid = (port.vid, port.pid)
|
|
60
|
+
description = (port.description or "").lower()
|
|
61
|
+
if vid_pid == (0x303A, 0x1001) or "esp32" in description or "cp210" in description:
|
|
62
|
+
preferred.append(port.device)
|
|
63
|
+
if not preferred:
|
|
64
|
+
return None
|
|
65
|
+
return _stable_serial_path(sorted(preferred)[0])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _stable_serial_path(device):
|
|
69
|
+
"""Map a volatile /dev/ttyUSBN to its stable /dev/serial/by-id/ symlink so a
|
|
70
|
+
USB re-enumeration (ttyUSB1 -> ttyUSB0) doesn't strand the reconnect loop."""
|
|
71
|
+
try:
|
|
72
|
+
target = Path(device).resolve()
|
|
73
|
+
for link in Path("/dev/serial/by-id").iterdir():
|
|
74
|
+
try:
|
|
75
|
+
if link.resolve() == target:
|
|
76
|
+
return str(link)
|
|
77
|
+
except OSError:
|
|
78
|
+
continue
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
return device
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _mac_optical_device():
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
["/usr/sbin/ioreg", "-r", "-c", "IODVDServices", "-l"],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
check=False,
|
|
91
|
+
timeout=3,
|
|
92
|
+
)
|
|
93
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
94
|
+
return None
|
|
95
|
+
match = re.search(r'"BSD Name"\s*=\s*"(disk\d+)"', result.stdout)
|
|
96
|
+
return f"/dev/{match.group(1)}" if match else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def disc_device():
|
|
100
|
+
system = system_name()
|
|
101
|
+
override = os.environ.get("DISC_DEVICE") or os.environ.get("DVD_DEVICE")
|
|
102
|
+
if system == "darwin":
|
|
103
|
+
detected = _mac_optical_device()
|
|
104
|
+
if detected:
|
|
105
|
+
return detected
|
|
106
|
+
if override and Path(override).exists():
|
|
107
|
+
return override
|
|
108
|
+
elif override:
|
|
109
|
+
return override
|
|
110
|
+
|
|
111
|
+
if system == "linux":
|
|
112
|
+
for name in ("/dev/dvd", "/dev/cdrom"):
|
|
113
|
+
path = Path(name)
|
|
114
|
+
if path.exists():
|
|
115
|
+
return str(path.resolve())
|
|
116
|
+
drives = sorted(Path("/dev").glob("sr*"))
|
|
117
|
+
if drives:
|
|
118
|
+
return str(drives[0])
|
|
119
|
+
elif system == "darwin":
|
|
120
|
+
try:
|
|
121
|
+
status = subprocess.run(["/usr/bin/drutil", "status"], capture_output=True, text=True, check=False, timeout=3)
|
|
122
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
123
|
+
status = None
|
|
124
|
+
if status:
|
|
125
|
+
match = re.search(r"Name:\s+(/dev/disk\S+)", status.stdout + status.stderr)
|
|
126
|
+
if match:
|
|
127
|
+
return match.group(1)
|
|
128
|
+
result = subprocess.run(["/usr/sbin/diskutil", "list"], capture_output=True, text=True, check=False, timeout=5)
|
|
129
|
+
for line in result.stdout.splitlines():
|
|
130
|
+
if "/dev/disk" in line and ("CD" in line or "DVD" in line or "optical" in line.lower()):
|
|
131
|
+
return line.strip().split()[0]
|
|
132
|
+
elif system == "windows":
|
|
133
|
+
raise RuntimeError("Set DISC_DEVICE to the optical drive letter on Windows")
|
|
134
|
+
raise FileNotFoundError("No optical disc drive found; set DISC_DEVICE explicitly")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _drutil_media_properties():
|
|
138
|
+
result = subprocess.run(["/usr/bin/drutil", "status"], capture_output=True, text=True, check=False, timeout=3)
|
|
139
|
+
text = result.stdout + result.stderr
|
|
140
|
+
lowered = text.lower()
|
|
141
|
+
if result.returncode != 0 or "no media" in lowered:
|
|
142
|
+
return {}
|
|
143
|
+
props = {"ID_CDROM": "1", "ID_CDROM_MEDIA": "1"}
|
|
144
|
+
if "space used:" in lowered and "00:00:00" in lowered.split("space used:", 1)[1][:24]:
|
|
145
|
+
props["ID_CDROM_MEDIA_STATE"] = "blank"
|
|
146
|
+
if "type: cd" in lowered:
|
|
147
|
+
props["ID_CDROM_MEDIA_TYPE"] = "audio"
|
|
148
|
+
elif "type: dvd" in lowered:
|
|
149
|
+
props["ID_CDROM_MEDIA_TYPE"] = "dvd"
|
|
150
|
+
return props
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def media_properties(device):
|
|
154
|
+
"""Return udev-like media properties on non-Linux hosts."""
|
|
155
|
+
if system_name() == "darwin":
|
|
156
|
+
try:
|
|
157
|
+
disk = subprocess.run(["/usr/sbin/diskutil", "info", device], capture_output=True, text=True, check=False, timeout=3)
|
|
158
|
+
except subprocess.TimeoutExpired:
|
|
159
|
+
return _drutil_media_properties()
|
|
160
|
+
if disk.returncode != 0:
|
|
161
|
+
return {}
|
|
162
|
+
fields = {}
|
|
163
|
+
for line in disk.stdout.splitlines():
|
|
164
|
+
if ":" in line:
|
|
165
|
+
key, value = line.split(":", 1)
|
|
166
|
+
fields[key.strip()] = value.strip()
|
|
167
|
+
optical = fields.get("Optical Media Type", "")
|
|
168
|
+
if not optical and not fields.get("Device / Media Name"):
|
|
169
|
+
return {}
|
|
170
|
+
props = {"ID_CDROM": "1", "ID_CDROM_MEDIA": "1"}
|
|
171
|
+
label = fields.get("Volume Name", "")
|
|
172
|
+
filesystem = fields.get("Type (Bundle)") or fields.get("File System Personality")
|
|
173
|
+
if label and label not in ("Not applicable (no file system)", ""):
|
|
174
|
+
props["ID_FS_LABEL"] = label
|
|
175
|
+
if filesystem:
|
|
176
|
+
normalized = re.sub(r"[^a-z0-9]+", "", filesystem.lower())
|
|
177
|
+
if "udf" in normalized or "universaldiskformat" in normalized:
|
|
178
|
+
props["ID_FS_TYPE"] = "udf"
|
|
179
|
+
elif "iso9660" in normalized:
|
|
180
|
+
props["ID_FS_TYPE"] = "iso9660"
|
|
181
|
+
else:
|
|
182
|
+
props["ID_FS_TYPE"] = filesystem.lower()
|
|
183
|
+
mount_point = fields.get("Mount Point", "")
|
|
184
|
+
if mount_point and mount_point != "Not applicable":
|
|
185
|
+
props["ID_MOUNT_POINT"] = mount_point
|
|
186
|
+
if label.lower() == "audio cd":
|
|
187
|
+
props["ID_CDROM_MEDIA_TYPE"] = "audio"
|
|
188
|
+
elif "cd" in optical.lower() and "r" not in optical.lower():
|
|
189
|
+
props["ID_CDROM_MEDIA_TYPE"] = "audio"
|
|
190
|
+
elif "dvd" in optical.lower():
|
|
191
|
+
props["ID_CDROM_MEDIA_TYPE"] = "dvd"
|
|
192
|
+
if not label or label == "Not applicable (no file system)":
|
|
193
|
+
props["ID_CDROM_MEDIA_STATE"] = "blank"
|
|
194
|
+
optical = optical.lower()
|
|
195
|
+
if "dvd+r dl" in optical or "dvd-r dl" in optical:
|
|
196
|
+
props["ID_CDROM_MEDIA_DVD_PLUS_R_DL"] = "1"
|
|
197
|
+
elif "dvd+r" in optical or "dvd-r" in optical:
|
|
198
|
+
props["ID_CDROM_MEDIA_DVD_PLUS_R"] = "1"
|
|
199
|
+
return props
|
|
200
|
+
return {}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def media_capacity_bytes(device):
|
|
204
|
+
if system_name() == "darwin":
|
|
205
|
+
try:
|
|
206
|
+
result = subprocess.run(["/usr/sbin/diskutil", "info", device], capture_output=True, text=True, check=False, timeout=3)
|
|
207
|
+
except subprocess.TimeoutExpired:
|
|
208
|
+
result = subprocess.run(["/usr/bin/drutil", "status"], capture_output=True, text=True, check=False, timeout=3)
|
|
209
|
+
match = re.search(r"blocks:\s*(\d+)\s*/", result.stdout + result.stderr, re.IGNORECASE)
|
|
210
|
+
if match:
|
|
211
|
+
return int(match.group(1)) * 2048
|
|
212
|
+
return None
|
|
213
|
+
match = re.search(r"Disk Size:.*?\((\d+) Bytes\)", result.stdout, re.IGNORECASE)
|
|
214
|
+
if match:
|
|
215
|
+
return int(match.group(1))
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def tool(name):
|
|
220
|
+
local = user_home() / ".local" / "bin" / name
|
|
221
|
+
if local.exists():
|
|
222
|
+
return str(local)
|
|
223
|
+
path = shutil.which(name)
|
|
224
|
+
if path:
|
|
225
|
+
return path
|
|
226
|
+
if system_name() == "darwin":
|
|
227
|
+
candidates = [
|
|
228
|
+
Path("/opt/homebrew/bin") / name,
|
|
229
|
+
Path("/usr/local/bin") / name,
|
|
230
|
+
Path("/opt/homebrew/opt/cdrtools/bin") / name,
|
|
231
|
+
Path("/usr/local/opt/cdrtools/bin") / name,
|
|
232
|
+
]
|
|
233
|
+
for candidate in candidates:
|
|
234
|
+
if candidate.exists():
|
|
235
|
+
return str(candidate)
|
|
236
|
+
raise FileNotFoundError(
|
|
237
|
+
f"{name} is not installed on {system_name()}; install the DiscStation host dependencies"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def null_device():
|
|
242
|
+
return "NUL" if system_name() == "windows" else "/dev/null"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def can_use_linux_optical_backend():
|
|
246
|
+
return system_name() == "linux" and all(shutil.which(name) for name in ("growisofs", "wodim", "cdrdao"))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def build_data_image(source_paths, output_path, label, video=False):
|
|
250
|
+
system = system_name()
|
|
251
|
+
if system == "darwin":
|
|
252
|
+
command = [tool("hdiutil"), "makehybrid", "-o", str(output_path), "-iso", "-joliet", "-udf"]
|
|
253
|
+
command += ["-default-volume-name", label, *[str(path) for path in source_paths]]
|
|
254
|
+
elif system == "windows":
|
|
255
|
+
command = [tool("xorriso"), "-as", "mkisofs", "-iso-level", "3", "-J", "-R", "-V", label, "-o", str(output_path)]
|
|
256
|
+
command += [str(path) for path in source_paths]
|
|
257
|
+
else:
|
|
258
|
+
raise RuntimeError("Image building is only used by non-Linux optical backends")
|
|
259
|
+
subprocess.run(command, check=True, capture_output=True, text=True)
|
|
260
|
+
return output_path
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def iso_burn_command(device, image_path):
|
|
264
|
+
system = system_name()
|
|
265
|
+
if system == "darwin":
|
|
266
|
+
return [tool("hdiutil"), "burn", str(image_path)]
|
|
267
|
+
if system == "windows":
|
|
268
|
+
return [tool("isoburn.exe"), "/Q", device, str(image_path)]
|
|
269
|
+
raise RuntimeError("ISO command requested on Linux; use growisofs backend")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def audio_output_device():
|
|
273
|
+
"""Return an MPV audio-device override for a connected Bluetooth sink."""
|
|
274
|
+
override = os.environ.get("DISC_AUDIO_DEVICE")
|
|
275
|
+
if override:
|
|
276
|
+
return override
|
|
277
|
+
if system_name() == "linux":
|
|
278
|
+
pactl = shutil.which("pactl")
|
|
279
|
+
if pactl:
|
|
280
|
+
result = subprocess.run([pactl, "list", "short", "sinks"], capture_output=True, text=True, check=False)
|
|
281
|
+
for line in result.stdout.splitlines():
|
|
282
|
+
fields = line.split()
|
|
283
|
+
if len(fields) >= 2 and "bluez_output." in fields[1]:
|
|
284
|
+
return f"pipewire/{fields[1]}"
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def cdrdao_device(device):
|
|
289
|
+
if system_name() != "darwin":
|
|
290
|
+
return device
|
|
291
|
+
cdrdao = tool("cdrdao")
|
|
292
|
+
for attempt in range(2):
|
|
293
|
+
result = subprocess.run([cdrdao, "scanbus"], capture_output=True, text=True, check=False, timeout=10)
|
|
294
|
+
for line in (result.stdout + result.stderr).splitlines():
|
|
295
|
+
if "IODVDServices" in line and " : " in line:
|
|
296
|
+
return line.split(" : ", 1)[0].strip()
|
|
297
|
+
if attempt == 0 and device:
|
|
298
|
+
subprocess.run(
|
|
299
|
+
["/usr/sbin/diskutil", "unmountDisk", "force", device],
|
|
300
|
+
capture_output=True,
|
|
301
|
+
text=True,
|
|
302
|
+
check=False,
|
|
303
|
+
timeout=15,
|
|
304
|
+
)
|
|
305
|
+
raise RuntimeError("cdrdao could not find the macOS optical writer")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def unmount_device(device):
|
|
309
|
+
"""Unmount Linux optical media before handing the device to a writer."""
|
|
310
|
+
if system_name() != "linux" or not device:
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
udisksctl = shutil.which("udisksctl")
|
|
314
|
+
if udisksctl:
|
|
315
|
+
try:
|
|
316
|
+
result = subprocess.run(
|
|
317
|
+
[udisksctl, "unmount", "--block-device", device],
|
|
318
|
+
capture_output=True,
|
|
319
|
+
text=True,
|
|
320
|
+
check=False,
|
|
321
|
+
timeout=15,
|
|
322
|
+
)
|
|
323
|
+
if result.returncode == 0:
|
|
324
|
+
return True
|
|
325
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
mounts = subprocess.run(
|
|
330
|
+
["findmnt", "--source", device, "--output", "TARGET", "--noheadings", "--raw"],
|
|
331
|
+
capture_output=True,
|
|
332
|
+
text=True,
|
|
333
|
+
check=False,
|
|
334
|
+
timeout=5,
|
|
335
|
+
)
|
|
336
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
337
|
+
raise RuntimeError(f"Could not inspect optical mount: {exc}") from exc
|
|
338
|
+
|
|
339
|
+
if mounts.returncode not in (0, 1):
|
|
340
|
+
detail = (mounts.stderr or mounts.stdout or "findmnt failed").strip()
|
|
341
|
+
raise RuntimeError(f"Could not inspect optical mount: {detail}")
|
|
342
|
+
|
|
343
|
+
targets = [line.strip() for line in mounts.stdout.splitlines() if line.strip()]
|
|
344
|
+
for target in reversed(targets):
|
|
345
|
+
result = subprocess.run(
|
|
346
|
+
["umount", "--", target],
|
|
347
|
+
capture_output=True,
|
|
348
|
+
text=True,
|
|
349
|
+
check=False,
|
|
350
|
+
timeout=15,
|
|
351
|
+
)
|
|
352
|
+
if result.returncode != 0:
|
|
353
|
+
detail = (result.stderr or result.stdout or "umount failed").strip()
|
|
354
|
+
raise RuntimeError(f"Could not unmount optical disc: {detail}")
|
|
355
|
+
|
|
356
|
+
return True
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def eject_device(device, close=False):
|
|
360
|
+
system = system_name()
|
|
361
|
+
if system == "linux":
|
|
362
|
+
command = ["eject"]
|
|
363
|
+
if close:
|
|
364
|
+
command.append("-t")
|
|
365
|
+
command.append(device)
|
|
366
|
+
elif system == "darwin":
|
|
367
|
+
action = "close" if close else "open"
|
|
368
|
+
commands = [["/usr/bin/drutil", "tray", action]]
|
|
369
|
+
if device:
|
|
370
|
+
commands.append(["/usr/sbin/diskutil", "eject", device])
|
|
371
|
+
for command in commands:
|
|
372
|
+
try:
|
|
373
|
+
if subprocess.run(command, capture_output=True, text=True, timeout=10).returncode == 0:
|
|
374
|
+
return True
|
|
375
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
376
|
+
pass
|
|
377
|
+
return False
|
|
378
|
+
else:
|
|
379
|
+
raise RuntimeError("Automatic optical-drive eject is not implemented on Windows")
|
|
380
|
+
return subprocess.run(command, capture_output=True, text=True, timeout=10).returncode == 0
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Optional video (movie / TV) metadata lookup via TMDb.
|
|
2
|
+
|
|
3
|
+
All functions degrade to no-ops when `tmdbsimple` is not installed or no API key
|
|
4
|
+
is configured (env DISCSTATION_TMDB_API_KEY, or ~/.local/share/discstation/tmdb.key
|
|
5
|
+
/ ~/.config/discstation/tmdb.key).
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import requests
|
|
14
|
+
except Exception: # pragma: no cover
|
|
15
|
+
requests = None
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import tmdbsimple as _tmdb
|
|
19
|
+
except Exception:
|
|
20
|
+
_tmdb = None
|
|
21
|
+
|
|
22
|
+
_IMG_BASE = "https://image.tmdb.org/t/p/w500"
|
|
23
|
+
_YEAR_RE = re.compile(r"(19|20)\d{2}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _api_key():
|
|
27
|
+
key = os.environ.get("DISCSTATION_TMDB_API_KEY", "").strip()
|
|
28
|
+
if key:
|
|
29
|
+
return key
|
|
30
|
+
for cand in (
|
|
31
|
+
Path.home() / ".local/share/discstation/tmdb.key",
|
|
32
|
+
Path.home() / ".config/discstation/tmdb.key",
|
|
33
|
+
):
|
|
34
|
+
try:
|
|
35
|
+
k = cand.read_text().strip()
|
|
36
|
+
if k:
|
|
37
|
+
return k
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def available():
|
|
44
|
+
return _tmdb is not None and _api_key() is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _clean_title(raw):
|
|
48
|
+
"""Turn a disc label / filename stem into a plausible search title + year."""
|
|
49
|
+
if not raw:
|
|
50
|
+
return "", None
|
|
51
|
+
name = str(raw).replace("_", " ").strip()
|
|
52
|
+
year = None
|
|
53
|
+
m = _YEAR_RE.search(name)
|
|
54
|
+
if m:
|
|
55
|
+
year = m.group(0)
|
|
56
|
+
name = (name[:m.start()] + " " + name[m.end():])
|
|
57
|
+
name = re.sub(r"\b(disc\s*\d*|dvd|video[_ ]?ts|bluray|blu-ray|pal|ntsc|"
|
|
58
|
+
r"region\s*\d|season\s*\d+|s\d+|d\d+)\b", " ", name, flags=re.I)
|
|
59
|
+
name = re.sub(r"[._()\[\]]+", " ", name)
|
|
60
|
+
name = re.sub(r"\s{2,}", " ", name).strip(" -")
|
|
61
|
+
return name, year
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def lookup(title_guess, year=None):
|
|
65
|
+
"""Return {title, year, tmdb_id, media_type, poster_url, overview} or None."""
|
|
66
|
+
if not available():
|
|
67
|
+
return None
|
|
68
|
+
name, guessed_year = _clean_title(title_guess)
|
|
69
|
+
year = year or guessed_year
|
|
70
|
+
if not name:
|
|
71
|
+
return None
|
|
72
|
+
_tmdb.API_KEY = _api_key()
|
|
73
|
+
try:
|
|
74
|
+
search = _tmdb.Search()
|
|
75
|
+
movie_kwargs = {"query": name}
|
|
76
|
+
if year:
|
|
77
|
+
movie_kwargs["year"] = year
|
|
78
|
+
results = (search.movie(**movie_kwargs).get("results") or [])
|
|
79
|
+
media_type = "movie"
|
|
80
|
+
if not results:
|
|
81
|
+
tv_kwargs = {"query": name}
|
|
82
|
+
if year:
|
|
83
|
+
tv_kwargs["first_air_date_year"] = year
|
|
84
|
+
results = (search.tv(**tv_kwargs).get("results") or [])
|
|
85
|
+
media_type = "tv"
|
|
86
|
+
if not results:
|
|
87
|
+
return None
|
|
88
|
+
top = results[0]
|
|
89
|
+
released = top.get("release_date") or top.get("first_air_date") or ""
|
|
90
|
+
return {
|
|
91
|
+
"title": top.get("title") or top.get("name") or name,
|
|
92
|
+
"year": released[:4] or (year or ""),
|
|
93
|
+
"tmdb_id": top.get("id"),
|
|
94
|
+
"media_type": media_type,
|
|
95
|
+
"poster_url": _IMG_BASE + top["poster_path"] if top.get("poster_path") else None,
|
|
96
|
+
"overview": top.get("overview") or "",
|
|
97
|
+
}
|
|
98
|
+
except Exception as e: # tmdbsimple raises its own APIKeyError / HTTPError types
|
|
99
|
+
print(f"TMDb lookup failed: {e}")
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def folder_name(meta):
|
|
104
|
+
base = re.sub(r'[\\/:*?"<>|]+', "_", (meta.get("title") or "").strip())
|
|
105
|
+
base = re.sub(r"\s+", " ", base).strip()
|
|
106
|
+
year = meta.get("year")
|
|
107
|
+
if base and year:
|
|
108
|
+
return f"{base} ({year})"
|
|
109
|
+
return base or ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _nfo(meta):
|
|
113
|
+
tag = "tvshow" if meta.get("media_type") == "tv" else "movie"
|
|
114
|
+
|
|
115
|
+
def esc(value):
|
|
116
|
+
return (str(value).replace("&", "&").replace("<", "<").replace(">", ">"))
|
|
117
|
+
|
|
118
|
+
return "\n".join([
|
|
119
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
120
|
+
f"<{tag}>",
|
|
121
|
+
f" <title>{esc(meta.get('title', ''))}</title>",
|
|
122
|
+
f" <year>{esc(meta.get('year', ''))}</year>",
|
|
123
|
+
f" <plot>{esc(meta.get('overview', ''))}</plot>",
|
|
124
|
+
f' <uniqueid type="tmdb" default="true">{esc(meta.get("tmdb_id", ""))}</uniqueid>',
|
|
125
|
+
f"</{tag}>",
|
|
126
|
+
"",
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def save_assets(out_dir, meta):
|
|
131
|
+
"""Write movie.nfo and poster.jpg into out_dir (best effort)."""
|
|
132
|
+
out_dir = Path(out_dir)
|
|
133
|
+
try:
|
|
134
|
+
(out_dir / "movie.nfo").write_text(_nfo(meta), encoding="utf-8")
|
|
135
|
+
except OSError:
|
|
136
|
+
pass
|
|
137
|
+
if meta.get("poster_url") and requests is not None:
|
|
138
|
+
try:
|
|
139
|
+
r = requests.get(meta["poster_url"], timeout=30)
|
|
140
|
+
if r.status_code == 200 and r.content:
|
|
141
|
+
(out_dir / "poster.jpg").write_bytes(r.content)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__": # quick manual test: python3 discstation_meta.py "The Matrix 1999"
|
|
147
|
+
import sys
|
|
148
|
+
print("available:", available())
|
|
149
|
+
if len(sys.argv) > 1:
|
|
150
|
+
print(json.dumps(lookup(" ".join(sys.argv[1:])), indent=2))
|