ttyplayer 0.6.0__tar.gz → 0.7.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.6.0 → ttyplayer-0.7.0}/PKG-INFO +13 -3
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/README.md +12 -2
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/pyproject.toml +1 -1
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/pyproject.toml.orig +1 -1
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/cli.py +33 -12
- ttyplayer-0.7.0/src/ttyplayer/models.py +25 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/player.py +140 -1
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/remote.py +7 -2
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/server.py +24 -6
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/settings.py +6 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/spotify.py +2 -1
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/static/index.html +4 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/static/remote.css +2 -2
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/static/remote.js +16 -6
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/tui.py +47 -15
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/tui.tcss +5 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/utils.py +17 -3
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/youtube.py +24 -2
- ttyplayer-0.6.0/src/ttyplayer/models.py +0 -13
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/LICENSE +0 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/__init__.py +0 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/control.py +0 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/favorites.py +0 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/history.py +0 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/playlists.py +0 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/static/icon.svg +0 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/static/manifest.webmanifest +0 -0
- {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/stream.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ttyplayer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -35,8 +35,10 @@ ttyplayer play <playlist url> queue every entry of a playlist
|
|
|
35
35
|
ttyplayer play <words...> search, pick one or more results, play them in order
|
|
36
36
|
ttyplayer play ... --video open a video window as well
|
|
37
37
|
ttyplayer play ... --limit 10 show more search results
|
|
38
|
+
ttyplayer play ... --source soundcloud search SoundCloud instead of YouTube
|
|
38
39
|
|
|
39
40
|
ttyplayer search <words...> list results with durations
|
|
41
|
+
ttyplayer search --source soundcloud <words...> the same, on SoundCloud
|
|
40
42
|
ttyplayer history recently played, newest first
|
|
41
43
|
ttyplayer history --play pick from history and play again
|
|
42
44
|
ttyplayer history --clear forget the history
|
|
@@ -57,6 +59,8 @@ ttyplayer version
|
|
|
57
59
|
|
|
58
60
|
Channels and playlists in search results are skipped; a track that cannot be played is reported and skipped.
|
|
59
61
|
|
|
62
|
+
Search looks on YouTube or SoundCloud. `--source soundcloud` (on `play`, `search` and `playlist add`) picks SoundCloud for one search; `ttyplayer config set search_source soundcloud` makes it the default everywhere. SoundCloud rows carry an `SC` tag in every list, and history, favorites and playlists keep their SoundCloud link. Links from any site yt-dlp knows play as they are.
|
|
63
|
+
|
|
60
64
|
While ttyplayer plays in one terminal, any other terminal can drive it:
|
|
61
65
|
|
|
62
66
|
```
|
|
@@ -64,9 +68,14 @@ ttyplayer pause pause / resume
|
|
|
64
68
|
ttyplayer next next in the queue
|
|
65
69
|
ttyplayer prev previous in the queue
|
|
66
70
|
ttyplayer stop quit the player
|
|
71
|
+
ttyplayer sleep 30m Sleeping in 30:00 (also 1h, 1h30m, 90 seconds)
|
|
72
|
+
ttyplayer sleep end stop when the current track ends
|
|
73
|
+
ttyplayer sleep off cancel it (no argument: show it)
|
|
67
74
|
ttyplayer status 1:23 / 4:56 Playing <title>
|
|
68
75
|
```
|
|
69
76
|
|
|
77
|
+
When the sleep timer runs out, the volume fades to nothing over 5 seconds and the player stops as if `q` was pressed; the volume is put back first, so the next play starts at the old level. Paused or idle, it stops at once. `ttyplayer status`, the TUI and the web remote show `zz 27:13` (or `zz end`) while a timer is armed.
|
|
78
|
+
|
|
70
79
|
## Playlists
|
|
71
80
|
|
|
72
81
|
Playlists are named lists you keep, in the order you choose, separate from history and favorites. A name is 1 to 40 letters, digits, spaces, `_` or `-`.
|
|
@@ -127,7 +136,7 @@ ttyplayer asks only for read access to your playlists (`playlist-read-private pl
|
|
|
127
136
|
|
|
128
137
|
## TUI
|
|
129
138
|
|
|
130
|
-
`ttyplayer tui` opens a full-screen player: a search box, Search / Queue / History / Favorites / Playlists tabs, and a now-playing panel. Type a search or paste a link and press Enter. Ctrl-P opens the command palette (search, playlists, save queue as playlist, next theme, settings, help, quit, pause, next, previous, mute, and Textual's own theme picker); `?` lists every key and command.
|
|
139
|
+
`ttyplayer tui` opens a full-screen player: a search box, Search / Queue / History / Favorites / Playlists tabs, and a now-playing panel. Type a search or paste a link and press Enter; start the search with `sc:` (`sc: boards of canada`) to search SoundCloud, or `yt:` for YouTube, whatever `search_source` says. The Search tab's heading then reads `SoundCloud results` or `YouTube results`. Ctrl-P opens the command palette (search, playlists, save queue as playlist, next theme, settings, help, quit, pause, next, previous, mute, sleep, and Textual's own theme picker); **Sleep…** asks for a sleep timer (`30m` filled in; `end`, `off`, `1h30m`, … work as on the command line; Esc leaves the timer alone); `?` lists every key and command.
|
|
131
140
|
|
|
132
141
|
Under the volume, the panel's level meter shows two bars, `L` and `R`, that follow the sound's peaks about ten times a second (empty at −60 dBFS and below, full at 0 dBFS; empty while paused). `ttyplayer config set show_levels false`, or Enter on `show_levels` in the Settings screen, hides them and takes mpv's measuring filter out.
|
|
133
142
|
|
|
@@ -185,6 +194,7 @@ ttyplayer keeps its preferences in `~/.config/ttyplayer/settings.toml` (`$XDG_CO
|
|
|
185
194
|
| `stream_enabled` | `false` | `ttyplayer serve` streams the sound to the web remote instead of playing it, as `--stream` does |
|
|
186
195
|
| `spotify_client_id` | *none* | the Client ID of your own Spotify app, which `ttyplayer spotify login` needs (see Spotify above) |
|
|
187
196
|
| `show_levels` | `true` | the TUI's level meter: mpv measures the sound's peaks and the now-playing panel shows them |
|
|
197
|
+
| `search_source` | `youtube` | where a search looks: `youtube` or `soundcloud`; `--source` and the TUI's `sc:` / `yt:` prefix override it once |
|
|
188
198
|
|
|
189
199
|
```
|
|
190
200
|
ttyplayer config every setting, (default) when unchanged
|
|
@@ -227,7 +237,7 @@ curl -H "Authorization: Bearer $TOKEN" -d '{"name": "volume", "value": -5}' http
|
|
|
227
237
|
| GET | `/api/status` | the player's status, plus `queue` (the videos) and `index` (1-based) |
|
|
228
238
|
| POST | `/api/play` | `{"url": "…"}` or `{"query": "…"}`: the link's videos, or the first search result, replace the queue and play |
|
|
229
239
|
| POST | `/api/queue` | `{"url": "…"}` or `{"query": "…"}`: appended to the queue (played at once when nothing plays) |
|
|
230
|
-
| POST | `/api/command` | `{"name": "pause"\|"next"\|"prev"\|"stop"\|"mute"\|"seek"\|"volume"\|"jump"\|"remove"\|"move"\|"clear_others", "value"?}`; `seek` and `volume`
|
|
240
|
+
| POST | `/api/command` | `{"name": "pause"\|"next"\|"prev"\|"stop"\|"mute"\|"sleep"\|"seek"\|"volume"\|"jump"\|"remove"\|"move"\|"clear_others", "value"?}`; `sleep` takes the text `ttyplayer sleep` does (`"30m"`, `"end"`, `"off"`), `seek` and `volume` 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 |
|
|
231
241
|
| GET | `/api/commands` | the command names `/api/command` takes |
|
|
232
242
|
| GET | `/api/search?q=…` | the search results (`search_limit` of them) |
|
|
233
243
|
| GET | `/api/favorites` | the favorites, newest first |
|
|
@@ -10,8 +10,10 @@ ttyplayer play <playlist url> queue every entry of a playlist
|
|
|
10
10
|
ttyplayer play <words...> search, pick one or more results, play them in order
|
|
11
11
|
ttyplayer play ... --video open a video window as well
|
|
12
12
|
ttyplayer play ... --limit 10 show more search results
|
|
13
|
+
ttyplayer play ... --source soundcloud search SoundCloud instead of YouTube
|
|
13
14
|
|
|
14
15
|
ttyplayer search <words...> list results with durations
|
|
16
|
+
ttyplayer search --source soundcloud <words...> the same, on SoundCloud
|
|
15
17
|
ttyplayer history recently played, newest first
|
|
16
18
|
ttyplayer history --play pick from history and play again
|
|
17
19
|
ttyplayer history --clear forget the history
|
|
@@ -32,6 +34,8 @@ ttyplayer version
|
|
|
32
34
|
|
|
33
35
|
Channels and playlists in search results are skipped; a track that cannot be played is reported and skipped.
|
|
34
36
|
|
|
37
|
+
Search looks on YouTube or SoundCloud. `--source soundcloud` (on `play`, `search` and `playlist add`) picks SoundCloud for one search; `ttyplayer config set search_source soundcloud` makes it the default everywhere. SoundCloud rows carry an `SC` tag in every list, and history, favorites and playlists keep their SoundCloud link. Links from any site yt-dlp knows play as they are.
|
|
38
|
+
|
|
35
39
|
While ttyplayer plays in one terminal, any other terminal can drive it:
|
|
36
40
|
|
|
37
41
|
```
|
|
@@ -39,9 +43,14 @@ ttyplayer pause pause / resume
|
|
|
39
43
|
ttyplayer next next in the queue
|
|
40
44
|
ttyplayer prev previous in the queue
|
|
41
45
|
ttyplayer stop quit the player
|
|
46
|
+
ttyplayer sleep 30m Sleeping in 30:00 (also 1h, 1h30m, 90 seconds)
|
|
47
|
+
ttyplayer sleep end stop when the current track ends
|
|
48
|
+
ttyplayer sleep off cancel it (no argument: show it)
|
|
42
49
|
ttyplayer status 1:23 / 4:56 Playing <title>
|
|
43
50
|
```
|
|
44
51
|
|
|
52
|
+
When the sleep timer runs out, the volume fades to nothing over 5 seconds and the player stops as if `q` was pressed; the volume is put back first, so the next play starts at the old level. Paused or idle, it stops at once. `ttyplayer status`, the TUI and the web remote show `zz 27:13` (or `zz end`) while a timer is armed.
|
|
53
|
+
|
|
45
54
|
## Playlists
|
|
46
55
|
|
|
47
56
|
Playlists are named lists you keep, in the order you choose, separate from history and favorites. A name is 1 to 40 letters, digits, spaces, `_` or `-`.
|
|
@@ -102,7 +111,7 @@ ttyplayer asks only for read access to your playlists (`playlist-read-private pl
|
|
|
102
111
|
|
|
103
112
|
## TUI
|
|
104
113
|
|
|
105
|
-
`ttyplayer tui` opens a full-screen player: a search box, Search / Queue / History / Favorites / Playlists tabs, and a now-playing panel. Type a search or paste a link and press Enter. Ctrl-P opens the command palette (search, playlists, save queue as playlist, next theme, settings, help, quit, pause, next, previous, mute, and Textual's own theme picker); `?` lists every key and command.
|
|
114
|
+
`ttyplayer tui` opens a full-screen player: a search box, Search / Queue / History / Favorites / Playlists tabs, and a now-playing panel. Type a search or paste a link and press Enter; start the search with `sc:` (`sc: boards of canada`) to search SoundCloud, or `yt:` for YouTube, whatever `search_source` says. The Search tab's heading then reads `SoundCloud results` or `YouTube results`. Ctrl-P opens the command palette (search, playlists, save queue as playlist, next theme, settings, help, quit, pause, next, previous, mute, sleep, and Textual's own theme picker); **Sleep…** asks for a sleep timer (`30m` filled in; `end`, `off`, `1h30m`, … work as on the command line; Esc leaves the timer alone); `?` lists every key and command.
|
|
106
115
|
|
|
107
116
|
Under the volume, the panel's level meter shows two bars, `L` and `R`, that follow the sound's peaks about ten times a second (empty at −60 dBFS and below, full at 0 dBFS; empty while paused). `ttyplayer config set show_levels false`, or Enter on `show_levels` in the Settings screen, hides them and takes mpv's measuring filter out.
|
|
108
117
|
|
|
@@ -160,6 +169,7 @@ ttyplayer keeps its preferences in `~/.config/ttyplayer/settings.toml` (`$XDG_CO
|
|
|
160
169
|
| `stream_enabled` | `false` | `ttyplayer serve` streams the sound to the web remote instead of playing it, as `--stream` does |
|
|
161
170
|
| `spotify_client_id` | *none* | the Client ID of your own Spotify app, which `ttyplayer spotify login` needs (see Spotify above) |
|
|
162
171
|
| `show_levels` | `true` | the TUI's level meter: mpv measures the sound's peaks and the now-playing panel shows them |
|
|
172
|
+
| `search_source` | `youtube` | where a search looks: `youtube` or `soundcloud`; `--source` and the TUI's `sc:` / `yt:` prefix override it once |
|
|
163
173
|
|
|
164
174
|
```
|
|
165
175
|
ttyplayer config every setting, (default) when unchanged
|
|
@@ -202,7 +212,7 @@ curl -H "Authorization: Bearer $TOKEN" -d '{"name": "volume", "value": -5}' http
|
|
|
202
212
|
| GET | `/api/status` | the player's status, plus `queue` (the videos) and `index` (1-based) |
|
|
203
213
|
| POST | `/api/play` | `{"url": "…"}` or `{"query": "…"}`: the link's videos, or the first search result, replace the queue and play |
|
|
204
214
|
| POST | `/api/queue` | `{"url": "…"}` or `{"query": "…"}`: appended to the queue (played at once when nothing plays) |
|
|
205
|
-
| POST | `/api/command` | `{"name": "pause"\|"next"\|"prev"\|"stop"\|"mute"\|"seek"\|"volume"\|"jump"\|"remove"\|"move"\|"clear_others", "value"?}`; `seek` and `volume`
|
|
215
|
+
| POST | `/api/command` | `{"name": "pause"\|"next"\|"prev"\|"stop"\|"mute"\|"sleep"\|"seek"\|"volume"\|"jump"\|"remove"\|"move"\|"clear_others", "value"?}`; `sleep` takes the text `ttyplayer sleep` does (`"30m"`, `"end"`, `"off"`), `seek` and `volume` 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 |
|
|
206
216
|
| GET | `/api/commands` | the command names `/api/command` takes |
|
|
207
217
|
| GET | `/api/search?q=…` | the search results (`search_limit` of them) |
|
|
208
218
|
| GET | `/api/favorites` | the favorites, newest first |
|
|
@@ -21,6 +21,8 @@ app.add_typer(playlist_app, name="playlist")
|
|
|
21
21
|
spotify_app = typer.Typer()
|
|
22
22
|
app.add_typer(spotify_app, name="spotify")
|
|
23
23
|
|
|
24
|
+
# play, search and playlist add: where a search looks; empty means the search_source setting.
|
|
25
|
+
SOURCE_OPTION = typer.Option("", "--source", help="Search youtube or soundcloud (default: search_source)")
|
|
24
26
|
shuffler = random.Random() # playlist play --shuffle; tests swap in a seeded one
|
|
25
27
|
|
|
26
28
|
# How to install mpv, per sys.platform prefix; the README's Install section and the
|
|
@@ -160,21 +162,21 @@ def check_control_dir():
|
|
|
160
162
|
|
|
161
163
|
|
|
162
164
|
@app.command()
|
|
163
|
-
def play(target: list[str], video: bool = False, limit: int = 5):
|
|
165
|
+
def play(target: list[str], video: bool = False, limit: int = 5, source: str = SOURCE_OPTION):
|
|
164
166
|
"""Play a YouTube link or playlist, or search and pick what to play.
|
|
165
167
|
|
|
166
168
|
Keys while playing: space pause, left/right or , . seek, up/down or - + volume,
|
|
167
169
|
n next, p previous, q quit.
|
|
168
170
|
"""
|
|
169
|
-
start_playback(resolve(target, limit), video)
|
|
171
|
+
start_playback(resolve(target, limit, source), video)
|
|
170
172
|
|
|
171
173
|
|
|
172
174
|
@app.command()
|
|
173
|
-
def search(query: list[str], limit: int = 5):
|
|
174
|
-
"""Search YouTube and list the results"""
|
|
175
|
+
def search(query: list[str], limit: int = 5, source: str = SOURCE_OPTION):
|
|
176
|
+
"""Search YouTube or SoundCloud and list the results"""
|
|
175
177
|
from ttyplayer import youtube # yt-dlp loads on use, so doctor runs without it
|
|
176
178
|
|
|
177
|
-
videos = lookup(youtube.search, " ".join(query), limit)
|
|
179
|
+
videos = lookup(youtube.search, " ".join(query), limit, search_source(source))
|
|
178
180
|
exit_if_empty(videos)
|
|
179
181
|
print_videos(videos)
|
|
180
182
|
|
|
@@ -424,10 +426,10 @@ def playlist_create(name: str):
|
|
|
424
426
|
|
|
425
427
|
|
|
426
428
|
@playlist_app.command(name="add")
|
|
427
|
-
def playlist_add(name: str, target: list[str], limit: int = 5):
|
|
429
|
+
def playlist_add(name: str, target: list[str], limit: int = 5, source: str = SOURCE_OPTION):
|
|
428
430
|
"""Add a YouTube link or playlist, or search picks, to the end of a playlist"""
|
|
429
431
|
on_playlist(playlists.require, name)
|
|
430
|
-
count = on_playlist(playlists.add, name, resolve(target, limit))
|
|
432
|
+
count = on_playlist(playlists.add, name, resolve(target, limit, source))
|
|
431
433
|
typer.echo(f"Added {count} videos to {name}")
|
|
432
434
|
|
|
433
435
|
|
|
@@ -585,6 +587,12 @@ def stop():
|
|
|
585
587
|
remote("stop")
|
|
586
588
|
|
|
587
589
|
|
|
590
|
+
@app.command(name="sleep")
|
|
591
|
+
def sleep_command(spec: str = typer.Argument("", help=f"{player.SLEEP_FORMS}; none shows the timer")):
|
|
592
|
+
"""Stop the playing ttyplayer after a while, or when its track ends"""
|
|
593
|
+
typer.echo(remote(f"sleep {spec}".strip())["message"])
|
|
594
|
+
|
|
595
|
+
|
|
588
596
|
@app.command()
|
|
589
597
|
def status():
|
|
590
598
|
"""Show what the playing ttyplayer is playing"""
|
|
@@ -602,18 +610,31 @@ def remote(name):
|
|
|
602
610
|
return reply
|
|
603
611
|
|
|
604
612
|
|
|
605
|
-
def resolve(target, limit):
|
|
606
|
-
"""Turn a link into its videos, or search words into the videos the user picks."""
|
|
613
|
+
def resolve(target, limit, source=""):
|
|
614
|
+
"""Turn a link into its videos, or search words (on source, by default the setting's) into the videos the user picks."""
|
|
607
615
|
from ttyplayer import youtube
|
|
608
616
|
|
|
609
617
|
target_text = " ".join(target)
|
|
610
618
|
if youtube.is_url(target_text):
|
|
611
619
|
return lookup(youtube.fetch, target_text)
|
|
620
|
+
source = search_source(source)
|
|
612
621
|
|
|
613
622
|
def more(shown):
|
|
614
|
-
return unseen(lookup(youtube.search, target_text, len(shown) + limit), shown)
|
|
623
|
+
return unseen(lookup(youtube.search, target_text, len(shown) + limit, source), shown)
|
|
624
|
+
|
|
625
|
+
return pick_from(lookup(youtube.search, target_text, limit, source), more)
|
|
626
|
+
|
|
615
627
|
|
|
616
|
-
|
|
628
|
+
def search_source(source):
|
|
629
|
+
"""source, or the search_source setting when it is empty; a one-line message and exit 1 when it is unknown."""
|
|
630
|
+
from ttyplayer import youtube
|
|
631
|
+
|
|
632
|
+
source = source or load_settings().search_source
|
|
633
|
+
try:
|
|
634
|
+
youtube.search_key(source)
|
|
635
|
+
except ValueError as error:
|
|
636
|
+
fail(str(error))
|
|
637
|
+
return source
|
|
617
638
|
|
|
618
639
|
|
|
619
640
|
def pick_from(videos, more=None):
|
|
@@ -679,7 +700,7 @@ def start_mpv(with_video, on_state=None, headless_pcm=False):
|
|
|
679
700
|
|
|
680
701
|
def print_videos(videos, start=1):
|
|
681
702
|
for number, video in enumerate(videos, start=start):
|
|
682
|
-
typer.echo(f"{number:>2}. {video.
|
|
703
|
+
typer.echo(f"{number:>2}. {video.tagged_title} ({format_time(video.duration)}) {video.uploader}")
|
|
683
704
|
|
|
684
705
|
|
|
685
706
|
def lookup(func, *args):
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
TAGS = {"soundcloud": "SC"} # the two-char mark in front of a non-YouTube row; YouTube rows have none
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Video:
|
|
8
|
+
id: str
|
|
9
|
+
title: str
|
|
10
|
+
uploader: str
|
|
11
|
+
duration: int | None # seconds; None for live streams or when yt-dlp does not know
|
|
12
|
+
source: str = "youtube" # one of youtube.SOURCES for a search result; the site's name for a link
|
|
13
|
+
link: str | None = None # the page URL yt-dlp reported, for every source but youtube
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def url(self):
|
|
17
|
+
if self.source == "youtube":
|
|
18
|
+
return f"https://www.youtube.com/watch?v={self.id}"
|
|
19
|
+
return self.link
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def tagged_title(self):
|
|
23
|
+
"""The title after its source's tag, "SC Song", so mixed lists read; a YouTube title as it is."""
|
|
24
|
+
tag = TAGS.get(self.source)
|
|
25
|
+
return f"{tag} {self.title}" if tag else self.title
|
|
@@ -3,6 +3,7 @@ import json
|
|
|
3
3
|
import math
|
|
4
4
|
import os
|
|
5
5
|
import queue
|
|
6
|
+
import re
|
|
6
7
|
import secrets
|
|
7
8
|
import shutil
|
|
8
9
|
import signal
|
|
@@ -40,6 +41,10 @@ LEVEL_KEY = "lavfi.astats.{}.Peak_level" # {} is the channel, from 1
|
|
|
40
41
|
LEVELS_INTERVAL = 0.1 # seconds between level reads while a track plays
|
|
41
42
|
LEVEL_SILENCE = -90.0 # dBFS stored for astats' -inf
|
|
42
43
|
LEVEL_FLOOR = -60.0 # dBFS at which a level bar is empty; 0 dBFS fills it
|
|
44
|
+
SLEEP_FADE_SECONDS = 5 # how long the sleep timer takes to fade the volume out
|
|
45
|
+
SLEEP_FADE_STEPS = 10 # volume steps in that fade
|
|
46
|
+
SLEEP_FORMS = "30m, 1h, 1h30m, 90 (seconds), end or off"
|
|
47
|
+
SLEEP_PATTERN = re.compile(r"(\d+)|(?:(\d+)h)?(?:(\d+)m)?", re.ASCII) # seconds, or hours and/or minutes
|
|
43
48
|
|
|
44
49
|
# What each key does, by the key's name: the character typed, or the arrow's direction.
|
|
45
50
|
KEYS = {
|
|
@@ -123,6 +128,42 @@ def parse_level(text):
|
|
|
123
128
|
return max(level, LEVEL_SILENCE)
|
|
124
129
|
|
|
125
130
|
|
|
131
|
+
def parse_sleep(text):
|
|
132
|
+
"""A sleep timer from `ttyplayer sleep`'s text: seconds (an int), "end" of the track, or None for off."""
|
|
133
|
+
text = text.strip().lower()
|
|
134
|
+
if text in ("end", "off"):
|
|
135
|
+
return "end" if text == "end" else None
|
|
136
|
+
match = SLEEP_PATTERN.fullmatch(text)
|
|
137
|
+
seconds, hours, minutes = (int(group or 0) for group in match.groups()) if match else (0, 0, 0)
|
|
138
|
+
seconds += hours * 3600 + minutes * 60
|
|
139
|
+
if seconds <= 0:
|
|
140
|
+
raise ValueError(f"Sleep takes {SLEEP_FORMS}, not {text!r}")
|
|
141
|
+
return seconds
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def format_sleep(sleep):
|
|
145
|
+
"""status()["sleep"] as the bars show it: the time left (m:ss) or "end"; None while no timer is armed."""
|
|
146
|
+
if sleep is None:
|
|
147
|
+
return None
|
|
148
|
+
if "after" in sleep:
|
|
149
|
+
return "end"
|
|
150
|
+
return format_time(math.ceil(max(0, sleep["ends_at"] - time.time())))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def sleep_text(sleep):
|
|
154
|
+
"""zz and the time left (or end) for a status line; "" while no timer is armed."""
|
|
155
|
+
left = format_sleep(sleep)
|
|
156
|
+
return f"zz {left}" if left else ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def sleep_message(sleep):
|
|
160
|
+
"""The `ttyplayer sleep` reply for status()["sleep"]."""
|
|
161
|
+
left = format_sleep(sleep)
|
|
162
|
+
if left is None:
|
|
163
|
+
return "Sleep timer off"
|
|
164
|
+
return "Sleeping after this track" if left == "end" else f"Sleeping in {left}"
|
|
165
|
+
|
|
166
|
+
|
|
126
167
|
def macos():
|
|
127
168
|
return sys.platform == "darwin"
|
|
128
169
|
|
|
@@ -157,6 +198,8 @@ def status_line(status):
|
|
|
157
198
|
position = format_time(status["position"])
|
|
158
199
|
duration = format_time(status["duration"])
|
|
159
200
|
state = "Paused" if status["paused"] else "Playing"
|
|
201
|
+
if sleep := sleep_text(status.get("sleep")):
|
|
202
|
+
state += f" {sleep}"
|
|
160
203
|
volume = volume_meter(status["volume"], status["muted"])
|
|
161
204
|
# The meter goes before the title, so a narrow terminal cuts the title, not the meter.
|
|
162
205
|
line = f"{position} / {duration} {state} {volume} {status['title']}"
|
|
@@ -287,6 +330,12 @@ class MpvClient:
|
|
|
287
330
|
headless_pcm = False # no local sound: process.stdout carries it as PCM for serve --stream (see stream.py)
|
|
288
331
|
show_levels = False # whether mpv runs the level filter (never under headless_pcm)
|
|
289
332
|
levels = None # [left, right] peak dBFS while a track plays with show_levels, else None
|
|
333
|
+
sleep_timer = None # the threading.Timer of `sleep <duration>`; it also runs the fade, under fade_lock
|
|
334
|
+
sleep_ends_at = None # epoch time that timer fires at
|
|
335
|
+
sleep_after_track = False # `sleep end`: stop when the current track ends
|
|
336
|
+
sleep_cancelled = None # that timer's Event, set by the next sleep() or quit(): its fade stops and restores the volume
|
|
337
|
+
timer = threading.Timer # tests swap in fakes for both
|
|
338
|
+
fade_wait = staticmethod(threading.Event.wait) # (cancelled, seconds): a step's pause, cut short by a cancel
|
|
290
339
|
|
|
291
340
|
def __init__(self, video=False, on_play=None, on_state=None, headless_pcm=False, levels=True):
|
|
292
341
|
# on_play(video) is called whenever a queued video starts; the CLI uses
|
|
@@ -313,6 +362,7 @@ class MpvClient:
|
|
|
313
362
|
raise RuntimeError("mpv did not start")
|
|
314
363
|
# Keys, listener, control socket and TUI all move through the queue.
|
|
315
364
|
self.queue_lock = threading.RLock()
|
|
365
|
+
self.fade_lock = threading.RLock() # held by a sleep timer while it fades; quit() waits on it
|
|
316
366
|
self.send_lock = threading.Lock()
|
|
317
367
|
self.reply_lock = threading.Lock()
|
|
318
368
|
self.replies = {} # request_id -> callback(data), until the reply arrives
|
|
@@ -375,7 +425,7 @@ class MpvClient:
|
|
|
375
425
|
self.notify()
|
|
376
426
|
elif message.get("event") == "end-file" and message.get("reason") == "eof":
|
|
377
427
|
# At the end of the queue this still notifies, to tell a UI it went idle.
|
|
378
|
-
self._edit(self.
|
|
428
|
+
self._edit(self._end_of_track)
|
|
379
429
|
elif message.get("event") == "end-file" and message.get("reason") == "error":
|
|
380
430
|
self._edit(self._skip_failed, message.get("file_error") or "unavailable")
|
|
381
431
|
|
|
@@ -501,6 +551,9 @@ class MpvClient:
|
|
|
501
551
|
# --- lifecycle ------------------------------------------------------
|
|
502
552
|
|
|
503
553
|
def quit(self):
|
|
554
|
+
self._disarm_sleep()
|
|
555
|
+
with self.fade_lock: # a fade, even of a cancelled timer, restores the volume before mpv goes
|
|
556
|
+
pass
|
|
504
557
|
if self.poller:
|
|
505
558
|
self.stop_polling.set() # before the socket closes under it
|
|
506
559
|
self.poller.join(timeout=1)
|
|
@@ -576,6 +629,8 @@ class MpvClient:
|
|
|
576
629
|
self.toggle_mute()
|
|
577
630
|
elif name == "stop":
|
|
578
631
|
interrupt_main() # run()'s Ctrl-C path quits and restores the terminal
|
|
632
|
+
elif name.partition(" ")[0] == "sleep":
|
|
633
|
+
return self.sleep_control(name.removeprefix("sleep").strip())
|
|
579
634
|
elif name == "status":
|
|
580
635
|
return control.ok(**self.status())
|
|
581
636
|
elif name == "queue":
|
|
@@ -584,6 +639,81 @@ class MpvClient:
|
|
|
584
639
|
return control.failure(f"unknown command {name}")
|
|
585
640
|
return control.ok()
|
|
586
641
|
|
|
642
|
+
def sleep_control(self, text):
|
|
643
|
+
"""`sleep <text>`: arm or cancel the timer, or with no text leave it; the reply's message says how it stands."""
|
|
644
|
+
if text:
|
|
645
|
+
try:
|
|
646
|
+
self.sleep(parse_sleep(text))
|
|
647
|
+
except ValueError as error:
|
|
648
|
+
return control.failure(str(error))
|
|
649
|
+
return control.ok(message=sleep_message(self.sleep_status()))
|
|
650
|
+
|
|
651
|
+
# --- sleep timer ------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
def sleep(self, spec):
|
|
654
|
+
"""Stop spec seconds from now, at the "end" of the current track, or (None) never; replaces any earlier timer."""
|
|
655
|
+
with self.queue_lock:
|
|
656
|
+
self._disarm_sleep()
|
|
657
|
+
self.sleep_after_track = spec == "end"
|
|
658
|
+
if isinstance(spec, int):
|
|
659
|
+
self.sleep_ends_at = time.time() + spec
|
|
660
|
+
self.sleep_cancelled = threading.Event()
|
|
661
|
+
self.sleep_timer = self.timer(spec, self.fall_asleep, args=(self.sleep_cancelled,))
|
|
662
|
+
self.sleep_timer.daemon = True
|
|
663
|
+
self.sleep_timer.start()
|
|
664
|
+
self.notify()
|
|
665
|
+
|
|
666
|
+
def _disarm_sleep(self):
|
|
667
|
+
"""Cancel the timer and the end-of-track stop, and stop a fade (it restores the volume on its own thread)."""
|
|
668
|
+
with self.queue_lock:
|
|
669
|
+
if self.sleep_timer:
|
|
670
|
+
self.sleep_timer.cancel()
|
|
671
|
+
self.sleep_cancelled.set()
|
|
672
|
+
self.sleep_timer = self.sleep_cancelled = self.sleep_ends_at = None
|
|
673
|
+
self.sleep_after_track = False
|
|
674
|
+
|
|
675
|
+
def sleep_status(self):
|
|
676
|
+
if self.sleep_after_track:
|
|
677
|
+
return {"after": "track"}
|
|
678
|
+
if self.sleep_ends_at is not None:
|
|
679
|
+
return {"ends_at": self.sleep_ends_at}
|
|
680
|
+
return None
|
|
681
|
+
|
|
682
|
+
def fall_asleep(self, cancelled):
|
|
683
|
+
"""The timer's callback, on its own thread: fade out while a track plays, then stop as `ttyplayer stop` does.
|
|
684
|
+
|
|
685
|
+
It only sends to mpv and interrupts the main thread, as the keys and the control socket do.
|
|
686
|
+
"""
|
|
687
|
+
with self.fade_lock:
|
|
688
|
+
if cancelled.is_set():
|
|
689
|
+
return # cancelled as it fired
|
|
690
|
+
volume = self.state.get(self.volume_backend())
|
|
691
|
+
if self.playing() and isinstance(volume, (int, float)) and volume > 0 and not self.fade_out(volume, cancelled):
|
|
692
|
+
return # a newer sleep() or quit() took over
|
|
693
|
+
if not cancelled.is_set():
|
|
694
|
+
interrupt_main()
|
|
695
|
+
|
|
696
|
+
def fade_out(self, volume, cancelled):
|
|
697
|
+
"""Step the volume down to 0 over SLEEP_FADE_SECONDS, pause, then put the volume back; False if cancelled first.
|
|
698
|
+
|
|
699
|
+
A cancel ends the step's wait, so the volume comes back at once, in one step.
|
|
700
|
+
"""
|
|
701
|
+
step = volume / SLEEP_FADE_STEPS
|
|
702
|
+
faded = 0
|
|
703
|
+
for _ in range(SLEEP_FADE_STEPS):
|
|
704
|
+
if cancelled.is_set():
|
|
705
|
+
break
|
|
706
|
+
self.change_volume(-step)
|
|
707
|
+
faded += step
|
|
708
|
+
self.fade_wait(cancelled, SLEEP_FADE_SECONDS / SLEEP_FADE_STEPS)
|
|
709
|
+
finished = not cancelled.is_set()
|
|
710
|
+
if finished and self.playing():
|
|
711
|
+
self.toggle_pause() # silent before the volume comes back
|
|
712
|
+
self.change_volume(faded)
|
|
713
|
+
if self.volume_writer:
|
|
714
|
+
self.volume_steps.join() # macOS: written before quit() drops the steps left
|
|
715
|
+
return finished
|
|
716
|
+
|
|
587
717
|
# --- queue ----------------------------------------------------------
|
|
588
718
|
|
|
589
719
|
def add(self, video: Video):
|
|
@@ -649,6 +779,14 @@ class MpvClient:
|
|
|
649
779
|
self.on_play(video)
|
|
650
780
|
return True
|
|
651
781
|
|
|
782
|
+
def _end_of_track(self):
|
|
783
|
+
"""Play the next track, or after `sleep end` stop as `ttyplayer stop` does."""
|
|
784
|
+
if self.sleep_after_track:
|
|
785
|
+
self.sleep_after_track = False
|
|
786
|
+
interrupt_main()
|
|
787
|
+
return True
|
|
788
|
+
return self._next_or_idle()
|
|
789
|
+
|
|
652
790
|
def _next_or_idle(self):
|
|
653
791
|
if not self._play_index(self.index + 1):
|
|
654
792
|
self.idle = True
|
|
@@ -749,6 +887,7 @@ class MpvClient:
|
|
|
749
887
|
"error": self.error,
|
|
750
888
|
"stream": self.headless_pcm,
|
|
751
889
|
"levels": self.levels,
|
|
890
|
+
"sleep": self.sleep_status(),
|
|
752
891
|
}
|
|
753
892
|
|
|
754
893
|
def notify(self):
|
|
@@ -18,7 +18,7 @@ from urllib.parse import urlsplit
|
|
|
18
18
|
import aiohttp
|
|
19
19
|
from aiohttp import WSMsgType
|
|
20
20
|
|
|
21
|
-
from ttyplayer import control, server, tui
|
|
21
|
+
from ttyplayer import control, player, server, tui
|
|
22
22
|
from ttyplayer.models import Video
|
|
23
23
|
|
|
24
24
|
REQUEST_TIMEOUT = 2 # seconds a command may hold the TUI's thread
|
|
@@ -151,7 +151,12 @@ class RemoteClient:
|
|
|
151
151
|
return self.command("move", [source, target])
|
|
152
152
|
|
|
153
153
|
def handle_control(self, name):
|
|
154
|
-
"""The control socket's player commands, done on the server."""
|
|
154
|
+
"""The control socket's player commands, done on the server; sleep's reply says how the timer stands."""
|
|
155
|
+
command, _, text = name.partition(" ")
|
|
156
|
+
if command == server.SLEEP_COMMAND:
|
|
157
|
+
if not self.command(command, text.strip()):
|
|
158
|
+
return control.failure(f"{command} failed on the server")
|
|
159
|
+
return control.ok(message=player.sleep_message((self.last_status or {}).get("sleep")))
|
|
155
160
|
if name not in server.CONTROL_COMMANDS:
|
|
156
161
|
return control.failure(f"unknown command {name}")
|
|
157
162
|
return control.ok() if self.command(name) else control.failure(f"{name} failed on the server")
|
|
@@ -24,15 +24,16 @@ SECRET_KEY = "server_token" # the one setting the API never shows nor changes
|
|
|
24
24
|
SHUTDOWN_TIMEOUT = 1 # seconds aiohttp waits for open requests when the server stops
|
|
25
25
|
TOKEN_BYTES = 24
|
|
26
26
|
|
|
27
|
-
# The /api/command and /ws command names: handle_control's own, then MpvClient methods
|
|
27
|
+
# The /api/command and /ws command names: handle_control's own (sleep with its text, "30m"), then MpvClient methods
|
|
28
28
|
# taking a number, a 0-based queue row, two rows, or nothing. COMMANDS is all of them, as /api/commands lists them.
|
|
29
29
|
CONTROL_COMMANDS = {"pause", "next", "prev", "stop", "mute"}
|
|
30
|
+
SLEEP_COMMAND = "sleep"
|
|
30
31
|
VALUE_COMMANDS = {"seek": "seek", "volume": "change_volume"} # name -> MpvClient method
|
|
31
32
|
ROW_COMMANDS = {"jump": "jump", "remove": "remove"}
|
|
32
33
|
PAIR_COMMANDS = {"move": "move"} # [source, target]
|
|
33
34
|
PLAIN_COMMANDS = {"clear_others": "clear_others"}
|
|
34
35
|
COMMANDS = sorted(
|
|
35
|
-
CONTROL_COMMANDS | VALUE_COMMANDS.keys() | ROW_COMMANDS.keys() | PAIR_COMMANDS.keys() | PLAIN_COMMANDS.keys()
|
|
36
|
+
CONTROL_COMMANDS | {SLEEP_COMMAND} | VALUE_COMMANDS.keys() | ROW_COMMANDS.keys() | PAIR_COMMANDS.keys() | PLAIN_COMMANDS.keys()
|
|
36
37
|
)
|
|
37
38
|
|
|
38
39
|
|
|
@@ -183,6 +184,12 @@ def run_command(client, body):
|
|
|
183
184
|
value = body.get("value")
|
|
184
185
|
if name in CONTROL_COMMANDS:
|
|
185
186
|
client.handle_control(name)
|
|
187
|
+
elif name == SLEEP_COMMAND:
|
|
188
|
+
if not isinstance(value, str):
|
|
189
|
+
raise ApiError(400, f'{name} needs a text value, like "30m", "end" or "off"')
|
|
190
|
+
reply = client.handle_control(f"{name} {value}")
|
|
191
|
+
if not reply["ok"]:
|
|
192
|
+
raise ApiError(400, reply["error"])
|
|
186
193
|
elif name in VALUE_COMMANDS:
|
|
187
194
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
188
195
|
raise ApiError(400, f"{name} needs a numeric value")
|
|
@@ -216,13 +223,19 @@ async def resolve(request, first=False):
|
|
|
216
223
|
if isinstance(body.get("url"), str):
|
|
217
224
|
videos = await lookup(youtube.fetch, body["url"])
|
|
218
225
|
elif isinstance(body.get("query"), str):
|
|
219
|
-
videos = await lookup(youtube.search, body["query"],
|
|
226
|
+
videos = await lookup(youtube.search, body["query"], *search_args(request))
|
|
220
227
|
videos = videos[:1] if first else videos
|
|
221
228
|
else:
|
|
222
229
|
raise ApiError(400, 'the body needs a "url" or a "query"')
|
|
223
230
|
return require_videos(videos)
|
|
224
231
|
|
|
225
232
|
|
|
233
|
+
def search_args(request):
|
|
234
|
+
"""youtube.search's limit and source, from the live settings."""
|
|
235
|
+
current = live_settings(request)
|
|
236
|
+
return current.search_limit, current.search_source
|
|
237
|
+
|
|
238
|
+
|
|
226
239
|
def require_videos(videos):
|
|
227
240
|
if not videos:
|
|
228
241
|
raise ApiError(404, "No videos found")
|
|
@@ -303,8 +316,8 @@ async def get_search(request):
|
|
|
303
316
|
query = request.query.get("q", "").strip()
|
|
304
317
|
if not query:
|
|
305
318
|
raise ApiError(400, "search needs ?q=")
|
|
306
|
-
videos = await lookup(youtube.search, query,
|
|
307
|
-
return web.json_response([
|
|
319
|
+
videos = await lookup(youtube.search, query, *search_args(request))
|
|
320
|
+
return web.json_response([video_json(video) for video in videos])
|
|
308
321
|
|
|
309
322
|
|
|
310
323
|
@routes.get("/api/playlists")
|
|
@@ -345,7 +358,12 @@ async def post_favorite(request):
|
|
|
345
358
|
|
|
346
359
|
|
|
347
360
|
def favorite_listing():
|
|
348
|
-
return [
|
|
361
|
+
return [video_json(video) for video in favorites.load()]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def video_json(video):
|
|
365
|
+
"""A video as the page gets it: its fields and the URL that plays it, so Play/Queue need not know its site."""
|
|
366
|
+
return {**asdict(video), "url": video.url}
|
|
349
367
|
|
|
350
368
|
|
|
351
369
|
@routes.get("/api/settings")
|
|
@@ -28,6 +28,7 @@ class Settings:
|
|
|
28
28
|
stream_enabled: bool = False # serve streams the sound to the web remote instead of playing it
|
|
29
29
|
spotify_client_id: str = "" # the user's own Spotify app, for spotify login (public by design: PKCE)
|
|
30
30
|
show_levels: bool = True # the level meter: mpv's level filter and the TUI's two bars
|
|
31
|
+
search_source: str = "youtube" # where a search looks: one of youtube.SOURCES
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
DEFAULTS = Settings()
|
|
@@ -124,6 +125,11 @@ def check(key, value):
|
|
|
124
125
|
raise SettingsError(
|
|
125
126
|
f"server_port must be between {SERVER_PORT_RANGE.start} and {SERVER_PORT_RANGE.stop - 1}, not {value}"
|
|
126
127
|
)
|
|
128
|
+
if key == "search_source":
|
|
129
|
+
from ttyplayer.youtube import SOURCES # yt-dlp loads on use, so doctor runs without it
|
|
130
|
+
|
|
131
|
+
if value not in SOURCES:
|
|
132
|
+
raise SettingsError(f"search_source must be one of {', '.join(SOURCES)}, not {value!r}")
|
|
127
133
|
|
|
128
134
|
|
|
129
135
|
def toml_value(value):
|
|
@@ -326,7 +326,8 @@ def import_tracks(tracks, name, echo) -> int:
|
|
|
326
326
|
try:
|
|
327
327
|
for number, track in enumerate(tracks, start=1):
|
|
328
328
|
prefix = f"[{number}/{len(tracks)}]"
|
|
329
|
-
|
|
329
|
+
# Always YouTube, whatever search_source says: it has far more of a Spotify catalogue than SoundCloud.
|
|
330
|
+
found = youtube.search(track.query.strip(), 1, source="youtube")
|
|
330
331
|
if found:
|
|
331
332
|
saved += playlists.add(name, found[:1])
|
|
332
333
|
echo(f"{prefix} ✓ {track.label} → {found[0].title}")
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
<section id="now-playing" class="now-playing" aria-label="Now playing">
|
|
29
29
|
<div class="np-head">
|
|
30
30
|
<span id="np-state" class="np-state" aria-label="Stopped">■</span>
|
|
31
|
+
<span id="np-sleep" class="np-sleep"></span>
|
|
31
32
|
<div class="np-text">
|
|
32
33
|
<div id="np-title" class="np-title">Nothing playing</div>
|
|
33
34
|
<div id="np-uploader" class="np-uploader"></div>
|
|
@@ -46,6 +47,9 @@
|
|
|
46
47
|
<button id="play-pause" type="button" class="primary" aria-label="Play or pause">▶</button>
|
|
47
48
|
<button id="next" type="button" aria-label="Next">⏭</button>
|
|
48
49
|
</div>
|
|
50
|
+
<div class="sleep-row">
|
|
51
|
+
<button id="sleep" type="button" class="small">Sleep 30m</button>
|
|
52
|
+
</div>
|
|
49
53
|
<div id="listen-row" class="listen-row" hidden>
|
|
50
54
|
<button id="listen" type="button" aria-pressed="false">Listen here</button>
|
|
51
55
|
<audio id="listen-audio" preload="none"></audio>
|
|
@@ -87,7 +87,7 @@ button.primary, button[type="submit"] {
|
|
|
87
87
|
.np-state { font-size: 1.5rem; width: 1.5rem; text-align: center; }
|
|
88
88
|
.np-text { flex: 1; min-width: 0; }
|
|
89
89
|
.np-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
90
|
-
.np-uploader, .np-position, .times, .meta, .connection, .volume-value { color: var(--muted); font-size: 0.875rem; }
|
|
90
|
+
.np-uploader, .np-position, .np-sleep, .times, .meta, .connection, .volume-value { color: var(--muted); font-size: 0.875rem; }
|
|
91
91
|
|
|
92
92
|
.progress {
|
|
93
93
|
height: 0.375rem;
|
|
@@ -121,7 +121,7 @@ button.icon { border: none; background: none; font-size: 1.25rem; }
|
|
|
121
121
|
}
|
|
122
122
|
.transport .primary { width: calc(var(--transport) * 1.25); height: calc(var(--transport) * 1.25); font-size: 2rem; }
|
|
123
123
|
|
|
124
|
-
.listen-row { display: flex; justify-content: center; margin-top: 0.75rem; }
|
|
124
|
+
.listen-row, .sleep-row { display: flex; justify-content: center; margin-top: 0.75rem; }
|
|
125
125
|
.connection { text-align: center; min-height: 1.25rem; margin-top: 0.25rem; }
|
|
126
126
|
|
|
127
127
|
/* --- search and the lists --- */
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const TOKEN_KEY = "ttyplayer-token";
|
|
6
6
|
const RECONNECT_FIRST = 1000; // ms; doubles after each failed attempt
|
|
7
7
|
const RECONNECT_MAX = 10000;
|
|
8
|
+
const SLEEP_FOR = "30m"; // what the Sleep button arms
|
|
8
9
|
|
|
9
10
|
const $ = (id) => document.getElementById(id);
|
|
10
11
|
|
|
@@ -85,10 +86,6 @@ function command(name, value) {
|
|
|
85
86
|
return act(async () => setState(await api("POST", "/api/command", body)));
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
function videoUrl(video) {
|
|
89
|
-
return `https://www.youtube.com/watch?v=${encodeURIComponent(video.id)}`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
89
|
function play(body) {
|
|
93
90
|
return act(async () => setState(await api("POST", "/api/play", body)));
|
|
94
91
|
}
|
|
@@ -198,7 +195,16 @@ function renderNowPlaying() {
|
|
|
198
195
|
$("mute").textContent = state.muted ? "🔇" : "🔊";
|
|
199
196
|
$("mute").setAttribute("aria-pressed", String(Boolean(state.muted)));
|
|
200
197
|
$("listen-row").hidden = !state.stream;
|
|
198
|
+
$("sleep").textContent = state.sleep ? "Sleep off" : `Sleep ${SLEEP_FOR}`;
|
|
201
199
|
renderProgress();
|
|
200
|
+
renderSleep();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// zz and the time left on the player's sleep timer (zz end: when the track ends), as the TUI shows it.
|
|
204
|
+
function renderSleep() {
|
|
205
|
+
const sleep = state.sleep;
|
|
206
|
+
const left = sleep && (sleep.after ? "end" : formatTime(Math.ceil(sleep.ends_at - Date.now() / 1000)));
|
|
207
|
+
$("np-sleep").textContent = left ? `zz ${left}` : "";
|
|
202
208
|
}
|
|
203
209
|
|
|
204
210
|
function button(label, onClick, className = "small", ariaLabel = label) {
|
|
@@ -253,7 +259,7 @@ function videoRow(video, buttons, onTap) {
|
|
|
253
259
|
}
|
|
254
260
|
|
|
255
261
|
function playOrQueueButtons(video) {
|
|
256
|
-
return [button("Play", () => play({ url:
|
|
262
|
+
return [button("Play", () => play({ url: video.url })), button("Queue", () => enqueue({ url: video.url }))];
|
|
257
263
|
}
|
|
258
264
|
|
|
259
265
|
function emptyRow(text) {
|
|
@@ -416,6 +422,7 @@ function wire() {
|
|
|
416
422
|
$("play-pause").addEventListener("click", () => command("pause"));
|
|
417
423
|
$("next").addEventListener("click", () => command("next"));
|
|
418
424
|
$("mute").addEventListener("click", () => command("mute"));
|
|
425
|
+
$("sleep").addEventListener("click", () => command("sleep", state.sleep ? "off" : SLEEP_FOR));
|
|
419
426
|
$("volume").addEventListener("input", () => {
|
|
420
427
|
volumeHeld = true;
|
|
421
428
|
$("volume-value").textContent = $("volume").value;
|
|
@@ -433,7 +440,10 @@ function wire() {
|
|
|
433
440
|
});
|
|
434
441
|
$("favorites").addEventListener("toggle", () => $("favorites").open && loadFavorites());
|
|
435
442
|
$("playlists").addEventListener("toggle", () => $("playlists").open && loadPlaylists());
|
|
436
|
-
setInterval(() =>
|
|
443
|
+
setInterval(() => {
|
|
444
|
+
if (playing()) renderProgress();
|
|
445
|
+
renderSleep();
|
|
446
|
+
}, 1000);
|
|
437
447
|
}
|
|
438
448
|
|
|
439
449
|
wire();
|
|
@@ -56,14 +56,16 @@ COMMANDS = [
|
|
|
56
56
|
("Next", "next", "Play the next track in the queue"),
|
|
57
57
|
("Previous", "prev", "Play the previous track in the queue"),
|
|
58
58
|
("Mute", "toggle_mute", "Mute or unmute"),
|
|
59
|
+
("Sleep…", "sleep", f"Stop after a while or at the track's end: {player.SLEEP_FORMS}"),
|
|
59
60
|
]
|
|
61
|
+
SLEEP_PREFILL = "30m"
|
|
60
62
|
|
|
61
63
|
|
|
62
|
-
def resolve(text, limit):
|
|
63
|
-
"""A link's videos, or the first `limit` search results for words."""
|
|
64
|
+
def resolve(text, limit, source):
|
|
65
|
+
"""A link's videos, or the first `limit` search results for words on source."""
|
|
64
66
|
if youtube.is_url(text):
|
|
65
67
|
return youtube.fetch(text)
|
|
66
|
-
return youtube.search(text, limit)
|
|
68
|
+
return youtube.search(text, limit, source)
|
|
67
69
|
|
|
68
70
|
|
|
69
71
|
def number_cell(number, playing):
|
|
@@ -72,7 +74,7 @@ def number_cell(number, playing):
|
|
|
72
74
|
|
|
73
75
|
def video_cells(video, number, playing=False, favorite=False):
|
|
74
76
|
"""One table row in column order: #, Title (♥ when a favorite), Uploader, Length."""
|
|
75
|
-
title = video.
|
|
77
|
+
title = video.tagged_title + FAVORITE_MARK if favorite else video.tagged_title
|
|
76
78
|
return number_cell(number, playing), Text(title), Text(video.uploader), format_time(video.duration)
|
|
77
79
|
|
|
78
80
|
|
|
@@ -138,6 +140,7 @@ class NowPlaying(Vertical):
|
|
|
138
140
|
yield Static(id="np-time")
|
|
139
141
|
with Horizontal(classes="np-line"):
|
|
140
142
|
yield Static(id="np-volume")
|
|
143
|
+
yield Static(id="np-sleep")
|
|
141
144
|
yield Static(id="np-next", markup=False)
|
|
142
145
|
yield Static(id="np-timing")
|
|
143
146
|
yield LevelMeter("L", id="np-level-left", classes="np-level")
|
|
@@ -164,6 +167,7 @@ class NowPlaying(Vertical):
|
|
|
164
167
|
f"{format_time(status['position'])} / {format_time(status['duration'])}"
|
|
165
168
|
)
|
|
166
169
|
self.query_one("#np-volume", Static).update(player.volume_meter(status["volume"], status["muted"]))
|
|
170
|
+
self.query_one("#np-sleep", Static).update(player.sleep_text(status.get("sleep")))
|
|
167
171
|
self.query_one("#np-next", Static).update(queue_text(status))
|
|
168
172
|
self.query_one("#np-timing", Static).update(timing_text(status))
|
|
169
173
|
|
|
@@ -345,20 +349,24 @@ class HelpScreen(ModalScreen):
|
|
|
345
349
|
|
|
346
350
|
|
|
347
351
|
class NameScreen(ModalScreen):
|
|
348
|
-
"""Asks for a playlist name: Enter runs save(name), whose
|
|
352
|
+
"""Asks for a playlist name, or another line: Enter runs save(name), whose error stays on screen until a name works.
|
|
353
|
+
|
|
354
|
+
The error is a PlaylistError, or a ValueError (a sleep timer's text).
|
|
355
|
+
"""
|
|
349
356
|
|
|
350
357
|
BINDINGS = [Binding("escape", "dismiss", "Cancel", show=False)]
|
|
351
358
|
|
|
352
|
-
def __init__(self, title, name, save):
|
|
359
|
+
def __init__(self, title, name, save, placeholder=playlists.NAME_RULE):
|
|
353
360
|
super().__init__()
|
|
354
361
|
self.title_text = title
|
|
355
362
|
self.prefill = name
|
|
356
363
|
self.save = save
|
|
364
|
+
self.placeholder = placeholder
|
|
357
365
|
|
|
358
366
|
def compose(self) -> ComposeResult:
|
|
359
367
|
with Vertical(classes="dialog") as box:
|
|
360
368
|
box.border_title = self.title_text
|
|
361
|
-
yield Input(value=self.prefill, placeholder=
|
|
369
|
+
yield Input(value=self.prefill, placeholder=self.placeholder)
|
|
362
370
|
yield Static(id="name-error", markup=False)
|
|
363
371
|
|
|
364
372
|
@on(Input.Submitted)
|
|
@@ -367,7 +375,7 @@ class NameScreen(ModalScreen):
|
|
|
367
375
|
name = event.value.strip()
|
|
368
376
|
try:
|
|
369
377
|
self.save(name)
|
|
370
|
-
except playlists.PlaylistError as error:
|
|
378
|
+
except (playlists.PlaylistError, ValueError) as error:
|
|
371
379
|
self.query_one("#name-error", Static).update(str(error))
|
|
372
380
|
return
|
|
373
381
|
self.dismiss(name)
|
|
@@ -475,7 +483,7 @@ class TtyplayerApp(App):
|
|
|
475
483
|
self.client_factory = client_factory
|
|
476
484
|
self.resolve = resolve
|
|
477
485
|
self.video = video
|
|
478
|
-
self.searched = None # the text behind the Search tab's rows, for m
|
|
486
|
+
self.searched = None # the text behind the Search tab's rows (its sc: or yt: kept), for m
|
|
479
487
|
self.favorite_ids = set()
|
|
480
488
|
self.history_stale = False
|
|
481
489
|
self.queue_shown = ([], None)
|
|
@@ -487,7 +495,7 @@ class TtyplayerApp(App):
|
|
|
487
495
|
def compose(self) -> ComposeResult:
|
|
488
496
|
yield Header(show_clock=self.settings.show_clock)
|
|
489
497
|
with Horizontal(id="search-row"):
|
|
490
|
-
yield SearchBox(placeholder="Search YouTube or paste a link…")
|
|
498
|
+
yield SearchBox(placeholder="Search (sc: SoundCloud, yt: YouTube) or paste a link…")
|
|
491
499
|
yield LoadingIndicator()
|
|
492
500
|
with TabbedContent():
|
|
493
501
|
with TabPane("Search", id="search"):
|
|
@@ -540,20 +548,22 @@ class TtyplayerApp(App):
|
|
|
540
548
|
|
|
541
549
|
@work(thread=True, exclusive=True)
|
|
542
550
|
def lookup(self, text, shown=None):
|
|
543
|
-
"""Search for text; with shown, for the results after those (as the CLI's m does)."""
|
|
551
|
+
"""Search for text (a leading sc: or yt: picks the source); with shown, for the results after those (as the CLI's m does)."""
|
|
544
552
|
videos, error = None, None
|
|
553
|
+
source, query = youtube.split_source(text, self.settings.search_source)
|
|
545
554
|
try:
|
|
546
555
|
limit = self.settings.search_limit
|
|
547
556
|
if shown is None:
|
|
548
|
-
videos = self.resolve(
|
|
557
|
+
videos = self.resolve(query, limit, source)
|
|
549
558
|
else:
|
|
550
|
-
videos = unseen(self.resolve(
|
|
559
|
+
videos = unseen(self.resolve(query, len(shown) + limit, source), shown)
|
|
551
560
|
except youtube.YouTubeError as caught:
|
|
552
561
|
error = caught
|
|
553
562
|
if not get_current_worker().is_cancelled: # a newer search replaced this one
|
|
554
|
-
|
|
563
|
+
heading = "Search" if youtube.is_url(query) else f"{youtube.SOURCE_NAMES[source]} results"
|
|
564
|
+
self.call_from_thread(self.search_done, text, videos, error, shown, heading)
|
|
555
565
|
|
|
556
|
-
def search_done(self, text, videos, error, shown):
|
|
566
|
+
def search_done(self, text, videos, error, shown, heading):
|
|
557
567
|
self.set_searching(False)
|
|
558
568
|
if error is not None:
|
|
559
569
|
self.toast(f"YouTube lookup failed: {error}", severity="error")
|
|
@@ -563,6 +573,7 @@ class TtyplayerApp(App):
|
|
|
563
573
|
self.query_one(ResultsTable).show(shown + videos, self.playing_video(), self.favorite_ids)
|
|
564
574
|
else:
|
|
565
575
|
self.searched = text
|
|
576
|
+
self.query_one(TabbedContent).get_tab("search").label = heading
|
|
566
577
|
self.show_results(videos)
|
|
567
578
|
|
|
568
579
|
def show_results(self, videos):
|
|
@@ -931,6 +942,27 @@ class TtyplayerApp(App):
|
|
|
931
942
|
if self.client:
|
|
932
943
|
self.client.toggle_mute()
|
|
933
944
|
|
|
945
|
+
def action_sleep(self):
|
|
946
|
+
"""Sleep…: a sleep timer's text (30m, end, off, …) for the player; Esc leaves the timer as it is."""
|
|
947
|
+
if self.screen is not self.screen_stack[0]:
|
|
948
|
+
return
|
|
949
|
+
replies = []
|
|
950
|
+
|
|
951
|
+
def arm(text):
|
|
952
|
+
player.parse_sleep(text) # a ValueError stays in the dialog
|
|
953
|
+
if self.client is None:
|
|
954
|
+
raise ValueError("Nothing is playing")
|
|
955
|
+
reply = self.client.handle_control(f"sleep {text}")
|
|
956
|
+
if not reply["ok"]:
|
|
957
|
+
raise ValueError(reply["error"])
|
|
958
|
+
replies.append(reply["message"])
|
|
959
|
+
|
|
960
|
+
def armed(text):
|
|
961
|
+
if text is not None:
|
|
962
|
+
self.toast(replies[-1], severity="information")
|
|
963
|
+
|
|
964
|
+
self.push_screen(NameScreen("Sleep timer", SLEEP_PREFILL, arm, placeholder=player.SLEEP_FORMS), armed)
|
|
965
|
+
|
|
934
966
|
def action_next_theme(self):
|
|
935
967
|
themes = sorted(self.available_themes)
|
|
936
968
|
self.theme = themes[(themes.index(self.theme) + 1) % len(themes)]
|
|
@@ -11,6 +11,8 @@ APP_NAME = "ttyplayer" # the product name; every path, prefix and message deriv
|
|
|
11
11
|
OLD_NAME = "cli" "tube" # the name before ttyplayer, split so the old-name grep guard stays clean
|
|
12
12
|
WINDOWS = sys.platform == "win32" # the one platform test; modules import it, tests patch theirs
|
|
13
13
|
VIDEO_ID = re.compile(r"[A-Za-z0-9_-]{11}") # channel (UC…, 24) and playlist ids are longer
|
|
14
|
+
# yt-dlp's key for a single track of each of youtube.SOURCES; a test keeps the two in step.
|
|
15
|
+
TRACK_EXTRACTORS = ("Youtube", "Soundcloud")
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
def data_path(filename):
|
|
@@ -41,13 +43,22 @@ def format_time(seconds):
|
|
|
41
43
|
return f"{mins}:{secs:02d}"
|
|
42
44
|
|
|
43
45
|
|
|
46
|
+
def extractor(entry):
|
|
47
|
+
"""The yt-dlp extractor behind an entry: ie_key in a flat list, extractor_key in a full one."""
|
|
48
|
+
return entry.get("ie_key") or entry.get("extractor_key")
|
|
49
|
+
|
|
50
|
+
|
|
44
51
|
def video_from_info(entry):
|
|
45
|
-
"""Build a Video from one yt-dlp entry. Only id is guaranteed to be present."""
|
|
52
|
+
"""Build a Video from one yt-dlp entry, or a stored one (which names its source). Only id is guaranteed to be present."""
|
|
53
|
+
source = entry.get("source") or (extractor(entry) or "youtube").lower()
|
|
46
54
|
return Video(
|
|
47
55
|
id=entry["id"],
|
|
48
56
|
title=entry.get("title") or "Untitled",
|
|
49
57
|
uploader=entry.get("uploader") or entry.get("channel") or "Unknown",
|
|
50
58
|
duration=entry.get("duration"),
|
|
59
|
+
source=source,
|
|
60
|
+
# a SoundCloud search entry's url is an API one; webpage_url is the page people share
|
|
61
|
+
link=None if source == "youtube" else entry.get("link") or entry.get("webpage_url") or entry.get("url"),
|
|
51
62
|
)
|
|
52
63
|
|
|
53
64
|
|
|
@@ -58,6 +69,8 @@ def video_entry(video, stamp):
|
|
|
58
69
|
"title": video.title,
|
|
59
70
|
"uploader": video.uploader,
|
|
60
71
|
"duration": video.duration,
|
|
72
|
+
"source": video.source,
|
|
73
|
+
"link": video.link,
|
|
61
74
|
stamp: datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
62
75
|
}
|
|
63
76
|
|
|
@@ -92,8 +105,9 @@ def is_video(entry):
|
|
|
92
105
|
"""Whether a yt-dlp entry is a playable video: a search also returns channels and playlists."""
|
|
93
106
|
if not entry: # yt-dlp leaves None in place of deleted or private playlist entries
|
|
94
107
|
return False
|
|
95
|
-
if
|
|
96
|
-
|
|
108
|
+
if extractor(entry):
|
|
109
|
+
# "YoutubeTab", "SoundcloudSet", "SoundcloudUser"… for channels and playlists
|
|
110
|
+
return extractor(entry) in TRACK_EXTRACTORS
|
|
97
111
|
return bool(VIDEO_ID.fullmatch(entry.get("id") or ""))
|
|
98
112
|
|
|
99
113
|
|
|
@@ -4,6 +4,12 @@ from yt_dlp.utils import DownloadError
|
|
|
4
4
|
from ttyplayer.models import Video
|
|
5
5
|
from ttyplayer.utils import handle_many_entries, video_from_info
|
|
6
6
|
|
|
7
|
+
SOURCES = ("youtube", "soundcloud") # where search can look; the search_source setting is one of these
|
|
8
|
+
# Each source's two letters: yt-dlp's search key (ytsearch5:, scsearch5:) and the TUI's yt:/sc: prefix.
|
|
9
|
+
PREFIXES = dict(zip(("yt", "sc"), SOURCES))
|
|
10
|
+
SOURCE_NAMES = dict(zip(SOURCES, ("YouTube", "SoundCloud")))
|
|
11
|
+
|
|
12
|
+
|
|
7
13
|
class _Silent:
|
|
8
14
|
"""yt-dlp prints errors to stderr even when quiet; we report them ourselves."""
|
|
9
15
|
|
|
@@ -40,11 +46,27 @@ def _clean(message):
|
|
|
40
46
|
return message.removeprefix("ERROR: ").strip()
|
|
41
47
|
|
|
42
48
|
|
|
43
|
-
def search(query, limit=5) -> list[Video]:
|
|
44
|
-
info = _extract(f"
|
|
49
|
+
def search(query, limit=5, source="youtube") -> list[Video]:
|
|
50
|
+
info = _extract(f"{search_key(source)}search{limit}:{query}")
|
|
45
51
|
return handle_many_entries(info.get("entries", []))
|
|
46
52
|
|
|
47
53
|
|
|
54
|
+
def search_key(source):
|
|
55
|
+
"""The two letters of a source; ValueError naming the valid ones for anything else."""
|
|
56
|
+
for prefix, known in PREFIXES.items():
|
|
57
|
+
if known == source:
|
|
58
|
+
return prefix
|
|
59
|
+
raise ValueError(f"Unknown source {source!r}; valid sources: {', '.join(SOURCES)}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def split_source(text, default):
|
|
63
|
+
"""(source, query) for search box text: a leading yt: or sc: picks the source, else default."""
|
|
64
|
+
prefix, colon, rest = text.partition(":")
|
|
65
|
+
if colon and prefix.strip().lower() in PREFIXES:
|
|
66
|
+
return PREFIXES[prefix.strip().lower()], rest.strip()
|
|
67
|
+
return default, text
|
|
68
|
+
|
|
69
|
+
|
|
48
70
|
def fetch(url) -> list[Video]:
|
|
49
71
|
"""A single video link gives a one-item list; a playlist link gives every entry."""
|
|
50
72
|
return fetch_playlist(url)[1]
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
@dataclass
|
|
5
|
-
class Video:
|
|
6
|
-
id: str
|
|
7
|
-
title: str
|
|
8
|
-
uploader: str
|
|
9
|
-
duration: int | None # seconds; None for live streams or when yt-dlp does not know
|
|
10
|
-
|
|
11
|
-
@property
|
|
12
|
-
def url(self):
|
|
13
|
-
return f"https://www.youtube.com/watch?v={self.id}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|