Merge remote-tracking branch 'origin/main'

# Conflicts:
#	README.md
#	mtsmusic.py
#	qt_app.py
This commit is contained in:
jads
2026-10-25 23:45:33 +03:00
5 changed files with 952 additions and 410 deletions

264
qt_app.py
View File

@@ -4,6 +4,7 @@
from __future__ import annotations
import sys
from pathlib import Path
from PySide6.QtCore import (
QEasingCurve,
@@ -53,6 +54,7 @@ from PySide6.QtWidgets import (
QListWidgetItem,
QMainWindow,
QMessageBox,
QProgressBar,
QPushButton,
QSizePolicy,
QSlider,
@@ -75,6 +77,8 @@ from media import (
from mtsmusic import (
USER_AGENT,
Api,
collect_download_tracks,
download_tracks,
fetch_liked_tracks,
fetch_mix_batch,
fmt_duration,
@@ -83,7 +87,7 @@ from mtsmusic import (
lrclib_lyrics,
lyrics_response,
lyrics_text,
resolve_stream,
fetch_track_file,
)
from settings import (
START_QUEUE_LABELS,
@@ -314,49 +318,42 @@ class Task(QRunnable):
pass
class DownloadTask(QRunnable):
"""Скачивает список треков в фоне и сообщает о прогрессе."""
class ProgressTask(QRunnable):
"""Задача, которая умеет присылать прогресс в GUI-поток."""
class Signals(QObject):
progress = Signal(str)
done = Signal(int, int, str)
failed = Signal(str)
progress = Signal(object)
done = Signal(object, object)
failed = Signal(object, str)
def __init__(self, api, tracks: list[dict], dest: str) -> None:
def __init__(self, fn) -> None:
super().__init__()
self.api = api
self.tracks = tracks
self.dest = dest
self.signals = DownloadTask.Signals()
self.fn = fn
self.signals = ProgressTask.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)
result = self.fn(self._emit_progress)
except SystemExit as exc:
self._emit_failed(str(exc) or "ошибка")
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)
self.signals.done.emit(self, result)
except RuntimeError:
pass
def _emit(self, text: str) -> None:
def _emit_progress(self, event: dict) -> None:
try:
self.signals.progress.emit(text)
self.signals.progress.emit(event)
except RuntimeError:
pass
def _emit_failed(self, message: str) -> None:
try:
self.signals.failed.emit(message)
self.signals.failed.emit(self, message)
except RuntimeError:
pass
@@ -597,6 +594,7 @@ class QtMusicApp(QMainWindow):
self.current_index = -1
self.liked_ids: set[str] = set()
self.favorites: list[dict] = []
self.search_results: list[dict] = []
self.history: list[dict] = load_history()
self.cover_cache: dict[str, QPixmap] = {}
self.current_cover_url = ""
@@ -617,7 +615,6 @@ 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)
@@ -729,6 +726,9 @@ class QtMusicApp(QMainWindow):
self.settings_page = self._build_settings_page()
self.pages.addWidget(self.settings_page)
self.download_page = self._build_download_page()
self.pages.addWidget(self.download_page)
self._setup_tab_order()
def _setup_tab_order(self) -> None:
@@ -1078,6 +1078,150 @@ class QtMusicApp(QMainWindow):
layout.addStretch(1)
return page
def _build_download_page(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(18, 10, 18, 14)
layout.setSpacing(12)
layout.addLayout(self._page_header("Скачать"))
dir_label = QLabel("Папка для сохранения")
dir_label.setObjectName("settingLabel")
layout.addWidget(dir_label)
dir_row = QHBoxLayout()
dir_row.setSpacing(8)
self.download_dir = QLineEdit(
str(self.settings.get("download_dir")
or Path.home() / "Music" / "MTS")
)
self.download_dir.setObjectName("downloadDir")
dir_row.addWidget(self.download_dir, 1)
browse = QPushButton("Обзор…")
browse.setObjectName("actionBtn")
browse.setCursor(Qt.CursorShape.PointingHandCursor)
browse.clicked.connect(self.browse_download_dir)
dir_row.addWidget(browse)
layout.addLayout(dir_row)
source_label = QLabel("Что скачать")
source_label.setObjectName("settingLabel")
layout.addWidget(source_label)
self.download_source = QComboBox()
self.download_source.setObjectName("settingCombo")
for text, kind in (
("Текущий трек", "current"),
("Всё избранное", "likes"),
("Моя волна (20 треков)", "wave"),
("Результаты поиска", "search"),
):
self.download_source.addItem(text, kind)
layout.addWidget(self.download_source)
self.download_btn = QPushButton("Скачать")
self.download_btn.setObjectName("actionBtn")
self.download_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.download_btn.clicked.connect(self.start_download)
layout.addWidget(self.download_btn)
self.download_progress = QProgressBar()
self.download_progress.setRange(0, 1)
self.download_progress.setValue(0)
self.download_progress.setTextVisible(False)
layout.addWidget(self.download_progress)
self.download_status = QLabel(
"Уже скачанные файлы пропускаются, имена — "
"«Исполнитель - Название.mp3»."
)
self.download_status.setObjectName("pageStatus")
self.download_status.setWordWrap(True)
layout.addWidget(self.download_status)
layout.addStretch(1)
return page
# -- скачивание -------------------------------------------------------
def show_download(self) -> None:
self._show(self.download_page)
def browse_download_dir(self) -> None:
directory = QFileDialog.getExistingDirectory(
self, "Папка для скачивания", self.download_dir.text().strip()
)
if directory:
self.download_dir.setText(directory)
def start_download(self) -> None:
raw = self.download_dir.text().strip()
if not raw:
self.toast("Укажи папку для сохранения")
return
destination = Path(raw).expanduser()
self.update_setting("download_dir", str(destination))
if hasattr(self, "download_dir_edit"):
self.download_dir_edit.setText(str(destination))
kind = self.download_source.currentData()
if kind == "current" and not self.current:
self.toast("Сначала выбери трек")
return
if kind == "search" and not self.search_results:
self.toast("Сначала выполни поиск")
return
self.download_btn.setEnabled(False)
self.download_progress.setRange(0, 0)
self.download_status.setText("Готовлю список…")
self.run_progress_task(
lambda progress: self._download_job(kind, destination, progress),
self.download_done,
self.download_failed,
)
def _download_job(self, kind: str, destination: Path, progress) -> tuple:
if kind == "current":
tracks = [self.current]
elif kind == "likes":
tracks = collect_download_tracks(self.api, likes=True)
elif kind == "wave":
tracks = collect_download_tracks(self.api, wave=True, number=20)
else:
tracks = list(self.search_results)
if not tracks:
raise ValueError("нечего скачивать")
saved, skipped, failed = download_tracks(
self.api, tracks, destination, on_event=progress
)
return saved, skipped, failed, destination
def on_download_progress(self, event: dict) -> None:
track = event["track"]
self.download_progress.setRange(0, event["total"])
self.download_progress.setValue(event["index"] - 1)
title = f"{track_artist(track)} — {track.get('title') or '?'}"
self.download_status.setText(
f"[{event['index']}/{event['total']}] {title}")
def download_done(self, result: tuple) -> None:
saved, skipped, failed, destination = result
self.download_btn.setEnabled(True)
self.download_progress.setRange(0, 1)
self.download_progress.setValue(1)
self.set_top_status(self.queue_name)
message = (
f"Готово: {saved} сохранено, {skipped} уже было, "
f"{failed} ошибок"
)
self.download_status.setText(f"{message}\n{destination}")
self.toast(message)
def download_failed(self, message: str) -> None:
self.download_btn.setEnabled(True)
self.download_progress.setRange(0, 1)
self.download_progress.setValue(0)
self.download_status.setText(f"Ошибка: {message}")
self.toast(message)
# -- настройки --------------------------------------------------------
def update_setting(self, key: str, value) -> None:
@@ -1118,14 +1262,14 @@ class QtMusicApp(QMainWindow):
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], "трек")
self._quick_download([self.current])
def download_queue(self) -> None:
self.close_drawer()
@@ -1133,36 +1277,32 @@ class QtMusicApp(QMainWindow):
if not tracks:
self.toast("Список пуст")
return
self.download_tracks(tracks, "список")
self._quick_download(list(tracks))
def download_tracks(self, tracks: list[dict], label: str) -> None:
if not tracks:
return
def _quick_download(self, tracks: list[dict]) -> None:
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)
if not dest:
self.toast("Укажи папку загрузок в настройках")
return
destination = Path(dest).expanduser()
self.download_btn.setEnabled(False)
self.download_progress.setRange(0, 0)
self.download_status.setText("Готовлю список…")
self.set_top_status("Скачиваю…")
self.run_progress_task(
lambda progress: self._download_tracks_job(
tracks, destination, progress),
self.download_done,
self.download_failed,
)
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 _download_tracks_job(
self, tracks: list[dict], destination: Path, progress
) -> tuple:
saved, skipped, failed = download_tracks(
self.api, tracks, destination, on_event=progress
)
return saved, skipped, failed, destination
def show_settings(self) -> None:
self._show(self.settings_page)
@@ -1191,6 +1331,7 @@ class QtMusicApp(QMainWindow):
("Избранное", self.show_favorites),
("История", self.show_history),
("Поиск", self.show_search),
("Скачать", self.show_download),
("Текст песни", self.show_lyrics),
("Скачать трек", self.download_current),
("Скачать список", self.download_queue),
@@ -1314,6 +1455,16 @@ class QtMusicApp(QMainWindow):
self._tasks.add(task)
self.pool.start(task)
def run_progress_task(self, fn, done, failed) -> None:
task = ProgressTask(fn)
task.done_cb = done
task.failed_cb = failed
task.signals.done.connect(self._task_done)
task.signals.failed.connect(self._task_failed)
task.signals.progress.connect(self.on_download_progress)
self._tasks.add(task)
self.pool.start(task)
def _task_done(self, task: Task, result) -> None:
if task not in self._tasks:
return
@@ -1496,7 +1647,7 @@ class QtMusicApp(QMainWindow):
self.set_top_status("Загрузка…")
self.run_task(
lambda: resolve_stream(self.api, str(track.get("id"))),
lambda: fetch_track_file(self.api, str(track.get("id"))),
lambda result: self.start_playback(token, track, result),
self.on_error,
)
@@ -1506,9 +1657,9 @@ class QtMusicApp(QMainWindow):
) -> None:
if token != self.play_token:
return
url, item = result
path, item = result
self._preview = bool(item.get("preview"))
self.player.setSource(QUrl(url))
self.player.setSource(QUrl.fromLocalFile(str(path)))
self.player.play()
self.history = record_history(track)
title = str(track.get("title") or "")
@@ -1931,6 +2082,7 @@ class QtMusicApp(QMainWindow):
self.run_task(search, self.apply_search, self.search_failed)
def apply_search(self, tracks: list[dict]) -> None:
self.search_results = tracks
self.fill_track_list(self.search_list, tracks, "Поиск")
self.search_status.setText(
f"{len(tracks)} результат(ов)" if tracks else "Ничего не найдено"