tsetse 0.2.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.
tsetse-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Geet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tsetse-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: tsetse
3
+ Version: 0.2.0
4
+ Summary: Terminal YouTube music player with vim-like bindings
5
+ Author: Geet
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/GeetRakala/tsetse
8
+ Project-URL: Repository, https://github.com/GeetRakala/tsetse
9
+ Project-URL: Issues, https://github.com/GeetRakala/tsetse/issues
10
+ Keywords: terminal,music,youtube,mpv,player
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Players
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: yt-dlp
24
+ Dynamic: license-file
25
+
26
+ # tsetse
27
+
28
+ `tsetse` is a terminal YouTube music player. It searches YouTube in a low-latency mode, plays audio-only through `mpv`, stops cleanly on `Ctrl+C`, and keeps autoplaying similar songs from the current track's YouTube mix.
29
+
30
+ ## Caveat
31
+ The entire thing is vibe-coded. Use at your own risk.
32
+
33
+ ## Dependencies
34
+
35
+ - `python3` 3.11+
36
+ - `mpv`
37
+
38
+ Optional:
39
+
40
+ - `ffmpeg` is not required for normal playback, but it is useful for local media debugging.
41
+ - A supported JavaScript runtime such as `node` may help `yt-dlp` on some YouTube videos.
42
+
43
+ ## Install
44
+
45
+ From PyPI:
46
+
47
+ ```bash
48
+ pip install tsetse
49
+ ```
50
+
51
+ `pip` installs the Python `yt-dlp` dependency automatically. `mpv` is still a separate system binary, so `pip` cannot install it for you.
52
+
53
+ - `mpv`
54
+
55
+ Install `mpv` with your OS package manager:
56
+
57
+ ```bash
58
+ # macOS
59
+ brew install mpv
60
+
61
+ # Debian / Ubuntu
62
+ sudo apt install mpv
63
+
64
+ # Fedora
65
+ sudo dnf install mpv
66
+
67
+ # Arch
68
+ sudo pacman -S mpv
69
+
70
+ # Windows
71
+ winget search mpv
72
+ winget install <mpv-package-id>
73
+ ```
74
+
75
+ More options: <https://mpv.io/installation>
76
+
77
+ From this repo:
78
+
79
+ ```bash
80
+ python3 -m venv .venv
81
+ source .venv/bin/activate
82
+ pip install -e .
83
+ ```
84
+
85
+ If you do not want an editable install, you can still run the app directly with `python3 -m tsetse`.
86
+ For direct repo runs, `tsetse` can use either the Python `yt-dlp` package or an installed `yt-dlp` binary.
87
+
88
+ ## Run
89
+
90
+ Start the UI with no query:
91
+
92
+ ```bash
93
+ tsetse
94
+ ```
95
+
96
+ Start with an initial search:
97
+
98
+ ```bash
99
+ tsetse "daft punk"
100
+ ```
101
+
102
+ Without installing:
103
+
104
+ ```bash
105
+ python3 -m tsetse "khruangbin"
106
+ ```
107
+
108
+ ## Keybindings
109
+
110
+ - `/` enter search mode with a fresh empty query
111
+ - typing in search mode starts a debounced live search automatically
112
+ - `Enter` leave search mode and keep the current results
113
+ - `Esc` leave search mode
114
+ - `j` / `k` move through the current list
115
+ - `Enter` on a search result starts playback
116
+ - after playback starts, the main list becomes `Up Next`, and `Enter` on a queued item jumps to it
117
+ - `space` or `p` pause or resume
118
+ - `h` / `l` seek backward or forward 10 seconds
119
+ - `H` / `L` lower or raise volume by 5%
120
+ - `n` play the next queued or similar track
121
+ - `b` go to the previous track, or restart the current track if more than 5 seconds in
122
+ - `r` cycle loop mode: `off` -> `all` -> `one`
123
+ - `g` / `G` jump to the top or bottom of the results list
124
+ - `q` quit
125
+ - `Ctrl+C` stop playback and exit cleanly
126
+
127
+ ## How autoplay works
128
+
129
+ When a track starts, `tsetse` immediately seeds `Up Next` from the current search results so the queue is usable right away. In parallel, it asks YouTube for the current video's mix playlist (`list=RD<video_id>`). Those related tracks are added to the in-memory queue, shown in the `Up Next` list, and the next few are prefetched for faster transitions.
130
+
131
+ ## Notes
132
+
133
+ - The only non-Python runtime dependency is `mpv`. `yt-dlp` is installed automatically as a Python package dependency.
134
+ - If the Python `yt-dlp` module is missing, `tsetse` falls back to the `yt-dlp` CLI when it is installed on your system.
135
+ - `mpv` is kept alive as one long-running process and controlled over its IPC socket for better responsiveness.
136
+ - Queue progression and autoplay are driven by the app over `mpv` IPC, and the resolved prefix of the queue is mirrored into `mpv` so its next/previous controls stay usable.
137
+ - Search uses a fast YouTube page parser first, with the bundled `yt-dlp` Python package as fallback when needed.
138
+ - Direct audio stream URLs are cached briefly in memory because YouTube stream URLs expire.
139
+ - The first few search results and queued tracks are prefetched in the background so selecting them is more likely to start from a cached direct audio URL.
140
+ - If `yt-dlp` starts failing on some videos due to YouTube extractor changes, update it first.
141
+
142
+ ## Commands
143
+
144
+ Run tests:
145
+
146
+ ```bash
147
+ python3 -m unittest discover -s tests -v
148
+ ```
149
+
150
+ Format or linting commands are not included because this project is stdlib-only and does not ship a formatter config yet.
tsetse-0.2.0/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # tsetse
2
+
3
+ `tsetse` is a terminal YouTube music player. It searches YouTube in a low-latency mode, plays audio-only through `mpv`, stops cleanly on `Ctrl+C`, and keeps autoplaying similar songs from the current track's YouTube mix.
4
+
5
+ ## Caveat
6
+ The entire thing is vibe-coded. Use at your own risk.
7
+
8
+ ## Dependencies
9
+
10
+ - `python3` 3.11+
11
+ - `mpv`
12
+
13
+ Optional:
14
+
15
+ - `ffmpeg` is not required for normal playback, but it is useful for local media debugging.
16
+ - A supported JavaScript runtime such as `node` may help `yt-dlp` on some YouTube videos.
17
+
18
+ ## Install
19
+
20
+ From PyPI:
21
+
22
+ ```bash
23
+ pip install tsetse
24
+ ```
25
+
26
+ `pip` installs the Python `yt-dlp` dependency automatically. `mpv` is still a separate system binary, so `pip` cannot install it for you.
27
+
28
+ - `mpv`
29
+
30
+ Install `mpv` with your OS package manager:
31
+
32
+ ```bash
33
+ # macOS
34
+ brew install mpv
35
+
36
+ # Debian / Ubuntu
37
+ sudo apt install mpv
38
+
39
+ # Fedora
40
+ sudo dnf install mpv
41
+
42
+ # Arch
43
+ sudo pacman -S mpv
44
+
45
+ # Windows
46
+ winget search mpv
47
+ winget install <mpv-package-id>
48
+ ```
49
+
50
+ More options: <https://mpv.io/installation>
51
+
52
+ From this repo:
53
+
54
+ ```bash
55
+ python3 -m venv .venv
56
+ source .venv/bin/activate
57
+ pip install -e .
58
+ ```
59
+
60
+ If you do not want an editable install, you can still run the app directly with `python3 -m tsetse`.
61
+ For direct repo runs, `tsetse` can use either the Python `yt-dlp` package or an installed `yt-dlp` binary.
62
+
63
+ ## Run
64
+
65
+ Start the UI with no query:
66
+
67
+ ```bash
68
+ tsetse
69
+ ```
70
+
71
+ Start with an initial search:
72
+
73
+ ```bash
74
+ tsetse "daft punk"
75
+ ```
76
+
77
+ Without installing:
78
+
79
+ ```bash
80
+ python3 -m tsetse "khruangbin"
81
+ ```
82
+
83
+ ## Keybindings
84
+
85
+ - `/` enter search mode with a fresh empty query
86
+ - typing in search mode starts a debounced live search automatically
87
+ - `Enter` leave search mode and keep the current results
88
+ - `Esc` leave search mode
89
+ - `j` / `k` move through the current list
90
+ - `Enter` on a search result starts playback
91
+ - after playback starts, the main list becomes `Up Next`, and `Enter` on a queued item jumps to it
92
+ - `space` or `p` pause or resume
93
+ - `h` / `l` seek backward or forward 10 seconds
94
+ - `H` / `L` lower or raise volume by 5%
95
+ - `n` play the next queued or similar track
96
+ - `b` go to the previous track, or restart the current track if more than 5 seconds in
97
+ - `r` cycle loop mode: `off` -> `all` -> `one`
98
+ - `g` / `G` jump to the top or bottom of the results list
99
+ - `q` quit
100
+ - `Ctrl+C` stop playback and exit cleanly
101
+
102
+ ## How autoplay works
103
+
104
+ When a track starts, `tsetse` immediately seeds `Up Next` from the current search results so the queue is usable right away. In parallel, it asks YouTube for the current video's mix playlist (`list=RD<video_id>`). Those related tracks are added to the in-memory queue, shown in the `Up Next` list, and the next few are prefetched for faster transitions.
105
+
106
+ ## Notes
107
+
108
+ - The only non-Python runtime dependency is `mpv`. `yt-dlp` is installed automatically as a Python package dependency.
109
+ - If the Python `yt-dlp` module is missing, `tsetse` falls back to the `yt-dlp` CLI when it is installed on your system.
110
+ - `mpv` is kept alive as one long-running process and controlled over its IPC socket for better responsiveness.
111
+ - Queue progression and autoplay are driven by the app over `mpv` IPC, and the resolved prefix of the queue is mirrored into `mpv` so its next/previous controls stay usable.
112
+ - Search uses a fast YouTube page parser first, with the bundled `yt-dlp` Python package as fallback when needed.
113
+ - Direct audio stream URLs are cached briefly in memory because YouTube stream URLs expire.
114
+ - The first few search results and queued tracks are prefetched in the background so selecting them is more likely to start from a cached direct audio URL.
115
+ - If `yt-dlp` starts failing on some videos due to YouTube extractor changes, update it first.
116
+
117
+ ## Commands
118
+
119
+ Run tests:
120
+
121
+ ```bash
122
+ python3 -m unittest discover -s tests -v
123
+ ```
124
+
125
+ Format or linting commands are not included because this project is stdlib-only and does not ship a formatter config yet.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tsetse"
7
+ dynamic = ["version"]
8
+ description = "Terminal YouTube music player with vim-like bindings"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ dependencies = ["yt-dlp"]
14
+ authors = [{ name = "Geet" }]
15
+ keywords = ["terminal", "music", "youtube", "mpv", "player"]
16
+ classifiers = [
17
+ "Environment :: Console",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Multimedia :: Sound/Audio :: Players",
25
+ "Topic :: Utilities",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/GeetRakala/tsetse"
30
+ Repository = "https://github.com/GeetRakala/tsetse"
31
+ Issues = "https://github.com/GeetRakala/tsetse/issues"
32
+
33
+ [project.scripts]
34
+ tsetse = "tsetse.__main__:main"
35
+
36
+ [tool.setuptools]
37
+ packages = ["tsetse"]
38
+
39
+ [tool.setuptools.dynamic]
40
+ version = { attr = "tsetse.__version__" }
tsetse-0.2.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from tsetse.app import TsetseApp
6
+ from tsetse.models import StreamCacheEntry, Track
7
+
8
+
9
+ class TsetseAppTests(unittest.TestCase):
10
+ def test_starts_out_of_search_mode_without_initial_query(self) -> None:
11
+ app = TsetseApp()
12
+ self.assertFalse(app.search_mode)
13
+
14
+ def test_starts_out_of_search_mode_with_initial_query(self) -> None:
15
+ app = TsetseApp(initial_query="daft punk")
16
+ self.assertFalse(app.search_mode)
17
+
18
+ def test_slash_starts_fresh_search(self) -> None:
19
+ app = TsetseApp()
20
+ app.search_query = "old query"
21
+
22
+ app._handle_key(ord("/"))
23
+
24
+ self.assertTrue(app.search_mode)
25
+ self.assertEqual(app.search_query, "")
26
+
27
+ def test_typing_in_search_mode_schedules_live_search(self) -> None:
28
+ app = TsetseApp()
29
+ app.search_mode = True
30
+
31
+ app._handle_search_key(ord("d"))
32
+
33
+ self.assertEqual(app.search_query, "d")
34
+ self.assertIsNotNone(app.search_debounce_deadline)
35
+
36
+ def test_live_search_starts_when_debounce_expires(self) -> None:
37
+ app = TsetseApp()
38
+ started: list[str] = []
39
+ app.search_mode = True
40
+ app.search_query = "daft punk"
41
+ app.search_debounce_deadline = 0.0
42
+ app._start_search = lambda query: started.append(query) # type: ignore[method-assign]
43
+
44
+ app._maybe_start_live_search()
45
+
46
+ self.assertEqual(started, ["daft punk"])
47
+
48
+ def test_enter_skips_duplicate_search_when_results_are_current(self) -> None:
49
+ app = TsetseApp()
50
+ started: list[str] = []
51
+ app.search_mode = True
52
+ app.search_query = "daft punk"
53
+ app.last_completed_search_query = "daft punk"
54
+ app._start_search = lambda query: started.append(query) # type: ignore[method-assign]
55
+
56
+ app._handle_search_key(10)
57
+
58
+ self.assertFalse(app.search_mode)
59
+ self.assertEqual(started, [])
60
+
61
+ def test_uppercase_h_lowers_volume(self) -> None:
62
+ app = TsetseApp()
63
+ changes: list[int] = []
64
+
65
+ app.player.change_volume = lambda delta: changes.append(delta) # type: ignore[method-assign]
66
+
67
+ app._handle_key(ord("H"))
68
+
69
+ self.assertEqual(changes, [-5])
70
+ self.assertEqual(app.volume, 95.0)
71
+ self.assertEqual(app.status_message, "Volume: 95%")
72
+
73
+ def test_uppercase_l_raises_volume(self) -> None:
74
+ app = TsetseApp()
75
+ changes: list[int] = []
76
+
77
+ app.player.change_volume = lambda delta: changes.append(delta) # type: ignore[method-assign]
78
+
79
+ app._handle_key(ord("L"))
80
+
81
+ self.assertEqual(changes, [5])
82
+ self.assertEqual(app.volume, 105.0)
83
+ self.assertEqual(app.status_message, "Volume: 105%")
84
+
85
+ def test_load_track_starts_pending_playback_before_stream_resolve(self) -> None:
86
+ app = TsetseApp()
87
+ track = Track(video_id="abc123", title="Song")
88
+ resolved: list[str] = []
89
+
90
+ app._start_stream_resolve = lambda current: resolved.append(current.video_id) # type: ignore[method-assign]
91
+
92
+ app._load_track(track)
93
+
94
+ self.assertEqual(resolved, ["abc123"])
95
+ self.assertEqual(app.pending_play_video_id, "abc123")
96
+ self.assertEqual(app.status_message, "Loading: Song")
97
+
98
+ def test_pending_playback_uses_cached_stream_when_ready(self) -> None:
99
+ app = TsetseApp()
100
+ track = Track(video_id="abc123", title="Song")
101
+ loads: list[tuple[str, str | None]] = []
102
+ synced: list[bool] = []
103
+
104
+ app.current_track = track
105
+ app.pending_play_video_id = track.video_id
106
+ app.stream_cache[track.video_id] = StreamCacheEntry(url="https://example.com/audio")
107
+ app.player.load = lambda url, media_title=None: loads.append((url, media_title)) # type: ignore[method-assign]
108
+ app._sync_mpv_playlist = lambda: synced.append(True) # type: ignore[method-assign]
109
+
110
+ app._maybe_start_pending_playback()
111
+
112
+ self.assertEqual(loads, [("https://example.com/audio", "Song")])
113
+ self.assertEqual(synced, [True])
114
+ self.assertIsNone(app.pending_play_video_id)
115
+ self.assertEqual(app.status_message, "Playing: Song")
116
+
117
+ def test_pending_playback_waits_for_resolved_stream_url(self) -> None:
118
+ app = TsetseApp()
119
+ track = Track(video_id="abc123", title="Song")
120
+ loads: list[tuple[str, str | None]] = []
121
+
122
+ app.current_track = track
123
+ app.pending_play_video_id = track.video_id
124
+ app.status_message = "Loading: Song"
125
+ app.player.load = lambda url, media_title=None: loads.append((url, media_title)) # type: ignore[method-assign]
126
+
127
+ app._maybe_start_pending_playback()
128
+
129
+ self.assertEqual(loads, [])
130
+ self.assertEqual(app.pending_play_video_id, track.video_id)
131
+ self.assertEqual(app.status_message, "Loading: Song")
132
+
133
+ def test_play_selected_seeds_queue_from_search_results(self) -> None:
134
+ app = TsetseApp()
135
+ first = Track(video_id="one", title="One")
136
+ second = Track(video_id="two", title="Two")
137
+ third = Track(video_id="three", title="Three")
138
+
139
+ app.list_mode = "search"
140
+ app.results = [first, second, third]
141
+ app.selected_index = 1
142
+ app._load_track = lambda track: None # type: ignore[method-assign]
143
+ app._start_related_prefetch = lambda track: None # type: ignore[method-assign]
144
+ app._fill_queue_from_related = lambda track: None # type: ignore[method-assign]
145
+ app._warm_queue_streams = lambda: None # type: ignore[method-assign]
146
+
147
+ app._play_selected()
148
+
149
+ self.assertEqual(app.current_track.video_id, "two")
150
+ self.assertEqual(list(app.up_next), [first, third])
151
+ self.assertEqual(app.results, [first, third])
152
+
153
+ def test_related_error_does_not_override_current_playback_status(self) -> None:
154
+ app = TsetseApp()
155
+ track = Track(video_id="abc123", title="Song")
156
+
157
+ app.current_track = track
158
+ app.status_message = "Playing: Song"
159
+ app.pending_related.add(track.video_id)
160
+
161
+ app._process_event(
162
+ {
163
+ "type": "related-error",
164
+ "video_id": track.video_id,
165
+ "message": "[ERROR] video not found",
166
+ }
167
+ )
168
+
169
+ self.assertEqual(app.status_message, "Playing: Song")
170
+ self.assertNotIn(track.video_id, app.pending_related)
171
+
172
+ def test_stream_error_for_current_track_clears_pending_playback_and_surfaces_message(self) -> None:
173
+ app = TsetseApp()
174
+ track = Track(video_id="abc123", title="Song")
175
+ loads: list[tuple[str, str | None]] = []
176
+
177
+ app.current_track = track
178
+ app.pending_play_video_id = track.video_id
179
+ app.player.load = lambda url, media_title=None: loads.append((url, media_title)) # type: ignore[method-assign]
180
+
181
+ app._process_event(
182
+ {
183
+ "type": "stream-error",
184
+ "video_id": track.video_id,
185
+ "message": "Video is unavailable on YouTube.",
186
+ }
187
+ )
188
+
189
+ self.assertEqual(loads, [])
190
+ self.assertIsNone(app.pending_play_video_id)
191
+ self.assertEqual(app.status_message, "Video is unavailable on YouTube.")
192
+
193
+ def test_stream_error_removes_unplayable_track_from_queue(self) -> None:
194
+ app = TsetseApp()
195
+ current = Track(video_id="now", title="Now")
196
+ broken = Track(video_id="bad", title="Broken")
197
+ healthy = Track(video_id="good", title="Healthy")
198
+
199
+ app.current_track = current
200
+ app.list_mode = "queue"
201
+ app.up_next.extend([broken, healthy])
202
+ app.results = [broken, healthy]
203
+
204
+ app._process_event(
205
+ {
206
+ "type": "stream-error",
207
+ "video_id": broken.video_id,
208
+ "message": "Video is unavailable on YouTube.",
209
+ }
210
+ )
211
+
212
+ self.assertEqual([track.video_id for track in app.up_next], ["good"])
213
+ self.assertEqual([track.video_id for track in app.results], ["good"])
214
+
215
+ def test_core_idle_change_resumes_autoplay_when_waiting(self) -> None:
216
+ app = TsetseApp()
217
+ current = Track(video_id="now", title="Now")
218
+ healthy = Track(video_id="good", title="Healthy")
219
+ resumed: list[bool] = []
220
+
221
+ app.current_track = current
222
+ app.awaiting_autoplay = True
223
+ app.player_idle = False
224
+ app.up_next.append(healthy)
225
+ app._play_next = lambda *, auto: resumed.append(auto) # type: ignore[method-assign]
226
+
227
+ app._handle_player_payload({"event": "property-change", "name": "core-idle", "data": True})
228
+
229
+ self.assertEqual(resumed, [True])
230
+
231
+ def test_volume_property_change_updates_state(self) -> None:
232
+ app = TsetseApp()
233
+
234
+ app._handle_player_payload({"event": "property-change", "name": "volume", "data": 72.0})
235
+
236
+ self.assertEqual(app.volume, 72.0)
237
+
238
+ def test_sync_mpv_playlist_uses_resolved_queue_prefix(self) -> None:
239
+ app = TsetseApp()
240
+ history = Track(video_id="hist", title="History")
241
+ current = Track(video_id="now", title="Now")
242
+ first = Track(video_id="next1", title="Next 1")
243
+ second = Track(video_id="next2", title="Next 2")
244
+ synced: list[tuple[list[tuple[Track, str]], list[tuple[Track, str]]]] = []
245
+ titles: list[str] = []
246
+
247
+ app.history = [history]
248
+ app.current_track = current
249
+ app.up_next.extend([first, second])
250
+ app.stream_cache = {
251
+ history.video_id: StreamCacheEntry(url="https://example.com/history"),
252
+ current.video_id: StreamCacheEntry(url="https://example.com/current"),
253
+ first.video_id: StreamCacheEntry(url="https://example.com/next1"),
254
+ }
255
+ app.player.sync_playlist = lambda history_entries, up_next_entries: synced.append( # type: ignore[method-assign]
256
+ (history_entries, up_next_entries)
257
+ )
258
+ app.player.set_media_title = lambda title: titles.append(title) # type: ignore[method-assign]
259
+
260
+ app._sync_mpv_playlist()
261
+
262
+ self.assertEqual(app.playlist_tracks, [history, current, first])
263
+ self.assertEqual(
264
+ synced,
265
+ [
266
+ (
267
+ [(history, "https://example.com/history")],
268
+ [(first, "https://example.com/next1")],
269
+ )
270
+ ],
271
+ )
272
+ self.assertEqual(titles, ["Now"])
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from tsetse.models import LoopMode, StreamCacheEntry, Track, format_duration
6
+
7
+
8
+ class FormatDurationTests(unittest.TestCase):
9
+ def test_formats_short_values(self) -> None:
10
+ self.assertEqual(format_duration(65), "1:05")
11
+
12
+ def test_formats_hour_values(self) -> None:
13
+ self.assertEqual(format_duration(3661), "1:01:01")
14
+
15
+ def test_formats_missing_values(self) -> None:
16
+ self.assertEqual(format_duration(None), "--:--")
17
+
18
+
19
+ class LoopModeTests(unittest.TestCase):
20
+ def test_cycle_order(self) -> None:
21
+ self.assertEqual(LoopMode.OFF.cycle(), LoopMode.ALL)
22
+ self.assertEqual(LoopMode.ALL.cycle(), LoopMode.ONE)
23
+ self.assertEqual(LoopMode.ONE.cycle(), LoopMode.OFF)
24
+
25
+
26
+ class TrackTests(unittest.TestCase):
27
+ def test_default_watch_url_is_derived(self) -> None:
28
+ track = Track(video_id="abc123", title="Song")
29
+ self.assertEqual(track.watch_url, "https://www.youtube.com/watch?v=abc123")
30
+
31
+ def test_stream_cache_entry_is_fresh_initially(self) -> None:
32
+ entry = StreamCacheEntry(url="https://example.com/audio")
33
+ self.assertTrue(entry.is_fresh())