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.
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/PKG-INFO +60 -1
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/README.md +57 -0
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/pyproject.toml +3 -1
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/pyproject.toml.orig +3 -1
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/cli.py +80 -9
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/player.py +17 -1
- ttyplayer-0.5.0/src/ttyplayer/remote.py +299 -0
- ttyplayer-0.5.0/src/ttyplayer/server.py +456 -0
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/settings.py +9 -0
- ttyplayer-0.5.0/src/ttyplayer/static/icon.svg +6 -0
- ttyplayer-0.5.0/src/ttyplayer/static/index.html +74 -0
- ttyplayer-0.5.0/src/ttyplayer/static/manifest.webmanifest +14 -0
- ttyplayer-0.5.0/src/ttyplayer/static/remote.css +202 -0
- ttyplayer-0.5.0/src/ttyplayer/static/remote.js +398 -0
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/tui.py +7 -0
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/utils.py +12 -2
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/LICENSE +0 -0
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/__init__.py +0 -0
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/control.py +0 -0
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/favorites.py +0 -0
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/history.py +0 -0
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/models.py +0 -0
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/playlists.py +0 -0
- {ttyplayer-0.4.0 → ttyplayer-0.5.0}/src/ttyplayer/tui.tcss +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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"]
|