ytm 0.2.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.
- ytm-0.2.0/LICENSE +20 -0
- ytm-0.2.0/PKG-INFO +208 -0
- ytm-0.2.0/README.md +155 -0
- ytm-0.2.0/pyproject.toml +50 -0
- ytm-0.2.0/setup.cfg +4 -0
- ytm-0.2.0/tests/test_api.py +232 -0
- ytm-0.2.0/tests/test_cache.py +122 -0
- ytm-0.2.0/tests/test_cli_core.py +362 -0
- ytm-0.2.0/tests/test_config.py +102 -0
- ytm-0.2.0/tests/test_core_state_music_auth.py +185 -0
- ytm-0.2.0/tests/test_from_browser.py +109 -0
- ytm-0.2.0/tests/test_mpv_player.py +401 -0
- ytm-0.2.0/tests/test_oauth.py +223 -0
- ytm-0.2.0/tests/test_playlists.py +114 -0
- ytm-0.2.0/tests/test_tui.py +1408 -0
- ytm-0.2.0/tests/test_tui_backend.py +242 -0
- ytm-0.2.0/ytm/__init__.py +1 -0
- ytm-0.2.0/ytm/api.py +28 -0
- ytm-0.2.0/ytm/auth.py +372 -0
- ytm-0.2.0/ytm/cache.py +184 -0
- ytm-0.2.0/ytm/cli.py +503 -0
- ytm-0.2.0/ytm/config.py +149 -0
- ytm-0.2.0/ytm/mpv/autoplay.lua +64 -0
- ytm-0.2.0/ytm/music.py +372 -0
- ytm-0.2.0/ytm/player.py +442 -0
- ytm-0.2.0/ytm/playlists_local.py +183 -0
- ytm-0.2.0/ytm/state.py +79 -0
- ytm-0.2.0/ytm/tui/__init__.py +1 -0
- ytm-0.2.0/ytm/tui/app.py +657 -0
- ytm-0.2.0/ytm/tui/app.tcss +118 -0
- ytm-0.2.0/ytm/tui/backend.py +364 -0
- ytm-0.2.0/ytm/tui/lyrics.py +29 -0
- ytm-0.2.0/ytm/tui/nowplaying.py +203 -0
- ytm-0.2.0/ytm/tui/playlists.py +111 -0
- ytm-0.2.0/ytm/tui/queue.py +58 -0
- ytm-0.2.0/ytm/tui/search.py +54 -0
- ytm-0.2.0/ytm/tui/widgets.py +22 -0
- ytm-0.2.0/ytm.egg-info/PKG-INFO +208 -0
- ytm-0.2.0/ytm.egg-info/SOURCES.txt +41 -0
- ytm-0.2.0/ytm.egg-info/dependency_links.txt +1 -0
- ytm-0.2.0/ytm.egg-info/entry_points.txt +2 -0
- ytm-0.2.0/ytm.egg-info/requires.txt +8 -0
- ytm-0.2.0/ytm.egg-info/top_level.txt +1 -0
ytm-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mahesh Koduri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR OTHER USE OF THE SOFTWARE.
|
ytm-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ytm
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: YouTube Music in the terminal: a Textual TUI and CLI on top of mpv
|
|
5
|
+
Author: Mahesh Koduri
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Mahesh Koduri
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR OTHER USE OF THE SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Homepage, https://github.com/MaheshBhushan/yt-music-cli
|
|
28
|
+
Project-URL: Source, https://github.com/MaheshBhushan/yt-music-cli
|
|
29
|
+
Project-URL: Issues, https://github.com/MaheshBhushan/yt-music-cli/issues
|
|
30
|
+
Keywords: youtube-music,music,player,tui,cli,mpv,textual
|
|
31
|
+
Classifier: Environment :: Console
|
|
32
|
+
Classifier: Environment :: Console :: Curses
|
|
33
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
36
|
+
Classifier: Operating System :: MacOS
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Players
|
|
42
|
+
Requires-Python: >=3.11
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
License-File: LICENSE
|
|
45
|
+
Requires-Dist: ytmusicapi
|
|
46
|
+
Requires-Dist: yt-dlp
|
|
47
|
+
Requires-Dist: bgutil-ytdlp-pot-provider
|
|
48
|
+
Requires-Dist: textual
|
|
49
|
+
Requires-Dist: textual-image
|
|
50
|
+
Provides-Extra: dev
|
|
51
|
+
Requires-Dist: pytest; extra == "dev"
|
|
52
|
+
Dynamic: license-file
|
|
53
|
+
|
|
54
|
+
<h1 align="center">ytm</h1>
|
|
55
|
+
<p align="center">YouTube Music in the terminal: search, queue, radio and lyrics, with mpv doing the playing.</p>
|
|
56
|
+
|
|
57
|
+
<p align="center">
|
|
58
|
+
<img alt="PyPI" src="https://img.shields.io/pypi/v/ytm">
|
|
59
|
+
<img alt="Tests" src="https://github.com/MaheshBhushan/yt-music-cli/actions/workflows/tests.yml/badge.svg">
|
|
60
|
+
<img alt="License" src="https://img.shields.io/github/license/MaheshBhushan/yt-music-cli">
|
|
61
|
+
<img alt="Last commit" src="https://img.shields.io/github/last-commit/MaheshBhushan/yt-music-cli">
|
|
62
|
+
<img alt="Python 3.11+" src="https://img.shields.io/badge/python-3.11%2B-blue">
|
|
63
|
+
</p>
|
|
64
|
+
|
|
65
|
+

|
|
66
|
+
|
|
67
|
+
## Overview
|
|
68
|
+
|
|
69
|
+
YouTube Music has no desktop client that is not a browser. `ytm` is a small Python CLI and a Textual TUI over three tools that already do the hard parts: [ytmusicapi](https://github.com/sigma67/ytmusicapi) for the catalogue, [yt-dlp](https://github.com/yt-dlp/yt-dlp) for stream resolution and [mpv](https://mpv.io) for audio.
|
|
70
|
+
|
|
71
|
+
mpv is the only long-running process. `ytm` starts it once, idle, with a JSON IPC socket, and every command after that is a stateless message to it. Close the terminal and the music keeps playing. A Lua script inside mpv keeps the queue fed with the station for whatever is playing, so it never runs dry.
|
|
72
|
+
|
|
73
|
+
## Quickstart
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pipx install ytm # or: uv tool install ytm / pip install ytm
|
|
77
|
+
|
|
78
|
+
ytm auth # cookies from a logged-in browser, see Authentication
|
|
79
|
+
ytm play "daft punk" # search, play the first hit, radio follows
|
|
80
|
+
ytm # the TUI
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
To hack on it instead:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
git clone https://github.com/MaheshBhushan/yt-music-cli.git && cd yt-music-cli
|
|
87
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
88
|
+
pip install -e '.[dev]'
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
> [!IMPORTANT]
|
|
92
|
+
> `mpv` must be on your `PATH`; pip cannot install it. `pacman -S mpv`, `apt install mpv`, `brew install mpv`, or the installers at <https://mpv.io>. Node is optional but recommended: yt-dlp uses it to solve YouTube's JavaScript challenges.
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
The TUI is `ytm` with no arguments. Type a query and press Enter to play the first result. Every key is listed in the bar at the bottom, and everything is clickable: results, queue rows, playlists, the progress bar, the shortcuts.
|
|
97
|
+
|
|
98
|
+
| Key | Action |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `/` or `s` | Focus search |
|
|
101
|
+
| `Enter` | Play the selected result, queue entry or playlist |
|
|
102
|
+
| `q` | Enqueue the selected result |
|
|
103
|
+
| `space` | Play / pause |
|
|
104
|
+
| `n` `p` | Next / previous |
|
|
105
|
+
| `←` `→` | Seek 5 s |
|
|
106
|
+
| `+` `-` | Volume |
|
|
107
|
+
| `a` | Add the selected song to a playlist: `a`, pick the list with `↑` `↓`, `a` or `Enter` |
|
|
108
|
+
| `l` | Focus playlists |
|
|
109
|
+
| `Tab` | Cycle panes |
|
|
110
|
+
| `e` | Exit, music keeps playing |
|
|
111
|
+
| `x` | Exit and stop mpv |
|
|
112
|
+
|
|
113
|
+
One-shot commands talk to the same mpv. Add `--json` to any of them for machine-readable output.
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
ytm search "song name" -n 10 # results are numbered
|
|
117
|
+
ytm play 3 # a number from the last search, an 11-char video id, or a query
|
|
118
|
+
ytm add 4 # enqueue
|
|
119
|
+
ytm radio # replace the queue with a station for the current track
|
|
120
|
+
ytm status | queue | lyrics | like
|
|
121
|
+
ytm pause | resume | toggle | next | prev | stop
|
|
122
|
+
ytm seek -10 | seek --to 90 | volume 60 | clear | shuffle
|
|
123
|
+
ytm quit # stop mpv entirely
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The queue never holds a track twice: playing something already queued jumps to it, and radio skips what is there.
|
|
127
|
+
|
|
128
|
+
## Authentication
|
|
129
|
+
|
|
130
|
+
Search works signed out, but library, playlists, likes and lyrics need your account. Credentials live in `~/.config/ytm/auth.json` (mode 0600) and are validated with a live call before being kept.
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
ytm auth # cookies from Chrome, Chromium, Edge, Brave, Vivaldi, Opera or Firefox
|
|
134
|
+
ytm auth --from-browser firefox # pick one
|
|
135
|
+
ytm auth --manual # paste request headers copied from DevTools
|
|
136
|
+
ytm auth --oauth # device-code flow, for SSH and headless boxes
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Browser cookies expire after a few weeks; re-run `ytm auth` when the app says so. OAuth needs your own Google Cloud client (YouTube removed the shared one in 2024): create an OAuth client of type *TVs and Limited Input devices* and pass `--client-id`/`--client-secret`, or set `YTM_OAUTH_CLIENT_ID`/`YTM_OAUTH_CLIENT_SECRET`.
|
|
140
|
+
|
|
141
|
+
> [!NOTE]
|
|
142
|
+
> Streams resolve **anonymously by default**. With account cookies, YouTube hands out URLs that require an account-bound proof-of-origin token and then answers 403. Anonymous resolution plays the same catalogue. Set `behaviour.authenticated_streams = true` only if you need private or age-gated tracks.
|
|
143
|
+
|
|
144
|
+
## Configuration
|
|
145
|
+
|
|
146
|
+
`~/.config/ytm/config.toml`. A missing file means these defaults; a partial file overrides only what it names; a bad value is warned about and ignored.
|
|
147
|
+
|
|
148
|
+
```toml
|
|
149
|
+
[audio]
|
|
150
|
+
volume = 70
|
|
151
|
+
device = "auto" # an mpv --audio-device name
|
|
152
|
+
|
|
153
|
+
[behaviour]
|
|
154
|
+
autoplay_radio = true # keep the queue fed with radio
|
|
155
|
+
confirm_remote_delete = true
|
|
156
|
+
authenticated_streams = false # see the note above
|
|
157
|
+
|
|
158
|
+
[ui]
|
|
159
|
+
theme = "dark" # or "light"
|
|
160
|
+
art = "blocks" # blocks | kitty | sixel | auto | ascii | off
|
|
161
|
+
|
|
162
|
+
[pot]
|
|
163
|
+
enabled = true # proof-of-origin tokens via bgutil-ytdlp-pot-provider
|
|
164
|
+
base_url = "http://127.0.0.1:4416"
|
|
165
|
+
|
|
166
|
+
[keys]
|
|
167
|
+
toggle = "space"
|
|
168
|
+
next = "n"
|
|
169
|
+
prev = "p"
|
|
170
|
+
search = "/"
|
|
171
|
+
quit = "e"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`art = "blocks"` draws the cover with coloured half-cell glyphs and works in every terminal, tmux included. `kitty` and `sixel` use the terminal's pixel protocol; Sixel is known to freeze the pane in Konsole, which is why it is opt-in.
|
|
175
|
+
|
|
176
|
+
The proof-of-origin token provider is a yt-dlp plugin installed with `ytm`. It asks an HTTP service for tokens when YouTube demands one; run `docker run -d --name bgutil-provider -p 4416:4416 brainicism/bgutil-ytdlp-pot-provider` if you want it, or set `enabled = false`. Playback works without it for most accounts.
|
|
177
|
+
|
|
178
|
+
## More
|
|
179
|
+
|
|
180
|
+
- **Offline cache.** `ytm cache add <video_id>` downloads a track into `~/.cache/ytm/tracks/`; `cache rm` and `cache list` manage it. 2 GB cap, least-recently-played evicted first.
|
|
181
|
+
- **Local playlists** live in `~/.local/state/ytm/playlists.json` and show up next to your YouTube Music playlists in the TUI.
|
|
182
|
+
- **Media keys.** `ytm` has no MPRIS of its own; install the [mpv-mpris](https://github.com/hoyon/mpv-mpris) plugin and mpv announces itself to your desktop.
|
|
183
|
+
- **yt-dlp goes stale.** YouTube changes things; yt-dlp follows within days. `pipx upgrade ytm` (or `pip install -U yt-dlp` in the same environment) fixes most sudden resolution failures.
|
|
184
|
+
- **Windows** is supported in design (named-pipe IPC, no D-Bus) but has not been tested.
|
|
185
|
+
- **Logs.** mpv writes to `~/.local/state/ytm/mpv.log`.
|
|
186
|
+
|
|
187
|
+
## Repository structure
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
ytm/
|
|
191
|
+
cli.py commands and the mpv launch configuration
|
|
192
|
+
player.py Player: mpv over JSON IPC
|
|
193
|
+
music.py ytmusicapi wrappers, Track
|
|
194
|
+
state.py remembered searches and track metadata
|
|
195
|
+
auth.py browser cookies, DevTools headers, OAuth
|
|
196
|
+
cache.py offline downloads
|
|
197
|
+
mpv/autoplay.lua radio autoplay inside mpv
|
|
198
|
+
tui/ Textual app, panes, backend over Player
|
|
199
|
+
tests/ pytest; no network and no mpv needed
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
pip install -e '.[dev]' && pytest -q
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT, see [LICENSE](LICENSE).
|
ytm-0.2.0/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<h1 align="center">ytm</h1>
|
|
2
|
+
<p align="center">YouTube Music in the terminal: search, queue, radio and lyrics, with mpv doing the playing.</p>
|
|
3
|
+
|
|
4
|
+
<p align="center">
|
|
5
|
+
<img alt="PyPI" src="https://img.shields.io/pypi/v/ytm">
|
|
6
|
+
<img alt="Tests" src="https://github.com/MaheshBhushan/yt-music-cli/actions/workflows/tests.yml/badge.svg">
|
|
7
|
+
<img alt="License" src="https://img.shields.io/github/license/MaheshBhushan/yt-music-cli">
|
|
8
|
+
<img alt="Last commit" src="https://img.shields.io/github/last-commit/MaheshBhushan/yt-music-cli">
|
|
9
|
+
<img alt="Python 3.11+" src="https://img.shields.io/badge/python-3.11%2B-blue">
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
## Overview
|
|
15
|
+
|
|
16
|
+
YouTube Music has no desktop client that is not a browser. `ytm` is a small Python CLI and a Textual TUI over three tools that already do the hard parts: [ytmusicapi](https://github.com/sigma67/ytmusicapi) for the catalogue, [yt-dlp](https://github.com/yt-dlp/yt-dlp) for stream resolution and [mpv](https://mpv.io) for audio.
|
|
17
|
+
|
|
18
|
+
mpv is the only long-running process. `ytm` starts it once, idle, with a JSON IPC socket, and every command after that is a stateless message to it. Close the terminal and the music keeps playing. A Lua script inside mpv keeps the queue fed with the station for whatever is playing, so it never runs dry.
|
|
19
|
+
|
|
20
|
+
## Quickstart
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pipx install ytm # or: uv tool install ytm / pip install ytm
|
|
24
|
+
|
|
25
|
+
ytm auth # cookies from a logged-in browser, see Authentication
|
|
26
|
+
ytm play "daft punk" # search, play the first hit, radio follows
|
|
27
|
+
ytm # the TUI
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
To hack on it instead:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git clone https://github.com/MaheshBhushan/yt-music-cli.git && cd yt-music-cli
|
|
34
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
35
|
+
pip install -e '.[dev]'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> [!IMPORTANT]
|
|
39
|
+
> `mpv` must be on your `PATH`; pip cannot install it. `pacman -S mpv`, `apt install mpv`, `brew install mpv`, or the installers at <https://mpv.io>. Node is optional but recommended: yt-dlp uses it to solve YouTube's JavaScript challenges.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
The TUI is `ytm` with no arguments. Type a query and press Enter to play the first result. Every key is listed in the bar at the bottom, and everything is clickable: results, queue rows, playlists, the progress bar, the shortcuts.
|
|
44
|
+
|
|
45
|
+
| Key | Action |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `/` or `s` | Focus search |
|
|
48
|
+
| `Enter` | Play the selected result, queue entry or playlist |
|
|
49
|
+
| `q` | Enqueue the selected result |
|
|
50
|
+
| `space` | Play / pause |
|
|
51
|
+
| `n` `p` | Next / previous |
|
|
52
|
+
| `←` `→` | Seek 5 s |
|
|
53
|
+
| `+` `-` | Volume |
|
|
54
|
+
| `a` | Add the selected song to a playlist: `a`, pick the list with `↑` `↓`, `a` or `Enter` |
|
|
55
|
+
| `l` | Focus playlists |
|
|
56
|
+
| `Tab` | Cycle panes |
|
|
57
|
+
| `e` | Exit, music keeps playing |
|
|
58
|
+
| `x` | Exit and stop mpv |
|
|
59
|
+
|
|
60
|
+
One-shot commands talk to the same mpv. Add `--json` to any of them for machine-readable output.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
ytm search "song name" -n 10 # results are numbered
|
|
64
|
+
ytm play 3 # a number from the last search, an 11-char video id, or a query
|
|
65
|
+
ytm add 4 # enqueue
|
|
66
|
+
ytm radio # replace the queue with a station for the current track
|
|
67
|
+
ytm status | queue | lyrics | like
|
|
68
|
+
ytm pause | resume | toggle | next | prev | stop
|
|
69
|
+
ytm seek -10 | seek --to 90 | volume 60 | clear | shuffle
|
|
70
|
+
ytm quit # stop mpv entirely
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The queue never holds a track twice: playing something already queued jumps to it, and radio skips what is there.
|
|
74
|
+
|
|
75
|
+
## Authentication
|
|
76
|
+
|
|
77
|
+
Search works signed out, but library, playlists, likes and lyrics need your account. Credentials live in `~/.config/ytm/auth.json` (mode 0600) and are validated with a live call before being kept.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
ytm auth # cookies from Chrome, Chromium, Edge, Brave, Vivaldi, Opera or Firefox
|
|
81
|
+
ytm auth --from-browser firefox # pick one
|
|
82
|
+
ytm auth --manual # paste request headers copied from DevTools
|
|
83
|
+
ytm auth --oauth # device-code flow, for SSH and headless boxes
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Browser cookies expire after a few weeks; re-run `ytm auth` when the app says so. OAuth needs your own Google Cloud client (YouTube removed the shared one in 2024): create an OAuth client of type *TVs and Limited Input devices* and pass `--client-id`/`--client-secret`, or set `YTM_OAUTH_CLIENT_ID`/`YTM_OAUTH_CLIENT_SECRET`.
|
|
87
|
+
|
|
88
|
+
> [!NOTE]
|
|
89
|
+
> Streams resolve **anonymously by default**. With account cookies, YouTube hands out URLs that require an account-bound proof-of-origin token and then answers 403. Anonymous resolution plays the same catalogue. Set `behaviour.authenticated_streams = true` only if you need private or age-gated tracks.
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
`~/.config/ytm/config.toml`. A missing file means these defaults; a partial file overrides only what it names; a bad value is warned about and ignored.
|
|
94
|
+
|
|
95
|
+
```toml
|
|
96
|
+
[audio]
|
|
97
|
+
volume = 70
|
|
98
|
+
device = "auto" # an mpv --audio-device name
|
|
99
|
+
|
|
100
|
+
[behaviour]
|
|
101
|
+
autoplay_radio = true # keep the queue fed with radio
|
|
102
|
+
confirm_remote_delete = true
|
|
103
|
+
authenticated_streams = false # see the note above
|
|
104
|
+
|
|
105
|
+
[ui]
|
|
106
|
+
theme = "dark" # or "light"
|
|
107
|
+
art = "blocks" # blocks | kitty | sixel | auto | ascii | off
|
|
108
|
+
|
|
109
|
+
[pot]
|
|
110
|
+
enabled = true # proof-of-origin tokens via bgutil-ytdlp-pot-provider
|
|
111
|
+
base_url = "http://127.0.0.1:4416"
|
|
112
|
+
|
|
113
|
+
[keys]
|
|
114
|
+
toggle = "space"
|
|
115
|
+
next = "n"
|
|
116
|
+
prev = "p"
|
|
117
|
+
search = "/"
|
|
118
|
+
quit = "e"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`art = "blocks"` draws the cover with coloured half-cell glyphs and works in every terminal, tmux included. `kitty` and `sixel` use the terminal's pixel protocol; Sixel is known to freeze the pane in Konsole, which is why it is opt-in.
|
|
122
|
+
|
|
123
|
+
The proof-of-origin token provider is a yt-dlp plugin installed with `ytm`. It asks an HTTP service for tokens when YouTube demands one; run `docker run -d --name bgutil-provider -p 4416:4416 brainicism/bgutil-ytdlp-pot-provider` if you want it, or set `enabled = false`. Playback works without it for most accounts.
|
|
124
|
+
|
|
125
|
+
## More
|
|
126
|
+
|
|
127
|
+
- **Offline cache.** `ytm cache add <video_id>` downloads a track into `~/.cache/ytm/tracks/`; `cache rm` and `cache list` manage it. 2 GB cap, least-recently-played evicted first.
|
|
128
|
+
- **Local playlists** live in `~/.local/state/ytm/playlists.json` and show up next to your YouTube Music playlists in the TUI.
|
|
129
|
+
- **Media keys.** `ytm` has no MPRIS of its own; install the [mpv-mpris](https://github.com/hoyon/mpv-mpris) plugin and mpv announces itself to your desktop.
|
|
130
|
+
- **yt-dlp goes stale.** YouTube changes things; yt-dlp follows within days. `pipx upgrade ytm` (or `pip install -U yt-dlp` in the same environment) fixes most sudden resolution failures.
|
|
131
|
+
- **Windows** is supported in design (named-pipe IPC, no D-Bus) but has not been tested.
|
|
132
|
+
- **Logs.** mpv writes to `~/.local/state/ytm/mpv.log`.
|
|
133
|
+
|
|
134
|
+
## Repository structure
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
ytm/
|
|
138
|
+
cli.py commands and the mpv launch configuration
|
|
139
|
+
player.py Player: mpv over JSON IPC
|
|
140
|
+
music.py ytmusicapi wrappers, Track
|
|
141
|
+
state.py remembered searches and track metadata
|
|
142
|
+
auth.py browser cookies, DevTools headers, OAuth
|
|
143
|
+
cache.py offline downloads
|
|
144
|
+
mpv/autoplay.lua radio autoplay inside mpv
|
|
145
|
+
tui/ Textual app, panes, backend over Player
|
|
146
|
+
tests/ pytest; no network and no mpv needed
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
pip install -e '.[dev]' && pytest -q
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT, see [LICENSE](LICENSE).
|
ytm-0.2.0/pyproject.toml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ytm"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "YouTube Music in the terminal: a Textual TUI and CLI on top of mpv"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
authors = [{ name = "Mahesh Koduri" }]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
keywords = ["youtube-music", "music", "player", "tui", "cli", "mpv", "textual"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Environment :: Console :: Curses",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Multimedia :: Sound/Audio :: Players",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"ytmusicapi",
|
|
29
|
+
"yt-dlp",
|
|
30
|
+
"bgutil-ytdlp-pot-provider",
|
|
31
|
+
"textual",
|
|
32
|
+
"textual-image",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = ["pytest"]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
ytm = "ytm.cli:main"
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/MaheshBhushan/yt-music-cli"
|
|
43
|
+
Source = "https://github.com/MaheshBhushan/yt-music-cli"
|
|
44
|
+
Issues = "https://github.com/MaheshBhushan/yt-music-cli/issues"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
include = ["ytm*"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
ytm = ["mpv/*.lua", "tui/*.tcss"]
|
ytm-0.2.0/setup.cfg
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Tests for search-result normalisation and typed auth errors."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from ytmusicapi.exceptions import YTMusicServerError
|
|
6
|
+
|
|
7
|
+
from ytm import api, auth
|
|
8
|
+
|
|
9
|
+
# Hand-written fixture mirroring the shapes ytmusicapi 1.12.1 actually returns
|
|
10
|
+
# for search(filter="songs"), including the degenerate ones.
|
|
11
|
+
SEARCH_RESPONSE = [
|
|
12
|
+
{
|
|
13
|
+
"category": "Songs",
|
|
14
|
+
"resultType": "song",
|
|
15
|
+
"videoId": "ZrOKjDZOtkA",
|
|
16
|
+
"title": "Wonderwall",
|
|
17
|
+
"artists": [{"name": "Oasis", "id": "UCmMUZbaYdNH0bEd1PAlAqsA"}],
|
|
18
|
+
"album": {"name": "(What's The Story) Morning Glory?", "id": "MPREb_9nqEki4ZDpp"},
|
|
19
|
+
"duration": "4:19",
|
|
20
|
+
"duration_seconds": 259,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
# no album key at all
|
|
24
|
+
"resultType": "song",
|
|
25
|
+
"videoId": "noalbum123",
|
|
26
|
+
"title": "Untitled Demo",
|
|
27
|
+
"artists": [{"name": "Some Artist", "id": "UC1"}],
|
|
28
|
+
"duration": "2:05",
|
|
29
|
+
"duration_seconds": 125,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
# empty artists list
|
|
33
|
+
"resultType": "song",
|
|
34
|
+
"videoId": "noartist123",
|
|
35
|
+
"title": "Field Recording",
|
|
36
|
+
"artists": [],
|
|
37
|
+
"album": None,
|
|
38
|
+
"duration": "1:00",
|
|
39
|
+
"duration_seconds": 60,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
# null duration
|
|
43
|
+
"resultType": "song",
|
|
44
|
+
"videoId": "nodur123",
|
|
45
|
+
"title": "Mystery Length",
|
|
46
|
+
"artists": [{"name": "Anon"}],
|
|
47
|
+
"album": {"name": "Anon EP"},
|
|
48
|
+
"duration": None,
|
|
49
|
+
"duration_seconds": None,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
# personal upload: out of scope, must be filtered out
|
|
53
|
+
"resultType": "song",
|
|
54
|
+
"videoId": "upload123",
|
|
55
|
+
"title": "My Own Rip",
|
|
56
|
+
"artists": [{"name": "Me"}],
|
|
57
|
+
"videoType": "MUSIC_VIDEO_TYPE_PRIVATELY_OWNED_TRACK",
|
|
58
|
+
"duration": "3:00",
|
|
59
|
+
"duration_seconds": 180,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
# upload from a library-scope search, identified by resultType/entityId
|
|
63
|
+
"resultType": "upload",
|
|
64
|
+
"entityId": "t_po_abc",
|
|
65
|
+
"videoId": "upload456",
|
|
66
|
+
"title": "Another Rip",
|
|
67
|
+
"artists": [{"name": "Me"}],
|
|
68
|
+
"duration": "3:30",
|
|
69
|
+
"duration_seconds": 210,
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class FakeYTMusic:
|
|
75
|
+
"""Stand-in for ytmusicapi.YTMusic; never touches the network."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, results=None, error=None):
|
|
78
|
+
self._results = results if results is not None else []
|
|
79
|
+
self._error = error
|
|
80
|
+
self.calls = []
|
|
81
|
+
|
|
82
|
+
def search(self, query, filter=None, limit=20):
|
|
83
|
+
self.calls.append({"query": query, "filter": filter, "limit": limit})
|
|
84
|
+
if self._error:
|
|
85
|
+
raise self._error
|
|
86
|
+
return self._results
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_normalises_fixture_into_tracks():
|
|
90
|
+
tracks = api.to_tracks(SEARCH_RESPONSE)
|
|
91
|
+
assert [t.video_id for t in tracks] == ["ZrOKjDZOtkA", "noalbum123", "noartist123", "nodur123"]
|
|
92
|
+
first = tracks[0]
|
|
93
|
+
assert first.title == "Wonderwall"
|
|
94
|
+
assert first.artist == "Oasis"
|
|
95
|
+
assert first.album == "(What's The Story) Morning Glory?"
|
|
96
|
+
assert first.duration == "4:19"
|
|
97
|
+
assert first.duration_seconds == 259
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_missing_album_normalises_to_empty_string():
|
|
101
|
+
no_album, null_album = api.to_tracks(SEARCH_RESPONSE)[1:3]
|
|
102
|
+
assert no_album.album == ""
|
|
103
|
+
assert null_album.album == ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_empty_artists_normalises_to_placeholder():
|
|
107
|
+
track = api.to_tracks(SEARCH_RESPONSE)[2]
|
|
108
|
+
assert track.artist == "Unknown Artist"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_null_duration_normalises_to_zero():
|
|
112
|
+
track = api.to_tracks(SEARCH_RESPONSE)[3]
|
|
113
|
+
assert track.duration_seconds == 0
|
|
114
|
+
assert track.duration == "0:00"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_uploads_filtered_out_of_search_results():
|
|
118
|
+
yt = FakeYTMusic(results=SEARCH_RESPONSE)
|
|
119
|
+
tracks = api.search("wonderwall", yt=yt)
|
|
120
|
+
assert all(t.video_id not in {"upload123", "upload456"} for t in tracks)
|
|
121
|
+
assert "My Own Rip" not in [t.title for t in tracks]
|
|
122
|
+
assert len(tracks) == 4
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_search_uses_songs_filter():
|
|
126
|
+
yt = FakeYTMusic(results=[])
|
|
127
|
+
api.search("kaanave kaanave", yt=yt)
|
|
128
|
+
assert yt.calls[0]["filter"] == "songs"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_expired_auth_raises_typed_autherror_not_raw_traceback():
|
|
132
|
+
expired = YTMusicServerError(
|
|
133
|
+
"Server returned HTTP 401: Unauthorized.\nRequest had invalid authentication credentials."
|
|
134
|
+
)
|
|
135
|
+
yt = FakeYTMusic(error=expired)
|
|
136
|
+
with pytest.raises(auth.AuthExpired) as excinfo:
|
|
137
|
+
api.search("anything", yt=yt)
|
|
138
|
+
assert "ytm auth" in str(excinfo.value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_non_auth_server_error_is_not_swallowed():
|
|
142
|
+
yt = FakeYTMusic(error=YTMusicServerError("Server returned HTTP 500: Internal Server Error.\nboom"))
|
|
143
|
+
with pytest.raises(YTMusicServerError):
|
|
144
|
+
api.search("anything", yt=yt)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class FakeLyricsYTMusic:
|
|
148
|
+
"""Stand-in for the two calls behind api.get_lyrics."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, browse_id="browse-1", lyrics_result=None, error=None):
|
|
151
|
+
self._browse_id = browse_id
|
|
152
|
+
self._lyrics_result = lyrics_result
|
|
153
|
+
self._error = error
|
|
154
|
+
self.watch_calls = []
|
|
155
|
+
self.lyrics_calls = []
|
|
156
|
+
|
|
157
|
+
def get_watch_playlist(self, videoId=None):
|
|
158
|
+
self.watch_calls.append(videoId)
|
|
159
|
+
if self._error:
|
|
160
|
+
raise self._error
|
|
161
|
+
data = {"tracks": []}
|
|
162
|
+
if self._browse_id is not None:
|
|
163
|
+
data["lyrics"] = self._browse_id
|
|
164
|
+
return data
|
|
165
|
+
|
|
166
|
+
def get_lyrics(self, browseId):
|
|
167
|
+
self.lyrics_calls.append(browseId)
|
|
168
|
+
return self._lyrics_result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_get_lyrics_returns_normalised_text_and_source():
|
|
172
|
+
yt = FakeLyricsYTMusic(
|
|
173
|
+
browse_id="browse-1",
|
|
174
|
+
lyrics_result={"lyrics": "some lyrics text", "source": "Musixmatch"},
|
|
175
|
+
)
|
|
176
|
+
lyrics, source = api.get_lyrics("PYgcJpC6WAQ", yt=yt)
|
|
177
|
+
assert lyrics == "some lyrics text"
|
|
178
|
+
assert source == "Musixmatch"
|
|
179
|
+
assert yt.watch_calls == ["PYgcJpC6WAQ"]
|
|
180
|
+
assert yt.lyrics_calls == ["browse-1"]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_get_lyrics_missing_browse_id_returns_none_without_second_call():
|
|
184
|
+
yt = FakeLyricsYTMusic(browse_id=None)
|
|
185
|
+
lyrics, source = api.get_lyrics("no-lyrics-track", yt=yt)
|
|
186
|
+
assert lyrics is None
|
|
187
|
+
assert source is None
|
|
188
|
+
assert yt.lyrics_calls == []
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_get_lyrics_null_result_returns_none():
|
|
192
|
+
yt = FakeLyricsYTMusic(browse_id="browse-1", lyrics_result=None)
|
|
193
|
+
lyrics, source = api.get_lyrics("v1", yt=yt)
|
|
194
|
+
assert lyrics is None
|
|
195
|
+
assert source is None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_get_lyrics_expired_auth_raises_typed_autherror():
|
|
199
|
+
expired = YTMusicServerError(
|
|
200
|
+
"Server returned HTTP 401: Unauthorized.\nRequest had invalid authentication credentials."
|
|
201
|
+
)
|
|
202
|
+
yt = FakeLyricsYTMusic(error=expired)
|
|
203
|
+
with pytest.raises(auth.AuthExpired):
|
|
204
|
+
api.get_lyrics("v1", yt=yt)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_playlist_normalisation_marks_remote_and_local():
|
|
208
|
+
remote = api.to_playlist({"playlistId": "PL1", "title": "Mix", "count": "12"})
|
|
209
|
+
assert (remote.playlist_id, remote.title, remote.track_count, remote.local) == ("PL1", "Mix", 12, False)
|
|
210
|
+
local = api.to_playlist({"playlistId": "local-1", "title": "Offline"}, local=True)
|
|
211
|
+
assert local.local is True
|
|
212
|
+
assert local.track_count == 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_missing_auth_file_raises_authmissing(tmp_path):
|
|
216
|
+
with pytest.raises(auth.AuthMissing) as excinfo:
|
|
217
|
+
auth.load_headers(tmp_path / "auth.json")
|
|
218
|
+
assert "ytm auth" in str(excinfo.value)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_cookies_exposed_for_stream_resolution(tmp_path):
|
|
222
|
+
path = tmp_path / "auth.json"
|
|
223
|
+
path.write_text(json.dumps({"Cookie": "SID=abc; HSID=def", "authorization": "SAPISIDHASH x"}))
|
|
224
|
+
assert auth.load_cookies(path) == "SID=abc; HSID=def"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_setup_writes_file_with_mode_0600(tmp_path, monkeypatch):
|
|
228
|
+
path = tmp_path / "config" / "auth.json"
|
|
229
|
+
monkeypatch.setattr(auth.ytmusicapi, "setup", lambda: json.dumps({"cookie": "SID=abc"}))
|
|
230
|
+
auth.setup(path)
|
|
231
|
+
assert path.stat().st_mode & 0o777 == 0o600
|
|
232
|
+
assert json.loads(path.read_text())["cookie"] == "SID=abc"
|