feat: download tracks with full ID3 metadata
- Add downloads.py: "Artist - Title.mp3" naming, no-overwrite dedupe, configurable download_dir, progress and per-item callbacks. - Save maximum metadata via tags.py (ID3v2.3, mutagen): artists, album and album artist, track/disc, year/date, raw genre, label, duration, explicit flag, cover 600x600, plain and synced lyrics (MTS then LRCLIB), track URLs and Yandex/MTS ids. - TUI/Qt: download current track and queue from the menu, "d" key in lists, folder picker in settings; CLI download gains -d/--dir and --no-tags. - Add mutagen to requirements and document download/metadata in README. - Fix MPRIS update_metadata crashing without dbus-next.
This commit is contained in:
165
tui.py
165
tui.py
@@ -115,7 +115,10 @@ def track_row(track: dict) -> tuple[str, str, str, str]:
|
||||
|
||||
|
||||
class ListScreen(Screen):
|
||||
BINDINGS = [Binding("escape", "back", "Назад")]
|
||||
BINDINGS = [
|
||||
Binding("escape", "back", "Назад"),
|
||||
Binding("d", "download", "Скачать"),
|
||||
]
|
||||
|
||||
def __init__(self, title: str, tracks: list[dict]) -> None:
|
||||
super().__init__()
|
||||
@@ -141,6 +144,11 @@ class ListScreen(Screen):
|
||||
def action_back(self) -> None:
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_download(self) -> None:
|
||||
index = self.query_one("#list-table", DataTable).cursor_row
|
||||
if 0 <= index < len(self.tracks):
|
||||
self.app.download_tracks([self.tracks[index]], "трек")
|
||||
|
||||
@on(Button.Pressed, "#back")
|
||||
def back_pressed(self) -> None:
|
||||
self.action_back()
|
||||
@@ -158,7 +166,10 @@ class ListScreen(Screen):
|
||||
|
||||
|
||||
class FavoritesScreen(Screen):
|
||||
BINDINGS = [Binding("escape", "back", "Назад")]
|
||||
BINDINGS = [
|
||||
Binding("escape", "back", "Назад"),
|
||||
Binding("d", "download", "Скачать"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield make_topbar("Избранное")
|
||||
@@ -173,6 +184,12 @@ class FavoritesScreen(Screen):
|
||||
def action_back(self) -> None:
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_download(self) -> None:
|
||||
index = self.query_one("#list-table", DataTable).cursor_row
|
||||
tracks = self.app.favorites
|
||||
if 0 <= index < len(tracks):
|
||||
self.app.download_tracks([tracks[index]], "трек")
|
||||
|
||||
@on(Button.Pressed, "#back")
|
||||
def back_pressed(self) -> None:
|
||||
self.action_back()
|
||||
@@ -222,7 +239,10 @@ class HistoryScreen(ListScreen):
|
||||
|
||||
|
||||
class SearchScreen(Screen):
|
||||
BINDINGS = [Binding("escape", "back", "Назад")]
|
||||
BINDINGS = [
|
||||
Binding("escape", "back", "Назад"),
|
||||
Binding("d", "download", "Скачать"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield make_topbar("Поиск")
|
||||
@@ -241,6 +261,11 @@ class SearchScreen(Screen):
|
||||
def action_back(self) -> None:
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_download(self) -> None:
|
||||
index = self.query_one("#list-table", DataTable).cursor_row
|
||||
if 0 <= index < len(self.results):
|
||||
self.app.download_tracks([self.results[index]], "трек")
|
||||
|
||||
@on(Button.Pressed, "#back")
|
||||
def back_pressed(self) -> None:
|
||||
self.action_back()
|
||||
@@ -385,6 +410,10 @@ class SettingsScreen(Screen):
|
||||
with Horizontal(classes="srow"):
|
||||
yield Static("Волна с последних треков", classes="slabel")
|
||||
yield Button("", id="wave-toggle", classes="stoggle")
|
||||
with Horizontal(classes="srow"):
|
||||
yield Static("Папка загрузок", classes="slabel")
|
||||
yield Button("Изменить", id="dir-edit", classes="stoggle")
|
||||
yield Static("", id="dir-value")
|
||||
yield Static("", id="settings-path")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
@@ -409,10 +438,17 @@ class SettingsScreen(Screen):
|
||||
self.query_one("#wave-toggle", Button).label = (
|
||||
"вкл" if s["wave_from_history"] else "выкл"
|
||||
)
|
||||
self.query_one("#dir-value", Static).update(
|
||||
str(s.get("download_dir") or ""))
|
||||
|
||||
def action_back(self) -> None:
|
||||
self.app.pop_screen()
|
||||
|
||||
def dir_chosen(self, value: str | None) -> None:
|
||||
if value:
|
||||
self.app.update_setting("download_dir", value)
|
||||
self.refresh_values()
|
||||
|
||||
@on(Button.Pressed)
|
||||
def pressed(self, event: Button.Pressed) -> None:
|
||||
bid = event.button.id or ""
|
||||
@@ -439,6 +475,14 @@ class SettingsScreen(Screen):
|
||||
self.app.update_setting(
|
||||
"wave_from_history",
|
||||
not s["wave_from_history"])
|
||||
elif bid == "dir-edit":
|
||||
self.app.push_screen(
|
||||
PathScreen(
|
||||
str(s.get("download_dir") or ""),
|
||||
"Папка загрузок",
|
||||
),
|
||||
self.dir_chosen)
|
||||
return
|
||||
self.refresh_values()
|
||||
|
||||
|
||||
@@ -453,6 +497,8 @@ class MenuScreen(ModalScreen[str | None]):
|
||||
yield Button("История", id="m-history")
|
||||
yield Button("Поиск", id="m-search")
|
||||
yield Button("Текст песни", id="m-lyrics")
|
||||
yield Button("Скачать трек", id="m-download")
|
||||
yield Button("Скачать список", id="m-download-list")
|
||||
yield Button("Настройки", id="m-settings")
|
||||
yield Static("", id="menu-spacer")
|
||||
yield Button("Выход", id="m-quit", variant="error")
|
||||
@@ -465,6 +511,45 @@ class MenuScreen(ModalScreen[str | None]):
|
||||
self.dismiss(event.button.id)
|
||||
|
||||
|
||||
class PathScreen(ModalScreen[str | None]):
|
||||
"""Диалог ввода пути (например, папки загрузок)."""
|
||||
|
||||
BINDINGS = [Binding("escape", "cancel", "Отмена")]
|
||||
|
||||
def __init__(self, value: str, title: str = "Путь") -> None:
|
||||
super().__init__()
|
||||
self.value = value
|
||||
self.dialog_title = title
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="path-panel"):
|
||||
yield Static(self.dialog_title, id="path-title")
|
||||
yield Input(value=self.value, id="path-input")
|
||||
with Horizontal(id="path-actions"):
|
||||
yield Button("Сохранить", id="path-save", variant="primary")
|
||||
yield Button("Отмена", id="path-cancel")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
field = self.query_one("#path-input", Input)
|
||||
field.focus()
|
||||
field.cursor_position = len(field.value)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
@on(Input.Submitted)
|
||||
def submitted(self, event: Input.Submitted) -> None:
|
||||
self.dismiss(event.value.strip() or None)
|
||||
|
||||
@on(Button.Pressed)
|
||||
def pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "path-save":
|
||||
value = self.query_one("#path-input", Input).value.strip()
|
||||
self.dismiss(value or None)
|
||||
else:
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# главный экран
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -606,12 +691,26 @@ class MusicTui(App):
|
||||
#menu-panel Button { width: 100%; margin: 1 0; }
|
||||
#menu-spacer { height: 1fr; }
|
||||
|
||||
PathScreen { align: center middle; background: #000000 70%; }
|
||||
#path-panel {
|
||||
width: 64;
|
||||
height: auto;
|
||||
background: #0d0d0d;
|
||||
border: thick #ffffff;
|
||||
padding: 1 2;
|
||||
}
|
||||
#path-title { text-style: bold; padding: 0 0 1 0; color: #ffffff; }
|
||||
#path-input { width: 100%; }
|
||||
#path-actions { height: 3; align: right middle; }
|
||||
#path-actions Button { width: 14; margin-left: 1; }
|
||||
|
||||
#settings-scroll { height: 1fr; padding: 1 2; }
|
||||
.srow { height: 3; align: left middle; }
|
||||
.slabel { width: 1fr; content-align: left middle; color: #d0d0d0; }
|
||||
.svalue { width: 8; content-align: center middle; color: #ffffff; }
|
||||
.sstep { width: 5; min-width: 5; }
|
||||
.stoggle { width: 16; min-width: 12; }
|
||||
#dir-value { padding: 0 0 1 0; color: #8a8a8a; }
|
||||
#settings-path { padding: 1 0; color: #6a6a6a; }
|
||||
"""
|
||||
|
||||
@@ -1194,6 +1293,10 @@ class MusicTui(App):
|
||||
self.push_screen(SearchScreen())
|
||||
elif action == "m-lyrics":
|
||||
self.action_show_lyrics()
|
||||
elif action == "m-download":
|
||||
self.action_download_current()
|
||||
elif action == "m-download-list":
|
||||
self.action_download_list()
|
||||
elif action == "m-settings":
|
||||
self.push_screen(SettingsScreen())
|
||||
elif action == "m-quit":
|
||||
@@ -1205,6 +1308,62 @@ class MusicTui(App):
|
||||
self.settings = update_settings(**{key: value})
|
||||
self.apply_settings()
|
||||
|
||||
# -- скачивание -------------------------------------------------------
|
||||
|
||||
def action_download_current(self) -> None:
|
||||
if not self.current:
|
||||
self.notify("Нет текущего трека", severity="warning")
|
||||
return
|
||||
self.download_tracks([self.current], "трек")
|
||||
|
||||
def action_download_list(self) -> None:
|
||||
tracks = self.queue or ([self.current] if self.current else [])
|
||||
if not tracks:
|
||||
self.notify("Список пуст", severity="warning")
|
||||
return
|
||||
self.download_tracks(list(tracks), self.queue_name)
|
||||
|
||||
def download_tracks(self, tracks: list[dict], label: str) -> None:
|
||||
if not self.api:
|
||||
return
|
||||
self.set_topstatus(f"Скачиваю {label}…")
|
||||
self.download_worker(tracks, label)
|
||||
|
||||
@work(thread=True, exclusive=False, group="download")
|
||||
def download_worker(self, tracks: list[dict], label: str) -> None:
|
||||
from downloads import download_many
|
||||
|
||||
dest = str(self.settings.get("download_dir") or "")
|
||||
|
||||
def on_item(index: int, count: int, track: dict) -> None:
|
||||
title = str(track.get("title") or "")
|
||||
self.call_from_thread(
|
||||
self.set_topstatus,
|
||||
f"Скачиваю {index}/{count}: {title}",
|
||||
)
|
||||
|
||||
saved = download_many(self.api, tracks, dest, on_item=on_item)
|
||||
self.call_from_thread(
|
||||
self.download_finished,
|
||||
label,
|
||||
len(saved),
|
||||
len(tracks),
|
||||
dest,
|
||||
)
|
||||
|
||||
def download_finished(
|
||||
self,
|
||||
label: str,
|
||||
saved: int,
|
||||
total: int,
|
||||
dest: str,
|
||||
) -> None:
|
||||
self.set_topstatus(self.queue_name)
|
||||
if saved:
|
||||
self.notify(f"Скачано {saved}/{total} → {dest}", timeout=6)
|
||||
else:
|
||||
self.notify("Не удалось скачать", severity="error")
|
||||
|
||||
def apply_settings(self) -> None:
|
||||
volume = int(self.settings.get("volume", 80))
|
||||
if self.player.volume != volume:
|
||||
|
||||
Reference in New Issue
Block a user