ttyplayer 0.3.0__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ttyplayer
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -43,7 +43,9 @@ ttyplayer favorites favorites, newest first
43
43
  ttyplayer favorites --play pick from favorites and play them
44
44
  ttyplayer favorites --remove 2 drop the second favorite as listed
45
45
  ttyplayer favorites --clear forget all favorites
46
+ ttyplayer playlist ... your own named playlists (see Playlists below)
46
47
  ttyplayer tui [--video] full-screen: search box, results list, now-playing bar
48
+ ttyplayer config list the settings (see Settings below)
47
49
  ttyplayer doctor check Python, yt-dlp, mpv and ttyplayer's folders
48
50
  ttyplayer version
49
51
  ```
@@ -58,13 +60,32 @@ ttyplayer stop quit the player
58
60
  ttyplayer status 1:23 / 4:56 Playing <title>
59
61
  ```
60
62
 
61
- Keys while playing:
63
+ ## Playlists
64
+
65
+ 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 `-`.
66
+
67
+ ```
68
+ ttyplayer playlist list every playlist and how many videos it holds
69
+ ttyplayer playlist show <name> its videos, numbered
70
+ ttyplayer playlist create <name> a new, empty playlist
71
+ ttyplayer playlist add <name> <url | words...> append a link, every entry of a playlist link, or search picks
72
+ ttyplayer playlist remove <name> 3 drop the third video
73
+ ttyplayer playlist move <name> 3 1 move the third video to the top
74
+ ttyplayer playlist delete <name> [--yes] delete it (asks first unless --yes)
75
+ ttyplayer playlist play <name> [--video] [--shuffle] queue the whole playlist, in order or shuffled
76
+ ttyplayer playlist import <playlist url> [--as <name>] save a YouTube playlist, named after its title
77
+ ttyplayer playlist save-queue <name> save what the playing ttyplayer has queued, replacing <name>
78
+ ```
79
+
80
+ `save-queue` works from another terminal while ttyplayer plays, like `pause` and `status`; it creates the playlist if there is none by that name. `import` refuses a name that is already taken; give another with `--as`.
81
+
82
+ Keys in `ttyplayer play` (the terminal player; `ttyplayer tui` has its own table below):
62
83
 
63
84
  | Key | Action |
64
85
  |---|---|
65
86
  | space | pause / resume |
66
87
  | left / right, `,` / `.` | seek 5 seconds |
67
- | up / down | volume |
88
+ | up / down, `-` / `+` | volume |
68
89
  | `n` / `p` | next / previous in the queue |
69
90
  | `q` or Ctrl-C | quit, restores the terminal and stops mpv |
70
91
 
@@ -78,16 +99,19 @@ Picks accept several numbers at once: `1 3 5` queues those three in that order.
78
99
 
79
100
  ## TUI
80
101
 
81
- `ttyplayer tui` opens a full-screen player: a search box, Search / Queue / History / Favorites tabs, and a now-playing panel. Type a search or paste a link and press Enter. Ctrl-P opens the command palette (search, next theme, help, quit, pause, next, previous, mute, and Textual's own theme picker); `?` lists every key and command.
102
+ `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.
82
103
 
83
104
  | Where | Key | Action |
84
105
  |---|---|---|
106
+ | table | `↑` / `↓` | move through the list (volume is `-` / `+`) |
85
107
  | anywhere | `/` | focus the search box (`esc` returns to the table) |
86
108
  | anywhere | `?` | help: every key and command (`esc` closes) |
87
- | anywhere | `1` `2` `3` `4` | Search / Queue / History / Favorites tab |
109
+ | anywhere | `1` `2` `3` `4` `5` | Search / Queue / History / Favorites / Playlists tab |
88
110
  | anywhere | Ctrl-C | quit and stop mpv |
89
111
  | anywhere | Ctrl-P | command palette |
90
- | anywhere | `t` | next theme |
112
+ | anywhere | `t` | next theme (remembered for next time) |
113
+ | table | `P` | save the queue as a playlist (asks for a name; an existing playlist of that name is replaced) |
114
+ | table | `S` | settings: Enter flips a true / false one, `esc` closes |
91
115
  | table | `q` | quit and stop mpv (in the search box it is just a letter) |
92
116
  | table | `space` | pause / resume |
93
117
  | table | `n` / `p` | next / previous |
@@ -98,15 +122,42 @@ Picks accept several numbers at once: `1 3 5` queues those three in that order.
98
122
  | table | `f` | favorite / unfavorite this row (the track playing when there is no row) |
99
123
  | Search · History · Favorites row | Enter | play this one, then the rows after it |
100
124
  | Search · History · Favorites row | `a` | add to the queue |
125
+ | Search · History · Favorites · Queue row | `A` | add to a playlist (pick one, or New playlist…) |
101
126
  | Search | `m` | more results |
102
127
  | Queue row | Enter | jump to this item |
103
128
  | Queue row | `d` | remove from the queue |
104
129
  | Queue row | `K` / `J` (or `shift+↑` / `shift+↓`) | move up / down |
105
130
  | Queue | `c` | clear the queue (keeps the current track playing) |
106
131
  | Favorites row | `d` | remove from favorites |
132
+ | Playlists | Enter | open the playlist (`esc` or Backspace goes back to the list) |
133
+ | Playlists | `d` | delete the playlist (asks first: `y` or Enter deletes, `esc` keeps it) |
134
+ | playlist track | Enter | play the whole playlist from this track |
135
+ | playlist track | `a` | add to the queue |
136
+ | playlist track | `d` | remove from the playlist |
137
+ | playlist track | `K` / `J` (or `shift+↑` / `shift+↓`) | move up / down in the playlist |
138
+ | open playlist | `s` | shuffle-play the playlist |
107
139
 
108
140
  In the search box, letters, digits, `/` and `?` are typed as text; `esc` leaves it for the table.
109
141
 
142
+ ## Settings
143
+
144
+ ttyplayer keeps its preferences in `~/.config/ttyplayer/settings.toml` (`$XDG_CONFIG_HOME/ttyplayer/` when that is set, `%APPDATA%\ttyplayer\` on Windows). Every key is optional; a missing file means the defaults.
145
+
146
+ | Key | Default | What it does |
147
+ |---|---|---|
148
+ | `show_clock` | `true` | the clock in the TUI's header |
149
+ | `theme` | `textual-dark` | the TUI's color theme; `t` in the TUI picks the next one and saves it |
150
+ | `search_limit` | `10` | how many results a TUI search fetches, and `m` adds (1–50) |
151
+
152
+ ```
153
+ ttyplayer config every setting, (default) when unchanged
154
+ ttyplayer config get <key> one setting's value
155
+ ttyplayer config set <key> <value> change it: ttyplayer config set show_clock false
156
+ ttyplayer config path where the file is
157
+ ```
158
+
159
+ In the TUI, `S` (or Settings… in Ctrl-P) lists the settings: Enter on a true / false one flips it and saves it (the clock shows or hides at once); the others are set with `ttyplayer config set`.
160
+
110
161
  ## Install
111
162
 
112
163
  One command installs everything ttyplayer needs.
@@ -199,7 +250,7 @@ uv run pytest -q # default suite, live test
199
250
  TTYPLAYER_LIVE=1 uv run pytest -q tests/test_live.py # live tests, needs network
200
251
  ```
201
252
 
202
- History lives in `$XDG_DATA_HOME/ttyplayer/history.jsonl`, by default `~/.local/share/ttyplayer/history.jsonl` (`%LOCALAPPDATA%\ttyplayer\history.jsonl` on Windows). Favorites live next to it in `favorites.jsonl`. History and favorites kept under the player's earlier name are moved here on the first run.
253
+ History lives in `$XDG_DATA_HOME/ttyplayer/history.jsonl`, by default `~/.local/share/ttyplayer/history.jsonl` (`%LOCALAPPDATA%\ttyplayer\history.jsonl` on Windows). Favorites live next to it in `favorites.jsonl`, and each playlist in `playlists/<name>.jsonl`. History and favorites kept under the player's earlier name are moved here on the first run.
203
254
 
204
255
  ### Releasing
205
256
 
@@ -20,7 +20,9 @@ ttyplayer favorites favorites, newest first
20
20
  ttyplayer favorites --play pick from favorites and play them
21
21
  ttyplayer favorites --remove 2 drop the second favorite as listed
22
22
  ttyplayer favorites --clear forget all favorites
23
+ ttyplayer playlist ... your own named playlists (see Playlists below)
23
24
  ttyplayer tui [--video] full-screen: search box, results list, now-playing bar
25
+ ttyplayer config list the settings (see Settings below)
24
26
  ttyplayer doctor check Python, yt-dlp, mpv and ttyplayer's folders
25
27
  ttyplayer version
26
28
  ```
@@ -35,13 +37,32 @@ ttyplayer stop quit the player
35
37
  ttyplayer status 1:23 / 4:56 Playing <title>
36
38
  ```
37
39
 
38
- Keys while playing:
40
+ ## Playlists
41
+
42
+ 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 `-`.
43
+
44
+ ```
45
+ ttyplayer playlist list every playlist and how many videos it holds
46
+ ttyplayer playlist show <name> its videos, numbered
47
+ ttyplayer playlist create <name> a new, empty playlist
48
+ ttyplayer playlist add <name> <url | words...> append a link, every entry of a playlist link, or search picks
49
+ ttyplayer playlist remove <name> 3 drop the third video
50
+ ttyplayer playlist move <name> 3 1 move the third video to the top
51
+ ttyplayer playlist delete <name> [--yes] delete it (asks first unless --yes)
52
+ ttyplayer playlist play <name> [--video] [--shuffle] queue the whole playlist, in order or shuffled
53
+ ttyplayer playlist import <playlist url> [--as <name>] save a YouTube playlist, named after its title
54
+ ttyplayer playlist save-queue <name> save what the playing ttyplayer has queued, replacing <name>
55
+ ```
56
+
57
+ `save-queue` works from another terminal while ttyplayer plays, like `pause` and `status`; it creates the playlist if there is none by that name. `import` refuses a name that is already taken; give another with `--as`.
58
+
59
+ Keys in `ttyplayer play` (the terminal player; `ttyplayer tui` has its own table below):
39
60
 
40
61
  | Key | Action |
41
62
  |---|---|
42
63
  | space | pause / resume |
43
64
  | left / right, `,` / `.` | seek 5 seconds |
44
- | up / down | volume |
65
+ | up / down, `-` / `+` | volume |
45
66
  | `n` / `p` | next / previous in the queue |
46
67
  | `q` or Ctrl-C | quit, restores the terminal and stops mpv |
47
68
 
@@ -55,16 +76,19 @@ Picks accept several numbers at once: `1 3 5` queues those three in that order.
55
76
 
56
77
  ## TUI
57
78
 
58
- `ttyplayer tui` opens a full-screen player: a search box, Search / Queue / History / Favorites tabs, and a now-playing panel. Type a search or paste a link and press Enter. Ctrl-P opens the command palette (search, next theme, help, quit, pause, next, previous, mute, and Textual's own theme picker); `?` lists every key and command.
79
+ `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.
59
80
 
60
81
  | Where | Key | Action |
61
82
  |---|---|---|
83
+ | table | `↑` / `↓` | move through the list (volume is `-` / `+`) |
62
84
  | anywhere | `/` | focus the search box (`esc` returns to the table) |
63
85
  | anywhere | `?` | help: every key and command (`esc` closes) |
64
- | anywhere | `1` `2` `3` `4` | Search / Queue / History / Favorites tab |
86
+ | anywhere | `1` `2` `3` `4` `5` | Search / Queue / History / Favorites / Playlists tab |
65
87
  | anywhere | Ctrl-C | quit and stop mpv |
66
88
  | anywhere | Ctrl-P | command palette |
67
- | anywhere | `t` | next theme |
89
+ | anywhere | `t` | next theme (remembered for next time) |
90
+ | table | `P` | save the queue as a playlist (asks for a name; an existing playlist of that name is replaced) |
91
+ | table | `S` | settings: Enter flips a true / false one, `esc` closes |
68
92
  | table | `q` | quit and stop mpv (in the search box it is just a letter) |
69
93
  | table | `space` | pause / resume |
70
94
  | table | `n` / `p` | next / previous |
@@ -75,15 +99,42 @@ Picks accept several numbers at once: `1 3 5` queues those three in that order.
75
99
  | table | `f` | favorite / unfavorite this row (the track playing when there is no row) |
76
100
  | Search · History · Favorites row | Enter | play this one, then the rows after it |
77
101
  | Search · History · Favorites row | `a` | add to the queue |
102
+ | Search · History · Favorites · Queue row | `A` | add to a playlist (pick one, or New playlist…) |
78
103
  | Search | `m` | more results |
79
104
  | Queue row | Enter | jump to this item |
80
105
  | Queue row | `d` | remove from the queue |
81
106
  | Queue row | `K` / `J` (or `shift+↑` / `shift+↓`) | move up / down |
82
107
  | Queue | `c` | clear the queue (keeps the current track playing) |
83
108
  | Favorites row | `d` | remove from favorites |
109
+ | Playlists | Enter | open the playlist (`esc` or Backspace goes back to the list) |
110
+ | Playlists | `d` | delete the playlist (asks first: `y` or Enter deletes, `esc` keeps it) |
111
+ | playlist track | Enter | play the whole playlist from this track |
112
+ | playlist track | `a` | add to the queue |
113
+ | playlist track | `d` | remove from the playlist |
114
+ | playlist track | `K` / `J` (or `shift+↑` / `shift+↓`) | move up / down in the playlist |
115
+ | open playlist | `s` | shuffle-play the playlist |
84
116
 
85
117
  In the search box, letters, digits, `/` and `?` are typed as text; `esc` leaves it for the table.
86
118
 
119
+ ## Settings
120
+
121
+ ttyplayer keeps its preferences in `~/.config/ttyplayer/settings.toml` (`$XDG_CONFIG_HOME/ttyplayer/` when that is set, `%APPDATA%\ttyplayer\` on Windows). Every key is optional; a missing file means the defaults.
122
+
123
+ | Key | Default | What it does |
124
+ |---|---|---|
125
+ | `show_clock` | `true` | the clock in the TUI's header |
126
+ | `theme` | `textual-dark` | the TUI's color theme; `t` in the TUI picks the next one and saves it |
127
+ | `search_limit` | `10` | how many results a TUI search fetches, and `m` adds (1–50) |
128
+
129
+ ```
130
+ ttyplayer config every setting, (default) when unchanged
131
+ ttyplayer config get <key> one setting's value
132
+ ttyplayer config set <key> <value> change it: ttyplayer config set show_clock false
133
+ ttyplayer config path where the file is
134
+ ```
135
+
136
+ In the TUI, `S` (or Settings… in Ctrl-P) lists the settings: Enter on a true / false one flips it and saves it (the clock shows or hides at once); the others are set with `ttyplayer config set`.
137
+
87
138
  ## Install
88
139
 
89
140
  One command installs everything ttyplayer needs.
@@ -176,7 +227,7 @@ uv run pytest -q # default suite, live test
176
227
  TTYPLAYER_LIVE=1 uv run pytest -q tests/test_live.py # live tests, needs network
177
228
  ```
178
229
 
179
- History lives in `$XDG_DATA_HOME/ttyplayer/history.jsonl`, by default `~/.local/share/ttyplayer/history.jsonl` (`%LOCALAPPDATA%\ttyplayer\history.jsonl` on Windows). Favorites live next to it in `favorites.jsonl`. History and favorites kept under the player's earlier name are moved here on the first run.
230
+ History lives in `$XDG_DATA_HOME/ttyplayer/history.jsonl`, by default `~/.local/share/ttyplayer/history.jsonl` (`%LOCALAPPDATA%\ttyplayer\history.jsonl` on Windows). Favorites live next to it in `favorites.jsonl`, and each playlist in `playlists/<name>.jsonl`. History and favorites kept under the player's earlier name are moved here on the first run.
180
231
 
181
232
  ### Releasing
182
233
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ttyplayer"
3
- version = "0.3.0"
3
+ version = "0.4.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.3.0"
3
+ version = "0.4.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,4 +1,5 @@
1
1
  import os
2
+ import random
2
3
  import shutil
3
4
  import subprocess
4
5
  import sys
@@ -8,10 +9,16 @@ from importlib import metadata
8
9
 
9
10
  import typer
10
11
 
11
- from ttyplayer import control, favorites, history, player
12
- from ttyplayer.utils import APP_NAME, data_path, format_time, parse_picks, unseen
12
+ from ttyplayer import control, favorites, history, player, playlists, settings
13
+ from ttyplayer.utils import APP_NAME, data_path, format_time, parse_picks, unseen, video_from_info
13
14
 
14
15
  app = typer.Typer()
16
+ config_app = typer.Typer()
17
+ app.add_typer(config_app, name="config")
18
+ playlist_app = typer.Typer()
19
+ app.add_typer(playlist_app, name="playlist")
20
+
21
+ shuffler = random.Random() # playlist play --shuffle; tests swap in a seeded one
15
22
 
16
23
  # How to install mpv, per sys.platform prefix; the README's Install section and the
17
24
  # install.sh / install.ps1 scripts repeat these, and tests keep all three in step.
@@ -118,7 +125,7 @@ def check_control_dir():
118
125
  def play(target: list[str], video: bool = False, limit: int = 5):
119
126
  """Play a YouTube link or playlist, or search and pick what to play.
120
127
 
121
- Keys while playing: space pause, left/right or , . seek, up/down volume,
128
+ Keys while playing: space pause, left/right or , . seek, up/down or - + volume,
122
129
  n next, p previous, q quit.
123
130
  """
124
131
  start_playback(resolve(target, limit), video)
@@ -194,7 +201,164 @@ def tui(video: bool = False):
194
201
  """
195
202
  from ttyplayer import tui as screen # Textual loads only for this command
196
203
 
197
- screen.TtyplayerApp(client_factory=player.MpvClient, resolve=screen.resolve, video=video).run()
204
+ screen.TtyplayerApp(
205
+ client_factory=player.MpvClient, resolve=screen.resolve, video=video, settings=load_settings()
206
+ ).run()
207
+
208
+
209
+ @config_app.callback(invoke_without_command=True)
210
+ def config(context: typer.Context):
211
+ """List the settings, or get, set or locate them"""
212
+ if context.invoked_subcommand is not None:
213
+ return
214
+ current = load_settings()
215
+ for key in settings.KEYS:
216
+ value = getattr(current, key)
217
+ default = " (default)" if value == getattr(settings.DEFAULTS, key) else ""
218
+ typer.echo(f"{key} = {settings.display(value)}{default}")
219
+
220
+
221
+ @config_app.command(name="get")
222
+ def config_get(key: str):
223
+ """Print one setting's value"""
224
+ try:
225
+ settings.check_key(key)
226
+ except settings.SettingsError as error:
227
+ fail(str(error))
228
+ typer.echo(settings.display(getattr(load_settings(), key)))
229
+
230
+
231
+ @config_app.command(name="set")
232
+ def config_set(key: str, value: str):
233
+ """Change one setting and save it"""
234
+ try:
235
+ changed = settings.update(key, value)
236
+ except settings.SettingsError as error:
237
+ fail(str(error))
238
+ typer.echo(f"{key} = {settings.display(getattr(changed, key))}")
239
+
240
+
241
+ @config_app.command(name="path")
242
+ def config_path():
243
+ """Print where the settings file lives"""
244
+ typer.echo(settings.settings_path())
245
+
246
+
247
+ def load_settings():
248
+ """The saved settings, or a one-line message and exit 1 when the file is broken."""
249
+ try:
250
+ return settings.load()
251
+ except settings.SettingsError as error:
252
+ fail(str(error))
253
+
254
+
255
+ @playlist_app.callback()
256
+ def playlist():
257
+ """Make, edit and play your own playlists"""
258
+
259
+
260
+ @playlist_app.command(name="list")
261
+ def playlist_list():
262
+ """List the playlists and how many videos each holds"""
263
+ names = playlists.names()
264
+ if not names:
265
+ fail("No playlists yet")
266
+ for name in names:
267
+ typer.echo(f"{name} ({len(on_playlist(playlists.load, name))} videos)")
268
+
269
+
270
+ @playlist_app.command(name="show")
271
+ def playlist_show(name: str):
272
+ """List a playlist's videos, numbered"""
273
+ videos = on_playlist(playlists.load, name)
274
+ if not videos:
275
+ typer.echo(f"{name} is empty")
276
+ print_videos(videos)
277
+
278
+
279
+ @playlist_app.command(name="create")
280
+ def playlist_create(name: str):
281
+ """Make a new, empty playlist"""
282
+ on_playlist(playlists.create, name)
283
+ typer.echo(f"Created playlist {name}")
284
+
285
+
286
+ @playlist_app.command(name="add")
287
+ def playlist_add(name: str, target: list[str], limit: int = 5):
288
+ """Add a YouTube link or playlist, or search picks, to the end of a playlist"""
289
+ on_playlist(playlists.require, name)
290
+ count = on_playlist(playlists.add, name, resolve(target, limit))
291
+ typer.echo(f"Added {count} videos to {name}")
292
+
293
+
294
+ @playlist_app.command(name="remove")
295
+ def playlist_remove(name: str, number: int):
296
+ """Drop the video at this position"""
297
+ removed = on_playlist(playlists.remove, name, number)
298
+ typer.echo(f"Removed: {removed.title}")
299
+
300
+
301
+ @playlist_app.command(name="move")
302
+ def playlist_move(name: str, source: int, target: int):
303
+ """Move the video at one position to another"""
304
+ moved = on_playlist(playlists.move, name, source, target)
305
+ typer.echo(f"Moved to {target}: {moved.title}")
306
+
307
+
308
+ @playlist_app.command(name="delete")
309
+ def playlist_delete(name: str, yes: bool = typer.Option(False, "--yes", help="Do not ask first")):
310
+ """Delete a playlist, after asking"""
311
+ on_playlist(playlists.require, name)
312
+ if not yes and not typer.confirm(f"Delete playlist {name}?", default=False):
313
+ typer.echo(f"Kept {name}")
314
+ return
315
+ on_playlist(playlists.delete, name)
316
+ typer.echo(f"Deleted playlist {name}")
317
+
318
+
319
+ @playlist_app.command(name="play")
320
+ def playlist_play(name: str, video: bool = False, shuffle: bool = False):
321
+ """Play a whole playlist, in order or shuffled"""
322
+ videos = on_playlist(playlists.load, name)
323
+ if shuffle:
324
+ shuffler.shuffle(videos)
325
+ start_playback(videos, video)
326
+
327
+
328
+ @playlist_app.command(name="import")
329
+ def playlist_import(url: str, name: str | None = typer.Option(None, "--as", help="Name it this, not its title")):
330
+ """Save a YouTube playlist as a playlist of your own"""
331
+ from ttyplayer import youtube
332
+
333
+ title, videos = lookup(youtube.fetch_playlist, url)
334
+ if title is None:
335
+ fail(f"Not a playlist link: {url}")
336
+ name = name or playlists.sanitize(title)
337
+ if not name:
338
+ fail("The playlist title has nothing to name it by; name it with --as")
339
+ exit_if_empty(videos)
340
+ on_playlist(playlists.create, name)
341
+ on_playlist(playlists.add, name, videos)
342
+ typer.echo(f"Imported {len(videos)} videos as {name}")
343
+
344
+
345
+ @playlist_app.command(name="save-queue")
346
+ def playlist_save_queue(name: str):
347
+ """Save the playing ttyplayer's queue as a playlist, replacing what it held"""
348
+ on_playlist(playlists.playlist_path, name)
349
+ videos = [video_from_info(entry) for entry in remote("queue")["videos"]]
350
+ if not videos:
351
+ fail("The queue is empty")
352
+ on_playlist(playlists.replace, name, videos)
353
+ typer.echo(f"Saved {len(videos)} videos to {name}")
354
+
355
+
356
+ def on_playlist(func, *args):
357
+ """Run a playlists.* call, turning its errors into a one-line message and exit 1."""
358
+ try:
359
+ return func(*args)
360
+ except playlists.PlaylistError as error:
361
+ fail(str(error))
198
362
 
199
363
 
200
364
  @app.command()
@@ -1,11 +1,9 @@
1
1
  """Videos the user chose to keep, one JSON object per line, newest at the bottom."""
2
2
 
3
- import json
4
- from datetime import datetime, timezone
5
3
  from pathlib import Path
6
4
 
7
5
  from ttyplayer.models import Video
8
- from ttyplayer.utils import data_path, video_from_info
6
+ from ttyplayer.utils import append_entries, data_path, read_entries, video_entry, video_from_info, write_entries
9
7
 
10
8
 
11
9
  def favorites_path() -> Path:
@@ -17,16 +15,7 @@ def add(video: Video, path: Path | None = None) -> bool:
17
15
  path = path or favorites_path()
18
16
  if any(entry["id"] == video.id for entry in _entries(path)):
19
17
  return False
20
- path.parent.mkdir(parents=True, exist_ok=True)
21
- entry = {
22
- "id": video.id,
23
- "title": video.title,
24
- "uploader": video.uploader,
25
- "duration": video.duration,
26
- "favorited_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
27
- }
28
- with path.open("a", encoding="utf-8") as f:
29
- f.write(json.dumps(entry) + "\n")
18
+ append_entries(path, [video_entry(video, "favorited_at")])
30
19
  return True
31
20
 
32
21
 
@@ -48,7 +37,7 @@ def remove_id(video_id: str, path: Path | None = None) -> bool:
48
37
  kept = [entry for entry in reversed(entries) if entry["id"] != video_id]
49
38
  if len(kept) == len(entries):
50
39
  return False
51
- path.write_text("".join(json.dumps(entry) + "\n" for entry in kept), encoding="utf-8")
40
+ write_entries(path, kept)
52
41
  return True
53
42
 
54
43
 
@@ -74,16 +63,10 @@ def load(path: Path | None = None, limit: int | None = None) -> list[Video]:
74
63
 
75
64
  def _entries(path: Path) -> list[dict]:
76
65
  """Stored entries newest first, one per video id, skipping corrupt lines."""
77
- if not path.exists():
78
- return []
79
66
  seen = set()
80
67
  entries = []
81
- for line in reversed(path.read_text(encoding="utf-8").splitlines()):
82
- try:
83
- entry = json.loads(line)
84
- except json.JSONDecodeError:
85
- continue
86
- if not isinstance(entry, dict) or "id" not in entry or entry["id"] in seen:
68
+ for entry in reversed(read_entries(path)):
69
+ if entry["id"] in seen:
87
70
  continue
88
71
  seen.add(entry["id"])
89
72
  entries.append(entry)
@@ -0,0 +1,38 @@
1
+ """What has been played, one JSON object per line, newest at the bottom."""
2
+
3
+ from pathlib import Path
4
+
5
+ from ttyplayer.models import Video
6
+ from ttyplayer.utils import append_entries, data_path, read_entries, video_entry, video_from_info
7
+
8
+
9
+ def history_path() -> Path:
10
+ return data_path("history.jsonl")
11
+
12
+
13
+ def record(video: Video, path: Path | None = None):
14
+ append_entries(path or history_path(), [video_entry(video, "played_at")])
15
+
16
+
17
+ def clear(path: Path | None = None) -> int:
18
+ """Forget everything. Returns how many entries were there."""
19
+ path = path or history_path()
20
+ if not path.exists():
21
+ return 0
22
+ count = len(path.read_text(encoding="utf-8").splitlines())
23
+ path.unlink()
24
+ return count
25
+
26
+
27
+ def load(path: Path | None = None, limit: int = 20) -> list[Video]:
28
+ """Most recently played first, each video once, at most `limit` of them."""
29
+ seen = set()
30
+ videos = []
31
+ for entry in reversed(read_entries(path or history_path())):
32
+ if entry["id"] in seen:
33
+ continue
34
+ seen.add(entry["id"])
35
+ videos.append(video_from_info(entry))
36
+ if len(videos) == limit:
37
+ break
38
+ return videos
@@ -11,6 +11,7 @@ import sys
11
11
  import tempfile
12
12
  import threading
13
13
  import time
14
+ from dataclasses import asdict
14
15
 
15
16
  from ttyplayer import control
16
17
  from ttyplayer.models import Video
@@ -40,6 +41,9 @@ KEYS = {
40
41
  "right": ("seek", SEEK_SECONDS),
41
42
  "up": ("change_volume", VOLUME_STEP),
42
43
  "down": ("change_volume", -VOLUME_STEP),
44
+ "+": ("change_volume", VOLUME_STEP),
45
+ "=": ("change_volume", VOLUME_STEP), # the + key without shift
46
+ "-": ("change_volume", -VOLUME_STEP),
43
47
  "n": ("next",),
44
48
  "p": ("prev",),
45
49
  }
@@ -476,6 +480,8 @@ class MpvClient:
476
480
  interrupt_main() # run()'s Ctrl-C path quits and restores the terminal
477
481
  elif name == "status":
478
482
  return control.ok(**self.status())
483
+ elif name == "queue":
484
+ return control.ok(**self.queue_listing())
479
485
  else:
480
486
  return control.failure(f"unknown command {name}")
481
487
  return control.ok()
@@ -586,6 +592,11 @@ class MpvClient:
586
592
  self.index = 0
587
593
  return True
588
594
 
595
+ def queue_listing(self):
596
+ """Every queued video and the 1-based index of the current one, as the control socket sends them."""
597
+ with self.queue_lock:
598
+ return {"videos": [asdict(video) for video in self.queue], "index": self.index + 1}
599
+
589
600
  def current_title(self):
590
601
  # The queue knows the title before mpv does, so no file-name flicker on load.
591
602
  if self.queue: