From de9bf80d11e2687ec85cae4696115d86be0ec243 Mon Sep 17 00:00:00 2001 From: jads Date: Sun, 25 Oct 2026 20:06:59 +0300 Subject: [PATCH] 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. --- README.md | 50 ++++++++-- downloads.py | 140 +++++++++++++++++++++++++++ mpris.py | 2 + mtsmusic.py | 63 +++++++------ qt_app.py | 123 ++++++++++++++++++++++++ requirements.txt | 1 + settings.py | 8 ++ tags.py | 239 +++++++++++++++++++++++++++++++++++++++++++++++ tui.py | 165 +++++++++++++++++++++++++++++++- 9 files changed, 754 insertions(+), 37 deletions(-) create mode 100644 downloads.py create mode 100644 tags.py diff --git a/README.md b/README.md index 9da0552..02ec34e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # MTS Music — another client Неофициальный клиент для **своего** аккаунта MTS Music: «Моя волна», поиск, -избранное, тексты песен, воспроизведение и скачивание в MP3. Есть компактный -TUI-плеер и настольное Qt-приложение в стиле телефона. +избранное, тексты песен, воспроизведение и скачивание в MP3 с полными +ID3-тегами. Есть компактный TUI-плеер и настольное Qt-приложение в стиле +телефона. > Только для личного использования с собственной платной подпиской MTS. > Проект не связан с МТС и Яндексом; контент не перераспространяется. @@ -50,7 +51,7 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже «Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое меню: «Моя волна», избранное, история прослушивания, поиск, текст песни, -«Настройки» и выход. +«Скачать трек», «Скачать список», «Настройки» и выход. Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны (клик по прогрессу перематывает трек). @@ -67,6 +68,7 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu | `f` | избранное | | `h` | история | | `s` | поиск | +| `d` | скачать выделенный трек в списке | | `q` | выход | История хранится в `.mts-music/history.json` (последние 100 треков). @@ -82,7 +84,8 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu Отдельное окно в стиле телефона: крупная цветная обложка, кнопка play/pause, переключение треков, перемотка, текст песни и избранное. Кнопка «☰» открывает -боковую панель с «Моей волной», избранным, историей, поиском и настройками. +боковую панель с «Моей волной», избранным, историей, поиском, скачиванием +и настройками. Воспроизведение — через `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) При запуске TUI и Qt-приложение регистрируются в сессионном D-Bus как источник @@ -134,10 +161,12 @@ D-Bus или без `dbus-next` приложение просто работае | `autoplay` | `false` | начинать воспроизведение сразу при запуске | | `start_queue` | `wave` | что открыть первым: `wave`, `favorites` или `history` | | `wave_from_history` | `true` | продолжать «Мою волну» с последних треков | +| `download_dir` | `~/Music/MTS Music` | куда сохранять скачанные MP3 | В Qt-приложении на странице настроек дополнительно выбирается устройство вывода -звука. Громкость меняется на лету, остальные настройки применяются к новым -трекам и при следующем запуске. +звука и папка загрузок (кнопка «Выбрать…»). В TUI путь задаётся в настройках +пунктом «Папка загрузок». Громкость меняется на лету, остальные настройки +применяются к новым трекам и при следующем запуске. ## Команды @@ -173,9 +202,16 @@ D-Bus или без `dbus-next` приложение просто работае # воспроизведение (нужен mpv или ffplay) .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 +# без ID3-тегов (быстрее, без обложки и текста) +.venv/bin/python mtsmusic.py download 12345678 --no-tags + # настройки .venv/bin/python mtsmusic.py settings .venv/bin/python mtsmusic.py settings -s volume=60 seek_step=10 diff --git a/downloads.py b/downloads.py new file mode 100644 index 0000000..bfc7d6e --- /dev/null +++ b/downloads.py @@ -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 diff --git a/mpris.py b/mpris.py index 5c646b7..bd67f75 100644 --- a/mpris.py +++ b/mpris.py @@ -317,6 +317,8 @@ class MprisService: duration_ms: int = 0, art_url: str = "", ): + if not HAVE_DBUS: + return metadata: dict[str, Any] = { "mpris:trackid": Variant("o", track_object_path(track_id)), "mpris:length": Variant("x", int(duration_ms) * 1000), diff --git a/mtsmusic.py b/mtsmusic.py index 717a38c..f966d6f 100644 --- a/mtsmusic.py +++ b/mtsmusic.py @@ -872,34 +872,33 @@ def cmd_play(args: argparse.Namespace) -> None: def cmd_download(args: argparse.Namespace) -> None: - api = Api() - print_track_info(api, args.track_id) - url, _ = resolve_stream(api, args.track_id) - force_ipv4() - output = Path(args.output or f"{args.track_id}.mp3") + from downloads import download_track, stream_to_file, tag_downloaded + from settings import get_setting - 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 total: - percent = done * 100 // total - kib = done // 1024 - print( - f"\r{percent:3d}% {kib} KiB", - end="", - file=sys.stderr) + api = Api() + track = get_track(api, args.track_id) + print(fmt_track(track), file=sys.stderr) + + def progress(done: int, total: int) -> None: + if total: + percent = done * 100 // total + print( + f"\r{percent:3d}% {done // 1024} KiB", + end="", + file=sys.stderr) + + with_tags = not args.no_tags + if args.output: + # Явный путь: скачиваем ровно в указанный файл, как раньше. + url, _ = resolve_stream(api, args.track_id) + output = stream_to_file(url, Path(args.output), progress) + if with_tags: + tag_downloaded(api, track, output) + else: + dest = args.dir or get_setting("download_dir") + output = download_track(api, track, dest, progress, with_tags) + + if sys.stderr.isatty(): print(file=sys.stderr) print(f"Сохранено: {output} ({output.stat().st_size} байт)") @@ -1145,6 +1144,16 @@ def build_parser() -> argparse.ArgumentParser: download = sub.add_parser("download", help="скачать трек в MP3") download.add_argument("track_id") 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) return parser diff --git a/qt_app.py b/qt_app.py index ae18375..b03bd26 100644 --- a/qt_app.py +++ b/qt_app.py @@ -44,6 +44,7 @@ from PySide6.QtWidgets import ( QAbstractButton, QCheckBox, QComboBox, + QFileDialog, QFrame, QHBoxLayout, QLabel, @@ -313,6 +314,53 @@ class Task(QRunnable): pass +class DownloadTask(QRunnable): + """Скачивает список треков в фоне и сообщает о прогрессе.""" + + class Signals(QObject): + progress = Signal(str) + done = Signal(int, int, str) + failed = Signal(str) + + def __init__(self, api, tracks: list[dict], dest: str) -> None: + super().__init__() + self.api = api + self.tracks = tracks + self.dest = dest + self.signals = DownloadTask.Signals() + self.setAutoDelete(False) + + def run(self) -> None: + from downloads import download_many + + def on_item(index: int, count: int, track: dict) -> None: + title = str(track.get("title") or "") + self._emit(f"Скачиваю {index}/{count}: {title}") + + try: + saved = download_many( + self.api, self.tracks, self.dest, on_item=on_item) + except Exception as exc: # noqa: BLE001 - сообщение показываем в UI + self._emit_failed(str(exc) or exc.__class__.__name__) + else: + try: + self.signals.done.emit(len(saved), len(self.tracks), self.dest) + except RuntimeError: + pass + + def _emit(self, text: str) -> None: + try: + self.signals.progress.emit(text) + except RuntimeError: + pass + + def _emit_failed(self, message: str) -> None: + try: + self.signals.failed.emit(message) + except RuntimeError: + pass + + class CoverView(QWidget): """Обложка со скруглениями и аккуратным плейсхолдером.""" @@ -569,6 +617,7 @@ class QtMusicApp(QMainWindow): self.pool = QThreadPool(self) self.pool.setMaxThreadCount(4) self.covers = QNetworkAccessManager(self) + self._download_tasks: set[DownloadTask] = set() self.player = QMediaPlayer(self) self.audio = QAudioOutput(self) @@ -1006,6 +1055,22 @@ class QtMusicApp(QMainWindow): self.wave_check.toggled.connect(self.on_wave_from_history_toggled) layout.addWidget(self.wave_check) + dir_row = QHBoxLayout() + dir_row.setSpacing(10) + dir_label = QLabel("Папка загрузок") + dir_label.setObjectName("settingLabel") + self.download_dir_edit = QLineEdit( + str(self.settings.get("download_dir") or "")) + self.download_dir_edit.setObjectName("settingCombo") + self.download_dir_edit.setReadOnly(True) + dir_button = QPushButton("Выбрать…") + dir_button.setCursor(Qt.CursorShape.PointingHandCursor) + dir_button.clicked.connect(self.choose_download_dir) + dir_row.addWidget(dir_label) + dir_row.addWidget(self.download_dir_edit, 1) + dir_row.addWidget(dir_button) + layout.addLayout(dir_row) + hint = QLabel("Настройки сохраняются в .mts-music/settings.json") hint.setObjectName("pageStatus") hint.setWordWrap(True) @@ -1043,6 +1108,62 @@ class QtMusicApp(QMainWindow): def on_wave_from_history_toggled(self, checked: bool) -> None: self.update_setting("wave_from_history", checked) + def choose_download_dir(self) -> None: + current = str(self.settings.get("download_dir") or "") + chosen = QFileDialog.getExistingDirectory( + self, "Папка загрузок", current) + if not chosen: + return + self.update_setting("download_dir", chosen) + self.download_dir_edit.setText(chosen) + self.toast(f"Папка загрузок: {chosen}") + + # -- скачивание ------------------------------------------------------- + + def download_current(self) -> None: + self.close_drawer() + if not self.current: + self.toast("Сначала выбери трек") + return + self.download_tracks([self.current], "трек") + + def download_queue(self) -> None: + self.close_drawer() + tracks = self.queue or ([self.current] if self.current else []) + if not tracks: + self.toast("Список пуст") + return + self.download_tracks(tracks, "список") + + def download_tracks(self, tracks: list[dict], label: str) -> None: + if not tracks: + return + dest = str(self.settings.get("download_dir") or "") + task = DownloadTask(self.api, list(tracks), dest) + task.signals.progress.connect(self.set_top_status) + task.signals.done.connect( + lambda saved, total, folder, t=task: + self._download_done(t, saved, total, folder)) + task.signals.failed.connect( + lambda message, t=task: self._download_failed(t, message)) + self._download_tasks.add(task) + self.set_top_status(f"Скачиваю {label}…") + self.pool.start(task) + + def _download_done( + self, task: DownloadTask, saved: int, total: int, dest: str + ) -> None: + self._download_tasks.discard(task) + self.set_top_status(self.queue_name) + if saved: + self.toast(f"Скачано {saved}/{total} → {dest}") + else: + self.toast("Не удалось скачать") + + def _download_failed(self, task: DownloadTask, message: str) -> None: + self._download_tasks.discard(task) + self.on_error(message) + def show_settings(self) -> None: self._show(self.settings_page) @@ -1071,6 +1192,8 @@ class QtMusicApp(QMainWindow): ("История", self.show_history), ("Поиск", self.show_search), ("Текст песни", self.show_lyrics), + ("Скачать трек", self.download_current), + ("Скачать список", self.download_queue), ("Настройки", self.show_settings), ): button = QPushButton(text) diff --git a/requirements.txt b/requirements.txt index 41dd7d3..39caeb3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ requests>=2.31 textual>=0.80 pillow>=10 dbus-next>=0.2.3 +mutagen>=1.47 diff --git a/settings.py b/settings.py index acb0256..8962fa6 100644 --- a/settings.py +++ b/settings.py @@ -10,12 +10,15 @@ from mtsmusic import CONFIG_DIR SETTINGS_PATH = CONFIG_DIR / "settings.json" +DEFAULT_DOWNLOAD_DIR = str(Path.home() / "Music" / "MTS Music") + DEFAULTS: dict[str, Any] = { "volume": 80, # громкость 0..100 "seek_step": 5, # шаг перемотки стрелками, секунды "autoplay": False, # запускать воспроизведение сразу "start_queue": "wave", # wave | favorites | history "wave_from_history": True, # продолжать волну с последних треков + "download_dir": DEFAULT_DOWNLOAD_DIR, # куда сохранять скачанные MP3 } START_QUEUES = ("wave", "favorites", "history") @@ -47,6 +50,11 @@ def _coerce(key: str, value: Any) -> Any: if key == "start_queue": text = str(value) 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 diff --git a/tags.py b/tags.py new file mode 100644 index 0000000..e7fa1cb --- /dev/null +++ b/tags.py @@ -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) diff --git a/tui.py b/tui.py index 162a168..66e9e04 100644 --- a/tui.py +++ b/tui.py @@ -115,7 +115,10 @@ def track_row(track: dict) -> tuple[str, str, str, str]: class ListScreen(Screen): - BINDINGS = [Binding("escape", "back", "Назад")] + BINDINGS = [ + Binding("escape", "back", "Назад"), + Binding("d", "download", "Скачать"), + ] def __init__(self, title: str, tracks: list[dict]) -> None: super().__init__() @@ -141,6 +144,11 @@ class ListScreen(Screen): def action_back(self) -> None: 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") def back_pressed(self) -> None: self.action_back() @@ -158,7 +166,10 @@ class ListScreen(Screen): class FavoritesScreen(Screen): - BINDINGS = [Binding("escape", "back", "Назад")] + BINDINGS = [ + Binding("escape", "back", "Назад"), + Binding("d", "download", "Скачать"), + ] def compose(self) -> ComposeResult: yield make_topbar("Избранное") @@ -173,6 +184,12 @@ class FavoritesScreen(Screen): def action_back(self) -> None: 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") def back_pressed(self) -> None: self.action_back() @@ -222,7 +239,10 @@ class HistoryScreen(ListScreen): class SearchScreen(Screen): - BINDINGS = [Binding("escape", "back", "Назад")] + BINDINGS = [ + Binding("escape", "back", "Назад"), + Binding("d", "download", "Скачать"), + ] def compose(self) -> ComposeResult: yield make_topbar("Поиск") @@ -241,6 +261,11 @@ class SearchScreen(Screen): def action_back(self) -> None: 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") def back_pressed(self) -> None: self.action_back() @@ -385,6 +410,10 @@ class SettingsScreen(Screen): with Horizontal(classes="srow"): yield Static("Волна с последних треков", classes="slabel") 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") def on_mount(self) -> None: @@ -409,10 +438,17 @@ class SettingsScreen(Screen): self.query_one("#wave-toggle", Button).label = ( "вкл" if s["wave_from_history"] else "выкл" ) + self.query_one("#dir-value", Static).update( + str(s.get("download_dir") or "")) def action_back(self) -> None: 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) def pressed(self, event: Button.Pressed) -> None: bid = event.button.id or "" @@ -439,6 +475,14 @@ class SettingsScreen(Screen): self.app.update_setting( "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() @@ -453,6 +497,8 @@ class MenuScreen(ModalScreen[str | None]): yield Button("История", id="m-history") yield Button("Поиск", id="m-search") yield Button("Текст песни", id="m-lyrics") + yield Button("Скачать трек", id="m-download") + yield Button("Скачать список", id="m-download-list") yield Button("Настройки", id="m-settings") yield Static("", id="menu-spacer") yield Button("Выход", id="m-quit", variant="error") @@ -465,6 +511,45 @@ class MenuScreen(ModalScreen[str | None]): 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-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; } .srow { height: 3; align: left middle; } .slabel { width: 1fr; content-align: left middle; color: #d0d0d0; } .svalue { width: 8; content-align: center middle; color: #ffffff; } .sstep { width: 5; min-width: 5; } .stoggle { width: 16; min-width: 12; } + #dir-value { padding: 0 0 1 0; color: #8a8a8a; } #settings-path { padding: 1 0; color: #6a6a6a; } """ @@ -1194,6 +1293,10 @@ class MusicTui(App): self.push_screen(SearchScreen()) elif action == "m-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": self.push_screen(SettingsScreen()) elif action == "m-quit": @@ -1205,6 +1308,62 @@ class MusicTui(App): self.settings = update_settings(**{key: value}) 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: volume = int(self.settings.get("volume", 80)) if self.player.volume != volume: