Merge remote-tracking branch 'origin/main'
# Conflicts: # README.md # mtsmusic.py # qt_app.py
This commit is contained in:
51
README.md
51
README.md
@@ -73,8 +73,15 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
|
|
||||||
История хранится в `.mts-music/history.json` (последние 100 треков).
|
История хранится в `.mts-music/history.json` (последние 100 треков).
|
||||||
Плеер — `ffplay` (или `mpv`, если установлен). Пауза реализована через
|
Плеер — `ffplay` (или `mpv`, если установлен). Пауза реализована через
|
||||||
`SIGSTOP`/`SIGCONT`, а перемотка перезапускает поток с нужной секунды,
|
`SIGSTOP`/`SIGCONT`, а перемотка перезапускает воспроизведение с нужной
|
||||||
поэтому отдельный демон не нужен.
|
секунды, поэтому отдельный демон не нужен.
|
||||||
|
|
||||||
|
Треки не транслируются напрямую: сначала они скачиваются в локальный кэш
|
||||||
|
`.mts-music/cache/` (до 512 МБ, старые файлы удаляются автоматически), а плеер
|
||||||
|
играет уже готовый MP3. Так надёжнее — сервер Яндекса закрывает соединение,
|
||||||
|
если клиент читает поток медленнее реального времени, из-за чего трек
|
||||||
|
обрывался через полминуты и клиент переключался на следующий. Заодно кэш
|
||||||
|
убирает задержку при повторном прослушивании и делает перемотку мгновенной.
|
||||||
|
|
||||||
## Qt-приложение
|
## Qt-приложение
|
||||||
|
|
||||||
@@ -101,11 +108,16 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
|
|
||||||
## Скачивание и метаданные
|
## Скачивание и метаданные
|
||||||
|
|
||||||
Скачать трек можно из меню (TUI: «Скачать трек»/«Скачать список»; Qt: те же
|
В меню обоих клиентов пункт «Скачать» открывает выбор: текущий трек, всё
|
||||||
пункты в боковой панели), клавишей `d` на экране списка в TUI или командой
|
избранное, 20 треков «Моей волны» или результаты поиска; папка выбирается
|
||||||
`download`. Файлы кладутся в папку `download_dir` (`~/Music/MTS Music` по
|
диалогом в Qt и вводится вручную в TUI, прогресс и число ошибок видны на
|
||||||
умолчанию), имя — `Исполнитель - Название.mp3`; существующие файлы не
|
экране. Быстрые пункты «Скачать трек»/«Скачать список» (и клавиша `d` на
|
||||||
перезаписываются, при совпадении добавляется ` (2)`, ` (3)` и т.д.
|
экране списка в TUI) сразу кладут в папку `download_dir` без диалога. В CLI
|
||||||
|
это команда `download` с теми же источниками (`--likes`, `--wave`, `--search`).
|
||||||
|
|
||||||
|
Файлы кладутся в папку `download_dir` (`~/Music/MTS Music` по умолчанию),
|
||||||
|
имя — `Исполнитель - Название.mp3`; существующие файлы не перезаписываются,
|
||||||
|
при совпадении добавляется ` (2)`, ` (3)` и т.д. (`--force` перезаписывает).
|
||||||
|
|
||||||
В скачанный файл пишется максимум метаданных ID3v2.3 (через `mutagen`, он в
|
В скачанный файл пишется максимум метаданных ID3v2.3 (через `mutagen`, он в
|
||||||
`requirements.txt`):
|
`requirements.txt`):
|
||||||
@@ -183,10 +195,12 @@ D-Bus или без `dbus-next` приложение просто работае
|
|||||||
# сразу слушать подборку (Ctrl-C — остановить)
|
# сразу слушать подборку (Ctrl-C — остановить)
|
||||||
.venv/bin/python mtsmusic.py mix -n 5 --play
|
.venv/bin/python mtsmusic.py mix -n 5 --play
|
||||||
|
|
||||||
# избранное: добавить / убрать / показать
|
# избранное: добавить / убрать / показать текстом
|
||||||
.venv/bin/python mtsmusic.py like 12345678
|
.venv/bin/python mtsmusic.py like 12345678
|
||||||
.venv/bin/python mtsmusic.py unlike 12345678
|
.venv/bin/python mtsmusic.py unlike 12345678
|
||||||
.venv/bin/python mtsmusic.py likes -n 20
|
.venv/bin/python mtsmusic.py likes # все любимые треки
|
||||||
|
.venv/bin/python mtsmusic.py likes -n 20 # только первые 20
|
||||||
|
.venv/bin/python mtsmusic.py likes --ids # только ID, для скриптов
|
||||||
|
|
||||||
# текст песни: показать, сохранить, или с таймкодами
|
# текст песни: показать, сохранить, или с таймкодами
|
||||||
.venv/bin/python mtsmusic.py lyrics 12345678
|
.venv/bin/python mtsmusic.py lyrics 12345678
|
||||||
@@ -205,9 +219,19 @@ D-Bus или без `dbus-next` приложение просто работае
|
|||||||
# скачивание: в папку из download_dir, имя «Исполнитель - Название.mp3»
|
# скачивание: в папку из download_dir, имя «Исполнитель - Название.mp3»
|
||||||
.venv/bin/python mtsmusic.py download 12345678
|
.venv/bin/python mtsmusic.py download 12345678
|
||||||
|
|
||||||
# скачивание в конкретную папку или файл
|
# несколько треков или своя папка
|
||||||
.venv/bin/python mtsmusic.py download 12345678 -d ~/Music
|
.venv/bin/python mtsmusic.py download 12345678 87654321 -d ~/Music/MTS
|
||||||
|
|
||||||
|
# как в мобильном приложении: избранное, волна или результаты поиска
|
||||||
|
.venv/bin/python mtsmusic.py download --likes -d ~/Music/MTS
|
||||||
|
.venv/bin/python mtsmusic.py download --wave -n 20 -d ~/Music/MTS
|
||||||
|
.venv/bin/python mtsmusic.py download --search "Кино" -n 10 -d ~/Music/MTS
|
||||||
|
|
||||||
|
# один трек с явным именем файла, перезапись существующих
|
||||||
.venv/bin/python mtsmusic.py download 12345678 -o track.mp3
|
.venv/bin/python mtsmusic.py download 12345678 -o track.mp3
|
||||||
|
.venv/bin/python mtsmusic.py download --likes -d ~/Music/MTS --force
|
||||||
|
|
||||||
|
# то же самое есть в меню TUI и Qt — пункт «Скачать»
|
||||||
|
|
||||||
# без ID3-тегов (быстрее, без обложки и текста)
|
# без ID3-тегов (быстрее, без обложки и текста)
|
||||||
.venv/bin/python mtsmusic.py download 12345678 --no-tags
|
.venv/bin/python mtsmusic.py download 12345678 --no-tags
|
||||||
@@ -252,6 +276,11 @@ TUI нужно запускать в настоящем терминале (stdi
|
|||||||
- Использовать только для личного прослушивания, не перераздавать контент.
|
- Использовать только для личного прослушивания, не перераздавать контент.
|
||||||
- Если `api.music.yandex.net` недоступен, проверь IPv6: клиент принудительно
|
- Если `api.music.yandex.net` недоступен, проверь IPv6: клиент принудительно
|
||||||
ходит по IPv4.
|
ходит по IPv4.
|
||||||
|
- Кэш треков лежит в `.mts-music/cache/`. Его можно удалить в любой момент —
|
||||||
|
при следующем запуске треки скачаются заново.
|
||||||
|
- `download` (и пункт «Скачать» в TUI/Qt) по умолчанию не перезаписывает уже
|
||||||
|
скачанные файлы: повторный запуск докачает только новое. Флаг `--force`
|
||||||
|
в CLI включает перезапись.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
140
downloads.py
140
downloads.py
@@ -1,140 +0,0 @@
|
|||||||
"""Скачивание треков в MP3: имена файлов, папка и прогресс.
|
|
||||||
|
|
||||||
Как в мобильном приложении: трек сохраняется в папку из настройки
|
|
||||||
``download_dir``, имя файла — «Исполнитель - Название.mp3», а уже
|
|
||||||
существующие файлы не перезаписываются (добавляется « (2)» и т.д.).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from media import track_artist
|
|
||||||
from mtsmusic import USER_AGENT, force_ipv4, resolve_stream
|
|
||||||
|
|
||||||
log = logging.getLogger("downloads")
|
|
||||||
|
|
||||||
# on_progress(downloaded_bytes, total_bytes); total может быть 0.
|
|
||||||
ProgressCallback = Callable[[int, int], None]
|
|
||||||
# on_item(index, total, track) — перед началом каждого трека.
|
|
||||||
ItemCallback = Callable[[int, int, dict], None]
|
|
||||||
|
|
||||||
_INVALID = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_filename(name: str, fallback: str = "track") -> str:
|
|
||||||
"""Убирает из имени символы, недопустимые в имени файла."""
|
|
||||||
clean = _INVALID.sub("_", str(name or ""))
|
|
||||||
clean = re.sub(r"\s+", " ", clean).strip().strip(".")
|
|
||||||
return clean[:120].strip() or fallback
|
|
||||||
|
|
||||||
|
|
||||||
def track_filename(track: dict) -> str:
|
|
||||||
"""«Исполнитель - Название.mp3» с запасным вариантом по id."""
|
|
||||||
title = str(track.get("title") or "").strip()
|
|
||||||
artist = track_artist(track)
|
|
||||||
if artist and artist != "?":
|
|
||||||
stem = f"{artist} - {title}" if title else artist
|
|
||||||
else:
|
|
||||||
stem = title
|
|
||||||
return sanitize_filename(stem, str(track.get("id") or "track")) + ".mp3"
|
|
||||||
|
|
||||||
|
|
||||||
def unique_path(directory: Path, filename: str) -> Path:
|
|
||||||
"""Путь, которого ещё нет: при совпадении добавляет « (2)», « (3)»."""
|
|
||||||
path = directory / filename
|
|
||||||
if not path.exists():
|
|
||||||
return path
|
|
||||||
stem, suffix = path.stem, path.suffix
|
|
||||||
for index in range(2, 1000):
|
|
||||||
candidate = directory / f"{stem} ({index}){suffix}"
|
|
||||||
if not candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def stream_to_file(
|
|
||||||
url: str,
|
|
||||||
output: Path,
|
|
||||||
on_progress: ProgressCallback | None = None,
|
|
||||||
) -> Path:
|
|
||||||
"""Скачивает поток по ссылке в конкретный файл."""
|
|
||||||
output = Path(output).expanduser()
|
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
force_ipv4()
|
|
||||||
with requests.get(
|
|
||||||
url,
|
|
||||||
stream=True,
|
|
||||||
headers={"User-Agent": USER_AGENT},
|
|
||||||
timeout=60,
|
|
||||||
) as response:
|
|
||||||
response.raise_for_status()
|
|
||||||
total = int(response.headers.get("Content-Length") or 0)
|
|
||||||
done = 0
|
|
||||||
with output.open("wb") as handle:
|
|
||||||
for chunk in response.iter_content(chunk_size=256 * 1024):
|
|
||||||
if not chunk:
|
|
||||||
continue
|
|
||||||
handle.write(chunk)
|
|
||||||
done += len(chunk)
|
|
||||||
if on_progress:
|
|
||||||
on_progress(done, total)
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def download_track(
|
|
||||||
api,
|
|
||||||
track: dict,
|
|
||||||
dest_dir: Path | str,
|
|
||||||
on_progress: ProgressCallback | None = None,
|
|
||||||
with_tags: bool = True,
|
|
||||||
) -> Path:
|
|
||||||
"""Скачивает один трек в папку и возвращает путь к файлу."""
|
|
||||||
url, _item = resolve_stream(api, str(track.get("id")))
|
|
||||||
dest_dir = Path(dest_dir).expanduser()
|
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
output = unique_path(dest_dir, track_filename(track))
|
|
||||||
output = stream_to_file(url, output, on_progress)
|
|
||||||
if with_tags:
|
|
||||||
tag_downloaded(api, track, output)
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def tag_downloaded(api, track: dict, path: Path) -> None:
|
|
||||||
"""Проставляет максимум метаданных; ошибки тегов не срывают загрузку."""
|
|
||||||
try:
|
|
||||||
from tags import tag_track
|
|
||||||
except ImportError:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
tag_track(api, track, path)
|
|
||||||
except Exception: # noqa: BLE001 - теги не должны ломать скачивание
|
|
||||||
log.warning("не удалось записать теги для %s", path, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def download_many(
|
|
||||||
api,
|
|
||||||
tracks: list[dict],
|
|
||||||
dest_dir: Path | str,
|
|
||||||
on_item: ItemCallback | None = None,
|
|
||||||
on_progress: ProgressCallback | None = None,
|
|
||||||
with_tags: bool = True,
|
|
||||||
) -> list[Path]:
|
|
||||||
"""Скачивает список треков; сбой одного не останавливает остальные."""
|
|
||||||
saved: list[Path] = []
|
|
||||||
total = len(tracks)
|
|
||||||
for index, track in enumerate(tracks, 1):
|
|
||||||
if on_item:
|
|
||||||
on_item(index, total, track)
|
|
||||||
try:
|
|
||||||
saved.append(
|
|
||||||
download_track(
|
|
||||||
api, track, dest_dir, on_progress, with_tags))
|
|
||||||
except (SystemExit, requests.RequestException, OSError):
|
|
||||||
continue
|
|
||||||
return saved
|
|
||||||
700
mtsmusic.py
700
mtsmusic.py
File diff suppressed because it is too large
Load Diff
264
qt_app.py
264
qt_app.py
@@ -4,6 +4,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import (
|
from PySide6.QtCore import (
|
||||||
QEasingCurve,
|
QEasingCurve,
|
||||||
@@ -53,6 +54,7 @@ from PySide6.QtWidgets import (
|
|||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QProgressBar,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSlider,
|
QSlider,
|
||||||
@@ -75,6 +77,8 @@ from media import (
|
|||||||
from mtsmusic import (
|
from mtsmusic import (
|
||||||
USER_AGENT,
|
USER_AGENT,
|
||||||
Api,
|
Api,
|
||||||
|
collect_download_tracks,
|
||||||
|
download_tracks,
|
||||||
fetch_liked_tracks,
|
fetch_liked_tracks,
|
||||||
fetch_mix_batch,
|
fetch_mix_batch,
|
||||||
fmt_duration,
|
fmt_duration,
|
||||||
@@ -83,7 +87,7 @@ from mtsmusic import (
|
|||||||
lrclib_lyrics,
|
lrclib_lyrics,
|
||||||
lyrics_response,
|
lyrics_response,
|
||||||
lyrics_text,
|
lyrics_text,
|
||||||
resolve_stream,
|
fetch_track_file,
|
||||||
)
|
)
|
||||||
from settings import (
|
from settings import (
|
||||||
START_QUEUE_LABELS,
|
START_QUEUE_LABELS,
|
||||||
@@ -314,49 +318,42 @@ class Task(QRunnable):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DownloadTask(QRunnable):
|
class ProgressTask(QRunnable):
|
||||||
"""Скачивает список треков в фоне и сообщает о прогрессе."""
|
"""Задача, которая умеет присылать прогресс в GUI-поток."""
|
||||||
|
|
||||||
class Signals(QObject):
|
class Signals(QObject):
|
||||||
progress = Signal(str)
|
progress = Signal(object)
|
||||||
done = Signal(int, int, str)
|
done = Signal(object, object)
|
||||||
failed = Signal(str)
|
failed = Signal(object, str)
|
||||||
|
|
||||||
def __init__(self, api, tracks: list[dict], dest: str) -> None:
|
def __init__(self, fn) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.api = api
|
self.fn = fn
|
||||||
self.tracks = tracks
|
self.signals = ProgressTask.Signals()
|
||||||
self.dest = dest
|
|
||||||
self.signals = DownloadTask.Signals()
|
|
||||||
self.setAutoDelete(False)
|
self.setAutoDelete(False)
|
||||||
|
|
||||||
def run(self) -> None:
|
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:
|
try:
|
||||||
saved = download_many(
|
result = self.fn(self._emit_progress)
|
||||||
self.api, self.tracks, self.dest, on_item=on_item)
|
except SystemExit as exc:
|
||||||
|
self._emit_failed(str(exc) or "ошибка")
|
||||||
except Exception as exc: # noqa: BLE001 - сообщение показываем в UI
|
except Exception as exc: # noqa: BLE001 - сообщение показываем в UI
|
||||||
self._emit_failed(str(exc) or exc.__class__.__name__)
|
self._emit_failed(str(exc) or exc.__class__.__name__)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
self.signals.done.emit(len(saved), len(self.tracks), self.dest)
|
self.signals.done.emit(self, result)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _emit(self, text: str) -> None:
|
def _emit_progress(self, event: dict) -> None:
|
||||||
try:
|
try:
|
||||||
self.signals.progress.emit(text)
|
self.signals.progress.emit(event)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _emit_failed(self, message: str) -> None:
|
def _emit_failed(self, message: str) -> None:
|
||||||
try:
|
try:
|
||||||
self.signals.failed.emit(message)
|
self.signals.failed.emit(self, message)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -597,6 +594,7 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.current_index = -1
|
self.current_index = -1
|
||||||
self.liked_ids: set[str] = set()
|
self.liked_ids: set[str] = set()
|
||||||
self.favorites: list[dict] = []
|
self.favorites: list[dict] = []
|
||||||
|
self.search_results: list[dict] = []
|
||||||
self.history: list[dict] = load_history()
|
self.history: list[dict] = load_history()
|
||||||
self.cover_cache: dict[str, QPixmap] = {}
|
self.cover_cache: dict[str, QPixmap] = {}
|
||||||
self.current_cover_url = ""
|
self.current_cover_url = ""
|
||||||
@@ -617,7 +615,6 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.pool = QThreadPool(self)
|
self.pool = QThreadPool(self)
|
||||||
self.pool.setMaxThreadCount(4)
|
self.pool.setMaxThreadCount(4)
|
||||||
self.covers = QNetworkAccessManager(self)
|
self.covers = QNetworkAccessManager(self)
|
||||||
self._download_tasks: set[DownloadTask] = set()
|
|
||||||
|
|
||||||
self.player = QMediaPlayer(self)
|
self.player = QMediaPlayer(self)
|
||||||
self.audio = QAudioOutput(self)
|
self.audio = QAudioOutput(self)
|
||||||
@@ -729,6 +726,9 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.settings_page = self._build_settings_page()
|
self.settings_page = self._build_settings_page()
|
||||||
self.pages.addWidget(self.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()
|
self._setup_tab_order()
|
||||||
|
|
||||||
def _setup_tab_order(self) -> None:
|
def _setup_tab_order(self) -> None:
|
||||||
@@ -1078,6 +1078,150 @@ class QtMusicApp(QMainWindow):
|
|||||||
layout.addStretch(1)
|
layout.addStretch(1)
|
||||||
return page
|
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:
|
def update_setting(self, key: str, value) -> None:
|
||||||
@@ -1118,14 +1262,14 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.download_dir_edit.setText(chosen)
|
self.download_dir_edit.setText(chosen)
|
||||||
self.toast(f"Папка загрузок: {chosen}")
|
self.toast(f"Папка загрузок: {chosen}")
|
||||||
|
|
||||||
# -- скачивание -------------------------------------------------------
|
# -- быстрое скачивание из меню ---------------------------------------
|
||||||
|
|
||||||
def download_current(self) -> None:
|
def download_current(self) -> None:
|
||||||
self.close_drawer()
|
self.close_drawer()
|
||||||
if not self.current:
|
if not self.current:
|
||||||
self.toast("Сначала выбери трек")
|
self.toast("Сначала выбери трек")
|
||||||
return
|
return
|
||||||
self.download_tracks([self.current], "трек")
|
self._quick_download([self.current])
|
||||||
|
|
||||||
def download_queue(self) -> None:
|
def download_queue(self) -> None:
|
||||||
self.close_drawer()
|
self.close_drawer()
|
||||||
@@ -1133,36 +1277,32 @@ class QtMusicApp(QMainWindow):
|
|||||||
if not tracks:
|
if not tracks:
|
||||||
self.toast("Список пуст")
|
self.toast("Список пуст")
|
||||||
return
|
return
|
||||||
self.download_tracks(tracks, "список")
|
self._quick_download(list(tracks))
|
||||||
|
|
||||||
def download_tracks(self, tracks: list[dict], label: str) -> None:
|
def _quick_download(self, tracks: list[dict]) -> None:
|
||||||
if not tracks:
|
|
||||||
return
|
|
||||||
dest = str(self.settings.get("download_dir") or "")
|
dest = str(self.settings.get("download_dir") or "")
|
||||||
task = DownloadTask(self.api, list(tracks), dest)
|
if not dest:
|
||||||
task.signals.progress.connect(self.set_top_status)
|
self.toast("Укажи папку загрузок в настройках")
|
||||||
task.signals.done.connect(
|
return
|
||||||
lambda saved, total, folder, t=task:
|
destination = Path(dest).expanduser()
|
||||||
self._download_done(t, saved, total, folder))
|
self.download_btn.setEnabled(False)
|
||||||
task.signals.failed.connect(
|
self.download_progress.setRange(0, 0)
|
||||||
lambda message, t=task: self._download_failed(t, message))
|
self.download_status.setText("Готовлю список…")
|
||||||
self._download_tasks.add(task)
|
self.set_top_status("Скачиваю…")
|
||||||
self.set_top_status(f"Скачиваю {label}…")
|
self.run_progress_task(
|
||||||
self.pool.start(task)
|
lambda progress: self._download_tracks_job(
|
||||||
|
tracks, destination, progress),
|
||||||
|
self.download_done,
|
||||||
|
self.download_failed,
|
||||||
|
)
|
||||||
|
|
||||||
def _download_done(
|
def _download_tracks_job(
|
||||||
self, task: DownloadTask, saved: int, total: int, dest: str
|
self, tracks: list[dict], destination: Path, progress
|
||||||
) -> None:
|
) -> tuple:
|
||||||
self._download_tasks.discard(task)
|
saved, skipped, failed = download_tracks(
|
||||||
self.set_top_status(self.queue_name)
|
self.api, tracks, destination, on_event=progress
|
||||||
if saved:
|
)
|
||||||
self.toast(f"Скачано {saved}/{total} → {dest}")
|
return saved, skipped, failed, destination
|
||||||
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:
|
def show_settings(self) -> None:
|
||||||
self._show(self.settings_page)
|
self._show(self.settings_page)
|
||||||
@@ -1191,6 +1331,7 @@ class QtMusicApp(QMainWindow):
|
|||||||
("Избранное", self.show_favorites),
|
("Избранное", self.show_favorites),
|
||||||
("История", self.show_history),
|
("История", self.show_history),
|
||||||
("Поиск", self.show_search),
|
("Поиск", self.show_search),
|
||||||
|
("Скачать", self.show_download),
|
||||||
("Текст песни", self.show_lyrics),
|
("Текст песни", self.show_lyrics),
|
||||||
("Скачать трек", self.download_current),
|
("Скачать трек", self.download_current),
|
||||||
("Скачать список", self.download_queue),
|
("Скачать список", self.download_queue),
|
||||||
@@ -1314,6 +1455,16 @@ class QtMusicApp(QMainWindow):
|
|||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
self.pool.start(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:
|
def _task_done(self, task: Task, result) -> None:
|
||||||
if task not in self._tasks:
|
if task not in self._tasks:
|
||||||
return
|
return
|
||||||
@@ -1496,7 +1647,7 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.set_top_status("Загрузка…")
|
self.set_top_status("Загрузка…")
|
||||||
|
|
||||||
self.run_task(
|
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),
|
lambda result: self.start_playback(token, track, result),
|
||||||
self.on_error,
|
self.on_error,
|
||||||
)
|
)
|
||||||
@@ -1506,9 +1657,9 @@ class QtMusicApp(QMainWindow):
|
|||||||
) -> None:
|
) -> None:
|
||||||
if token != self.play_token:
|
if token != self.play_token:
|
||||||
return
|
return
|
||||||
url, item = result
|
path, item = result
|
||||||
self._preview = bool(item.get("preview"))
|
self._preview = bool(item.get("preview"))
|
||||||
self.player.setSource(QUrl(url))
|
self.player.setSource(QUrl.fromLocalFile(str(path)))
|
||||||
self.player.play()
|
self.player.play()
|
||||||
self.history = record_history(track)
|
self.history = record_history(track)
|
||||||
title = str(track.get("title") or "")
|
title = str(track.get("title") or "")
|
||||||
@@ -1931,6 +2082,7 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.run_task(search, self.apply_search, self.search_failed)
|
self.run_task(search, self.apply_search, self.search_failed)
|
||||||
|
|
||||||
def apply_search(self, tracks: list[dict]) -> None:
|
def apply_search(self, tracks: list[dict]) -> None:
|
||||||
|
self.search_results = tracks
|
||||||
self.fill_track_list(self.search_list, tracks, "Поиск")
|
self.fill_track_list(self.search_list, tracks, "Поиск")
|
||||||
self.search_status.setText(
|
self.search_status.setText(
|
||||||
f"{len(tracks)} результат(ов)" if tracks else "Ничего не найдено"
|
f"{len(tracks)} результат(ов)" if tracks else "Ничего не найдено"
|
||||||
|
|||||||
179
tui.py
179
tui.py
@@ -4,6 +4,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -34,13 +35,15 @@ from media import (
|
|||||||
from mtsmusic import (
|
from mtsmusic import (
|
||||||
USER_AGENT,
|
USER_AGENT,
|
||||||
Api,
|
Api,
|
||||||
|
collect_download_tracks,
|
||||||
|
download_tracks,
|
||||||
|
fetch_track_file,
|
||||||
fmt_duration,
|
fmt_duration,
|
||||||
force_ipv4,
|
force_ipv4,
|
||||||
get_uid,
|
get_uid,
|
||||||
lrclib_lyrics,
|
lrclib_lyrics,
|
||||||
lyrics_response,
|
lyrics_response,
|
||||||
lyrics_text,
|
lyrics_text,
|
||||||
resolve_stream,
|
|
||||||
)
|
)
|
||||||
from player import MusicPlayer
|
from player import MusicPlayer
|
||||||
from mpris import MprisService, track_object_path
|
from mpris import MprisService, track_object_path
|
||||||
@@ -307,6 +310,7 @@ class SearchScreen(Screen):
|
|||||||
|
|
||||||
def fill(self, tracks: list[dict]) -> None:
|
def fill(self, tracks: list[dict]) -> None:
|
||||||
self.results = tracks
|
self.results = tracks
|
||||||
|
self.app.search_results = tracks
|
||||||
table = self.query_one("#list-table", DataTable)
|
table = self.query_one("#list-table", DataTable)
|
||||||
table.clear()
|
table.clear()
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
@@ -486,6 +490,138 @@ class SettingsScreen(Screen):
|
|||||||
self.refresh_values()
|
self.refresh_values()
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadScreen(Screen):
|
||||||
|
"""Скачивание текущего трека, избранного, волны или результатов поиска."""
|
||||||
|
|
||||||
|
BINDINGS = [Binding("escape", "back", "Назад")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield make_topbar("Скачать")
|
||||||
|
yield Static("Папка для сохранения:", id="screen-status")
|
||||||
|
yield Input(
|
||||||
|
value=str(
|
||||||
|
self.app.settings.get("download_dir")
|
||||||
|
or Path.home() / "Music" / "MTS"
|
||||||
|
),
|
||||||
|
id="download-dir",
|
||||||
|
)
|
||||||
|
with Horizontal(id="download-actions"):
|
||||||
|
yield Button("Текущий трек", id="dl-current")
|
||||||
|
yield Button("Избранное", id="dl-likes")
|
||||||
|
yield Button("Моя волна (20)", id="dl-wave")
|
||||||
|
yield Button(
|
||||||
|
"Результаты поиска",
|
||||||
|
id="dl-search",
|
||||||
|
disabled=not self.app.search_results,
|
||||||
|
)
|
||||||
|
yield Static("", id="download-status")
|
||||||
|
yield ProgressBar(
|
||||||
|
total=100,
|
||||||
|
show_eta=False,
|
||||||
|
show_percentage=True,
|
||||||
|
id="download-progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#back")
|
||||||
|
def back_pressed(self) -> None:
|
||||||
|
self.action_back()
|
||||||
|
|
||||||
|
@on(Button.Pressed)
|
||||||
|
def pressed(self, event: Button.Pressed) -> None:
|
||||||
|
bid = event.button.id or ""
|
||||||
|
kinds = {
|
||||||
|
"dl-current": "current",
|
||||||
|
"dl-likes": "likes",
|
||||||
|
"dl-wave": "wave",
|
||||||
|
"dl-search": "search",
|
||||||
|
}
|
||||||
|
kind = kinds.get(bid)
|
||||||
|
if not kind:
|
||||||
|
return
|
||||||
|
raw = self.query_one("#download-dir", Input).value.strip()
|
||||||
|
if not raw:
|
||||||
|
self.notify("Укажи папку для сохранения", severity="warning")
|
||||||
|
return
|
||||||
|
if kind == "current" and not self.app.current:
|
||||||
|
self.notify("Сначала выбери трек", severity="warning")
|
||||||
|
return
|
||||||
|
self.query_one("#download-progress", ProgressBar).update(progress=0)
|
||||||
|
self.query_one("#download-status", Static).update("Готовлю список…")
|
||||||
|
self.run_download(kind, Path(raw).expanduser())
|
||||||
|
|
||||||
|
def set_status(self, text: str) -> None:
|
||||||
|
if not self.is_mounted:
|
||||||
|
return
|
||||||
|
self.query_one("#download-status", Static).update(text)
|
||||||
|
|
||||||
|
def download_event(self, event: dict) -> None:
|
||||||
|
if not self.is_mounted:
|
||||||
|
return
|
||||||
|
track = event["track"]
|
||||||
|
title = f"{track_artist(track)} — {track.get('title') or '?'}"
|
||||||
|
self.query_one("#download-progress", ProgressBar).update(
|
||||||
|
progress=event["index"], total=event["total"]
|
||||||
|
)
|
||||||
|
self.set_status(f"[{event['index']}/{event['total']}] {title}")
|
||||||
|
|
||||||
|
def done(self, saved: int, skipped: int, failed: int,
|
||||||
|
directory: Path) -> None:
|
||||||
|
if not self.is_mounted:
|
||||||
|
return
|
||||||
|
message = (
|
||||||
|
f"Готово: {saved} сохранено, {skipped} уже было, "
|
||||||
|
f"{failed} ошибок"
|
||||||
|
)
|
||||||
|
self.set_status(f"{message}\n{directory}")
|
||||||
|
self.notify(message, timeout=6)
|
||||||
|
|
||||||
|
def failed(self, message: str) -> None:
|
||||||
|
if not self.is_mounted:
|
||||||
|
return
|
||||||
|
self.set_status(f"Ошибка: {message}")
|
||||||
|
self.notify(message, severity="error", timeout=8)
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True, group="download")
|
||||||
|
def run_download(self, kind: str, directory: Path) -> None:
|
||||||
|
api = self.app.api
|
||||||
|
if not api:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if kind == "current":
|
||||||
|
track = self.app.current
|
||||||
|
if not track:
|
||||||
|
self.app.call_from_thread(self.failed, "трек не выбран")
|
||||||
|
return
|
||||||
|
tracks = [track]
|
||||||
|
elif kind == "likes":
|
||||||
|
self.app.call_from_thread(
|
||||||
|
self.set_status, "Получаю избранное…")
|
||||||
|
tracks = collect_download_tracks(api, likes=True)
|
||||||
|
elif kind == "wave":
|
||||||
|
self.app.call_from_thread(
|
||||||
|
self.set_status, "Получаю «Мою волну»…")
|
||||||
|
tracks = collect_download_tracks(api, wave=True, number=20)
|
||||||
|
else:
|
||||||
|
tracks = list(self.app.search_results)
|
||||||
|
if not tracks:
|
||||||
|
self.app.call_from_thread(self.failed, "нечего скачивать")
|
||||||
|
return
|
||||||
|
|
||||||
|
def progress(event: dict) -> None:
|
||||||
|
self.app.call_from_thread(self.download_event, event)
|
||||||
|
|
||||||
|
saved, skipped, failed = download_tracks(
|
||||||
|
api, tracks, directory, on_event=progress
|
||||||
|
)
|
||||||
|
self.app.call_from_thread(
|
||||||
|
self.done, saved, skipped, failed, directory)
|
||||||
|
except SystemExit as exc:
|
||||||
|
self.app.call_from_thread(self.failed, str(exc))
|
||||||
|
|
||||||
|
|
||||||
class MenuScreen(ModalScreen[str | None]):
|
class MenuScreen(ModalScreen[str | None]):
|
||||||
BINDINGS = [Binding("escape", "close", "Закрыть")]
|
BINDINGS = [Binding("escape", "close", "Закрыть")]
|
||||||
|
|
||||||
@@ -496,8 +632,9 @@ class MenuScreen(ModalScreen[str | None]):
|
|||||||
yield Button("Избранное", id="m-favorites")
|
yield Button("Избранное", id="m-favorites")
|
||||||
yield Button("История", id="m-history")
|
yield Button("История", id="m-history")
|
||||||
yield Button("Поиск", id="m-search")
|
yield Button("Поиск", id="m-search")
|
||||||
|
yield Button("Скачать", id="m-download")
|
||||||
yield Button("Текст песни", id="m-lyrics")
|
yield Button("Текст песни", id="m-lyrics")
|
||||||
yield Button("Скачать трек", id="m-download")
|
yield Button("Скачать трек", id="m-download-current")
|
||||||
yield Button("Скачать список", id="m-download-list")
|
yield Button("Скачать список", id="m-download-list")
|
||||||
yield Button("Настройки", id="m-settings")
|
yield Button("Настройки", id="m-settings")
|
||||||
yield Static("", id="menu-spacer")
|
yield Static("", id="menu-spacer")
|
||||||
@@ -712,6 +849,12 @@ class MusicTui(App):
|
|||||||
.stoggle { width: 16; min-width: 12; }
|
.stoggle { width: 16; min-width: 12; }
|
||||||
#dir-value { padding: 0 0 1 0; color: #8a8a8a; }
|
#dir-value { padding: 0 0 1 0; color: #8a8a8a; }
|
||||||
#settings-path { padding: 1 0; color: #6a6a6a; }
|
#settings-path { padding: 1 0; color: #6a6a6a; }
|
||||||
|
|
||||||
|
#download-dir { margin: 0 1; }
|
||||||
|
#download-actions { height: 3; padding: 0 1; margin-top: 1; }
|
||||||
|
#download-actions Button { margin-right: 1; }
|
||||||
|
#download-status { height: 2; padding: 1 1 0 1; color: #d0d0d0; }
|
||||||
|
#download-progress { margin: 1 1 0 1; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
@@ -742,6 +885,7 @@ class MusicTui(App):
|
|||||||
"volume", 80)))
|
"volume", 80)))
|
||||||
self.wave: list[dict] = []
|
self.wave: list[dict] = []
|
||||||
self.favorites: list[dict] = []
|
self.favorites: list[dict] = []
|
||||||
|
self.search_results: list[dict] = []
|
||||||
self.liked_ids: set[str] = set()
|
self.liked_ids: set[str] = set()
|
||||||
self.history: list[dict] = load_history()
|
self.history: list[dict] = load_history()
|
||||||
self.queue: list[dict] = []
|
self.queue: list[dict] = []
|
||||||
@@ -1077,7 +1221,7 @@ class MusicTui(App):
|
|||||||
if not api:
|
if not api:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
url, item = resolve_stream(api, str(track.get("id")))
|
path, item = fetch_track_file(api, str(track.get("id")))
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
self.call_from_thread(
|
self.call_from_thread(
|
||||||
self.notify,
|
self.notify,
|
||||||
@@ -1085,7 +1229,8 @@ class MusicTui(App):
|
|||||||
severity="error")
|
severity="error")
|
||||||
return
|
return
|
||||||
self.call_from_thread(
|
self.call_from_thread(
|
||||||
self.start_playback, token, track, url, bool(item.get("preview"))
|
self.start_playback, token, track, str(path),
|
||||||
|
bool(item.get("preview")),
|
||||||
)
|
)
|
||||||
|
|
||||||
def start_playback(
|
def start_playback(
|
||||||
@@ -1291,9 +1436,11 @@ class MusicTui(App):
|
|||||||
self.push_screen(HistoryScreen(self.history))
|
self.push_screen(HistoryScreen(self.history))
|
||||||
elif action == "m-search":
|
elif action == "m-search":
|
||||||
self.push_screen(SearchScreen())
|
self.push_screen(SearchScreen())
|
||||||
|
elif action == "m-download":
|
||||||
|
self.push_screen(DownloadScreen())
|
||||||
elif action == "m-lyrics":
|
elif action == "m-lyrics":
|
||||||
self.action_show_lyrics()
|
self.action_show_lyrics()
|
||||||
elif action == "m-download":
|
elif action == "m-download-current":
|
||||||
self.action_download_current()
|
self.action_download_current()
|
||||||
elif action == "m-download-list":
|
elif action == "m-download-list":
|
||||||
self.action_download_list()
|
self.action_download_list()
|
||||||
@@ -1331,22 +1478,30 @@ class MusicTui(App):
|
|||||||
|
|
||||||
@work(thread=True, exclusive=False, group="download")
|
@work(thread=True, exclusive=False, group="download")
|
||||||
def download_worker(self, tracks: list[dict], label: str) -> None:
|
def download_worker(self, tracks: list[dict], label: str) -> None:
|
||||||
from downloads import download_many
|
|
||||||
|
|
||||||
dest = str(self.settings.get("download_dir") or "")
|
dest = str(self.settings.get("download_dir") or "")
|
||||||
|
if not dest:
|
||||||
|
self.call_from_thread(
|
||||||
|
self.notify,
|
||||||
|
"Не задана папка загрузок",
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
def on_item(index: int, count: int, track: dict) -> None:
|
def on_event(event: dict) -> None:
|
||||||
title = str(track.get("title") or "")
|
if event.get("status") != "start":
|
||||||
|
return
|
||||||
|
title = str(event["track"].get("title") or "")
|
||||||
self.call_from_thread(
|
self.call_from_thread(
|
||||||
self.set_topstatus,
|
self.set_topstatus,
|
||||||
f"Скачиваю {index}/{count}: {title}",
|
f"Скачиваю {event['index']}/{event['total']}: {title}",
|
||||||
)
|
)
|
||||||
|
|
||||||
saved = download_many(self.api, tracks, dest, on_item=on_item)
|
saved, _skipped, _failed = download_tracks(
|
||||||
|
self.api, tracks, Path(dest).expanduser(), on_event=on_event)
|
||||||
self.call_from_thread(
|
self.call_from_thread(
|
||||||
self.download_finished,
|
self.download_finished,
|
||||||
label,
|
label,
|
||||||
len(saved),
|
saved,
|
||||||
len(tracks),
|
len(tracks),
|
||||||
dest,
|
dest,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user