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.
Files changed (28) hide show
  1. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/PKG-INFO +13 -3
  2. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/README.md +12 -2
  3. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/pyproject.toml +1 -1
  4. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/pyproject.toml.orig +1 -1
  5. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/cli.py +33 -12
  6. ttyplayer-0.7.0/src/ttyplayer/models.py +25 -0
  7. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/player.py +140 -1
  8. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/remote.py +7 -2
  9. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/server.py +24 -6
  10. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/settings.py +6 -0
  11. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/spotify.py +2 -1
  12. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/static/index.html +4 -0
  13. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/static/remote.css +2 -2
  14. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/static/remote.js +16 -6
  15. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/tui.py +47 -15
  16. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/tui.tcss +5 -0
  17. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/utils.py +17 -3
  18. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/youtube.py +24 -2
  19. ttyplayer-0.6.0/src/ttyplayer/models.py +0 -13
  20. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/LICENSE +0 -0
  21. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/__init__.py +0 -0
  22. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/control.py +0 -0
  23. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/favorites.py +0 -0
  24. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/history.py +0 -0
  25. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/playlists.py +0 -0
  26. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/static/icon.svg +0 -0
  27. {ttyplayer-0.6.0 → ttyplayer-0.7.0}/src/ttyplayer/static/manifest.webmanifest +0 -0
  28. {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.6.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` 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 |
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` 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 |
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 |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ttyplayer"
3
- version = "0.6.0"
3
+ version = "0.7.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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ttyplayer"
3
- version = "0.6.0"
3
+ version = "0.7.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"
@@ -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
- return pick_from(lookup(youtube.search, target_text, limit), more)
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.title} ({format_time(video.duration)}) {video.uploader}")
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._next_or_idle)
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"], live_settings(request).search_limit)
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, live_settings(request).search_limit)
307
- return web.json_response([asdict(video) for video in videos])
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 [asdict(video) for video in favorites.load()]
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
- found = youtube.search(track.query.strip(), 1)
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: videoUrl(video) })), button("Queue", () => enqueue({ url: videoUrl(video) }))];
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(() => playing() && renderProgress(), 1000);
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.title + FAVORITE_MARK if favorite else video.title
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 PlaylistError stays on screen until a name works."""
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=playlists.NAME_RULE)
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(text, limit)
557
+ videos = self.resolve(query, limit, source)
549
558
  else:
550
- videos = unseen(self.resolve(text, len(shown) + limit), shown)
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
- self.call_from_thread(self.search_done, text, videos, error, shown)
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)]
@@ -101,6 +101,11 @@ NowPlaying.-no-levels .np-level {
101
101
  width: auto;
102
102
  }
103
103
 
104
+ #np-sleep {
105
+ width: auto;
106
+ padding-left: 2;
107
+ }
108
+
104
109
  #np-next {
105
110
  width: 1fr;
106
111
  padding-left: 4;
@@ -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 "ie_key" in entry:
96
- return entry["ie_key"] == "Youtube" # "YoutubeTab" for channels and playlists
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"ytsearch{limit}:{query}")
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