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 +17 -0
- tuiman-1.0.0/README.md +0 -0
- tuiman-1.0.0/pyproject.toml +26 -0
- tuiman-1.0.0/src/tuiman/__init__.py +0 -0
- tuiman-1.0.0/src/tuiman/app.py +145 -0
- tuiman-1.0.0/src/tuiman/media/unknown.png +0 -0
- tuiman-1.0.0/src/tuiman/modules/__init__.py +0 -0
- tuiman-1.0.0/src/tuiman/modules/bottom_box.py +68 -0
- tuiman-1.0.0/src/tuiman/modules/top_box.py +306 -0
- tuiman-1.0.0/src/tuiman/music.tcss +145 -0
- tuiman-1.0.0/src/tuiman/utils/__init__.py +0 -0
- tuiman-1.0.0/src/tuiman/utils/caching.py +77 -0
- tuiman-1.0.0/src/tuiman/utils/library_manager.py +98 -0
- tuiman-1.0.0/src/tuiman/utils/lyrics.py +109 -0
- tuiman-1.0.0/src/tuiman/utils/models.py +13 -0
- tuiman-1.0.0/src/tuiman/utils/player.py +86 -0
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()
|
|
Binary file
|
|
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,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
|