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.
Files changed (43) hide show
  1. ytm-0.2.0/LICENSE +20 -0
  2. ytm-0.2.0/PKG-INFO +208 -0
  3. ytm-0.2.0/README.md +155 -0
  4. ytm-0.2.0/pyproject.toml +50 -0
  5. ytm-0.2.0/setup.cfg +4 -0
  6. ytm-0.2.0/tests/test_api.py +232 -0
  7. ytm-0.2.0/tests/test_cache.py +122 -0
  8. ytm-0.2.0/tests/test_cli_core.py +362 -0
  9. ytm-0.2.0/tests/test_config.py +102 -0
  10. ytm-0.2.0/tests/test_core_state_music_auth.py +185 -0
  11. ytm-0.2.0/tests/test_from_browser.py +109 -0
  12. ytm-0.2.0/tests/test_mpv_player.py +401 -0
  13. ytm-0.2.0/tests/test_oauth.py +223 -0
  14. ytm-0.2.0/tests/test_playlists.py +114 -0
  15. ytm-0.2.0/tests/test_tui.py +1408 -0
  16. ytm-0.2.0/tests/test_tui_backend.py +242 -0
  17. ytm-0.2.0/ytm/__init__.py +1 -0
  18. ytm-0.2.0/ytm/api.py +28 -0
  19. ytm-0.2.0/ytm/auth.py +372 -0
  20. ytm-0.2.0/ytm/cache.py +184 -0
  21. ytm-0.2.0/ytm/cli.py +503 -0
  22. ytm-0.2.0/ytm/config.py +149 -0
  23. ytm-0.2.0/ytm/mpv/autoplay.lua +64 -0
  24. ytm-0.2.0/ytm/music.py +372 -0
  25. ytm-0.2.0/ytm/player.py +442 -0
  26. ytm-0.2.0/ytm/playlists_local.py +183 -0
  27. ytm-0.2.0/ytm/state.py +79 -0
  28. ytm-0.2.0/ytm/tui/__init__.py +1 -0
  29. ytm-0.2.0/ytm/tui/app.py +657 -0
  30. ytm-0.2.0/ytm/tui/app.tcss +118 -0
  31. ytm-0.2.0/ytm/tui/backend.py +364 -0
  32. ytm-0.2.0/ytm/tui/lyrics.py +29 -0
  33. ytm-0.2.0/ytm/tui/nowplaying.py +203 -0
  34. ytm-0.2.0/ytm/tui/playlists.py +111 -0
  35. ytm-0.2.0/ytm/tui/queue.py +58 -0
  36. ytm-0.2.0/ytm/tui/search.py +54 -0
  37. ytm-0.2.0/ytm/tui/widgets.py +22 -0
  38. ytm-0.2.0/ytm.egg-info/PKG-INFO +208 -0
  39. ytm-0.2.0/ytm.egg-info/SOURCES.txt +41 -0
  40. ytm-0.2.0/ytm.egg-info/dependency_links.txt +1 -0
  41. ytm-0.2.0/ytm.egg-info/entry_points.txt +2 -0
  42. ytm-0.2.0/ytm.egg-info/requires.txt +8 -0
  43. 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
+ ![ytm's TUI: search results on top, queue, playlists and lyrics in the middle, the current track with its cover at the bottom](docs/screenshot.png)
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
+ ![ytm's TUI: search results on top, queue, playlists and lyrics in the middle, the current track with its cover at the bottom](docs/screenshot.png)
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).
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"