tuiman 1.0.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.
tuiman-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.3
2
+ Name: tuiman
3
+ Version: 1.0.0
4
+ Summary: A TUI music player for your favourite local mp3 albums!
5
+ License: MIT
6
+ Requires-Dist: aiofiles>=25.1.0
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: mutagen>=1.47.0
9
+ Requires-Dist: platformdirs>=4.9.6
10
+ Requires-Dist: pygame-ce>=2.5.7
11
+ Requires-Dist: rich-pixels>=3.0.1
12
+ Requires-Dist: textual>=8.2.4
13
+ Requires-Dist: textual-autocomplete>=4.0.6
14
+ Requires-Dist: textual-dev>=1.8.0
15
+ Requires-Python: >=3.14
16
+ Description-Content-Type: text/markdown
17
+
tuiman-1.0.0/README.md ADDED
File without changes
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["uv_build"]
3
+ backend-path = []
4
+ build-backend = "uv_build"
5
+
6
+ [project]
7
+ name = "tuiman"
8
+ version = "1.0.0"
9
+ description = "A TUI music player for your favourite local mp3 albums!"
10
+ readme = "README.md"
11
+ license = { text = "MIT" }
12
+ requires-python = ">=3.14"
13
+ dependencies = [
14
+ "aiofiles>=25.1.0",
15
+ "httpx>=0.28.1",
16
+ "mutagen>=1.47.0",
17
+ "platformdirs>=4.9.6",
18
+ "pygame-ce>=2.5.7",
19
+ "rich-pixels>=3.0.1",
20
+ "textual>=8.2.4",
21
+ "textual-autocomplete>=4.0.6",
22
+ "textual-dev>=1.8.0",
23
+ ]
24
+
25
+ [project.scripts]
26
+ my-cli = "tuiman.app:main"
File without changes
@@ -0,0 +1,145 @@
1
+ import random
2
+ from pathlib import Path
3
+ from textual.app import App, ComposeResult
4
+ from textual.color import Color
5
+ from textual.containers import Horizontal, Vertical
6
+ from textual.css.scalar import Scalar, Unit
7
+ from textual.events import Click
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Footer, Input, Label, Button, OptionList
10
+ from textual_autocomplete import PathAutoComplete
11
+ from modules.bottom_box import BottomBox, PlayControls, QueueOptions
12
+ from modules.top_box import TopBox, AlbumList
13
+ from utils.models import ReversibleIterator
14
+ from utils.player import init_player, pause, resume
15
+ from utils.caching import Cache
16
+
17
+
18
+ init_player()
19
+ path_cacher = Cache()
20
+
21
+ class DirectoryDialog(ModalScreen[str]):
22
+ def compose(self) -> ComposeResult:
23
+ with Vertical(id="dialog-container"):
24
+ yield Label(" Enter albums folder path:")
25
+ input_widget = Input(placeholder="/path/to/albums", id="modal_input")
26
+ yield input_widget
27
+ yield PathAutoComplete(target=input_widget, path="../..")
28
+ with Horizontal(id="dialog-buttons"):
29
+ yield Button("Load", variant="primary", id="dia-sub")
30
+ yield Button("Load previous path", variant="primary", id="dia-prev")
31
+
32
+ def on_button_pressed(self, event: Button.Pressed) -> None:
33
+ if event.button.id == "dia-sub":
34
+ raw = self.query_one(Input).value.strip().strip("'\"")
35
+ if raw:
36
+ path = Path(raw).expanduser().resolve()
37
+
38
+ if path.is_dir():
39
+ path_cacher.create_path_cache(path = str(path))
40
+ self.dismiss(str(path))
41
+ else:
42
+ self.query_one(Label).update(" ❌ Invalid path, try again:")
43
+ # load previous path
44
+ elif event.button.id == "dia-prev":
45
+ path = path_cacher.find_path_cache()
46
+ if path:
47
+ self.dismiss(str(path))
48
+
49
+
50
+ class Tuiman(App):
51
+ """main App class"""
52
+ def __init__(self):
53
+ super().__init__()
54
+ self.library_path: str = ""
55
+
56
+ CSS_PATH = "music.tcss"
57
+ BINDINGS = [
58
+ ("n", "backward", "Backward"),
59
+ ("space", "pause", "Pause"),
60
+ ("m", "forward", "Forward"),
61
+ ("q", "show_queue", "Show Queue"),
62
+ ("alt+q", "shuffle_queue", "Shuffle Queue"),
63
+ ]
64
+ AUTO_FOCUS: str | None = "#modal_input"
65
+
66
+ def compose(self) -> ComposeResult:
67
+ # yield Header()
68
+ yield Footer()
69
+ # topbox
70
+ yield BottomBox()
71
+
72
+ def action_pause(self) -> None:
73
+ self.query_one(PlayControls).query("Button")[1].press()
74
+ def action_forward(self) -> None:
75
+ self.query_one(PlayControls).query("Button")[2].press()
76
+ def action_backward(self) -> None:
77
+ self.query_one(PlayControls).query("Button")[0].press()
78
+ def action_show_queue(self) -> None:
79
+ self.query_one(QueueOptions).query_one("#show-queue").press()
80
+ def action_shuffle_queue(self) -> None:
81
+ self.query_one(QueueOptions).query_one("#shuffle-queue").press()
82
+
83
+ def on_mount(self) -> None:
84
+ self.push_screen(DirectoryDialog(), self.on_directory_chosen)
85
+ # pass
86
+
87
+ def on_directory_chosen(self, path: str) -> None:
88
+ self.library_path = path
89
+ self.mount(TopBox(self.library_path), before=self.query_one(BottomBox))
90
+ # set library here
91
+
92
+ def on_button_pressed(self, event: Button.Pressed)->None:
93
+ """handle button presses"""
94
+
95
+ # show Queue if button pressed
96
+ if event.button.id == "show-queue":
97
+ album_obj = self.query_one(AlbumList)
98
+ # logger(f"{album_obj.styles.width.value}, {album_obj.styles.width.unit}, {album_obj.styles.width.percent_unit}")
99
+ if album_obj.styles.width == Scalar(value=100.0, unit=Unit.WIDTH ,percent_unit=Unit.WIDTH):
100
+ album_obj.styles.width = Scalar(value=40.0, unit=Unit.WIDTH ,percent_unit=Unit.WIDTH)
101
+ else:
102
+ album_obj.styles.width = Scalar(value=100.0, unit=Unit.WIDTH ,percent_unit=Unit.WIDTH)
103
+
104
+ # play/pause, forward backward buttons
105
+ if event.button.id == "pause":
106
+ if event.button.label == "||":
107
+ event.button.label = "▶"
108
+ event.button.styles.border = ("round", "yellow")
109
+ event.button.styles.color = "deeppink"
110
+ pause()
111
+ else:
112
+ event.button.label = "||"
113
+ event.button.styles.border = ("round", "deeppink")
114
+ event.button.styles.color = Color(255, 255, 255, 0.7)
115
+ resume()
116
+
117
+ if "playback" in event.button.classes:
118
+ tb = self.query_one(TopBox)
119
+ # forward backward logic
120
+ queue = getattr(tb, "queue_iterator", None)
121
+
122
+ if queue is not None:
123
+ if event.button.id == "backward":
124
+ queue.pos = (queue.pos - 2) % len(queue.lst)
125
+ tb.song_manager(song_name=next(queue))
126
+
127
+ if "queue-btn" in event.button.classes:
128
+ tb = self.query_one(TopBox)
129
+ # shuffle logic, shuffles the queue, updates queue iterator and plays song using it.
130
+ if tb.song_queue:
131
+ if event.button.id == "shuffle-queue":
132
+ random.shuffle(tb.song_queue)
133
+ tb.queue_iterator = ReversibleIterator(lst=tb.song_queue)
134
+ tb.song_manager(song_name=next(tb.queue_iterator))
135
+
136
+ def on_click(self, event: Click) -> None:
137
+ if not isinstance(event.widget, (Input, OptionList)):
138
+ self.screen.set_focus(None)
139
+ for option_list in self.query(OptionList):
140
+ option_list.highlighted = None
141
+
142
+
143
+ def main():
144
+ app = Tuiman()
145
+ app.run()
File without changes
@@ -0,0 +1,68 @@
1
+ from textual.app import ComposeResult
2
+ from textual.color import Gradient
3
+ from textual.reactive import reactive
4
+ from textual.widget import Widget
5
+ from textual.widgets import Button, Label, ProgressBar
6
+ from src.tuiman.utils.player import get_progress, get_current
7
+
8
+
9
+ class PlayControls(Widget):
10
+ """Pause/resume, forward/backward controls"""
11
+ def compose(self) -> ComposeResult:
12
+ yield Button("⏮", id="backward", classes="playback", variant="primary", flat=True)
13
+ yield Button("||", id="pause",variant="success", flat=True)
14
+ yield Button("⏭", id="forward", classes="playback",variant="primary", flat=True)
15
+
16
+
17
+ class Playback(Widget):
18
+ """Displays playing bar. Everything from updating the Playing: to the progressbar seems to be autonomous (depends
19
+ on output give by player.py)"""
20
+ current_song = reactive("-----")
21
+
22
+ def compose(self) -> ComposeResult:
23
+ gradient = Gradient.from_colors("#881177","#aa3355","#cc6666","#ee9944","#eedd00","#99dd55","#44dd88","#22ccbb","#00bbcc","#0099cc","#3366bb","#663399",)
24
+ yield Label("Playing: -----", id="song-name")
25
+ yield ProgressBar(total=100, show_eta=False, gradient=gradient)
26
+
27
+ def on_mount(self) -> None:
28
+ """Progress bar"""
29
+ self.set_interval(1 /30, self.make_progress)
30
+ self.set_interval(1/30 , self.sync_song)
31
+
32
+ def make_progress(self) -> None:
33
+ """Called automatically to advance the progress bar."""
34
+ progress = 0
35
+ time_stamps = get_progress()
36
+ if not time_stamps[2]:
37
+ try:
38
+ progress = int((time_stamps[0]/time_stamps[1]) * 100)
39
+ except ZeroDivisionError:
40
+ progress = 0
41
+ self.query_one(ProgressBar).update(progress=progress)
42
+
43
+ def sync_song(self)->None:
44
+ c_song = get_current().get("song")
45
+ if c_song != self.current_song:
46
+ self.current_song = c_song
47
+
48
+ def watch_current_song(self, song):
49
+ self.query_one("#song-name", Label).update(f"Playing: {song}")
50
+
51
+ class QueueOptions(Widget):
52
+ """show/ hide queue, shuffle options"""
53
+
54
+ def compose(self) -> ComposeResult:
55
+ yield Button("Queue", variant="primary", flat=True, classes="queue-btn", id="show-queue")
56
+ yield Button("Shuffle", variant="primary", flat=True, classes="queue-btn" ,id="shuffle-queue")
57
+
58
+
59
+ def on_mount(self) -> None:
60
+ self.query_one("#show-queue").border_title = "Show"
61
+ self.query_one("#shuffle-queue").border_subtitle = "queue"
62
+
63
+ class BottomBox(Widget):
64
+ """Class containing play controls and playback bar"""
65
+ def compose(self) -> ComposeResult:
66
+ yield PlayControls()
67
+ yield Playback()
68
+ yield QueueOptions()
@@ -0,0 +1,306 @@
1
+ import asyncio
2
+ from rich_pixels import Pixels
3
+ from textual import work
4
+ from textual.app import ComposeResult, RenderResult
5
+ from textual.containers import VerticalScroll
6
+ from textual.reactive import reactive
7
+ from textual.widget import Widget
8
+ from textual.widgets import Input, OptionList, RadioSet, RadioButton, Markdown
9
+ from src.tuiman.utils.caching import Cache
10
+ from src.tuiman.utils.library_manager import search_function, load_library
11
+ from src.tuiman.utils.lyrics import extract_lyrics
12
+ from src.tuiman.utils.models import ReversibleIterator
13
+ from src.tuiman.utils.player import get_progress, play_song
14
+
15
+ cache = Cache()
16
+
17
+ class AlbumCover(Widget):
18
+ """using rich renderable to render ascii album cover"""
19
+ path: reactive[str] = reactive("./media/unknown.png")
20
+
21
+ def __init__(self):
22
+ super().__init__()
23
+ self._album_cover = None
24
+
25
+ def on_mount(self) -> None:
26
+ self._album_cover = Pixels.from_image_path("./media/unknown.png", resize=(20, 15))
27
+
28
+ def watch_path(self, new_path: str) -> None:
29
+ self._album_cover = Pixels.from_image_path(new_path or "./media/unknown.png", resize=(20, 15))
30
+ self.refresh()
31
+
32
+ def render(self) -> RenderResult:
33
+ return self._album_cover
34
+
35
+ class AlbumList(Widget):
36
+ """Lists user albums"""
37
+
38
+ data: reactive[dict] = reactive({}, init=False)
39
+ def __init__(self, data: dict) -> None:
40
+ super().__init__()
41
+ self.albums = [*data.keys()]
42
+
43
+ def compose(self) -> ComposeResult:
44
+ yield Input(placeholder="Search album name", type="text")
45
+ yield OptionList(
46
+ *self.albums
47
+ )
48
+
49
+ def on_mount(self) -> None:
50
+ self.border_title = "Albums"
51
+
52
+ def on_input_changed(self, event: Input.Changed) -> None:
53
+ search_function(self, event, self.albums)
54
+
55
+ def watch_data(self, data: dict) -> None:
56
+ """"""
57
+ self.albums = [*data.keys()]
58
+ option_list = self.query_one(OptionList)
59
+ option_list.clear_options()
60
+ for album in self.albums:
61
+ option_list.add_option(album)
62
+
63
+
64
+ class SongList(Widget):
65
+ """Lists album songs"""
66
+
67
+ def __init__(self, song_data: dict) -> None:
68
+ super().__init__()
69
+ self.song_data = song_data
70
+ self.current_songs: list = [] # these are songs in currently selected album
71
+
72
+ def compose(self) -> ComposeResult:
73
+ yield Input(placeholder="Search song name", type="text")
74
+ yield OptionList(
75
+ )
76
+
77
+ def on_mount(self) -> None:
78
+ self.border_title = "Songs"
79
+
80
+ def on_input_changed(self, event: Input.Changed) -> None:
81
+ all_songs = self.current_songs
82
+ search_function(self, event, all_songs)
83
+
84
+ def update_song_list(self, album_name: str) -> None:
85
+ """
86
+ updates the SongList Ui when album is selected
87
+ :param album_name: passed by event handler
88
+ """
89
+ songs = self.song_data.get(album_name, {}).get("songs", [])
90
+ # clear and repopulate the OptionList
91
+ option_list = self.query_one(OptionList)
92
+ option_list.clear_options()
93
+ self.current_songs = []
94
+ for song in sorted(songs):
95
+ self.current_songs.append(song)
96
+ option_list.add_option(song)
97
+
98
+ class RightPane(Widget):
99
+ pass
100
+ class LyricBox(Widget):
101
+ """Display song lyrics"""
102
+
103
+ current_index: reactive[int] = reactive(-1)
104
+ current_song_path: reactive[str] = reactive("")
105
+ def __init__(self):
106
+ super().__init__()
107
+ self.parsed_lyrics = []
108
+
109
+ def watch_current_song_path(self, path: str) -> None:
110
+ self.current_index = -1
111
+ self.parsed_lyrics = []
112
+ self.query_one(Markdown).update("")
113
+
114
+ if path:
115
+ self.load_lyrics(path)
116
+
117
+ @work(exclusive=True, group="lyrics", exit_on_error=False)
118
+ async def load_lyrics(self, path: str) -> None:
119
+ try:
120
+ cached_lyrics = await cache.find_cache(song_path=path)
121
+ if cached_lyrics is not None:
122
+ lyrics = cached_lyrics
123
+ else:
124
+ lyrics = (await extract_lyrics(path=path)).get("lyrics", [])
125
+
126
+ except Exception:
127
+ lyrics = []
128
+ if path != self.current_song_path:
129
+ return
130
+
131
+ self.parsed_lyrics = lyrics
132
+
133
+ def compose(self) -> ComposeResult:
134
+ yield Markdown("")
135
+
136
+ def on_mount(self) -> None:
137
+ self.border_title = "Lyrics"
138
+ # Poll every 1/30ms
139
+ self.set_interval(1/30, self.update_lyrics)
140
+
141
+ def update_lyrics(self) -> None:
142
+ if not self.parsed_lyrics:
143
+ return
144
+
145
+ progress = get_progress()
146
+
147
+ if progress[2]: # song ended
148
+ return
149
+
150
+ now = progress[0] # how much the song has finished
151
+
152
+ # Find the latest lyric whose time <= now
153
+ idx = -1
154
+ for i, (t, _) in enumerate(self.parsed_lyrics):
155
+ if t <= now:
156
+ idx = i
157
+ else:
158
+ break
159
+
160
+ if idx != self.current_index:
161
+ self.current_index = idx
162
+
163
+ def watch_current_index(self, idx: int) -> None:
164
+ """Called automatically when current_index changes"""
165
+ if not self.parsed_lyrics or idx == -1:
166
+ self.query_one(Markdown).update("")
167
+ return
168
+
169
+ lyrics = self.parsed_lyrics
170
+
171
+ prev_text = f"*{lyrics[idx - 1][1]}* " if idx > 0 else ""
172
+ curr_text = f"**{lyrics[idx][1]}** "
173
+ next_text = f"*{lyrics[idx + 1][1]}* " if idx < len(lyrics) - 1 else ""
174
+
175
+ content = "\n".join(filter(bool, [prev_text, curr_text, next_text]))
176
+ self.query_one(Markdown).update(content)
177
+
178
+ class LeftPane(Widget):
179
+ pass
180
+ class QueueBox(VerticalScroll):
181
+ """View the current song queue"""
182
+ def compose(self) -> ComposeResult:
183
+ yield RadioSet(disabled=True)
184
+
185
+ def on_mount(self) -> None:
186
+ self.border_title = "Queue"
187
+ # self.query_one(RadioSet).mount(RadioButton("Hello"))
188
+
189
+ class TopBox(Widget):
190
+ """Class containing album and song list"""
191
+
192
+ song_over: reactive = reactive(False, init=False)
193
+ def __init__(self, path: str) -> None:
194
+ super().__init__()
195
+ self.path = path
196
+ self.current_album: str = ""
197
+ self.queue_iterator: ReversibleIterator = None
198
+ self.data_dict: dict = {}
199
+ self.song_queue = []
200
+
201
+ def compose(self) -> ComposeResult:
202
+ with LeftPane():
203
+ yield AlbumList(self.data_dict)
204
+ yield QueueBox()
205
+ with RightPane():
206
+ yield SongList(self.data_dict)
207
+ yield LyricBox()
208
+ yield AlbumCover()
209
+
210
+ def on_mount(self) -> None:
211
+ # worker that loads library
212
+ self.load_library_data()
213
+ self.set_interval(1/30, self.song_status)
214
+
215
+ @work(thread=True, exit_on_error=False)
216
+ def load_library_data(self) -> None:
217
+ """runs load_library() asynchronously, calls finish_loading() at end."""
218
+ library = asyncio.run(load_library(self.path, cache=cache))
219
+ self.app.call_from_thread(
220
+ self.finish_loading,
221
+ library
222
+ )
223
+
224
+ def finish_loading(self, library: dict) -> None:
225
+ self.data_dict = library
226
+ album_data = list(self.data_dict.values())
227
+ if album_data:
228
+ self.query_one(AlbumCover).path = album_data[0]["album_art"]
229
+ else:
230
+ self.query_one(AlbumCover).path = "./media/unknown.png"
231
+ self.query_one(AlbumList).data = library
232
+ self.query_one(SongList).song_data = library
233
+
234
+ ## HELPER FUNCTIONS ##
235
+
236
+ def update_queue_box(self, song_name:str):
237
+ qb = self.query_one(QueueBox).query_one(RadioSet)
238
+ qb.remove_children()
239
+ for song in self.song_queue:
240
+ if song == song_name:
241
+ qb.mount(RadioButton(f"{song}", value=True))
242
+ else:
243
+ qb.mount(RadioButton(f"{song}"))
244
+
245
+ def song_manager(self, song_name: str) -> None:
246
+ """ plays song, updates queue, loads lyrics"""
247
+ # play song using player
248
+ if not play_song(data_dict=self.data_dict, song_name=song_name):
249
+ return
250
+ # load song lyrics
251
+ path = self.data_dict.get(self.current_album, {}).get("songs", {}).get(song_name, "")
252
+ self.query_one(LyricBox).current_song_path = path
253
+ # update queue box
254
+ self.update_queue_box(song_name=song_name)
255
+
256
+ def set_album_queue(self, song_name: str):
257
+ album = self.data_dict.get(self.current_album)
258
+ if not album:
259
+ return
260
+ songs = album.get("songs", {})
261
+ if song_name not in songs:
262
+ return
263
+ song_list = sorted(songs.keys())
264
+ idx = song_list.index(song_name)
265
+ self.song_queue = song_list[idx:] + song_list[:idx]
266
+
267
+ ## HELPER FUNCTIONS END ##
268
+
269
+
270
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
271
+ # Make sure the event came from AlbumList, not SongList's OptionList
272
+ if event.control in self.query_one(AlbumList).query(OptionList):
273
+ album_name = str(event.option.prompt)
274
+ self.current_album = album_name
275
+ # Update SongList UI
276
+ song_list_obj = self.query_one(SongList)
277
+ song_list_obj.update_song_list(album_name)
278
+ song_list_obj.query_one(Input).clear()
279
+ # Update Album Cover Display
280
+ self.query_one(AlbumCover).path = self.data_dict.get(album_name).get('album_art')
281
+
282
+ # If SongList option, selected, play song
283
+ if event.control in self.query_one(SongList).query(OptionList):
284
+ song_name = str(event.option.prompt)
285
+ # generating song queue
286
+ self.set_album_queue(song_name=song_name)
287
+ self.queue_iterator = ReversibleIterator(lst=self.song_queue)
288
+ self.song_manager(song_name=next(self.queue_iterator))
289
+
290
+ def song_status(self):
291
+ """checks if song is over"""
292
+ progress = get_progress()
293
+ if progress[2] and progress[1] != 0.0:
294
+ self.song_over = True
295
+
296
+ def watch_song_over(self)->None:
297
+ """watches song_over flag, if changed, plays next song in queue"""
298
+ if not self.song_over:
299
+ return
300
+
301
+ self.song_over = False
302
+ if self.queue_iterator is None:
303
+ return
304
+
305
+ self.song_manager(song_name=next(self.queue_iterator))
306
+
@@ -0,0 +1,145 @@
1
+ AlbumList {
2
+ border-title-style: bold;
3
+ border: dashed round mediumslateblue;
4
+ width: 100%;
5
+ dock: left;
6
+ }
7
+
8
+ QueueBox {
9
+ border: dashed round deeppink;
10
+ }
11
+
12
+ SongList {
13
+ border-title-style: bold;
14
+ border: dashed round mediumslateblue;
15
+ width: 100%;
16
+ height: 1fr;
17
+ }
18
+
19
+ SongList > OptionList {
20
+ height: 1fr;
21
+ }
22
+
23
+ LyricBox {
24
+ border: dashed round deeppink;
25
+ width: 100%;
26
+ height: 5;
27
+ background: black 10%;
28
+ }
29
+
30
+ LyricBox > Markdown {
31
+ margin: 0;
32
+ padding: 0;
33
+ background: transparent;
34
+ }
35
+
36
+ TopBox {
37
+ margin-right: 1;
38
+ margin-left: 1;
39
+ height: 1fr;
40
+ layers: below above;
41
+ align: right bottom;
42
+ }
43
+
44
+ LeftPane {
45
+ width: 30%;
46
+ layout: horizontal;
47
+ dock: left;
48
+ }
49
+
50
+ RightPane {
51
+ width: 70.5%;
52
+ dock: right;
53
+ layout: vertical;
54
+ }
55
+
56
+ BottomBox {
57
+ border: solid round white;
58
+ height: 5;
59
+ dock: bottom;
60
+ margin-bottom: 1;
61
+ margin-right: 1;
62
+ margin-left: 1;
63
+ layout: horizontal; /* buttons and bar inline */
64
+ }
65
+
66
+ PlayControls {
67
+ width: 32;
68
+ dock: left;
69
+ layout: horizontal;
70
+ margin-left: 1;
71
+ align-horizontal: left;
72
+ }
73
+
74
+ Playback {
75
+ width: 100%;
76
+ }
77
+
78
+ QueueOptions {
79
+ layout: horizontal;
80
+ align-horizontal: right;
81
+ margin-right: 1;
82
+ width: 23;
83
+ dock: right;
84
+ }
85
+
86
+ Button {
87
+ border: round deeppink;
88
+ min-width: 10;
89
+ text-align: center;
90
+ width: 7;
91
+ height: 3;
92
+ color: white 70%;
93
+ background: red 0%;
94
+ }
95
+
96
+ #song-name {
97
+ margin-bottom: 1;
98
+ }
99
+
100
+ AlbumCover {
101
+ layer: above;
102
+ width: 22;
103
+ height: 10;
104
+ border: round gainsboro;
105
+ background: transparent;
106
+ }
107
+
108
+ DirectoryDialog {
109
+ align: center middle;
110
+ }
111
+
112
+ Horizontal > Button{
113
+ border: solid round gainsboro;
114
+ }
115
+
116
+ #modal_input{
117
+ width: 100%;
118
+ }
119
+
120
+ #dia-prev {
121
+ width: auto;
122
+ }
123
+
124
+ #dialog-container {
125
+ width: 50%;
126
+ height: auto;
127
+ }
128
+
129
+ #dialog-buttons {
130
+ height: auto;
131
+ width: 100%;
132
+ }
133
+
134
+ .queue-btn{
135
+ min-width: 11;
136
+ border: round ansi_bright_yellow;
137
+ }
138
+
139
+ #show-queue{
140
+ border-title-align: left;
141
+ }
142
+
143
+ #shuffle-queue{
144
+ border-subtitle-align: right;
145
+ }
File without changes
@@ -0,0 +1,77 @@
1
+ import hashlib
2
+ import os
3
+ from json import JSONDecodeError
4
+
5
+ from platformdirs import PlatformDirs
6
+ import json
7
+ import aiofiles
8
+
9
+
10
+ DIRS = PlatformDirs("lyric_cache", "TUIman")
11
+
12
+ class Cache:
13
+ def __init__(self):
14
+ self.lyric_cache_path = DIRS.user_cache_path / "lyrics.json"
15
+ self.album_cache_path = DIRS.user_cache_path / "last_used_path.txt"
16
+ self.album_art_path = DIRS.user_cache_path / "album_art"
17
+ self.init_cache()
18
+
19
+ def init_cache(self):
20
+ """Ensure cache directories exist."""
21
+ DIRS.user_cache_path.mkdir(parents=True, exist_ok=True)
22
+ self.album_art_path.mkdir(parents=True, exist_ok=True)
23
+
24
+ async def _load(self) -> dict:
25
+ if not self.lyric_cache_path.exists():
26
+ return {}
27
+
28
+ try:
29
+ async with aiofiles.open(self.lyric_cache_path, "r") as f:
30
+ raw = await f.read()
31
+ return json.loads(raw) if raw.strip() else {}
32
+ except (JSONDecodeError, OSError):
33
+ return {}
34
+
35
+ async def create_cache(self, song_path: str, lyrics: list) -> None:
36
+ data = await self._load()
37
+ data[song_path] = lyrics
38
+
39
+ self.lyric_cache_path.parent.mkdir(parents=True, exist_ok=True)
40
+ temp_path = self.lyric_cache_path.with_suffix(".json.tmp")
41
+
42
+ async with aiofiles.open(temp_path, "w") as f:
43
+ await f.write(json.dumps(data, indent=2))
44
+
45
+ os.replace(temp_path, self.lyric_cache_path)
46
+
47
+ def create_path_cache(self, path: str):
48
+ with open(self.album_cache_path, "w") as f:
49
+ f.write(path)
50
+
51
+ def find_path_cache(self):
52
+ try:
53
+ with open(self.album_cache_path, "r") as f:
54
+ return f.read().strip()
55
+ except FileNotFoundError:
56
+ return ""
57
+
58
+ async def find_cache(self, song_path: str) -> list | None:
59
+ return (await self._load()).get(song_path)
60
+
61
+ async def find_album_art_cache(self, album_path: str) -> str | None:
62
+ """Return cached image path if it exists on disk, else None."""
63
+ cache_dir = self.album_art_path
64
+ # Use a hash of the album path as the filename to avoid collisions
65
+ key = hashlib.md5(album_path.encode()).hexdigest()
66
+ cached = cache_dir / f"{key}.jpg"
67
+ return str(cached) if cached.exists() else None
68
+
69
+ async def create_album_art_cache(self, album_path: str, image_data: bytes) -> str:
70
+ """Write image bytes to cache and return the cached file path."""
71
+ cache_dir = self.album_art_path
72
+ cache_dir.mkdir(parents=True, exist_ok=True)
73
+ key = hashlib.md5(album_path.encode()).hexdigest()
74
+ cached = cache_dir / f"{key}.jpg"
75
+ async with aiofiles.open(cached, "wb") as f:
76
+ await f.write(image_data)
77
+ return str(cached)
@@ -0,0 +1,98 @@
1
+ from pprint import pprint
2
+ from textual.widgets import Input, OptionList
3
+ from mutagen.id3 import ID3, APIC
4
+ from pathlib import Path
5
+ from src.tuiman.utils.caching import Cache
6
+
7
+ SUPPORTED_AUDIO_EXTENSIONS = {".mp3"}
8
+ SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"}
9
+
10
+
11
+ def search_function(object, event: Input.Changed, iterables) -> None:
12
+ """search helper function
13
+ :param object: self instance of class Input
14
+ :param event: event of class Input
15
+ :param iterables: the song/ album list
16
+ """
17
+ query = event.value.lower()
18
+
19
+ filtered = [a for a in iterables if query in a.lower()]
20
+
21
+ option_list = object.query_one(OptionList)
22
+ option_list.clear_options()
23
+ for song_or_album in filtered:
24
+ option_list.add_option(song_or_album)
25
+
26
+ async def _extract_album_art(album_path: Path, songs: dict, cache: Cache) -> str:
27
+ """Try cache first, then extract from ID3 tags, store result in cache."""
28
+ cached = await cache.find_album_art_cache(str(album_path))
29
+ if cached:
30
+ return cached
31
+
32
+ for song_path in songs.values():
33
+ try:
34
+ tags = ID3(song_path)
35
+ for tag in tags.values():
36
+ if isinstance(tag, APIC):
37
+ return await cache.create_album_art_cache(str(album_path), tag.data)
38
+ except Exception:
39
+ continue
40
+
41
+ return ""
42
+
43
+
44
+ def _load_album(album_path: Path) -> dict | None:
45
+ songs = {}
46
+
47
+ for extension in SUPPORTED_AUDIO_EXTENSIONS:
48
+ for entry in album_path.glob(f"*{extension}"):
49
+ songs[entry.stem] = str(entry)
50
+
51
+ if not songs:
52
+ return None
53
+
54
+ # Sort by filename for consistent ordering
55
+ songs = dict(sorted(songs.items(), key=lambda kv: kv[0].casefold()))
56
+ return {"songs": songs, "album_art": ""}
57
+
58
+
59
+ async def load_library(root_dir: str, cache: Cache) -> dict:
60
+ library = {}
61
+ seen_names = {} # name -> count of times seen
62
+ root_path = Path(root_dir).expanduser().resolve()
63
+
64
+ album_paths = set()
65
+ for extension in SUPPORTED_AUDIO_EXTENSIONS:
66
+ for match in root_path.rglob(f"*{extension}"):
67
+ album_paths.add(match.parent)
68
+
69
+ if not album_paths:
70
+ album_paths = {root_path}
71
+
72
+ for album_path in sorted(album_paths, key=lambda e: e.name.casefold()):
73
+ album = _load_album(album_path)
74
+ if album is None:
75
+ continue
76
+
77
+ album["album_art"] = await _extract_album_art(album_path, album["songs"], cache)
78
+
79
+ name = album_path.name
80
+ if name not in seen_names:
81
+ seen_names[name] = 0
82
+ library[name] = album
83
+ else:
84
+ seen_names[name] += 1
85
+ library[f"{name} ({seen_names[name]})"] = album
86
+
87
+ return library
88
+
89
+
90
+ if "__main__" == __name__:
91
+ library = load_library("../../../data")
92
+ # print(*library.get("album2", []).keys())
93
+ pprint(library)
94
+ # print(list(library.values())[0]['album_art'])
95
+ # print([*library.keys()])
96
+ # for album in library:
97
+ # print(album)
98
+ #print(next((songs["Chic 'N' Stu.mp3"] for album in library.values() for songs in [album['songs']] if "Chic 'N' Stu.mp3" in songs), ""))
@@ -0,0 +1,109 @@
1
+ import asyncio
2
+ import os
3
+ import re
4
+ import httpx
5
+ import mutagen.id3
6
+ from httpx import ReadTimeout
7
+ from src.tuiman.utils.caching import Cache
8
+
9
+ BASE_URL = "https://lrclib.net/api/search"
10
+ lyrics_cache = Cache()
11
+
12
+ async def lrclib(**kwargs)->str:
13
+ params = {
14
+ "track_name": kwargs.get("title", None),
15
+ "artist_name": kwargs.get("artist", None),
16
+ "album_name": kwargs.get("album", None)
17
+ }
18
+
19
+ async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=15.0)) as client:
20
+ response = await client.get(BASE_URL, params=params)
21
+ response.raise_for_status()
22
+ data = response.json()
23
+ first_match = data[0].get("syncedLyrics") if data else None
24
+ return first_match
25
+
26
+ async def parse_lrc_lyrics(lrc_text: str) -> list[tuple[float, str]]:
27
+ pattern = re.compile(r'\[(\d+):(\d+\.\d+)\]\s*(.*)')
28
+ results = []
29
+
30
+ for line in lrc_text.splitlines():
31
+ m = pattern.match(line.strip())
32
+ if not m:
33
+ continue
34
+ minutes, seconds, text = int(m.group(1)), float(m.group(2)), m.group(3).strip()
35
+ if text:
36
+ ts = round(minutes * 60 + seconds, 2)
37
+ results.append((ts, text))
38
+
39
+ return sorted(results, key=lambda x: x[0])
40
+
41
+
42
+ async def extract_lyrics(path: str) -> dict:
43
+ """
44
+ Extracts synced lyrics from an mp3's ID3 tags (SYLT frame).
45
+ Falls back to LRCLIB
46
+ Stores lyrics in .cache
47
+ Returns {"lyrics": [(time_ms: float, "text": str), ...]}
48
+ """
49
+ if not os.path.exists(path):
50
+ return {"lyrics": []}
51
+
52
+ try:
53
+ tags = mutagen.id3.ID3(path)
54
+ except mutagen.id3.ID3NoHeaderError:
55
+ return {"lyrics": []}
56
+
57
+ # synced lyrics — stores (text, timestamp_in_ms) tuples
58
+ song_meta = {}
59
+ for key in tags.keys():
60
+ # synced lyrics found
61
+ if key.startswith("SYLT"):
62
+ sylt = tags[key]
63
+ lyrics = [
64
+ (round(ms / 1000.0, 3), text.strip())
65
+ for text, ms in sylt.text
66
+ if text.strip()
67
+ ]
68
+ lyrics.sort(key=lambda x: x[0])
69
+ # caching those lyrics
70
+ await lyrics_cache.create_cache(song_path=path, lyrics=lyrics)
71
+ return {"lyrics": lyrics}
72
+ else:
73
+ #track name
74
+ if key.startswith("TIT2"):
75
+ song_meta["title"] = tags[key].text
76
+ #artist name
77
+ if key.startswith("TPE1"):
78
+ song_meta["artist"] = tags[key].text
79
+ #album name
80
+ if key.startswith("TALB"):
81
+ song_meta["album"] = tags[key].text
82
+
83
+ # Try to fetch lyrics from LRCLIB
84
+ try:
85
+ synced_lyrics = await lrclib(**song_meta)
86
+ except ReadTimeout:
87
+ synced_lyrics = []
88
+ if synced_lyrics:
89
+ lyrics = await parse_lrc_lyrics(lrc_text=synced_lyrics)
90
+ # caching those lyrics
91
+ await lyrics_cache.create_cache(song_path=path, lyrics=lyrics)
92
+ return {"lyrics": lyrics}
93
+
94
+ # nothing found, store empty list
95
+ await lyrics_cache.create_cache(song_path=path, lyrics=[])
96
+ return {"lyrics": []}
97
+
98
+ if "__main__" == __name__:
99
+ # pprint(extract_lyrics("../data/album2/Human Nature - lyrics.mp3"))
100
+ # {'lyrics': [(10.72, 'Looking out'),
101
+ # (13.35, 'Across the nighttime'),
102
+ # pprint(extract_lyrics(""))
103
+ lyrics = asyncio.run(lrclib(
104
+ title="",
105
+ artist="",
106
+ album=""
107
+ ))
108
+ print(lyrics)
109
+ # print(asyncio.run(extract_lyrics(path= "../data/exeter/IMAGINARY.mp3")))
@@ -0,0 +1,13 @@
1
+ class ReversibleIterator:
2
+ def __init__(self, lst):
3
+ self.lst = lst
4
+ self.pos = 0
5
+ self.delta = 1
6
+
7
+ def __next__(self):
8
+ ret = self.lst[self.pos]
9
+ self.pos = (self.pos + 1) % len(self.lst)
10
+ return ret
11
+
12
+ def __iter__(self):
13
+ return self
@@ -0,0 +1,86 @@
1
+ import mutagen
2
+ import os
3
+ os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1"
4
+ import pygame
5
+ from typing import Optional
6
+
7
+ # module-level state
8
+ _current_album: Optional[str] = None
9
+ _current_song: Optional[str] = "-----"
10
+ _current_duration: float = 0.0
11
+ _paused: bool = False
12
+
13
+ def init_player() -> None:
14
+ """Call once at app startup."""
15
+ pygame.mixer.init()
16
+
17
+ def play_song(data_dict: dict, song_name: str) -> bool:
18
+ """
19
+ Play a song by name, searching across all albums.
20
+ Returns True on success, False if song not found.
21
+ """
22
+ global _current_album, _current_duration, _current_song, _paused
23
+
24
+ for album_name, data in data_dict.items():
25
+ if song_name not in data["songs"]:
26
+ continue
27
+ path = data["songs"][song_name]
28
+
29
+ try:
30
+ pygame.mixer.music.stop()
31
+ pygame.mixer.music.load(path)
32
+ pygame.mixer.music.play()
33
+ audio = mutagen.File(path)
34
+ _current_duration = audio.info.length if audio and audio.info else 0.0
35
+ except Exception:
36
+ _current_duration = 0.0
37
+ return False
38
+ _current_album = album_name
39
+ _current_song = song_name
40
+ _paused = False
41
+ return True
42
+
43
+ return False
44
+ def pause() -> None:
45
+ if pygame.mixer.music.get_busy() and not _paused:
46
+ pygame.mixer.music.pause()
47
+ globals()['_paused'] = True
48
+
49
+ def resume() -> None:
50
+ global _paused
51
+ if _paused:
52
+ pygame.mixer.music.unpause()
53
+ _paused = False
54
+
55
+ def stop() -> None:
56
+ global _current_album, _current_song, _paused
57
+ pygame.mixer.music.stop()
58
+ _current_album = None
59
+ _current_song = None
60
+ _paused = False
61
+
62
+ def set_volume(level: float) -> None:
63
+ """level: 0.0 to 1.0"""
64
+ pygame.mixer.music.set_volume(max(0.0, min(1.0, level)))
65
+
66
+ def get_current() -> dict:
67
+ return {
68
+ "album": _current_album,
69
+ "song": _current_song,
70
+ "paused": _paused,
71
+ "playing": pygame.mixer.music.get_busy()
72
+ }
73
+
74
+ def get_progress() -> tuple[float, float, bool]:
75
+ """Returns (elapsed_seconds, total_seconds, track_ended)."""
76
+ if _current_song is None:
77
+ return 0.0, 0.0, False
78
+
79
+ track_ended = False
80
+ raw_pos = pygame.mixer.music.get_pos()
81
+ elapsed = max(0.0, raw_pos / 1000.0)
82
+
83
+ if elapsed > _current_duration:
84
+ track_ended = True
85
+
86
+ return elapsed, _current_duration, track_ended