ttyplayer 0.3.1__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.1
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,6 +60,25 @@ ttyplayer stop quit the player
58
60
  ttyplayer status 1:23 / 4:56 Playing <title>
59
61
  ```
60
62
 
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
+
61
82
  Keys in `ttyplayer play` (the terminal player; `ttyplayer tui` has its own table below):
62
83
 
63
84
  | Key | Action |
@@ -78,17 +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
  |---|---|---|
85
106
  | table | `↑` / `↓` | move through the list (volume is `-` / `+`) |
86
107
  | anywhere | `/` | focus the search box (`esc` returns to the table) |
87
108
  | anywhere | `?` | help: every key and command (`esc` closes) |
88
- | anywhere | `1` `2` `3` `4` | Search / Queue / History / Favorites tab |
109
+ | anywhere | `1` `2` `3` `4` `5` | Search / Queue / History / Favorites / Playlists tab |
89
110
  | anywhere | Ctrl-C | quit and stop mpv |
90
111
  | anywhere | Ctrl-P | command palette |
91
- | 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 |
92
115
  | table | `q` | quit and stop mpv (in the search box it is just a letter) |
93
116
  | table | `space` | pause / resume |
94
117
  | table | `n` / `p` | next / previous |
@@ -99,15 +122,42 @@ Picks accept several numbers at once: `1 3 5` queues those three in that order.
99
122
  | table | `f` | favorite / unfavorite this row (the track playing when there is no row) |
100
123
  | Search · History · Favorites row | Enter | play this one, then the rows after it |
101
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…) |
102
126
  | Search | `m` | more results |
103
127
  | Queue row | Enter | jump to this item |
104
128
  | Queue row | `d` | remove from the queue |
105
129
  | Queue row | `K` / `J` (or `shift+↑` / `shift+↓`) | move up / down |
106
130
  | Queue | `c` | clear the queue (keeps the current track playing) |
107
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 |
108
139
 
109
140
  In the search box, letters, digits, `/` and `?` are typed as text; `esc` leaves it for the table.
110
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
+
111
161
  ## Install
112
162
 
113
163
  One command installs everything ttyplayer needs.
@@ -200,7 +250,7 @@ uv run pytest -q # default suite, live test
200
250
  TTYPLAYER_LIVE=1 uv run pytest -q tests/test_live.py # live tests, needs network
201
251
  ```
202
252
 
203
- 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.
204
254
 
205
255
  ### Releasing
206
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,6 +37,25 @@ ttyplayer stop quit the player
35
37
  ttyplayer status 1:23 / 4:56 Playing <title>
36
38
  ```
37
39
 
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
+
38
59
  Keys in `ttyplayer play` (the terminal player; `ttyplayer tui` has its own table below):
39
60
 
40
61
  | Key | Action |
@@ -55,17 +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
  |---|---|---|
62
83
  | table | `↑` / `↓` | move through the list (volume is `-` / `+`) |
63
84
  | anywhere | `/` | focus the search box (`esc` returns to the table) |
64
85
  | anywhere | `?` | help: every key and command (`esc` closes) |
65
- | anywhere | `1` `2` `3` `4` | Search / Queue / History / Favorites tab |
86
+ | anywhere | `1` `2` `3` `4` `5` | Search / Queue / History / Favorites / Playlists tab |
66
87
  | anywhere | Ctrl-C | quit and stop mpv |
67
88
  | anywhere | Ctrl-P | command palette |
68
- | 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 |
69
92
  | table | `q` | quit and stop mpv (in the search box it is just a letter) |
70
93
  | table | `space` | pause / resume |
71
94
  | table | `n` / `p` | next / previous |
@@ -76,15 +99,42 @@ Picks accept several numbers at once: `1 3 5` queues those three in that order.
76
99
  | table | `f` | favorite / unfavorite this row (the track playing when there is no row) |
77
100
  | Search · History · Favorites row | Enter | play this one, then the rows after it |
78
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…) |
79
103
  | Search | `m` | more results |
80
104
  | Queue row | Enter | jump to this item |
81
105
  | Queue row | `d` | remove from the queue |
82
106
  | Queue row | `K` / `J` (or `shift+↑` / `shift+↓`) | move up / down |
83
107
  | Queue | `c` | clear the queue (keeps the current track playing) |
84
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 |
85
116
 
86
117
  In the search box, letters, digits, `/` and `?` are typed as text; `esc` leaves it for the table.
87
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
+
88
138
  ## Install
89
139
 
90
140
  One command installs everything ttyplayer needs.
@@ -177,7 +227,7 @@ uv run pytest -q # default suite, live test
177
227
  TTYPLAYER_LIVE=1 uv run pytest -q tests/test_live.py # live tests, needs network
178
228
  ```
179
229
 
180
- 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.
181
231
 
182
232
  ### Releasing
183
233
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ttyplayer"
3
- version = "0.3.1"
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.1"
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.
@@ -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
@@ -479,6 +480,8 @@ class MpvClient:
479
480
  interrupt_main() # run()'s Ctrl-C path quits and restores the terminal
480
481
  elif name == "status":
481
482
  return control.ok(**self.status())
483
+ elif name == "queue":
484
+ return control.ok(**self.queue_listing())
482
485
  else:
483
486
  return control.failure(f"unknown command {name}")
484
487
  return control.ok()
@@ -589,6 +592,11 @@ class MpvClient:
589
592
  self.index = 0
590
593
  return True
591
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
+
592
600
  def current_title(self):
593
601
  # The queue knows the title before mpv does, so no file-name flicker on load.
594
602
  if self.queue:
@@ -0,0 +1,99 @@
1
+ """Named lists the user curates, one JSON-lines file per playlist, in the order the user keeps."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from ttyplayer.models import Video
7
+ from ttyplayer.utils import append_entries, data_path, read_entries, video_entry, video_from_info, write_entries
8
+
9
+ NAME = re.compile(r"[A-Za-z0-9 _-]{1,40}") # also keeps a name from escaping the playlists dir
10
+ NAME_RULE = "1 to 40 letters, digits, spaces, _ or -"
11
+
12
+
13
+ class PlaylistError(Exception):
14
+ """A bad playlist name, a missing playlist, or a position out of range."""
15
+
16
+
17
+ def playlists_dir() -> Path:
18
+ return data_path("playlists")
19
+
20
+
21
+ def playlist_path(name: str) -> Path:
22
+ if not NAME.fullmatch(name):
23
+ raise PlaylistError(f"Bad playlist name {name!r}: use {NAME_RULE}")
24
+ return playlists_dir() / f"{name}.jsonl"
25
+
26
+
27
+ def require(name: str) -> Path:
28
+ """The path of an existing playlist; PlaylistError if there is none by that name."""
29
+ path = playlist_path(name)
30
+ if not path.exists():
31
+ raise PlaylistError(f"No playlist named {name}")
32
+ return path
33
+
34
+
35
+ def sanitize(title: str) -> str:
36
+ """A playlist name made from a title: other characters dropped, spaces collapsed, cut to 40."""
37
+ kept = re.sub(r"[^A-Za-z0-9 _-]", " ", title)
38
+ return " ".join(kept.split())[:40].strip()
39
+
40
+
41
+ def names() -> list[str]:
42
+ directory = playlists_dir()
43
+ if not directory.is_dir():
44
+ return []
45
+ return sorted(path.stem for path in directory.glob("*.jsonl"))
46
+
47
+
48
+ def load(name: str) -> list[Video]:
49
+ """The playlist's videos in file order, repeats kept."""
50
+ return [video_from_info(entry) for entry in read_entries(require(name))]
51
+
52
+
53
+ def create(name: str):
54
+ path = playlist_path(name)
55
+ if path.exists():
56
+ raise PlaylistError(f"A playlist named {name} already exists")
57
+ write_entries(path, [])
58
+
59
+
60
+ def delete(name: str):
61
+ require(name).unlink()
62
+
63
+
64
+ def add(name: str, videos: list[Video]) -> int:
65
+ """Append videos to the playlist. Returns how many were added."""
66
+ append_entries(require(name), [video_entry(video, "added_at") for video in videos])
67
+ return len(videos)
68
+
69
+
70
+ def remove(name: str, number: int) -> Video:
71
+ """Drop the `number`th video (1-indexed) and return it."""
72
+ path = require(name)
73
+ entries = read_entries(path)
74
+ _check_position(name, number, entries)
75
+ removed = entries.pop(number - 1)
76
+ write_entries(path, entries)
77
+ return video_from_info(removed)
78
+
79
+
80
+ def move(name: str, source: int, target: int) -> Video:
81
+ """Move the `source`th video (1-indexed) to position `target` and return it."""
82
+ path = require(name)
83
+ entries = read_entries(path)
84
+ _check_position(name, source, entries)
85
+ _check_position(name, target, entries)
86
+ moved = entries.pop(source - 1)
87
+ entries.insert(target - 1, moved)
88
+ write_entries(path, entries)
89
+ return video_from_info(moved)
90
+
91
+
92
+ def replace(name: str, videos: list[Video]):
93
+ """Make the playlist exactly these videos, creating it if it does not exist."""
94
+ write_entries(playlist_path(name), [video_entry(video, "added_at") for video in videos])
95
+
96
+
97
+ def _check_position(name, number, entries):
98
+ if not 1 <= number <= len(entries):
99
+ raise PlaylistError(f"No video number {number} in {name}")