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.
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
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))