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

123
qt_app.py
View File

@@ -44,6 +44,7 @@ from PySide6.QtWidgets import (
QAbstractButton,
QCheckBox,
QComboBox,
QFileDialog,
QFrame,
QHBoxLayout,
QLabel,
@@ -313,6 +314,53 @@ class Task(QRunnable):
pass
class DownloadTask(QRunnable):
"""Скачивает список треков в фоне и сообщает о прогрессе."""
class Signals(QObject):
progress = Signal(str)
done = Signal(int, int, str)
failed = Signal(str)
def __init__(self, api, tracks: list[dict], dest: str) -> None:
super().__init__()
self.api = api
self.tracks = tracks
self.dest = dest
self.signals = DownloadTask.Signals()
self.setAutoDelete(False)
def run(self) -> None:
from downloads import download_many
def on_item(index: int, count: int, track: dict) -> None:
title = str(track.get("title") or "")
self._emit(f"Скачиваю {index}/{count}: {title}")
try:
saved = download_many(
self.api, self.tracks, self.dest, on_item=on_item)
except Exception as exc: # noqa: BLE001 - сообщение показываем в UI
self._emit_failed(str(exc) or exc.__class__.__name__)
else:
try:
self.signals.done.emit(len(saved), len(self.tracks), self.dest)
except RuntimeError:
pass
def _emit(self, text: str) -> None:
try:
self.signals.progress.emit(text)
except RuntimeError:
pass
def _emit_failed(self, message: str) -> None:
try:
self.signals.failed.emit(message)
except RuntimeError:
pass
class CoverView(QWidget):
"""Обложка со скруглениями и аккуратным плейсхолдером."""
@@ -569,6 +617,7 @@ class QtMusicApp(QMainWindow):
self.pool = QThreadPool(self)
self.pool.setMaxThreadCount(4)
self.covers = QNetworkAccessManager(self)
self._download_tasks: set[DownloadTask] = set()
self.player = QMediaPlayer(self)
self.audio = QAudioOutput(self)
@@ -1006,6 +1055,22 @@ class QtMusicApp(QMainWindow):
self.wave_check.toggled.connect(self.on_wave_from_history_toggled)
layout.addWidget(self.wave_check)
dir_row = QHBoxLayout()
dir_row.setSpacing(10)
dir_label = QLabel("Папка загрузок")
dir_label.setObjectName("settingLabel")
self.download_dir_edit = QLineEdit(
str(self.settings.get("download_dir") or ""))
self.download_dir_edit.setObjectName("settingCombo")
self.download_dir_edit.setReadOnly(True)
dir_button = QPushButton("Выбрать…")
dir_button.setCursor(Qt.CursorShape.PointingHandCursor)
dir_button.clicked.connect(self.choose_download_dir)
dir_row.addWidget(dir_label)
dir_row.addWidget(self.download_dir_edit, 1)
dir_row.addWidget(dir_button)
layout.addLayout(dir_row)
hint = QLabel("Настройки сохраняются в .mts-music/settings.json")
hint.setObjectName("pageStatus")
hint.setWordWrap(True)
@@ -1043,6 +1108,62 @@ class QtMusicApp(QMainWindow):
def on_wave_from_history_toggled(self, checked: bool) -> None:
self.update_setting("wave_from_history", checked)
def choose_download_dir(self) -> None:
current = str(self.settings.get("download_dir") or "")
chosen = QFileDialog.getExistingDirectory(
self, "Папка загрузок", current)
if not chosen:
return
self.update_setting("download_dir", chosen)
self.download_dir_edit.setText(chosen)
self.toast(f"Папка загрузок: {chosen}")
# -- скачивание -------------------------------------------------------
def download_current(self) -> None:
self.close_drawer()
if not self.current:
self.toast("Сначала выбери трек")
return
self.download_tracks([self.current], "трек")
def download_queue(self) -> None:
self.close_drawer()
tracks = self.queue or ([self.current] if self.current else [])
if not tracks:
self.toast("Список пуст")
return
self.download_tracks(tracks, "список")
def download_tracks(self, tracks: list[dict], label: str) -> None:
if not tracks:
return
dest = str(self.settings.get("download_dir") or "")
task = DownloadTask(self.api, list(tracks), dest)
task.signals.progress.connect(self.set_top_status)
task.signals.done.connect(
lambda saved, total, folder, t=task:
self._download_done(t, saved, total, folder))
task.signals.failed.connect(
lambda message, t=task: self._download_failed(t, message))
self._download_tasks.add(task)
self.set_top_status(f"Скачиваю {label}…")
self.pool.start(task)
def _download_done(
self, task: DownloadTask, saved: int, total: int, dest: str
) -> None:
self._download_tasks.discard(task)
self.set_top_status(self.queue_name)
if saved:
self.toast(f"Скачано {saved}/{total} → {dest}")
else:
self.toast("Не удалось скачать")
def _download_failed(self, task: DownloadTask, message: str) -> None:
self._download_tasks.discard(task)
self.on_error(message)
def show_settings(self) -> None:
self._show(self.settings_page)
@@ -1071,6 +1192,8 @@ class QtMusicApp(QMainWindow):
("История", self.show_history),
("Поиск", self.show_search),
("Текст песни", self.show_lyrics),
("Скачать трек", self.download_current),
("Скачать список", self.download_queue),
("Настройки", self.show_settings),
):
button = QPushButton(text)