Compare commits

..

15 Commits

Author SHA1 Message Date
jads
c8043d8b80 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	README.md
#	mtsmusic.py
#	qt_app.py
2026-10-25 23:45:33 +03:00
jads
de9bf80d11 feat: download tracks with full ID3 metadata
- Add downloads.py: "Artist - Title.mp3" naming, no-overwrite dedupe,
  configurable download_dir, progress and per-item callbacks.
- Save maximum metadata via tags.py (ID3v2.3, mutagen): artists, album
  and album artist, track/disc, year/date, raw genre, label, duration,
  explicit flag, cover 600x600, plain and synced lyrics (MTS then
  LRCLIB), track URLs and Yandex/MTS ids.
- TUI/Qt: download current track and queue from the menu, "d" key in
  lists, folder picker in settings; CLI download gains -d/--dir and
  --no-tags.
- Add mutagen to requirements and document download/metadata in README.
- Fix MPRIS update_metadata crashing without dbus-next.
2026-10-25 20:06:59 +03:00
jadssss
ab595f6ff6 feat: cache tracks locally and add downloads
Треки больше не играют напрямую из сети: сервер Яндекса закрывает поток
примерно через 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 для скриптов.
2026-10-24 10:10:20 +03:00
jads
e71f5623a7 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.
2026-10-23 23:52:14 +03:00
jads
4460791a95 feat: expose player over MPRIS for system media controls
Register a session D-Bus MPRIS service (org.mpris.MediaPlayer2.mtsmusic)
in both the TUI and Qt apps, so the player shows up as a media source:
play/pause, next/previous, seek, metadata (title/artist/album/length/art)
and volume can be controlled from the GNOME media-controls extension,
playerctl and other MPRIS clients. Integration is optional and degrades
to a no-op without dbus-next or a session bus.
2026-10-23 23:32:59 +03:00
jads
5b202984fa docs: add MIT license and project description 2026-10-23 23:09:42 +03:00
jads
637ac23b73 feat: add settings (volume, seek step, start queue) with TUI/Qt pages
- settings.py persists to .mts-music/settings.json; CLI 'settings' command
- player gains volume support (ffplay -volume / mpv --volume)
- TUI settings screen and Qt settings page (volume, output device, seek step,
  autoplay, start queue, wave-from-history)
- start queue + autoplay applied at startup; arrows use configured seek step
- shared fetch_liked_tracks helper; cache TUI cover renders and Qt scaled cover
2026-10-23 22:44:55 +03:00
jads
b64c2dcd12 feat: polish Qt player and keep TUI cover in color 2026-10-23 22:21:47 +03:00
jads
0eb6909724 style: center TUI cover and switch player to monochrome 2026-10-23 22:04:51 +03:00
jads
26a58068cb feat: add phone-style PySide6 desktop player 2026-10-23 21:54:21 +03:00
jads
f77f81dead feat: redesign TUI as a compact phone-style player 2026-10-23 21:44:03 +03:00
jads
ff7edd9da7 fix: harden TUI startup and add diagnostics 2026-10-23 21:26:58 +03:00
jads
773ba69f0b feat: add Textual TUI player 2026-10-23 21:26:58 +03:00
jads
ce2e324dbe feat: add My Wave, favorites and lyrics support 2026-10-23 21:26:58 +03:00
jads
a41ec450fe feat: add search, playback and MP3 downloads 2026-10-23 21:26:58 +03:00
12 changed files with 6262 additions and 28 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 jads
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

257
README.md
View File

@@ -1,7 +1,12 @@
# MTS Music CLI
# MTS Music — another client
Минимальный клиент для **своего** аккаунта MTS Music: вход, статус аккаунта
и проверка подписки.
Неофициальный клиент для **своего** аккаунта MTS Music: «Моя волна», поиск,
избранное, тексты песен, воспроизведение и скачивание в MP3 с полными
ID3-тегами. Есть компактный TUI-плеер и настольное Qt-приложение в стиле
телефона.
> Только для личного использования с собственной платной подпиской MTS.
> Проект не связан с МТС и Яндексом; контент не перераспространяется.
Внутри используется тот же путь, что и в веб-клиенте: MTS SSO выдаёт
OAuth-токен Яндекс.Музыки, дальше запросы идут на `api.music.yandex.net`.
@@ -14,6 +19,12 @@ python3 -m venv .venv
PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromium
```
Для Qt-приложения дополнительно:
```bash
.venv/bin/pip install -r requirements-qt.txt
```
Всё локальное лежит рядом с проектом:
- `.venv/` — зависимости
- `.playwright/` — браузер Chromium
@@ -29,9 +40,249 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
сохранятся в `.mts-music/credentials.json` с правами `600`.
Повторный вход нужен только когда токен перестанет работать.
## TUI-плеер
```bash
.venv/bin/python mtsmusic.py tui
```
Интерфейс в терминале сделан как экран телефона: чёрно-белые элементы и
цветная обложка. Обложка по центру, строго под ней по той же ширине —
большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже
«Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое
меню: «Моя волна», избранное, история прослушивания, поиск, текст песни,
«Скачать трек», «Скачать список», «Настройки» и выход.
Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны
(клик по прогрессу перематывает трек).
| Клавиша | Действие |
|---------|----------|
| `Space` | пауза / продолжить |
| `←` / `→` | перемотка на −5 / +5 секунд |
| `n` / `p` | следующий / предыдущий трек |
| `t` | текст песни |
| `l` | добавить / убрать из избранного |
| `m` | открыть меню |
| `w` | список «Моей волны» |
| `f` | избранное |
| `h` | история |
| `s` | поиск |
| `d` | скачать выделенный трек в списке |
| `q` | выход |
История хранится в `.mts-music/history.json` (последние 100 треков).
Плеер — `ffplay` (или `mpv`, если установлен). Пауза реализована через
`SIGSTOP`/`SIGCONT`, а перемотка перезапускает воспроизведение с нужной
секунды, поэтому отдельный демон не нужен.
Треки не транслируются напрямую: сначала они скачиваются в локальный кэш
`.mts-music/cache/` (до 512 МБ, старые файлы удаляются автоматически), а плеер
играет уже готовый MP3. Так надёжнее — сервер Яндекса закрывает соединение,
если клиент читает поток медленнее реального времени, из-за чего трек
обрывался через полминуты и клиент переключался на следующий. Заодно кэш
убирает задержку при повторном прослушивании и делает перемотку мгновенной.
## Qt-приложение
```bash
.venv/bin/python mtsmusic.py qt
```
Отдельное окно в стиле телефона: крупная цветная обложка, кнопка play/pause,
переключение треков, перемотка, текст песни и избранное. Кнопка «☰» открывает
боковую панель с «Моей волной», избранным, историей, поиском, скачиванием
и настройками.
Воспроизведение — через `QMediaPlayer`/FFmpeg из PySide6, поэтому работает
перемотка и системный звук. По полосе прогресса можно кликнуть в любое место —
каретка сразу перейдёт туда (и потянется, если вести мышью). Окно растягивается
вместе с обложкой, списки прокручиваются, двойной клик по строке запускает трек.
`Tab` переключает фокус по элементам: меню, полоса, предыдущий, play, следующий,
«Текст», «Избранное». Горячие клавиши: `Space`, `←`/`→`, `N`/`P`, `L`, `T`, `M`,
`F`, `H`, `S`, `W`; `Esc` закрывает меню/экран, `Ctrl+Q` — выход.
«Моя волна» продолжается с последних прослушанных треков (берётся история), а не
начинается каждый раз с одного и того же места.
## Скачивание и метаданные
В меню обоих клиентов пункт «Скачать» открывает выбор: текущий трек, всё
избранное, 20 треков «Моей волны» или результаты поиска; папка выбирается
диалогом в Qt и вводится вручную в TUI, прогресс и число ошибок видны на
экране. Быстрые пункты «Скачать трек»/«Скачать список» (и клавиша `d` на
экране списка в TUI) сразу кладут в папку `download_dir` без диалога. В CLI
это команда `download` с теми же источниками (`--likes`, `--wave`, `--search`).
Файлы кладутся в папку `download_dir` (`~/Music/MTS Music` по умолчанию),
имя — `Исполнитель - Название.mp3`; существующие файлы не перезаписываются,
при совпадении добавляется ` (2)`, ` (3)` и т.д. (`--force` перезаписывает).
В скачанный файл пишется максимум метаданных ID3v2.3 (через `mutagen`, он в
`requirements.txt`):
- название, все исполнители (`TPE1`), альбом (`TALB`) и альбомный исполнитель
(`TPE2`);
- номер трека и диска (`TRCK`/`TPOS`), год и дата релиза (`TYER`/`TDAT`),
жанр как в API (`TCON`), лейбл (`TPUB`), длительность (`TLEN`);
- обложка 600×600 (`APIC`);
- текст песни (`USLT`) и синхронный текст (`SYLT`), если он есть: сначала
пробуем MTS, затем [LRCLIB](https://lrclib.net);
- ссылка на трек (`WOAR`/`COMM`), пометка `explicit` (`ITUNESADVISORY`) и
служебные поля с id трека, альбома и `realId` (`TXXX`).
Без `mutagen` или при `--no-tags` теги не пишутся, скачивание при этом
работает как обычно.
## Управление из системной панели (MPRIS)
При запуске TUI и Qt-приложение регистрируются в сессионном D-Bus как источник
звука (`org.mpris.MediaPlayer2.mtsmusic`). Поэтому плеер виден системе и им можно
управлять из панели GNOME — например, расширением
[media-controls](https://github.com/sakithb/media-controls), — а также через
`playerctl` и другие MPRIS-клиенты.
Что работает из панели:
- play/pause, следующий/предыдущий трек;
- перемотка (слайдер и seek);
- отображение названия, исполнителя, альбома, длительности и цветной обложки;
- громкость.
Для этого нужен `dbus-next` (он уже в `requirements.txt`). Без сессионного
D-Bus или без `dbus-next` приложение просто работает как обычно, без интеграции.
## Настройки
Настройки открываются из меню (пункт «Настройки» в TUI и Qt) или из командной
строки и хранятся в `.mts-music/settings.json`:
```bash
# показать текущие настройки
.venv/bin/python mtsmusic.py settings
# изменить: громкость 60%, шаг перемотки 10 секунд
.venv/bin/python mtsmusic.py settings -s volume=60 seek_step=10
```
| Настройка | По умолчанию | Описание |
|-----------|--------------|----------|
| `volume` | `80` | громкость плеера, 0–100 |
| `seek_step` | `5` | шаг перемотки стрелками, секунды |
| `autoplay` | `false` | начинать воспроизведение сразу при запуске |
| `start_queue` | `wave` | что открыть первым: `wave`, `favorites` или `history` |
| `wave_from_history` | `true` | продолжать «Мою волну» с последних треков |
| `download_dir` | `~/Music/MTS Music` | куда сохранять скачанные MP3 |
В Qt-приложении на странице настроек дополнительно выбирается устройство вывода
звука и папка загрузок (кнопка «Выбрать…»). В TUI путь задаётся в настройках
пунктом «Папка загрузок». Громкость меняется на лету, остальные настройки
применяются к новым трекам и при следующем запуске.
## Команды
```bash
# кто я и есть ли подписка
.venv/bin/python mtsmusic.py status
# поиск
.venv/bin/python mtsmusic.py search "miyagi" -n 10
# «Моя волна»: 10 треков, ♥ — уже в избранном
.venv/bin/python mtsmusic.py mix -n 10
# сразу слушать подборку (Ctrl-C — остановить)
.venv/bin/python mtsmusic.py mix -n 5 --play
# избранное: добавить / убрать / показать текстом
.venv/bin/python mtsmusic.py like 12345678
.venv/bin/python mtsmusic.py unlike 12345678
.venv/bin/python mtsmusic.py likes # все любимые треки
.venv/bin/python mtsmusic.py likes -n 20 # только первые 20
.venv/bin/python mtsmusic.py likes --ids # только ID, для скриптов
# текст песни: показать, сохранить, или с таймкодами
.venv/bin/python mtsmusic.py lyrics 12345678
.venv/bin/python mtsmusic.py lyrics 12345678 -o track.txt
.venv/bin/python mtsmusic.py lyrics 12345678 --lrc -o track.lrc
# только права MTS, без внешнего источника
.venv/bin/python mtsmusic.py lyrics 12345678 --no-fallback
# прямая ссылка на MP3
.venv/bin/python mtsmusic.py url 12345678
# воспроизведение (нужен mpv или ffplay)
.venv/bin/python mtsmusic.py play 12345678
# скачивание: в папку из download_dir, имя «Исполнитель - Название.mp3»
.venv/bin/python mtsmusic.py download 12345678
# несколько треков или своя папка
.venv/bin/python mtsmusic.py download 12345678 87654321 -d ~/Music/MTS
# как в мобильном приложении: избранное, волна или результаты поиска
.venv/bin/python mtsmusic.py download --likes -d ~/Music/MTS
.venv/bin/python mtsmusic.py download --wave -n 20 -d ~/Music/MTS
.venv/bin/python mtsmusic.py download --search "Кино" -n 10 -d ~/Music/MTS
# один трек с явным именем файла, перезапись существующих
.venv/bin/python mtsmusic.py download 12345678 -o track.mp3
.venv/bin/python mtsmusic.py download --likes -d ~/Music/MTS --force
# то же самое есть в меню TUI и Qt — пункт «Скачать»
# без ID3-тегов (быстрее, без обложки и текста)
.venv/bin/python mtsmusic.py download 12345678 --no-tags
# настройки
.venv/bin/python mtsmusic.py settings
.venv/bin/python mtsmusic.py settings -s volume=60 seek_step=10
```
Токен можно передать и через переменную окружения:
```bash
export MTS_YA_TOKEN='...'
```
## Если TUI не отображается
```bash
# диагностика окружения
.venv/bin/python mtsmusic.py tui --check
# без alternate screen (если экран пустой)
.venv/bin/python mtsmusic.py tui --inline
# отключить мышь, если терминал плохо её поддерживает
.venv/bin/python mtsmusic.py tui --inline --no-mouse
```
TUI нужно запускать в настоящем терминале (stdin/stdout — TTY), а не в панели
вывода IDE. При падении traceback пишется в `.mts-music/tui-error.log`.
## Ограничения
- Логин автоматизирован не полностью: QRATOR-капчу проходит человек.
- `reference_uri` у MTS должен быть на домене `music.mts.ru`, поэтому
локальный callback не используется — токен забирается из браузера.
- Без активной подписки `download-info` отдаёт `preview` (короткий фрагмент).
- Тексты песен — отдельная подписка Яндекс Плюс, которой у MTS-аккаунта нет
(`hasPlus: false`), поэтому MTS возвращает `404` даже для треков с текстом.
`lyrics` автоматически берёт текст из бесплатного [LRCLIB](https://lrclib.net)
и помечает источник в выводе. `--no-fallback` отключает это поведение.
- Использовать только для личного прослушивания, не перераздавать контент.
- Если `api.music.yandex.net` недоступен, проверь IPv6: клиент принудительно
ходит по IPv4.
- Кэш треков лежит в `.mts-music/cache/`. Его можно удалить в любой момент —
при следующем запуске треки скачаются заново.
- `download` (и пункт «Скачать» в TUI/Qt) по умолчанию не перезаписывает уже
скачанные файлы: повторный запуск докачает только новое. Флаг `--force`
в CLI включает перезапись.
## Лицензия
[MIT](LICENSE) © 2026 jads. Проект предоставляется «как есть», без гарантий;
используйте на свой страх и риск и только со своим аккаунтом.

74
media.py Normal file
View File

@@ -0,0 +1,74 @@
"""Shared track helpers and local history storage."""
from __future__ import annotations
import json
from mtsmusic import CONFIG_DIR
HISTORY_PATH = CONFIG_DIR / "history.json"
HISTORY_LIMIT = 100
def track_artist(track: dict) -> str:
return ", ".join(a.get("name", "?")
for a in track.get("artists") or []) or "?"
def track_album(track: dict) -> str:
albums = track.get("albums") or []
return (albums[0].get("title") or "—") if albums else "—"
def track_cover_url(track: dict, size: int = 360) -> str | None:
uri = track.get("coverUri")
if not uri:
albums = track.get("albums") or []
uri = albums[0].get("coverUri") if albums else None
if not uri:
return None
uri = uri.replace("%%", f"{size}x{size}")
if not uri.startswith("http"):
uri = "https://" + uri
return uri
def load_history() -> list[dict]:
try:
data = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
if not isinstance(data, list):
return []
seen: set[str] = set()
history: list[dict] = []
for track in data:
if not isinstance(track, dict):
continue
track_id = str(track.get("id") or "")
if not track_id or track_id in seen:
continue
seen.add(track_id)
history.append(track)
return history[:HISTORY_LIMIT]
def record_history(track: dict) -> list[dict]:
"""Добавляет трек в начало истории и сохраняет последние 100."""
track_id = str(track.get("id") or "")
if not track_id:
return load_history()
history = [
item for item in load_history() if str(
item.get("id")) != track_id]
history.insert(0, track)
history = history[:HISTORY_LIMIT]
try:
HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
HISTORY_PATH.write_text(
json.dumps(history, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
except OSError:
pass
return history

381
mpris.py Normal file
View File

@@ -0,0 +1,381 @@
"""MPRIS (D-Bus) интеграция: система видит плеер как источник звука.
Благодаря этому плеером можно управлять из панели GNOME (расширение
media-controls), из `playerctl` и т.п.: play/pause, следующий/предыдущий,
перемотка, метаданные и обложка.
Модуль необязательный: если нет `dbus-next` или сессионного D-Bus, всё
превращается в no-op, и приложения работают как обычно.
"""
import asyncio
import logging
import threading
from typing import Any, Callable
log = logging.getLogger("mpris")
try: # pragma: no cover - зависит от окружения
from dbus_next import Variant
from dbus_next.aio import MessageBus
from dbus_next.constants import PropertyAccess
from dbus_next.service import (
ServiceInterface,
dbus_property,
method,
signal,
)
HAVE_DBUS = True
except Exception: # pragma: no cover
HAVE_DBUS = False
BUS_NAME = "org.mpris.MediaPlayer2.mtsmusic"
OBJECT_PATH = "/org/mpris/MediaPlayer2"
def track_object_path(track_id: Any) -> str:
"""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"
return f"/mtsmusic/track/{safe}"
if HAVE_DBUS:
class _RootInterface(ServiceInterface):
def __init__(self, service: "MprisService"):
super().__init__("org.mpris.MediaPlayer2")
self._svc = service
@method()
def Raise(self):
self._svc.dispatch("raise")
@method()
def Quit(self):
self._svc.dispatch("quit")
@dbus_property(access=PropertyAccess.READ)
def Identity(self) -> "s": # noqa: F821
return self._svc.identity
@dbus_property(access=PropertyAccess.READ)
def DesktopEntry(self) -> "s": # noqa: F821
return self._svc.desktop_entry
@dbus_property(access=PropertyAccess.READ)
def CanQuit(self) -> "b": # noqa: F821
return True
@dbus_property(access=PropertyAccess.READ)
def CanRaise(self) -> "b": # noqa: F821
return True
@dbus_property(access=PropertyAccess.READ)
def HasTrackList(self) -> "b": # noqa: F821
return False
@dbus_property(access=PropertyAccess.READ)
def SupportedUriSchemes(self) -> "as": # noqa: F722
return []
@dbus_property(access=PropertyAccess.READ)
def SupportedMimeTypes(self) -> "as": # noqa: F722
return []
class _PlayerInterface(ServiceInterface):
def __init__(self, service: "MprisService"):
super().__init__("org.mpris.MediaPlayer2.Player")
self._svc = service
# -- методы ------------------------------------------------------
@method()
def Play(self):
self._svc.dispatch("play")
@method()
def Pause(self):
self._svc.dispatch("pause")
@method()
def PlayPause(self):
self._svc.dispatch("play_pause")
@method()
def Stop(self):
self._svc.dispatch("stop")
@method()
def Next(self):
self._svc.dispatch("next")
@method()
def Previous(self):
self._svc.dispatch("previous")
@method()
def Seek(self, offset: "x"): # noqa: F821
self._svc.dispatch("seek", int(offset))
@method()
def SetPosition(self, track_id: "o", position: "x"): # noqa: F821
self._svc.dispatch("set_position", str(track_id), int(position))
@method()
def OpenUri(self, uri: "s"): # noqa: F821
self._svc.dispatch("open_uri", str(uri))
# -- сигналы -----------------------------------------------------
@signal()
def Seeked(self, position: "x") -> "x": # noqa: F821
return position
# -- свойства ----------------------------------------------------
@dbus_property(access=PropertyAccess.READ)
def PlaybackStatus(self) -> "s": # noqa: F821
return self._svc.playback_status
@dbus_property(access=PropertyAccess.READ)
def Metadata(self) -> "a{sv}": # noqa: F722
return self._svc.metadata
@dbus_property(access=PropertyAccess.READ)
def Position(self) -> "x": # noqa: F821
return int(self._svc.position)
@dbus_property(access=PropertyAccess.READ)
def MinimumRate(self) -> "d": # noqa: F821
return 1.0
@dbus_property(access=PropertyAccess.READ)
def MaximumRate(self) -> "d": # noqa: F821
return 1.0
@dbus_property(access=PropertyAccess.READ)
def CanGoNext(self) -> "b": # noqa: F821
return self._svc.can_next
@dbus_property(access=PropertyAccess.READ)
def CanGoPrevious(self) -> "b": # noqa: F821
return self._svc.can_previous
@dbus_property(access=PropertyAccess.READ)
def CanPlay(self) -> "b": # noqa: F821
return self._svc.can_play
@dbus_property(access=PropertyAccess.READ)
def CanPause(self) -> "b": # noqa: F821
return self._svc.can_pause
@dbus_property(access=PropertyAccess.READ)
def CanSeek(self) -> "b": # noqa: F821
return self._svc.can_seek
@dbus_property(access=PropertyAccess.READ)
def CanControl(self) -> "b": # noqa: F821
return True
@dbus_property(access=PropertyAccess.READWRITE)
def Volume(self) -> "d": # noqa: F821
return float(self._svc.volume)
@Volume.setter
def Volume(self, value: "d"): # noqa: F821
self._svc.dispatch("volume", float(value))
@dbus_property(access=PropertyAccess.READWRITE)
def LoopStatus(self) -> "s": # noqa: F821
return self._svc.loop_status
@LoopStatus.setter
def LoopStatus(self, value: "s"): # noqa: F821
self._svc.loop_status = str(value)
@dbus_property(access=PropertyAccess.READWRITE)
def Shuffle(self) -> "b": # noqa: F821
return self._svc.shuffle
@Shuffle.setter
def Shuffle(self, value: "b"): # noqa: F821
self._svc.shuffle = bool(value)
@dbus_property(access=PropertyAccess.READWRITE)
def Rate(self) -> "d": # noqa: F821
return float(self._svc.rate)
@Rate.setter
def Rate(self, value: "d"): # noqa: F821
self._svc.rate = float(value)
class MprisService:
"""Публикует MPRIS на сессионном D-Bus; состояние задаётся снаружи.
``handler`` — объект с необязательными методами ``on_play``,
``on_pause``, ``on_play_pause``, ``on_next``, ``on_previous``,
``on_stop``, ``on_seek(offset_us)``, ``on_set_position(track_path, us)``,
``on_volume(value)``, ``on_raise()``, ``on_quit()``.
"""
def __init__(
self,
handler: Any,
identity: str = "MTS Music",
desktop_entry: str = "mts-music",
):
self.handler = handler
self.identity = identity
self.desktop_entry = desktop_entry
self.enabled = False
self.playback_status = "Stopped"
self.metadata: dict[str, Any] = {}
self.position = 0
self.volume = 1.0
self.can_next = False
self.can_previous = False
self.can_play = True
self.can_pause = True
self.can_seek = False
self.loop_status = "None"
self.shuffle = False
self.rate = 1.0
self._loop: asyncio.AbstractEventLoop | None = None
self._bus: Any = None
self._player: Any = None
self._root: Any = None
self._thread: threading.Thread | None = None
if HAVE_DBUS:
self._player = _PlayerInterface(self)
self._root = _RootInterface(self)
self._thread = threading.Thread(
target=self._run, name="mpris-dbus", daemon=True
)
self._thread.start()
# -- жизненный цикл ---------------------------------------------------
def _run(self):
loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(self._connect())
loop.run_forever()
except Exception: # pragma: no cover - окружение без D-Bus
log.debug("MPRIS недоступен", exc_info=True)
async def _connect(self):
self._bus = await MessageBus().connect()
self._bus.export(OBJECT_PATH, self._player)
self._bus.export(OBJECT_PATH, self._root)
await self._bus.request_name(BUS_NAME)
self.enabled = True
log.debug("MPRIS зарегистрирован: %s", BUS_NAME)
def close(self):
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
# -- диспетчеризация команд из D-Bus ---------------------------------
def dispatch(self, action: str, *args: Any):
callback: Callable[..., Any] | None = getattr(
self.handler, "on_" + action, None
)
if callback is None:
return
try:
callback(*args)
except Exception: # pragma: no cover
log.debug("ошибка обработчика MPRIS: %s", action, exc_info=True)
def _emit(self, changed: dict[str, Any]):
if not (self.enabled and self._loop):
return
self._loop.call_soon_threadsafe(
self._player.emit_properties_changed, changed)
# -- обновление состояния (из основного потока) ----------------------
def update_playback_status(self, status: str):
if status == self.playback_status:
return
self.playback_status = status
self.can_seek = status != "Stopped" or self.can_seek
self._emit({"PlaybackStatus": status})
def update_metadata(
self,
track_id: Any,
title: str = "",
artist: str = "",
album: str = "",
duration_ms: int = 0,
art_url: str = "",
):
if not HAVE_DBUS:
return
metadata: dict[str, Any] = {
"mpris:trackid": Variant("o", track_object_path(track_id)),
"mpris:length": Variant("x", int(duration_ms) * 1000),
"xesam:title": Variant("s", title or ""),
"xesam:artist": Variant("as", [artist] if artist else []),
"xesam:album": Variant("s", album or ""),
}
if art_url:
metadata["mpris:artUrl"] = Variant("s", art_url)
self.metadata = metadata
self._emit({"Metadata": metadata})
def clear_metadata(self):
self.metadata = {}
self._emit({"Metadata": self.metadata})
def update_position(self, position_us: int):
# Position в MPRIS не рассылается через PropertiesChanged — клиенты
# читают его сами (или ориентируются на сигнал Seeked).
self.position = max(0, int(position_us))
def notify_seeked(self, position_us: int):
self.update_position(position_us)
if self.enabled and self._loop:
self._loop.call_soon_threadsafe(
self._player.Seeked, int(position_us))
def update_volume(self, volume: float):
volume = max(0.0, min(1.0, float(volume)))
if abs(volume - self.volume) < 1e-4:
return
self.volume = volume
self._emit({"Volume": volume})
def update_capabilities(
self,
can_next: bool | None = None,
can_previous: bool | None = None,
can_play: bool | None = None,
can_pause: bool | None = None,
can_seek: bool | None = None,
):
changed: dict[str, Any] = {}
if can_next is not None and can_next != self.can_next:
self.can_next = can_next
changed["CanGoNext"] = can_next
if can_previous is not None and can_previous != self.can_previous:
self.can_previous = can_previous
changed["CanGoPrevious"] = can_previous
if can_play is not None and can_play != self.can_play:
self.can_play = can_play
changed["CanPlay"] = can_play
if can_pause is not None and can_pause != self.can_pause:
self.can_pause = can_pause
changed["CanPause"] = can_pause
if can_seek is not None and can_seek != self.can_seek:
self.can_seek = can_seek
changed["CanSeek"] = can_seek
if changed:
self._emit(changed)

File diff suppressed because it is too large Load Diff

167
player.py Normal file
View File

@@ -0,0 +1,167 @@
"""Playback engine for the MTS Music clients.
ffplay supports pause via SIGSTOP/SIGCONT. Seeking restarts the stream with
`-ss` at the requested offset, which keeps the implementation tiny and avoids
requiring mpv/libmpv.
"""
from __future__ import annotations
import asyncio
import os
import shutil
import signal
import time
from pathlib import Path
class MusicPlayer:
def __init__(self, explicit: str | None = None, volume: int = 80) -> None:
self.path = self._pick(explicit)
self.proc: asyncio.subprocess.Process | None = None
self.url: str | None = None
self.offset = 0.0
self.paused = False
self.started_at = 0.0
self.paused_total = 0.0
self.paused_at = 0.0
self.switching = False
self.volume = max(0, min(100, int(volume)))
@staticmethod
def _pick(explicit: str | None) -> str | None:
if explicit:
return explicit
env = os.environ.get("MTSMUSIC_PLAYER")
if env:
return env
for candidate in ("mpv", "ffplay"):
found = shutil.which(candidate)
if found:
return found
return None
def _command(self, url: str, start: float = 0.0) -> list[str]:
name = Path(self.path or "").name
if name == "ffplay":
command = [
self.path or "ffplay",
"-nodisp",
"-autoexit",
"-volume",
str(self.volume),
"-loglevel",
"error",
]
if start > 0:
command += ["-ss", f"{start:.3f}"]
return command + [url]
command = [
self.path or "mpv",
"--no-video",
"--really-quiet",
f"--volume={self.volume}",
]
if start > 0:
command.append(f"--start={start:.3f}")
return command + [url]
async def set_volume(self, volume: int) -> None:
"""Меняет громкость; ffplay/mpv не умеют на лету, поэтому
перезапускаем."""
self.volume = max(0, min(100, int(volume)))
if self.url and self.is_playing():
position = self.elapsed()
was_paused = self.paused
await self.play(self.url, position)
if was_paused:
self.pause()
async def play(self, url: str, start: float = 0.0) -> None:
await self.stop()
if not self.path:
raise RuntimeError("не найден mpv/ffplay")
self.switching = True
self.url = url
self.offset = max(0.0, start)
self.proc = await asyncio.create_subprocess_exec(
*self._command(url, self.offset),
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
self.paused = False
self.started_at = time.monotonic()
self.paused_total = 0.0
self.switching = False
async def stop(self) -> None:
self.switching = True
proc = self.proc
if proc and proc.returncode is None:
if self.paused:
try:
proc.send_signal(signal.SIGCONT)
except ProcessLookupError:
pass
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=2)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
self.proc = None
self.paused = False
self.switching = False
def pause(self) -> None:
if self.proc and self.proc.returncode is None and not self.paused:
try:
self.proc.send_signal(signal.SIGSTOP)
except ProcessLookupError:
return
self.paused = True
self.paused_at = time.monotonic()
def resume(self) -> None:
if self.proc and self.proc.returncode is None and self.paused:
try:
self.proc.send_signal(signal.SIGCONT)
except ProcessLookupError:
return
self.paused = False
self.paused_total += time.monotonic() - self.paused_at
def toggle_pause(self) -> bool:
if self.paused:
self.resume()
else:
self.pause()
return self.paused
def is_playing(self) -> bool:
return bool(self.proc and self.proc.returncode is None)
def elapsed(self) -> float:
if not self.proc:
return 0.0
now = self.paused_at if self.paused else time.monotonic()
return max(
0.0,
self.offset +
now -
self.started_at -
self.paused_total)
async def seek(self, delta: float, duration: float) -> None:
"""Seek by delta seconds. Restarts ffplay at the new position."""
if not self.url or not self.is_playing():
return
limit = max(1.0, duration - 1.0) if duration else None
target = max(0.0, self.elapsed() + delta)
if limit:
target = min(target, limit)
was_paused = self.paused
await self.play(self.url, target)
if was_paused:
self.pause()

2193
qt_app.py Normal file

File diff suppressed because it is too large Load Diff

2
requirements-qt.txt Normal file
View File

@@ -0,0 +1,2 @@
-r requirements.txt
PySide6>=6.7

View File

@@ -1,2 +1,6 @@
playwright>=1.40
requests>=2.31
textual>=0.80
pillow>=10
dbus-next>=0.2.3
mutagen>=1.47

113
settings.py Normal file
View File

@@ -0,0 +1,113 @@
"""Настройки MTS Music: хранятся в .mts-music/settings.json."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from mtsmusic import CONFIG_DIR
SETTINGS_PATH = CONFIG_DIR / "settings.json"
DEFAULT_DOWNLOAD_DIR = str(Path.home() / "Music" / "MTS Music")
DEFAULTS: dict[str, Any] = {
"volume": 80, # громкость 0..100
"seek_step": 5, # шаг перемотки стрелками, секунды
"autoplay": False, # запускать воспроизведение сразу
"start_queue": "wave", # wave | favorites | history
"wave_from_history": True, # продолжать волну с последних треков
"download_dir": DEFAULT_DOWNLOAD_DIR, # куда сохранять скачанные MP3
}
START_QUEUES = ("wave", "favorites", "history")
START_QUEUE_LABELS = {
"wave": "Моя волна",
"favorites": "Избранное",
"history": "История",
}
_cache: dict[str, Any] | None = None
def _coerce(key: str, value: Any) -> Any:
if key == "volume":
try:
return max(0, min(100, int(value)))
except (TypeError, ValueError):
return DEFAULTS[key]
if key == "seek_step":
try:
return max(1, min(60, int(value)))
except (TypeError, ValueError):
return DEFAULTS[key]
if key in ("autoplay", "wave_from_history"):
if isinstance(value, bool):
return value
return str(value).strip().lower() in (
"1", "true", "yes", "on", "да", "вкл")
if key == "start_queue":
text = str(value)
return text if text in START_QUEUES else DEFAULTS[key]
if key == "download_dir":
text = str(value).strip()
if not text:
return DEFAULTS[key]
return str(Path(text).expanduser())
return value
def load_settings() -> dict[str, Any]:
"""Возвращает настройки с дефолтами; файл читается один раз."""
global _cache
if _cache is not None:
return dict(_cache)
raw: dict[str, Any] = {}
try:
data = json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
if isinstance(data, dict):
raw = data
except (OSError, ValueError):
raw = {}
merged = dict(DEFAULTS)
for key, value in raw.items():
if key in DEFAULTS:
merged[key] = _coerce(key, value)
_cache = merged
return dict(merged)
def save_settings(values: dict[str, Any]) -> dict[str, Any]:
"""Сохраняет изменённые настройки и возвращает актуальный набор."""
global _cache
merged = load_settings()
for key, value in values.items():
if key in DEFAULTS:
merged[key] = _coerce(key, value)
_cache = merged
try:
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_PATH.write_text(
json.dumps(merged, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError:
pass
return dict(merged)
def get_setting(key: str, default: Any = None) -> Any:
return load_settings().get(key, DEFAULTS.get(key, default))
def set_setting(key: str, value: Any) -> Any:
return save_settings({key: value}).get(key)
def update_settings(**values: Any) -> dict[str, Any]:
return save_settings(dict(values))
def settings_path() -> Path:
return SETTINGS_PATH

239
tags.py Normal file
View File

@@ -0,0 +1,239 @@
"""ID3-теги для скачанных треков: максимум метаданных.
Пишем ID3v2.3: название, исполнителей, альбом и альбомного исполнителя,
позицию трека и диска, год и дату, жанр, лейбл, длительность, ссылку,
служебные поля (id в Яндекс.Музыке и MTS, explicit), обложку 600×600 и
текст песни (обычный USLT и синхронный SYLT, если он есть).
Модуль необязательный: без `mutagen` теги просто не пишутся.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
import requests
from media import track_cover_url
from mtsmusic import (
USER_AGENT,
force_ipv4,
lrclib_lyrics,
lyrics_response,
lyrics_text,
)
try:
from mutagen.id3 import (
APIC,
COMM,
SYLT,
TALB,
TCON,
TDAT,
TIT2,
TLEN,
TPE1,
TPE2,
TPOS,
TPUB,
TRCK,
TXXX,
TYER,
USLT,
WOAR,
ID3,
)
HAVE_MUTAGEN = True
except Exception: # pragma: no cover - зависит от окружения
HAVE_MUTAGEN = False
log = logging.getLogger("tags")
COVER_SIZE = 600
_LRC_LINE = re.compile(r"\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]")
def _names(items) -> str:
return ", ".join(str(item.get("name") or "") for item in items or [])
def fetch_cover(track: dict) -> bytes | None:
"""Скачивает обложку 600×600 для встраивания в APIC."""
url = track_cover_url(track, COVER_SIZE)
if not url:
return None
try:
force_ipv4()
response = requests.get(
url, headers={"User-Agent": USER_AGENT}, timeout=30)
response.raise_for_status()
return response.content
except requests.RequestException:
return None
def _mts_lyrics(api, track_id: str, fmt: str) -> str:
try:
response = lyrics_response(api, track_id, fmt)
except Exception:
return ""
if response.status_code != 200:
return ""
try:
text = lyrics_text(api, response)
except Exception:
return ""
if "Подключите подписку" in text:
return ""
return text.strip()
def fetch_lyrics(api, track: dict) -> tuple[str, str]:
"""(обычный текст, синхронный LRC). Пробуем MTS, затем LRCLIB."""
track_id = str(track.get("id"))
plain = _mts_lyrics(api, track_id, "TEXT")
synced = _mts_lyrics(api, track_id, "LRC")
if not plain:
plain = (lrclib_lyrics(track, want_sync=False) or "").strip()
if not synced:
synced = (lrclib_lyrics(track, want_sync=True) or "").strip()
if synced and not _LRC_LINE.search(synced):
synced = ""
return plain, synced
def parse_lrc(text: str) -> list[tuple[str, int]]:
"""LRC → [(строка, миллисекунды)] для синхронного SYLT."""
entries: list[tuple[str, int]] = []
for line in text.splitlines():
stamps = list(_LRC_LINE.finditer(line))
if not stamps:
continue
content = _LRC_LINE.sub("", line).strip()
for stamp in stamps:
minutes = int(stamp.group(1))
seconds = int(stamp.group(2))
fraction = (stamp.group(3) or "0").ljust(3, "0")[:3]
millis = (minutes * 60 + seconds) * 1000 + int(fraction)
entries.append((content, millis))
entries.sort(key=lambda item: item[1])
return entries
def _cover_mime(data: bytes) -> str:
return "image/png" if data[:8].startswith(b"\x89PNG") else "image/jpeg"
def write_tags(
track: dict,
path: Path,
plain_lyrics: str = "",
synced_lyrics: str = "",
cover: bytes | None = None,
) -> None:
"""Проставляет ID3v2.3-теги в уже скачанном файле."""
if not HAVE_MUTAGEN:
return
album = (track.get("albums") or [{}])[0]
artist = _names(track.get("artists"))
album_artist = _names(album.get("artists")) or artist
track_id = str(track.get("id") or "")
try:
tags = ID3(path)
except Exception:
tags = ID3()
def add(frame) -> None:
tags.add(frame)
if track.get("title"):
add(TIT2(encoding=3, text=str(track["title"])))
if artist:
add(TPE1(encoding=3, text=artist))
if album_artist:
add(TPE2(encoding=3, text=album_artist))
if album.get("title"):
add(TALB(encoding=3, text=str(album["title"])))
position = album.get("trackPosition") or {}
index = position.get("index")
if index:
count = album.get("trackCount")
add(TRCK(encoding=3, text=f"{index}/{count}" if count else str(index)))
volume = position.get("volume")
if volume:
add(TPOS(encoding=3, text=str(volume)))
if album.get("year"):
add(TYER(encoding=3, text=str(album["year"])))
release = str(album.get("releaseDate") or "")
if release[:10].count("-") == 2:
_year, month, day = release[:10].split("-")
add(TDAT(encoding=3, text=f"{day}{month}")) # формат DDMM
if album.get("genre"):
add(TCON(encoding=3, text=str(album["genre"])))
labels = _names(album.get("labels"))
if labels:
add(TPUB(encoding=3, text=labels))
if track.get("durationMs"):
add(TLEN(encoding=3, text=str(int(track["durationMs"]))))
if track_id:
add(COMM(
encoding=3,
lang="XXX",
desc="",
text=f"https://music.yandex.ru/track/{track_id}",
))
add(WOAR(url=f"https://music.yandex.ru/track/{track_id}"))
def add_text(desc: str, value) -> None:
if value not in (None, ""):
add(TXXX(encoding=3, desc=desc, text=str(value)))
add_text("YANDEX_MUSIC_TRACK_ID", track.get("id"))
add_text("YANDEX_MUSIC_REAL_ID", track.get("realId"))
add_text("YANDEX_MUSIC_ALBUM_ID", album.get("id"))
add_text("MTS_MUSIC_SOURCE", track.get("trackSource"))
add_text("YANDEX_MUSIC_TYPE", track.get("type"))
if track.get("contentWarning") == "explicit":
add_text("ITUNESADVISORY", 1)
if cover:
add(APIC(
encoding=3,
mime=_cover_mime(cover),
type=3,
desc="Cover",
data=cover,
))
if plain_lyrics:
add(USLT(encoding=3, lang="XXX", desc="", text=plain_lyrics))
if synced_lyrics:
entries = parse_lrc(synced_lyrics)
if entries:
add(SYLT(
encoding=3,
lang="XXX",
format=2,
type=1,
desc="",
text=entries,
))
tags.save(path, v2_version=3)
def tag_track(api, track: dict, path: Path) -> None:
"""Собирает метаданные (текст, обложка) и пишет их в файл."""
if not HAVE_MUTAGEN:
return
plain, synced = fetch_lyrics(api, track)
cover = fetch_cover(track)
write_tags(track, Path(path), plain, synced, cover)

1649
tui.py Normal file

File diff suppressed because it is too large Load Diff