Compare commits
15 Commits
c0d0b0f1f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8043d8b80 | ||
|
|
de9bf80d11 | ||
|
|
ab595f6ff6 | ||
|
|
e71f5623a7 | ||
|
|
4460791a95 | ||
|
|
5b202984fa | ||
|
|
637ac23b73 | ||
|
|
b64c2dcd12 | ||
|
|
0eb6909724 | ||
|
|
26a58068cb | ||
|
|
f77f81dead | ||
|
|
ff7edd9da7 | ||
|
|
773ba69f0b | ||
|
|
ce2e324dbe | ||
|
|
a41ec450fe |
21
LICENSE
Normal file
21
LICENSE
Normal 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
257
README.md
@@ -1,7 +1,12 @@
|
|||||||
# MTS Music CLI
|
# MTS Music — another client
|
||||||
|
|
||||||
Минимальный клиент для **своего** аккаунта MTS Music: вход, статус аккаунта
|
Неофициальный клиент для **своего** аккаунта MTS Music: «Моя волна», поиск,
|
||||||
и проверка подписки.
|
избранное, тексты песен, воспроизведение и скачивание в MP3 с полными
|
||||||
|
ID3-тегами. Есть компактный TUI-плеер и настольное Qt-приложение в стиле
|
||||||
|
телефона.
|
||||||
|
|
||||||
|
> Только для личного использования с собственной платной подпиской MTS.
|
||||||
|
> Проект не связан с МТС и Яндексом; контент не перераспространяется.
|
||||||
|
|
||||||
Внутри используется тот же путь, что и в веб-клиенте: MTS SSO выдаёт
|
Внутри используется тот же путь, что и в веб-клиенте: MTS SSO выдаёт
|
||||||
OAuth-токен Яндекс.Музыки, дальше запросы идут на `api.music.yandex.net`.
|
OAuth-токен Яндекс.Музыки, дальше запросы идут на `api.music.yandex.net`.
|
||||||
@@ -14,6 +19,12 @@ python3 -m venv .venv
|
|||||||
PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromium
|
PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromium
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Для Qt-приложения дополнительно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pip install -r requirements-qt.txt
|
||||||
|
```
|
||||||
|
|
||||||
Всё локальное лежит рядом с проектом:
|
Всё локальное лежит рядом с проектом:
|
||||||
- `.venv/` — зависимости
|
- `.venv/` — зависимости
|
||||||
- `.playwright/` — браузер Chromium
|
- `.playwright/` — браузер Chromium
|
||||||
@@ -29,9 +40,249 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
сохранятся в `.mts-music/credentials.json` с правами `600`.
|
сохранятся в `.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
|
```bash
|
||||||
# кто я и есть ли подписка
|
# кто я и есть ли подписка
|
||||||
.venv/bin/python mtsmusic.py status
|
.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
74
media.py
Normal 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
381
mpris.py
Normal 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)
|
||||||
1190
mtsmusic.py
1190
mtsmusic.py
File diff suppressed because it is too large
Load Diff
167
player.py
Normal file
167
player.py
Normal 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()
|
||||||
2
requirements-qt.txt
Normal file
2
requirements-qt.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
PySide6>=6.7
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
playwright>=1.40
|
playwright>=1.40
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
|
textual>=0.80
|
||||||
|
pillow>=10
|
||||||
|
dbus-next>=0.2.3
|
||||||
|
mutagen>=1.47
|
||||||
|
|||||||
113
settings.py
Normal file
113
settings.py
Normal 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
239
tags.py
Normal 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)
|
||||||
Reference in New Issue
Block a user