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.
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/PKG-INFO +55 -5
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/README.md +54 -4
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/pyproject.toml +1 -1
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/pyproject.toml.orig +1 -1
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/src/ttyplayer/cli.py +167 -3
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/src/ttyplayer/favorites.py +5 -22
- ttyplayer-0.4.0/src/ttyplayer/history.py +38 -0
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/src/ttyplayer/player.py +8 -0
- ttyplayer-0.4.0/src/ttyplayer/playlists.py +99 -0
- ttyplayer-0.4.0/src/ttyplayer/settings.py +136 -0
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/src/ttyplayer/tui.py +383 -25
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/src/ttyplayer/tui.tcss +49 -0
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/src/ttyplayer/utils.py +39 -0
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/src/ttyplayer/youtube.py +7 -2
- ttyplayer-0.3.1/src/ttyplayer/history.py +0 -64
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/LICENSE +0 -0
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/src/ttyplayer/__init__.py +0 -0
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/src/ttyplayer/control.py +0 -0
- {ttyplayer-0.3.1 → ttyplayer-0.4.0}/src/ttyplayer/models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ttyplayer
|
|
3
|
-
Version: 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,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,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(
|
|
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
|
|
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
|
|
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
|
|
82
|
-
|
|
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}")
|