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:
jads
2026-10-25 20:06:59 +03:00
parent e71f5623a7
commit de9bf80d11
9 changed files with 754 additions and 37 deletions

165
tui.py
View File

@@ -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: