diff --git a/README.md b/README.md index 02ec34e..88f3dfa 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,15 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu История хранится в `.mts-music/history.json` (последние 100 треков). Плеер — `ffplay` (или `mpv`, если установлен). Пауза реализована через -`SIGSTOP`/`SIGCONT`, а перемотка перезапускает поток с нужной секунды, -поэтому отдельный демон не нужен. +`SIGSTOP`/`SIGCONT`, а перемотка перезапускает воспроизведение с нужной +секунды, поэтому отдельный демон не нужен. + +Треки не транслируются напрямую: сначала они скачиваются в локальный кэш +`.mts-music/cache/` (до 512 МБ, старые файлы удаляются автоматически), а плеер +играет уже готовый MP3. Так надёжнее — сервер Яндекса закрывает соединение, +если клиент читает поток медленнее реального времени, из-за чего трек +обрывался через полминуты и клиент переключался на следующий. Заодно кэш +убирает задержку при повторном прослушивании и делает перемотку мгновенной. ## Qt-приложение @@ -101,11 +108,16 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu ## Скачивание и метаданные -Скачать трек можно из меню (TUI: «Скачать трек»/«Скачать список»; Qt: те же -пункты в боковой панели), клавишей `d` на экране списка в TUI или командой -`download`. Файлы кладутся в папку `download_dir` (`~/Music/MTS Music` по -умолчанию), имя — `Исполнитель - Название.mp3`; существующие файлы не -перезаписываются, при совпадении добавляется ` (2)`, ` (3)` и т.д. +В меню обоих клиентов пункт «Скачать» открывает выбор: текущий трек, всё +избранное, 20 треков «Моей волны» или результаты поиска; папка выбирается +диалогом в Qt и вводится вручную в TUI, прогресс и число ошибок видны на +экране. Быстрые пункты «Скачать трек»/«Скачать список» (и клавиша `d` на +экране списка в TUI) сразу кладут в папку `download_dir` без диалога. В CLI +это команда `download` с теми же источниками (`--likes`, `--wave`, `--search`). + +Файлы кладутся в папку `download_dir` (`~/Music/MTS Music` по умолчанию), +имя — `Исполнитель - Название.mp3`; существующие файлы не перезаписываются, +при совпадении добавляется ` (2)`, ` (3)` и т.д. (`--force` перезаписывает). В скачанный файл пишется максимум метаданных ID3v2.3 (через `mutagen`, он в `requirements.txt`): @@ -183,10 +195,12 @@ D-Bus или без `dbus-next` приложение просто работае # сразу слушать подборку (Ctrl-C — остановить) .venv/bin/python mtsmusic.py mix -n 5 --play -# избранное: добавить / убрать / показать +# избранное: добавить / убрать / показать текстом .venv/bin/python mtsmusic.py like 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 @@ -205,9 +219,19 @@ D-Bus или без `dbus-next` приложение просто работае # скачивание: в папку из 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 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 --likes -d ~/Music/MTS --force + +# то же самое есть в меню TUI и Qt — пункт «Скачать» # без ID3-тегов (быстрее, без обложки и текста) .venv/bin/python mtsmusic.py download 12345678 --no-tags @@ -252,6 +276,11 @@ TUI нужно запускать в настоящем терминале (stdi - Использовать только для личного прослушивания, не перераздавать контент. - Если `api.music.yandex.net` недоступен, проверь IPv6: клиент принудительно ходит по IPv4. +- Кэш треков лежит в `.mts-music/cache/`. Его можно удалить в любой момент — + при следующем запуске треки скачаются заново. +- `download` (и пункт «Скачать» в TUI/Qt) по умолчанию не перезаписывает уже + скачанные файлы: повторный запуск докачает только новое. Флаг `--force` + в CLI включает перезапись. ## Лицензия diff --git a/downloads.py b/downloads.py deleted file mode 100644 index bfc7d6e..0000000 --- a/downloads.py +++ /dev/null @@ -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 diff --git a/mtsmusic.py b/mtsmusic.py index f966d6f..cc9de77 100644 --- a/mtsmusic.py +++ b/mtsmusic.py @@ -13,6 +13,7 @@ import hashlib import hmac import json import os +import re import random import shutil import socket @@ -23,22 +24,18 @@ import time import urllib.parse from datetime import datetime, timezone from pathlib import Path -from typing import NoReturn +from typing import Callable, NoReturn import requests PROJECT_DIR = Path(__file__).resolve().parent # Всё локальное храним рядом с проектом, а не в домашней директории. -os.environ.setdefault( - "PLAYWRIGHT_BROWSERS_PATH", str( - PROJECT_DIR / ".playwright")) +os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", + str(PROJECT_DIR / ".playwright")) -CONFIG_DIR = Path( - os.environ.get( - "MTSMUSIC_CONFIG_DIR", - PROJECT_DIR / - ".mts-music")) +CONFIG_DIR = Path(os.environ.get( + "MTSMUSIC_CONFIG_DIR", PROJECT_DIR / ".mts-music")) CREDS_PATH = CONFIG_DIR / "credentials.json" BROWSER_DIR = CONFIG_DIR / "browser" @@ -60,8 +57,7 @@ _orig_getaddrinfo = socket.getaddrinfo def force_ipv4() -> None: - """api.music.yandex.net иногда недоступен по IPv6 — ходим только - по IPv4.""" + """api.music.yandex.net недоступен по IPv6 — ходим только по IPv4.""" def getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): return _orig_getaddrinfo( @@ -91,12 +87,8 @@ def load_credentials() -> dict: def save_credentials(data: dict) -> None: CONFIG_DIR.mkdir(parents=True, exist_ok=True) - CREDS_PATH.write_text( - json.dumps( - data, - ensure_ascii=False, - indent=2), - encoding="utf-8") + CREDS_PATH.write_text(json.dumps( + data, ensure_ascii=False, indent=2), encoding="utf-8") try: CREDS_PATH.chmod(0o600) except OSError: @@ -143,8 +135,8 @@ def cmd_login(args: argparse.Namespace) -> None: from playwright.sync_api import sync_playwright except ImportError: die( - "не установлен playwright. Выполни: " - "pip install -r requirements.txt") + "не установлен playwright. " + "Выполни: pip install -r requirements.txt") settings = get_auth_settings() authorize_uri = settings["web_sso_authorize_uri"] @@ -172,8 +164,7 @@ def cmd_login(args: argparse.Namespace) -> None: token = None if token: break - # Токен может прийти в URL до того, как SPA успеет его - # сохранить. + # Токен может прийти в URL раньше, чем SPA его сохранит. if "yatoken=" in page.url: query = urllib.parse.parse_qs( urllib.parse.urlparse(page.url).query) @@ -184,8 +175,8 @@ def cmd_login(args: argparse.Namespace) -> None: if not token: die( - "не дождался токена. Вход не завершён " - "или истекло время ожидания.") + "не дождался токена. " + "Вход не завершён или истекло время ожидания.") ya_uid = page.evaluate("localStorage.getItem('ya_uid')") user_id = page.evaluate("localStorage.getItem('userId')") @@ -235,10 +226,11 @@ class Api: return self._request("POST", path, params=params) def _request( - self, - method: str, - path: str, - params: dict | None = None) -> dict: + self, + method: str, + path: str, + params: dict | None = None, + ) -> dict: force_ipv4() last_error: Exception | None = None for attempt in range(3): @@ -261,8 +253,8 @@ class Api: if response.status_code == 401: die( - "токен не принят (401). Выполни заново: " - "python mtsmusic.py login") + "токен не принят (401). " + "Выполни заново: python mtsmusic.py login") if response.status_code == 404: die(f"не найдено (404) на {path}") if response.status_code != 200: @@ -287,10 +279,7 @@ def require_token(api: Api, what: str) -> None: def bff_get(path: str) -> dict | None: - """Запрос к BFF MTS с сохранёнными cookie. - - None, если cookie нет/не подошли. - """ + """Запрос к BFF MTS с cookie. None, если cookie нет/не подошли.""" cookies = load_credentials().get("cookies") or [] if not cookies: return None @@ -344,14 +333,13 @@ def fmt_track(track: dict) -> str: ) -def cmd_search(args: argparse.Namespace) -> None: - api = Api() +def search_tracks(api: Api, query: str, limit: int = 10) -> list[dict]: payload = api.get( "/search", - text=args.query, + text=query, type="all", page=0, - **{"page-size": args.limit}, + **{"page-size": limit}, nocorrect="true", ) result = payload.get("result") or {} @@ -359,11 +347,17 @@ def cmd_search(args: argparse.Namespace) -> None: if not tracks: best = (result.get("best") or {}).get("result") if isinstance(best, dict) and best.get("id"): - print(fmt_track(best)) - return + return [best] + return tracks[:limit] + + +def cmd_search(args: argparse.Namespace) -> None: + api = Api() + tracks = search_tracks(api, args.query, args.limit) + if not tracks: print("Ничего не найдено.") return - for track in tracks[: args.limit]: + for track in tracks: print(fmt_track(track)) @@ -381,19 +375,20 @@ def cmd_status(args: argparse.Namespace) -> None: profile = bff_profile.get("profile") or {} bff_subscriptions = bff_profile.get("subscriptions") or [] - alias = ( + print(f"UID: {account.get('uid', '?')}") + display_name = ( profile.get("alias") or account.get("fullName") or account.get("displayName") or account.get("login") or "?" ) - phone = profile.get("phone") or account.get("phone") or "?" - has_sub = subscriptions or bff_subscriptions - print(f"UID: {account.get('uid', '?')}") - print(f"Имя: {alias}") - print(f"Телефон: {phone}") - print(f"Подписка: {'активна' if has_sub else 'нет'}") + print(f"Имя: {display_name}") + print( + f"Телефон: {profile.get('phone') or account.get('phone') or '?'}") + subscription_state = "активна" if ( + subscriptions or bff_subscriptions) else "нет" + print(f"Подписка: {subscription_state}") if bff_subscriptions: for sub in bff_subscriptions: name = sub.get("name") or sub.get("id") or "?" @@ -408,8 +403,11 @@ def cmd_status(args: argparse.Namespace) -> None: # Дописываем uid в креды, если при логине SPA не успела его сохранить. creds = load_credentials() - if creds and creds.get( - "ya_token") == api.token and not creds.get("ya_uid"): + if ( + creds + and creds.get("ya_token") == api.token + and not creds.get("ya_uid") + ): creds["ya_uid"] = str(account.get("uid") or "") save_credentials(creds) @@ -428,8 +426,8 @@ def get_uid(api: Api) -> str: uid = (result.get("account") or {}).get("uid") if not uid: die( - "не удалось определить uid аккаунта. Выполни: " - "python mtsmusic.py login") + "не удалось определить uid аккаунта. " + "Выполни: python mtsmusic.py login") creds = load_credentials() or {} creds["ya_uid"] = str(uid) save_credentials(creds) @@ -456,9 +454,12 @@ def track_album_ref(track_id: str, album: str | None = None) -> str: def fetch_mix_batch(api: Api, queue: list[str] | None = None) -> dict: params = {"queue": ",".join(queue)} if queue else {} - return api.get( - f"/rotor/station/{WAVE_STATION}/tracks", - **params).get("result") or {} + return ( + api.get( + f"/rotor/station/{WAVE_STATION}/tracks", **params + ).get("result") + or {} + ) def fetch_liked_tracks(api: Api, uid: str, limit: int = 300) -> list[dict]: @@ -472,9 +473,9 @@ def fetch_liked_tracks(api: Api, uid: str, limit: int = 300) -> list[dict]: chunk = ids[start: start + 50] if chunk: tracks.extend( - api.get( - "/tracks", - trackIds=",".join(chunk)).get("result") or []) + api.get("/tracks", trackIds=",".join(chunk) + ).get("result") or [] + ) return tracks @@ -484,18 +485,15 @@ def print_mix_item(index: int, item: dict) -> None: print(f"{heart} {index:>3}. {fmt_track(track)}", flush=True) -def cmd_mix(args: argparse.Namespace) -> None: - api = Api() - require_token(api, "моя волна") - - limit = max(1, args.number) +def collect_wave(api: Api, limit: int) -> list[dict]: + """Собирает элементы sequence «Моей волны» (track + liked).""" + limit = max(1, limit) collected: list[dict] = [] seen: set[str] = set() queue: list[str] | None = None attempts = 0 max_attempts = max(10, limit) - print(f"Моя волна — {limit} трек(ов):", file=sys.stderr) while len(collected) < limit and attempts < max_attempts: attempts += 1 result = fetch_mix_batch(api, queue) @@ -524,6 +522,16 @@ def cmd_mix(args: argparse.Namespace) -> None: ids = [str((item.get("track") or {}).get("id")) for item in sequence] queue = ids[-5:] + return collected + + +def cmd_mix(args: argparse.Namespace) -> None: + api = Api() + require_token(api, "моя волна") + + limit = max(1, args.number) + print(f"Моя волна — {limit} трек(ов):", file=sys.stderr) + collected = collect_wave(api, limit) if not collected: die("волна вернула пустой список") @@ -568,8 +576,9 @@ def cmd_likes(args: argparse.Namespace) -> None: print("В избранном пока пусто.") return - limit = max(1, args.number) - liked = liked[:limit] + total = len(liked) + if args.number and args.number > 0: + liked = liked[: args.number] details: dict[str, dict] = {} # /tracks отдаёт не больше сотни id за раз — бьём на порции. @@ -577,14 +586,21 @@ def cmd_likes(args: argparse.Namespace) -> None: for start in range(0, len(ids), 50): chunk = ids[start: start + 50] try: - tracks = api.get( - "/tracks", - trackIds=",".join(chunk)).get("result") or [] + tracks = api.get("/tracks", trackIds=",".join(chunk) + ).get("result") or [] except SystemExit: tracks = [] for track in tracks: details[str(track.get("id"))] = track + if args.ids: + for item in liked: + print(item.get("id")) + return + + shown = len(liked) + suffix = f" (показано {shown})" if shown != total else "" + print(f"В избранном: {total} трек(ов){suffix}\n") for index, item in enumerate(liked, 1): track_id = str(item.get("id")) track = details.get(track_id) @@ -596,17 +612,15 @@ def cmd_likes(args: argparse.Namespace) -> None: def lyrics_response( - api: Api, - track_id: str, - fmt: str = "TEXT") -> requests.Response: + api: Api, track_id: str, fmt: str = "TEXT" +) -> requests.Response: """Запрос текста с подписью из веб-клиента MTS.""" timestamp = int(time.time()) message = f"{track_id}{timestamp}" signature = base64.b64encode( - hmac.new( - LYRICS_SIGN_KEY.encode(), - message.encode(), - hashlib.sha256).digest()).decode() + hmac.new(LYRICS_SIGN_KEY.encode(), + message.encode(), hashlib.sha256).digest() + ).decode() force_ipv4() return api.session.get( @@ -663,10 +677,7 @@ def lrclib_lyrics(track: dict, want_sync: bool) -> str | None: for params in queries: try: response = requests.get( - LRCLIB_URL, - params=params, - headers=headers, - timeout=20) + LRCLIB_URL, params=params, headers=headers, timeout=20) except requests.RequestException: continue if response.status_code != 200: @@ -686,10 +697,8 @@ def lrclib_lyrics(track: dict, want_sync: bool) -> str | None: duration = (track.get("durationMs") or 0) / 1000 def score(item: dict) -> float: - diff = abs( - float( - item.get("duration") or 0) - - duration) if duration else 0 + diff = abs(float(item.get("duration") or 0) - + duration) if duration else 0 exact = 0 if (item.get("trackName") or "").casefold( ) == title.casefold() else 50 return diff + exact @@ -736,8 +745,7 @@ def cmd_lyrics(args: argparse.Namespace) -> None: output = Path(args.output) output.write_text(text, encoding="utf-8") print( - f"Сохранено: {output} ({ - len(text)} символов, источник: {source})") + f"Сохранено: {output} ({len(text)} символов, источник: {source})") else: if source != "MTS": print(f"# источник текста: {source}", file=sys.stderr) @@ -751,8 +759,7 @@ def cmd_lyrics(args: argparse.Namespace) -> None: # --------------------------------------------------------------------------- -def resolve_stream(api: Api, track_id: str) -> tuple[str, dict]: - """Возвращает (url_mp3, download_info_item).""" +def _best_download_item(api: Api, track_id: str) -> dict: info = api.get(f"/tracks/{track_id}/download-info") items = info.get("result") or [] if not items: @@ -760,12 +767,14 @@ def resolve_stream(api: Api, track_id: str) -> tuple[str, dict]: # Лучший вариант: не preview и максимальный битрейт. items.sort( - key=lambda item: ( - not item.get( - "preview", False), item.get( - "bitrateInKbps", 0)), reverse=True, ) - item = items[0] + key=lambda item: (not item.get("preview", False), + item.get("bitrateInKbps", 0)), + reverse=True, + ) + return items[0] + +def _stream_url(api: Api, item: dict) -> str: download_url = item["downloadInfoUrl"] parsed = urllib.parse.urlparse(download_url) query = parsed.query + ("&" if parsed.query else "") + "format=json" @@ -776,19 +785,121 @@ def resolve_stream(api: Api, track_id: str) -> tuple[str, dict]: host = meta["host"] timestamp = meta["ts"] - digest = hashlib.md5( - (STREAM_SIGN_SECRET + path + signature).encode()).hexdigest() - stream_url = f"https://{host}/get-mp3/{digest}/{timestamp}{path}" - return stream_url, item + digest = hashlib.md5((STREAM_SIGN_SECRET + path + + signature).encode()).hexdigest() + return f"https://{host}/get-mp3/{digest}/{timestamp}{path}" + + +def resolve_stream(api: Api, track_id: str) -> tuple[str, dict]: + """Возвращает (url_mp3, download_info_item).""" + item = _best_download_item(api, track_id) + return _stream_url(api, item), item + + +# Прямая трансляция с сервера обрывается через ~45 секунд (сервер закрывает +# TLS-соединение, если клиент читает медленнее, чем реальное время). Поэтому +# трек сначала скачивается в локальный кэш, а плеер работает с файлом. +CACHE_DIR = CONFIG_DIR / "cache" +CACHE_LIMIT_BYTES = 512 * 1024 * 1024 +CACHE_CHUNK = 64 * 1024 + + +def _prune_cache(keep: Path | None = None) -> None: + try: + files = [ + p + for p in CACHE_DIR.iterdir() + if p.is_file() and p.suffix in (".mp3", ".part") + ] + except OSError: + return + total = 0 + stats: list[tuple[float, int, Path]] = [] + for path in files: + try: + st = path.stat() + except OSError: + continue + total += st.st_size + stats.append((st.st_mtime, st.st_size, path)) + for _, size, path in sorted(stats): + if total <= CACHE_LIMIT_BYTES: + break + if keep is not None and path == keep: + continue + try: + path.unlink() + total -= size + except OSError: + pass + + +def fetch_track_file( + api: Api, + track_id: str, + progress: "Callable[[int, int], None] | None" = None, +) -> tuple[Path, dict]: + """Скачивает трек в кэш и возвращает (путь, download_info_item).""" + item = _best_download_item(api, track_id) + preview = bool(item.get("preview")) + CACHE_DIR.mkdir(parents=True, exist_ok=True) + target = CACHE_DIR / f"{track_id}{'.preview' if preview else ''}.mp3" + + try: + if target.exists() and target.stat().st_size > 0: + os.utime(target) # обновляем mtime, чтобы кэш был LRU + return target, item + except OSError: + pass + + url = _stream_url(api, item) + part = target.with_name(f"{target.name}.{os.getpid()}.part") + try: + with requests.get( + url, + stream=True, + headers={"User-Agent": USER_AGENT}, + timeout=60, + ) as response: + response.raise_for_status() + expected = int(response.headers.get("Content-Length") or 0) + received = 0 + with part.open("wb") as handle: + for chunk in response.iter_content(CACHE_CHUNK): + if not chunk: + continue + handle.write(chunk) + received += len(chunk) + if progress is not None: + progress(received, expected) + if expected and received != expected: + die( + f"трек {track_id} скачался не полностью " + f"({received}/{expected} байт)") + os.replace(part, target) + except requests.RequestException as exc: + try: + part.unlink() + except OSError: + pass + die(f"не удалось скачать трек {track_id}: {exc}") + except BaseException: + try: + part.unlink() + except OSError: + pass + raise + + _prune_cache(keep=target) + return target, item def cmd_url(args: argparse.Namespace) -> None: api = Api() url, item = resolve_stream(api, args.track_id) if item.get("preview"): - print( - "# preview: полный трек недоступен без активной подписки", - file=sys.stderr) + print("# preview: полный трек недоступен без активной подписки", + file=sys.stderr) print(url) @@ -823,28 +934,29 @@ def play_url(player: str, url: str) -> None: def play_items( - api: Api, - tracks: list[dict], - explicit_player: str | None = None) -> None: + api: Api, + tracks: list[dict], + explicit_player: str | None = None, +) -> None: player = pick_player(explicit_player) if not player: die( - "не найден mpv/ffplay. Установи один из них или используй: " - "mtsmusic.py url ") + "не найден mpv/ffplay. Установи один из них " + "или используй: mtsmusic.py url ") for index, track in enumerate(tracks, 1): track_id = str(track.get("id")) print(f"\n▶ {index}/{len(tracks)} {fmt_track(track)}", file=sys.stderr) try: - url, item = resolve_stream(api, track_id) + path, item = fetch_track_file(api, track_id) except SystemExit: - print(" пропускаю: не удалось получить поток", file=sys.stderr) + print(" пропускаю: не удалось получить трек", file=sys.stderr) continue if item.get("preview"): print(" внимание: preview-фрагмент", file=sys.stderr) try: - play_url(player, url) + play_url(player, str(path)) except KeyboardInterrupt: print("\nОстановлено.", file=sys.stderr) return @@ -853,54 +965,279 @@ def play_items( def cmd_play(args: argparse.Namespace) -> None: api = Api() print_track_info(api, args.track_id) - url, item = resolve_stream(api, args.track_id) + path, item = fetch_track_file(api, args.track_id) if item.get("preview"): - print( - "Внимание: это preview-фрагмент (нет активной подписки).", - file=sys.stderr) + print("Внимание: это preview-фрагмент (нет активной подписки).", + file=sys.stderr) player = pick_player(args.player) if not player: die( - "не найден mpv/ffplay. Установи один из них или используй: " - "mtsmusic.py url ") + "не найден mpv/ffplay. Установи один из них " + "или используй: mtsmusic.py url ") try: - play_url(player, url) + play_url(player, str(path)) except KeyboardInterrupt: pass +BAD_FILENAME = re.compile(r'[\x00-\x1f<>:"/\\|?*]') + + +def safe_filename(name: str, fallback: str = "track") -> str: + """Убирает символы, недопустимые в именах файлов Windows/Linux/macOS.""" + name = BAD_FILENAME.sub("_", name) + name = re.sub(r"\s+", " ", name).strip(" .") + if not name: + name = fallback + return name[:150] + + +def track_file_name(track: dict) -> str: + artists = ", ".join(a.get("name", "?") for a in track.get("artists") or []) + title = track.get("title") or "?" + base = f"{artists} - {title}" if artists else title + return safe_filename(base or str(track.get("id") or "track")) + ".mp3" + + +def unique_file(directory: Path, filename: str, used: set[Path]) -> Path: + """Добавляет « (2)», « (3)», если такое имя уже занято в этой пачке.""" + target = directory / filename + stem = Path(filename).stem + suffix = Path(filename).suffix + counter = 2 + while target in used: + target = directory / f"{stem} ({counter}){suffix}" + counter += 1 + return target + + +def collect_download_tracks( + api: Api, + track_ids: list[str] | None = None, + *, + likes: bool = False, + wave: bool = False, + search: str | None = None, + number: int = 0, + on_error: "Callable[[str], None] | None" = None, +) -> list[dict]: + """Собирает треки для скачивания из ID, избранного, волны и поиска.""" + + def report(message: str) -> None: + if on_error is not None: + on_error(message) + else: + print(message, file=sys.stderr) + + tracks: list[dict] = [] + for track_id in track_ids or []: + try: + tracks.append(get_track(api, track_id)) + except SystemExit as exc: + report(f"пропускаю {track_id}: {exc}") + + if likes: + uid = get_uid(api) + limit = number if number and number > 0 else 10**6 + try: + tracks.extend(fetch_liked_tracks(api, uid, limit=limit)) + except SystemExit as exc: + report(f"не удалось получить избранное: {exc}") + + if wave: + limit = number if number and number > 0 else 10 + try: + tracks.extend(item.get("track") or {} + for item in collect_wave(api, limit)) + except SystemExit as exc: + report(f"не удалось получить волну: {exc}") + + if search: + limit = number if number and number > 0 else 10 + try: + tracks.extend(search_tracks(api, search, limit)) + except SystemExit as exc: + report(f"не удалось выполнить поиск: {exc}") + + # Убираем дубли, сохраняя порядок. + result: list[dict] = [] + seen: set[str] = set() + for track in tracks: + track_id = str(track.get("id") or "") + if not track_id or track_id in seen: + continue + seen.add(track_id) + result.append(track) + return result + + +def download_tracks( + api: Api, + tracks: list[dict], + destination: Path, + *, + force: bool = False, + output: Path | None = None, + pretty_names: bool = True, + tags: bool = True, + on_event: "Callable[[dict], None] | None" = None, +) -> tuple[int, int, int]: + """Скачивает треки в папку. Возвращает (сохранено, пропущено, ошибок). + + События для интерфейса: start, skipped, preview, saved, failed. + """ + + def emit(status: str, **extra) -> None: + if on_event is not None: + on_event({"status": status, **extra}) + + destination.mkdir(parents=True, exist_ok=True) + used: set[Path] = set() + saved = skipped = failed = 0 + + for index, track in enumerate(tracks, 1): + track_id = str(track.get("id")) + emit("start", index=index, total=len(tracks), track=track) + + if output is not None: + target = output + elif pretty_names or len(tracks) > 1: + target = unique_file(destination, track_file_name(track), used) + else: + target = destination / f"{track_id}.mp3" + + if target.exists() and target.stat().st_size > 0 and not force: + emit("skipped", index=index, total=len( + tracks), track=track, path=target) + used.add(target) + skipped += 1 + continue + used.add(target) + + try: + cached, item = fetch_track_file(api, track_id) + except SystemExit as exc: + emit( + "failed", + index=index, + total=len(tracks), + track=track, + path=target, + message=str(exc), + ) + failed += 1 + continue + if item.get("preview"): + emit("preview", index=index, total=len( + tracks), track=track, path=target) + + target.parent.mkdir(parents=True, exist_ok=True) + part = target.with_name(f".{target.name}.{os.getpid()}.part") + try: + shutil.copyfile(cached, part) + os.replace(part, target) + except OSError as exc: + try: + part.unlink() + except OSError: + pass + emit( + "failed", + index=index, + total=len(tracks), + track=track, + path=target, + message=str(exc), + ) + failed += 1 + continue + saved += 1 + if tags: + _write_track_tags(api, track, target) + emit("saved", index=index, total=len(tracks), track=track, path=target) + + return saved, skipped, failed + + +def _write_track_tags(api: Api, track: dict, path: Path) -> None: + """Пишет максимум ID3-метаданных; сбой тегов не срывает скачивание.""" + try: + from tags import tag_track + except ImportError: + return + try: + tag_track(api, track, path) + except Exception as exc: # noqa: BLE001 - теги не критичны для файла + print(f" теги не записаны: {exc}", file=sys.stderr) + + +def _collect_download_tracks(api: Api, args: argparse.Namespace) -> list[dict]: + return collect_download_tracks( + api, + args.track_ids, + likes=args.likes, + wave=args.wave, + search=args.search, + number=args.number, + ) + + +def _print_download_event(event: dict) -> None: + track = event["track"] + status = event["status"] + path = event.get("path") + if status == "start": + artists = ", ".join(a.get("name", "?") + for a in track.get("artists") or []) + title = f"{artists} — {track.get('title') or '?'}" + print(f"[{event['index']}/{event['total']}] {title}", file=sys.stderr) + elif status == "skipped": + print(f" уже есть: {path}", file=sys.stderr) + elif status == "preview": + print(" внимание: preview-фрагмент (нет подписки)", file=sys.stderr) + elif status == "saved": + print(f" сохранено: {path}", file=sys.stderr) + elif status == "failed": + print(f" ошибка: {event.get('message')}", file=sys.stderr) + + def cmd_download(args: argparse.Namespace) -> None: - from downloads import download_track, stream_to_file, tag_downloaded from settings import get_setting api = Api() - track = get_track(api, args.track_id) - print(fmt_track(track), file=sys.stderr) + require_token(api, "скачивание") - 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) + tracks = _collect_download_tracks(api, args) + if not tracks: + die("нечего скачивать: укажи track_id, --likes, --wave или --search") + + if args.output and len(tracks) > 1: + die("-o/--output можно использовать только для одного трека") - 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) + output = Path(args.output).expanduser() + destination = output.parent if str(output.parent) else Path(".") + elif args.dir: + destination = Path(args.dir).expanduser() else: - dest = args.dir or get_setting("download_dir") - output = download_track(api, track, dest, progress, with_tags) + destination = Path(get_setting("download_dir")).expanduser() - if sys.stderr.isatty(): - print(file=sys.stderr) - print(f"Сохранено: {output} ({output.stat().st_size} байт)") + saved, skipped, failed = download_tracks( + api, + tracks, + destination, + force=args.force, + output=Path(args.output).expanduser() if args.output else None, + pretty_names=True, + tags=not args.no_tags, + on_event=_print_download_event, + ) + + print(f"\nГотово: {saved} сохранено, {skipped} уже было, {failed} ошибок.") + if saved or skipped: + print(f"Папка: {destination}") # --------------------------------------------------------------------------- @@ -914,8 +1251,7 @@ def cmd_tui(args: argparse.Namespace) -> None: return if not sys.stdin.isatty() or not sys.stdout.isatty(): die( - "TUI нужно запускать в обычном терминале " - "(stdin/stdout не TTY).\n" + "TUI нужно запускать в обычном терминале (stdin/stdout не TTY).\n" "Запусти прямо в окне терминала: " ".venv/bin/python mtsmusic.py tui\n" "Если это IDE/панель вывода — открой настоящий терминал." @@ -924,14 +1260,11 @@ def cmd_tui(args: argparse.Namespace) -> None: from tui import MusicTui except ImportError as exc: die( - f"для TUI нужен textual и pillow: {exc}. Установи: " - "pip install -r requirements.txt" - ) + f"для TUI нужен textual и pillow: {exc}. " + "Установи: pip install -r requirements.txt") try: MusicTui(player=args.player).run( - inline=args.inline, - mouse=not args.no_mouse, - ) + inline=args.inline, mouse=not args.no_mouse) except KeyboardInterrupt: pass except Exception as exc: # TUI не должен молча исчезать @@ -971,8 +1304,8 @@ def cmd_settings(args: argparse.Namespace) -> None: key = key.strip() if key not in DEFAULTS: die( - f"неизвестная настройка: {key}. Доступны: { - ', '.join(DEFAULTS)}") + f"неизвестная настройка: {key}. " + f"Доступны: {', '.join(DEFAULTS)}") updates[key] = value current = save_settings(updates) saved = True @@ -991,10 +1324,8 @@ def print_tui_check() -> None: print(f"Python: {sys.executable}") print( - f"Версия: { - platform.python_version()} ({ - platform.system()} { - platform.release()})") + f"Версия: {platform.python_version()} " + f"({platform.system()} {platform.release()})") print(f"TERM: {os.environ.get('TERM', '?')}") print(f"COLORTERM: {os.environ.get('COLORTERM', '?')}") print(f"stdout TTY: {sys.stdout.isatty()}") @@ -1016,8 +1347,8 @@ def print_tui_check() -> None: for name in ("ffplay", "mpv"): print(f"{name}: {shutil.which(name) or 'нет'}") print( - f"Креды: { - 'есть' if CREDS_PATH.exists() else 'нет'} ({CREDS_PATH})") + f"Креды: {'есть' if CREDS_PATH.exists() else 'нет'} " + f"({CREDS_PATH})") if size.columns < 90 or size.lines < 30: print("\nСовет: окно маленькое, TUI лучше смотреть при >= 90x30.") @@ -1031,10 +1362,8 @@ def build_parser() -> argparse.ArgumentParser: login = sub.add_parser( "login", help="войти через браузер и сохранить токен") - login.add_argument( - "--headless", - action="store_true", - help="без окна браузера (капчу не пройти)") + login.add_argument("--headless", action="store_true", + help="без окна браузера (капчу не пройти)") login.add_argument("--timeout", type=int, default=600, help="секунд на вход (по умолчанию 600)") login.set_defaults(func=cmd_login) @@ -1044,21 +1373,15 @@ def build_parser() -> argparse.ArgumentParser: search = sub.add_parser("search", help="поиск треков") search.add_argument("query", help="поисковый запрос") - search.add_argument( - "-n", - "--limit", - type=int, - default=10, - help="сколько результатов (по умолчанию 10)") + search.add_argument("-n", "--limit", type=int, default=10, + help="сколько результатов (по умолчанию 10)") search.set_defaults(func=cmd_search) mix = sub.add_parser("mix", help="«Моя волна»: подборка треков") mix.add_argument("-n", "--number", type=int, default=10, help="сколько треков (по умолчанию 10)") - mix.add_argument( - "--play", - action="store_true", - help="сразу воспроизвести подборку") + mix.add_argument("--play", action="store_true", + help="сразу воспроизвести подборку") mix.add_argument("--player", help="путь к mpv/ffplay") mix.set_defaults(func=cmd_mix) @@ -1070,26 +1393,29 @@ def build_parser() -> argparse.ArgumentParser: unlike = sub.add_parser("unlike", help="убрать трек из избранного") unlike.add_argument("track_id") unlike.add_argument( - "--album", - help="id альбома (если нужно переопределить)") + "--album", help="id альбома (если нужно переопределить)") unlike.set_defaults(func=cmd_unlike) - likes = sub.add_parser("likes", help="показать избранные треки") + likes = sub.add_parser("likes", help="показать избранные треки текстом") likes.add_argument( "-n", "--number", type=int, - default=50, - help="сколько показать (по умолчанию 50)") + default=0, + help="сколько показать (0 = все)", + ) + likes.add_argument( + "--ids", + action="store_true", + help="вывести только ID (по одному в строке)", + ) likes.set_defaults(func=cmd_likes) lyrics = sub.add_parser( "lyrics", help="показать или сохранить текст песни") lyrics.add_argument("track_id") - lyrics.add_argument( - "--lrc", - action="store_true", - help="формат LRC с таймкодами") + lyrics.add_argument("--lrc", action="store_true", + help="формат LRC с таймкодами") lyrics.add_argument("-o", "--output", help="сохранить текст в файл") lyrics.add_argument( "--no-fallback", @@ -1101,28 +1427,25 @@ def build_parser() -> argparse.ArgumentParser: tui = sub.add_parser( "tui", help="полноценный TUI-плеер (мышь, обложка, избранное)") tui.add_argument("--player", help="путь к mpv/ffplay") - tui.add_argument( - "--check", - action="store_true", - help="диагностика окружения и выход") + tui.add_argument("--check", action="store_true", + help="диагностика окружения и выход") tui.add_argument( "--inline", action="store_true", help=( "без alternate screen: рисовать прямо в терминале " - "(если экран пустой)"), + "(если экран пустой)" + ), ) - tui.add_argument( - "--no-mouse", - action="store_true", - help="отключить поддержку мыши") + tui.add_argument("--no-mouse", action="store_true", + help="отключить поддержку мыши") tui.set_defaults(func=cmd_tui) qt = sub.add_parser("qt", help="настольное Qt-приложение (PySide6)") qt.set_defaults(func=cmd_qt) - settings = sub.add_parser("settings", - help="показать или изменить настройки") + settings = sub.add_parser( + "settings", help="показать или изменить настройки") settings.add_argument( "-s", "--set", @@ -1141,13 +1464,36 @@ def build_parser() -> argparse.ArgumentParser: play.add_argument("--player", help="путь к mpv/ffplay") play.set_defaults(func=cmd_play) - download = sub.add_parser("download", help="скачать трек в MP3") - download.add_argument("track_id") - download.add_argument("-o", "--output", help="имя файла") + download = sub.add_parser( + "download", help="скачать трек или подборку в MP3" + ) download.add_argument( - "-d", - "--dir", - help="папка для сохранения (по умолчанию из download_dir)", + "track_ids", nargs="*", help="ID треков (можно несколько)" + ) + download.add_argument( + "-d", "--dir", help="папка для сохранения (по умолчанию download_dir)" + ) + download.add_argument( + "-o", "--output", help="имя файла (только для одного трека)" + ) + download.add_argument( + "--likes", action="store_true", help="скачать всё избранное" + ) + download.add_argument( + "--wave", action="store_true", help="скачать подборку «Моей волны»" + ) + download.add_argument( + "--search", metavar="QUERY", help="скачать результаты поиска" + ) + download.add_argument( + "-n", + "--number", + type=int, + default=0, + help="сколько треков: 0 — все для избранного, 10 для волны/поиска", + ) + download.add_argument( + "--force", action="store_true", help="перезаписать существующие файлы" ) download.add_argument( "--no-tags", diff --git a/qt_app.py b/qt_app.py index b03bd26..be1f008 100644 --- a/qt_app.py +++ b/qt_app.py @@ -4,6 +4,7 @@ from __future__ import annotations import sys +from pathlib import Path from PySide6.QtCore import ( QEasingCurve, @@ -53,6 +54,7 @@ from PySide6.QtWidgets import ( QListWidgetItem, QMainWindow, QMessageBox, + QProgressBar, QPushButton, QSizePolicy, QSlider, @@ -75,6 +77,8 @@ from media import ( from mtsmusic import ( USER_AGENT, Api, + collect_download_tracks, + download_tracks, fetch_liked_tracks, fetch_mix_batch, fmt_duration, @@ -83,7 +87,7 @@ from mtsmusic import ( lrclib_lyrics, lyrics_response, lyrics_text, - resolve_stream, + fetch_track_file, ) from settings import ( START_QUEUE_LABELS, @@ -314,49 +318,42 @@ class Task(QRunnable): pass -class DownloadTask(QRunnable): - """Скачивает список треков в фоне и сообщает о прогрессе.""" +class ProgressTask(QRunnable): + """Задача, которая умеет присылать прогресс в GUI-поток.""" class Signals(QObject): - progress = Signal(str) - done = Signal(int, int, str) - failed = Signal(str) + progress = Signal(object) + done = Signal(object, object) + failed = Signal(object, str) - def __init__(self, api, tracks: list[dict], dest: str) -> None: + def __init__(self, fn) -> None: super().__init__() - self.api = api - self.tracks = tracks - self.dest = dest - self.signals = DownloadTask.Signals() + self.fn = fn + self.signals = ProgressTask.Signals() self.setAutoDelete(False) def run(self) -> None: - from downloads import download_many - - def on_item(index: int, count: int, track: dict) -> None: - title = str(track.get("title") or "") - self._emit(f"Скачиваю {index}/{count}: {title}") - try: - saved = download_many( - self.api, self.tracks, self.dest, on_item=on_item) + result = self.fn(self._emit_progress) + except SystemExit as exc: + self._emit_failed(str(exc) or "ошибка") except Exception as exc: # noqa: BLE001 - сообщение показываем в UI self._emit_failed(str(exc) or exc.__class__.__name__) else: try: - self.signals.done.emit(len(saved), len(self.tracks), self.dest) + self.signals.done.emit(self, result) except RuntimeError: pass - def _emit(self, text: str) -> None: + def _emit_progress(self, event: dict) -> None: try: - self.signals.progress.emit(text) + self.signals.progress.emit(event) except RuntimeError: pass def _emit_failed(self, message: str) -> None: try: - self.signals.failed.emit(message) + self.signals.failed.emit(self, message) except RuntimeError: pass @@ -597,6 +594,7 @@ class QtMusicApp(QMainWindow): self.current_index = -1 self.liked_ids: set[str] = set() self.favorites: list[dict] = [] + self.search_results: list[dict] = [] self.history: list[dict] = load_history() self.cover_cache: dict[str, QPixmap] = {} self.current_cover_url = "" @@ -617,7 +615,6 @@ class QtMusicApp(QMainWindow): self.pool = QThreadPool(self) self.pool.setMaxThreadCount(4) self.covers = QNetworkAccessManager(self) - self._download_tasks: set[DownloadTask] = set() self.player = QMediaPlayer(self) self.audio = QAudioOutput(self) @@ -729,6 +726,9 @@ class QtMusicApp(QMainWindow): self.settings_page = self._build_settings_page() self.pages.addWidget(self.settings_page) + self.download_page = self._build_download_page() + self.pages.addWidget(self.download_page) + self._setup_tab_order() def _setup_tab_order(self) -> None: @@ -1078,6 +1078,150 @@ class QtMusicApp(QMainWindow): layout.addStretch(1) return page + def _build_download_page(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(18, 10, 18, 14) + layout.setSpacing(12) + layout.addLayout(self._page_header("Скачать")) + + dir_label = QLabel("Папка для сохранения") + dir_label.setObjectName("settingLabel") + layout.addWidget(dir_label) + + dir_row = QHBoxLayout() + dir_row.setSpacing(8) + self.download_dir = QLineEdit( + str(self.settings.get("download_dir") + or Path.home() / "Music" / "MTS") + ) + self.download_dir.setObjectName("downloadDir") + dir_row.addWidget(self.download_dir, 1) + browse = QPushButton("Обзор…") + browse.setObjectName("actionBtn") + browse.setCursor(Qt.CursorShape.PointingHandCursor) + browse.clicked.connect(self.browse_download_dir) + dir_row.addWidget(browse) + layout.addLayout(dir_row) + + source_label = QLabel("Что скачать") + source_label.setObjectName("settingLabel") + layout.addWidget(source_label) + + self.download_source = QComboBox() + self.download_source.setObjectName("settingCombo") + for text, kind in ( + ("Текущий трек", "current"), + ("Всё избранное", "likes"), + ("Моя волна (20 треков)", "wave"), + ("Результаты поиска", "search"), + ): + self.download_source.addItem(text, kind) + layout.addWidget(self.download_source) + + self.download_btn = QPushButton("Скачать") + self.download_btn.setObjectName("actionBtn") + self.download_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.download_btn.clicked.connect(self.start_download) + layout.addWidget(self.download_btn) + + self.download_progress = QProgressBar() + self.download_progress.setRange(0, 1) + self.download_progress.setValue(0) + self.download_progress.setTextVisible(False) + layout.addWidget(self.download_progress) + + self.download_status = QLabel( + "Уже скачанные файлы пропускаются, имена — " + "«Исполнитель - Название.mp3»." + ) + self.download_status.setObjectName("pageStatus") + self.download_status.setWordWrap(True) + layout.addWidget(self.download_status) + layout.addStretch(1) + return page + + # -- скачивание ------------------------------------------------------- + + def show_download(self) -> None: + self._show(self.download_page) + + def browse_download_dir(self) -> None: + directory = QFileDialog.getExistingDirectory( + self, "Папка для скачивания", self.download_dir.text().strip() + ) + if directory: + self.download_dir.setText(directory) + + def start_download(self) -> None: + raw = self.download_dir.text().strip() + if not raw: + self.toast("Укажи папку для сохранения") + return + destination = Path(raw).expanduser() + self.update_setting("download_dir", str(destination)) + if hasattr(self, "download_dir_edit"): + self.download_dir_edit.setText(str(destination)) + kind = self.download_source.currentData() + if kind == "current" and not self.current: + self.toast("Сначала выбери трек") + return + if kind == "search" and not self.search_results: + self.toast("Сначала выполни поиск") + return + self.download_btn.setEnabled(False) + self.download_progress.setRange(0, 0) + self.download_status.setText("Готовлю список…") + self.run_progress_task( + lambda progress: self._download_job(kind, destination, progress), + self.download_done, + self.download_failed, + ) + + def _download_job(self, kind: str, destination: Path, progress) -> tuple: + if kind == "current": + tracks = [self.current] + elif kind == "likes": + tracks = collect_download_tracks(self.api, likes=True) + elif kind == "wave": + tracks = collect_download_tracks(self.api, wave=True, number=20) + else: + tracks = list(self.search_results) + if not tracks: + raise ValueError("нечего скачивать") + saved, skipped, failed = download_tracks( + self.api, tracks, destination, on_event=progress + ) + return saved, skipped, failed, destination + + def on_download_progress(self, event: dict) -> None: + track = event["track"] + self.download_progress.setRange(0, event["total"]) + self.download_progress.setValue(event["index"] - 1) + title = f"{track_artist(track)} — {track.get('title') or '?'}" + self.download_status.setText( + f"[{event['index']}/{event['total']}] {title}") + + def download_done(self, result: tuple) -> None: + saved, skipped, failed, destination = result + self.download_btn.setEnabled(True) + self.download_progress.setRange(0, 1) + self.download_progress.setValue(1) + self.set_top_status(self.queue_name) + message = ( + f"Готово: {saved} сохранено, {skipped} уже было, " + f"{failed} ошибок" + ) + self.download_status.setText(f"{message}\n{destination}") + self.toast(message) + + def download_failed(self, message: str) -> None: + self.download_btn.setEnabled(True) + self.download_progress.setRange(0, 1) + self.download_progress.setValue(0) + self.download_status.setText(f"Ошибка: {message}") + self.toast(message) + # -- настройки -------------------------------------------------------- def update_setting(self, key: str, value) -> None: @@ -1118,14 +1262,14 @@ class QtMusicApp(QMainWindow): self.download_dir_edit.setText(chosen) self.toast(f"Папка загрузок: {chosen}") - # -- скачивание ------------------------------------------------------- + # -- быстрое скачивание из меню --------------------------------------- def download_current(self) -> None: self.close_drawer() if not self.current: self.toast("Сначала выбери трек") return - self.download_tracks([self.current], "трек") + self._quick_download([self.current]) def download_queue(self) -> None: self.close_drawer() @@ -1133,36 +1277,32 @@ class QtMusicApp(QMainWindow): if not tracks: self.toast("Список пуст") return - self.download_tracks(tracks, "список") + self._quick_download(list(tracks)) - def download_tracks(self, tracks: list[dict], label: str) -> None: - if not tracks: - return + def _quick_download(self, tracks: list[dict]) -> None: dest = str(self.settings.get("download_dir") or "") - task = DownloadTask(self.api, list(tracks), dest) - task.signals.progress.connect(self.set_top_status) - task.signals.done.connect( - lambda saved, total, folder, t=task: - self._download_done(t, saved, total, folder)) - task.signals.failed.connect( - lambda message, t=task: self._download_failed(t, message)) - self._download_tasks.add(task) - self.set_top_status(f"Скачиваю {label}…") - self.pool.start(task) + if not dest: + self.toast("Укажи папку загрузок в настройках") + return + destination = Path(dest).expanduser() + self.download_btn.setEnabled(False) + self.download_progress.setRange(0, 0) + self.download_status.setText("Готовлю список…") + self.set_top_status("Скачиваю…") + self.run_progress_task( + lambda progress: self._download_tracks_job( + tracks, destination, progress), + self.download_done, + self.download_failed, + ) - def _download_done( - self, task: DownloadTask, saved: int, total: int, dest: str - ) -> None: - self._download_tasks.discard(task) - self.set_top_status(self.queue_name) - if saved: - self.toast(f"Скачано {saved}/{total} → {dest}") - else: - self.toast("Не удалось скачать") - - def _download_failed(self, task: DownloadTask, message: str) -> None: - self._download_tasks.discard(task) - self.on_error(message) + def _download_tracks_job( + self, tracks: list[dict], destination: Path, progress + ) -> tuple: + saved, skipped, failed = download_tracks( + self.api, tracks, destination, on_event=progress + ) + return saved, skipped, failed, destination def show_settings(self) -> None: self._show(self.settings_page) @@ -1191,6 +1331,7 @@ class QtMusicApp(QMainWindow): ("Избранное", self.show_favorites), ("История", self.show_history), ("Поиск", self.show_search), + ("Скачать", self.show_download), ("Текст песни", self.show_lyrics), ("Скачать трек", self.download_current), ("Скачать список", self.download_queue), @@ -1314,6 +1455,16 @@ class QtMusicApp(QMainWindow): self._tasks.add(task) self.pool.start(task) + def run_progress_task(self, fn, done, failed) -> None: + task = ProgressTask(fn) + task.done_cb = done + task.failed_cb = failed + task.signals.done.connect(self._task_done) + task.signals.failed.connect(self._task_failed) + task.signals.progress.connect(self.on_download_progress) + self._tasks.add(task) + self.pool.start(task) + def _task_done(self, task: Task, result) -> None: if task not in self._tasks: return @@ -1496,7 +1647,7 @@ class QtMusicApp(QMainWindow): self.set_top_status("Загрузка…") self.run_task( - lambda: resolve_stream(self.api, str(track.get("id"))), + lambda: fetch_track_file(self.api, str(track.get("id"))), lambda result: self.start_playback(token, track, result), self.on_error, ) @@ -1506,9 +1657,9 @@ class QtMusicApp(QMainWindow): ) -> None: if token != self.play_token: return - url, item = result + path, item = result self._preview = bool(item.get("preview")) - self.player.setSource(QUrl(url)) + self.player.setSource(QUrl.fromLocalFile(str(path))) self.player.play() self.history = record_history(track) title = str(track.get("title") or "") @@ -1931,6 +2082,7 @@ class QtMusicApp(QMainWindow): self.run_task(search, self.apply_search, self.search_failed) def apply_search(self, tracks: list[dict]) -> None: + self.search_results = tracks self.fill_track_list(self.search_list, tracks, "Поиск") self.search_status.setText( f"{len(tracks)} результат(ов)" if tracks else "Ничего не найдено" diff --git a/tui.py b/tui.py index 66e9e04..1741c05 100644 --- a/tui.py +++ b/tui.py @@ -4,6 +4,7 @@ from __future__ import annotations import io +from pathlib import Path import requests from PIL import Image @@ -34,13 +35,15 @@ from media import ( from mtsmusic import ( USER_AGENT, Api, + collect_download_tracks, + download_tracks, + fetch_track_file, fmt_duration, force_ipv4, get_uid, lrclib_lyrics, lyrics_response, lyrics_text, - resolve_stream, ) from player import MusicPlayer from mpris import MprisService, track_object_path @@ -307,6 +310,7 @@ class SearchScreen(Screen): def fill(self, tracks: list[dict]) -> None: self.results = tracks + self.app.search_results = tracks table = self.query_one("#list-table", DataTable) table.clear() for track in tracks: @@ -486,6 +490,138 @@ class SettingsScreen(Screen): 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]): BINDINGS = [Binding("escape", "close", "Закрыть")] @@ -496,8 +632,9 @@ class MenuScreen(ModalScreen[str | None]): yield Button("Избранное", id="m-favorites") yield Button("История", id="m-history") yield Button("Поиск", id="m-search") + yield Button("Скачать", id="m-download") 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-settings") yield Static("", id="menu-spacer") @@ -712,6 +849,12 @@ class MusicTui(App): .stoggle { width: 16; min-width: 12; } #dir-value { padding: 0 0 1 0; color: #8a8a8a; } #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 = [ @@ -742,6 +885,7 @@ class MusicTui(App): "volume", 80))) self.wave: list[dict] = [] self.favorites: list[dict] = [] + self.search_results: list[dict] = [] self.liked_ids: set[str] = set() self.history: list[dict] = load_history() self.queue: list[dict] = [] @@ -1077,7 +1221,7 @@ class MusicTui(App): if not api: return try: - url, item = resolve_stream(api, str(track.get("id"))) + path, item = fetch_track_file(api, str(track.get("id"))) except SystemExit as exc: self.call_from_thread( self.notify, @@ -1085,7 +1229,8 @@ class MusicTui(App): severity="error") return 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( @@ -1291,9 +1436,11 @@ class MusicTui(App): self.push_screen(HistoryScreen(self.history)) elif action == "m-search": self.push_screen(SearchScreen()) + elif action == "m-download": + self.push_screen(DownloadScreen()) elif action == "m-lyrics": self.action_show_lyrics() - elif action == "m-download": + elif action == "m-download-current": self.action_download_current() elif action == "m-download-list": self.action_download_list() @@ -1331,22 +1478,30 @@ class MusicTui(App): @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 "") + if not dest: + self.call_from_thread( + self.notify, + "Не задана папка загрузок", + severity="warning", + ) + return - def on_item(index: int, count: int, track: dict) -> None: - title = str(track.get("title") or "") + def on_event(event: dict) -> None: + if event.get("status") != "start": + return + title = str(event["track"].get("title") or "") self.call_from_thread( 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.download_finished, label, - len(saved), + saved, len(tracks), dest, )