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.
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/PKG-INFO +3 -1
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/pyproject.toml +3 -1
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_config.py +0 -21
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_hybrid_player.py +62 -1
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_main.py +28 -8
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_ui.py +91 -1
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/__init__.py +1 -1
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/config.py +30 -1
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/hybrid_player.py +104 -12
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/main.py +95 -16
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/player.py +140 -22
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/ui.py +211 -114
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli.egg-info/PKG-INFO +3 -1
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli.egg-info/requires.txt +2 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/LICENSE +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/README.md +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/setup.cfg +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_cache.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_dislikes.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_ffmpeg_player.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_integration.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_lyrics_service.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_playlists.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_runner.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_spectrum.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/tests/test_utils.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/__main__.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/cache.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/demo.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/dislikes.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/ffmpeg_player.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/llm_client.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/lyrics_service.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/playlists.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/spectrum.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/utils.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli/verbose_logger.py +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli.egg-info/SOURCES.txt +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli.egg-info/dependency_links.txt +0 -0
- {ytm_cli-0.7.3 → ytm_cli-0.9.0}/ytm_cli.egg-info/entry_points.txt +0 -0
- {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.
|
|
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
|
+
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"
|
|
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
|
|
|
@@ -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 .
|
|
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(
|
|
58
|
-
""
|
|
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(
|
|
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 =
|
|
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.
|
|
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(
|
|
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
|
-
|
|
102
|
-
|
|
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:
|