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:
50
README.md
50
README.md
@@ -1,8 +1,9 @@
|
|||||||
# MTS Music — another client
|
# MTS Music — another client
|
||||||
|
|
||||||
Неофициальный клиент для **своего** аккаунта MTS Music: «Моя волна», поиск,
|
Неофициальный клиент для **своего** аккаунта MTS Music: «Моя волна», поиск,
|
||||||
избранное, тексты песен, воспроизведение и скачивание в MP3. Есть компактный
|
избранное, тексты песен, воспроизведение и скачивание в MP3 с полными
|
||||||
TUI-плеер и настольное Qt-приложение в стиле телефона.
|
ID3-тегами. Есть компактный TUI-плеер и настольное Qt-приложение в стиле
|
||||||
|
телефона.
|
||||||
|
|
||||||
> Только для личного использования с собственной платной подпиской MTS.
|
> Только для личного использования с собственной платной подпиской MTS.
|
||||||
> Проект не связан с МТС и Яндексом; контент не перераспространяется.
|
> Проект не связан с МТС и Яндексом; контент не перераспространяется.
|
||||||
@@ -50,7 +51,7 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже
|
большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже
|
||||||
«Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое
|
«Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое
|
||||||
меню: «Моя волна», избранное, история прослушивания, поиск, текст песни,
|
меню: «Моя волна», избранное, история прослушивания, поиск, текст песни,
|
||||||
«Настройки» и выход.
|
«Скачать трек», «Скачать список», «Настройки» и выход.
|
||||||
|
|
||||||
Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны
|
Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны
|
||||||
(клик по прогрессу перематывает трек).
|
(клик по прогрессу перематывает трек).
|
||||||
@@ -67,6 +68,7 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
| `f` | избранное |
|
| `f` | избранное |
|
||||||
| `h` | история |
|
| `h` | история |
|
||||||
| `s` | поиск |
|
| `s` | поиск |
|
||||||
|
| `d` | скачать выделенный трек в списке |
|
||||||
| `q` | выход |
|
| `q` | выход |
|
||||||
|
|
||||||
История хранится в `.mts-music/history.json` (последние 100 треков).
|
История хранится в `.mts-music/history.json` (последние 100 треков).
|
||||||
@@ -82,7 +84,8 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
|
|
||||||
Отдельное окно в стиле телефона: крупная цветная обложка, кнопка play/pause,
|
Отдельное окно в стиле телефона: крупная цветная обложка, кнопка play/pause,
|
||||||
переключение треков, перемотка, текст песни и избранное. Кнопка «☰» открывает
|
переключение треков, перемотка, текст песни и избранное. Кнопка «☰» открывает
|
||||||
боковую панель с «Моей волной», избранным, историей, поиском и настройками.
|
боковую панель с «Моей волной», избранным, историей, поиском, скачиванием
|
||||||
|
и настройками.
|
||||||
|
|
||||||
Воспроизведение — через `QMediaPlayer`/FFmpeg из PySide6, поэтому работает
|
Воспроизведение — через `QMediaPlayer`/FFmpeg из PySide6, поэтому работает
|
||||||
перемотка и системный звук. По полосе прогресса можно кликнуть в любое место —
|
перемотка и системный звук. По полосе прогресса можно кликнуть в любое место —
|
||||||
@@ -96,6 +99,30 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
«Моя волна» продолжается с последних прослушанных треков (берётся история), а не
|
«Моя волна» продолжается с последних прослушанных треков (берётся история), а не
|
||||||
начинается каждый раз с одного и того же места.
|
начинается каждый раз с одного и того же места.
|
||||||
|
|
||||||
|
## Скачивание и метаданные
|
||||||
|
|
||||||
|
Скачать трек можно из меню (TUI: «Скачать трек»/«Скачать список»; Qt: те же
|
||||||
|
пункты в боковой панели), клавишей `d` на экране списка в TUI или командой
|
||||||
|
`download`. Файлы кладутся в папку `download_dir` (`~/Music/MTS Music` по
|
||||||
|
умолчанию), имя — `Исполнитель - Название.mp3`; существующие файлы не
|
||||||
|
перезаписываются, при совпадении добавляется ` (2)`, ` (3)` и т.д.
|
||||||
|
|
||||||
|
В скачанный файл пишется максимум метаданных ID3v2.3 (через `mutagen`, он в
|
||||||
|
`requirements.txt`):
|
||||||
|
|
||||||
|
- название, все исполнители (`TPE1`), альбом (`TALB`) и альбомный исполнитель
|
||||||
|
(`TPE2`);
|
||||||
|
- номер трека и диска (`TRCK`/`TPOS`), год и дата релиза (`TYER`/`TDAT`),
|
||||||
|
жанр как в API (`TCON`), лейбл (`TPUB`), длительность (`TLEN`);
|
||||||
|
- обложка 600×600 (`APIC`);
|
||||||
|
- текст песни (`USLT`) и синхронный текст (`SYLT`), если он есть: сначала
|
||||||
|
пробуем MTS, затем [LRCLIB](https://lrclib.net);
|
||||||
|
- ссылка на трек (`WOAR`/`COMM`), пометка `explicit` (`ITUNESADVISORY`) и
|
||||||
|
служебные поля с id трека, альбома и `realId` (`TXXX`).
|
||||||
|
|
||||||
|
Без `mutagen` или при `--no-tags` теги не пишутся, скачивание при этом
|
||||||
|
работает как обычно.
|
||||||
|
|
||||||
## Управление из системной панели (MPRIS)
|
## Управление из системной панели (MPRIS)
|
||||||
|
|
||||||
При запуске TUI и Qt-приложение регистрируются в сессионном D-Bus как источник
|
При запуске TUI и Qt-приложение регистрируются в сессионном D-Bus как источник
|
||||||
@@ -134,10 +161,12 @@ D-Bus или без `dbus-next` приложение просто работае
|
|||||||
| `autoplay` | `false` | начинать воспроизведение сразу при запуске |
|
| `autoplay` | `false` | начинать воспроизведение сразу при запуске |
|
||||||
| `start_queue` | `wave` | что открыть первым: `wave`, `favorites` или `history` |
|
| `start_queue` | `wave` | что открыть первым: `wave`, `favorites` или `history` |
|
||||||
| `wave_from_history` | `true` | продолжать «Мою волну» с последних треков |
|
| `wave_from_history` | `true` | продолжать «Мою волну» с последних треков |
|
||||||
|
| `download_dir` | `~/Music/MTS Music` | куда сохранять скачанные MP3 |
|
||||||
|
|
||||||
В Qt-приложении на странице настроек дополнительно выбирается устройство вывода
|
В Qt-приложении на странице настроек дополнительно выбирается устройство вывода
|
||||||
звука. Громкость меняется на лету, остальные настройки применяются к новым
|
звука и папка загрузок (кнопка «Выбрать…»). В TUI путь задаётся в настройках
|
||||||
трекам и при следующем запуске.
|
пунктом «Папка загрузок». Громкость меняется на лету, остальные настройки
|
||||||
|
применяются к новым трекам и при следующем запуске.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
@@ -173,9 +202,16 @@ D-Bus или без `dbus-next` приложение просто работае
|
|||||||
# воспроизведение (нужен mpv или ffplay)
|
# воспроизведение (нужен mpv или ffplay)
|
||||||
.venv/bin/python mtsmusic.py play 12345678
|
.venv/bin/python mtsmusic.py play 12345678
|
||||||
|
|
||||||
# скачивание
|
# скачивание: в папку из download_dir, имя «Исполнитель - Название.mp3»
|
||||||
|
.venv/bin/python mtsmusic.py download 12345678
|
||||||
|
|
||||||
|
# скачивание в конкретную папку или файл
|
||||||
|
.venv/bin/python mtsmusic.py download 12345678 -d ~/Music
|
||||||
.venv/bin/python mtsmusic.py download 12345678 -o track.mp3
|
.venv/bin/python mtsmusic.py download 12345678 -o track.mp3
|
||||||
|
|
||||||
|
# без ID3-тегов (быстрее, без обложки и текста)
|
||||||
|
.venv/bin/python mtsmusic.py download 12345678 --no-tags
|
||||||
|
|
||||||
# настройки
|
# настройки
|
||||||
.venv/bin/python mtsmusic.py settings
|
.venv/bin/python mtsmusic.py settings
|
||||||
.venv/bin/python mtsmusic.py settings -s volume=60 seek_step=10
|
.venv/bin/python mtsmusic.py settings -s volume=60 seek_step=10
|
||||||
|
|||||||
140
downloads.py
Normal file
140
downloads.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""Скачивание треков в 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
|
||||||
2
mpris.py
2
mpris.py
@@ -317,6 +317,8 @@ class MprisService:
|
|||||||
duration_ms: int = 0,
|
duration_ms: int = 0,
|
||||||
art_url: str = "",
|
art_url: str = "",
|
||||||
):
|
):
|
||||||
|
if not HAVE_DBUS:
|
||||||
|
return
|
||||||
metadata: dict[str, Any] = {
|
metadata: dict[str, Any] = {
|
||||||
"mpris:trackid": Variant("o", track_object_path(track_id)),
|
"mpris:trackid": Variant("o", track_object_path(track_id)),
|
||||||
"mpris:length": Variant("x", int(duration_ms) * 1000),
|
"mpris:length": Variant("x", int(duration_ms) * 1000),
|
||||||
|
|||||||
63
mtsmusic.py
63
mtsmusic.py
@@ -872,34 +872,33 @@ def cmd_play(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_download(args: argparse.Namespace) -> None:
|
def cmd_download(args: argparse.Namespace) -> None:
|
||||||
api = Api()
|
from downloads import download_track, stream_to_file, tag_downloaded
|
||||||
print_track_info(api, args.track_id)
|
from settings import get_setting
|
||||||
url, _ = resolve_stream(api, args.track_id)
|
|
||||||
force_ipv4()
|
|
||||||
output = Path(args.output or f"{args.track_id}.mp3")
|
|
||||||
|
|
||||||
with requests.get(
|
api = Api()
|
||||||
url,
|
track = get_track(api, args.track_id)
|
||||||
stream=True,
|
print(fmt_track(track), file=sys.stderr)
|
||||||
headers={"User-Agent": USER_AGENT},
|
|
||||||
timeout=60,
|
def progress(done: int, total: int) -> None:
|
||||||
) as response:
|
if total:
|
||||||
response.raise_for_status()
|
percent = done * 100 // total
|
||||||
total = int(response.headers.get("Content-Length") or 0)
|
print(
|
||||||
done = 0
|
f"\r{percent:3d}% {done // 1024} KiB",
|
||||||
with output.open("wb") as handle:
|
end="",
|
||||||
for chunk in response.iter_content(chunk_size=256 * 1024):
|
file=sys.stderr)
|
||||||
if not chunk:
|
|
||||||
continue
|
with_tags = not args.no_tags
|
||||||
handle.write(chunk)
|
if args.output:
|
||||||
done += len(chunk)
|
# Явный путь: скачиваем ровно в указанный файл, как раньше.
|
||||||
if total:
|
url, _ = resolve_stream(api, args.track_id)
|
||||||
percent = done * 100 // total
|
output = stream_to_file(url, Path(args.output), progress)
|
||||||
kib = done // 1024
|
if with_tags:
|
||||||
print(
|
tag_downloaded(api, track, output)
|
||||||
f"\r{percent:3d}% {kib} KiB",
|
else:
|
||||||
end="",
|
dest = args.dir or get_setting("download_dir")
|
||||||
file=sys.stderr)
|
output = download_track(api, track, dest, progress, with_tags)
|
||||||
|
|
||||||
|
if sys.stderr.isatty():
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
print(f"Сохранено: {output} ({output.stat().st_size} байт)")
|
print(f"Сохранено: {output} ({output.stat().st_size} байт)")
|
||||||
|
|
||||||
@@ -1145,6 +1144,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
download = sub.add_parser("download", help="скачать трек в MP3")
|
download = sub.add_parser("download", help="скачать трек в MP3")
|
||||||
download.add_argument("track_id")
|
download.add_argument("track_id")
|
||||||
download.add_argument("-o", "--output", help="имя файла")
|
download.add_argument("-o", "--output", help="имя файла")
|
||||||
|
download.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--dir",
|
||||||
|
help="папка для сохранения (по умолчанию из download_dir)",
|
||||||
|
)
|
||||||
|
download.add_argument(
|
||||||
|
"--no-tags",
|
||||||
|
action="store_true",
|
||||||
|
help="не записывать ID3-теги (текст, обложка, метаданные)",
|
||||||
|
)
|
||||||
download.set_defaults(func=cmd_download)
|
download.set_defaults(func=cmd_download)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|||||||
123
qt_app.py
123
qt_app.py
@@ -44,6 +44,7 @@ from PySide6.QtWidgets import (
|
|||||||
QAbstractButton,
|
QAbstractButton,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
QFileDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -313,6 +314,53 @@ class Task(QRunnable):
|
|||||||
pass
|
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):
|
class CoverView(QWidget):
|
||||||
"""Обложка со скруглениями и аккуратным плейсхолдером."""
|
"""Обложка со скруглениями и аккуратным плейсхолдером."""
|
||||||
|
|
||||||
@@ -569,6 +617,7 @@ 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)
|
||||||
@@ -1006,6 +1055,22 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.wave_check.toggled.connect(self.on_wave_from_history_toggled)
|
self.wave_check.toggled.connect(self.on_wave_from_history_toggled)
|
||||||
layout.addWidget(self.wave_check)
|
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 = QLabel("Настройки сохраняются в .mts-music/settings.json")
|
||||||
hint.setObjectName("pageStatus")
|
hint.setObjectName("pageStatus")
|
||||||
hint.setWordWrap(True)
|
hint.setWordWrap(True)
|
||||||
@@ -1043,6 +1108,62 @@ class QtMusicApp(QMainWindow):
|
|||||||
def on_wave_from_history_toggled(self, checked: bool) -> None:
|
def on_wave_from_history_toggled(self, checked: bool) -> None:
|
||||||
self.update_setting("wave_from_history", checked)
|
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:
|
def show_settings(self) -> None:
|
||||||
self._show(self.settings_page)
|
self._show(self.settings_page)
|
||||||
|
|
||||||
@@ -1071,6 +1192,8 @@ class QtMusicApp(QMainWindow):
|
|||||||
("История", self.show_history),
|
("История", self.show_history),
|
||||||
("Поиск", self.show_search),
|
("Поиск", self.show_search),
|
||||||
("Текст песни", self.show_lyrics),
|
("Текст песни", self.show_lyrics),
|
||||||
|
("Скачать трек", self.download_current),
|
||||||
|
("Скачать список", self.download_queue),
|
||||||
("Настройки", self.show_settings),
|
("Настройки", self.show_settings),
|
||||||
):
|
):
|
||||||
button = QPushButton(text)
|
button = QPushButton(text)
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ requests>=2.31
|
|||||||
textual>=0.80
|
textual>=0.80
|
||||||
pillow>=10
|
pillow>=10
|
||||||
dbus-next>=0.2.3
|
dbus-next>=0.2.3
|
||||||
|
mutagen>=1.47
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ from mtsmusic import CONFIG_DIR
|
|||||||
|
|
||||||
SETTINGS_PATH = CONFIG_DIR / "settings.json"
|
SETTINGS_PATH = CONFIG_DIR / "settings.json"
|
||||||
|
|
||||||
|
DEFAULT_DOWNLOAD_DIR = str(Path.home() / "Music" / "MTS Music")
|
||||||
|
|
||||||
DEFAULTS: dict[str, Any] = {
|
DEFAULTS: dict[str, Any] = {
|
||||||
"volume": 80, # громкость 0..100
|
"volume": 80, # громкость 0..100
|
||||||
"seek_step": 5, # шаг перемотки стрелками, секунды
|
"seek_step": 5, # шаг перемотки стрелками, секунды
|
||||||
"autoplay": False, # запускать воспроизведение сразу
|
"autoplay": False, # запускать воспроизведение сразу
|
||||||
"start_queue": "wave", # wave | favorites | history
|
"start_queue": "wave", # wave | favorites | history
|
||||||
"wave_from_history": True, # продолжать волну с последних треков
|
"wave_from_history": True, # продолжать волну с последних треков
|
||||||
|
"download_dir": DEFAULT_DOWNLOAD_DIR, # куда сохранять скачанные MP3
|
||||||
}
|
}
|
||||||
|
|
||||||
START_QUEUES = ("wave", "favorites", "history")
|
START_QUEUES = ("wave", "favorites", "history")
|
||||||
@@ -47,6 +50,11 @@ def _coerce(key: str, value: Any) -> Any:
|
|||||||
if key == "start_queue":
|
if key == "start_queue":
|
||||||
text = str(value)
|
text = str(value)
|
||||||
return text if text in START_QUEUES else DEFAULTS[key]
|
return text if text in START_QUEUES else DEFAULTS[key]
|
||||||
|
if key == "download_dir":
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return DEFAULTS[key]
|
||||||
|
return str(Path(text).expanduser())
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
239
tags.py
Normal file
239
tags.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""ID3-теги для скачанных треков: максимум метаданных.
|
||||||
|
|
||||||
|
Пишем ID3v2.3: название, исполнителей, альбом и альбомного исполнителя,
|
||||||
|
позицию трека и диска, год и дату, жанр, лейбл, длительность, ссылку,
|
||||||
|
служебные поля (id в Яндекс.Музыке и MTS, explicit), обложку 600×600 и
|
||||||
|
текст песни (обычный USLT и синхронный SYLT, если он есть).
|
||||||
|
|
||||||
|
Модуль необязательный: без `mutagen` теги просто не пишутся.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from media import track_cover_url
|
||||||
|
from mtsmusic import (
|
||||||
|
USER_AGENT,
|
||||||
|
force_ipv4,
|
||||||
|
lrclib_lyrics,
|
||||||
|
lyrics_response,
|
||||||
|
lyrics_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from mutagen.id3 import (
|
||||||
|
APIC,
|
||||||
|
COMM,
|
||||||
|
SYLT,
|
||||||
|
TALB,
|
||||||
|
TCON,
|
||||||
|
TDAT,
|
||||||
|
TIT2,
|
||||||
|
TLEN,
|
||||||
|
TPE1,
|
||||||
|
TPE2,
|
||||||
|
TPOS,
|
||||||
|
TPUB,
|
||||||
|
TRCK,
|
||||||
|
TXXX,
|
||||||
|
TYER,
|
||||||
|
USLT,
|
||||||
|
WOAR,
|
||||||
|
ID3,
|
||||||
|
)
|
||||||
|
|
||||||
|
HAVE_MUTAGEN = True
|
||||||
|
except Exception: # pragma: no cover - зависит от окружения
|
||||||
|
HAVE_MUTAGEN = False
|
||||||
|
|
||||||
|
log = logging.getLogger("tags")
|
||||||
|
|
||||||
|
COVER_SIZE = 600
|
||||||
|
_LRC_LINE = re.compile(r"\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]")
|
||||||
|
|
||||||
|
|
||||||
|
def _names(items) -> str:
|
||||||
|
return ", ".join(str(item.get("name") or "") for item in items or [])
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_cover(track: dict) -> bytes | None:
|
||||||
|
"""Скачивает обложку 600×600 для встраивания в APIC."""
|
||||||
|
url = track_cover_url(track, COVER_SIZE)
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
force_ipv4()
|
||||||
|
response = requests.get(
|
||||||
|
url, headers={"User-Agent": USER_AGENT}, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
except requests.RequestException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mts_lyrics(api, track_id: str, fmt: str) -> str:
|
||||||
|
try:
|
||||||
|
response = lyrics_response(api, track_id, fmt)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if response.status_code != 200:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
text = lyrics_text(api, response)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if "Подключите подписку" in text:
|
||||||
|
return ""
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_lyrics(api, track: dict) -> tuple[str, str]:
|
||||||
|
"""(обычный текст, синхронный LRC). Пробуем MTS, затем LRCLIB."""
|
||||||
|
track_id = str(track.get("id"))
|
||||||
|
plain = _mts_lyrics(api, track_id, "TEXT")
|
||||||
|
synced = _mts_lyrics(api, track_id, "LRC")
|
||||||
|
if not plain:
|
||||||
|
plain = (lrclib_lyrics(track, want_sync=False) or "").strip()
|
||||||
|
if not synced:
|
||||||
|
synced = (lrclib_lyrics(track, want_sync=True) or "").strip()
|
||||||
|
if synced and not _LRC_LINE.search(synced):
|
||||||
|
synced = ""
|
||||||
|
return plain, synced
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lrc(text: str) -> list[tuple[str, int]]:
|
||||||
|
"""LRC → [(строка, миллисекунды)] для синхронного SYLT."""
|
||||||
|
entries: list[tuple[str, int]] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
stamps = list(_LRC_LINE.finditer(line))
|
||||||
|
if not stamps:
|
||||||
|
continue
|
||||||
|
content = _LRC_LINE.sub("", line).strip()
|
||||||
|
for stamp in stamps:
|
||||||
|
minutes = int(stamp.group(1))
|
||||||
|
seconds = int(stamp.group(2))
|
||||||
|
fraction = (stamp.group(3) or "0").ljust(3, "0")[:3]
|
||||||
|
millis = (minutes * 60 + seconds) * 1000 + int(fraction)
|
||||||
|
entries.append((content, millis))
|
||||||
|
entries.sort(key=lambda item: item[1])
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_mime(data: bytes) -> str:
|
||||||
|
return "image/png" if data[:8].startswith(b"\x89PNG") else "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
def write_tags(
|
||||||
|
track: dict,
|
||||||
|
path: Path,
|
||||||
|
plain_lyrics: str = "",
|
||||||
|
synced_lyrics: str = "",
|
||||||
|
cover: bytes | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Проставляет ID3v2.3-теги в уже скачанном файле."""
|
||||||
|
if not HAVE_MUTAGEN:
|
||||||
|
return
|
||||||
|
album = (track.get("albums") or [{}])[0]
|
||||||
|
artist = _names(track.get("artists"))
|
||||||
|
album_artist = _names(album.get("artists")) or artist
|
||||||
|
track_id = str(track.get("id") or "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tags = ID3(path)
|
||||||
|
except Exception:
|
||||||
|
tags = ID3()
|
||||||
|
|
||||||
|
def add(frame) -> None:
|
||||||
|
tags.add(frame)
|
||||||
|
|
||||||
|
if track.get("title"):
|
||||||
|
add(TIT2(encoding=3, text=str(track["title"])))
|
||||||
|
if artist:
|
||||||
|
add(TPE1(encoding=3, text=artist))
|
||||||
|
if album_artist:
|
||||||
|
add(TPE2(encoding=3, text=album_artist))
|
||||||
|
if album.get("title"):
|
||||||
|
add(TALB(encoding=3, text=str(album["title"])))
|
||||||
|
|
||||||
|
position = album.get("trackPosition") or {}
|
||||||
|
index = position.get("index")
|
||||||
|
if index:
|
||||||
|
count = album.get("trackCount")
|
||||||
|
add(TRCK(encoding=3, text=f"{index}/{count}" if count else str(index)))
|
||||||
|
volume = position.get("volume")
|
||||||
|
if volume:
|
||||||
|
add(TPOS(encoding=3, text=str(volume)))
|
||||||
|
|
||||||
|
if album.get("year"):
|
||||||
|
add(TYER(encoding=3, text=str(album["year"])))
|
||||||
|
release = str(album.get("releaseDate") or "")
|
||||||
|
if release[:10].count("-") == 2:
|
||||||
|
_year, month, day = release[:10].split("-")
|
||||||
|
add(TDAT(encoding=3, text=f"{day}{month}")) # формат DDMM
|
||||||
|
|
||||||
|
if album.get("genre"):
|
||||||
|
add(TCON(encoding=3, text=str(album["genre"])))
|
||||||
|
labels = _names(album.get("labels"))
|
||||||
|
if labels:
|
||||||
|
add(TPUB(encoding=3, text=labels))
|
||||||
|
if track.get("durationMs"):
|
||||||
|
add(TLEN(encoding=3, text=str(int(track["durationMs"]))))
|
||||||
|
|
||||||
|
if track_id:
|
||||||
|
add(COMM(
|
||||||
|
encoding=3,
|
||||||
|
lang="XXX",
|
||||||
|
desc="",
|
||||||
|
text=f"https://music.yandex.ru/track/{track_id}",
|
||||||
|
))
|
||||||
|
add(WOAR(url=f"https://music.yandex.ru/track/{track_id}"))
|
||||||
|
|
||||||
|
def add_text(desc: str, value) -> None:
|
||||||
|
if value not in (None, ""):
|
||||||
|
add(TXXX(encoding=3, desc=desc, text=str(value)))
|
||||||
|
|
||||||
|
add_text("YANDEX_MUSIC_TRACK_ID", track.get("id"))
|
||||||
|
add_text("YANDEX_MUSIC_REAL_ID", track.get("realId"))
|
||||||
|
add_text("YANDEX_MUSIC_ALBUM_ID", album.get("id"))
|
||||||
|
add_text("MTS_MUSIC_SOURCE", track.get("trackSource"))
|
||||||
|
add_text("YANDEX_MUSIC_TYPE", track.get("type"))
|
||||||
|
if track.get("contentWarning") == "explicit":
|
||||||
|
add_text("ITUNESADVISORY", 1)
|
||||||
|
|
||||||
|
if cover:
|
||||||
|
add(APIC(
|
||||||
|
encoding=3,
|
||||||
|
mime=_cover_mime(cover),
|
||||||
|
type=3,
|
||||||
|
desc="Cover",
|
||||||
|
data=cover,
|
||||||
|
))
|
||||||
|
if plain_lyrics:
|
||||||
|
add(USLT(encoding=3, lang="XXX", desc="", text=plain_lyrics))
|
||||||
|
if synced_lyrics:
|
||||||
|
entries = parse_lrc(synced_lyrics)
|
||||||
|
if entries:
|
||||||
|
add(SYLT(
|
||||||
|
encoding=3,
|
||||||
|
lang="XXX",
|
||||||
|
format=2,
|
||||||
|
type=1,
|
||||||
|
desc="",
|
||||||
|
text=entries,
|
||||||
|
))
|
||||||
|
|
||||||
|
tags.save(path, v2_version=3)
|
||||||
|
|
||||||
|
|
||||||
|
def tag_track(api, track: dict, path: Path) -> None:
|
||||||
|
"""Собирает метаданные (текст, обложка) и пишет их в файл."""
|
||||||
|
if not HAVE_MUTAGEN:
|
||||||
|
return
|
||||||
|
plain, synced = fetch_lyrics(api, track)
|
||||||
|
cover = fetch_cover(track)
|
||||||
|
write_tags(track, Path(path), plain, synced, cover)
|
||||||
165
tui.py
165
tui.py
@@ -115,7 +115,10 @@ def track_row(track: dict) -> tuple[str, str, str, str]:
|
|||||||
|
|
||||||
|
|
||||||
class ListScreen(Screen):
|
class ListScreen(Screen):
|
||||||
BINDINGS = [Binding("escape", "back", "Назад")]
|
BINDINGS = [
|
||||||
|
Binding("escape", "back", "Назад"),
|
||||||
|
Binding("d", "download", "Скачать"),
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self, title: str, tracks: list[dict]) -> None:
|
def __init__(self, title: str, tracks: list[dict]) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -141,6 +144,11 @@ class ListScreen(Screen):
|
|||||||
def action_back(self) -> None:
|
def action_back(self) -> None:
|
||||||
self.app.pop_screen()
|
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")
|
@on(Button.Pressed, "#back")
|
||||||
def back_pressed(self) -> None:
|
def back_pressed(self) -> None:
|
||||||
self.action_back()
|
self.action_back()
|
||||||
@@ -158,7 +166,10 @@ class ListScreen(Screen):
|
|||||||
|
|
||||||
|
|
||||||
class FavoritesScreen(Screen):
|
class FavoritesScreen(Screen):
|
||||||
BINDINGS = [Binding("escape", "back", "Назад")]
|
BINDINGS = [
|
||||||
|
Binding("escape", "back", "Назад"),
|
||||||
|
Binding("d", "download", "Скачать"),
|
||||||
|
]
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield make_topbar("Избранное")
|
yield make_topbar("Избранное")
|
||||||
@@ -173,6 +184,12 @@ class FavoritesScreen(Screen):
|
|||||||
def action_back(self) -> None:
|
def action_back(self) -> None:
|
||||||
self.app.pop_screen()
|
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")
|
@on(Button.Pressed, "#back")
|
||||||
def back_pressed(self) -> None:
|
def back_pressed(self) -> None:
|
||||||
self.action_back()
|
self.action_back()
|
||||||
@@ -222,7 +239,10 @@ class HistoryScreen(ListScreen):
|
|||||||
|
|
||||||
|
|
||||||
class SearchScreen(Screen):
|
class SearchScreen(Screen):
|
||||||
BINDINGS = [Binding("escape", "back", "Назад")]
|
BINDINGS = [
|
||||||
|
Binding("escape", "back", "Назад"),
|
||||||
|
Binding("d", "download", "Скачать"),
|
||||||
|
]
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield make_topbar("Поиск")
|
yield make_topbar("Поиск")
|
||||||
@@ -241,6 +261,11 @@ class SearchScreen(Screen):
|
|||||||
def action_back(self) -> None:
|
def action_back(self) -> None:
|
||||||
self.app.pop_screen()
|
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")
|
@on(Button.Pressed, "#back")
|
||||||
def back_pressed(self) -> None:
|
def back_pressed(self) -> None:
|
||||||
self.action_back()
|
self.action_back()
|
||||||
@@ -385,6 +410,10 @@ class SettingsScreen(Screen):
|
|||||||
with Horizontal(classes="srow"):
|
with Horizontal(classes="srow"):
|
||||||
yield Static("Волна с последних треков", classes="slabel")
|
yield Static("Волна с последних треков", classes="slabel")
|
||||||
yield Button("", id="wave-toggle", classes="stoggle")
|
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")
|
yield Static("", id="settings-path")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
@@ -409,10 +438,17 @@ class SettingsScreen(Screen):
|
|||||||
self.query_one("#wave-toggle", Button).label = (
|
self.query_one("#wave-toggle", Button).label = (
|
||||||
"вкл" if s["wave_from_history"] else "выкл"
|
"вкл" if s["wave_from_history"] else "выкл"
|
||||||
)
|
)
|
||||||
|
self.query_one("#dir-value", Static).update(
|
||||||
|
str(s.get("download_dir") or ""))
|
||||||
|
|
||||||
def action_back(self) -> None:
|
def action_back(self) -> None:
|
||||||
self.app.pop_screen()
|
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)
|
@on(Button.Pressed)
|
||||||
def pressed(self, event: Button.Pressed) -> None:
|
def pressed(self, event: Button.Pressed) -> None:
|
||||||
bid = event.button.id or ""
|
bid = event.button.id or ""
|
||||||
@@ -439,6 +475,14 @@ class SettingsScreen(Screen):
|
|||||||
self.app.update_setting(
|
self.app.update_setting(
|
||||||
"wave_from_history",
|
"wave_from_history",
|
||||||
not s["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()
|
self.refresh_values()
|
||||||
|
|
||||||
|
|
||||||
@@ -453,6 +497,8 @@ class MenuScreen(ModalScreen[str | None]):
|
|||||||
yield Button("История", id="m-history")
|
yield Button("История", id="m-history")
|
||||||
yield Button("Поиск", id="m-search")
|
yield Button("Поиск", id="m-search")
|
||||||
yield Button("Текст песни", id="m-lyrics")
|
yield Button("Текст песни", id="m-lyrics")
|
||||||
|
yield Button("Скачать трек", id="m-download")
|
||||||
|
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")
|
||||||
yield Button("Выход", id="m-quit", variant="error")
|
yield Button("Выход", id="m-quit", variant="error")
|
||||||
@@ -465,6 +511,45 @@ class MenuScreen(ModalScreen[str | None]):
|
|||||||
self.dismiss(event.button.id)
|
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-panel Button { width: 100%; margin: 1 0; }
|
||||||
#menu-spacer { height: 1fr; }
|
#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; }
|
#settings-scroll { height: 1fr; padding: 1 2; }
|
||||||
.srow { height: 3; align: left middle; }
|
.srow { height: 3; align: left middle; }
|
||||||
.slabel { width: 1fr; content-align: left middle; color: #d0d0d0; }
|
.slabel { width: 1fr; content-align: left middle; color: #d0d0d0; }
|
||||||
.svalue { width: 8; content-align: center middle; color: #ffffff; }
|
.svalue { width: 8; content-align: center middle; color: #ffffff; }
|
||||||
.sstep { width: 5; min-width: 5; }
|
.sstep { width: 5; min-width: 5; }
|
||||||
.stoggle { width: 16; min-width: 12; }
|
.stoggle { width: 16; min-width: 12; }
|
||||||
|
#dir-value { padding: 0 0 1 0; color: #8a8a8a; }
|
||||||
#settings-path { padding: 1 0; color: #6a6a6a; }
|
#settings-path { padding: 1 0; color: #6a6a6a; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -1194,6 +1293,10 @@ class MusicTui(App):
|
|||||||
self.push_screen(SearchScreen())
|
self.push_screen(SearchScreen())
|
||||||
elif action == "m-lyrics":
|
elif action == "m-lyrics":
|
||||||
self.action_show_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":
|
elif action == "m-settings":
|
||||||
self.push_screen(SettingsScreen())
|
self.push_screen(SettingsScreen())
|
||||||
elif action == "m-quit":
|
elif action == "m-quit":
|
||||||
@@ -1205,6 +1308,62 @@ class MusicTui(App):
|
|||||||
self.settings = update_settings(**{key: value})
|
self.settings = update_settings(**{key: value})
|
||||||
self.apply_settings()
|
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:
|
def apply_settings(self) -> None:
|
||||||
volume = int(self.settings.get("volume", 80))
|
volume = int(self.settings.get("volume", 80))
|
||||||
if self.player.volume != volume:
|
if self.player.volume != volume:
|
||||||
|
|||||||
Reference in New Issue
Block a user