style: format all code to PEP8 (flake8 clean)

Reformat every module with autopep8 and hand-tune the remaining long
lines so that `flake8` passes with default settings (max line length
79). No behavior changes: long f-strings, messages, QSS/Textual CSS
rules and argument lists are only wrapped or split, an unused import
and an unused local are dropped, and dbus-next D-Bus signature
annotations carry targeted `# noqa` comments.
This commit is contained in:
jads
2026-10-23 23:52:14 +03:00
parent 4460791a95
commit e71f5623a7
7 changed files with 558 additions and 213 deletions

View File

@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
from pathlib import Path
from mtsmusic import CONFIG_DIR from mtsmusic import CONFIG_DIR
@@ -12,7 +11,8 @@ HISTORY_LIMIT = 100
def track_artist(track: dict) -> str: def track_artist(track: dict) -> str:
return ", ".join(a.get("name", "?") for a in track.get("artists") or []) or "?" return ", ".join(a.get("name", "?")
for a in track.get("artists") or []) or "?"
def track_album(track: dict) -> str: def track_album(track: dict) -> str:
@@ -58,7 +58,9 @@ def record_history(track: dict) -> list[dict]:
track_id = str(track.get("id") or "") track_id = str(track.get("id") or "")
if not track_id: if not track_id:
return load_history() return load_history()
history = [item for item in load_history() if str(item.get("id")) != track_id] history = [
item for item in load_history() if str(
item.get("id")) != track_id]
history.insert(0, track) history.insert(0, track)
history = history[:HISTORY_LIMIT] history = history[:HISTORY_LIMIT]
try: try:

View File

@@ -19,7 +19,12 @@ try: # pragma: no cover - зависит от окружения
from dbus_next import Variant from dbus_next import Variant
from dbus_next.aio import MessageBus from dbus_next.aio import MessageBus
from dbus_next.constants import PropertyAccess from dbus_next.constants import PropertyAccess
from dbus_next.service import ServiceInterface, dbus_property, method, signal from dbus_next.service import (
ServiceInterface,
dbus_property,
method,
signal,
)
HAVE_DBUS = True HAVE_DBUS = True
except Exception: # pragma: no cover except Exception: # pragma: no cover
@@ -31,7 +36,8 @@ OBJECT_PATH = "/org/mpris/MediaPlayer2"
def track_object_path(track_id: Any) -> str: def track_object_path(track_id: Any) -> str:
"""D-Bus object path для mpris:trackid (только [A-Za-z0-9_]).""" """D-Bus object path для mpris:trackid (только [A-Za-z0-9_])."""
safe = "".join(ch if ch.isalnum() else "_" for ch in str(track_id)) or "unknown" safe = "".join(ch if ch.isalnum()
else "_" for ch in str(track_id)) or "unknown"
return f"/mtsmusic/track/{safe}" return f"/mtsmusic/track/{safe}"
@@ -51,31 +57,31 @@ if HAVE_DBUS:
self._svc.dispatch("quit") self._svc.dispatch("quit")
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def Identity(self) -> "s": def Identity(self) -> "s": # noqa: F821
return self._svc.identity return self._svc.identity
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def DesktopEntry(self) -> "s": def DesktopEntry(self) -> "s": # noqa: F821
return self._svc.desktop_entry return self._svc.desktop_entry
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def CanQuit(self) -> "b": def CanQuit(self) -> "b": # noqa: F821
return True return True
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def CanRaise(self) -> "b": def CanRaise(self) -> "b": # noqa: F821
return True return True
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def HasTrackList(self) -> "b": def HasTrackList(self) -> "b": # noqa: F821
return False return False
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def SupportedUriSchemes(self) -> "as": def SupportedUriSchemes(self) -> "as": # noqa: F722
return [] return []
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def SupportedMimeTypes(self) -> "as": def SupportedMimeTypes(self) -> "as": # noqa: F722
return [] return []
class _PlayerInterface(ServiceInterface): class _PlayerInterface(ServiceInterface):
@@ -109,97 +115,97 @@ if HAVE_DBUS:
self._svc.dispatch("previous") self._svc.dispatch("previous")
@method() @method()
def Seek(self, offset: "x"): def Seek(self, offset: "x"): # noqa: F821
self._svc.dispatch("seek", int(offset)) self._svc.dispatch("seek", int(offset))
@method() @method()
def SetPosition(self, track_id: "o", position: "x"): def SetPosition(self, track_id: "o", position: "x"): # noqa: F821
self._svc.dispatch("set_position", str(track_id), int(position)) self._svc.dispatch("set_position", str(track_id), int(position))
@method() @method()
def OpenUri(self, uri: "s"): def OpenUri(self, uri: "s"): # noqa: F821
self._svc.dispatch("open_uri", str(uri)) self._svc.dispatch("open_uri", str(uri))
# -- сигналы ----------------------------------------------------- # -- сигналы -----------------------------------------------------
@signal() @signal()
def Seeked(self, position: "x") -> "x": def Seeked(self, position: "x") -> "x": # noqa: F821
return position return position
# -- свойства ---------------------------------------------------- # -- свойства ----------------------------------------------------
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def PlaybackStatus(self) -> "s": def PlaybackStatus(self) -> "s": # noqa: F821
return self._svc.playback_status return self._svc.playback_status
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def Metadata(self) -> "a{sv}": def Metadata(self) -> "a{sv}": # noqa: F722
return self._svc.metadata return self._svc.metadata
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def Position(self) -> "x": def Position(self) -> "x": # noqa: F821
return int(self._svc.position) return int(self._svc.position)
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def MinimumRate(self) -> "d": def MinimumRate(self) -> "d": # noqa: F821
return 1.0 return 1.0
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def MaximumRate(self) -> "d": def MaximumRate(self) -> "d": # noqa: F821
return 1.0 return 1.0
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def CanGoNext(self) -> "b": def CanGoNext(self) -> "b": # noqa: F821
return self._svc.can_next return self._svc.can_next
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def CanGoPrevious(self) -> "b": def CanGoPrevious(self) -> "b": # noqa: F821
return self._svc.can_previous return self._svc.can_previous
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def CanPlay(self) -> "b": def CanPlay(self) -> "b": # noqa: F821
return self._svc.can_play return self._svc.can_play
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def CanPause(self) -> "b": def CanPause(self) -> "b": # noqa: F821
return self._svc.can_pause return self._svc.can_pause
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def CanSeek(self) -> "b": def CanSeek(self) -> "b": # noqa: F821
return self._svc.can_seek return self._svc.can_seek
@dbus_property(access=PropertyAccess.READ) @dbus_property(access=PropertyAccess.READ)
def CanControl(self) -> "b": def CanControl(self) -> "b": # noqa: F821
return True return True
@dbus_property(access=PropertyAccess.READWRITE) @dbus_property(access=PropertyAccess.READWRITE)
def Volume(self) -> "d": def Volume(self) -> "d": # noqa: F821
return float(self._svc.volume) return float(self._svc.volume)
@Volume.setter @Volume.setter
def Volume(self, value: "d"): def Volume(self, value: "d"): # noqa: F821
self._svc.dispatch("volume", float(value)) self._svc.dispatch("volume", float(value))
@dbus_property(access=PropertyAccess.READWRITE) @dbus_property(access=PropertyAccess.READWRITE)
def LoopStatus(self) -> "s": def LoopStatus(self) -> "s": # noqa: F821
return self._svc.loop_status return self._svc.loop_status
@LoopStatus.setter @LoopStatus.setter
def LoopStatus(self, value: "s"): def LoopStatus(self, value: "s"): # noqa: F821
self._svc.loop_status = str(value) self._svc.loop_status = str(value)
@dbus_property(access=PropertyAccess.READWRITE) @dbus_property(access=PropertyAccess.READWRITE)
def Shuffle(self) -> "b": def Shuffle(self) -> "b": # noqa: F821
return self._svc.shuffle return self._svc.shuffle
@Shuffle.setter @Shuffle.setter
def Shuffle(self, value: "b"): def Shuffle(self, value: "b"): # noqa: F821
self._svc.shuffle = bool(value) self._svc.shuffle = bool(value)
@dbus_property(access=PropertyAccess.READWRITE) @dbus_property(access=PropertyAccess.READWRITE)
def Rate(self) -> "d": def Rate(self) -> "d": # noqa: F821
return float(self._svc.rate) return float(self._svc.rate)
@Rate.setter @Rate.setter
def Rate(self, value: "d"): def Rate(self, value: "d"): # noqa: F821
self._svc.rate = float(value) self._svc.rate = float(value)
@@ -290,7 +296,8 @@ class MprisService:
def _emit(self, changed: dict[str, Any]): def _emit(self, changed: dict[str, Any]):
if not (self.enabled and self._loop): if not (self.enabled and self._loop):
return return
self._loop.call_soon_threadsafe(self._player.emit_properties_changed, changed) self._loop.call_soon_threadsafe(
self._player.emit_properties_changed, changed)
# -- обновление состояния (из основного потока) ---------------------- # -- обновление состояния (из основного потока) ----------------------
@@ -334,7 +341,8 @@ class MprisService:
def notify_seeked(self, position_us: int): def notify_seeked(self, position_us: int):
self.update_position(position_us) self.update_position(position_us)
if self.enabled and self._loop: if self.enabled and self._loop:
self._loop.call_soon_threadsafe(self._player.Seeked, int(position_us)) self._loop.call_soon_threadsafe(
self._player.Seeked, int(position_us))
def update_volume(self, volume: float): def update_volume(self, volume: float):
volume = max(0.0, min(1.0, float(volume))) volume = max(0.0, min(1.0, float(volume)))

View File

@@ -30,9 +30,15 @@ import requests
PROJECT_DIR = Path(__file__).resolve().parent 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" CREDS_PATH = CONFIG_DIR / "credentials.json"
BROWSER_DIR = CONFIG_DIR / "browser" BROWSER_DIR = CONFIG_DIR / "browser"
@@ -54,10 +60,12 @@ _orig_getaddrinfo = socket.getaddrinfo
def force_ipv4() -> None: 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): 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 socket.getaddrinfo = getaddrinfo
@@ -83,7 +91,12 @@ def load_credentials() -> dict:
def save_credentials(data: dict) -> None: def save_credentials(data: dict) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True) 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: try:
CREDS_PATH.chmod(0o600) CREDS_PATH.chmod(0o600)
except OSError: except OSError:
@@ -119,7 +132,9 @@ def get_auth_settings(reference_uri: str = "https://music.mts.ru/") -> dict:
timeout=30, timeout=30,
) )
if response.status_code != 200: if response.status_code != 200:
die(f"auth_settings вернул HTTP {response.status_code}: {response.text[:300]}") die(
f"auth_settings вернул HTTP {response.status_code}: "
f"{response.text[:300]}")
return response.json() return response.json()
@@ -127,7 +142,9 @@ def cmd_login(args: argparse.Namespace) -> None:
try: try:
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
except ImportError: except ImportError:
die("не установлен playwright. Выполни: pip install -r requirements.txt") die(
"не установлен playwright. Выполни: "
"pip install -r requirements.txt")
settings = get_auth_settings() settings = get_auth_settings()
authorize_uri = settings["web_sso_authorize_uri"] authorize_uri = settings["web_sso_authorize_uri"]
@@ -155,16 +172,20 @@ def cmd_login(args: argparse.Namespace) -> None:
token = None token = None
if token: if token:
break break
# Токен может прийти в URL до того, как SPA успеет его сохранить. # Токен может прийти в URL до того, как SPA успеет его
# сохранить.
if "yatoken=" in page.url: 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] token = (query.get("yatoken") or [None])[0]
if token: if token:
break break
page.wait_for_timeout(1000) page.wait_for_timeout(1000)
if not token: if not token:
die("не дождался токена. Вход не завершён или истекло время ожидания.") die(
"не дождался токена. Вход не завершён "
"или истекло время ожидания.")
ya_uid = page.evaluate("localStorage.getItem('ya_uid')") ya_uid = page.evaluate("localStorage.getItem('ya_uid')")
user_id = page.evaluate("localStorage.getItem('userId')") user_id = page.evaluate("localStorage.getItem('userId')")
@@ -213,7 +234,11 @@ class Api:
def post(self, path: str, **params) -> dict: def post(self, path: str, **params) -> dict:
return self._request("POST", path, params=params) 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() force_ipv4()
last_error: Exception | None = None last_error: Exception | None = None
for attempt in range(3): for attempt in range(3):
@@ -235,18 +260,24 @@ class Api:
raise AssertionError # для type checker raise AssertionError # для type checker
if response.status_code == 401: if response.status_code == 401:
die("токен не принят (401). Выполни заново: python mtsmusic.py login") die(
"токен не принят (401). Выполни заново: "
"python mtsmusic.py login")
if response.status_code == 404: if response.status_code == 404:
die(f"не найдено (404) на {path}") die(f"не найдено (404) на {path}")
if response.status_code != 200: if response.status_code != 200:
die(f"HTTP {response.status_code} на {path}: {response.text[:300]}") die(
f"HTTP {response.status_code} на {path}: "
f"{response.text[:300]}")
return response.json() return response.json()
def get_url(self, url: str) -> dict: def get_url(self, url: str) -> dict:
force_ipv4() force_ipv4()
response = self.session.get(url, headers=self._headers(), timeout=30) response = self.session.get(url, headers=self._headers(), timeout=30)
if response.status_code != 200: if response.status_code != 200:
die(f"HTTP {response.status_code} на {url[:120]}: {response.text[:300]}") die(
f"HTTP {response.status_code} на {url[:120]}: "
f"{response.text[:300]}")
return response.json() return response.json()
@@ -256,7 +287,10 @@ def require_token(api: Api, what: str) -> None:
def bff_get(path: str) -> dict | None: def bff_get(path: str) -> dict | None:
"""Запрос к BFF MTS с сохранёнными cookie. None, если cookie нет/не подошли.""" """Запрос к BFF MTS с сохранёнными cookie.
None, если cookie нет/не подошли.
"""
cookies = load_credentials().get("cookies") or [] cookies = load_credentials().get("cookies") or []
if not cookies: if not cookies:
return None return None
@@ -339,17 +373,27 @@ def cmd_status(args: argparse.Namespace) -> None:
payload = api.get("/account/mts/status") payload = api.get("/account/mts/status")
result = payload.get("result") or {} result = payload.get("result") or {}
account = result.get("account") or {} account = result.get("account") or {}
subscriptions = ((result.get("masterhub") or {}).get("activeSubscriptions")) or [] subscriptions = ((result.get("masterhub") or {}).get(
"activeSubscriptions")) or []
# BFF даёт человекочитаемое название тарифа. # BFF даёт человекочитаемое название тарифа.
bff_profile = bff_get("/v1/user-profile") or {} bff_profile = bff_get("/v1/user-profile") or {}
profile = bff_profile.get("profile") or {} profile = bff_profile.get("profile") or {}
bff_subscriptions = bff_profile.get("subscriptions") 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"UID: {account.get('uid', '?')}")
print(f"Имя: {profile.get('alias') or account.get('fullName') or account.get('displayName') or account.get('login') or '?'}") print(f"Имя: {alias}")
print(f"Телефон: {profile.get('phone') or account.get('phone') or '?'}") print(f"Телефон: {phone}")
print(f"Подписка: {'активна' if subscriptions or bff_subscriptions else 'нет'}") print(f"Подписка: {'активна' if has_sub else 'нет'}")
if bff_subscriptions: if bff_subscriptions:
for sub in bff_subscriptions: for sub in bff_subscriptions:
name = sub.get("name") or sub.get("id") or "?" name = sub.get("name") or sub.get("id") or "?"
@@ -357,13 +401,15 @@ def cmd_status(args: argparse.Namespace) -> None:
print(f" - {name}{f' (до {until})' if until else ''}") print(f" - {name}{f' (до {until})' if until else ''}")
else: else:
for sub in subscriptions: 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") until = sub.get("expires") or sub.get("expirationDate")
print(f" - {name}{f' (до {until})' if until else ''}") print(f" - {name}{f' (до {until})' if until else ''}")
# Дописываем uid в креды, если при логине SPA не успела его сохранить. # Дописываем uid в креды, если при логине SPA не успела его сохранить.
creds = load_credentials() 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 "") creds["ya_uid"] = str(account.get("uid") or "")
save_credentials(creds) save_credentials(creds)
@@ -381,7 +427,9 @@ def get_uid(api: Api) -> str:
result = api.get("/account/mts/status").get("result") or {} result = api.get("/account/mts/status").get("result") or {}
uid = (result.get("account") or {}).get("uid") uid = (result.get("account") or {}).get("uid")
if not uid: if not uid:
die("не удалось определить uid аккаунта. Выполни: python mtsmusic.py login") die(
"не удалось определить uid аккаунта. Выполни: "
"python mtsmusic.py login")
creds = load_credentials() or {} creds = load_credentials() or {}
creds["ya_uid"] = str(uid) creds["ya_uid"] = str(uid)
save_credentials(creds) save_credentials(creds)
@@ -408,21 +456,25 @@ def track_album_ref(track_id: str, album: str | None = None) -> str:
def fetch_mix_batch(api: Api, queue: list[str] | None = None) -> dict: def fetch_mix_batch(api: Api, queue: list[str] | None = None) -> dict:
params = {"queue": ",".join(queue)} if queue else {} 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]: def fetch_liked_tracks(api: Api, uid: str, limit: int = 300) -> list[dict]:
"""Избранные треки с деталями: /tracks принимает не больше 50 id за раз.""" """Избранные треки с деталями: /tracks принимает не больше 50 id за раз."""
payload = api.get(f"/users/{uid}/likes/tracks") 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]] ids = [str(ref.get("id")) for ref in refs[:limit]]
tracks: list[dict] = [] tracks: list[dict] = []
for start in range(0, len(ids), 50): for start in range(0, len(ids), 50):
chunk = ids[start : start + 50] chunk = ids[start: start + 50]
if chunk: if chunk:
tracks.extend( tracks.extend(
api.get("/tracks", trackIds=",".join(chunk)).get("result") or [] api.get(
) "/tracks",
trackIds=",".join(chunk)).get("result") or [])
return tracks return tracks
@@ -479,7 +531,8 @@ def cmd_mix(args: argparse.Namespace) -> None:
print_mix_item(index, item) print_mix_item(index, item)
if args.play: 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: def cmd_like(args: argparse.Namespace) -> None:
@@ -517,15 +570,16 @@ def cmd_likes(args: argparse.Namespace) -> None:
limit = max(1, args.number) limit = max(1, args.number)
liked = liked[:limit] liked = liked[:limit]
refs = ",".join(str(item.get("id")) for item in liked)
details: dict[str, dict] = {} details: dict[str, dict] = {}
# /tracks отдаёт не больше сотни id за раз — бьём на порции. # /tracks отдаёт не больше сотни id за раз — бьём на порции.
ids = [str(item.get("id")) for item in liked] ids = [str(item.get("id")) for item in liked]
for start in range(0, len(ids), 50): for start in range(0, len(ids), 50):
chunk = ids[start : start + 50] chunk = ids[start: start + 50]
try: try:
tracks = api.get("/tracks", trackIds=",".join(chunk)).get("result") or [] tracks = api.get(
"/tracks",
trackIds=",".join(chunk)).get("result") or []
except SystemExit: except SystemExit:
tracks = [] tracks = []
for track in tracks: for track in tracks:
@@ -541,13 +595,18 @@ def cmd_likes(args: argparse.Namespace) -> None:
print(f"{index:>3}. {track_id:>12} (альбом {album})") 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.""" """Запрос текста с подписью из веб-клиента MTS."""
timestamp = int(time.time()) timestamp = int(time.time())
message = f"{track_id}{timestamp}" message = f"{track_id}{timestamp}"
signature = base64.b64encode( signature = base64.b64encode(
hmac.new(LYRICS_SIGN_KEY.encode(), message.encode(), hashlib.sha256).digest() hmac.new(
).decode() LYRICS_SIGN_KEY.encode(),
message.encode(),
hashlib.sha256).digest()).decode()
force_ipv4() force_ipv4()
return api.session.get( return api.session.get(
@@ -603,7 +662,11 @@ def lrclib_lyrics(track: dict, want_sync: bool) -> str | None:
candidates: list[dict] = [] candidates: list[dict] = []
for params in queries: for params in queries:
try: 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: except requests.RequestException:
continue continue
if response.status_code != 200: if response.status_code != 200:
@@ -623,13 +686,18 @@ def lrclib_lyrics(track: dict, want_sync: bool) -> str | None:
duration = (track.get("durationMs") or 0) / 1000 duration = (track.get("durationMs") or 0) / 1000
def score(item: dict) -> float: def score(item: dict) -> float:
diff = abs(float(item.get("duration") or 0) - duration) if duration else 0 diff = abs(
exact = 0 if (item.get("trackName") or "").casefold() == title.casefold() else 50 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 return diff + exact
usable = [item for item in candidates if (item.get(field) or "").strip()] usable = [item for item in candidates if (item.get(field) or "").strip()]
if not usable and want_sync: 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" field = "plainLyrics"
if not usable: if not usable:
return None return None
@@ -667,7 +735,9 @@ def cmd_lyrics(args: argparse.Namespace) -> None:
if args.output: if args.output:
output = Path(args.output) output = Path(args.output)
output.write_text(text, encoding="utf-8") output.write_text(text, encoding="utf-8")
print(f"Сохранено: {output} ({len(text)} символов, источник: {source})") print(
f"Сохранено: {output} ({
len(text)} символов, источник: {source})")
else: else:
if source != "MTS": if source != "MTS":
print(f"# источник текста: {source}", file=sys.stderr) print(f"# источник текста: {source}", file=sys.stderr)
@@ -690,9 +760,10 @@ def resolve_stream(api: Api, track_id: str) -> tuple[str, dict]:
# Лучший вариант: не preview и максимальный битрейт. # Лучший вариант: не preview и максимальный битрейт.
items.sort( items.sort(
key=lambda item: (not item.get("preview", False), item.get("bitrateInKbps", 0)), key=lambda item: (
reverse=True, not item.get(
) "preview", False), item.get(
"bitrateInKbps", 0)), reverse=True, )
item = items[0] item = items[0]
download_url = item["downloadInfoUrl"] download_url = item["downloadInfoUrl"]
@@ -705,7 +776,8 @@ def resolve_stream(api: Api, track_id: str) -> tuple[str, dict]:
host = meta["host"] host = meta["host"]
timestamp = meta["ts"] timestamp = meta["ts"]
digest = hashlib.md5((STREAM_SIGN_SECRET + path + signature).encode()).hexdigest() digest = hashlib.md5(
(STREAM_SIGN_SECRET + path + signature).encode()).hexdigest()
stream_url = f"https://{host}/get-mp3/{digest}/{timestamp}{path}" stream_url = f"https://{host}/get-mp3/{digest}/{timestamp}{path}"
return stream_url, item return stream_url, item
@@ -714,7 +786,9 @@ def cmd_url(args: argparse.Namespace) -> None:
api = Api() api = Api()
url, item = resolve_stream(api, args.track_id) url, item = resolve_stream(api, args.track_id)
if item.get("preview"): if item.get("preview"):
print("# preview: полный трек недоступен без активной подписки", file=sys.stderr) print(
"# preview: полный трек недоступен без активной подписки",
file=sys.stderr)
print(url) print(url)
@@ -748,14 +822,20 @@ def play_url(player: str, url: str) -> None:
raise 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) player = pick_player(explicit_player)
if not player: if not player:
die("не найден mpv/ffplay. Установи один из них или используй: mtsmusic.py url <id>") die(
"не найден mpv/ffplay. Установи один из них или используй: "
"mtsmusic.py url <id>")
for index, track in enumerate(tracks, 1): for index, track in enumerate(tracks, 1):
track_id = str(track.get("id")) 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: try:
url, item = resolve_stream(api, track_id) url, item = resolve_stream(api, track_id)
except SystemExit: except SystemExit:
@@ -775,11 +855,15 @@ def cmd_play(args: argparse.Namespace) -> None:
print_track_info(api, args.track_id) print_track_info(api, args.track_id)
url, item = resolve_stream(api, args.track_id) url, item = resolve_stream(api, args.track_id)
if item.get("preview"): if item.get("preview"):
print("Внимание: это preview-фрагмент (нет активной подписки).", file=sys.stderr) print(
"Внимание: это preview-фрагмент (нет активной подписки).",
file=sys.stderr)
player = pick_player(args.player) player = pick_player(args.player)
if not player: if not player:
die("не найден mpv/ffplay. Установи один из них или используй: mtsmusic.py url <id>") die(
"не найден mpv/ffplay. Установи один из них или используй: "
"mtsmusic.py url <id>")
try: try:
play_url(player, url) play_url(player, url)
@@ -794,7 +878,12 @@ def cmd_download(args: argparse.Namespace) -> None:
force_ipv4() force_ipv4()
output = Path(args.output or f"{args.track_id}.mp3") 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: with requests.get(
url,
stream=True,
headers={"User-Agent": USER_AGENT},
timeout=60,
) as response:
response.raise_for_status() response.raise_for_status()
total = int(response.headers.get("Content-Length") or 0) total = int(response.headers.get("Content-Length") or 0)
done = 0 done = 0
@@ -805,7 +894,12 @@ def cmd_download(args: argparse.Namespace) -> None:
handle.write(chunk) handle.write(chunk)
done += len(chunk) done += len(chunk)
if total: if total:
print(f"\r{done * 100 // total:3d}% {done // 1024} KiB", end="", file=sys.stderr) percent = done * 100 // total
kib = done // 1024
print(
f"\r{percent:3d}% {kib} KiB",
end="",
file=sys.stderr)
print(file=sys.stderr) print(file=sys.stderr)
print(f"Сохранено: {output} ({output.stat().st_size} байт)") print(f"Сохранено: {output} ({output.stat().st_size} байт)")
@@ -821,16 +915,24 @@ def cmd_tui(args: argparse.Namespace) -> None:
return return
if not sys.stdin.isatty() or not sys.stdout.isatty(): if not sys.stdin.isatty() or not sys.stdout.isatty():
die( die(
"TUI нужно запускать в обычном терминале (stdin/stdout не TTY).\n" "TUI нужно запускать в обычном терминале "
"Запусти прямо в окне терминала: .venv/bin/python mtsmusic.py tui\n" "(stdin/stdout не TTY).\n"
"Запусти прямо в окне терминала: "
".venv/bin/python mtsmusic.py tui\n"
"Если это IDE/панель вывода — открой настоящий терминал." "Если это IDE/панель вывода — открой настоящий терминал."
) )
try: try:
from tui import MusicTui from tui import MusicTui
except ImportError as exc: 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: 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: except KeyboardInterrupt:
pass pass
except Exception as exc: # TUI не должен молча исчезать except Exception as exc: # TUI не должен молча исчезать
@@ -869,7 +971,9 @@ def cmd_settings(args: argparse.Namespace) -> None:
key, value = pair.split("=", 1) key, value = pair.split("=", 1)
key = key.strip() key = key.strip()
if key not in DEFAULTS: if key not in DEFAULTS:
die(f"неизвестная настройка: {key}. Доступны: {', '.join(DEFAULTS)}") die(
f"неизвестная настройка: {key}. Доступны: {
', '.join(DEFAULTS)}")
updates[key] = value updates[key] = value
current = save_settings(updates) current = save_settings(updates)
saved = True saved = True
@@ -887,7 +991,11 @@ def print_tui_check() -> None:
import platform import platform
print(f"Python: {sys.executable}") 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"TERM: {os.environ.get('TERM', '?')}")
print(f"COLORTERM: {os.environ.get('COLORTERM', '?')}") print(f"COLORTERM: {os.environ.get('COLORTERM', '?')}")
print(f"stdout TTY: {sys.stdout.isatty()}") print(f"stdout TTY: {sys.stdout.isatty()}")
@@ -908,7 +1016,9 @@ def print_tui_check() -> None:
print("pillow: НЕ УСТАНОВЛЕН") print("pillow: НЕ УСТАНОВЛЕН")
for name in ("ffplay", "mpv"): for name in ("ffplay", "mpv"):
print(f"{name}: {shutil.which(name) or 'нет'}") 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: if size.columns < 90 or size.lines < 30:
print("\nСовет: окно маленькое, TUI лучше смотреть при >= 90x30.") print("\nСовет: окно маленькое, TUI лучше смотреть при >= 90x30.")
@@ -920,9 +1030,14 @@ def build_parser() -> argparse.ArgumentParser:
) )
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
login = sub.add_parser("login", help="войти через браузер и сохранить токен") login = sub.add_parser(
login.add_argument("--headless", action="store_true", help="без окна браузера (капчу не пройти)") "login", help="войти через браузер и сохранить токен")
login.add_argument("--timeout", type=int, default=600, help="секунд на вход (по умолчанию 600)") login.add_argument(
"--headless",
action="store_true",
help="без окна браузера (капчу не пройти)")
login.add_argument("--timeout", type=int, default=600,
help="секунд на вход (по умолчанию 600)")
login.set_defaults(func=cmd_login) login.set_defaults(func=cmd_login)
status = sub.add_parser("status", help="показать аккаунт и подписку") status = sub.add_parser("status", help="показать аккаунт и подписку")
@@ -930,12 +1045,21 @@ def build_parser() -> argparse.ArgumentParser:
search = sub.add_parser("search", help="поиск треков") search = sub.add_parser("search", help="поиск треков")
search.add_argument("query", 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) search.set_defaults(func=cmd_search)
mix = sub.add_parser("mix", help="«Моя волна»: подборка треков") mix = sub.add_parser("mix", help="«Моя волна»: подборка треков")
mix.add_argument("-n", "--number", type=int, default=10, help="сколько треков (по умолчанию 10)") mix.add_argument("-n", "--number", type=int, default=10,
mix.add_argument("--play", action="store_true", help="сразу воспроизвести подборку") help="сколько треков (по умолчанию 10)")
mix.add_argument(
"--play",
action="store_true",
help="сразу воспроизвести подборку")
mix.add_argument("--player", help="путь к mpv/ffplay") mix.add_argument("--player", help="путь к mpv/ffplay")
mix.set_defaults(func=cmd_mix) mix.set_defaults(func=cmd_mix)
@@ -946,16 +1070,27 @@ def build_parser() -> argparse.ArgumentParser:
unlike = sub.add_parser("unlike", help="убрать трек из избранного") unlike = sub.add_parser("unlike", help="убрать трек из избранного")
unlike.add_argument("track_id") unlike.add_argument("track_id")
unlike.add_argument("--album", help="id альбома (если нужно переопределить)") unlike.add_argument(
"--album",
help="id альбома (если нужно переопределить)")
unlike.set_defaults(func=cmd_unlike) 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)") likes.add_argument(
"-n",
"--number",
type=int,
default=50,
help="сколько показать (по умолчанию 50)")
likes.set_defaults(func=cmd_likes) 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("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("-o", "--output", help="сохранить текст в файл")
lyrics.add_argument( lyrics.add_argument(
"--no-fallback", "--no-fallback",
@@ -964,21 +1099,31 @@ def build_parser() -> argparse.ArgumentParser:
) )
lyrics.set_defaults(func=cmd_lyrics) 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("--player", help="путь к mpv/ffplay")
tui.add_argument("--check", action="store_true", help="диагностика окружения и выход") tui.add_argument(
"--check",
action="store_true",
help="диагностика окружения и выход")
tui.add_argument( tui.add_argument(
"--inline", "--inline",
action="store_true", 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) tui.set_defaults(func=cmd_tui)
qt = sub.add_parser("qt", help="настольное Qt-приложение (PySide6)") qt = sub.add_parser("qt", help="настольное Qt-приложение (PySide6)")
qt.set_defaults(func=cmd_qt) qt.set_defaults(func=cmd_qt)
settings = sub.add_parser("settings", help="показать или изменить настройки") settings = sub.add_parser("settings",
help="показать или изменить настройки")
settings.add_argument( settings.add_argument(
"-s", "-s",
"--set", "--set",

View File

@@ -67,7 +67,8 @@ class MusicPlayer:
return command + [url] return command + [url]
async def set_volume(self, volume: int) -> None: async def set_volume(self, volume: int) -> None:
"""Меняет громкость; ffplay/mpv не умеют на лету, поэтому перезапускаем.""" """Меняет громкость; ffplay/mpv не умеют на лету, поэтому
перезапускаем."""
self.volume = max(0, min(100, int(volume))) self.volume = max(0, min(100, int(volume)))
if self.url and self.is_playing(): if self.url and self.is_playing():
position = self.elapsed() position = self.elapsed()
@@ -145,7 +146,12 @@ class MusicPlayer:
if not self.proc: if not self.proc:
return 0.0 return 0.0
now = self.paused_at if self.paused else time.monotonic() now = self.paused_at if self.paused else time.monotonic()
return max(0.0, self.offset + now - self.started_at - self.paused_total) return max(
0.0,
self.offset +
now -
self.started_at -
self.paused_total)
async def seek(self, delta: float, duration: float) -> None: async def seek(self, delta: float, duration: float) -> None:
"""Seek by delta seconds. Restarts ffplay at the new position.""" """Seek by delta seconds. Restarts ffplay at the new position."""

205
qt_app.py
View File

@@ -111,7 +111,9 @@ QLabel { background: transparent; }
#albumLabel { color: #6f6f82; font-size: 12px; } #albumLabel { color: #6f6f82; font-size: 12px; }
#timeLabel { color: #8b8b9e; font-size: 11px; } #timeLabel { color: #8b8b9e; font-size: 11px; }
#queueLabel { color: #6f6f82; font-size: 11px; } #queueLabel { color: #6f6f82; font-size: 11px; }
QSlider::groove:horizontal { height: 4px; background: #2a2a36; border-radius: 2px; } QSlider::groove:horizontal {
height: 4px; background: #2a2a36; border-radius: 2px;
}
QSlider::sub-page:horizontal { background: #ff3b5c; border-radius: 2px; } QSlider::sub-page:horizontal { background: #ff3b5c; border-radius: 2px; }
QSlider::handle:horizontal { QSlider::handle:horizontal {
width: 12px; margin: -4px 0; border-radius: 6px; background: #ffffff; width: 12px; margin: -4px 0; border-radius: 6px; background: #ffffff;
@@ -119,24 +121,32 @@ QSlider::handle:horizontal {
QSlider::handle:horizontal:hover { background: #ffd9df; } QSlider::handle:horizontal:hover { background: #ffd9df; }
#playBtn { background: #ff3b5c; border: none; border-radius: 38px; } #playBtn { background: #ff3b5c; border: none; border-radius: 38px; }
#playBtn:hover { background: #ff5574; } #playBtn:hover { background: #ff5574; }
#prevBtn, #nextBtn { background: rgba(255,255,255,0.05); border: none; border-radius: 22px; } #prevBtn, #nextBtn {
background: rgba(255,255,255,0.05); border: none; border-radius: 22px;
}
#prevBtn:hover, #nextBtn:hover { background: rgba(255,255,255,0.11); } #prevBtn:hover, #nextBtn:hover { background: rgba(255,255,255,0.11); }
#actionBtn { #actionBtn {
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px;
padding: 8px 18px; font-size: 12px; padding: 8px 18px; font-size: 12px;
} }
#actionBtn:hover { background: rgba(255,255,255,0.09); } #actionBtn:hover { background: rgba(255,255,255,0.09); }
#actionBtn[liked="true"] { color: #ff8296; border-color: rgba(255,59,92,0.40); } #actionBtn[liked="true"] {
color: #ff8296; border-color: rgba(255,59,92,0.40);
}
#menuBtn:focus, #prevBtn:focus, #nextBtn:focus, #playBtn:focus, #menuBtn:focus, #prevBtn:focus, #nextBtn:focus, #playBtn:focus,
#actionBtn:focus, #backBtn:focus, #searchBtn:focus { #actionBtn:focus, #backBtn:focus, #searchBtn:focus {
border: 2px solid #ffffff; border: 2px solid #ffffff;
} }
QSlider:focus { border: 1px solid #ffffff; border-radius: 4px; } QSlider:focus { border: 1px solid #ffffff; border-radius: 4px; }
#drawer { background: #16161f; border-right: 1px solid rgba(255,255,255,0.06); } #drawer {
background: #16161f; border-right: 1px solid rgba(255,255,255,0.06);
}
#drawerTitle { font-size: 18px; font-weight: 700; padding: 4px 2px 10px 2px; } #drawerTitle { font-size: 18px; font-weight: 700; padding: 4px 2px 10px 2px; }
#drawerBrand { color: #6f6f82; font-size: 11px; padding: 0 2px 12px 2px; } #drawerBrand { color: #6f6f82; font-size: 11px; padding: 0 2px 12px 2px; }
#drawer QPushButton { #drawer QPushButton {
text-align: left; background: transparent; border: none; border-radius: 10px; text-align: left; background: transparent;
border: none; border-radius: 10px;
padding: 11px 12px; font-size: 14px; color: #e8e8f0; padding: 11px 12px; font-size: 14px; color: #e8e8f0;
} }
#drawer QPushButton:hover { background: rgba(255,255,255,0.06); } #drawer QPushButton:hover { background: rgba(255,255,255,0.06); }
@@ -151,7 +161,8 @@ QSlider:focus { border: 1px solid #ffffff; border-radius: 4px; }
QListWidget { background: transparent; border: none; outline: 0; } QListWidget { background: transparent; border: none; outline: 0; }
QListWidget::item { border: none; } QListWidget::item { border: none; }
QLineEdit { QLineEdit {
background: #1b1b25; border: 1px solid rgba(255,255,255,0.07); border-radius: 13px; background: #1b1b25; border: 1px solid rgba(255,255,255,0.07);
border-radius: 13px;
padding: 9px 12px; font-size: 14px; selection-background-color: #ff3b5c; padding: 9px 12px; font-size: 14px; selection-background-color: #ff3b5c;
} }
QLineEdit:focus { border-color: rgba(255,59,92,0.53); } QLineEdit:focus { border-color: rgba(255,59,92,0.53); }
@@ -170,7 +181,8 @@ QComboBox QAbstractItemView {
QCheckBox { color: #dcdce6; font-size: 13px; spacing: 8px; } QCheckBox { color: #dcdce6; font-size: 13px; spacing: 8px; }
QCheckBox::indicator { width: 16px; height: 16px; } QCheckBox::indicator { width: 16px; height: 16px; }
QCheckBox::indicator:unchecked { QCheckBox::indicator:unchecked {
border: 1px solid rgba(255,255,255,0.35); border-radius: 4px; background: #1b1b25; border: 1px solid rgba(255,255,255,0.35);
border-radius: 4px; background: #1b1b25;
} }
#searchBtn { #searchBtn {
background: #ff3b5c; border: none; border-radius: 13px; background: #ff3b5c; border: none; border-radius: 13px;
@@ -188,14 +200,16 @@ QScrollBar::handle:vertical {
QScrollBar::add-line, QScrollBar::sub-line { height: 0; } QScrollBar::add-line, QScrollBar::sub-line { height: 0; }
QScrollBar::add-page, QScrollBar::sub-page { background: transparent; } QScrollBar::add-page, QScrollBar::sub-page { background: transparent; }
#toast { #toast {
background: #23232f; border: 1px solid rgba(255,255,255,0.09); border-radius: 14px; background: #23232f; border: 1px solid rgba(255,255,255,0.09);
border-radius: 14px;
padding: 10px 16px; color: #f2f2f7; font-size: 12px; padding: 10px 16px; color: #f2f2f7; font-size: 12px;
} }
""" """
def media_icon(kind: str, size: int = 64) -> QIcon: def media_icon(kind: str, size: int = 64) -> QIcon:
"""Рисует белые иконки плеера, чтобы они выглядели одинаково в любой теме.""" """Рисует белые иконки плеера, чтобы они выглядели одинаково
в любой теме."""
pixmap = QPixmap(size, size) pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent) pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap) painter = QPainter(pixmap)
@@ -211,10 +225,31 @@ def media_icon(kind: str, size: int = 64) -> QIcon:
path.closeSubpath() path.closeSubpath()
painter.drawPath(path) painter.drawPath(path)
elif kind == "pause": elif kind == "pause":
painter.drawRoundedRect(QRectF(20 * s, 16 * s, 9 * s, 32 * s), 2 * s, 2 * s) painter.drawRoundedRect(
painter.drawRoundedRect(QRectF(35 * s, 16 * s, 9 * s, 32 * s), 2 * s, 2 * s) QRectF(
20 * s,
16 * s,
9 * s,
32 * s),
2 * s,
2 * s)
painter.drawRoundedRect(
QRectF(
35 * s,
16 * s,
9 * s,
32 * s),
2 * s,
2 * s)
elif kind == "prev": elif kind == "prev":
painter.drawRoundedRect(QRectF(15 * s, 14 * s, 7 * s, 36 * s), 2 * s, 2 * s) painter.drawRoundedRect(
QRectF(
15 * s,
14 * s,
7 * s,
36 * s),
2 * s,
2 * s)
path = QPainterPath() path = QPainterPath()
path.moveTo(52 * s, 14 * s) path.moveTo(52 * s, 14 * s)
path.lineTo(52 * s, 50 * s) path.lineTo(52 * s, 50 * s)
@@ -222,7 +257,14 @@ def media_icon(kind: str, size: int = 64) -> QIcon:
path.closeSubpath() path.closeSubpath()
painter.drawPath(path) painter.drawPath(path)
elif kind == "next": elif kind == "next":
painter.drawRoundedRect(QRectF(42 * s, 14 * s, 7 * s, 36 * s), 2 * s, 2 * s) painter.drawRoundedRect(
QRectF(
42 * s,
14 * s,
7 * s,
36 * s),
2 * s,
2 * s)
path = QPainterPath() path = QPainterPath()
path.moveTo(12 * s, 14 * s) path.moveTo(12 * s, 14 * s)
path.lineTo(12 * s, 50 * s) path.lineTo(12 * s, 50 * s)
@@ -320,14 +362,14 @@ class CoverView(QWidget):
gradient.setColorAt(0.0, QColor("#252533")) gradient.setColorAt(0.0, QColor("#252533"))
gradient.setColorAt(1.0, QColor("#15151d")) gradient.setColorAt(1.0, QColor("#15151d"))
painter.fillRect(rect, gradient) painter.fillRect(rect, gradient)
painter.setPen(QColor(255,255,255,48)) painter.setPen(QColor(255, 255, 255, 48))
font = QFont() font = QFont()
font.setPointSize(64) font.setPointSize(64)
painter.setFont(font) painter.setFont(font)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "♪") painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "♪")
painter.setClipping(False) painter.setClipping(False)
painter.setPen(QPen(QColor(255,255,255,31), 1)) painter.setPen(QPen(QColor(255, 255, 255, 31), 1))
painter.setBrush(Qt.BrushStyle.NoBrush) painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 20, 20) painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 20, 20)
painter.end() painter.end()
@@ -422,7 +464,12 @@ class TrackDelegate(QStyledItemDelegate):
painter.setPen(QColor("#6f6f82")) painter.setPen(QColor("#6f6f82"))
painter.drawText( painter.drawText(
QRectF(rect.right() - duration_width, rect.top(), duration_width, 22), QRectF(
rect.right() -
duration_width,
rect.top(),
duration_width,
22),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
duration, duration,
) )
@@ -539,7 +586,8 @@ class QtMusicApp(QMainWindow):
self._update_cover_size() self._update_cover_size()
self.play_btn.setFocus() self.play_btn.setFocus()
# MPRIS: система видит плеер как источник звука (панель GNOME, playerctl). # MPRIS: система видит плеер как источник звука (панель GNOME,
# playerctl).
self.mpris_bridge = MprisBridge(self) self.mpris_bridge = MprisBridge(self)
self.mpris_bridge.play.connect(self.mpris_play) self.mpris_bridge.play.connect(self.mpris_play)
self.mpris_bridge.pause.connect(self.mpris_pause) self.mpris_bridge.pause.connect(self.mpris_pause)
@@ -608,9 +656,12 @@ class QtMusicApp(QMainWindow):
) )
self.pages.addWidget(self.wave_page) self.pages.addWidget(self.wave_page)
self.search_page, self.search_input, self.search_list, self.search_status = ( (
self._build_search_page() self.search_page,
) self.search_input,
self.search_list,
self.search_status,
) = self._build_search_page()
self.search_list._queue_name = "Поиск" self.search_list._queue_name = "Поиск"
self.search_list._tracks = [] self.search_list._tracks = []
self.search_list.itemDoubleClicked.connect( self.search_list.itemDoubleClicked.connect(
@@ -618,9 +669,12 @@ class QtMusicApp(QMainWindow):
) )
self.pages.addWidget(self.search_page) self.pages.addWidget(self.search_page)
self.lyrics_page, self.lyrics_title, self.lyrics_status, self.lyrics_body = ( (
self._build_lyrics_page() self.lyrics_page,
) self.lyrics_title,
self.lyrics_status,
self.lyrics_body,
) = self._build_lyrics_page()
self.pages.addWidget(self.lyrics_page) self.pages.addWidget(self.lyrics_page)
self.settings_page = self._build_settings_page() self.settings_page = self._build_settings_page()
@@ -708,8 +762,10 @@ class QtMusicApp(QMainWindow):
self.slider.setRange(0, 0) self.slider.setRange(0, 0)
self.slider.seekRequested.connect(self.on_slider_seek) self.slider.seekRequested.connect(self.on_slider_seek)
self.slider.actionTriggered.connect(self.on_slider_action) self.slider.actionTriggered.connect(self.on_slider_action)
self.slider.scrubStarted.connect(lambda: setattr(self, "_seeking", True)) self.slider.scrubStarted.connect(
self.slider.scrubFinished.connect(lambda: setattr(self, "_seeking", False)) lambda: setattr(self, "_seeking", True))
self.slider.scrubFinished.connect(
lambda: setattr(self, "_seeking", False))
layout.addWidget(self.slider) layout.addWidget(self.slider)
layout.addSpacing(3) layout.addSpacing(3)
@@ -794,7 +850,8 @@ class QtMusicApp(QMainWindow):
row.addStretch(1) row.addStretch(1)
return row return row
def _build_list_page(self, title: str) -> tuple[QWidget, QListWidget, QLabel]: def _build_list_page(
self, title: str) -> tuple[QWidget, QListWidget, QLabel]:
page = QWidget() page = QWidget()
layout = QVBoxLayout(page) layout = QVBoxLayout(page)
layout.setContentsMargins(16, 10, 16, 12) layout.setContentsMargins(16, 10, 16, 12)
@@ -813,7 +870,8 @@ class QtMusicApp(QMainWindow):
layout.addWidget(widget, 1) layout.addWidget(widget, 1)
return page, widget, status return page, widget, status
def _build_search_page(self) -> tuple[QWidget, QLineEdit, QListWidget, QLabel]: def _build_search_page(
self) -> tuple[QWidget, QLineEdit, QListWidget, QLabel]:
page = QWidget() page = QWidget()
layout = QVBoxLayout(page) layout = QVBoxLayout(page)
layout.setContentsMargins(16, 10, 16, 12) layout.setContentsMargins(16, 10, 16, 12)
@@ -930,15 +988,21 @@ class QtMusicApp(QMainWindow):
self.start_combo.setObjectName("settingCombo") self.start_combo.setObjectName("settingCombo")
for key in START_QUEUES: for key in START_QUEUES:
self.start_combo.addItem(START_QUEUE_LABELS[key], key) self.start_combo.addItem(START_QUEUE_LABELS[key], key)
start_index = self.start_combo.findData(self.settings.get("start_queue", "wave")) start_index = self.start_combo.findData(
self.settings.get("start_queue", "wave"))
self.start_combo.setCurrentIndex(max(0, start_index)) self.start_combo.setCurrentIndex(max(0, start_index))
self.start_combo.currentIndexChanged.connect(self.on_start_changed) self.start_combo.currentIndexChanged.connect(self.on_start_changed)
start_row.addWidget(start_label) start_row.addWidget(start_label)
start_row.addWidget(self.start_combo, 1) start_row.addWidget(self.start_combo, 1)
layout.addLayout(start_row) layout.addLayout(start_row)
self.wave_check = QCheckBox("Продолжать «Мою волну» с последних треков") self.wave_check = QCheckBox(
self.wave_check.setChecked(bool(self.settings.get("wave_from_history", True))) "Продолжать «Мою волну» с последних треков")
self.wave_check.setChecked(
bool(
self.settings.get(
"wave_from_history",
True)))
self.wave_check.toggled.connect(self.on_wave_from_history_toggled) self.wave_check.toggled.connect(self.on_wave_from_history_toggled)
layout.addWidget(self.wave_check) layout.addWidget(self.wave_check)
@@ -1147,7 +1211,11 @@ class QtMusicApp(QMainWindow):
self.set_top_status("Загрузка…") self.set_top_status("Загрузка…")
self.run_task(self._fetch_initial, self.apply_initial, self.on_error) self.run_task(self._fetch_initial, self.apply_initial, self.on_error)
def _fetch_initial(self) -> tuple[str, list[dict], set[str], list[dict], str]: def _fetch_initial(self) -> tuple[str,
list[dict],
set[str],
list[dict],
str]:
api = self.api api = self.api
uid = self.uid or get_uid(api) uid = self.uid or get_uid(api)
# Продолжаем волну с последних прослушанных треков, а не с начала. # Продолжаем волну с последних прослушанных треков, а не с начала.
@@ -1170,7 +1238,8 @@ class QtMusicApp(QMainWindow):
liked = {str(track.get("id")) for track in start} liked = {str(track.get("id")) for track in start}
else: else:
payload = api.get(f"/users/{uid}/likes/tracks") 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 []
liked = {str(ref.get("id")) for ref in refs} liked = {str(ref.get("id")) for ref in refs}
if want == "history": if want == "history":
start, name = list(self.history), "История" start, name = list(self.history), "История"
@@ -1214,14 +1283,17 @@ class QtMusicApp(QMainWindow):
def update_like_button(self, liked: bool | None = None) -> None: def update_like_button(self, liked: bool | None = None) -> None:
if liked is None: if liked is None:
liked = bool(self.current and str(self.current.get("id")) in self.liked_ids) liked = bool(
self.current and str(
self.current.get("id")) in self.liked_ids)
self.like_btn.setText("♥ В избранном" if liked else "♡ В избранное") self.like_btn.setText("♥ В избранном" if liked else "♡ В избранное")
self.like_btn.setProperty("liked", "true" if liked else "false") self.like_btn.setProperty("liked", "true" if liked else "false")
self.like_btn.style().unpolish(self.like_btn) self.like_btn.style().unpolish(self.like_btn)
self.like_btn.style().polish(self.like_btn) self.like_btn.style().polish(self.like_btn)
def update_play_button(self, *args) -> None: def update_play_button(self, *args) -> None:
playing = self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState state = self.player.playbackState()
playing = state == QMediaPlayer.PlaybackState.PlayingState
self.play_btn.setIcon(media_icon("pause" if playing else "play", 80)) self.play_btn.setIcon(media_icon("pause" if playing else "play", 80))
self._update_mpris_status() self._update_mpris_status()
@@ -1239,7 +1311,9 @@ class QtMusicApp(QMainWindow):
return return
self.cover.set_pixmap(None) self.cover.set_pixmap(None)
request = QNetworkRequest(QUrl(url)) request = QNetworkRequest(QUrl(url))
request.setHeader(QNetworkRequest.KnownHeaders.UserAgentHeader, USER_AGENT) request.setHeader(
QNetworkRequest.KnownHeaders.UserAgentHeader,
USER_AGENT)
reply = self.covers.get(request) reply = self.covers.get(request)
def done() -> None: def done() -> None:
@@ -1260,7 +1334,10 @@ class QtMusicApp(QMainWindow):
# -- воспроизведение -------------------------------------------------- # -- воспроизведение --------------------------------------------------
def play_from_list(self, widget: QListWidget, item: QListWidgetItem) -> None: def play_from_list(
self,
widget: QListWidget,
item: QListWidgetItem) -> None:
tracks = getattr(widget, "_tracks", []) tracks = getattr(widget, "_tracks", [])
index = widget.row(item) index = widget.row(item)
if not (0 <= index < len(tracks)): if not (0 <= index < len(tracks)):
@@ -1362,7 +1439,8 @@ class QtMusicApp(QMainWindow):
self.mpris.notify_seeked(value * 1000) self.mpris.notify_seeked(value * 1000)
def on_slider_action(self, action: int) -> None: def on_slider_action(self, action: int) -> None:
# Колесо и стрелки, когда слайдер в фокусе (клик обрабатывает ClickSlider). # Колесо и стрелки, когда слайдер в фокусе (клик обрабатывает
# ClickSlider).
if self.slider.maximum() > 0: if self.slider.maximum() > 0:
value = self.slider.value() value = self.slider.value()
self.player.setPosition(value) self.player.setPosition(value)
@@ -1374,7 +1452,8 @@ class QtMusicApp(QMainWindow):
if self.player.source().isEmpty(): if self.player.source().isEmpty():
return return
duration = self.slider.maximum() duration = self.slider.maximum()
position = max(0, min(self.player.position() + delta_ms, max(0, duration - 500))) position = max(0, min(self.player.position() +
delta_ms, max(0, duration - 500)))
self.player.setPosition(position) self.player.setPosition(position)
self.slider.setValue(position) self.slider.setValue(position)
self.elapsed_label.setText(fmt_millis(position)) self.elapsed_label.setText(fmt_millis(position))
@@ -1384,11 +1463,13 @@ class QtMusicApp(QMainWindow):
# -- MPRIS ------------------------------------------------------------ # -- MPRIS ------------------------------------------------------------
def mpris_play(self) -> None: def mpris_play(self) -> None:
if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState: state = self.player.playbackState()
if state != QMediaPlayer.PlaybackState.PlayingState:
self.toggle_play() self.toggle_play()
def mpris_pause(self) -> None: def mpris_pause(self) -> None:
if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: state = self.player.playbackState()
if state == QMediaPlayer.PlaybackState.PlayingState:
self.toggle_play() self.toggle_play()
def mpris_stop(self) -> None: def mpris_stop(self) -> None:
@@ -1401,9 +1482,8 @@ class QtMusicApp(QMainWindow):
if self.player.source().isEmpty(): if self.player.source().isEmpty():
return return
duration = self.slider.maximum() duration = self.slider.maximum()
position = max( position = max(0, min(self.player.position() +
0, min(self.player.position() + offset_us // 1000, max(0, duration - 500)) offset_us // 1000, max(0, duration - 500)))
)
self.player.setPosition(position) self.player.setPosition(position)
self.slider.setValue(position) self.slider.setValue(position)
self.elapsed_label.setText(fmt_millis(position)) self.elapsed_label.setText(fmt_millis(position))
@@ -1491,13 +1571,18 @@ class QtMusicApp(QMainWindow):
if item.get("type") == "track" and item.get("track") if item.get("type") == "track" and item.get("track")
] ]
self.run_task(fetch, lambda tracks: self.apply_more_wave(tracks, then_play), self.on_error) self.run_task(
fetch, lambda tracks: self.apply_more_wave(
tracks, then_play), self.on_error)
def apply_more_wave(self, tracks: list[dict], then_play: int) -> None: def apply_more_wave(self, tracks: list[dict], then_play: int) -> None:
known = {str(track.get("id")) for track in self.wave} known = {str(track.get("id")) for track in self.wave}
added = [track for track in tracks if str(track.get("id")) not in known] added = [
track for track in tracks if str(
track.get("id")) not in known]
self.wave.extend(added) self.wave.extend(added)
self.set_top_status(f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}") self.set_top_status(
f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}")
if self.queue_name == "Моя волна": if self.queue_name == "Моя волна":
self.queue = list(self.wave) self.queue = list(self.wave)
if then_play < len(self.queue) and added: if then_play < len(self.queue) and added:
@@ -1536,7 +1621,12 @@ class QtMusicApp(QMainWindow):
) )
return track_id return track_id
self.run_task(request, lambda _: self.apply_like(track, not liked), self.like_failed) self.run_task(
request,
lambda _: self.apply_like(
track,
not liked),
self.like_failed)
def apply_like(self, track: dict, liked: bool) -> None: def apply_like(self, track: dict, liked: bool) -> None:
track_id = str(track.get("id")) track_id = str(track.get("id"))
@@ -1548,7 +1638,8 @@ class QtMusicApp(QMainWindow):
if self.current and str(self.current.get("id")) == track_id: if self.current and str(self.current.get("id")) == track_id:
self.update_like_button(liked) self.update_like_button(liked)
self.set_top_status(f"{self.queue_name} · ♥ {len(self.liked_ids)}") self.set_top_status(f"{self.queue_name} · ♥ {len(self.liked_ids)}")
self.toast("♥ Добавлено в избранное" if liked else "♡ Убрано из избранного") self.toast(
"♥ Добавлено в избранное" if liked else "♡ Убрано из избранного")
if self.pages.currentWidget() is self.favorites_page: if self.pages.currentWidget() is self.favorites_page:
self.load_favorites(force=True) self.load_favorites(force=True)
@@ -1664,14 +1755,20 @@ class QtMusicApp(QMainWindow):
if self._favorites_loading: if self._favorites_loading:
return return
if self.favorites and not force: if self.favorites and not force:
self.fill_track_list(self.favorites_list, self.favorites, "Избранное") self.fill_track_list(
self.favorites_list,
self.favorites,
"Избранное")
self.favorites_status.setText(f"{len(self.favorites)} трек(ов)") self.favorites_status.setText(f"{len(self.favorites)} трек(ов)")
return return
self._favorites_loading = True self._favorites_loading = True
self.favorites_status.setText("Загружаю…") self.favorites_status.setText("Загружаю…")
if not self.favorites: if not self.favorites:
self.favorites_list.clear() self.favorites_list.clear()
self.run_task(self._fetch_favorites, self.apply_favorites, self.favorites_failed) self.run_task(
self._fetch_favorites,
self.apply_favorites,
self.favorites_failed)
def _fetch_favorites(self) -> list[dict]: def _fetch_favorites(self) -> list[dict]:
uid = self.uid or get_uid(self.api) uid = self.uid or get_uid(self.api)
@@ -1705,10 +1802,8 @@ class QtMusicApp(QMainWindow):
**{"page-size": 25}, **{"page-size": 25},
nocorrect="true", nocorrect="true",
) )
return ( return (((payload.get("result") or {}).get(
((payload.get("result") or {}).get("tracks") or {}).get("results") "tracks") or {}).get("results") or [])
or []
)
self.run_task(search, self.apply_search, self.search_failed) self.run_task(search, self.apply_search, self.search_failed)

View File

@@ -42,7 +42,8 @@ def _coerce(key: str, value: Any) -> Any:
if key in ("autoplay", "wave_from_history"): if key in ("autoplay", "wave_from_history"):
if isinstance(value, bool): if isinstance(value, bool):
return value return value
return str(value).strip().lower() in ("1", "true", "yes", "on", "да", "вкл") return str(value).strip().lower() in (
"1", "true", "yes", "on", "да", "вкл")
if key == "start_queue": if key == "start_queue":
text = str(value) text = str(value)
return text if text in START_QUEUES else DEFAULTS[key] return text if text in START_QUEUES else DEFAULTS[key]

178
tui.py
View File

@@ -149,9 +149,9 @@ class ListScreen(Screen):
def row_selected(self, event: DataTable.RowSelected) -> None: def row_selected(self, event: DataTable.RowSelected) -> None:
track_id = str(event.row_key.value) track_id = str(event.row_key.value)
index = next( index = next(
(i for i, item in enumerate(self.tracks) if str(item.get("id")) == track_id), (i for i, item in enumerate(
-1, self.tracks) if str(
) item.get("id")) == track_id), -1, )
if index >= 0: if index >= 0:
self.app.play_from_list(self.tracks, index, self.list_title) self.app.play_from_list(self.tracks, index, self.list_title)
self.app.pop_screen() self.app.pop_screen()
@@ -209,9 +209,8 @@ class FavoritesScreen(Screen):
track_id = str(event.row_key.value) track_id = str(event.row_key.value)
tracks = self.app.favorites tracks = self.app.favorites
index = next( index = next(
(i for i, item in enumerate(tracks) if str(item.get("id")) == track_id), (i for i, item in enumerate(tracks) if str(
-1, item.get("id")) == track_id), -1, )
)
if index >= 0: if index >= 0:
self.app.play_from_list(tracks, index, "Избранное") self.app.play_from_list(tracks, index, "Избранное")
self.app.pop_screen() self.app.pop_screen()
@@ -275,7 +274,8 @@ class SearchScreen(Screen):
**{"page-size": 25}, **{"page-size": 25},
nocorrect="true", nocorrect="true",
) )
tracks = (((payload.get("result") or {}).get("tracks") or {}).get("results")) or [] tracks = (((payload.get("result") or {}).get(
"tracks") or {}).get("results")) or []
self.app.call_from_thread(self.fill, tracks) self.app.call_from_thread(self.fill, tracks)
except SystemExit as exc: except SystemExit as exc:
self.app.call_from_thread(self.failed, str(exc)) self.app.call_from_thread(self.failed, str(exc))
@@ -297,9 +297,9 @@ class SearchScreen(Screen):
def row_selected(self, event: DataTable.RowSelected) -> None: def row_selected(self, event: DataTable.RowSelected) -> None:
track_id = str(event.row_key.value) track_id = str(event.row_key.value)
index = next( index = next(
(i for i, item in enumerate(self.results) if str(item.get("id")) == track_id), (i for i, item in enumerate(
-1, self.results) if str(
) item.get("id")) == track_id), -1, )
if index >= 0: if index >= 0:
self.app.play_from_list(self.results, index, "Поиск") self.app.play_from_list(self.results, index, "Поиск")
self.app.pop_screen() self.app.pop_screen()
@@ -391,14 +391,21 @@ class SettingsScreen(Screen):
self.refresh_values() self.refresh_values()
from settings import settings_path from settings import settings_path
self.query_one("#settings-path", Static).update(f"Файл: {settings_path()}") self.query_one(
"#settings-path",
Static).update(
f"Файл: {
settings_path()}")
def refresh_values(self) -> None: def refresh_values(self) -> None:
s = self.app.settings s = self.app.settings
self.query_one("#vol-value", Static).update(f"{s['volume']} %") self.query_one("#vol-value", Static).update(f"{s['volume']} %")
self.query_one("#seek-value", Static).update(f"{s['seek_step']} с") self.query_one("#seek-value", Static).update(f"{s['seek_step']} с")
self.query_one("#autoplay-toggle", Button).label = "вкл" if s["autoplay"] else "выкл" self.query_one(
self.query_one("#start-toggle", Button).label = START_QUEUE_LABELS[s["start_queue"]] "#autoplay-toggle",
Button).label = "вкл" if s["autoplay"] else "выкл"
self.query_one("#start-toggle",
Button).label = START_QUEUE_LABELS[s["start_queue"]]
self.query_one("#wave-toggle", Button).label = ( self.query_one("#wave-toggle", Button).label = (
"вкл" if s["wave_from_history"] else "выкл" "вкл" if s["wave_from_history"] else "выкл"
) )
@@ -426,9 +433,12 @@ class SettingsScreen(Screen):
elif bid == "start-toggle": elif bid == "start-toggle":
order = list(START_QUEUES) order = list(START_QUEUES)
index = order.index(s["start_queue"]) index = order.index(s["start_queue"])
self.app.update_setting("start_queue", order[(index + 1) % len(order)]) self.app.update_setting(
"start_queue", order[(index + 1) % len(order)])
elif bid == "wave-toggle": elif bid == "wave-toggle":
self.app.update_setting("wave_from_history", not s["wave_from_history"]) self.app.update_setting(
"wave_from_history",
not s["wave_from_history"])
self.refresh_values() self.refresh_values()
@@ -532,7 +542,10 @@ class MusicTui(App):
} }
#menu { width: 5; min-width: 5; margin: 0 1 0 0; } #menu { width: 5; min-width: 5; margin: 0 1 0 0; }
#brand { text-style: bold; padding: 0 1; color: #f2f2f2; } #brand { text-style: bold; padding: 0 1; color: #f2f2f2; }
#topstatus { width: 1fr; text-align: right; content-align: right middle; color: #8a8a8a; } #topstatus {
width: 1fr; text-align: right; content-align: right middle;
color: #8a8a8a;
}
#phone { #phone {
height: 1fr; height: 1fr;
@@ -548,7 +561,10 @@ class MusicTui(App):
color: #8a8a8a; color: #8a8a8a;
margin-bottom: 1; margin-bottom: 1;
} }
#title { width: 100%; text-align: center; text-style: bold; color: #ffffff; } #title {
width: 100%; text-align: center; text-style: bold;
color: #ffffff;
}
#artist { width: 100%; text-align: center; color: #b0b0b0; } #artist { width: 100%; text-align: center; color: #b0b0b0; }
#album { width: 100%; text-align: center; color: #6a6a6a; } #album { width: 100%; text-align: center; color: #6a6a6a; }
#time { width: 100%; text-align: center; color: #8a8a8a; margin-top: 1; } #time { width: 100%; text-align: center; color: #8a8a8a; margin-top: 1; }
@@ -566,7 +582,10 @@ class MusicTui(App):
background: #0a0a0a; background: #0a0a0a;
} }
#back { width: 5; min-width: 5; margin-right: 1; } #back { width: 5; min-width: 5; margin-right: 1; }
#screen-title { text-style: bold; width: 1fr; content-align: left middle; color: #f2f2f2; } #screen-title {
text-style: bold; width: 1fr; content-align: left middle;
color: #f2f2f2;
}
#screen-status { height: 1; padding: 0 1; color: #8a8a8a; } #screen-status { height: 1; padding: 0 1; color: #8a8a8a; }
#list-table { height: 1fr; background: #000000; } #list-table { height: 1fr; background: #000000; }
#search-row { height: 3; padding: 0 1; } #search-row { height: 3; padding: 0 1; }
@@ -618,7 +637,10 @@ class MusicTui(App):
self.settings = load_settings() self.settings = load_settings()
self.api: Api | None = None self.api: Api | None = None
self.uid = "" self.uid = ""
self.player = MusicPlayer(player, volume=int(self.settings.get("volume", 80))) self.player = MusicPlayer(
player, volume=int(
self.settings.get(
"volume", 80)))
self.wave: list[dict] = [] self.wave: list[dict] = []
self.favorites: list[dict] = [] self.favorites: list[dict] = []
self.liked_ids: set[str] = set() self.liked_ids: set[str] = set()
@@ -648,7 +670,12 @@ class MusicTui(App):
yield Static("", id="artist") yield Static("", id="artist")
yield Static("", id="album") yield Static("", id="album")
yield Static("0:00 / 0:00", id="time") yield Static("0:00 / 0:00", id="time")
yield ProgressBar(total=100, show_eta=False, show_percentage=False, id="progress") yield ProgressBar(
total=100,
show_eta=False,
show_percentage=False,
id="progress",
)
with Horizontal(id="controls"): with Horizontal(id="controls"):
yield Button("⏮", id="prev") yield Button("⏮", id="prev")
yield Button("▶", id="playpause") yield Button("▶", id="playpause")
@@ -671,7 +698,10 @@ class MusicTui(App):
self.exit() self.exit()
return return
if not self.api.token: if not self.api.token:
self.notify("Сначала войди: python mtsmusic.py login", severity="error", timeout=8) self.notify(
"Сначала войди: python mtsmusic.py login",
severity="error",
timeout=8)
self.set_timer(3, self.exit) self.set_timer(3, self.exit)
return return
self.load_initial() self.load_initial()
@@ -690,7 +720,8 @@ class MusicTui(App):
return value - (value % 2) return value - (value % 2)
def apply_cover_width(self) -> None: def apply_cover_width(self) -> None:
self.cover_width = self.cover_width_for(self.size.width, self.size.height) self.cover_width = self.cover_width_for(
self.size.width, self.size.height)
self.apply_column_width(self.cover_width) self.apply_column_width(self.cover_width)
def apply_column_width(self, width: int) -> None: def apply_column_width(self, width: int) -> None:
@@ -751,15 +782,22 @@ class MusicTui(App):
liked = {str(track.get("id")) for track in start} liked = {str(track.get("id")) for track in start}
else: else:
payload = api.get(f"/users/{self.uid}/likes/tracks") payload = api.get(f"/users/{self.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 []
liked = {str(ref.get("id")) for ref in refs} liked = {str(ref.get("id")) for ref in refs}
if want == "history": if want == "history":
start, name = list(self.history), "История" start, name = list(self.history), "История"
self.call_from_thread(self.apply_initial, wave, liked, start, name) self.call_from_thread(self.apply_initial, wave, liked, start, name)
except SystemExit as exc: except SystemExit as exc:
self.call_from_thread(self.notify, f"Ошибка загрузки: {exc}", severity="error") self.call_from_thread(
self.notify,
f"Ошибка загрузки: {exc}",
severity="error")
except requests.RequestException as exc: except requests.RequestException as exc:
self.call_from_thread(self.notify, f"Сеть недоступна: {exc}", severity="error") self.call_from_thread(
self.notify,
f"Сеть недоступна: {exc}",
severity="error")
def apply_initial( def apply_initial(
self, self,
@@ -795,7 +833,10 @@ class MusicTui(App):
track_id = str(track.get("id")) track_id = str(track.get("id"))
liked = track_id in self.liked_ids liked = track_id in self.liked_ids
self.query_one("#title", Static).update(Text(track.get("title") or "?")) self.query_one(
"#title", Static).update(
Text(
track.get("title") or "?"))
self.query_one("#artist", Static).update(Text(track_artist(track))) self.query_one("#artist", Static).update(Text(track_artist(track)))
self.query_one("#album", Static).update(Text(track_album(track))) self.query_one("#album", Static).update(Text(track_album(track)))
self.update_like_button(liked) self.update_like_button(liked)
@@ -806,19 +847,26 @@ class MusicTui(App):
def update_like_button(self, liked: bool | None = None) -> None: def update_like_button(self, liked: bool | None = None) -> None:
if liked is None: if liked is None:
liked = bool(self.current and str(self.current.get("id")) in self.liked_ids) liked = bool(
self.current and str(
self.current.get("id")) in self.liked_ids)
button = self.query_one("#like", Button) button = self.query_one("#like", Button)
heart = "♥" if liked else "♡" heart = "♥" if liked else "♡"
suffix = " Избранное" if self.cover_width >= 34 else "" suffix = " Избранное" if self.cover_width >= 34 else ""
button.label = heart + suffix button.label = heart + suffix
def update_play_button(self) -> None: def update_play_button(self) -> None:
self.query_one("#playpause", Button).label = "⏸" if self.player.paused else "▶" self.query_one(
"#playpause",
Button).label = "⏸" if self.player.paused else "▶"
self._update_mpris_status() self._update_mpris_status()
def update_time(self) -> None: def update_time(self) -> None:
duration = (self.current or {}).get("durationMs") or 0 duration = (self.current or {}).get("durationMs") or 0
elapsed = min(self.player.elapsed(), duration / 1000 if duration else 0) elapsed = min(
self.player.elapsed(),
duration /
1000 if duration else 0)
bar = self.query_one("#progress", ProgressBar) bar = self.query_one("#progress", ProgressBar)
bar.update(total=max(duration / 1000, 1), progress=elapsed) bar.update(total=max(duration / 1000, 1), progress=elapsed)
self.query_one("#time", Static).update( self.query_one("#time", Static).update(
@@ -867,7 +915,9 @@ class MusicTui(App):
def fetch_cover(self, token: int, url: str) -> None: def fetch_cover(self, token: int, url: str) -> None:
try: try:
force_ipv4() force_ipv4()
response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=20) response = requests.get(
url, headers={
"User-Agent": USER_AGENT}, timeout=20)
response.raise_for_status() response.raise_for_status()
data = response.content data = response.content
except (requests.RequestException, OSError): except (requests.RequestException, OSError):
@@ -884,7 +934,11 @@ class MusicTui(App):
# -- воспроизведение -------------------------------------------------- # -- воспроизведение --------------------------------------------------
def play_from_list(self, tracks: list[dict], index: int, name: str) -> None: def play_from_list(
self,
tracks: list[dict],
index: int,
name: str) -> None:
if not (0 <= index < len(tracks)): if not (0 <= index < len(tracks)):
return return
self.queue_name = name self.queue_name = name
@@ -927,14 +981,20 @@ class MusicTui(App):
url, item = resolve_stream(api, str(track.get("id"))) url, item = resolve_stream(api, str(track.get("id")))
except SystemExit as exc: except SystemExit as exc:
self.call_from_thread( self.call_from_thread(
self.notify, f"Не удалось открыть трек: {exc}", severity="error" self.notify,
) f"Не удалось открыть трек: {exc}",
severity="error")
return return
self.call_from_thread( self.call_from_thread(
self.start_playback, token, track, url, bool(item.get("preview")) self.start_playback, token, track, url, bool(item.get("preview"))
) )
def start_playback(self, token: int, track: dict, url: str, preview: bool) -> None: def start_playback(
self,
token: int,
track: dict,
url: str,
preview: bool) -> None:
if token != self.play_token: if token != self.play_token:
return return
self.run_worker(self._play_url(track, url, preview), group="playback") self.run_worker(self._play_url(track, url, preview), group="playback")
@@ -962,7 +1022,10 @@ class MusicTui(App):
def action_play_current(self) -> None: def action_play_current(self) -> None:
if self.current: if self.current:
self.play_track(self.current, self.queue or self.wave, self.current_index) self.play_track(
self.current,
self.queue or self.wave,
self.current_index)
def try_next(self, direction: int) -> None: def try_next(self, direction: int) -> None:
queue = self.queue or self.wave queue = self.queue or self.wave
@@ -995,7 +1058,12 @@ class MusicTui(App):
if not self.current or not self.player.is_playing(): if not self.current or not self.player.is_playing():
return return
duration = (self.current.get("durationMs") or 0) / 1000 duration = (self.current.get("durationMs") or 0) / 1000
self.run_worker(self._seek(delta, duration), group="seek", exclusive=True) self.run_worker(
self._seek(
delta,
duration),
group="seek",
exclusive=True)
async def _seek(self, delta: float, duration: float) -> None: async def _seek(self, delta: float, duration: float) -> None:
await self.player.seek(delta, duration) await self.player.seek(delta, duration)
@@ -1035,19 +1103,29 @@ class MusicTui(App):
] ]
self.call_from_thread(self.apply_more_wave, tracks, then_play) self.call_from_thread(self.apply_more_wave, tracks, then_play)
except SystemExit as exc: except SystemExit as exc:
self.call_from_thread(self.notify, f"Ошибка волны: {exc}", severity="error") self.call_from_thread(
self.notify,
f"Ошибка волны: {exc}",
severity="error")
def apply_more_wave(self, tracks: list[dict], then_play: int | None) -> None: def apply_more_wave(
self,
tracks: list[dict],
then_play: int | None) -> None:
known = {str(track.get("id")) for track in self.wave} known = {str(track.get("id")) for track in self.wave}
added = [track for track in tracks if str(track.get("id")) not in known] added = [
track for track in tracks if str(
track.get("id")) not in known]
self.wave.extend(added) self.wave.extend(added)
self.set_topstatus(f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}") self.set_topstatus(
f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}")
if then_play is not None and then_play < len(self.queue): if then_play is not None and then_play < len(self.queue):
self.play_track(self.queue[then_play], self.queue, then_play) self.play_track(self.queue[then_play], self.queue, then_play)
elif then_play is not None and added: elif then_play is not None and added:
self.queue = self.wave self.queue = self.wave
self.current_index = len(self.wave) - len(added) self.current_index = len(self.wave) - len(added)
self.play_track(self.wave[self.current_index], self.wave, self.current_index) self.play_track(self.wave[self.current_index],
self.wave, self.current_index)
# -- избранное -------------------------------------------------------- # -- избранное --------------------------------------------------------
@@ -1064,7 +1142,10 @@ class MusicTui(App):
albums = track.get("albums") or [] albums = track.get("albums") or []
album = str(albums[0].get("id")) if albums else "" album = str(albums[0].get("id")) if albums else ""
if not album: if not album:
self.call_from_thread(self.notify, "У трека нет альбома", severity="error") self.call_from_thread(
self.notify,
"У трека нет альбома",
severity="error")
return return
liked = track_id in self.liked_ids liked = track_id in self.liked_ids
try: try:
@@ -1079,7 +1160,10 @@ class MusicTui(App):
**{"track-id": f"{track_id}:{album}"}, **{"track-id": f"{track_id}:{album}"},
) )
except SystemExit as exc: except SystemExit as exc:
self.call_from_thread(self.notify, f"Ошибка избранного: {exc}", severity="error") self.call_from_thread(
self.notify,
f"Ошибка избранного: {exc}",
severity="error")
return return
self.call_from_thread(self.apply_like, track, not liked) self.call_from_thread(self.apply_like, track, not liked)
@@ -1124,7 +1208,10 @@ class MusicTui(App):
def apply_settings(self) -> None: def apply_settings(self) -> None:
volume = int(self.settings.get("volume", 80)) volume = int(self.settings.get("volume", 80))
if self.player.volume != volume: if self.player.volume != volume:
self.run_worker(self.player.set_volume(volume), group="volume", exclusive=True) self.run_worker(
self.player.set_volume(volume),
group="volume",
exclusive=True)
if self.mpris: if self.mpris:
self.mpris.update_volume(volume / 100) self.mpris.update_volume(volume / 100)
@@ -1194,7 +1281,8 @@ class MusicTui(App):
self.seek(target - self.player.elapsed()) self.seek(target - self.player.elapsed())
def mpris_set_volume(self, value: float) -> None: def mpris_set_volume(self, value: float) -> None:
self.update_setting("volume", int(round(max(0.0, min(1.0, value)) * 100))) self.update_setting("volume", int(
round(max(0.0, min(1.0, value)) * 100)))
def action_show_favorites(self) -> None: def action_show_favorites(self) -> None:
self.push_screen(FavoritesScreen()) self.push_screen(FavoritesScreen())