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.
Files changed (41) hide show
  1. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/PKG-INFO +7 -3
  2. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/README.md +1 -1
  3. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/pyproject.toml +7 -3
  4. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_main.py +4 -8
  5. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/__init__.py +1 -1
  6. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/config.py +25 -1
  7. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/dislikes.py +1 -1
  8. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/ffmpeg_player.py +1 -1
  9. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/hybrid_player.py +58 -11
  10. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/main.py +96 -19
  11. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/player.py +120 -24
  12. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/playlists.py +1 -1
  13. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/spectrum.py +1 -1
  14. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/ui.py +17 -14
  15. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli.egg-info/PKG-INFO +7 -3
  16. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/LICENSE +0 -0
  17. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/setup.cfg +0 -0
  18. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_cache.py +0 -0
  19. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_config.py +0 -0
  20. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_dislikes.py +0 -0
  21. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_ffmpeg_player.py +0 -0
  22. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_hybrid_player.py +0 -0
  23. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_integration.py +0 -0
  24. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_lyrics_service.py +0 -0
  25. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_playlists.py +0 -0
  26. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_runner.py +0 -0
  27. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_spectrum.py +0 -0
  28. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_ui.py +0 -0
  29. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/tests/test_utils.py +0 -0
  30. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/__main__.py +0 -0
  31. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/cache.py +0 -0
  32. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/demo.py +0 -0
  33. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/llm_client.py +0 -0
  34. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/lyrics_service.py +0 -0
  35. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/utils.py +0 -0
  36. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli/verbose_logger.py +0 -0
  37. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli.egg-info/SOURCES.txt +0 -0
  38. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli.egg-info/dependency_links.txt +0 -0
  39. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli.egg-info/entry_points.txt +0 -0
  40. {ytm_cli-0.7.2 → ytm_cli-0.8.0}/ytm_cli.egg-info/requires.txt +0 -0
  41. {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.7.2
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.14
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.14+** (set in `pyproject.toml`)
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.14+** (set in `pyproject.toml`)
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.2"
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.14"
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 = "py314"
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", "User description"]),
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"""
@@ -1,3 +1,3 @@
1
1
  """YouTube Music CLI - A terminal interface for YouTube Music"""
2
2
 
3
- __version__ = "0.7.2"
3
+ __version__ = "0.8.0"
@@ -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.DEVNULL,
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(20):
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
- # Socket not created but process is running
102
- return self.mpv_process.poll() is None
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
- print("[red]Playlist name is required[/red]")
208
- return
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
- if not description:
211
- description = input("Enter description (optional): ").strip()
233
+ print(f"[cyan]Using default name: {name}[/cyan]")
212
234
 
213
- playlist_manager.create_playlist(name, description)
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, f"AI-generated: {prompt}"):
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 "--verbose" not in sys.argv
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
- search_and_play(sys.argv[1])
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
- # Set verbose mode globally
620
- global _VERBOSE, _VERBOSE_FILE
621
- _VERBOSE = getattr(args, "verbose", False)
622
- _VERBOSE_FILE = getattr(args, "log_file", None)
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
- print("Cancelled - no name provided.")
306
- time.sleep(1)
307
- return False
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
- description = input("Description (optional): ").strip()
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, description):
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
- draw_player(
475
- stdscr,
476
- song_title,
477
- artist,
478
- is_paused,
479
- track_num,
480
- track_total,
481
- None,
482
- None,
483
- frame,
484
- toast_msg,
485
- toast_expire,
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 player.play(video_id, display_title):
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
@@ -114,7 +114,7 @@ class SpectrumAnalyzer:
114
114
  with self._lock:
115
115
  self._bands = prev.copy()
116
116
  self._has_data = True
117
- except OSError, ValueError:
117
+ except (OSError, ValueError):
118
118
  pass
119
119
  finally:
120
120
  self._running = False
@@ -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 bands:
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
- if not is_paused:
682
- seed = _WAVE_SEEDS[i % len(_WAVE_SEEDS)]
683
- phase = frame * 0.7 + i * 0.65
684
- val = 0.5 + 0.5 * math.sin(phase)
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.7.2
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.14
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.14+** (set in `pyproject.toml`)
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