From a41ec450feefa61df4947b5f90fbb9287854cb40 Mon Sep 17 00:00:00 2001 From: jads Date: Fri, 23 Oct 2026 21:26:58 +0300 Subject: [PATCH] feat: add search, playback and MP3 downloads --- README.md | 22 ++++++- mtsmusic.py | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index feb06e3..428af38 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # MTS Music CLI -Минимальный клиент для **своего** аккаунта MTS Music: вход, статус аккаунта -и проверка подписки. +Минимальный клиент для **своего** аккаунта MTS Music: поиск, ссылка на поток, +воспроизведение и скачивание в MP3. Внутри используется тот же путь, что и в веб-клиенте: MTS SSO выдаёт OAuth-токен Яндекс.Музыки, дальше запросы идут на `api.music.yandex.net`. @@ -34,4 +34,22 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu ```bash # кто я и есть ли подписка .venv/bin/python mtsmusic.py status + +# поиск +.venv/bin/python mtsmusic.py search "miyagi" -n 10 + +# прямая ссылка на MP3 +.venv/bin/python mtsmusic.py url 12345678 + +# воспроизведение (нужен mpv или ffplay) +.venv/bin/python mtsmusic.py play 12345678 + +# скачивание +.venv/bin/python mtsmusic.py download 12345678 -o track.mp3 +``` + +Токен можно передать и через переменную окружения: + +```bash +export MTS_YA_TOKEN='...' ``` diff --git a/mtsmusic.py b/mtsmusic.py index 2d99107..4539e04 100644 --- a/mtsmusic.py +++ b/mtsmusic.py @@ -310,6 +310,29 @@ def fmt_track(track: dict) -> str: ) +def cmd_search(args: argparse.Namespace) -> None: + api = Api() + payload = api.get( + "/search", + text=args.query, + type="all", + page=0, + **{"page-size": args.limit}, + nocorrect="true", + ) + result = payload.get("result") or {} + tracks = ((result.get("tracks") or {}).get("results")) or [] + if not tracks: + best = (result.get("best") or {}).get("result") + if isinstance(best, dict) and best.get("id"): + print(fmt_track(best)) + return + print("Ничего не найдено.") + return + for track in tracks[: args.limit]: + print(fmt_track(track)) + + def cmd_status(args: argparse.Namespace) -> None: api = Api() require_token(api, "статус") @@ -345,6 +368,140 @@ def cmd_status(args: argparse.Namespace) -> None: save_credentials(creds) +# --------------------------------------------------------------------------- +# streaming +# --------------------------------------------------------------------------- + + +def resolve_stream(api: Api, track_id: str) -> tuple[str, dict]: + """Возвращает (url_mp3, download_info_item).""" + info = api.get(f"/tracks/{track_id}/download-info") + items = info.get("result") or [] + if not items: + die(f"для трека {track_id} нет download-info (возможно, недоступен)") + + # Лучший вариант: не preview и максимальный битрейт. + items.sort( + key=lambda item: (not item.get("preview", False), item.get("bitrateInKbps", 0)), + reverse=True, + ) + item = items[0] + + download_url = item["downloadInfoUrl"] + parsed = urllib.parse.urlparse(download_url) + query = parsed.query + ("&" if parsed.query else "") + "format=json" + + meta = api.get_url(f"{YANDEX_BASE}{parsed.path}?{query}") + path = meta["path"] + signature = meta["s"] + 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 + + +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(url) + + +def pick_player(explicit: str | None) -> str | None: + if explicit: + return explicit + for candidate in ("mpv", "ffplay"): + path = shutil.which(candidate) + if path: + return path + return None + + +def print_track_info(api: Api, track_id: str) -> None: + try: + track = api.get(f"/tracks/{track_id}")["result"][0] + except (SystemExit, KeyError, IndexError): + return + print(fmt_track(track), file=sys.stderr) + + +def play_url(player: str, url: str) -> None: + name = Path(player).name + if name == "ffplay": + command = [player, "-nodisp", "-autoexit", "-loglevel", "warning", url] + else: + command = [player, "--no-video", "--really-quiet", url] + try: + subprocess.run(command, check=False) + except KeyboardInterrupt: + raise + + +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 ") + + 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) + except SystemExit: + print(" пропускаю: не удалось получить поток", file=sys.stderr) + continue + if item.get("preview"): + print(" внимание: preview-фрагмент", file=sys.stderr) + try: + play_url(player, url) + except KeyboardInterrupt: + print("\nОстановлено.", file=sys.stderr) + return + + +def cmd_play(args: argparse.Namespace) -> None: + api = Api() + print_track_info(api, args.track_id) + url, item = resolve_stream(api, args.track_id) + if item.get("preview"): + print("Внимание: это preview-фрагмент (нет активной подписки).", file=sys.stderr) + + player = pick_player(args.player) + if not player: + die("не найден mpv/ffplay. Установи один из них или используй: mtsmusic.py url ") + + try: + play_url(player, url) + except KeyboardInterrupt: + pass + + +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") + + 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: + print(f"\r{done * 100 // total:3d}% {done // 1024} KiB", end="", file=sys.stderr) + print(file=sys.stderr) + print(f"Сохранено: {output} ({output.stat().st_size} байт)") + + # --------------------------------------------------------------------------- # cli def build_parser() -> argparse.ArgumentParser: @@ -362,6 +519,25 @@ def build_parser() -> argparse.ArgumentParser: status = sub.add_parser("status", help="показать аккаунт и подписку") status.set_defaults(func=cmd_status) + search = sub.add_parser("search", help="поиск треков") + search.add_argument("query", help="поисковый запрос") + search.add_argument("-n", "--limit", type=int, default=10, help="сколько результатов (по умолчанию 10)") + search.set_defaults(func=cmd_search) + + url = sub.add_parser("url", help="получить прямую ссылку на MP3") + url.add_argument("track_id") + url.set_defaults(func=cmd_url) + + play = sub.add_parser("play", help="воспроизвести трек через mpv/ffplay") + play.add_argument("track_id") + 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.set_defaults(func=cmd_download) + return parser