ytm-cli 0.6.0__tar.gz → 0.7.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.6.0 → ytm_cli-0.7.0}/PKG-INFO +12 -3
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/README.md +10 -2
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/pyproject.toml +2 -1
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/tests/test_dislikes.py +2 -7
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/tests/test_lyrics_service.py +6 -12
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/__init__.py +1 -1
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/dislikes.py +0 -3
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/hybrid_player.py +6 -1
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/lyrics_service.py +4 -8
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/player.py +264 -181
- ytm_cli-0.7.0/ytm_cli/spectrum.py +149 -0
- ytm_cli-0.7.0/ytm_cli/ui.py +764 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli.egg-info/PKG-INFO +12 -3
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli.egg-info/SOURCES.txt +1 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli.egg-info/requires.txt +1 -0
- ytm_cli-0.6.0/ytm_cli/ui.py +0 -451
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/LICENSE +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/setup.cfg +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/tests/test_config.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/tests/test_ffmpeg_player.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/tests/test_hybrid_player.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/tests/test_integration.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/tests/test_main.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/tests/test_playlists.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/tests/test_runner.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/tests/test_ui.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/tests/test_utils.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/__main__.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/auth.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/cache.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/config.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/ffmpeg_player.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/llm_client.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/main.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/playlists.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/utils.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli/verbose_logger.py +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli.egg-info/dependency_links.txt +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.0}/ytm_cli.egg-info/entry_points.txt +0 -0
- {ytm_cli-0.6.0 → ytm_cli-0.7.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.7.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
|
|
@@ -23,6 +23,7 @@ Requires-Dist: rich
|
|
|
23
23
|
Requires-Dist: mpv
|
|
24
24
|
Requires-Dist: requests
|
|
25
25
|
Requires-Dist: pyperclip
|
|
26
|
+
Requires-Dist: numpy
|
|
26
27
|
Dynamic: license-file
|
|
27
28
|
|
|
28
29
|
# YTM - YouTube Music CLI
|
|
@@ -42,7 +43,7 @@ Stream music directly from YouTube Music in your terminal with intuitive control
|
|
|
42
43
|
- **📱 Vim-like Navigation**: Use `j/k` keys or arrow keys to navigate
|
|
43
44
|
- **📋 Local Playlists**: Create and manage personal playlists
|
|
44
45
|
- **👎 Smart Filtering**: Dislike songs to filter them from future results
|
|
45
|
-
- **📜 Lyrics
|
|
46
|
+
- **📜 Synced Lyrics**: Live-highlighted lyrics with auto-scroll (press `l`)
|
|
46
47
|
- **🎯 Radio Mode**: Automatic playlist generation based on your selection
|
|
47
48
|
- **🤖 AI-Powered**: Natural language music requests and AI-generated playlists
|
|
48
49
|
|
|
@@ -162,11 +163,19 @@ ytm-cli search "song" -s 1 -v --log-file debug.log # Save debug logs
|
|
|
162
163
|
- `Space` - Play/pause
|
|
163
164
|
- `n` - Next song
|
|
164
165
|
- `b` - Previous song
|
|
165
|
-
- `l` - Show lyrics
|
|
166
|
+
- `l` - Show lyrics (synced highlighting with auto-scroll)
|
|
166
167
|
- `a` - Add to playlist
|
|
167
168
|
- `d` - Dislike song (skip and filter from future results)
|
|
168
169
|
- `q` - Quit
|
|
169
170
|
|
|
171
|
+
### During Lyrics View
|
|
172
|
+
|
|
173
|
+
- `j/k` or `↑/↓` - Manual scroll
|
|
174
|
+
- `Space` - Re-enable auto-scroll
|
|
175
|
+
- `q` or `Esc` - Back to player
|
|
176
|
+
- `PgUp/PgDn` - Page scroll
|
|
177
|
+
- `Home/End` - Jump to top/bottom
|
|
178
|
+
|
|
170
179
|
## 📋 Playlist Management
|
|
171
180
|
|
|
172
181
|
**Create and manage playlists:**
|
|
@@ -15,7 +15,7 @@ Stream music directly from YouTube Music in your terminal with intuitive control
|
|
|
15
15
|
- **📱 Vim-like Navigation**: Use `j/k` keys or arrow keys to navigate
|
|
16
16
|
- **📋 Local Playlists**: Create and manage personal playlists
|
|
17
17
|
- **👎 Smart Filtering**: Dislike songs to filter them from future results
|
|
18
|
-
- **📜 Lyrics
|
|
18
|
+
- **📜 Synced Lyrics**: Live-highlighted lyrics with auto-scroll (press `l`)
|
|
19
19
|
- **🎯 Radio Mode**: Automatic playlist generation based on your selection
|
|
20
20
|
- **🤖 AI-Powered**: Natural language music requests and AI-generated playlists
|
|
21
21
|
|
|
@@ -135,11 +135,19 @@ ytm-cli search "song" -s 1 -v --log-file debug.log # Save debug logs
|
|
|
135
135
|
- `Space` - Play/pause
|
|
136
136
|
- `n` - Next song
|
|
137
137
|
- `b` - Previous song
|
|
138
|
-
- `l` - Show lyrics
|
|
138
|
+
- `l` - Show lyrics (synced highlighting with auto-scroll)
|
|
139
139
|
- `a` - Add to playlist
|
|
140
140
|
- `d` - Dislike song (skip and filter from future results)
|
|
141
141
|
- `q` - Quit
|
|
142
142
|
|
|
143
|
+
### During Lyrics View
|
|
144
|
+
|
|
145
|
+
- `j/k` or `↑/↓` - Manual scroll
|
|
146
|
+
- `Space` - Re-enable auto-scroll
|
|
147
|
+
- `q` or `Esc` - Back to player
|
|
148
|
+
- `PgUp/PgDn` - Page scroll
|
|
149
|
+
- `Home/End` - Jump to top/bottom
|
|
150
|
+
|
|
143
151
|
## 📋 Playlist Management
|
|
144
152
|
|
|
145
153
|
**Create and manage playlists:**
|
|
@@ -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.7.0"
|
|
8
8
|
description = "YouTube Music CLI tool with interactive terminal interface"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -28,6 +28,7 @@ dependencies = [
|
|
|
28
28
|
"mpv",
|
|
29
29
|
"requests",
|
|
30
30
|
"pyperclip",
|
|
31
|
+
"numpy",
|
|
31
32
|
]
|
|
32
33
|
|
|
33
34
|
[project.urls]
|
|
@@ -216,8 +216,7 @@ class TestFilterDislikedSongs:
|
|
|
216
216
|
|
|
217
217
|
manager = DislikeManager(dislikes_file)
|
|
218
218
|
|
|
219
|
-
|
|
220
|
-
result = manager.filter_disliked_songs(sample_songs)
|
|
219
|
+
result = manager.filter_disliked_songs(sample_songs)
|
|
221
220
|
|
|
222
221
|
# Should filter out song1
|
|
223
222
|
assert len(result) == 2
|
|
@@ -226,8 +225,6 @@ class TestFilterDislikedSongs:
|
|
|
226
225
|
assert "song2" in video_ids
|
|
227
226
|
assert "song3" in video_ids
|
|
228
227
|
|
|
229
|
-
mock_print.assert_called_with("[yellow]Filtered out 1 disliked song(s)[/yellow]")
|
|
230
|
-
|
|
231
228
|
def test_filter_disliked_songs_all_disliked(self, temp_dir, sample_songs):
|
|
232
229
|
"""Test filtering when all songs are disliked"""
|
|
233
230
|
dislikes_data = {
|
|
@@ -244,11 +241,9 @@ class TestFilterDislikedSongs:
|
|
|
244
241
|
|
|
245
242
|
manager = DislikeManager(dislikes_file)
|
|
246
243
|
|
|
247
|
-
|
|
248
|
-
result = manager.filter_disliked_songs(sample_songs)
|
|
244
|
+
result = manager.filter_disliked_songs(sample_songs)
|
|
249
245
|
|
|
250
246
|
assert len(result) == 0
|
|
251
|
-
mock_print.assert_called_with("[yellow]Filtered out 3 disliked song(s)[/yellow]")
|
|
252
247
|
|
|
253
248
|
def test_filter_disliked_songs_missing_video_id(self, temp_dir):
|
|
254
249
|
"""Test filtering songs with missing videoId"""
|
|
@@ -50,7 +50,7 @@ class TestLyricsService:
|
|
|
50
50
|
"album_name": "Test Album",
|
|
51
51
|
"duration": 225,
|
|
52
52
|
},
|
|
53
|
-
timeout=
|
|
53
|
+
timeout=5,
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
def test_get_lyrics_without_optional_params(self, sample_lyrics_response):
|
|
@@ -69,7 +69,7 @@ class TestLyricsService:
|
|
|
69
69
|
mock_get.assert_called_once_with(
|
|
70
70
|
f"{service.base_url}/get",
|
|
71
71
|
params={"track_name": "Test Song", "artist_name": "Test Artist"},
|
|
72
|
-
timeout=
|
|
72
|
+
timeout=5,
|
|
73
73
|
)
|
|
74
74
|
|
|
75
75
|
def test_get_lyrics_not_found(self):
|
|
@@ -94,11 +94,9 @@ class TestLyricsService:
|
|
|
94
94
|
"get",
|
|
95
95
|
side_effect=requests.RequestException("Network error"),
|
|
96
96
|
):
|
|
97
|
-
|
|
98
|
-
result = service.get_lyrics("Test Song", "Test Artist")
|
|
97
|
+
result = service.get_lyrics("Test Song", "Test Artist")
|
|
99
98
|
|
|
100
99
|
assert result is None
|
|
101
|
-
mock_print.assert_called_with("Error fetching lyrics from LRCLIB: Network error")
|
|
102
100
|
|
|
103
101
|
def test_search_lyrics_success(self):
|
|
104
102
|
"""Test successful lyrics search"""
|
|
@@ -120,7 +118,7 @@ class TestLyricsService:
|
|
|
120
118
|
mock_get.assert_called_once_with(
|
|
121
119
|
f"{service.base_url}/search",
|
|
122
120
|
params={"track_name": "Test Song"},
|
|
123
|
-
timeout=
|
|
121
|
+
timeout=5,
|
|
124
122
|
)
|
|
125
123
|
|
|
126
124
|
def test_search_lyrics_no_results(self):
|
|
@@ -146,11 +144,9 @@ class TestLyricsService:
|
|
|
146
144
|
mock_response.status_code = 500
|
|
147
145
|
mock_get.return_value = mock_response
|
|
148
146
|
|
|
149
|
-
|
|
150
|
-
result = service.search_lyrics("Test Song")
|
|
147
|
+
result = service.search_lyrics("Test Song")
|
|
151
148
|
|
|
152
149
|
assert result == []
|
|
153
|
-
mock_print.assert_called_with("LRCLIB search error: 500")
|
|
154
150
|
|
|
155
151
|
def test_search_lyrics_network_error(self):
|
|
156
152
|
"""Test lyrics search with network error"""
|
|
@@ -161,11 +157,9 @@ class TestLyricsService:
|
|
|
161
157
|
"get",
|
|
162
158
|
side_effect=requests.RequestException("Network error"),
|
|
163
159
|
):
|
|
164
|
-
|
|
165
|
-
result = service.search_lyrics("Test Song")
|
|
160
|
+
result = service.search_lyrics("Test Song")
|
|
166
161
|
|
|
167
162
|
assert result == []
|
|
168
|
-
mock_print.assert_called_with("Error searching lyrics from LRCLIB: Network error")
|
|
169
163
|
|
|
170
164
|
|
|
171
165
|
class TestLRCParser:
|
|
@@ -116,9 +116,6 @@ class DislikeManager:
|
|
|
116
116
|
continue
|
|
117
117
|
filtered.append(song)
|
|
118
118
|
|
|
119
|
-
if filtered_count > 0:
|
|
120
|
-
print(f"[yellow]Filtered out {filtered_count} disliked song(s)[/yellow]")
|
|
121
|
-
|
|
122
119
|
return filtered
|
|
123
120
|
|
|
124
121
|
def get_disliked_songs(self) -> list[dict[str, Any]]:
|
|
@@ -79,7 +79,12 @@ class CLIHybridPlayerService:
|
|
|
79
79
|
|
|
80
80
|
url = f"https://music.youtube.com/watch?v={video_id}"
|
|
81
81
|
mpv_flags = get_mpv_flags()
|
|
82
|
-
mpv_flags.extend(
|
|
82
|
+
mpv_flags.extend(
|
|
83
|
+
[
|
|
84
|
+
f"--input-ipc-server={self.socket_path}",
|
|
85
|
+
"--af-append=@vstats:lavfi=[astats=metadata=1:reset=1:length=0.1]",
|
|
86
|
+
]
|
|
87
|
+
)
|
|
83
88
|
|
|
84
89
|
log_info(f"Starting MPV playback: {title or video_id}")
|
|
85
90
|
self.mpv_process = subprocess.Popen(
|
|
@@ -43,16 +43,14 @@ class LyricsService:
|
|
|
43
43
|
params["duration"] = duration
|
|
44
44
|
|
|
45
45
|
try:
|
|
46
|
-
response = self.session.get(f"{self.base_url}/get", params=params, timeout=
|
|
46
|
+
response = self.session.get(f"{self.base_url}/get", params=params, timeout=5)
|
|
47
47
|
if response.status_code == 200:
|
|
48
48
|
return response.json()
|
|
49
49
|
elif response.status_code == 404:
|
|
50
50
|
return None
|
|
51
51
|
else:
|
|
52
|
-
print(f"LRCLIB API error: {response.status_code}")
|
|
53
52
|
return None
|
|
54
|
-
except requests.RequestException
|
|
55
|
-
print(f"Error fetching lyrics from LRCLIB: {e}")
|
|
53
|
+
except requests.RequestException:
|
|
56
54
|
return None
|
|
57
55
|
|
|
58
56
|
def search_lyrics(self, track_name: str) -> list[dict]:
|
|
@@ -68,14 +66,12 @@ class LyricsService:
|
|
|
68
66
|
params = {"track_name": track_name}
|
|
69
67
|
|
|
70
68
|
try:
|
|
71
|
-
response = self.session.get(f"{self.base_url}/search", params=params, timeout=
|
|
69
|
+
response = self.session.get(f"{self.base_url}/search", params=params, timeout=5)
|
|
72
70
|
if response.status_code == 200:
|
|
73
71
|
return response.json()
|
|
74
72
|
else:
|
|
75
|
-
print(f"LRCLIB search error: {response.status_code}")
|
|
76
73
|
return []
|
|
77
|
-
except requests.RequestException
|
|
78
|
-
print(f"Error searching lyrics from LRCLIB: {e}")
|
|
74
|
+
except requests.RequestException:
|
|
79
75
|
return []
|
|
80
76
|
|
|
81
77
|
|