ytm-cli 0.7.2__tar.gz → 0.8.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_cli-0.7.2 → ytm_cli-0.8.0}/PKG-INFO +7 -3
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/README.md +1 -1
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/pyproject.toml +7 -3
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_main.py +4 -8
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/__init__.py +1 -1
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/config.py +25 -1
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/dislikes.py +1 -1
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/ffmpeg_player.py +1 -1
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/hybrid_player.py +58 -11
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/main.py +96 -19
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/player.py +120 -24
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/playlists.py +1 -1
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/spectrum.py +1 -1
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/ui.py +17 -14
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli.egg-info/PKG-INFO +7 -3
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/LICENSE +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/setup.cfg +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_cache.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_config.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_dislikes.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_ffmpeg_player.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_hybrid_player.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_integration.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_lyrics_service.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_playlists.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_runner.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_spectrum.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_ui.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_utils.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/__main__.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/cache.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/demo.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/llm_client.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/lyrics_service.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/utils.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/verbose_logger.py +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli.egg-info/SOURCES.txt +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli.egg-info/dependency_links.txt +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli.egg-info/entry_points.txt +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli.egg-info/requires.txt +0 -0
- {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ytm-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: YouTube Music CLI tool with interactive terminal interface
|
|
5
5
|
Author-email: thieuluan <thieuluan.dark@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -14,9 +14,13 @@ Classifier: Intended Audience :: End Users/Desktop
|
|
|
14
14
|
Classifier: Operating System :: POSIX :: Linux
|
|
15
15
|
Classifier: Operating System :: MacOS
|
|
16
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
21
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
22
|
Classifier: Topic :: Multimedia :: Sound/Audio :: Players
|
|
19
|
-
Requires-Python: >=3.
|
|
23
|
+
Requires-Python: >=3.10
|
|
20
24
|
Description-Content-Type: text/markdown
|
|
21
25
|
License-File: LICENSE
|
|
22
26
|
Requires-Dist: ytmusicapi
|
|
@@ -60,7 +64,7 @@ Stream music directly from YouTube Music in your terminal with intuitive control
|
|
|
60
64
|
|
|
61
65
|
### Requirements
|
|
62
66
|
|
|
63
|
-
- Python **3.
|
|
67
|
+
- Python **3.10+** (set in `pyproject.toml`)
|
|
64
68
|
- [mpv media player](https://mpv.io/installation/) (must be installed system-wide)
|
|
65
69
|
- [`ffmpeg`](https://ffmpeg.org/) on `PATH` for the real-time spectrum visualizer (optional — falls back to a stereo oscilloscope if missing)
|
|
66
70
|
|
|
@@ -31,7 +31,7 @@ Stream music directly from YouTube Music in your terminal with intuitive control
|
|
|
31
31
|
|
|
32
32
|
### Requirements
|
|
33
33
|
|
|
34
|
-
- Python **3.
|
|
34
|
+
- Python **3.10+** (set in `pyproject.toml`)
|
|
35
35
|
- [mpv media player](https://mpv.io/installation/) (must be installed system-wide)
|
|
36
36
|
- [`ffmpeg`](https://ffmpeg.org/) on `PATH` for the real-time spectrum visualizer (optional — falls back to a stereo oscilloscope if missing)
|
|
37
37
|
|
|
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ytm-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "YouTube Music CLI tool with interactive terminal interface"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
|
-
requires-python = ">=3.
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
12
|
authors = [
|
|
13
13
|
{ name = "thieuluan", email = "thieuluan.dark@gmail.com" },
|
|
14
14
|
]
|
|
@@ -20,6 +20,10 @@ classifiers = [
|
|
|
20
20
|
"Operating System :: POSIX :: Linux",
|
|
21
21
|
"Operating System :: MacOS",
|
|
22
22
|
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
23
27
|
"Programming Language :: Python :: 3.14",
|
|
24
28
|
"Topic :: Multimedia :: Sound/Audio :: Players",
|
|
25
29
|
]
|
|
@@ -56,7 +60,7 @@ include = ["ytm_cli*"]
|
|
|
56
60
|
[tool.ruff]
|
|
57
61
|
# Set line length to match pylint (100 characters)
|
|
58
62
|
line-length = 100
|
|
59
|
-
target-version = "
|
|
63
|
+
target-version = "py310"
|
|
60
64
|
|
|
61
65
|
[tool.ruff.lint]
|
|
62
66
|
# Enable flake8 rules and other important checks
|
|
@@ -184,23 +184,19 @@ class TestPlaylistCommands:
|
|
|
184
184
|
|
|
185
185
|
playlist_create_command("New Playlist", "A test playlist")
|
|
186
186
|
|
|
187
|
-
mock_playlist_manager.create_playlist.assert_called_once_with(
|
|
188
|
-
"New Playlist", "A test playlist"
|
|
189
|
-
)
|
|
187
|
+
mock_playlist_manager.create_playlist.assert_called_once_with("New Playlist", "")
|
|
190
188
|
|
|
191
189
|
def test_playlist_create_command_prompt_for_name(self):
|
|
192
190
|
"""Test playlist creation command that prompts for name"""
|
|
193
191
|
with (
|
|
194
192
|
patch("ytm_cli.main.playlist_manager") as mock_playlist_manager,
|
|
195
|
-
patch("builtins.input", side_effect=["User Playlist"
|
|
193
|
+
patch("builtins.input", side_effect=["User Playlist"]),
|
|
196
194
|
):
|
|
197
195
|
mock_playlist_manager.create_playlist.return_value = True
|
|
198
196
|
|
|
199
197
|
playlist_create_command(None, None)
|
|
200
198
|
|
|
201
|
-
mock_playlist_manager.create_playlist.assert_called_once_with(
|
|
202
|
-
"User Playlist", "User description"
|
|
203
|
-
)
|
|
199
|
+
mock_playlist_manager.create_playlist.assert_called_once_with("User Playlist", "")
|
|
204
200
|
|
|
205
201
|
def test_playlist_show_command_success(self, sample_playlist_data):
|
|
206
202
|
"""Test successful playlist show command"""
|
|
@@ -277,7 +273,7 @@ class TestMainFunction:
|
|
|
277
273
|
):
|
|
278
274
|
main()
|
|
279
275
|
|
|
280
|
-
mock_search_and_play.assert_called_once_with("test song query")
|
|
276
|
+
mock_search_and_play.assert_called_once_with("test song query", None)
|
|
281
277
|
|
|
282
278
|
def test_main_search_command(self, sample_songs):
|
|
283
279
|
"""Test main function with search command"""
|
|
@@ -19,6 +19,8 @@ def get_config_path() -> str:
|
|
|
19
19
|
config = configparser.ConfigParser()
|
|
20
20
|
config.read(get_config_path())
|
|
21
21
|
|
|
22
|
+
_PID_FILE = get_config_dir() / "player.pid"
|
|
23
|
+
|
|
22
24
|
_ytmusic = None
|
|
23
25
|
|
|
24
26
|
|
|
@@ -41,9 +43,31 @@ def get_mpv_flags():
|
|
|
41
43
|
"""Get MPV flags from config"""
|
|
42
44
|
if "mpv" in config and "flags" in config["mpv"]:
|
|
43
45
|
return config["mpv"]["flags"].split()
|
|
44
|
-
return []
|
|
46
|
+
return ["--no-video"]
|
|
45
47
|
|
|
46
48
|
|
|
47
49
|
def get_config_value(section, key, fallback=None):
|
|
48
50
|
"""Get a value from the config"""
|
|
49
51
|
return config.get(section, key, fallback=fallback)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def save_player_pid(pid: int) -> None:
|
|
55
|
+
"""Save MPV player PID to file"""
|
|
56
|
+
with open(_PID_FILE, "w") as f:
|
|
57
|
+
f.write(str(pid))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_player_pid() -> int | None:
|
|
61
|
+
"""Get MPV player PID from file"""
|
|
62
|
+
if _PID_FILE.exists():
|
|
63
|
+
try:
|
|
64
|
+
return int(_PID_FILE.read_text().strip())
|
|
65
|
+
except (ValueError, OSError):
|
|
66
|
+
return None
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def clear_player_pid() -> None:
|
|
71
|
+
"""Clear the player PID file"""
|
|
72
|
+
if _PID_FILE.exists():
|
|
73
|
+
_PID_FILE.unlink()
|
|
@@ -72,7 +72,7 @@ class DislikeManager:
|
|
|
72
72
|
with open(self.dislikes_file, encoding="utf-8") as f:
|
|
73
73
|
data = json.load(f)
|
|
74
74
|
existing_songs = data.get("songs", [])
|
|
75
|
-
except json.JSONDecodeError, FileNotFoundError:
|
|
75
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
76
76
|
pass
|
|
77
77
|
|
|
78
78
|
# Create dislike entry
|
|
@@ -50,7 +50,7 @@ class FFmpegPlayerService:
|
|
|
50
50
|
["ffplay", "-version"], capture_output=True, text=True, timeout=5
|
|
51
51
|
)
|
|
52
52
|
return result.returncode == 0
|
|
53
|
-
except subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError:
|
|
53
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
|
|
54
54
|
return False
|
|
55
55
|
|
|
56
56
|
def play(self, video_id: str, title: str = "") -> bool:
|
|
@@ -8,11 +8,35 @@ import subprocess
|
|
|
8
8
|
import tempfile
|
|
9
9
|
import time
|
|
10
10
|
|
|
11
|
-
from .config import get_mpv_flags
|
|
11
|
+
from .config import clear_player_pid, get_mpv_flags, save_player_pid
|
|
12
12
|
from .ffmpeg_player import FFmpegPlayerService
|
|
13
13
|
from .verbose_logger import log_error, log_info, log_section
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def resolve_audio_url(video_id: str) -> str | None:
|
|
17
|
+
"""Pre-resolve audio stream URL via yt-dlp. Returns direct URL or None."""
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
[
|
|
21
|
+
"yt-dlp",
|
|
22
|
+
"-f",
|
|
23
|
+
"bestaudio",
|
|
24
|
+
"--get-url",
|
|
25
|
+
f"https://music.youtube.com/watch?v={video_id}",
|
|
26
|
+
],
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
timeout=15,
|
|
30
|
+
)
|
|
31
|
+
if result.returncode == 0:
|
|
32
|
+
url = result.stdout.strip().split("\n")[0]
|
|
33
|
+
if url.startswith("http"):
|
|
34
|
+
return url
|
|
35
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
36
|
+
pass
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
16
40
|
class CLIHybridPlayerService:
|
|
17
41
|
"""Hybrid player for CLI mode that uses mpv by default, falls back to FFmpeg if needed"""
|
|
18
42
|
|
|
@@ -54,21 +78,21 @@ class CLIHybridPlayerService:
|
|
|
54
78
|
"""Check if any player is available"""
|
|
55
79
|
return self.player_type != "none"
|
|
56
80
|
|
|
57
|
-
def play(self, video_id: str, title: str = "") -> bool:
|
|
58
|
-
"""Start playing a song"""
|
|
81
|
+
def play(self, video_id: str, title: str = "", resolved_url: str | None = None) -> bool:
|
|
82
|
+
"""Start playing a song. If resolved_url is provided, skip yt-dlp resolution."""
|
|
59
83
|
if not self.is_available():
|
|
60
84
|
log_error("Play attempted but no audio player available")
|
|
61
85
|
print("No audio player available")
|
|
62
86
|
return False
|
|
63
87
|
|
|
64
88
|
if self.player_type == "mpv":
|
|
65
|
-
return self._play_mpv(video_id, title)
|
|
89
|
+
return self._play_mpv(video_id, title, resolved_url)
|
|
66
90
|
elif self.player_type == "ffmpeg" and self.ffmpeg_player:
|
|
67
91
|
return self.ffmpeg_player.play(video_id, title)
|
|
68
92
|
|
|
69
93
|
return False
|
|
70
94
|
|
|
71
|
-
def _play_mpv(self, video_id: str, title: str = "") -> bool:
|
|
95
|
+
def _play_mpv(self, video_id: str, title: str = "", resolved_url: str | None = None) -> bool:
|
|
72
96
|
"""Play using mpv"""
|
|
73
97
|
try:
|
|
74
98
|
# Clean up previous process if exists
|
|
@@ -77,7 +101,7 @@ class CLIHybridPlayerService:
|
|
|
77
101
|
# Create socket for IPC
|
|
78
102
|
self.socket_path = tempfile.mktemp(suffix=".sock")
|
|
79
103
|
|
|
80
|
-
url = f"https://music.youtube.com/watch?v={video_id}"
|
|
104
|
+
url = resolved_url or f"https://music.youtube.com/watch?v={video_id}"
|
|
81
105
|
mpv_flags = get_mpv_flags()
|
|
82
106
|
mpv_flags.extend(
|
|
83
107
|
[
|
|
@@ -90,16 +114,38 @@ class CLIHybridPlayerService:
|
|
|
90
114
|
self.mpv_process = subprocess.Popen(
|
|
91
115
|
["mpv", url] + mpv_flags,
|
|
92
116
|
stdout=subprocess.DEVNULL,
|
|
93
|
-
stderr=subprocess.
|
|
117
|
+
stderr=subprocess.PIPE,
|
|
94
118
|
)
|
|
95
119
|
|
|
120
|
+
# Save MPV PID for --terminate
|
|
121
|
+
if self.mpv_process:
|
|
122
|
+
save_player_pid(self.mpv_process.pid)
|
|
123
|
+
|
|
96
124
|
# Wait for mpv to create the IPC socket before returning
|
|
97
|
-
for _ in range(
|
|
125
|
+
for _ in range(30):
|
|
98
126
|
time.sleep(0.1)
|
|
127
|
+
# Check if mpv exited early (e.g., yt-dlp failure)
|
|
128
|
+
if self.mpv_process.poll() is not None:
|
|
129
|
+
stderr_out = ""
|
|
130
|
+
if self.mpv_process.stderr:
|
|
131
|
+
stderr_out = self.mpv_process.stderr.read().decode(errors="replace")
|
|
132
|
+
self.mpv_process.stderr.close()
|
|
133
|
+
log_error(
|
|
134
|
+
f"MPV exited early (code {self.mpv_process.returncode}): {stderr_out[:200]}"
|
|
135
|
+
)
|
|
136
|
+
clear_player_pid()
|
|
137
|
+
self.mpv_process = None
|
|
138
|
+
return False
|
|
99
139
|
if os.path.exists(self.socket_path):
|
|
140
|
+
# Detach stderr now that startup succeeded (avoid blocking on pipe)
|
|
141
|
+
if self.mpv_process.stderr:
|
|
142
|
+
self.mpv_process.stderr.close()
|
|
100
143
|
return True
|
|
101
|
-
|
|
102
|
-
|
|
144
|
+
|
|
145
|
+
# Socket not created but process is running — close stderr pipe
|
|
146
|
+
if self.mpv_process and self.mpv_process.stderr:
|
|
147
|
+
self.mpv_process.stderr.close()
|
|
148
|
+
return self.mpv_process.poll() is None if self.mpv_process else False
|
|
103
149
|
except Exception as e:
|
|
104
150
|
log_error(f"Failed to start mpv: {e}")
|
|
105
151
|
print(f"Failed to start mpv: {e}")
|
|
@@ -114,6 +160,7 @@ class CLIHybridPlayerService:
|
|
|
114
160
|
if self.socket_path and os.path.exists(self.socket_path):
|
|
115
161
|
os.unlink(self.socket_path)
|
|
116
162
|
self.socket_path = None
|
|
163
|
+
clear_player_pid()
|
|
117
164
|
elif self.player_type == "ffmpeg" and self.ffmpeg_player:
|
|
118
165
|
self.ffmpeg_player.stop()
|
|
119
166
|
|
|
@@ -170,7 +217,7 @@ class CLIHybridPlayerService:
|
|
|
170
217
|
parsed = json.loads(line)
|
|
171
218
|
if "event" not in parsed and parsed.get("error") == "success":
|
|
172
219
|
return parsed.get("data")
|
|
173
|
-
except OSError, json.JSONDecodeError, UnicodeDecodeError:
|
|
220
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
174
221
|
pass
|
|
175
222
|
return None
|
|
176
223
|
|
|
@@ -4,6 +4,7 @@ import argparse
|
|
|
4
4
|
import curses
|
|
5
5
|
import sys
|
|
6
6
|
from curses import wrapper
|
|
7
|
+
from datetime import datetime
|
|
7
8
|
|
|
8
9
|
from rich import print
|
|
9
10
|
|
|
@@ -120,7 +121,7 @@ def search_and_play(query=None, auto_select=None):
|
|
|
120
121
|
if selected_index < 0 or selected_index >= len(results[:songs_to_display]):
|
|
121
122
|
print("[red]Invalid selection.[/red]")
|
|
122
123
|
return
|
|
123
|
-
except ValueError, KeyboardInterrupt:
|
|
124
|
+
except (ValueError, KeyboardInterrupt):
|
|
124
125
|
return
|
|
125
126
|
|
|
126
127
|
if selected_index is None:
|
|
@@ -132,6 +133,18 @@ def search_and_play(query=None, auto_select=None):
|
|
|
132
133
|
# Fetch radio in background so the first song starts immediately
|
|
133
134
|
import threading
|
|
134
135
|
|
|
136
|
+
# Pre-resolve audio URL in parallel with radio fetch — eliminates ~3s yt-dlp
|
|
137
|
+
# wait from mpv startup path
|
|
138
|
+
from .hybrid_player import resolve_audio_url
|
|
139
|
+
|
|
140
|
+
prefetched_url = [None]
|
|
141
|
+
|
|
142
|
+
def prefetch_first_url():
|
|
143
|
+
prefetched_url[0] = resolve_audio_url(song["videoId"])
|
|
144
|
+
|
|
145
|
+
url_thread = threading.Thread(target=prefetch_first_url, daemon=True)
|
|
146
|
+
url_thread.start()
|
|
147
|
+
|
|
135
148
|
def fetch_radio():
|
|
136
149
|
log_step("Fetching radio playlist", f"videoId: {song['videoId']}")
|
|
137
150
|
log_api_call("ytmusic.get_watch_playlist", {"videoId": song["videoId"]})
|
|
@@ -163,7 +176,7 @@ def search_and_play(query=None, auto_select=None):
|
|
|
163
176
|
radio_thread = threading.Thread(target=fetch_radio, daemon=True)
|
|
164
177
|
radio_thread.start()
|
|
165
178
|
|
|
166
|
-
play_music_with_controls(playlist)
|
|
179
|
+
play_music_with_controls(playlist, prefetched_url_thread=(url_thread, prefetched_url))
|
|
167
180
|
|
|
168
181
|
|
|
169
182
|
# Playlist Commands
|
|
@@ -202,15 +215,24 @@ def playlist_list_command():
|
|
|
202
215
|
def playlist_create_command(name, description=""):
|
|
203
216
|
"""Create a new playlist"""
|
|
204
217
|
if not name:
|
|
205
|
-
name = input("Enter playlist name: ").strip()
|
|
218
|
+
name = input("Enter playlist name (or press Enter for default): ").strip()
|
|
206
219
|
if not name:
|
|
207
|
-
|
|
208
|
-
|
|
220
|
+
# Generate unique default name with timestamp and counter
|
|
221
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
222
|
+
base_name = f"My Playlist #{timestamp}"
|
|
223
|
+
name = base_name
|
|
224
|
+
|
|
225
|
+
# Check if this name already exists and add counter if needed
|
|
226
|
+
existing_names = playlist_manager.get_playlist_names()
|
|
227
|
+
if name in existing_names:
|
|
228
|
+
counter = 1
|
|
229
|
+
while f"{base_name}_{counter}" in existing_names:
|
|
230
|
+
counter += 1
|
|
231
|
+
name = f"{base_name}_{counter}"
|
|
209
232
|
|
|
210
|
-
|
|
211
|
-
description = input("Enter description (optional): ").strip()
|
|
233
|
+
print(f"[cyan]Using default name: {name}[/cyan]")
|
|
212
234
|
|
|
213
|
-
playlist_manager.create_playlist(name,
|
|
235
|
+
playlist_manager.create_playlist(name, "")
|
|
214
236
|
|
|
215
237
|
|
|
216
238
|
def playlist_show_command(name):
|
|
@@ -242,7 +264,7 @@ def playlist_show_command(name):
|
|
|
242
264
|
return
|
|
243
265
|
else:
|
|
244
266
|
name = choice
|
|
245
|
-
except ValueError, KeyboardInterrupt:
|
|
267
|
+
except (ValueError, KeyboardInterrupt):
|
|
246
268
|
return
|
|
247
269
|
|
|
248
270
|
playlist = playlist_manager.get_playlist(name)
|
|
@@ -306,7 +328,7 @@ def playlist_play_command(name):
|
|
|
306
328
|
return
|
|
307
329
|
else:
|
|
308
330
|
name = choice
|
|
309
|
-
except ValueError, KeyboardInterrupt:
|
|
331
|
+
except (ValueError, KeyboardInterrupt):
|
|
310
332
|
return
|
|
311
333
|
|
|
312
334
|
playlist = playlist_manager.get_playlist(name)
|
|
@@ -376,7 +398,7 @@ def playlist_delete_command(name):
|
|
|
376
398
|
return
|
|
377
399
|
else:
|
|
378
400
|
name = choice
|
|
379
|
-
except ValueError, KeyboardInterrupt:
|
|
401
|
+
except (ValueError, KeyboardInterrupt):
|
|
380
402
|
return
|
|
381
403
|
|
|
382
404
|
# Confirm deletion
|
|
@@ -402,7 +424,7 @@ def llm_create_playlist_command(llm_client, prompt, num_songs=15, auto_play=Fals
|
|
|
402
424
|
print(f"[cyan]Searching YouTube Music for {len(response.songs)} songs...[/cyan]")
|
|
403
425
|
|
|
404
426
|
# Create the playlist
|
|
405
|
-
if not playlist_manager.create_playlist(playlist_name,
|
|
427
|
+
if not playlist_manager.create_playlist(playlist_name, ""):
|
|
406
428
|
return
|
|
407
429
|
|
|
408
430
|
# Search each song on YTMusic and add to playlist
|
|
@@ -444,11 +466,25 @@ def main():
|
|
|
444
466
|
len(sys.argv) >= 2
|
|
445
467
|
and not sys.argv[1].startswith("-")
|
|
446
468
|
and sys.argv[1] not in ["search", "playlist", "llm"]
|
|
447
|
-
and "--
|
|
448
|
-
and "--select" not in sys.argv
|
|
469
|
+
and "--terminate" not in sys.argv
|
|
449
470
|
):
|
|
471
|
+
# Extract --select/-s value if present
|
|
472
|
+
auto_select = None
|
|
473
|
+
for i, arg in enumerate(sys.argv):
|
|
474
|
+
if arg in ("--select", "-s") and i + 1 < len(sys.argv):
|
|
475
|
+
try:
|
|
476
|
+
auto_select = int(sys.argv[i + 1])
|
|
477
|
+
except ValueError:
|
|
478
|
+
pass
|
|
479
|
+
break
|
|
480
|
+
|
|
481
|
+
# Extract --verbose if present
|
|
482
|
+
verbose = "--verbose" in sys.argv
|
|
483
|
+
|
|
450
484
|
# This is likely a song query, handle it directly (backward compatible mode)
|
|
451
|
-
|
|
485
|
+
if verbose:
|
|
486
|
+
set_verbose(True)
|
|
487
|
+
search_and_play(sys.argv[1], auto_select)
|
|
452
488
|
return
|
|
453
489
|
|
|
454
490
|
# Allow `llm "prompt"` as shortcut for `llm ask "prompt"`
|
|
@@ -498,6 +534,11 @@ During music playback:
|
|
|
498
534
|
action="store_true",
|
|
499
535
|
help="Run with a fake playlist + synthetic spectrum (for screenshots / docs)",
|
|
500
536
|
)
|
|
537
|
+
parser.add_argument(
|
|
538
|
+
"--terminate",
|
|
539
|
+
action="store_true",
|
|
540
|
+
help="Terminate all running ytm-cli sessions",
|
|
541
|
+
)
|
|
501
542
|
|
|
502
543
|
# Create subcommands
|
|
503
544
|
subparsers = parser.add_subparsers(
|
|
@@ -616,10 +657,46 @@ During music playback:
|
|
|
616
657
|
|
|
617
658
|
args = parser.parse_args()
|
|
618
659
|
|
|
619
|
-
#
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
660
|
+
# Handle --terminate flag
|
|
661
|
+
if getattr(args, "terminate", False):
|
|
662
|
+
import os
|
|
663
|
+
import signal
|
|
664
|
+
import subprocess
|
|
665
|
+
|
|
666
|
+
current_pid = os.getpid()
|
|
667
|
+
parent_pid = os.getppid()
|
|
668
|
+
terminated = 0
|
|
669
|
+
|
|
670
|
+
# Find ytm-cli processes using subprocess with timeout
|
|
671
|
+
try:
|
|
672
|
+
result = subprocess.run(
|
|
673
|
+
["pgrep", "-f", "ytm.cli"], capture_output=True, text=True, timeout=5
|
|
674
|
+
)
|
|
675
|
+
pids = [int(p) for p in result.stdout.strip().split("\n") if p.strip()]
|
|
676
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
|
|
677
|
+
pids = []
|
|
678
|
+
|
|
679
|
+
for pid in pids:
|
|
680
|
+
if pid in (current_pid, parent_pid):
|
|
681
|
+
continue
|
|
682
|
+
try:
|
|
683
|
+
os.kill(pid, signal.SIGTERM)
|
|
684
|
+
terminated += 1
|
|
685
|
+
except (ProcessLookupError, PermissionError):
|
|
686
|
+
pass
|
|
687
|
+
|
|
688
|
+
# Also terminate MPV process if PID file exists
|
|
689
|
+
from .config import get_player_pid
|
|
690
|
+
|
|
691
|
+
player_pid = get_player_pid()
|
|
692
|
+
if player_pid:
|
|
693
|
+
try:
|
|
694
|
+
os.kill(player_pid, signal.SIGTERM)
|
|
695
|
+
except (ProcessLookupError, PermissionError):
|
|
696
|
+
pass
|
|
697
|
+
|
|
698
|
+
print(f"Terminated {terminated} ytm-cli session(s)")
|
|
699
|
+
return
|
|
623
700
|
|
|
624
701
|
# Initialize verbose logger
|
|
625
702
|
set_verbose(_VERBOSE, _VERBOSE_FILE)
|
|
@@ -9,6 +9,7 @@ import subprocess
|
|
|
9
9
|
import sys
|
|
10
10
|
import tempfile
|
|
11
11
|
import termios
|
|
12
|
+
import threading
|
|
12
13
|
import time
|
|
13
14
|
import tty
|
|
14
15
|
|
|
@@ -72,7 +73,7 @@ class CavaVisualizer:
|
|
|
72
73
|
values = [int(v) for v in last_line.split(";") if v.strip()]
|
|
73
74
|
if values:
|
|
74
75
|
self.bars = values
|
|
75
|
-
except BlockingIOError, ValueError:
|
|
76
|
+
except (BlockingIOError, ValueError):
|
|
76
77
|
pass
|
|
77
78
|
return self.bars
|
|
78
79
|
|
|
@@ -115,7 +116,7 @@ def _mpv_ipc(socket_path, command, expect_response=False):
|
|
|
115
116
|
continue
|
|
116
117
|
try:
|
|
117
118
|
data = json.loads(line.decode())
|
|
118
|
-
except json.JSONDecodeError, UnicodeDecodeError:
|
|
119
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
119
120
|
continue
|
|
120
121
|
if "event" in data:
|
|
121
122
|
continue
|
|
@@ -127,7 +128,7 @@ def _mpv_ipc(socket_path, command, expect_response=False):
|
|
|
127
128
|
sock.close()
|
|
128
129
|
return None
|
|
129
130
|
sock.close()
|
|
130
|
-
except OSError, json.JSONDecodeError, UnicodeDecodeError, TimeoutError:
|
|
131
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError, TimeoutError):
|
|
131
132
|
return None
|
|
132
133
|
|
|
133
134
|
|
|
@@ -190,7 +191,7 @@ def get_mpv_audio_levels(socket_path):
|
|
|
190
191
|
return None
|
|
191
192
|
try:
|
|
192
193
|
db = float(raw)
|
|
193
|
-
except ValueError, TypeError:
|
|
194
|
+
except (ValueError, TypeError):
|
|
194
195
|
return None
|
|
195
196
|
if db != db or db <= floor_db: # NaN or -inf / silence
|
|
196
197
|
return 0.0
|
|
@@ -302,14 +303,25 @@ def add_song_to_playlist_interactive(song_data):
|
|
|
302
303
|
print("\nCreate new playlist:")
|
|
303
304
|
playlist_name = input("Playlist name: ").strip()
|
|
304
305
|
if not playlist_name:
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
306
|
+
# Generate unique default name with timestamp and counter
|
|
307
|
+
from datetime import datetime
|
|
308
|
+
|
|
309
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
310
|
+
base_name = f"My Playlist #{timestamp}"
|
|
311
|
+
playlist_name = base_name
|
|
312
|
+
|
|
313
|
+
# Check if this name already exists and add counter if needed
|
|
314
|
+
existing_names = playlist_manager.get_playlist_names()
|
|
315
|
+
if playlist_name in existing_names:
|
|
316
|
+
counter = 1
|
|
317
|
+
while f"{base_name}_{counter}" in existing_names:
|
|
318
|
+
counter += 1
|
|
319
|
+
playlist_name = f"{base_name}_{counter}"
|
|
308
320
|
|
|
309
|
-
|
|
321
|
+
print(f"[cyan]Using default name: {playlist_name}[/cyan]")
|
|
310
322
|
|
|
311
323
|
# Create the playlist
|
|
312
|
-
if playlist_manager.create_playlist(playlist_name,
|
|
324
|
+
if playlist_manager.create_playlist(playlist_name, ""):
|
|
313
325
|
selected_playlist = playlist_name
|
|
314
326
|
else:
|
|
315
327
|
print("Failed to create playlist.")
|
|
@@ -390,7 +402,7 @@ def get_and_display_lyrics(video_id, title, socket_path=None):
|
|
|
390
402
|
return False
|
|
391
403
|
|
|
392
404
|
|
|
393
|
-
def play_music_with_controls(playlist, playlist_name=None, demo=False):
|
|
405
|
+
def play_music_with_controls(playlist, playlist_name=None, demo=False, prefetched_url_thread=None):
|
|
394
406
|
"""Play music with keyboard controls using curses-based UI
|
|
395
407
|
|
|
396
408
|
Args:
|
|
@@ -398,6 +410,7 @@ def play_music_with_controls(playlist, playlist_name=None, demo=False):
|
|
|
398
410
|
playlist_name: Name of user playlist (if playing from a user playlist)
|
|
399
411
|
demo: When True, use the demo player + synthetic spectrum (no audio,
|
|
400
412
|
no network) so screenshot tooling can capture deterministic frames.
|
|
413
|
+
prefetched_url_thread: Tuple of (thread, result_list) for async URL resolution.
|
|
401
414
|
"""
|
|
402
415
|
from .verbose_logger import log_info, log_section
|
|
403
416
|
|
|
@@ -446,6 +459,38 @@ def play_music_with_controls(playlist, playlist_name=None, demo=False):
|
|
|
446
459
|
toast_msg = None
|
|
447
460
|
toast_expire = 0
|
|
448
461
|
|
|
462
|
+
# Pre-resolved URLs: {video_id: url or None}
|
|
463
|
+
prefetch_cache: dict[str, str | None] = {}
|
|
464
|
+
prefetch_lock = threading.Lock()
|
|
465
|
+
_initial_url_thread = None
|
|
466
|
+
|
|
467
|
+
# Track the caller's prefetch thread (already running in parallel since song selection)
|
|
468
|
+
if prefetched_url_thread and playlist:
|
|
469
|
+
_initial_url_thread = prefetched_url_thread # (thread, result_list)
|
|
470
|
+
|
|
471
|
+
def _prefetch_url(vid: str):
|
|
472
|
+
"""Resolve audio URL in background and cache it."""
|
|
473
|
+
if demo:
|
|
474
|
+
return
|
|
475
|
+
from .hybrid_player import resolve_audio_url
|
|
476
|
+
|
|
477
|
+
url = resolve_audio_url(vid)
|
|
478
|
+
with prefetch_lock:
|
|
479
|
+
prefetch_cache[vid] = url
|
|
480
|
+
|
|
481
|
+
def _get_cached_url(vid: str) -> str | None:
|
|
482
|
+
nonlocal _initial_url_thread
|
|
483
|
+
# Check if the initial prefetch thread has finished
|
|
484
|
+
if _initial_url_thread and playlist:
|
|
485
|
+
url_thread, url_result = _initial_url_thread
|
|
486
|
+
if not url_thread.is_alive():
|
|
487
|
+
if url_result[0]:
|
|
488
|
+
with prefetch_lock:
|
|
489
|
+
prefetch_cache[playlist[0]["videoId"]] = url_result[0]
|
|
490
|
+
_initial_url_thread = None
|
|
491
|
+
with prefetch_lock:
|
|
492
|
+
return prefetch_cache.get(vid)
|
|
493
|
+
|
|
449
494
|
def fire(msg):
|
|
450
495
|
nonlocal toast_msg, toast_expire
|
|
451
496
|
toast_msg = msg
|
|
@@ -471,26 +516,77 @@ def play_music_with_controls(playlist, playlist_name=None, demo=False):
|
|
|
471
516
|
track_num = current_song_index + 1
|
|
472
517
|
track_total = len(playlist)
|
|
473
518
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
519
|
+
# Prefetch next song's URL while this one loads/plays
|
|
520
|
+
next_idx = current_song_index + 1
|
|
521
|
+
if next_idx < len(playlist) and not demo:
|
|
522
|
+
next_vid = playlist[next_idx]["videoId"]
|
|
523
|
+
if _get_cached_url(next_vid) is None and next_vid not in prefetch_cache:
|
|
524
|
+
threading.Thread(
|
|
525
|
+
target=_prefetch_url, args=(next_vid,), daemon=True
|
|
526
|
+
).start()
|
|
527
|
+
|
|
528
|
+
# Start playback in background so the UI keeps animating.
|
|
529
|
+
# Wait briefly for prefetch to resolve (animating meanwhile),
|
|
530
|
+
# then start mpv with whatever URL we have.
|
|
531
|
+
play_result = [None] # None = pending, True/False = done
|
|
532
|
+
play_started = False
|
|
533
|
+
fire("Loading...")
|
|
534
|
+
|
|
535
|
+
while play_result[0] is None:
|
|
536
|
+
# Once prefetch resolves (or after a short wait), kick off mpv
|
|
537
|
+
if not play_started:
|
|
538
|
+
resolved_url = _get_cached_url(video_id)
|
|
539
|
+
if resolved_url is not None or frame > 5:
|
|
540
|
+
# Either got prefetched URL or waited ~1s — start mpv
|
|
541
|
+
def _start_play(
|
|
542
|
+
vid=video_id,
|
|
543
|
+
title=display_title,
|
|
544
|
+
url=resolved_url,
|
|
545
|
+
_res=play_result,
|
|
546
|
+
):
|
|
547
|
+
_res[0] = player.play(vid, title, resolved_url=url)
|
|
548
|
+
|
|
549
|
+
threading.Thread(target=_start_play, daemon=True).start()
|
|
550
|
+
play_started = True
|
|
551
|
+
|
|
552
|
+
draw_player(
|
|
553
|
+
stdscr,
|
|
554
|
+
song_title,
|
|
555
|
+
artist,
|
|
556
|
+
is_paused,
|
|
557
|
+
track_num,
|
|
558
|
+
track_total,
|
|
559
|
+
None,
|
|
560
|
+
None,
|
|
561
|
+
frame,
|
|
562
|
+
toast_msg,
|
|
563
|
+
toast_expire,
|
|
564
|
+
)
|
|
565
|
+
frame += 1
|
|
566
|
+
key = stdscr.getch()
|
|
567
|
+
if key == ord("q") or key == 3:
|
|
568
|
+
quit_pressed = True
|
|
569
|
+
return
|
|
570
|
+
if key == ord("n"):
|
|
571
|
+
current_song_index += 1
|
|
572
|
+
if play_started:
|
|
573
|
+
time.sleep(0.1)
|
|
574
|
+
if play_result[0] is True:
|
|
575
|
+
player.stop()
|
|
576
|
+
fire("Next →")
|
|
577
|
+
break
|
|
578
|
+
|
|
579
|
+
if play_result[0] is None:
|
|
580
|
+
# broke out via 'n'
|
|
581
|
+
continue
|
|
487
582
|
|
|
488
|
-
if not
|
|
583
|
+
if not play_result[0]:
|
|
489
584
|
fire(f"Failed: {song_title}")
|
|
490
585
|
current_song_index += 1
|
|
491
586
|
continue
|
|
492
587
|
|
|
493
588
|
is_paused = False
|
|
589
|
+
toast_msg = None # clear "Loading..." toast
|
|
494
590
|
|
|
495
591
|
lyrics_just_viewed = False
|
|
496
592
|
|
|
@@ -263,7 +263,7 @@ class PlaylistManager:
|
|
|
263
263
|
data = json.load(f)
|
|
264
264
|
if data.get("name", "").lower() == playlist_name.lower():
|
|
265
265
|
return filepath
|
|
266
|
-
except json.JSONDecodeError, FileNotFoundError:
|
|
266
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
267
267
|
continue
|
|
268
268
|
except OSError:
|
|
269
269
|
pass
|
|
@@ -387,7 +387,7 @@ def add_song_to_playlist_ui(stdscr, song):
|
|
|
387
387
|
try:
|
|
388
388
|
input_bytes = stdscr.getstr(current_line, 4, max_x - 6)
|
|
389
389
|
input_str = input_bytes.decode("utf-8").strip()
|
|
390
|
-
except curses.error, UnicodeDecodeError:
|
|
390
|
+
except (curses.error, UnicodeDecodeError):
|
|
391
391
|
input_str = ""
|
|
392
392
|
finally:
|
|
393
393
|
curses.noecho()
|
|
@@ -403,7 +403,7 @@ def add_song_to_playlist_ui(stdscr, song):
|
|
|
403
403
|
if 0 <= playlist_index < len(playlists):
|
|
404
404
|
playlist_name = playlists[playlist_index]
|
|
405
405
|
return playlist_manager.add_song_to_playlist(playlist_name, song)
|
|
406
|
-
except ValueError, IndexError:
|
|
406
|
+
except (ValueError, IndexError):
|
|
407
407
|
pass
|
|
408
408
|
|
|
409
409
|
# Treat as new playlist name
|
|
@@ -648,15 +648,23 @@ def draw_player(
|
|
|
648
648
|
history = list(_WAVE_HISTORY)
|
|
649
649
|
have_real = bool(history) and audio_levels is not None
|
|
650
650
|
|
|
651
|
-
if
|
|
651
|
+
if is_paused:
|
|
652
|
+
mode_label = ""
|
|
653
|
+
# Gentle breathing wave: slow sine that ripples across bars
|
|
654
|
+
t = time.time()
|
|
655
|
+
for i in range(nbars):
|
|
656
|
+
# Slow ripple from center outward
|
|
657
|
+
dist = abs(i - nbars / 2.0) / (nbars / 2.0)
|
|
658
|
+
phase = t * 1.2 - dist * 2.5
|
|
659
|
+
breath = 0.08 + 0.12 * (0.5 + 0.5 * math.sin(phase))
|
|
660
|
+
_draw_bar(scr, bar_bottom, wx + i * 2, breath, _WAVE_ROWS, bar_attr)
|
|
661
|
+
elif bands:
|
|
652
662
|
mode_label = "spectrum"
|
|
653
663
|
n_in = len(bands)
|
|
654
664
|
for i in range(nbars):
|
|
655
665
|
lo = int(i * n_in / nbars)
|
|
656
666
|
hi = max(lo + 1, int((i + 1) * n_in / nbars))
|
|
657
667
|
level = max(bands[lo:hi])
|
|
658
|
-
if is_paused:
|
|
659
|
-
level *= 0.3
|
|
660
668
|
_draw_bar(scr, bar_bottom, wx + i * 2, level, _WAVE_ROWS, bar_attr)
|
|
661
669
|
elif have_real:
|
|
662
670
|
mode_label = ""
|
|
@@ -672,19 +680,14 @@ def draw_player(
|
|
|
672
680
|
else:
|
|
673
681
|
level = recent[nbars - 1 - i][1]
|
|
674
682
|
|
|
675
|
-
if is_paused:
|
|
676
|
-
level *= 0.3
|
|
677
683
|
_draw_bar(scr, bar_bottom, wx + i * 2, level, _WAVE_ROWS, bar_attr)
|
|
678
684
|
else:
|
|
679
685
|
mode_label = "sim"
|
|
680
686
|
for i in range(nbars):
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
level = (seed / 40.0) * (0.4 + 0.6 * val)
|
|
686
|
-
else:
|
|
687
|
-
level = 0.05
|
|
687
|
+
seed = _WAVE_SEEDS[i % len(_WAVE_SEEDS)]
|
|
688
|
+
phase = frame * 0.7 + i * 0.65
|
|
689
|
+
val = 0.5 + 0.5 * math.sin(phase)
|
|
690
|
+
level = (seed / 40.0) * (0.4 + 0.6 * val)
|
|
688
691
|
_draw_bar(scr, bar_bottom, wx + i * 2, level, _WAVE_ROWS, bar_attr)
|
|
689
692
|
|
|
690
693
|
# Mode indicator: lets you confirm which visualizer pipeline is live.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ytm-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: YouTube Music CLI tool with interactive terminal interface
|
|
5
5
|
Author-email: thieuluan <thieuluan.dark@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -14,9 +14,13 @@ Classifier: Intended Audience :: End Users/Desktop
|
|
|
14
14
|
Classifier: Operating System :: POSIX :: Linux
|
|
15
15
|
Classifier: Operating System :: MacOS
|
|
16
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
21
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
22
|
Classifier: Topic :: Multimedia :: Sound/Audio :: Players
|
|
19
|
-
Requires-Python: >=3.
|
|
23
|
+
Requires-Python: >=3.10
|
|
20
24
|
Description-Content-Type: text/markdown
|
|
21
25
|
License-File: LICENSE
|
|
22
26
|
Requires-Dist: ytmusicapi
|
|
@@ -60,7 +64,7 @@ Stream music directly from YouTube Music in your terminal with intuitive control
|
|
|
60
64
|
|
|
61
65
|
### Requirements
|
|
62
66
|
|
|
63
|
-
- Python **3.
|
|
67
|
+
- Python **3.10+** (set in `pyproject.toml`)
|
|
64
68
|
- [mpv media player](https://mpv.io/installation/) (must be installed system-wide)
|
|
65
69
|
- [`ffmpeg`](https://ffmpeg.org/) on `PATH` for the real-time spectrum visualizer (optional — falls back to a stereo oscilloscope if missing)
|
|
66
70
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|