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.
Files changed (41) hide show
  1. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/PKG-INFO +1 -4
  2. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/README.md +0 -3
  3. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/pyproject.toml +1 -1
  4. ytm_cli-0.7.1/tests/test_cache.py +111 -0
  5. ytm_cli-0.7.1/tests/test_spectrum.py +167 -0
  6. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/__init__.py +1 -1
  7. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/main.py +9 -8
  8. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli.egg-info/PKG-INFO +1 -4
  9. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli.egg-info/SOURCES.txt +2 -0
  10. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/LICENSE +0 -0
  11. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/setup.cfg +0 -0
  12. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_config.py +0 -0
  13. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_dislikes.py +0 -0
  14. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_ffmpeg_player.py +0 -0
  15. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_hybrid_player.py +0 -0
  16. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_integration.py +0 -0
  17. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_lyrics_service.py +0 -0
  18. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_main.py +0 -0
  19. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_playlists.py +0 -0
  20. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_runner.py +0 -0
  21. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_ui.py +0 -0
  22. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/tests/test_utils.py +0 -0
  23. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/__main__.py +0 -0
  24. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/auth.py +0 -0
  25. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/cache.py +0 -0
  26. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/config.py +0 -0
  27. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/dislikes.py +0 -0
  28. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/ffmpeg_player.py +0 -0
  29. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/hybrid_player.py +0 -0
  30. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/llm_client.py +0 -0
  31. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/lyrics_service.py +0 -0
  32. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/player.py +0 -0
  33. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/playlists.py +0 -0
  34. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/spectrum.py +0 -0
  35. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/ui.py +0 -0
  36. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/utils.py +0 -0
  37. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli/verbose_logger.py +0 -0
  38. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli.egg-info/dependency_links.txt +0 -0
  39. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli.egg-info/entry_points.txt +0 -0
  40. {ytm_cli-0.7.0 → ytm_cli-0.7.1}/ytm_cli.egg-info/requires.txt +0 -0
  41. {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.0
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>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ytm-cli"
7
- version = "0.7.0"
7
+ version = "0.7.1"
8
8
  description = "YouTube Music CLI tool with interactive terminal interface"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
@@ -1,3 +1,3 @@
1
1
  """YouTube Music CLI - A terminal interface for YouTube Music"""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.7.1"
@@ -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
- "--verbose",
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.0
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