feat: cache tracks locally and add downloads
Треки больше не играют напрямую из сети: сервер Яндекса закрывает поток примерно через 45 секунд, если клиент читает медленнее реального времени, из-за чего плеер считал трек законченным и переключался на следующий. Теперь трек сначала скачивается в локальный кэш .mts-music/cache/ (512 МБ, LRU), а плеер играет готовый MP3 — это также ускоряет перемотку и повторное воспроизведение. Скачивание в выбранную папку: - CLI: download с несколькими ID, -d/--dir, -o/--output, --force; подборки --likes (всё избранное), --wave, --search QUERY, -n; - TUI и Qt: пункт меню «Скачать» с выбором папки и прогрессом; - имена файлов «Исполнитель - Название.mp3», уже скачанное пропускается. likes по умолчанию показывает всё избранное с общим количеством и поддерживает --ids для скриптов.
This commit is contained in:
206
qt_app.py
206
qt_app.py
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QEasingCurve,
|
||||
@@ -44,6 +45,7 @@ from PySide6.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -52,6 +54,7 @@ from PySide6.QtWidgets import (
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
@@ -74,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,
|
||||
@@ -82,7 +87,7 @@ from mtsmusic import (
|
||||
lrclib_lyrics,
|
||||
lyrics_response,
|
||||
lyrics_text,
|
||||
resolve_stream,
|
||||
fetch_track_file,
|
||||
)
|
||||
from settings import (
|
||||
START_QUEUE_LABELS,
|
||||
@@ -313,6 +318,46 @@ class Task(QRunnable):
|
||||
pass
|
||||
|
||||
|
||||
class ProgressTask(QRunnable):
|
||||
"""Задача, которая умеет присылать прогресс в GUI-поток."""
|
||||
|
||||
class Signals(QObject):
|
||||
progress = Signal(object)
|
||||
done = Signal(object, object)
|
||||
failed = Signal(object, str)
|
||||
|
||||
def __init__(self, fn) -> None:
|
||||
super().__init__()
|
||||
self.fn = fn
|
||||
self.signals = ProgressTask.Signals()
|
||||
self.setAutoDelete(False)
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
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(self, result)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def _emit_progress(self, event: dict) -> None:
|
||||
try:
|
||||
self.signals.progress.emit(event)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def _emit_failed(self, message: str) -> None:
|
||||
try:
|
||||
self.signals.failed.emit(self, message)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
class CoverView(QWidget):
|
||||
"""Обложка со скруглениями и аккуратным плейсхолдером."""
|
||||
|
||||
@@ -549,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 = ""
|
||||
@@ -680,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:
|
||||
@@ -1013,6 +1062,143 @@ 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(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()
|
||||
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)
|
||||
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:
|
||||
@@ -1070,6 +1256,7 @@ class QtMusicApp(QMainWindow):
|
||||
("Избранное", self.show_favorites),
|
||||
("История", self.show_history),
|
||||
("Поиск", self.show_search),
|
||||
("Скачать", self.show_download),
|
||||
("Текст песни", self.show_lyrics),
|
||||
("Настройки", self.show_settings),
|
||||
):
|
||||
@@ -1191,6 +1378,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
|
||||
@@ -1373,7 +1570,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,
|
||||
)
|
||||
@@ -1383,9 +1580,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 "")
|
||||
@@ -1808,6 +2005,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 "Ничего не найдено"
|
||||
|
||||
Reference in New Issue
Block a user