ytm-cli 0.7.3__tar.gz → 0.9.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.3 → ytm_cli-0.9.0}/PKG-INFO +3 -1
  2. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/pyproject.toml +3 -1
  3. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_config.py +0 -21
  4. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_hybrid_player.py +62 -1
  5. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_main.py +28 -8
  6. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_ui.py +91 -1
  7. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/__init__.py +1 -1
  8. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/config.py +30 -1
  9. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/hybrid_player.py +104 -12
  10. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/main.py +95 -16
  11. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/player.py +140 -22
  12. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/ui.py +211 -114
  13. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli.egg-info/PKG-INFO +3 -1
  14. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli.egg-info/requires.txt +2 -0
  15. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/LICENSE +0 -0
  16. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/README.md +0 -0
  17. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/setup.cfg +0 -0
  18. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_cache.py +0 -0
  19. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_dislikes.py +0 -0
  20. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_ffmpeg_player.py +0 -0
  21. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_integration.py +0 -0
  22. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_lyrics_service.py +0 -0
  23. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_playlists.py +0 -0
  24. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_runner.py +0 -0
  25. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_spectrum.py +0 -0
  26. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_utils.py +0 -0
  27. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/__main__.py +0 -0
  28. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/cache.py +0 -0
  29. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/demo.py +0 -0
  30. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/dislikes.py +0 -0
  31. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/ffmpeg_player.py +0 -0
  32. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/llm_client.py +0 -0
  33. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/lyrics_service.py +0 -0
  34. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/playlists.py +0 -0
  35. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/spectrum.py +0 -0
  36. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/utils.py +0 -0
  37. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/verbose_logger.py +0 -0
  38. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli.egg-info/SOURCES.txt +0 -0
  39. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli.egg-info/dependency_links.txt +0 -0
  40. {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli.egg-info/entry_points.txt +0 -0
  41. {ytm_cli-0.7.3 → ytm_cli-0.9.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.3
3
+ Version: 0.9.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
@@ -29,6 +29,8 @@ Requires-Dist: mpv
29
29
  Requires-Dist: requests
30
30
  Requires-Dist: pyperclip
31
31
  Requires-Dist: numpy
32
+ Requires-Dist: yt-dlp
33
+ Requires-Dist: yt-dlp-ejs
32
34
  Dynamic: license-file
33
35
 
34
36
  # YTM - YouTube Music CLI
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ytm-cli"
7
- version = "0.7.3"
7
+ version = "0.9.0"
8
8
  description = "YouTube Music CLI tool with interactive terminal interface"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -34,6 +34,8 @@ dependencies = [
34
34
  "requests",
35
35
  "pyperclip",
36
36
  "numpy",
37
+ "yt-dlp",
38
+ "yt-dlp-ejs",
37
39
  ]
38
40
 
39
41
  [dependency-groups]
@@ -80,27 +80,6 @@ class TestGetMpvFlags:
80
80
 
81
81
  assert result == ["--no-video", "--volume=50"]
82
82
 
83
- def test_get_mpv_flags_no_mpv_section(self):
84
- """Test when mpv section doesn't exist"""
85
- mock_config = Mock()
86
- mock_config.__contains__ = Mock(return_value=False)
87
-
88
- with patch("ytm_cli.config.config", mock_config):
89
- result = get_mpv_flags()
90
-
91
- assert result == []
92
-
93
- def test_get_mpv_flags_no_flags_key(self):
94
- """Test when mpv section exists but no flags key"""
95
- mock_config = Mock()
96
- mock_config.__contains__ = Mock(side_effect=lambda x: x == "mpv")
97
- mock_config.__getitem__ = Mock(return_value={})
98
-
99
- with patch("ytm_cli.config.config", mock_config):
100
- result = get_mpv_flags()
101
-
102
- assert result == []
103
-
104
83
  def test_get_mpv_flags_empty_flags(self):
105
84
  """Test when flags value is empty"""
106
85
  mock_config = Mock()
@@ -6,7 +6,23 @@ import unittest
6
6
  from unittest.mock import MagicMock, patch
7
7
 
8
8
  from ytm_cli.ffmpeg_player import FFmpegPlayerService
9
- from ytm_cli.hybrid_player import CLIHybridPlayerService
9
+ from ytm_cli.hybrid_player import CLIHybridPlayerService, ResolvedAudio, resolve_audio_url
10
+
11
+
12
+ def test_resolve_audio_url_includes_required_user_agent():
13
+ """Return the request metadata required to play a direct stream URL."""
14
+ result = MagicMock(
15
+ returncode=0,
16
+ stdout=('https://example.test/audio\n{"User-Agent": "Test Browser", "Accept": "*/*"}\n'),
17
+ )
18
+
19
+ with (
20
+ patch("ytm_cli.hybrid_player.get_cookies_browser", return_value=None),
21
+ patch("ytm_cli.hybrid_player.subprocess.run", return_value=result),
22
+ ):
23
+ resolved = resolve_audio_url("video-id")
24
+
25
+ assert resolved == ResolvedAudio("https://example.test/audio", "Test Browser")
10
26
 
11
27
 
12
28
  class TestCLIHybridPlayerInitialization(unittest.TestCase):
@@ -171,6 +187,51 @@ class TestCLIHybridPlayerPlayback(unittest.TestCase):
171
187
  assert result is True
172
188
  player.ffmpeg_player.is_playing_now.assert_called_once()
173
189
 
190
+ @patch("shutil.which", return_value="/usr/bin/mpv")
191
+ def test_play_mpv_passes_resolved_user_agent(self, mock_which):
192
+ """mpv must use the user agent associated with a direct stream URL."""
193
+ with patch("builtins.print"):
194
+ player = CLIHybridPlayerService()
195
+
196
+ process = MagicMock(pid=123)
197
+ process.poll.return_value = None
198
+ resolved = ResolvedAudio("https://example.test/audio", "Test Browser")
199
+
200
+ with (
201
+ patch("ytm_cli.hybrid_player.get_mpv_flags", return_value=["--no-video"]),
202
+ patch("ytm_cli.hybrid_player.save_player_pid"),
203
+ patch("ytm_cli.hybrid_player.subprocess.Popen", return_value=process) as mock_popen,
204
+ patch("ytm_cli.hybrid_player.os.path.exists", return_value=True),
205
+ patch("ytm_cli.hybrid_player.time.sleep"),
206
+ ):
207
+ assert player.play("video-id", "Test Song", resolved) is True
208
+
209
+ command = mock_popen.call_args.args[0]
210
+ assert command[1] == resolved.url
211
+ assert "--user-agent=Test Browser" in command
212
+
213
+ @patch("shutil.which", return_value="/usr/bin/mpv")
214
+ def test_play_mpv_passes_user_agent_when_stream_is_unresolved(self, mock_which):
215
+ """mpv's yt-dlp fallback must not request media as libmpv."""
216
+ with patch("builtins.print"):
217
+ player = CLIHybridPlayerService()
218
+
219
+ process = MagicMock(pid=123)
220
+ process.poll.return_value = None
221
+
222
+ with (
223
+ patch("ytm_cli.hybrid_player.get_mpv_flags", return_value=["--no-video"]),
224
+ patch("ytm_cli.hybrid_player.get_cookies_browser", return_value=None),
225
+ patch("ytm_cli.hybrid_player.save_player_pid"),
226
+ patch("ytm_cli.hybrid_player.subprocess.Popen", return_value=process) as mock_popen,
227
+ patch("ytm_cli.hybrid_player.os.path.exists", return_value=True),
228
+ patch("ytm_cli.hybrid_player.time.sleep"),
229
+ ):
230
+ assert player.play("video-id", "Test Song") is True
231
+
232
+ command = mock_popen.call_args.args[0]
233
+ assert any(flag.startswith("--user-agent=Mozilla/") for flag in command)
234
+
174
235
  @patch("shutil.which", return_value=None)
175
236
  @patch.object(FFmpegPlayerService, "__init__", return_value=None)
176
237
  def test_cleanup_ffmpeg(self, mock_ffmpeg_init, mock_which):
@@ -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,31 @@ 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)
277
+
278
+ def test_main_backward_compatibility_search_with_select(self, sample_songs):
279
+ """Test direct search supports auto-select."""
280
+ test_args = ["ytm_cli", "gio dem qua duong", "-s", "1"]
281
+
282
+ with (
283
+ patch("sys.argv", test_args),
284
+ patch("ytm_cli.main.search_and_play") as mock_search_and_play,
285
+ ):
286
+ main()
287
+
288
+ mock_search_and_play.assert_called_once_with("gio dem qua duong", 1)
289
+
290
+ def test_main_backward_compatibility_unquoted_search_with_select(self, sample_songs):
291
+ """Test direct search joins unquoted query words before options."""
292
+ test_args = ["ytm_cli", "gio", "dem", "qua", "duong", "-s", "1"]
293
+
294
+ with (
295
+ patch("sys.argv", test_args),
296
+ patch("ytm_cli.main.search_and_play") as mock_search_and_play,
297
+ ):
298
+ main()
299
+
300
+ mock_search_and_play.assert_called_once_with("gio dem qua duong", 1)
281
301
 
282
302
  def test_main_search_command(self, sample_songs):
283
303
  """Test main function with search command"""
@@ -10,7 +10,7 @@ with (
10
10
  patch("curses.init_pair"),
11
11
  patch("curses.color_pair"),
12
12
  ):
13
- from ytm_cli.ui import display_player_status
13
+ from ytm_cli.ui import display_player_status, draw_player
14
14
 
15
15
 
16
16
  def _capture_status_output(*args, width=80, terminal_size_error=False, **kwargs):
@@ -27,6 +27,96 @@ def _capture_status_output(*args, width=80, terminal_size_error=False, **kwargs)
27
27
  return buf.getvalue()
28
28
 
29
29
 
30
+ class _FakeScreen:
31
+ """Small curses screen double that exposes complete rendered frames."""
32
+
33
+ def __init__(self, height, width):
34
+ self.height = height
35
+ self.width = width
36
+ self.rows = []
37
+ self.erase()
38
+
39
+ def getmaxyx(self):
40
+ return self.height, self.width
41
+
42
+ def erase(self):
43
+ self.rows = [[" "] * self.width for _ in range(self.height)]
44
+
45
+ def addstr(self, y, x, text, _attr=0):
46
+ for offset, char in enumerate(text):
47
+ if 0 <= y < self.height and 0 <= x + offset < self.width:
48
+ self.rows[y][x + offset] = char
49
+
50
+ def refresh(self):
51
+ pass
52
+
53
+ def render(self):
54
+ return "\n".join("".join(row).rstrip() for row in self.rows)
55
+
56
+
57
+ def _render_player(height=24, width=80, **overrides):
58
+ values = {
59
+ "song_title": "Neon Cruise",
60
+ "artist": "Synthwave Demo Band",
61
+ "is_paused": False,
62
+ "track_idx": 2,
63
+ "track_total": 8,
64
+ "elapsed": 72,
65
+ "duration": 180,
66
+ "bands": [0.1, 0.3, 0.7, 0.4] * 6,
67
+ "next_title": "Rainy Afternoon Lo-Fi",
68
+ "next_artist": "ChillCat",
69
+ }
70
+ values.update(overrides)
71
+ screen = _FakeScreen(height, width)
72
+ with (
73
+ patch("ytm_cli.ui.curses.color_pair", return_value=0),
74
+ patch("ytm_cli.ui.time.time", return_value=100),
75
+ ):
76
+ draw_player(screen, **values)
77
+ return screen.render()
78
+
79
+
80
+ class TestDrawPlayer:
81
+ """Render-level coverage for the responsive full-screen player."""
82
+
83
+ def test_standard_layout_has_clear_hierarchy_and_queue_context(self):
84
+ rendered = _render_player()
85
+
86
+ for text in (
87
+ "YTM // PLAYER",
88
+ "PLAYING 2 / 8",
89
+ "NOW PLAYING",
90
+ "Neon Cruise",
91
+ "Synthwave Demo Band",
92
+ "1:12",
93
+ "40%",
94
+ "UP NEXT",
95
+ "Rainy Afternoon Lo-Fi · ChillCat",
96
+ "[SPACE] pause",
97
+ "[D] dislike",
98
+ ):
99
+ assert text in rendered
100
+
101
+ def test_compact_layout_keeps_essential_information_visible(self):
102
+ rendered = _render_player(height=10, width=42, is_paused=True)
103
+
104
+ assert "PAUSED" in rendered
105
+ assert "Neon Cruise" in rendered
106
+ assert "Synthwave Demo Band" in rendered
107
+ assert "[SPC]" in rendered
108
+ assert "[Q]" in rendered
109
+ assert "NOW PLAYING" not in rendered
110
+
111
+ def test_tiny_layout_does_not_overlap_brand_and_status(self):
112
+ rendered = _render_player(height=6, width=24)
113
+
114
+ first_line = rendered.splitlines()[0]
115
+ assert "PLAYING" in first_line
116
+ assert "YTM●" not in first_line
117
+ assert "Neon Cruise" in rendered
118
+
119
+
30
120
  class TestDisplayPlayerStatus:
31
121
  """Tests for display_player_status function.
32
122
 
@@ -1,3 +1,3 @@
1
1
  """YouTube Music CLI - A terminal interface for YouTube Music"""
2
2
 
3
- __version__ = "0.7.3"
3
+ __version__ = "0.9.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,36 @@ 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"]
47
+
48
+
49
+ def get_cookies_browser() -> str | None:
50
+ """Get the browser to extract cookies from (for yt-dlp authentication)."""
51
+ return config.get("general", "cookies_from_browser", fallback=None)
45
52
 
46
53
 
47
54
  def get_config_value(section, key, fallback=None):
48
55
  """Get a value from the config"""
49
56
  return config.get(section, key, fallback=fallback)
57
+
58
+
59
+ def save_player_pid(pid: int) -> None:
60
+ """Save MPV player PID to file"""
61
+ with open(_PID_FILE, "w") as f:
62
+ f.write(str(pid))
63
+
64
+
65
+ def get_player_pid() -> int | None:
66
+ """Get MPV player PID from file"""
67
+ if _PID_FILE.exists():
68
+ try:
69
+ return int(_PID_FILE.read_text().strip())
70
+ except (ValueError, OSError):
71
+ return None
72
+ return None
73
+
74
+
75
+ def clear_player_pid() -> None:
76
+ """Clear the player PID file"""
77
+ if _PID_FILE.exists():
78
+ _PID_FILE.unlink()
@@ -7,10 +7,56 @@ import socket
7
7
  import subprocess
8
8
  import tempfile
9
9
  import time
10
+ from typing import NamedTuple
10
11
 
11
- from .config import get_mpv_flags
12
+ from yt_dlp.utils.networking import std_headers
13
+
14
+ from .config import clear_player_pid, get_cookies_browser, get_mpv_flags, save_player_pid
12
15
  from .ffmpeg_player import FFmpegPlayerService
13
- from .verbose_logger import log_error, log_info, log_section
16
+ from .verbose_logger import is_verbose, log_error, log_info, log_section
17
+
18
+
19
+ class ResolvedAudio(NamedTuple):
20
+ """Direct audio URL and the user agent required to request it."""
21
+
22
+ url: str
23
+ user_agent: str
24
+
25
+
26
+ def resolve_audio_url(video_id: str) -> ResolvedAudio | None:
27
+ """Pre-resolve audio stream details via yt-dlp."""
28
+ try:
29
+ cmd = [
30
+ "yt-dlp",
31
+ "-f",
32
+ "bestaudio",
33
+ "--print",
34
+ "%(url)s",
35
+ "--print",
36
+ "%(http_headers)j",
37
+ ]
38
+ browser = get_cookies_browser()
39
+ if browser:
40
+ cmd.extend(["--cookies-from-browser", browser])
41
+ cmd.append(f"https://music.youtube.com/watch?v={video_id}")
42
+ result = subprocess.run(
43
+ cmd,
44
+ capture_output=True,
45
+ text=True,
46
+ timeout=15,
47
+ )
48
+ if result.returncode == 0:
49
+ lines = result.stdout.strip().splitlines()
50
+ for url, raw_headers in zip(lines, lines[1:], strict=False):
51
+ if not url.startswith(("http://", "https://")):
52
+ continue
53
+ headers = json.loads(raw_headers)
54
+ user_agent = headers.get("User-Agent")
55
+ if isinstance(user_agent, str) and user_agent:
56
+ return ResolvedAudio(url, user_agent)
57
+ except (json.JSONDecodeError, subprocess.TimeoutExpired, FileNotFoundError, OSError):
58
+ pass
59
+ return None
14
60
 
15
61
 
16
62
  class CLIHybridPlayerService:
@@ -54,21 +100,25 @@ class CLIHybridPlayerService:
54
100
  """Check if any player is available"""
55
101
  return self.player_type != "none"
56
102
 
57
- def play(self, video_id: str, title: str = "") -> bool:
58
- """Start playing a song"""
103
+ def play(
104
+ self, video_id: str, title: str = "", resolved_audio: ResolvedAudio | None = None
105
+ ) -> bool:
106
+ """Start playing a song, optionally using pre-resolved stream details."""
59
107
  if not self.is_available():
60
108
  log_error("Play attempted but no audio player available")
61
109
  print("No audio player available")
62
110
  return False
63
111
 
64
112
  if self.player_type == "mpv":
65
- return self._play_mpv(video_id, title)
113
+ return self._play_mpv(video_id, title, resolved_audio)
66
114
  elif self.player_type == "ffmpeg" and self.ffmpeg_player:
67
115
  return self.ffmpeg_player.play(video_id, title)
68
116
 
69
117
  return False
70
118
 
71
- def _play_mpv(self, video_id: str, title: str = "") -> bool:
119
+ def _play_mpv(
120
+ self, video_id: str, title: str = "", resolved_audio: ResolvedAudio | None = None
121
+ ) -> bool:
72
122
  """Play using mpv"""
73
123
  try:
74
124
  # Clean up previous process if exists
@@ -77,7 +127,11 @@ class CLIHybridPlayerService:
77
127
  # Create socket for IPC
78
128
  self.socket_path = tempfile.mktemp(suffix=".sock")
79
129
 
80
- url = f"https://music.youtube.com/watch?v={video_id}"
130
+ url = (
131
+ resolved_audio.url
132
+ if resolved_audio
133
+ else f"https://music.youtube.com/watch?v={video_id}"
134
+ )
81
135
  mpv_flags = get_mpv_flags()
82
136
  mpv_flags.extend(
83
137
  [
@@ -85,21 +139,59 @@ class CLIHybridPlayerService:
85
139
  "--af-append=@vstats:lavfi=[astats=metadata=1:reset=1:length=0.1]",
86
140
  ]
87
141
  )
142
+ user_agent = resolved_audio.user_agent if resolved_audio else std_headers["User-Agent"]
143
+ mpv_flags.append(f"--user-agent={user_agent}")
144
+ # Pass cookies to mpv's internal yt-dlp when playing unresolved URLs
145
+ if not resolved_audio:
146
+ browser = get_cookies_browser()
147
+ if browser:
148
+ mpv_flags.append(f"--ytdl-raw-options=cookies-from-browser={browser}")
88
149
 
89
150
  log_info(f"Starting MPV playback: {title or video_id}")
90
151
  self.mpv_process = subprocess.Popen(
91
152
  ["mpv", url] + mpv_flags,
92
153
  stdout=subprocess.DEVNULL,
93
- stderr=subprocess.DEVNULL,
154
+ stderr=subprocess.PIPE,
94
155
  )
95
156
 
157
+ # Save MPV PID for --terminate
158
+ if self.mpv_process:
159
+ save_player_pid(self.mpv_process.pid)
160
+
96
161
  # Wait for mpv to create the IPC socket before returning
97
- for _ in range(20):
162
+ for _ in range(30):
98
163
  time.sleep(0.1)
164
+ # Check if mpv exited early (e.g., yt-dlp failure)
165
+ if self.mpv_process.poll() is not None:
166
+ stderr_out = ""
167
+ if self.mpv_process.stderr:
168
+ stderr_out = self.mpv_process.stderr.read().decode(errors="replace")
169
+ self.mpv_process.stderr.close()
170
+ log_error(
171
+ f"MPV exited early (code {self.mpv_process.returncode}): {stderr_out[:200]}"
172
+ )
173
+ clear_player_pid()
174
+ self.mpv_process = None
175
+ return False
99
176
  if os.path.exists(self.socket_path):
177
+ if is_verbose():
178
+ ao = self._get_mpv_property("current-ao")
179
+ volume = self._get_mpv_property("volume")
180
+ mute = self._get_mpv_property("mute")
181
+ aid = self._get_mpv_property("aid")
182
+ log_info(
183
+ f"MPV audio output: ao={ao or 'unknown'}, aid={aid}, "
184
+ f"volume={volume}, mute={mute}"
185
+ )
186
+ # Detach stderr now that startup succeeded (avoid blocking on pipe)
187
+ if self.mpv_process.stderr:
188
+ self.mpv_process.stderr.close()
100
189
  return True
101
- # Socket not created but process is running
102
- return self.mpv_process.poll() is None
190
+
191
+ # Socket not created but process is running — close stderr pipe
192
+ if self.mpv_process and self.mpv_process.stderr:
193
+ self.mpv_process.stderr.close()
194
+ return self.mpv_process.poll() is None if self.mpv_process else False
103
195
  except Exception as e:
104
196
  log_error(f"Failed to start mpv: {e}")
105
197
  print(f"Failed to start mpv: {e}")
@@ -114,6 +206,7 @@ class CLIHybridPlayerService:
114
206
  if self.socket_path and os.path.exists(self.socket_path):
115
207
  os.unlink(self.socket_path)
116
208
  self.socket_path = None
209
+ clear_player_pid()
117
210
  elif self.player_type == "ffmpeg" and self.ffmpeg_player:
118
211
  self.ffmpeg_player.stop()
119
212
 
@@ -140,7 +233,6 @@ class CLIHybridPlayerService:
140
233
  if self.player_type == "mpv" and self.mpv_process:
141
234
  if self.mpv_process.poll() is not None:
142
235
  return False
143
- # Also check if mpv reports idle (finished playing)
144
236
  if self.socket_path and os.path.exists(self.socket_path):
145
237
  idle = self._get_mpv_property("idle-active")
146
238
  if idle is True: