ytm-cli 0.7.0__tar.gz → 0.7.1__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.0 → ytm_cli-0.7.1}/PKG-INFO +1 -4
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/README.md +0 -3
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/pyproject.toml +1 -1
- ytm_cli-0.7.1/tests/test_cache.py +111 -0
- ytm_cli-0.7.1/tests/test_spectrum.py +167 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/__init__.py +1 -1
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/main.py +9 -8
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli.egg-info/PKG-INFO +1 -4
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli.egg-info/SOURCES.txt +2 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/LICENSE +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/setup.cfg +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_config.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_dislikes.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_ffmpeg_player.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_hybrid_player.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_integration.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_lyrics_service.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_main.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_playlists.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_runner.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_ui.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_utils.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/__main__.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/auth.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/cache.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/config.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/dislikes.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/ffmpeg_player.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/hybrid_player.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/llm_client.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/lyrics_service.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/player.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/playlists.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/spectrum.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/ui.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/utils.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/verbose_logger.py +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli.egg-info/dependency_links.txt +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli.egg-info/entry_points.txt +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli.egg-info/requires.txt +0 -0
- {ytm_cli-0.7.0 → ytm_cli-0.7.1}/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
|
+
Version: 0.7.1
|
|
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
|
|
@@ -61,9 +61,6 @@ Stream music directly from YouTube Music in your terminal with intuitive control
|
|
|
61
61
|
```bash
|
|
62
62
|
# Install from PyPI
|
|
63
63
|
uvx ytm-cli "song name"
|
|
64
|
-
|
|
65
|
-
# Or from source (no clone needed)
|
|
66
|
-
uvx --from git+https://github.com/thieuluan1618/ytm.git ytm-cli "song name"
|
|
67
64
|
```
|
|
68
65
|
|
|
69
66
|
<details>
|
|
@@ -33,9 +33,6 @@ Stream music directly from YouTube Music in your terminal with intuitive control
|
|
|
33
33
|
```bash
|
|
34
34
|
# Install from PyPI
|
|
35
35
|
uvx ytm-cli "song name"
|
|
36
|
-
|
|
37
|
-
# Or from source (no clone needed)
|
|
38
|
-
uvx --from git+https://github.com/thieuluan1618/ytm.git ytm-cli "song name"
|
|
39
36
|
```
|
|
40
37
|
|
|
41
38
|
<details>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Tests for ytm_cli.cache."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from ytm_cli import cache as cache_module
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def temp_cache_file(tmp_path, monkeypatch):
|
|
14
|
+
"""Redirect CACHE_FILE to an isolated temp path for each test."""
|
|
15
|
+
path = tmp_path / "ytm_cache.json"
|
|
16
|
+
monkeypatch.setattr(cache_module, "CACHE_FILE", str(path))
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestLoadSaveCache:
|
|
21
|
+
def test_load_cache_returns_empty_dict_when_missing(self, temp_cache_file):
|
|
22
|
+
assert cache_module.load_cache() == {}
|
|
23
|
+
|
|
24
|
+
def test_load_cache_returns_empty_dict_on_invalid_json(self, temp_cache_file):
|
|
25
|
+
temp_cache_file.write_text("not json {{{")
|
|
26
|
+
assert cache_module.load_cache() == {}
|
|
27
|
+
|
|
28
|
+
def test_save_cache_then_load_round_trip(self, temp_cache_file):
|
|
29
|
+
payload = {"k": {"data": "v", "timestamp": 1234.0}}
|
|
30
|
+
cache_module.save_cache(payload)
|
|
31
|
+
assert json.loads(temp_cache_file.read_text()) == payload
|
|
32
|
+
assert cache_module.load_cache() == payload
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestGetSetCached:
|
|
36
|
+
def test_get_cached_missing_key_returns_none(self, temp_cache_file):
|
|
37
|
+
assert cache_module.get_cached("nope") is None
|
|
38
|
+
|
|
39
|
+
def test_set_then_get_returns_value(self, temp_cache_file):
|
|
40
|
+
cache_module.set_cached("query1", "result1")
|
|
41
|
+
assert cache_module.get_cached("query1") == "result1"
|
|
42
|
+
|
|
43
|
+
def test_get_cached_expired_entry_removed_and_returns_none(self, temp_cache_file):
|
|
44
|
+
# Write an entry with timestamp older than the TTL.
|
|
45
|
+
old_ts = time.time() - cache_module.CACHE_TTL - 100
|
|
46
|
+
cache_module.save_cache({"old": {"data": "stale", "timestamp": old_ts}})
|
|
47
|
+
|
|
48
|
+
assert cache_module.get_cached("old") is None
|
|
49
|
+
# Expired entry should be evicted from disk on read.
|
|
50
|
+
assert "old" not in cache_module.load_cache()
|
|
51
|
+
|
|
52
|
+
def test_set_cached_overwrites_existing(self, temp_cache_file):
|
|
53
|
+
cache_module.set_cached("k", "v1")
|
|
54
|
+
cache_module.set_cached("k", "v2")
|
|
55
|
+
assert cache_module.get_cached("k") == "v2"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestClearExpired:
|
|
59
|
+
def test_clear_expired_removes_only_old_entries(self, temp_cache_file):
|
|
60
|
+
now = time.time()
|
|
61
|
+
cache_module.save_cache(
|
|
62
|
+
{
|
|
63
|
+
"fresh": {"data": "a", "timestamp": now},
|
|
64
|
+
"stale": {"data": "b", "timestamp": now - cache_module.CACHE_TTL - 10},
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
cache_module.clear_expired()
|
|
69
|
+
|
|
70
|
+
remaining = cache_module.load_cache()
|
|
71
|
+
assert "fresh" in remaining
|
|
72
|
+
assert "stale" not in remaining
|
|
73
|
+
|
|
74
|
+
def test_clear_expired_on_empty_cache_is_noop(self, temp_cache_file):
|
|
75
|
+
cache_module.clear_expired()
|
|
76
|
+
assert cache_module.load_cache() == {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestSearchWithCache:
|
|
80
|
+
def test_search_with_cache_calls_search_func_on_miss(self, temp_cache_file):
|
|
81
|
+
search_func = lambda q: [{"title": q}] # noqa: E731
|
|
82
|
+
|
|
83
|
+
result = cache_module.search_with_cache("song", search_func)
|
|
84
|
+
|
|
85
|
+
assert result == [{"title": "song"}]
|
|
86
|
+
# Underlying entry is JSON-serialized.
|
|
87
|
+
stored = cache_module.get_cached("song")
|
|
88
|
+
assert json.loads(stored) == [{"title": "song"}]
|
|
89
|
+
|
|
90
|
+
def test_search_with_cache_uses_cached_value_on_hit(self, temp_cache_file):
|
|
91
|
+
calls = {"n": 0}
|
|
92
|
+
|
|
93
|
+
def search_func(q):
|
|
94
|
+
calls["n"] += 1
|
|
95
|
+
return [{"title": q}]
|
|
96
|
+
|
|
97
|
+
cache_module.search_with_cache("song", search_func)
|
|
98
|
+
cache_module.search_with_cache("song", search_func)
|
|
99
|
+
|
|
100
|
+
assert calls["n"] == 1 # second call served from cache
|
|
101
|
+
|
|
102
|
+
def test_search_with_cache_handles_non_string_cached_value(self, temp_cache_file):
|
|
103
|
+
# Pre-populate cache with a non-string (already-deserialized) value.
|
|
104
|
+
cache_module.set_cached("song", [{"title": "song"}])
|
|
105
|
+
|
|
106
|
+
# search_func should NOT be called.
|
|
107
|
+
with patch.object(cache_module, "save_cache") as mock_save:
|
|
108
|
+
result = cache_module.search_with_cache("song", lambda q: pytest.fail("must not call"))
|
|
109
|
+
|
|
110
|
+
assert result == [{"title": "song"}]
|
|
111
|
+
mock_save.assert_not_called()
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Tests for ytm_cli.spectrum.SpectrumAnalyzer.
|
|
2
|
+
|
|
3
|
+
These cover the parts that don't require an ffmpeg subprocess: band-bin
|
|
4
|
+
construction, availability detection, start/stop guard behaviour, and
|
|
5
|
+
get_bands gating on _has_data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from ytm_cli.spectrum import SpectrumAnalyzer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestInitAndBandBins:
|
|
17
|
+
def test_default_n_bands(self):
|
|
18
|
+
sa = SpectrumAnalyzer()
|
|
19
|
+
assert sa.n_bands == 24
|
|
20
|
+
assert sa._bands.shape == (24,)
|
|
21
|
+
assert sa._bands.dtype == np.float32
|
|
22
|
+
assert np.all(sa._bands == 0.0)
|
|
23
|
+
|
|
24
|
+
def test_custom_n_bands(self):
|
|
25
|
+
sa = SpectrumAnalyzer(n_bands=8)
|
|
26
|
+
assert sa.n_bands == 8
|
|
27
|
+
assert sa._bands.shape == (8,)
|
|
28
|
+
assert len(sa._band_slices) == 8
|
|
29
|
+
|
|
30
|
+
def test_band_slices_are_monotonic_and_non_empty(self):
|
|
31
|
+
sa = SpectrumAnalyzer(n_bands=12)
|
|
32
|
+
last_hi = -1
|
|
33
|
+
for lo, hi in sa._band_slices:
|
|
34
|
+
assert hi > lo, "every band must contain at least one bin"
|
|
35
|
+
assert lo >= last_hi or lo == 0, "bands should be non-decreasing"
|
|
36
|
+
last_hi = hi
|
|
37
|
+
|
|
38
|
+
def test_window_matches_chunk_size(self):
|
|
39
|
+
sa = SpectrumAnalyzer()
|
|
40
|
+
assert sa._window.shape == (SpectrumAnalyzer.CHUNK,)
|
|
41
|
+
assert sa._window.dtype == np.float32
|
|
42
|
+
# Hann window peaks near the center.
|
|
43
|
+
assert sa._window.max() == pytest.approx(1.0, rel=1e-2)
|
|
44
|
+
|
|
45
|
+
def test_initial_state_flags(self):
|
|
46
|
+
sa = SpectrumAnalyzer()
|
|
47
|
+
assert sa._running is False
|
|
48
|
+
assert sa._has_data is False
|
|
49
|
+
assert sa._proc is None
|
|
50
|
+
assert sa._thread is None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestAvailable:
|
|
54
|
+
def test_available_returns_true_when_ffmpeg_in_path(self):
|
|
55
|
+
with patch("ytm_cli.spectrum.shutil.which", return_value="/usr/bin/ffmpeg"):
|
|
56
|
+
assert SpectrumAnalyzer.available() is True
|
|
57
|
+
|
|
58
|
+
def test_available_returns_false_when_ffmpeg_missing(self):
|
|
59
|
+
with patch("ytm_cli.spectrum.shutil.which", return_value=None):
|
|
60
|
+
assert SpectrumAnalyzer.available() is False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestGetBands:
|
|
64
|
+
def test_get_bands_returns_none_before_any_data(self):
|
|
65
|
+
sa = SpectrumAnalyzer()
|
|
66
|
+
assert sa.get_bands() is None
|
|
67
|
+
|
|
68
|
+
def test_get_bands_returns_list_after_data_flag(self):
|
|
69
|
+
sa = SpectrumAnalyzer(n_bands=4)
|
|
70
|
+
sa._bands = np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32)
|
|
71
|
+
sa._has_data = True
|
|
72
|
+
|
|
73
|
+
bands = sa.get_bands()
|
|
74
|
+
assert isinstance(bands, list)
|
|
75
|
+
assert len(bands) == 4
|
|
76
|
+
for got, want in zip(bands, [0.1, 0.2, 0.3, 0.4], strict=True):
|
|
77
|
+
assert got == pytest.approx(want, rel=1e-5)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestStart:
|
|
81
|
+
def test_start_returns_false_when_ffmpeg_unavailable(self):
|
|
82
|
+
sa = SpectrumAnalyzer()
|
|
83
|
+
with patch.object(SpectrumAnalyzer, "available", return_value=False):
|
|
84
|
+
assert sa.start("http://example/audio") is False
|
|
85
|
+
assert sa._running is False
|
|
86
|
+
|
|
87
|
+
def test_start_returns_false_with_empty_url(self):
|
|
88
|
+
sa = SpectrumAnalyzer()
|
|
89
|
+
with patch.object(SpectrumAnalyzer, "available", return_value=True):
|
|
90
|
+
assert sa.start("") is False
|
|
91
|
+
|
|
92
|
+
def test_start_returns_false_when_popen_raises_oserror(self):
|
|
93
|
+
sa = SpectrumAnalyzer()
|
|
94
|
+
with (
|
|
95
|
+
patch.object(SpectrumAnalyzer, "available", return_value=True),
|
|
96
|
+
patch("ytm_cli.spectrum.subprocess.Popen", side_effect=OSError("boom")),
|
|
97
|
+
):
|
|
98
|
+
assert sa.start("http://example/audio") is False
|
|
99
|
+
assert sa._running is False
|
|
100
|
+
|
|
101
|
+
def test_start_spawns_ffmpeg_and_marks_running(self):
|
|
102
|
+
sa = SpectrumAnalyzer()
|
|
103
|
+
fake_proc = MagicMock()
|
|
104
|
+
fake_proc.poll.return_value = None
|
|
105
|
+
# Reader will hit `len(data) < chunk_bytes` immediately and exit.
|
|
106
|
+
fake_proc.stdout.read.return_value = b""
|
|
107
|
+
|
|
108
|
+
with (
|
|
109
|
+
patch.object(SpectrumAnalyzer, "available", return_value=True),
|
|
110
|
+
patch("ytm_cli.spectrum.subprocess.Popen", return_value=fake_proc) as mock_popen,
|
|
111
|
+
):
|
|
112
|
+
ok = sa.start("http://example/audio")
|
|
113
|
+
|
|
114
|
+
assert ok is True
|
|
115
|
+
assert sa._proc is fake_proc
|
|
116
|
+
assert sa._thread is not None
|
|
117
|
+
# Ensure we asked ffmpeg for s16le mono at the analyzer's sample rate.
|
|
118
|
+
cmd = mock_popen.call_args.args[0]
|
|
119
|
+
assert cmd[0] == "ffmpeg"
|
|
120
|
+
assert "-f" in cmd and cmd[cmd.index("-f") + 1] == "s16le"
|
|
121
|
+
assert "-ac" in cmd and cmd[cmd.index("-ac") + 1] == "1"
|
|
122
|
+
assert "-ar" in cmd and cmd[cmd.index("-ar") + 1] == str(SpectrumAnalyzer.SR)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestStop:
|
|
126
|
+
def test_stop_when_never_started_is_safe(self):
|
|
127
|
+
sa = SpectrumAnalyzer(n_bands=4)
|
|
128
|
+
sa._bands = np.array([0.5, 0.5, 0.5, 0.5], dtype=np.float32)
|
|
129
|
+
sa._has_data = True
|
|
130
|
+
|
|
131
|
+
sa.stop()
|
|
132
|
+
|
|
133
|
+
assert sa._running is False
|
|
134
|
+
assert sa._proc is None
|
|
135
|
+
assert sa._thread is None
|
|
136
|
+
assert sa._has_data is False
|
|
137
|
+
assert np.all(sa._bands == 0.0)
|
|
138
|
+
|
|
139
|
+
def test_stop_terminates_running_process(self):
|
|
140
|
+
sa = SpectrumAnalyzer()
|
|
141
|
+
fake_proc = MagicMock()
|
|
142
|
+
fake_proc.wait.return_value = 0
|
|
143
|
+
sa._proc = fake_proc
|
|
144
|
+
sa._running = True
|
|
145
|
+
|
|
146
|
+
sa.stop()
|
|
147
|
+
|
|
148
|
+
fake_proc.terminate.assert_called_once()
|
|
149
|
+
fake_proc.wait.assert_called()
|
|
150
|
+
assert sa._proc is None
|
|
151
|
+
assert sa._running is False
|
|
152
|
+
|
|
153
|
+
def test_stop_kills_process_on_terminate_timeout(self):
|
|
154
|
+
sa = SpectrumAnalyzer()
|
|
155
|
+
fake_proc = MagicMock()
|
|
156
|
+
# First wait raises TimeoutExpired, then kill+wait succeed.
|
|
157
|
+
from subprocess import TimeoutExpired
|
|
158
|
+
|
|
159
|
+
fake_proc.wait.side_effect = [TimeoutExpired(cmd="ffmpeg", timeout=1), 0]
|
|
160
|
+
sa._proc = fake_proc
|
|
161
|
+
sa._running = True
|
|
162
|
+
|
|
163
|
+
sa.stop()
|
|
164
|
+
|
|
165
|
+
fake_proc.terminate.assert_called_once()
|
|
166
|
+
fake_proc.kill.assert_called_once()
|
|
167
|
+
assert sa._proc is None
|
|
@@ -7,6 +7,7 @@ from curses import wrapper
|
|
|
7
7
|
|
|
8
8
|
from rich import print
|
|
9
9
|
|
|
10
|
+
from . import __version__
|
|
10
11
|
from .config import get_songs_to_display, ytmusic
|
|
11
12
|
from .dislikes import dislike_manager
|
|
12
13
|
from .player import play_music_with_controls
|
|
@@ -479,8 +480,13 @@ During music playback:
|
|
|
479
480
|
|
|
480
481
|
# Global options (available for all commands)
|
|
481
482
|
parser.add_argument(
|
|
482
|
-
"--
|
|
483
|
+
"--version",
|
|
483
484
|
"-v",
|
|
485
|
+
action="version",
|
|
486
|
+
version=f"ytm-cli {__version__}",
|
|
487
|
+
)
|
|
488
|
+
parser.add_argument(
|
|
489
|
+
"--verbose",
|
|
484
490
|
action="store_true",
|
|
485
491
|
help="Enable verbose output with detailed logging",
|
|
486
492
|
)
|
|
@@ -510,7 +516,6 @@ During music playback:
|
|
|
510
516
|
)
|
|
511
517
|
search_parser.add_argument(
|
|
512
518
|
"--verbose",
|
|
513
|
-
"-v",
|
|
514
519
|
action="store_true",
|
|
515
520
|
help="Enable verbose output with detailed logging",
|
|
516
521
|
)
|
|
@@ -543,9 +548,7 @@ During music playback:
|
|
|
543
548
|
llm_ask_parser.add_argument(
|
|
544
549
|
"prompt", help="Natural language request for music (e.g. 'play upbeat pop songs')"
|
|
545
550
|
)
|
|
546
|
-
llm_ask_parser.add_argument(
|
|
547
|
-
"--verbose", "-v", action="store_true", help="Enable verbose output"
|
|
548
|
-
)
|
|
551
|
+
llm_ask_parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
|
549
552
|
|
|
550
553
|
# llm playlist - AI-generated playlist
|
|
551
554
|
llm_playlist_parser = llm_subparsers.add_parser(
|
|
@@ -560,9 +563,7 @@ During music playback:
|
|
|
560
563
|
llm_playlist_parser.add_argument(
|
|
561
564
|
"--play", "-p", action="store_true", help="Start playing after creation"
|
|
562
565
|
)
|
|
563
|
-
llm_playlist_parser.add_argument(
|
|
564
|
-
"--verbose", "-v", action="store_true", help="Enable verbose output"
|
|
565
|
-
)
|
|
566
|
+
llm_playlist_parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
|
566
567
|
|
|
567
568
|
playlist_subparsers.add_parser(
|
|
568
569
|
"list",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ytm-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.1
|
|
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
|
|
@@ -61,9 +61,6 @@ Stream music directly from YouTube Music in your terminal with intuitive control
|
|
|
61
61
|
```bash
|
|
62
62
|
# Install from PyPI
|
|
63
63
|
uvx ytm-cli "song name"
|
|
64
|
-
|
|
65
|
-
# Or from source (no clone needed)
|
|
66
|
-
uvx --from git+https://github.com/thieuluan1618/ytm.git ytm-cli "song name"
|
|
67
64
|
```
|
|
68
65
|
|
|
69
66
|
<details>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
LICENSE
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
|
+
tests/test_cache.py
|
|
4
5
|
tests/test_config.py
|
|
5
6
|
tests/test_dislikes.py
|
|
6
7
|
tests/test_ffmpeg_player.py
|
|
@@ -10,6 +11,7 @@ tests/test_lyrics_service.py
|
|
|
10
11
|
tests/test_main.py
|
|
11
12
|
tests/test_playlists.py
|
|
12
13
|
tests/test_runner.py
|
|
14
|
+
tests/test_spectrum.py
|
|
13
15
|
tests/test_ui.py
|
|
14
16
|
tests/test_utils.py
|
|
15
17
|
ytm_cli/__init__.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|