ttyplayer 0.4.0__tar.gz → 0.5.0__tar.gz

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.
Files changed (25) hide show
  1. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/PKG-INFO +60 -1
  2. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/README.md +57 -0
  3. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/pyproject.toml +3 -1
  4. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/pyproject.toml.orig +3 -1
  5. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/cli.py +80 -9
  6. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/player.py +17 -1
  7. ttyplayer-0.5.0/src/ttyplayer/remote.py +299 -0
  8. ttyplayer-0.5.0/src/ttyplayer/server.py +456 -0
  9. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/settings.py +9 -0
  10. ttyplayer-0.5.0/src/ttyplayer/static/icon.svg +6 -0
  11. ttyplayer-0.5.0/src/ttyplayer/static/index.html +74 -0
  12. ttyplayer-0.5.0/src/ttyplayer/static/manifest.webmanifest +14 -0
  13. ttyplayer-0.5.0/src/ttyplayer/static/remote.css +202 -0
  14. ttyplayer-0.5.0/src/ttyplayer/static/remote.js +398 -0
  15. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/tui.py +7 -0
  16. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/utils.py +12 -2
  17. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/LICENSE +0 -0
  18. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/__init__.py +0 -0
  19. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/control.py +0 -0
  20. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/favorites.py +0 -0
  21. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/history.py +0 -0
  22. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/models.py +0 -0
  23. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/playlists.py +0 -0
  24. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/tui.tcss +0 -0
  25. {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/youtube.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ttyplayer
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: ttyplayer: a modern YouTube player for the terminal — search, queue, and play audio or video via mpv
5
5
  Keywords: youtube,music,player,mpv,cli,terminal,tui,yt-dlp
6
6
  License-Expression: MIT
@@ -13,6 +13,8 @@ Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Topic :: Multimedia :: Sound/Audio :: Players
16
+ Requires-Dist: aiohttp>=3.10
17
+ Requires-Dist: qrcode>=8
16
18
  Requires-Dist: textual>=8.2
17
19
  Requires-Dist: typer>=0.27.2
18
20
  Requires-Dist: yt-dlp>=2026.8.19
@@ -45,11 +47,14 @@ ttyplayer favorites --remove 2 drop the second favorite as listed
45
47
  ttyplayer favorites --clear forget all favorites
46
48
  ttyplayer playlist ... your own named playlists (see Playlists below)
47
49
  ttyplayer tui [--video] full-screen: search box, results list, now-playing bar
50
+ ttyplayer serve [--host] [--port] play headless, driven over HTTP from any device (see Server below)
48
51
  ttyplayer config list the settings (see Settings below)
49
52
  ttyplayer doctor check Python, yt-dlp, mpv and ttyplayer's folders
50
53
  ttyplayer version
51
54
  ```
52
55
 
56
+ Channels and playlists in search results are skipped; a track that cannot be played is reported and skipped.
57
+
53
58
  While ttyplayer plays in one terminal, any other terminal can drive it:
54
59
 
55
60
  ```
@@ -148,6 +153,10 @@ ttyplayer keeps its preferences in `~/.config/ttyplayer/settings.toml` (`$XDG_CO
148
153
  | `show_clock` | `true` | the clock in the TUI's header |
149
154
  | `theme` | `textual-dark` | the TUI's color theme; `t` in the TUI picks the next one and saves it |
150
155
  | `search_limit` | `10` | how many results a TUI search fetches, and `m` adds (1–50) |
156
+ | `server_host` | `127.0.0.1` | the address `ttyplayer serve` listens on; `0.0.0.0` opens it to the network |
157
+ | `server_port` | `7700` | the port `ttyplayer serve` listens on (1–65535) |
158
+ | `server_token` | *generated* | the token every API request needs; `ttyplayer serve` makes one on first use |
159
+ | `remote_url` | *none* | the server `ttyplayer tui` drives instead of playing itself, e.g. `http://host:7700` |
151
160
 
152
161
  ```
153
162
  ttyplayer config every setting, (default) when unchanged
@@ -158,6 +167,56 @@ ttyplayer config path where the file is
158
167
 
159
168
  In the TUI, `S` (or Settings… in Ctrl-P) lists the settings: Enter on a true / false one flips it and saves it (the clock shows or hides at once); the others are set with `ttyplayer config set`.
160
169
 
170
+ ## Server
171
+
172
+ `ttyplayer serve` runs the player headless on the machine with the speakers (no keyboard, no TUI) and lets any device on the network drive it through a small HTTP + WebSocket API. The other terminals' `ttyplayer pause`, `next`, `status` and `stop` keep working too.
173
+
174
+ ```
175
+ ttyplayer serve listen on 127.0.0.1:7700 (this machine only)
176
+ ttyplayer serve --host 0.0.0.0 listen on every address, so a phone on the LAN can reach it
177
+ ttyplayer serve --port 8000 another port
178
+ ```
179
+
180
+ It prints the address to open, with the token, and a QR code of it for a phone:
181
+
182
+ ```
183
+ Serving ttyplayer at http://192.168.1.20:7700/?token=…
184
+ ```
185
+
186
+ Open that address on a phone and it is a remote: what plays (with a moving progress bar), previous / play-pause / next, a volume slider and mute, a search box (a pasted link plays at once; each result has **Play** and **Queue**), the queue (tap a row to jump there, **×** removes it, **Clear** keeps only what plays), your favorites (the heart on any row adds or drops one) and your playlists. It follows the phone's dark or light mode, reconnects by itself when the server restarts, and "Add to Home Screen" makes it an app icon. The page keeps the token only for that browser tab; opened without one (from the home screen, say) it asks you to paste the token `serve` printed. Ctrl-C (or `ttyplayer stop`, or a `stop` command) stops the server and the player.
187
+
188
+ Every `/api/…` request and the socket need the token, as `Authorization: Bearer <token>` or `?token=<token>`; without it the reply is `401 {"error": "unauthorized"}`. Replies are JSON; errors are `{"error": "…"}`.
189
+
190
+ ```
191
+ curl -H "Authorization: Bearer $TOKEN" http://host:7700/api/status
192
+ curl -H "Authorization: Bearer $TOKEN" -d '{"query": "lofi beats"}' http://host:7700/api/play
193
+ curl -H "Authorization: Bearer $TOKEN" -d '{"name": "volume", "value": -5}' http://host:7700/api/command
194
+ ```
195
+
196
+ | Method | Path | Body / reply |
197
+ |---|---|---|
198
+ | GET | `/api/status` | the player's status, plus `queue` (the videos) and `index` (1-based) |
199
+ | POST | `/api/play` | `{"url": "…"}` or `{"query": "…"}`: the link's videos, or the first search result, replace the queue and play |
200
+ | POST | `/api/queue` | `{"url": "…"}` or `{"query": "…"}`: appended to the queue (played at once when nothing plays) |
201
+ | POST | `/api/command` | `{"name": "pause"\|"next"\|"prev"\|"stop"\|"mute"\|"seek"\|"volume"\|"jump"\|"remove"\|"move"\|"clear_others", "value"?}`; `seek` and `volume` take a number of seconds / steps, `jump` and `remove` a 0-based queue row, `move` two (`[from, to]`; the current track stays current); `clear_others` keeps only the current track → the new status |
202
+ | GET | `/api/commands` | the command names `/api/command` takes |
203
+ | GET | `/api/search?q=…` | the search results (`search_limit` of them) |
204
+ | GET | `/api/favorites` | the favorites, newest first |
205
+ | POST | `/api/favorites/<id>` | unfavorites that video, or favorites it (the body is the video: `{"title", "uploader", "duration"}`) → the favorites |
206
+ | GET | `/api/playlists` | `[{"name": …, "count": …}]` |
207
+ | POST | `/api/playlists/<name>/play` | that playlist becomes the queue |
208
+ | GET, PATCH | `/api/settings` | every setting but the token; PATCH `{"key": value}` changes and saves them, checked like `config set` |
209
+ | WS | `/ws?token=…` | sends the status on connect and on every change; takes the same `{"name", "value"?}` commands as `/api/command` and answers each with the status |
210
+ | GET | `/` | the web remote (needs no token itself; it reads the token from its address); its files are under `/static/`, plus `/manifest.webmanifest` |
211
+
212
+ ### The TUI as a remote
213
+
214
+ `ttyplayer tui --remote http://host:7700` opens the same screen, with the same keys and tabs, on another machine (a laptop, say) while the server plays: Enter on a result makes the *server's* speakers play, and the now-playing panel and the Queue tab follow the server over its socket (the header says `remote: host:7700`). The token is the `server_token` setting, so copy it into the laptop's settings with `ttyplayer config set server_token <token>`; `--token <token>` works too, but leaves it in your shell history. `ttyplayer config set remote_url http://host:7700` makes the remote the default, so a plain `ttyplayer tui` drives it.
215
+
216
+ Searching still happens on the laptop; the server looks each picked video up again before it plays or queues it, so a long queue fills in over a few seconds. History stays the server's: the laptop records nothing. When the server cannot be reached the TUI says so once and keeps trying; a refused token says `Server rejected the token`.
217
+
218
+ Security: the token is the only gate, and plain HTTP carries it in clear text. That is fine on a home network you trust; it is why `serve` listens on 127.0.0.1 unless told otherwise. To reach it beyond your LAN (a VPS, say), put it behind a reverse proxy with HTTPS. Anyone with the token can control the player, including stopping it; change the token with `ttyplayer config set server_token <new>` and restart.
219
+
161
220
  ## Install
162
221
 
163
222
  One command installs everything ttyplayer needs.
@@ -22,11 +22,14 @@ ttyplayer favorites --remove 2 drop the second favorite as listed
22
22
  ttyplayer favorites --clear forget all favorites
23
23
  ttyplayer playlist ... your own named playlists (see Playlists below)
24
24
  ttyplayer tui [--video] full-screen: search box, results list, now-playing bar
25
+ ttyplayer serve [--host] [--port] play headless, driven over HTTP from any device (see Server below)
25
26
  ttyplayer config list the settings (see Settings below)
26
27
  ttyplayer doctor check Python, yt-dlp, mpv and ttyplayer's folders
27
28
  ttyplayer version
28
29
  ```
29
30
 
31
+ Channels and playlists in search results are skipped; a track that cannot be played is reported and skipped.
32
+
30
33
  While ttyplayer plays in one terminal, any other terminal can drive it:
31
34
 
32
35
  ```
@@ -125,6 +128,10 @@ ttyplayer keeps its preferences in `~/.config/ttyplayer/settings.toml` (`$XDG_CO
125
128
  | `show_clock` | `true` | the clock in the TUI's header |
126
129
  | `theme` | `textual-dark` | the TUI's color theme; `t` in the TUI picks the next one and saves it |
127
130
  | `search_limit` | `10` | how many results a TUI search fetches, and `m` adds (1–50) |
131
+ | `server_host` | `127.0.0.1` | the address `ttyplayer serve` listens on; `0.0.0.0` opens it to the network |
132
+ | `server_port` | `7700` | the port `ttyplayer serve` listens on (1–65535) |
133
+ | `server_token` | *generated* | the token every API request needs; `ttyplayer serve` makes one on first use |
134
+ | `remote_url` | *none* | the server `ttyplayer tui` drives instead of playing itself, e.g. `http://host:7700` |
128
135
 
129
136
  ```
130
137
  ttyplayer config every setting, (default) when unchanged
@@ -135,6 +142,56 @@ ttyplayer config path where the file is
135
142
 
136
143
  In the TUI, `S` (or Settings… in Ctrl-P) lists the settings: Enter on a true / false one flips it and saves it (the clock shows or hides at once); the others are set with `ttyplayer config set`.
137
144
 
145
+ ## Server
146
+
147
+ `ttyplayer serve` runs the player headless on the machine with the speakers (no keyboard, no TUI) and lets any device on the network drive it through a small HTTP + WebSocket API. The other terminals' `ttyplayer pause`, `next`, `status` and `stop` keep working too.
148
+
149
+ ```
150
+ ttyplayer serve listen on 127.0.0.1:7700 (this machine only)
151
+ ttyplayer serve --host 0.0.0.0 listen on every address, so a phone on the LAN can reach it
152
+ ttyplayer serve --port 8000 another port
153
+ ```
154
+
155
+ It prints the address to open, with the token, and a QR code of it for a phone:
156
+
157
+ ```
158
+ Serving ttyplayer at http://192.168.1.20:7700/?token=…
159
+ ```
160
+
161
+ Open that address on a phone and it is a remote: what plays (with a moving progress bar), previous / play-pause / next, a volume slider and mute, a search box (a pasted link plays at once; each result has **Play** and **Queue**), the queue (tap a row to jump there, **×** removes it, **Clear** keeps only what plays), your favorites (the heart on any row adds or drops one) and your playlists. It follows the phone's dark or light mode, reconnects by itself when the server restarts, and "Add to Home Screen" makes it an app icon. The page keeps the token only for that browser tab; opened without one (from the home screen, say) it asks you to paste the token `serve` printed. Ctrl-C (or `ttyplayer stop`, or a `stop` command) stops the server and the player.
162
+
163
+ Every `/api/…` request and the socket need the token, as `Authorization: Bearer <token>` or `?token=<token>`; without it the reply is `401 {"error": "unauthorized"}`. Replies are JSON; errors are `{"error": "…"}`.
164
+
165
+ ```
166
+ curl -H "Authorization: Bearer $TOKEN" http://host:7700/api/status
167
+ curl -H "Authorization: Bearer $TOKEN" -d '{"query": "lofi beats"}' http://host:7700/api/play
168
+ curl -H "Authorization: Bearer $TOKEN" -d '{"name": "volume", "value": -5}' http://host:7700/api/command
169
+ ```
170
+
171
+ | Method | Path | Body / reply |
172
+ |---|---|---|
173
+ | GET | `/api/status` | the player's status, plus `queue` (the videos) and `index` (1-based) |
174
+ | POST | `/api/play` | `{"url": "…"}` or `{"query": "…"}`: the link's videos, or the first search result, replace the queue and play |
175
+ | POST | `/api/queue` | `{"url": "…"}` or `{"query": "…"}`: appended to the queue (played at once when nothing plays) |
176
+ | POST | `/api/command` | `{"name": "pause"\|"next"\|"prev"\|"stop"\|"mute"\|"seek"\|"volume"\|"jump"\|"remove"\|"move"\|"clear_others", "value"?}`; `seek` and `volume` take a number of seconds / steps, `jump` and `remove` a 0-based queue row, `move` two (`[from, to]`; the current track stays current); `clear_others` keeps only the current track → the new status |
177
+ | GET | `/api/commands` | the command names `/api/command` takes |
178
+ | GET | `/api/search?q=…` | the search results (`search_limit` of them) |
179
+ | GET | `/api/favorites` | the favorites, newest first |
180
+ | POST | `/api/favorites/<id>` | unfavorites that video, or favorites it (the body is the video: `{"title", "uploader", "duration"}`) → the favorites |
181
+ | GET | `/api/playlists` | `[{"name": …, "count": …}]` |
182
+ | POST | `/api/playlists/<name>/play` | that playlist becomes the queue |
183
+ | GET, PATCH | `/api/settings` | every setting but the token; PATCH `{"key": value}` changes and saves them, checked like `config set` |
184
+ | WS | `/ws?token=…` | sends the status on connect and on every change; takes the same `{"name", "value"?}` commands as `/api/command` and answers each with the status |
185
+ | GET | `/` | the web remote (needs no token itself; it reads the token from its address); its files are under `/static/`, plus `/manifest.webmanifest` |
186
+
187
+ ### The TUI as a remote
188
+
189
+ `ttyplayer tui --remote http://host:7700` opens the same screen, with the same keys and tabs, on another machine (a laptop, say) while the server plays: Enter on a result makes the *server's* speakers play, and the now-playing panel and the Queue tab follow the server over its socket (the header says `remote: host:7700`). The token is the `server_token` setting, so copy it into the laptop's settings with `ttyplayer config set server_token <token>`; `--token <token>` works too, but leaves it in your shell history. `ttyplayer config set remote_url http://host:7700` makes the remote the default, so a plain `ttyplayer tui` drives it.
190
+
191
+ Searching still happens on the laptop; the server looks each picked video up again before it plays or queues it, so a long queue fills in over a few seconds. History stays the server's: the laptop records nothing. When the server cannot be reached the TUI says so once and keeps trying; a refused token says `Server rejected the token`.
192
+
193
+ Security: the token is the only gate, and plain HTTP carries it in clear text. That is fine on a home network you trust; it is why `serve` listens on 127.0.0.1 unless told otherwise. To reach it beyond your LAN (a VPS, say), put it behind a reverse proxy with HTTPS. Anyone with the token can control the player, including stopping it; change the token with `ttyplayer config set server_token <new>` and restart.
194
+
138
195
  ## Install
139
196
 
140
197
  One command installs everything ttyplayer needs.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ttyplayer"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "ttyplayer: a modern YouTube player for the terminal — search, queue, and play audio or video via mpv"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -27,6 +27,8 @@ classifiers = [
27
27
  "Topic :: Multimedia :: Sound/Audio :: Players",
28
28
  ]
29
29
  dependencies = [
30
+ "aiohttp>=3.10",
31
+ "qrcode>=8",
30
32
  "textual>=8.2",
31
33
  "typer>=0.27.2",
32
34
  "yt-dlp>=2026.8.19",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ttyplayer"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "ttyplayer: a modern YouTube player for the terminal — search, queue, and play audio or video via mpv"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -18,6 +18,8 @@ classifiers = [
18
18
  "Topic :: Multimedia :: Sound/Audio :: Players",
19
19
  ]
20
20
  dependencies = [
21
+ "aiohttp>=3.10",
22
+ "qrcode>=8",
21
23
  "textual>=8.2",
22
24
  "typer>=0.27.2",
23
25
  "yt-dlp>=2026.8.19",
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  import random
3
3
  import shutil
4
+ import signal
4
5
  import subprocess
5
6
  import sys
6
7
  import tempfile
@@ -192,18 +193,83 @@ def favorites_command(
192
193
 
193
194
 
194
195
  @app.command()
195
- def tui(video: bool = False):
196
+ def tui(
197
+ video: bool = False,
198
+ remote_url: str = typer.Option("", "--remote", help="Drive the ttyplayer serve at this URL (default: remote_url)"),
199
+ token: str = typer.Option("", help="The server's token (default: server_token)"),
200
+ ):
196
201
  """Open the full-screen player: search, pick and play in one screen.
197
202
 
198
203
  Keys: / search, Enter play, a add to queue, f favorite, m more results,
199
204
  space pause, , . seek 5s, < > seek 30s, - + volume, M mute, n next,
200
205
  p previous, 1-4 tabs, t theme, ? help, Ctrl-P commands, q quit.
206
+
207
+ With --remote (or the remote_url setting) the server plays and this screen drives it.
201
208
  """
202
209
  from ttyplayer import tui as screen # Textual loads only for this command
203
210
 
204
- screen.TtyplayerApp(
205
- client_factory=player.MpvClient, resolve=screen.resolve, video=video, settings=load_settings()
206
- ).run()
211
+ current = load_settings()
212
+ url = remote_url or current.remote_url
213
+ if url:
214
+ from ttyplayer.remote import RemoteApp # aiohttp loads only for a remote
215
+
216
+ ui = RemoteApp(url, token or current.server_token, resolve=screen.resolve, video=video, settings=current)
217
+ else:
218
+ ui = screen.TtyplayerApp(client_factory=player.MpvClient, resolve=screen.resolve, video=video, settings=current)
219
+ ui.run()
220
+
221
+
222
+ @app.command()
223
+ def serve(host: str | None = None, port: int | None = None):
224
+ """Play headless and take commands over HTTP and WebSocket, gated by a token.
225
+
226
+ Prints the address to open (with its token) and a QR code of it; Ctrl-C stops.
227
+ """
228
+ from ttyplayer import server # aiohttp loads only for this command
229
+
230
+ try:
231
+ current = server.ensure_token(load_settings())
232
+ except settings.SettingsError as error:
233
+ fail(str(error))
234
+ host = host or current.server_host
235
+ port = port or current.server_port
236
+ hub = server.Broadcaster()
237
+ client = start_mpv(False, on_state=hub)
238
+ remote = control.serve(client.handle_control)
239
+ try:
240
+ web = server.ServerThread(server.make_app(client, current, hub), host, port)
241
+ except OSError as error:
242
+ stop_serving(None, remote, client)
243
+ fail(f"Cannot serve on {host}:{port}: {error.strerror or error}")
244
+ address = server.url(host, port, current.server_token)
245
+ typer.echo(f"Serving ttyplayer at {address}")
246
+ print_qr(address)
247
+ old_handler = signal.signal(signal.SIGTERM, signal.default_int_handler) # a kill stops it like Ctrl-C
248
+ try:
249
+ while True:
250
+ time.sleep(3600) # Ctrl-C, SIGTERM and `stop` all land here as KeyboardInterrupt
251
+ except KeyboardInterrupt:
252
+ pass
253
+ finally:
254
+ signal.signal(signal.SIGTERM, old_handler)
255
+ stop_serving(web, remote, client)
256
+
257
+
258
+ def stop_serving(web, remote, client):
259
+ """Stop the HTTP server (None if it never started), then remote control, then mpv."""
260
+ if web:
261
+ web.stop()
262
+ if remote:
263
+ remote.stop()
264
+ client.quit()
265
+
266
+
267
+ def print_qr(text):
268
+ import qrcode
269
+
270
+ code = qrcode.QRCode(border=1)
271
+ code.add_data(text)
272
+ code.print_ascii()
207
273
 
208
274
 
209
275
  @config_app.callback(invoke_without_command=True)
@@ -454,16 +520,21 @@ def show_more(videos, more):
454
520
 
455
521
  def start_playback(videos, with_video):
456
522
  exit_if_empty(videos)
523
+ client = start_mpv(with_video)
524
+ for video in videos:
525
+ client.add(video)
526
+ client.play_current()
527
+ client.run()
528
+
529
+
530
+ def start_mpv(with_video, on_state=None):
531
+ """An MpvClient that keeps history, or a one-line message and exit 1 when mpv cannot start."""
457
532
  try:
458
- client = player.MpvClient(with_video, on_play=history.record)
533
+ return player.MpvClient(with_video, on_play=history.record, on_state=on_state)
459
534
  except FileNotFoundError:
460
535
  fail(f"mpv is not installed. {mpv_install_hint()}")
461
536
  except RuntimeError as error:
462
537
  fail(str(error))
463
- for video in videos:
464
- client.add(video)
465
- client.play_current()
466
- client.run()
467
538
 
468
539
 
469
540
  def print_videos(videos, start=1):
@@ -230,6 +230,8 @@ class MpvClient:
230
230
  loaded_at = None # monotonic time of the last loadfile, until playback restarts
231
231
  started_in = None
232
232
  idle = True # nothing loaded: before the first track, after the queue ran out
233
+ shown_error = None # the error render() printed last
234
+ error = None # "Could not play <title>: <reason>" for the last track mpv failed to load, until one starts
233
235
  request_id = 0 # of the last command sent
234
236
  poller = None
235
237
  volume_writer = None # macOS only
@@ -318,9 +320,11 @@ class MpvClient:
318
320
  elif message.get("event") == "end-file" and message.get("reason") == "eof":
319
321
  # At the end of the queue this still notifies, to tell a UI it went idle.
320
322
  self._edit(self._next_or_idle)
323
+ elif message.get("event") == "end-file" and message.get("reason") == "error":
324
+ self._edit(self._skip_failed, message.get("file_error") or "unavailable")
321
325
 
322
326
  def _mark_started(self):
323
- """Store started_in on the first restart after a loadfile; later ones are seeks.
327
+ """Store started_in, and clear error, on the first restart after a loadfile; later ones are seeks.
324
328
 
325
329
  Under queue_lock, so a track change can't land between reading the clock and
326
330
  reading the stamp it is measured from.
@@ -330,6 +334,7 @@ class MpvClient:
330
334
  return False
331
335
  self.started_in = round(time.monotonic() - self.loaded_at, 1)
332
336
  self.loaded_at = None
337
+ self.error = None
333
338
  return True
334
339
 
335
340
  def poll_volume(self):
@@ -495,6 +500,7 @@ class MpvClient:
495
500
  self.send(["loadfile", url])
496
501
 
497
502
  def play_current(self):
503
+ self.error = None
498
504
  self._move(0)
499
505
 
500
506
  def next(self):
@@ -555,6 +561,11 @@ class MpvClient:
555
561
  self.idle = True
556
562
  return True
557
563
 
564
+ def _skip_failed(self, reason):
565
+ """Record why the current track failed to load, then move on as at its end."""
566
+ self.error = f"Could not play {self.current_title()}: {reason}"
567
+ return self._next_or_idle()
568
+
558
569
  def _remove(self, index):
559
570
  if not 0 <= index < len(self.queue):
560
571
  return False
@@ -642,6 +653,7 @@ class MpvClient:
642
653
  "up_next": self.up_next(),
643
654
  "started_in": self.started_in,
644
655
  "idle": self.idle,
656
+ "error": self.error,
645
657
  }
646
658
 
647
659
  def notify(self):
@@ -653,6 +665,10 @@ class MpvClient:
653
665
 
654
666
  def render(self):
655
667
  status = self.status()
668
+ if status["error"] and status["error"] != self.shown_error:
669
+ # One line of its own above the status line, which redraws below it.
670
+ print(f"\r{status['error']}\x1b[K", file=sys.stderr, flush=True)
671
+ self.shown_error = status["error"]
656
672
  line = status_line(status)
657
673
  if status["total"] > 1:
658
674
  up_next = status["up_next"]