diff --git a/README.md b/README.md index 9da0552..859ca4d 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,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-приложение @@ -114,6 +121,13 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu Для этого нужен `dbus-next` (он уже в `requirements.txt`). Без сессионного D-Bus или без `dbus-next` приложение просто работает как обычно, без интеграции. +## Скачивание из интерфейса + +В меню обоих клиентов есть пункт «Скачать»: текущий трек, всё избранное, +20 треков «Моей волны» или последние результаты поиска. Папка выбирается +диалогом в Qt и вводится вручную в TUI, прогресс и число ошибок видны на +экране. Скачивание идёт тем же кодом, что и в CLI. + ## Настройки Настройки открываются из меню (пункт «Настройки» в TUI и Qt) или из командной @@ -154,10 +168,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 @@ -173,8 +189,20 @@ D-Bus или без `dbus-next` приложение просто работае # воспроизведение (нужен mpv или ffplay) .venv/bin/python mtsmusic.py play 12345678 -# скачивание +# скачивание одного или нескольких треков в свою папку +.venv/bin/python mtsmusic.py download 12345678 -d ~/Music/MTS +.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 — пункт «Скачать» # настройки .venv/bin/python mtsmusic.py settings @@ -216,6 +244,11 @@ TUI нужно запускать в настоящем терминале (stdi - Использовать только для личного прослушивания, не перераздавать контент. - Если `api.music.yandex.net` недоступен, проверь IPv6: клиент принудительно ходит по IPv4. +- Кэш треков лежит в `.mts-music/cache/`. Его можно удалить в любой момент — + при следующем запуске треки скачаются заново. +- `download` (и пункт «Скачать» в TUI/Qt) по умолчанию не перезаписывает уже + скачанные файлы: повторный запуск докачает только новое. Флаг `--force` + в CLI включает перезапись. ## Лицензия diff --git a/mtsmusic.py b/mtsmusic.py index 717a38c..0fbf152 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,16 @@ 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,12 +55,10 @@ _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( - host, port, socket.AF_INET, type, proto, flags) + return _orig_getaddrinfo(host, port, socket.AF_INET, type, proto, flags) socket.getaddrinfo = getaddrinfo @@ -91,12 +84,7 @@ 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: @@ -132,9 +120,7 @@ def get_auth_settings(reference_uri: str = "https://music.mts.ru/") -> dict: timeout=30, ) if response.status_code != 200: - die( - f"auth_settings вернул HTTP {response.status_code}: " - f"{response.text[:300]}") + die(f"auth_settings вернул HTTP {response.status_code}: {response.text[:300]}") return response.json() @@ -142,9 +128,7 @@ def cmd_login(args: argparse.Namespace) -> None: try: from playwright.sync_api import sync_playwright except ImportError: - die( - "не установлен playwright. Выполни: " - "pip install -r requirements.txt") + die("не установлен playwright. Выполни: pip install -r requirements.txt") settings = get_auth_settings() authorize_uri = settings["web_sso_authorize_uri"] @@ -172,20 +156,16 @@ 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) + query = urllib.parse.parse_qs(urllib.parse.urlparse(page.url).query) token = (query.get("yatoken") or [None])[0] if token: break page.wait_for_timeout(1000) if not token: - die( - "не дождался токена. Вход не завершён " - "или истекло время ожидания.") + die("не дождался токена. Вход не завершён или истекло время ожидания.") ya_uid = page.evaluate("localStorage.getItem('ya_uid')") user_id = page.evaluate("localStorage.getItem('userId')") @@ -234,11 +214,7 @@ class Api: def post(self, path: str, **params) -> dict: return self._request("POST", path, params=params) - def _request( - self, - method: str, - path: str, - params: dict | None = None) -> dict: + def _request(self, method: str, path: str, params: dict | None = None) -> dict: force_ipv4() last_error: Exception | None = None for attempt in range(3): @@ -260,24 +236,18 @@ class Api: raise AssertionError # для type checker if response.status_code == 401: - die( - "токен не принят (401). Выполни заново: " - "python mtsmusic.py login") + die("токен не принят (401). Выполни заново: python mtsmusic.py login") if response.status_code == 404: die(f"не найдено (404) на {path}") if response.status_code != 200: - die( - f"HTTP {response.status_code} на {path}: " - f"{response.text[:300]}") + die(f"HTTP {response.status_code} на {path}: {response.text[:300]}") return response.json() def get_url(self, url: str) -> dict: force_ipv4() response = self.session.get(url, headers=self._headers(), timeout=30) if response.status_code != 200: - die( - f"HTTP {response.status_code} на {url[:120]}: " - f"{response.text[:300]}") + die(f"HTTP {response.status_code} на {url[:120]}: {response.text[:300]}") return response.json() @@ -287,10 +257,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 +311,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 +325,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)) @@ -373,27 +345,17 @@ def cmd_status(args: argparse.Namespace) -> None: payload = api.get("/account/mts/status") result = payload.get("result") or {} account = result.get("account") or {} - subscriptions = ((result.get("masterhub") or {}).get( - "activeSubscriptions")) or [] + subscriptions = ((result.get("masterhub") or {}).get("activeSubscriptions")) or [] # BFF даёт человекочитаемое название тарифа. bff_profile = bff_get("/v1/user-profile") or {} profile = bff_profile.get("profile") or {} bff_subscriptions = bff_profile.get("subscriptions") or [] - alias = ( - 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"Имя: {profile.get('alias') or account.get('fullName') or account.get('displayName') or account.get('login') or '?'}") + print(f"Телефон: {profile.get('phone') or account.get('phone') or '?'}") + print(f"Подписка: {'активна' if subscriptions or bff_subscriptions else 'нет'}") if bff_subscriptions: for sub in bff_subscriptions: name = sub.get("name") or sub.get("id") or "?" @@ -401,15 +363,13 @@ def cmd_status(args: argparse.Namespace) -> None: print(f" - {name}{f' (до {until})' if until else ''}") else: for sub in subscriptions: - name = sub.get("name") or sub.get("productName") or sub.get( - "subscriptionId") or sub.get("id") + name = sub.get("name") or sub.get("productName") or sub.get("subscriptionId") or sub.get("id") until = sub.get("expires") or sub.get("expirationDate") print(f" - {name}{f' (до {until})' if until else ''}") # Дописываем 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) @@ -427,9 +387,7 @@ def get_uid(api: Api) -> str: result = api.get("/account/mts/status").get("result") or {} uid = (result.get("account") or {}).get("uid") if not uid: - die( - "не удалось определить uid аккаунта. Выполни: " - "python mtsmusic.py login") + die("не удалось определить uid аккаунта. Выполни: python mtsmusic.py login") creds = load_credentials() or {} creds["ya_uid"] = str(uid) save_credentials(creds) @@ -456,25 +414,21 @@ 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]: """Избранные треки с деталями: /tracks принимает не больше 50 id за раз.""" payload = api.get(f"/users/{uid}/likes/tracks") - refs = ((payload.get("result") or {}).get( - "library") or {}).get("tracks") or [] + refs = ((payload.get("result") or {}).get("library") or {}).get("tracks") or [] ids = [str(ref.get("id")) for ref in refs[:limit]] tracks: list[dict] = [] for start in range(0, len(ids), 50): - chunk = ids[start: start + 50] + 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 +438,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 +475,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("волна вернула пустой список") @@ -531,8 +492,7 @@ def cmd_mix(args: argparse.Namespace) -> None: print_mix_item(index, item) if args.play: - play_items(api, [item.get("track") or {} - for item in collected], args.player) + play_items(api, [item.get("track") or {} for item in collected], args.player) def cmd_like(args: argparse.Namespace) -> None: @@ -568,23 +528,30 @@ 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 за раз — бьём на порции. ids = [str(item.get("id")) for item in liked] for start in range(0, len(ids), 50): - chunk = ids[start: start + 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) @@ -595,18 +562,13 @@ def cmd_likes(args: argparse.Namespace) -> None: print(f"{index:>3}. {track_id:>12} (альбом {album})") -def lyrics_response( - api: Api, - track_id: str, - fmt: str = "TEXT") -> requests.Response: +def lyrics_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( @@ -662,11 +624,7 @@ def lrclib_lyrics(track: dict, want_sync: bool) -> str | None: candidates: list[dict] = [] for params in queries: try: - response = requests.get( - LRCLIB_URL, - params=params, - headers=headers, - timeout=20) + response = requests.get(LRCLIB_URL, params=params, headers=headers, timeout=20) except requests.RequestException: continue if response.status_code != 200: @@ -686,18 +644,13 @@ 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 - exact = 0 if (item.get("trackName") or "").casefold( - ) == title.casefold() else 50 + 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 usable = [item for item in candidates if (item.get(field) or "").strip()] if not usable and want_sync: - usable = [item for item in candidates if ( - item.get("plainLyrics") or "").strip()] + usable = [item for item in candidates if (item.get("plainLyrics") or "").strip()] field = "plainLyrics" if not usable: return None @@ -735,9 +688,7 @@ def cmd_lyrics(args: argparse.Namespace) -> None: if args.output: output = Path(args.output) output.write_text(text, encoding="utf-8") - print( - f"Сохранено: {output} ({ - len(text)} символов, источник: {source})") + print(f"Сохранено: {output} ({len(text)} символов, источник: {source})") else: if source != "MTS": print(f"# источник текста: {source}", file=sys.stderr) @@ -751,8 +702,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 +710,13 @@ 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 +727,117 @@ 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} скачался не полностью ({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) @@ -822,29 +871,23 @@ def play_url(player: str, url: str) -> None: raise -def play_items( - api: Api, - tracks: list[dict], - explicit_player: str | None = None) -> None: +def play_items(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 ") + die("не найден 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) + 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,55 +896,254 @@ 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 ") + die("не найден 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, + 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 + emit("saved", index=index, total=len(tracks), track=track, path=target) + + return saved, skipped, failed + + +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: 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") + require_token(api, "скачивание") - 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) - print(file=sys.stderr) - print(f"Сохранено: {output} ({output.stat().st_size} байт)") + 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 можно использовать только для одного трека") + + if args.output: + output = Path(args.output).expanduser() + destination = output.parent if str(output.parent) else Path(".") + elif args.dir: + destination = Path(args.dir).expanduser() + else: + destination = Path.cwd() + + saved, skipped, failed = download_tracks( + api, + tracks, + destination, + force=args.force, + output=Path(args.output).expanduser() if args.output else None, + pretty_names=bool(args.dir), + on_event=_print_download_event, + ) + + print(f"\nГотово: {saved} сохранено, {skipped} уже было, {failed} ошибок.") + if saved or skipped: + print(f"Папка: {destination}") # --------------------------------------------------------------------------- @@ -915,24 +1157,16 @@ def cmd_tui(args: argparse.Namespace) -> None: return if not sys.stdin.isatty() or not sys.stdout.isatty(): die( - "TUI нужно запускать в обычном терминале " - "(stdin/stdout не TTY).\n" - "Запусти прямо в окне терминала: " - ".venv/bin/python mtsmusic.py tui\n" + "TUI нужно запускать в обычном терминале (stdin/stdout не TTY).\n" + "Запусти прямо в окне терминала: .venv/bin/python mtsmusic.py tui\n" "Если это IDE/панель вывода — открой настоящий терминал." ) try: from tui import MusicTui except ImportError as exc: - die( - f"для TUI нужен textual и pillow: {exc}. Установи: " - "pip install -r requirements.txt" - ) + die(f"для TUI нужен textual и pillow: {exc}. Установи: pip install -r requirements.txt") try: - MusicTui(player=args.player).run( - inline=args.inline, - mouse=not args.no_mouse, - ) + MusicTui(player=args.player).run(inline=args.inline, mouse=not args.no_mouse) except KeyboardInterrupt: pass except Exception as exc: # TUI не должен молча исчезать @@ -971,9 +1205,7 @@ def cmd_settings(args: argparse.Namespace) -> None: key, value = pair.split("=", 1) key = key.strip() if key not in DEFAULTS: - die( - f"неизвестная настройка: {key}. Доступны: { - ', '.join(DEFAULTS)}") + die(f"неизвестная настройка: {key}. Доступны: {', '.join(DEFAULTS)}") updates[key] = value current = save_settings(updates) saved = True @@ -991,11 +1223,7 @@ def print_tui_check() -> None: import platform print(f"Python: {sys.executable}") - print( - f"Версия: { - platform.python_version()} ({ - platform.system()} { - platform.release()})") + print(f"Версия: {platform.python_version()} ({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,9 +1244,7 @@ def print_tui_check() -> None: print("pillow: НЕ УСТАНОВЛЕН") for name in ("ffplay", "mpv"): print(f"{name}: {shutil.which(name) or 'нет'}") - print( - f"Креды: { - 'есть' if CREDS_PATH.exists() else 'нет'} ({CREDS_PATH})") + print(f"Креды: {'есть' if CREDS_PATH.exists() else 'нет'} ({CREDS_PATH})") if size.columns < 90 or size.lines < 30: print("\nСовет: окно маленькое, TUI лучше смотреть при >= 90x30.") @@ -1030,14 +1256,9 @@ def build_parser() -> argparse.ArgumentParser: ) sub = parser.add_subparsers(dest="command", required=True) - login = sub.add_parser( - "login", help="войти через браузер и сохранить токен") - login.add_argument( - "--headless", - action="store_true", - help="без окна браузера (капчу не пройти)") - login.add_argument("--timeout", type=int, default=600, - help="секунд на вход (по умолчанию 600)") + login = sub.add_parser("login", 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) status = sub.add_parser("status", help="показать аккаунт и подписку") @@ -1045,21 +1266,12 @@ 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("-n", "--number", type=int, default=10, help="сколько треков (по умолчанию 10)") + mix.add_argument("--play", action="store_true", help="сразу воспроизвести подборку") mix.add_argument("--player", help="путь к mpv/ffplay") mix.set_defaults(func=cmd_mix) @@ -1070,27 +1282,27 @@ def build_parser() -> argparse.ArgumentParser: unlike = sub.add_parser("unlike", help="убрать трек из избранного") unlike.add_argument("track_id") - unlike.add_argument( - "--album", - help="id альбома (если нужно переопределить)") + unlike.add_argument("--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 = 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", @@ -1099,31 +1311,21 @@ def build_parser() -> argparse.ArgumentParser: ) lyrics.set_defaults(func=cmd_lyrics) - tui = sub.add_parser( - "tui", help="полноценный TUI-плеер (мышь, обложка, избранное)") + 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: рисовать прямо в терминале " - "(если экран пустой)"), + 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", @@ -1142,9 +1344,35 @@ 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("track_ids", nargs="*", help="ID треков (можно несколько)") + download.add_argument( + "-d", "--dir", help="папка для сохранения (по умолчанию текущая)" + ) + 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.set_defaults(func=cmd_download) return parser diff --git a/qt_app.py b/qt_app.py index ae18375..f2ab178 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, @@ -44,6 +45,7 @@ from PySide6.QtWidgets import ( QAbstractButton, QCheckBox, QComboBox, + QFileDialog, QFrame, QHBoxLayout, QLabel, @@ -52,6 +54,7 @@ from PySide6.QtWidgets import ( QListWidgetItem, QMainWindow, QMessageBox, + QProgressBar, QPushButton, QSizePolicy, QSlider, @@ -74,6 +77,8 @@ from media import ( from mtsmusic import ( USER_AGENT, Api, + collect_download_tracks, + download_tracks, fetch_liked_tracks, fetch_mix_batch, fmt_duration, @@ -82,7 +87,7 @@ from mtsmusic import ( lrclib_lyrics, lyrics_response, lyrics_text, - resolve_stream, + fetch_track_file, ) from settings import ( START_QUEUE_LABELS, @@ -313,6 +318,46 @@ class Task(QRunnable): pass +class ProgressTask(QRunnable): + """Задача, которая умеет присылать прогресс в GUI-поток.""" + + class Signals(QObject): + progress = Signal(object) + done = Signal(object, object) + failed = Signal(object, str) + + def __init__(self, fn) -> None: + super().__init__() + self.fn = fn + self.signals = ProgressTask.Signals() + self.setAutoDelete(False) + + def run(self) -> None: + try: + result = self.fn(self._emit_progress) + except SystemExit as exc: + self._emit_failed(str(exc) or "ошибка") + except Exception as exc: # noqa: BLE001 - сообщение показываем в UI + self._emit_failed(str(exc) or exc.__class__.__name__) + else: + try: + self.signals.done.emit(self, result) + except RuntimeError: + pass + + def _emit_progress(self, event: dict) -> None: + try: + self.signals.progress.emit(event) + except RuntimeError: + pass + + def _emit_failed(self, message: str) -> None: + try: + self.signals.failed.emit(self, message) + except RuntimeError: + pass + + class CoverView(QWidget): """Обложка со скруглениями и аккуратным плейсхолдером.""" @@ -549,6 +594,7 @@ class QtMusicApp(QMainWindow): self.current_index = -1 self.liked_ids: set[str] = set() self.favorites: list[dict] = [] + self.search_results: list[dict] = [] self.history: list[dict] = load_history() self.cover_cache: dict[str, QPixmap] = {} self.current_cover_url = "" @@ -680,6 +726,9 @@ class QtMusicApp(QMainWindow): self.settings_page = self._build_settings_page() self.pages.addWidget(self.settings_page) + self.download_page = self._build_download_page() + self.pages.addWidget(self.download_page) + self._setup_tab_order() def _setup_tab_order(self) -> None: @@ -1013,6 +1062,143 @@ class QtMusicApp(QMainWindow): layout.addStretch(1) return page + def _build_download_page(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(18, 10, 18, 14) + layout.setSpacing(12) + layout.addLayout(self._page_header("Скачать")) + + dir_label = QLabel("Папка для сохранения") + dir_label.setObjectName("settingLabel") + layout.addWidget(dir_label) + + dir_row = QHBoxLayout() + dir_row.setSpacing(8) + self.download_dir = QLineEdit(str(Path.home() / "Music" / "MTS")) + self.download_dir.setObjectName("downloadDir") + dir_row.addWidget(self.download_dir, 1) + browse = QPushButton("Обзор…") + browse.setObjectName("actionBtn") + browse.setCursor(Qt.CursorShape.PointingHandCursor) + browse.clicked.connect(self.browse_download_dir) + dir_row.addWidget(browse) + layout.addLayout(dir_row) + + source_label = QLabel("Что скачать") + source_label.setObjectName("settingLabel") + layout.addWidget(source_label) + + self.download_source = QComboBox() + self.download_source.setObjectName("settingCombo") + for text, kind in ( + ("Текущий трек", "current"), + ("Всё избранное", "likes"), + ("Моя волна (20 треков)", "wave"), + ("Результаты поиска", "search"), + ): + self.download_source.addItem(text, kind) + layout.addWidget(self.download_source) + + self.download_btn = QPushButton("Скачать") + self.download_btn.setObjectName("actionBtn") + self.download_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.download_btn.clicked.connect(self.start_download) + layout.addWidget(self.download_btn) + + self.download_progress = QProgressBar() + self.download_progress.setRange(0, 1) + self.download_progress.setValue(0) + self.download_progress.setTextVisible(False) + layout.addWidget(self.download_progress) + + self.download_status = QLabel( + "Уже скачанные файлы пропускаются, имена — " + "«Исполнитель - Название.mp3»." + ) + self.download_status.setObjectName("pageStatus") + self.download_status.setWordWrap(True) + layout.addWidget(self.download_status) + layout.addStretch(1) + return page + + # -- скачивание ------------------------------------------------------- + + def show_download(self) -> None: + self._show(self.download_page) + + def browse_download_dir(self) -> None: + directory = QFileDialog.getExistingDirectory( + self, "Папка для скачивания", self.download_dir.text().strip() + ) + if directory: + self.download_dir.setText(directory) + + def start_download(self) -> None: + raw = self.download_dir.text().strip() + if not raw: + self.toast("Укажи папку для сохранения") + return + destination = Path(raw).expanduser() + kind = self.download_source.currentData() + if kind == "current" and not self.current: + self.toast("Сначала выбери трек") + return + if kind == "search" and not self.search_results: + self.toast("Сначала выполни поиск") + return + self.download_btn.setEnabled(False) + self.download_progress.setRange(0, 0) + self.download_status.setText("Готовлю список…") + self.run_progress_task( + lambda progress: self._download_job(kind, destination, progress), + self.download_done, + self.download_failed, + ) + + def _download_job(self, kind: str, destination: Path, progress) -> tuple: + if kind == "current": + tracks = [self.current] + elif kind == "likes": + tracks = collect_download_tracks(self.api, likes=True) + elif kind == "wave": + tracks = collect_download_tracks(self.api, wave=True, number=20) + else: + tracks = list(self.search_results) + if not tracks: + raise ValueError("нечего скачивать") + saved, skipped, failed = download_tracks( + self.api, tracks, destination, on_event=progress + ) + return saved, skipped, failed, destination + + def on_download_progress(self, event: dict) -> None: + track = event["track"] + self.download_progress.setRange(0, event["total"]) + self.download_progress.setValue(event["index"] - 1) + title = f"{track_artist(track)} — {track.get('title') or '?'}" + self.download_status.setText( + f"[{event['index']}/{event['total']}] {title}") + + def download_done(self, result: tuple) -> None: + saved, skipped, failed, destination = result + self.download_btn.setEnabled(True) + self.download_progress.setRange(0, 1) + self.download_progress.setValue(1) + message = ( + f"Готово: {saved} сохранено, {skipped} уже было, " + f"{failed} ошибок" + ) + self.download_status.setText(f"{message}\n{destination}") + self.toast(message) + + def download_failed(self, message: str) -> None: + self.download_btn.setEnabled(True) + self.download_progress.setRange(0, 1) + self.download_progress.setValue(0) + self.download_status.setText(f"Ошибка: {message}") + self.toast(message) + # -- настройки -------------------------------------------------------- def update_setting(self, key: str, value) -> None: @@ -1070,6 +1256,7 @@ class QtMusicApp(QMainWindow): ("Избранное", self.show_favorites), ("История", self.show_history), ("Поиск", self.show_search), + ("Скачать", self.show_download), ("Текст песни", self.show_lyrics), ("Настройки", self.show_settings), ): @@ -1191,6 +1378,16 @@ class QtMusicApp(QMainWindow): self._tasks.add(task) self.pool.start(task) + def run_progress_task(self, fn, done, failed) -> None: + task = ProgressTask(fn) + task.done_cb = done + task.failed_cb = failed + task.signals.done.connect(self._task_done) + task.signals.failed.connect(self._task_failed) + task.signals.progress.connect(self.on_download_progress) + self._tasks.add(task) + self.pool.start(task) + def _task_done(self, task: Task, result) -> None: if task not in self._tasks: return @@ -1373,7 +1570,7 @@ class QtMusicApp(QMainWindow): self.set_top_status("Загрузка…") self.run_task( - lambda: resolve_stream(self.api, str(track.get("id"))), + lambda: fetch_track_file(self.api, str(track.get("id"))), lambda result: self.start_playback(token, track, result), self.on_error, ) @@ -1383,9 +1580,9 @@ class QtMusicApp(QMainWindow): ) -> None: if token != self.play_token: return - url, item = result + path, item = result self._preview = bool(item.get("preview")) - self.player.setSource(QUrl(url)) + self.player.setSource(QUrl.fromLocalFile(str(path))) self.player.play() self.history = record_history(track) title = str(track.get("title") or "") @@ -1808,6 +2005,7 @@ class QtMusicApp(QMainWindow): self.run_task(search, self.apply_search, self.search_failed) def apply_search(self, tracks: list[dict]) -> None: + self.search_results = tracks self.fill_track_list(self.search_list, tracks, "Поиск") self.search_status.setText( f"{len(tracks)} результат(ов)" if tracks else "Ничего не найдено" diff --git a/tui.py b/tui.py index 162a168..cb3083b 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 @@ -282,6 +285,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: @@ -442,6 +446,132 @@ 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(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", "Закрыть")] @@ -452,6 +582,7 @@ 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-settings") yield Static("", id="menu-spacer") @@ -613,6 +744,12 @@ class MusicTui(App): .sstep { width: 5; min-width: 5; } .stoggle { width: 16; min-width: 12; } #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 = [ @@ -643,6 +780,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] = [] @@ -978,7 +1116,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, @@ -986,7 +1124,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( @@ -1192,6 +1331,8 @@ 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-settings":