Треки больше не играют напрямую из сети: сервер Яндекса закрывает поток примерно через 45 секунд, если клиент читает медленнее реального времени, из-за чего плеер считал трек законченным и переключался на следующий. Теперь трек сначала скачивается в локальный кэш .mts-music/cache/ (512 МБ, LRU), а плеер играет готовый MP3 — это также ускоряет перемотку и повторное воспроизведение. Скачивание в выбранную папку: - CLI: download с несколькими ID, -d/--dir, -o/--output, --force; подборки --likes (всё избранное), --wave, --search QUERY, -n; - TUI и Qt: пункт меню «Скачать» с выбором папки и прогрессом; - имена файлов «Исполнитель - Название.mp3», уже скачанное пропускается. likes по умолчанию показывает всё избранное с общим количеством и поддерживает --ids для скриптов.
1392 lines
49 KiB
Python
1392 lines
49 KiB
Python
#!/usr/bin/env python3
|
||
"""MTS Music CLI — минимальный клиент для личного аккаунта.
|
||
|
||
Авторизация: MTS SSO через браузер (Playwright), затем запросы к
|
||
Яндекс.Музыке с полученным OAuth-токеном.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import base64
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import os
|
||
import re
|
||
import random
|
||
import shutil
|
||
import socket
|
||
import string
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.parse
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Callable, NoReturn
|
||
|
||
import requests
|
||
|
||
PROJECT_DIR = Path(__file__).resolve().parent
|
||
|
||
# Всё локальное храним рядом с проектом, а не в домашней директории.
|
||
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(PROJECT_DIR / ".playwright"))
|
||
|
||
CONFIG_DIR = Path(os.environ.get("MTSMUSIC_CONFIG_DIR", PROJECT_DIR / ".mts-music"))
|
||
CREDS_PATH = CONFIG_DIR / "credentials.json"
|
||
BROWSER_DIR = CONFIG_DIR / "browser"
|
||
|
||
AUTH_BASE = "https://api.music.mts.ru/auth"
|
||
BFF_BASE = "https://api.music.mts.ru"
|
||
YANDEX_BASE = "https://api.music.yandex.net"
|
||
|
||
MUSIC_CLIENT = "MTSMusicWebPremium/2.25.0-release-20046039"
|
||
# Константа из веб-клиента: подпись URL потока.
|
||
STREAM_SIGN_SECRET = "I5gMw9n41e7Ug2JYZV5Td6"
|
||
# Константа из веб-клиента: подпись запроса текста песни.
|
||
LYRICS_SIGN_KEY = "DBTX5BSsFvbhdajN0ahmza"
|
||
|
||
WAVE_STATION = "user:onyourwave"
|
||
|
||
USER_AGENT = "MTSMusicCLI/0.1 (+personal use)"
|
||
|
||
_orig_getaddrinfo = socket.getaddrinfo
|
||
|
||
|
||
def force_ipv4() -> None:
|
||
"""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)
|
||
|
||
socket.getaddrinfo = getaddrinfo
|
||
|
||
|
||
def die(message: str, code: int = 1) -> NoReturn:
|
||
print(f"error: {message}", file=sys.stderr)
|
||
raise SystemExit(code)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# credentials
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def load_credentials() -> dict:
|
||
if not CREDS_PATH.exists():
|
||
return {}
|
||
try:
|
||
return json.loads(CREDS_PATH.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError) as exc:
|
||
die(f"не удалось прочитать {CREDS_PATH}: {exc}")
|
||
|
||
|
||
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")
|
||
try:
|
||
CREDS_PATH.chmod(0o600)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def get_token() -> str | None:
|
||
return os.environ.get("MTS_YA_TOKEN") or load_credentials().get("ya_token")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# auth
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def make_state(length: int = 24) -> str:
|
||
alphabet = string.ascii_letters + string.digits
|
||
rnd = random.SystemRandom()
|
||
return "".join(rnd.choice(alphabet) for _ in range(length))
|
||
|
||
|
||
def get_auth_settings(reference_uri: str = "https://music.mts.ru/") -> dict:
|
||
force_ipv4()
|
||
response = requests.get(
|
||
f"{AUTH_BASE}/v1/web/auth_settings",
|
||
params={"reference_uri": reference_uri, "state": make_state()},
|
||
headers={
|
||
"x-yandex-music-client": MUSIC_CLIENT,
|
||
"Origin": "https://music.mts.ru",
|
||
"Referer": "https://music.mts.ru/",
|
||
"User-Agent": USER_AGENT,
|
||
},
|
||
timeout=30,
|
||
)
|
||
if response.status_code != 200:
|
||
die(f"auth_settings вернул HTTP {response.status_code}: {response.text[:300]}")
|
||
return response.json()
|
||
|
||
|
||
def cmd_login(args: argparse.Namespace) -> None:
|
||
try:
|
||
from playwright.sync_api import sync_playwright
|
||
except ImportError:
|
||
die("не установлен playwright. Выполни: pip install -r requirements.txt")
|
||
|
||
settings = get_auth_settings()
|
||
authorize_uri = settings["web_sso_authorize_uri"]
|
||
print("Открываю браузер. Войди в аккаунт MTS (телефон + SMS/пароль).")
|
||
print("Капчу нужно пройти вручную. Окно закроется само после входа.\n")
|
||
|
||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||
with sync_playwright() as playwright:
|
||
context = playwright.chromium.launch_persistent_context(
|
||
str(BROWSER_DIR),
|
||
headless=args.headless,
|
||
args=["--disable-blink-features=AutomationControlled"],
|
||
viewport={"width": 1280, "height": 900},
|
||
)
|
||
try:
|
||
page = context.pages[0] if context.pages else context.new_page()
|
||
page.goto(authorize_uri, wait_until="domcontentloaded")
|
||
|
||
deadline = time.time() + args.timeout
|
||
token = None
|
||
while time.time() < deadline:
|
||
try:
|
||
token = page.evaluate("localStorage.getItem('ya_token')")
|
||
except Exception:
|
||
token = None
|
||
if token:
|
||
break
|
||
# Токен может прийти в URL до того, как SPA успеет его сохранить.
|
||
if "yatoken=" in page.url:
|
||
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("не дождался токена. Вход не завершён или истекло время ожидания.")
|
||
|
||
ya_uid = page.evaluate("localStorage.getItem('ya_uid')")
|
||
user_id = page.evaluate("localStorage.getItem('userId')")
|
||
cookies = context.cookies()
|
||
|
||
data = {
|
||
"ya_token": token,
|
||
"ya_uid": ya_uid,
|
||
"user_id": user_id,
|
||
"cookies": cookies,
|
||
"saved_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
save_credentials(data)
|
||
print(f"\nТокен сохранён: {CREDS_PATH} (chmod 600)")
|
||
if ya_uid:
|
||
print(f"Yandex UID: {ya_uid}")
|
||
finally:
|
||
context.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# API
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class Api:
|
||
def __init__(self, token: str | None = None) -> None:
|
||
self.token = token or get_token()
|
||
self.session = requests.Session()
|
||
self.session.headers.update(
|
||
{
|
||
"X-Yandex-Music-Client": MUSIC_CLIENT,
|
||
"User-Agent": USER_AGENT,
|
||
"Accept": "application/json",
|
||
}
|
||
)
|
||
|
||
def _headers(self) -> dict:
|
||
if self.token:
|
||
return {"Authorization": f"OAuth {self.token}"}
|
||
return {}
|
||
|
||
def get(self, path: str, **params) -> dict:
|
||
return self._request("GET", path, params=params)
|
||
|
||
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:
|
||
force_ipv4()
|
||
last_error: Exception | None = None
|
||
for attempt in range(3):
|
||
try:
|
||
response = self.session.request(
|
||
method,
|
||
f"{YANDEX_BASE}{path}",
|
||
params=params or None,
|
||
headers=self._headers(),
|
||
timeout=30,
|
||
)
|
||
break
|
||
except requests.RequestException as exc:
|
||
last_error = exc
|
||
if attempt < 2:
|
||
time.sleep(1.5 * (attempt + 1))
|
||
else:
|
||
die(f"сетевая ошибка на {path}: {last_error}")
|
||
raise AssertionError # для type checker
|
||
|
||
if response.status_code == 401:
|
||
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}: {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]}: {response.text[:300]}")
|
||
return response.json()
|
||
|
||
|
||
def require_token(api: Api, what: str) -> None:
|
||
if not api.token:
|
||
die(f"для «{what}» нужен токен. Сначала: python mtsmusic.py login")
|
||
|
||
|
||
def bff_get(path: str) -> dict | None:
|
||
"""Запрос к BFF MTS с сохранёнными cookie. None, если cookie нет/не подошли."""
|
||
cookies = load_credentials().get("cookies") or []
|
||
if not cookies:
|
||
return None
|
||
|
||
session = requests.Session()
|
||
for cookie in cookies:
|
||
session.cookies.set(
|
||
cookie["name"],
|
||
cookie["value"],
|
||
domain=cookie.get("domain"),
|
||
path=cookie.get("path", "/"),
|
||
)
|
||
force_ipv4()
|
||
try:
|
||
response = session.get(
|
||
f"{BFF_BASE}{path}",
|
||
headers={
|
||
"X-Music-Client": "web",
|
||
"X-Yandex-Music-Client": MUSIC_CLIENT,
|
||
"Origin": "https://music.mts.ru",
|
||
"Referer": "https://music.mts.ru/",
|
||
"User-Agent": USER_AGENT,
|
||
},
|
||
timeout=30,
|
||
)
|
||
except requests.RequestException:
|
||
return None
|
||
if response.status_code != 200:
|
||
return None
|
||
return response.json()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# catalog
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def fmt_duration(ms: int | None) -> str:
|
||
total = int((ms or 0) / 1000)
|
||
return f"{total // 60}:{total % 60:02d}"
|
||
|
||
|
||
def fmt_track(track: dict) -> str:
|
||
artists = ", ".join(a.get("name", "?") for a in track.get("artists") or [])
|
||
title = track.get("title") or "?"
|
||
available = "" if track.get("available", True) else " [недоступен]"
|
||
return (
|
||
f"{str(track.get('id', '?')):>12} "
|
||
f"{artists} — {title} "
|
||
f"[{fmt_duration(track.get('durationMs'))}]{available}"
|
||
)
|
||
|
||
|
||
def search_tracks(api: Api, query: str, limit: int = 10) -> list[dict]:
|
||
payload = api.get(
|
||
"/search",
|
||
text=query,
|
||
type="all",
|
||
page=0,
|
||
**{"page-size": 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"):
|
||
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:
|
||
print(fmt_track(track))
|
||
|
||
|
||
def cmd_status(args: argparse.Namespace) -> None:
|
||
api = Api()
|
||
require_token(api, "статус")
|
||
payload = api.get("/account/mts/status")
|
||
result = payload.get("result") or {}
|
||
account = result.get("account") 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 []
|
||
|
||
print(f"UID: {account.get('uid', '?')}")
|
||
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 "?"
|
||
until = sub.get("nextTarifficationDate")
|
||
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")
|
||
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"):
|
||
creds["ya_uid"] = str(account.get("uid") or "")
|
||
save_credentials(creds)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# моя волна, избранное, тексты
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def get_uid(api: Api) -> str:
|
||
uid = (load_credentials() or {}).get("ya_uid")
|
||
if uid:
|
||
return str(uid)
|
||
# Резервный путь: uid есть в ответе MTS-статуса.
|
||
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")
|
||
creds = load_credentials() or {}
|
||
creds["ya_uid"] = str(uid)
|
||
save_credentials(creds)
|
||
return str(uid)
|
||
|
||
|
||
def get_track(api: Api, track_id: str) -> dict:
|
||
result = api.get(f"/tracks/{track_id}").get("result") or []
|
||
if not result:
|
||
die(f"трек {track_id} не найден")
|
||
return result[0]
|
||
|
||
|
||
def album_id(track: dict) -> str | None:
|
||
albums = track.get("albums") or []
|
||
if albums:
|
||
return str(albums[0].get("id"))
|
||
return None
|
||
|
||
|
||
def track_album_ref(track_id: str, album: str | None = None) -> str:
|
||
return f"{track_id}:{album}" if album else str(track_id)
|
||
|
||
|
||
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 {}
|
||
|
||
|
||
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 []
|
||
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]
|
||
if chunk:
|
||
tracks.extend(
|
||
api.get("/tracks", trackIds=",".join(chunk)).get("result") or []
|
||
)
|
||
return tracks
|
||
|
||
|
||
def print_mix_item(index: int, item: dict) -> None:
|
||
track = item.get("track") or {}
|
||
heart = "♥" if item.get("liked") else " "
|
||
print(f"{heart} {index:>3}. {fmt_track(track)}", flush=True)
|
||
|
||
|
||
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)
|
||
|
||
while len(collected) < limit and attempts < max_attempts:
|
||
attempts += 1
|
||
result = fetch_mix_batch(api, queue)
|
||
sequence = result.get("sequence") or []
|
||
if not sequence:
|
||
break
|
||
|
||
new_items = []
|
||
for item in sequence:
|
||
if item.get("type") != "track":
|
||
continue
|
||
track_id = str((item.get("track") or {}).get("id") or "")
|
||
if not track_id or track_id in seen:
|
||
continue
|
||
seen.add(track_id)
|
||
new_items.append(item)
|
||
|
||
if not new_items:
|
||
break
|
||
|
||
for item in new_items:
|
||
if len(collected) >= limit:
|
||
break
|
||
collected.append(item)
|
||
|
||
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("волна вернула пустой список")
|
||
|
||
for index, item in enumerate(collected, 1):
|
||
print_mix_item(index, item)
|
||
|
||
if args.play:
|
||
play_items(api, [item.get("track") or {} for item in collected], args.player)
|
||
|
||
|
||
def cmd_like(args: argparse.Namespace) -> None:
|
||
api = Api()
|
||
require_token(api, "избранное")
|
||
uid = get_uid(api)
|
||
track = get_track(api, args.track_id)
|
||
album = args.album or album_id(track)
|
||
ref = track_album_ref(args.track_id, album)
|
||
api.post(f"/users/{uid}/likes/tracks/add", **{"track-id": ref})
|
||
print(f"♥ добавлено в избранное: {fmt_track(track)}")
|
||
|
||
|
||
def cmd_unlike(args: argparse.Namespace) -> None:
|
||
api = Api()
|
||
require_token(api, "избранное")
|
||
uid = get_uid(api)
|
||
track = get_track(api, args.track_id)
|
||
album = args.album or album_id(track)
|
||
ref = track_album_ref(args.track_id, album)
|
||
api.post(f"/users/{uid}/likes/tracks/remove", **{"track-ids": ref})
|
||
print(f"♡ убрано из избранного: {fmt_track(track)}")
|
||
|
||
|
||
def cmd_likes(args: argparse.Namespace) -> None:
|
||
api = Api()
|
||
require_token(api, "избранное")
|
||
uid = get_uid(api)
|
||
payload = api.get(f"/users/{uid}/likes/tracks")
|
||
library = (payload.get("result") or {}).get("library") or {}
|
||
liked = library.get("tracks") or []
|
||
if not liked:
|
||
print("В избранном пока пусто.")
|
||
return
|
||
|
||
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]
|
||
try:
|
||
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)
|
||
if track:
|
||
print(f"{index:>3}. {fmt_track(track)}")
|
||
else:
|
||
album = item.get("albumId")
|
||
print(f"{index:>3}. {track_id:>12} (альбом {album})")
|
||
|
||
|
||
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()
|
||
|
||
force_ipv4()
|
||
return api.session.get(
|
||
f"{YANDEX_BASE}/tracks/{track_id}/lyrics",
|
||
params={"timeStamp": timestamp, "sign": signature, "format": fmt},
|
||
headers=api._headers(),
|
||
timeout=30,
|
||
)
|
||
|
||
|
||
def lyrics_text(api: Api, response: requests.Response) -> str:
|
||
"""Достаёт сам текст: API отдаёт ссылку на файл, а не строки."""
|
||
payload = response.json()
|
||
result = payload.get("result") if isinstance(payload, dict) else None
|
||
if isinstance(result, str):
|
||
return result
|
||
if not isinstance(result, dict):
|
||
return ""
|
||
|
||
for key in ("lyrics", "full_lyrics", "fullLyrics", "text"):
|
||
value = result.get(key)
|
||
if isinstance(value, str) and value.strip():
|
||
return value
|
||
|
||
download_url = result.get("download_url") or result.get("downloadUrl")
|
||
if download_url:
|
||
force_ipv4()
|
||
file_response = requests.get(
|
||
download_url, headers={"User-Agent": USER_AGENT}, timeout=60
|
||
)
|
||
file_response.raise_for_status()
|
||
return file_response.text
|
||
return ""
|
||
|
||
|
||
LRCLIB_URL = "https://lrclib.net/api/search"
|
||
|
||
|
||
def lrclib_lyrics(track: dict, want_sync: bool) -> str | None:
|
||
"""Резервный источник текста: LRCLIB (бесплатный API, без ключа)."""
|
||
title = (track.get("title") or "").strip()
|
||
artists = track.get("artists") or []
|
||
artist = (artists[0].get("name") or "").strip() if artists else ""
|
||
if not title:
|
||
return None
|
||
|
||
force_ipv4()
|
||
headers = {"User-Agent": "MTSMusicCLI/0.1 (personal use)"}
|
||
queries = [
|
||
{"track_name": title, "artist_name": artist},
|
||
{"q": f"{artist} {title}".strip()},
|
||
]
|
||
candidates: list[dict] = []
|
||
for params in queries:
|
||
try:
|
||
response = requests.get(LRCLIB_URL, params=params, headers=headers, timeout=20)
|
||
except requests.RequestException:
|
||
continue
|
||
if response.status_code != 200:
|
||
continue
|
||
try:
|
||
data = response.json()
|
||
except ValueError:
|
||
continue
|
||
if isinstance(data, list) and data:
|
||
candidates = data
|
||
break
|
||
if not candidates:
|
||
return None
|
||
|
||
# Выбираем версию, ближайшую по длительности, где есть нужный формат.
|
||
field = "syncedLyrics" if want_sync else "plainLyrics"
|
||
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
|
||
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()]
|
||
field = "plainLyrics"
|
||
if not usable:
|
||
return None
|
||
best = min(usable, key=score)
|
||
return (best.get(field) or "").strip() or None
|
||
|
||
|
||
def cmd_lyrics(args: argparse.Namespace) -> None:
|
||
api = Api()
|
||
require_token(api, "текст песни")
|
||
track = get_track(api, args.track_id)
|
||
fmt = "LRC" if args.lrc else "TEXT"
|
||
|
||
text = ""
|
||
source = "MTS"
|
||
response = lyrics_response(api, args.track_id, fmt)
|
||
if response.status_code == 200:
|
||
try:
|
||
text = lyrics_text(api, response)
|
||
except (ValueError, requests.RequestException):
|
||
text = ""
|
||
if "Подключите подписку" in text:
|
||
text = ""
|
||
elif response.status_code not in (403, 404):
|
||
die(f"HTTP {response.status_code}: {response.text[:300]}")
|
||
|
||
if not text.strip() and not args.no_fallback:
|
||
text = lrclib_lyrics(track, want_sync=args.lrc) or ""
|
||
source = "LRCLIB"
|
||
|
||
if not text.strip():
|
||
print(f"Текст не найден: {fmt_track(track)}")
|
||
return
|
||
|
||
if args.output:
|
||
output = Path(args.output)
|
||
output.write_text(text, encoding="utf-8")
|
||
print(f"Сохранено: {output} ({len(text)} символов, источник: {source})")
|
||
else:
|
||
if source != "MTS":
|
||
print(f"# источник текста: {source}", file=sys.stderr)
|
||
print(f"# {fmt_track(track)}")
|
||
print()
|
||
print(text)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# streaming
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
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:
|
||
die(f"для трека {track_id} нет download-info (возможно, недоступен)")
|
||
|
||
# Лучший вариант: не preview и максимальный битрейт.
|
||
items.sort(
|
||
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"
|
||
|
||
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()
|
||
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(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:
|
||
path, item = fetch_track_file(api, track_id)
|
||
except SystemExit:
|
||
print(" пропускаю: не удалось получить трек", file=sys.stderr)
|
||
continue
|
||
if item.get("preview"):
|
||
print(" внимание: preview-фрагмент", file=sys.stderr)
|
||
try:
|
||
play_url(player, str(path))
|
||
except KeyboardInterrupt:
|
||
print("\nОстановлено.", file=sys.stderr)
|
||
return
|
||
|
||
|
||
def cmd_play(args: argparse.Namespace) -> None:
|
||
api = Api()
|
||
print_track_info(api, args.track_id)
|
||
path, item = fetch_track_file(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, 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()
|
||
require_token(api, "скачивание")
|
||
|
||
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}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# cli
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def cmd_tui(args: argparse.Namespace) -> None:
|
||
if args.check:
|
||
print_tui_check()
|
||
return
|
||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||
die(
|
||
"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")
|
||
try:
|
||
MusicTui(player=args.player).run(inline=args.inline, mouse=not args.no_mouse)
|
||
except KeyboardInterrupt:
|
||
pass
|
||
except Exception as exc: # TUI не должен молча исчезать
|
||
log = CONFIG_DIR / "tui-error.log"
|
||
try:
|
||
import traceback
|
||
|
||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||
log.write_text(traceback.format_exc(), encoding="utf-8")
|
||
except OSError:
|
||
log = Path("?")
|
||
die(f"TUI упал: {exc}. Подробности: {log}")
|
||
|
||
|
||
def cmd_qt(args: argparse.Namespace) -> None:
|
||
try:
|
||
from qt_app import main as qt_main
|
||
except ImportError as exc:
|
||
die(
|
||
"для Qt-приложения нужен PySide6: "
|
||
f"{exc}. Установи: pip install -r requirements-qt.txt"
|
||
)
|
||
raise SystemExit(qt_main())
|
||
|
||
|
||
def cmd_settings(args: argparse.Namespace) -> None:
|
||
# Импорт внутри функции: settings.py сам зависит от mtsmusic.CONFIG_DIR.
|
||
from settings import DEFAULTS, load_settings, settings_path, save_settings
|
||
|
||
saved = False
|
||
if args.set:
|
||
updates: dict = {}
|
||
for pair in args.set:
|
||
if "=" not in pair:
|
||
die(f"неверный формат: {pair} (нужно ключ=значение)")
|
||
key, value = pair.split("=", 1)
|
||
key = key.strip()
|
||
if key not in DEFAULTS:
|
||
die(f"неизвестная настройка: {key}. Доступны: {', '.join(DEFAULTS)}")
|
||
updates[key] = value
|
||
current = save_settings(updates)
|
||
saved = True
|
||
else:
|
||
current = load_settings()
|
||
|
||
for key, default in DEFAULTS.items():
|
||
print(f"{key:<18} = {current.get(key, default)}")
|
||
print(f"\nФайл: {settings_path()}")
|
||
if saved:
|
||
print("Сохранено.")
|
||
|
||
|
||
def print_tui_check() -> None:
|
||
import platform
|
||
|
||
print(f"Python: {sys.executable}")
|
||
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()}")
|
||
print(f"stdin TTY: {sys.stdin.isatty()}")
|
||
size = shutil.get_terminal_size()
|
||
print(f"Терминал: {size.columns}x{size.lines}")
|
||
try:
|
||
import textual
|
||
|
||
print(f"textual: {textual.__version__}")
|
||
except ImportError:
|
||
print("textual: НЕ УСТАНОВЛЕН")
|
||
try:
|
||
import PIL
|
||
|
||
print(f"pillow: {PIL.__version__}")
|
||
except ImportError:
|
||
print("pillow: НЕ УСТАНОВЛЕН")
|
||
for name in ("ffplay", "mpv"):
|
||
print(f"{name}: {shutil.which(name) or 'нет'}")
|
||
print(f"Креды: {'есть' if CREDS_PATH.exists() else 'нет'} ({CREDS_PATH})")
|
||
if size.columns < 90 or size.lines < 30:
|
||
print("\nСовет: окно маленькое, TUI лучше смотреть при >= 90x30.")
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(
|
||
prog="mtsmusic",
|
||
description="Минимальный клиент MTS Music для личного аккаунта.",
|
||
)
|
||
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.set_defaults(func=cmd_login)
|
||
|
||
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)
|
||
|
||
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("--player", help="путь к mpv/ffplay")
|
||
mix.set_defaults(func=cmd_mix)
|
||
|
||
like = sub.add_parser("like", help="добавить трек в избранное")
|
||
like.add_argument("track_id")
|
||
like.add_argument("--album", help="id альбома (если нужно переопределить)")
|
||
like.set_defaults(func=cmd_like)
|
||
|
||
unlike = sub.add_parser("unlike", help="убрать трек из избранного")
|
||
unlike.add_argument("track_id")
|
||
unlike.add_argument("--album", help="id альбома (если нужно переопределить)")
|
||
unlike.set_defaults(func=cmd_unlike)
|
||
|
||
likes = sub.add_parser("likes", help="показать избранные треки текстом")
|
||
likes.add_argument(
|
||
"-n",
|
||
"--number",
|
||
type=int,
|
||
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("-o", "--output", help="сохранить текст в файл")
|
||
lyrics.add_argument(
|
||
"--no-fallback",
|
||
action="store_true",
|
||
help="не использовать LRCLIB, если MTS не отдал текст",
|
||
)
|
||
lyrics.set_defaults(func=cmd_lyrics)
|
||
|
||
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(
|
||
"--inline",
|
||
action="store_true",
|
||
help="без alternate screen: рисовать прямо в терминале (если экран пустой)",
|
||
)
|
||
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.add_argument(
|
||
"-s",
|
||
"--set",
|
||
nargs="+",
|
||
metavar="KEY=VALUE",
|
||
help="изменить настройки, например: -s volume=60 seek_step=10",
|
||
)
|
||
settings.set_defaults(func=cmd_settings)
|
||
|
||
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_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
|
||
|
||
|
||
def main() -> None:
|
||
parser = build_parser()
|
||
args = parser.parse_args()
|
||
try:
|
||
args.func(args)
|
||
except requests.RequestException as exc:
|
||
die(f"сетевая ошибка: {exc}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|