ttyplayer 0.3.0__py3-none-any.whl

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.
ttyplayer/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from ttyplayer!")
ttyplayer/cli.py ADDED
@@ -0,0 +1,343 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ import time
7
+ from importlib import metadata
8
+
9
+ import typer
10
+
11
+ from ttyplayer import control, favorites, history, player
12
+ from ttyplayer.utils import APP_NAME, data_path, format_time, parse_picks, unseen
13
+
14
+ app = typer.Typer()
15
+
16
+ # How to install mpv, per sys.platform prefix; the README's Install section and the
17
+ # install.sh / install.ps1 scripts repeat these, and tests keep all three in step.
18
+ MPV_INSTALL = {
19
+ "darwin": ["brew install mpv"],
20
+ "linux": [
21
+ "sudo apt-get install -y mpv",
22
+ "sudo dnf install -y mpv",
23
+ "sudo pacman -S --noconfirm mpv",
24
+ "sudo zypper install -y mpv",
25
+ "sudo apk add mpv",
26
+ ],
27
+ "win32": ["winget install -e --id shinchiro.mpv", "scoop install mpv", "choco install mpv"],
28
+ }
29
+
30
+
31
+ @app.callback()
32
+ def callback():
33
+ """ttyplayer is a terminal YouTube player"""
34
+
35
+
36
+ @app.command()
37
+ def version():
38
+ """Print the version"""
39
+ typer.echo(f"{APP_NAME} {metadata.version(APP_NAME)}")
40
+
41
+
42
+ @app.command()
43
+ def doctor():
44
+ """Check that ttyplayer has everything it needs to play"""
45
+ passed = True
46
+ for ok, text in doctor_checks():
47
+ typer.echo(f"{'✓' if ok else '✗'} {text}")
48
+ passed = passed and ok
49
+ if not passed:
50
+ raise typer.Exit(code=1)
51
+
52
+
53
+ def doctor_checks():
54
+ """(ok, text) per check; nothing here touches the network."""
55
+ python = ".".join(map(str, sys.version_info[:3]))
56
+ yield sys.version_info >= (3, 13), f"Python {python}"
57
+ yield True, f"{APP_NAME} {metadata.version(APP_NAME)}"
58
+ yield check_yt_dlp()
59
+ yield check_mpv()
60
+ yield check_data_dir()
61
+ yield check_control_dir()
62
+
63
+
64
+ def check_yt_dlp():
65
+ try:
66
+ from yt_dlp.version import __version__
67
+ except ImportError as error:
68
+ return False, f"yt-dlp cannot be imported: {error}"
69
+ return True, f"yt-dlp {__version__}"
70
+
71
+
72
+ def check_mpv():
73
+ path = shutil.which("mpv")
74
+ if path is None:
75
+ return False, f"mpv not found on PATH. {mpv_install_hint()}"
76
+ try:
77
+ return True, mpv_version(path)
78
+ except (OSError, subprocess.SubprocessError, IndexError) as error:
79
+ return False, f"mpv at {path} did not answer --version: {error}"
80
+
81
+
82
+ def mpv_version(path):
83
+ """The first line `mpv --version` prints, e.g. mpv v0.39.0 Copyright ..."""
84
+ result = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=5, check=True)
85
+ return result.stdout.splitlines()[0]
86
+
87
+
88
+ def mpv_install_hint(platform=sys.platform):
89
+ """One line telling the user how to install mpv on this platform."""
90
+ for prefix, (command, *others) in MPV_INSTALL.items():
91
+ if platform.startswith(prefix):
92
+ alternatives = f" (or: {', '.join(others)})" if others else ""
93
+ return f"Install it with: {command}{alternatives}"
94
+ return "Install it from https://mpv.io/installation/"
95
+
96
+
97
+ def check_data_dir():
98
+ directory = data_path("")
99
+ try:
100
+ os.makedirs(directory, exist_ok=True)
101
+ tempfile.TemporaryFile(dir=directory).close()
102
+ except OSError as error:
103
+ return False, f"data dir {directory} is not writable: {error}"
104
+ return True, f"data dir {directory}"
105
+
106
+
107
+ def check_control_dir():
108
+ endpoint = control.control_endpoint()
109
+ directory = os.path.dirname(endpoint.path)
110
+ try:
111
+ endpoint.prepare()
112
+ except OSError as error:
113
+ return False, f"control socket dir: {error}"
114
+ return True, f"control socket dir {directory}"
115
+
116
+
117
+ @app.command()
118
+ def play(target: list[str], video: bool = False, limit: int = 5):
119
+ """Play a YouTube link or playlist, or search and pick what to play.
120
+
121
+ Keys while playing: space pause, left/right or , . seek, up/down volume,
122
+ n next, p previous, q quit.
123
+ """
124
+ start_playback(resolve(target, limit), video)
125
+
126
+
127
+ @app.command()
128
+ def search(query: list[str], limit: int = 5):
129
+ """Search YouTube and list the results"""
130
+ from ttyplayer import youtube # yt-dlp loads on use, so doctor runs without it
131
+
132
+ videos = lookup(youtube.search, " ".join(query), limit)
133
+ exit_if_empty(videos)
134
+ print_videos(videos)
135
+
136
+
137
+ @app.command(name="history")
138
+ def history_command(limit: int = 20, play: bool = False, video: bool = False, clear: bool = False):
139
+ """List recently played videos; --play to pick some and play them again, --clear to forget"""
140
+ if clear:
141
+ typer.echo(f"Forgot {history.clear()} plays")
142
+ return
143
+ videos = history.load(limit=limit)
144
+ if not videos:
145
+ fail("Nothing played yet")
146
+ if play:
147
+ start_playback(pick_from(videos), video)
148
+ else:
149
+ print_videos(videos)
150
+
151
+
152
+ @app.command()
153
+ def favorite(target: list[str], limit: int = 5):
154
+ """Add a YouTube link or playlist to favorites, or search and pick what to add"""
155
+ videos = resolve(target, limit)
156
+ exit_if_empty(videos)
157
+ for video in videos:
158
+ if favorites.add(video):
159
+ typer.echo(f"Favorited: {video.title}")
160
+ else:
161
+ typer.echo(f"Already a favorite: {video.title}")
162
+
163
+
164
+ @app.command(name="favorites")
165
+ def favorites_command(
166
+ play: bool = False, video: bool = False, remove: int | None = None, clear: bool = False
167
+ ):
168
+ """List favorites; --play to pick some and play them, --remove N to drop one, --clear to forget all"""
169
+ if clear:
170
+ typer.echo(f"Forgot {favorites.clear()} favorites")
171
+ return
172
+ if remove is not None:
173
+ removed = favorites.remove(remove)
174
+ if removed is None:
175
+ fail(f"No favorite number {remove}")
176
+ typer.echo(f"Removed: {removed.title}")
177
+ return
178
+ videos = favorites.load()
179
+ if not videos:
180
+ fail("No favorites yet")
181
+ if play:
182
+ start_playback(pick_from(videos), video)
183
+ else:
184
+ print_videos(videos)
185
+
186
+
187
+ @app.command()
188
+ def tui(video: bool = False):
189
+ """Open the full-screen player: search, pick and play in one screen.
190
+
191
+ Keys: / search, Enter play, a add to queue, f favorite, m more results,
192
+ space pause, , . seek 5s, < > seek 30s, - + volume, M mute, n next,
193
+ p previous, 1-4 tabs, t theme, ? help, Ctrl-P commands, q quit.
194
+ """
195
+ from ttyplayer import tui as screen # Textual loads only for this command
196
+
197
+ screen.TtyplayerApp(client_factory=player.MpvClient, resolve=screen.resolve, video=video).run()
198
+
199
+
200
+ @app.command()
201
+ def pause():
202
+ """Pause or resume the ttyplayer playing in another terminal"""
203
+ remote("pause")
204
+
205
+
206
+ @app.command(name="next")
207
+ def next_command():
208
+ """Skip to the next video in the playing ttyplayer's queue"""
209
+ remote("next")
210
+
211
+
212
+ @app.command()
213
+ def prev():
214
+ """Go back to the previous video in the playing ttyplayer's queue"""
215
+ remote("prev")
216
+
217
+
218
+ @app.command()
219
+ def stop():
220
+ """Stop the ttyplayer playing in another terminal"""
221
+ remote("stop")
222
+
223
+
224
+ @app.command()
225
+ def status():
226
+ """Show what the playing ttyplayer is playing"""
227
+ typer.echo(player.status_line(remote("status")))
228
+
229
+
230
+ def remote(name):
231
+ """Send name to the playing ttyplayer; its reply, or a one-line message and exit 1."""
232
+ try:
233
+ reply = control.send(name)
234
+ except control.ControlError:
235
+ fail("No ttyplayer is playing")
236
+ if not reply.get("ok"):
237
+ fail(reply.get("error", "The player refused"))
238
+ return reply
239
+
240
+
241
+ def resolve(target, limit):
242
+ """Turn a link into its videos, or search words into the videos the user picks."""
243
+ from ttyplayer import youtube
244
+
245
+ target_text = " ".join(target)
246
+ if youtube.is_url(target_text):
247
+ return lookup(youtube.fetch, target_text)
248
+
249
+ def more(shown):
250
+ return unseen(lookup(youtube.search, target_text, len(shown) + limit), shown)
251
+
252
+ return pick_from(lookup(youtube.search, target_text, limit), more)
253
+
254
+
255
+ def pick_from(videos, more=None):
256
+ """Show a numbered list and return the videos the user picked, in their order.
257
+
258
+ With more, the user can type m to list the next batch more(videos) returns.
259
+ """
260
+ exit_if_empty(videos)
261
+ videos = list(videos)
262
+ print_videos(videos)
263
+ return [videos[pick - 1] for pick in ask_for_picks(videos, more)]
264
+
265
+
266
+ def ask_for_picks(videos, more=None):
267
+ """Prompt until the answer is valid picks; m appends more's batch to videos and lists it."""
268
+ prompt = "Pick one or more numbers (1 or 1 3 5)"
269
+ if more:
270
+ prompt += ", m for more"
271
+ while True:
272
+ answer = typer.prompt(prompt)
273
+ if more and answer.strip().lower() == "m":
274
+ show_more(videos, more)
275
+ continue
276
+ picks = parse_picks(answer, len(videos))
277
+ if picks is not None:
278
+ return picks
279
+ typer.echo(f"Enter numbers between 1 and {len(videos)}, separated by spaces.", err=True)
280
+
281
+
282
+ def show_more(videos, more):
283
+ fresh = more(videos)
284
+ if not fresh:
285
+ typer.echo("No more results", err=True)
286
+ return
287
+ print_videos(fresh, start=len(videos) + 1)
288
+ videos.extend(fresh)
289
+
290
+
291
+ def start_playback(videos, with_video):
292
+ exit_if_empty(videos)
293
+ try:
294
+ client = player.MpvClient(with_video, on_play=history.record)
295
+ except FileNotFoundError:
296
+ fail(f"mpv is not installed. {mpv_install_hint()}")
297
+ except RuntimeError as error:
298
+ fail(str(error))
299
+ for video in videos:
300
+ client.add(video)
301
+ client.play_current()
302
+ client.run()
303
+
304
+
305
+ def print_videos(videos, start=1):
306
+ for number, video in enumerate(videos, start=start):
307
+ typer.echo(f"{number:>2}. {video.title} ({format_time(video.duration)}) {video.uploader}")
308
+
309
+
310
+ def lookup(func, *args):
311
+ """Run a youtube.* call, turning its errors into a one-line message and exit 1."""
312
+ from ttyplayer import youtube
313
+
314
+ began = time.monotonic()
315
+ try:
316
+ videos = func(*args)
317
+ except youtube.YouTubeError as error:
318
+ fail(f"YouTube lookup failed: {error}")
319
+ if player.timing():
320
+ typer.echo(f"lookup took {time.monotonic() - began:.1f}s", err=True)
321
+ return videos
322
+
323
+
324
+ def exit_if_empty(videos):
325
+ if not videos:
326
+ fail("No videos found")
327
+
328
+
329
+ def fail(message):
330
+ typer.echo(message, err=True)
331
+ raise typer.Exit(code=1)
332
+
333
+
334
+ def utf8_streams():
335
+ """Pipes and redirects on Windows default to a legacy code page; the ticks and meters need UTF-8."""
336
+ for stream in (sys.stdout, sys.stderr):
337
+ if stream.encoding.lower().replace("-", "") != "utf8":
338
+ stream.reconfigure(encoding="utf-8", errors="replace")
339
+
340
+
341
+ def main():
342
+ utf8_streams()
343
+ app()
ttyplayer/control.py ADDED
@@ -0,0 +1,244 @@
1
+ import hmac
2
+ import json
3
+ import os
4
+ import secrets
5
+ import socket
6
+ import stat
7
+ import sys
8
+ import tempfile
9
+ import threading
10
+
11
+ from ttyplayer.utils import APP_NAME, WINDOWS, data_path
12
+
13
+ CLIENT_TIMEOUT = 3 # seconds to wait for the player's reply
14
+ CONNECTION_TIMEOUT = 1 # seconds the server waits for one request line
15
+ ACCEPT_TIMEOUT = 0.2 # how often the server loop checks whether it was stopped
16
+
17
+
18
+ class ControlError(Exception):
19
+ """No player answered on the control socket."""
20
+
21
+
22
+ def control_path():
23
+ """$XDG_RUNTIME_DIR/ttyplayer/control.sock, or a per-user dir under the temp dir."""
24
+ runtime = os.environ.get("XDG_RUNTIME_DIR")
25
+ if runtime:
26
+ return os.path.join(runtime, APP_NAME, "control.sock")
27
+ return os.path.join(tempfile.gettempdir(), f"{APP_NAME}-{os.getuid()}", "control.sock")
28
+
29
+
30
+ def control_endpoint(path=None):
31
+ """Where the player listens and clients connect: a Unix socket, or loopback TCP plus a token on Windows."""
32
+ return LoopbackEndpoint(path) if WINDOWS else SocketEndpoint(path)
33
+
34
+
35
+ class SocketEndpoint:
36
+ """The control socket at path (control_path() by default), in a dir only this user can enter."""
37
+
38
+ def __init__(self, path=None):
39
+ self.path = path or control_path()
40
+ self.inode = None
41
+
42
+ def prepare(self):
43
+ private_dir(os.path.dirname(self.path))
44
+
45
+ def listen(self):
46
+ self.prepare()
47
+ if os.path.lexists(self.path):
48
+ os.unlink(self.path) # left behind by a player that crashed
49
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
50
+ try:
51
+ sock.bind(self.path)
52
+ sock.listen()
53
+ except OSError:
54
+ sock.close()
55
+ raise
56
+ # A newer player may replace the file; remove() must not remove that one.
57
+ self.inode = os.stat(self.path).st_ino
58
+ return sock
59
+
60
+ def connect(self):
61
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
62
+ sock.settimeout(CLIENT_TIMEOUT)
63
+ try:
64
+ sock.connect(self.path)
65
+ except OSError:
66
+ sock.close()
67
+ raise
68
+ return sock
69
+
70
+ def request(self, name):
71
+ return {"command": name}
72
+
73
+ def authorize(self, request):
74
+ return True # the dir's permissions already kept other users out
75
+
76
+ def remove(self):
77
+ try:
78
+ if os.stat(self.path).st_ino == self.inode:
79
+ os.unlink(self.path)
80
+ except FileNotFoundError:
81
+ pass
82
+
83
+
84
+ class LoopbackEndpoint:
85
+ """127.0.0.1 on an ephemeral port; "<port> <token>" in path (%LOCALAPPDATA%\\ttyplayer\\control.txt).
86
+
87
+ Any local process can reach the port, so every request must carry the token,
88
+ which only processes that can read the user's profile can know.
89
+ """
90
+
91
+ def __init__(self, path=None):
92
+ self.path = path or str(data_path("control.txt"))
93
+ self.token = None
94
+ self.written = None
95
+
96
+ def prepare(self):
97
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
98
+
99
+ def listen(self):
100
+ self.prepare()
101
+ sock = socket.create_server(("127.0.0.1", 0))
102
+ self.token = secrets.token_hex(16)
103
+ self.written = f"{sock.getsockname()[1]} {self.token}"
104
+ try:
105
+ with open(self.path, "w") as file:
106
+ file.write(self.written)
107
+ except OSError:
108
+ sock.close()
109
+ raise
110
+ return sock
111
+
112
+ def connect(self):
113
+ with open(self.path) as file:
114
+ text = file.read()
115
+ try:
116
+ port, self.token = text.split()
117
+ address = ("127.0.0.1", int(port))
118
+ except ValueError:
119
+ raise OSError(f"{self.path} does not hold a port and a token") from None
120
+ return socket.create_connection(address, timeout=CLIENT_TIMEOUT)
121
+
122
+ def request(self, name):
123
+ return {"command": name, "token": self.token}
124
+
125
+ def authorize(self, request):
126
+ token = request.get("token")
127
+ if not isinstance(token, str):
128
+ return False
129
+ # As bytes: compare_digest refuses non-ASCII str, and JSON can carry lone surrogates.
130
+ return hmac.compare_digest(token.encode("utf-8", "surrogatepass"), self.token.encode())
131
+
132
+ def remove(self):
133
+ try:
134
+ with open(self.path) as file:
135
+ if file.read() != self.written:
136
+ return # a newer player wrote its own
137
+ os.unlink(self.path)
138
+ except FileNotFoundError:
139
+ pass
140
+
141
+
142
+ def ok(**fields):
143
+ return {"ok": True, **fields}
144
+
145
+
146
+ def failure(message):
147
+ return {"ok": False, "error": message}
148
+
149
+
150
+ # --- client -------------------------------------------------------------
151
+
152
+
153
+ def send(name, path=None):
154
+ """Send one command to the running player and return its reply."""
155
+ endpoint = control_endpoint(path)
156
+ try:
157
+ with endpoint.connect() as sock, sock.makefile("rw") as stream:
158
+ stream.write(json.dumps(endpoint.request(name)) + "\n")
159
+ stream.flush()
160
+ text = stream.readline()
161
+ except OSError as error: # no socket file, refused, or no reply in time
162
+ raise ControlError(str(error)) from error
163
+ if not text:
164
+ raise ControlError("the player closed the connection")
165
+ return json.loads(text)
166
+
167
+
168
+ # --- server -------------------------------------------------------------
169
+
170
+
171
+ def private_dir(directory):
172
+ """Create directory 0o700, then refuse it unless it is a real dir only this user can enter."""
173
+ os.makedirs(directory, mode=0o700, exist_ok=True)
174
+ info = os.lstat(directory) # lstat: a symlink could point anywhere
175
+ if not stat.S_ISDIR(info.st_mode):
176
+ raise OSError(f"{directory} is not a directory")
177
+ if info.st_uid != os.getuid():
178
+ raise OSError(f"{directory} belongs to another user")
179
+ if stat.S_IMODE(info.st_mode) & 0o077:
180
+ raise OSError(f"{directory} is open to other users")
181
+
182
+
183
+ def reply_to(line, handler, authorize):
184
+ """The reply to one request line; a malformed or unauthorized line gets ok: false, never an exception."""
185
+ try:
186
+ request = json.loads(line)
187
+ name = request["command"]
188
+ except (ValueError, TypeError, KeyError):
189
+ return failure("malformed request")
190
+ if not isinstance(name, str):
191
+ return failure("malformed request")
192
+ if not authorize(request):
193
+ return failure("bad token")
194
+ return handler(name)
195
+
196
+
197
+ class Server:
198
+ """Answers one request per connection on a daemon thread with handler(name) -> dict."""
199
+
200
+ def __init__(self, handler, path=None):
201
+ self.handler = handler
202
+ self.endpoint = control_endpoint(path)
203
+ self.path = self.endpoint.path
204
+ self.sock = self.endpoint.listen()
205
+ self.sock.settimeout(ACCEPT_TIMEOUT)
206
+ self.stopped = threading.Event()
207
+ self.thread = threading.Thread(target=self.serve, daemon=True)
208
+ self.thread.start()
209
+
210
+ def serve(self):
211
+ while not self.stopped.is_set():
212
+ try:
213
+ conn, _ = self.sock.accept()
214
+ except TimeoutError:
215
+ continue
216
+ except OSError:
217
+ break
218
+ with conn:
219
+ self.answer(conn)
220
+
221
+ def answer(self, conn):
222
+ conn.settimeout(CONNECTION_TIMEOUT)
223
+ try:
224
+ # Undecodable bytes become U+FFFD, so they fail as malformed JSON, not here.
225
+ with conn.makefile("rw", encoding="utf-8", errors="replace") as stream:
226
+ reply = reply_to(stream.readline(), self.handler, self.endpoint.authorize)
227
+ stream.write(json.dumps(reply) + "\n")
228
+ except OSError:
229
+ pass # the client went away; wait for the next one
230
+
231
+ def stop(self):
232
+ self.stopped.set()
233
+ self.thread.join(timeout=1)
234
+ self.sock.close()
235
+ self.endpoint.remove()
236
+
237
+
238
+ def serve(handler, path=None):
239
+ """Start a Server, or warn on stderr and return None: playing never depends on it."""
240
+ try:
241
+ return Server(handler, path)
242
+ except OSError as error:
243
+ print(f"Remote control is off: {error}", file=sys.stderr)
244
+ return None
ttyplayer/favorites.py ADDED
@@ -0,0 +1,90 @@
1
+ """Videos the user chose to keep, one JSON object per line, newest at the bottom."""
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from ttyplayer.models import Video
8
+ from ttyplayer.utils import data_path, video_from_info
9
+
10
+
11
+ def favorites_path() -> Path:
12
+ return data_path("favorites.jsonl")
13
+
14
+
15
+ def add(video: Video, path: Path | None = None) -> bool:
16
+ """Favorite a video. Returns False if it already was one."""
17
+ path = path or favorites_path()
18
+ if any(entry["id"] == video.id for entry in _entries(path)):
19
+ return False
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+ entry = {
22
+ "id": video.id,
23
+ "title": video.title,
24
+ "uploader": video.uploader,
25
+ "duration": video.duration,
26
+ "favorited_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
27
+ }
28
+ with path.open("a", encoding="utf-8") as f:
29
+ f.write(json.dumps(entry) + "\n")
30
+ return True
31
+
32
+
33
+ def remove(number: int, path: Path | None = None) -> Video | None:
34
+ """Drop the `number`th favorite as load() lists it (1-indexed). None if out of range."""
35
+ path = path or favorites_path()
36
+ videos = load(path)
37
+ if not 1 <= number <= len(videos):
38
+ return None
39
+ removed = videos[number - 1]
40
+ remove_id(removed.id, path)
41
+ return removed
42
+
43
+
44
+ def remove_id(video_id: str, path: Path | None = None) -> bool:
45
+ """Drop the favorite with this id. Returns False if it was not one."""
46
+ path = path or favorites_path()
47
+ entries = _entries(path)
48
+ kept = [entry for entry in reversed(entries) if entry["id"] != video_id]
49
+ if len(kept) == len(entries):
50
+ return False
51
+ path.write_text("".join(json.dumps(entry) + "\n" for entry in kept), encoding="utf-8")
52
+ return True
53
+
54
+
55
+ def ids(path: Path | None = None) -> set[str]:
56
+ """The id of every favorite."""
57
+ return {entry["id"] for entry in _entries(path or favorites_path())}
58
+
59
+
60
+ def clear(path: Path | None = None) -> int:
61
+ """Forget every favorite. Returns how many there were."""
62
+ path = path or favorites_path()
63
+ count = len(load(path))
64
+ path.unlink(missing_ok=True)
65
+ return count
66
+
67
+
68
+ def load(path: Path | None = None, limit: int | None = None) -> list[Video]:
69
+ """Most recently added first, each video once, at most `limit` of them (all if None)."""
70
+ path = path or favorites_path()
71
+ videos = [video_from_info(entry) for entry in _entries(path)]
72
+ return videos[:limit]
73
+
74
+
75
+ def _entries(path: Path) -> list[dict]:
76
+ """Stored entries newest first, one per video id, skipping corrupt lines."""
77
+ if not path.exists():
78
+ return []
79
+ seen = set()
80
+ entries = []
81
+ for line in reversed(path.read_text(encoding="utf-8").splitlines()):
82
+ try:
83
+ entry = json.loads(line)
84
+ except json.JSONDecodeError:
85
+ continue
86
+ if not isinstance(entry, dict) or "id" not in entry or entry["id"] in seen:
87
+ continue
88
+ seen.add(entry["id"])
89
+ entries.append(entry)
90
+ return entries