feat: add search, playback and MP3 downloads
This commit is contained in:
22
README.md
22
README.md
@@ -1,7 +1,7 @@
|
|||||||
# MTS Music CLI
|
# MTS Music CLI
|
||||||
|
|
||||||
Минимальный клиент для **своего** аккаунта MTS Music: вход, статус аккаунта
|
Минимальный клиент для **своего** аккаунта MTS Music: поиск, ссылка на поток,
|
||||||
и проверка подписки.
|
воспроизведение и скачивание в MP3.
|
||||||
|
|
||||||
Внутри используется тот же путь, что и в веб-клиенте: MTS SSO выдаёт
|
Внутри используется тот же путь, что и в веб-клиенте: MTS SSO выдаёт
|
||||||
OAuth-токен Яндекс.Музыки, дальше запросы идут на `api.music.yandex.net`.
|
OAuth-токен Яндекс.Музыки, дальше запросы идут на `api.music.yandex.net`.
|
||||||
@@ -34,4 +34,22 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
```bash
|
```bash
|
||||||
# кто я и есть ли подписка
|
# кто я и есть ли подписка
|
||||||
.venv/bin/python mtsmusic.py status
|
.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='...'
|
||||||
```
|
```
|
||||||
|
|||||||
176
mtsmusic.py
176
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:
|
def cmd_status(args: argparse.Namespace) -> None:
|
||||||
api = Api()
|
api = Api()
|
||||||
require_token(api, "статус")
|
require_token(api, "статус")
|
||||||
@@ -345,6 +368,140 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|||||||
save_credentials(creds)
|
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 <id>")
|
||||||
|
|
||||||
|
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 <id>")
|
||||||
|
|
||||||
|
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
|
# cli
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
@@ -362,6 +519,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
status = sub.add_parser("status", help="показать аккаунт и подписку")
|
status = sub.add_parser("status", help="показать аккаунт и подписку")
|
||||||
status.set_defaults(func=cmd_status)
|
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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user