Open-source X Spaces transcription.

Star

142 lines ยท 4.1 KB

python
1"""Download a recorded X Space without remuxing.
2
3KEEP the mpegts/m4a. Do not remux โ€” remux strips Hydra ID3 tags.
4yt-dlp follows tweet โ†’ Space replay by itself.
5"""
6
7from __future__ import annotations
8
9import re
10import shutil
11import subprocess
12from pathlib import Path
13from urllib.parse import urlparse
14
15SPACES_RE = re.compile(
16 r"(?:https?://)?(?:www\.)?(?:x|twitter)\.com/i/spaces/([A-Za-z0-9]+)",
17 re.I,
18)
19STATUS_RE = re.compile(
20 r"(?:https?://)?(?:www\.)?(?:x|twitter)\.com/([A-Za-z0-9_]+)/status/(\d+)",
21 re.I,
22)
23
24
25class DownloadError(RuntimeError):
26 """yt-dlp found no media, or the binary is missing."""
27
28
29def parse_url(url: str) -> dict:
30 """Accept spaces links, status links, twitter.com equivalents."""
31 raw = url.strip()
32 if not raw:
33 raise DownloadError("Empty URL.")
34 spaces = SPACES_RE.search(raw)
35 if spaces:
36 space_id = spaces.group(1)
37 return {
38 "kind": "space",
39 "url": f"https://x.com/i/spaces/{space_id}",
40 "space_id": space_id,
41 "status_id": None,
42 "host": None,
43 }
44 status = STATUS_RE.search(raw)
45 if status:
46 handle, status_id = status.group(1), status.group(2)
47 host = None if handle.lower() == "i" else handle
48 return {
49 "kind": "status",
50 "url": f"https://x.com/{handle}/status/{status_id}",
51 "space_id": None,
52 "status_id": status_id,
53 "host": host,
54 }
55 parsed = urlparse(raw if "://" in raw else f"https://{raw}")
56 host = (parsed.hostname or "").lower()
57 if host in {"x.com", "www.x.com", "twitter.com", "www.twitter.com", "mobile.twitter.com"}:
58 return {
59 "kind": "url",
60 "url": raw if "://" in raw else f"https://{raw}",
61 "space_id": None,
62 "status_id": None,
63 "host": None,
64 }
65 raise DownloadError(
66 "Not an X Spaces or status URL. "
67 "Expected x.com/i/spaces/<id> or x.com/<user>/status/<id>."
68 )
69
70
71def download(url: str, out_dir: str | Path) -> Path:
72 """Fetch the replay with yt-dlp. Refuses remux. Returns the raw file."""
73 ytdlp = shutil.which("yt-dlp")
74 if not ytdlp:
75 raise DownloadError(
76 "yt-dlp is not on PATH. Install it: pip install yt-dlp"
77 )
78 dest = Path(out_dir)
79 dest.mkdir(parents=True, exist_ok=True)
80 pattern = str(dest / "replay.%(ext)s")
81 cmd = [
82 ytdlp,
83 "-f",
84 "bestaudio/best",
85 "--hls-use-mpegts",
86 "--no-part",
87 "-o",
88 pattern,
89 url,
90 ]
91 try:
92 proc = subprocess.run(
93 cmd,
94 check=False,
95 capture_output=True,
96 text=True,
97 )
98 except FileNotFoundError as exc:
99 raise DownloadError("yt-dlp is not on PATH.") from exc
100 if proc.returncode != 0:
101 err = (proc.stderr or proc.stdout or "").strip() or "yt-dlp failed"
102 if _no_media(err):
103 raise DownloadError(
104 "No media. The Space was not recorded, or the replay expired. "
105 "space-tape does not join live Spaces."
106 )
107 raise DownloadError(err.splitlines()[-1][:400])
108 found = _find_replay(dest)
109 if found is None:
110 raise DownloadError(
111 "No media. The Space was not recorded, or the replay expired. "
112 "space-tape does not join live Spaces."
113 )
114 return found
115
116
117def _no_media(err: str) -> bool:
118 lowered = err.lower()
119 needles = (
120 "no video",
121 "no media",
122 "requested format is not available",
123 "does not exist",
124 "unavailable",
125 "private",
126 )
127 return any(n in lowered for n in needles)
128
129
130def _find_replay(dest: Path) -> Path | None:
131 candidates = sorted(
132 [p for p in dest.glob("replay.*") if p.is_file() and p.stat().st_size > 0],
133 key=lambda p: p.stat().st_mtime,
134 reverse=True,
135 )
136 skip = {".json", ".info", ".description", ".jpg", ".png", ".webp"}
137 for path in candidates:
138 if path.suffix.lower() in skip:
139 continue
140 return path
141 return None