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 для скриптов.
This commit is contained in:
jadssss
2026-10-24 10:10:20 +03:00
parent e71f5623a7
commit ab595f6ff6
4 changed files with 872 additions and 272 deletions

147
tui.py
View File

@@ -4,6 +4,7 @@
from __future__ import annotations
import io
from pathlib import Path
import requests
from PIL import Image
@@ -34,13 +35,15 @@ from media import (
from mtsmusic import (
USER_AGENT,
Api,
collect_download_tracks,
download_tracks,
fetch_track_file,
fmt_duration,
force_ipv4,
get_uid,
lrclib_lyrics,
lyrics_response,
lyrics_text,
resolve_stream,
)
from player import MusicPlayer
from mpris import MprisService, track_object_path
@@ -282,6 +285,7 @@ class SearchScreen(Screen):
def fill(self, tracks: list[dict]) -> None:
self.results = tracks
self.app.search_results = tracks
table = self.query_one("#list-table", DataTable)
table.clear()
for track in tracks:
@@ -442,6 +446,132 @@ class SettingsScreen(Screen):
self.refresh_values()
class DownloadScreen(Screen):
"""Скачивание текущего трека, избранного, волны или результатов поиска."""
BINDINGS = [Binding("escape", "back", "Назад")]
def compose(self) -> ComposeResult:
yield make_topbar("Скачать")
yield Static("Папка для сохранения:", id="screen-status")
yield Input(value=str(Path.home() / "Music" / "MTS"), id="download-dir")
with Horizontal(id="download-actions"):
yield Button("Текущий трек", id="dl-current")
yield Button("Избранное", id="dl-likes")
yield Button("Моя волна (20)", id="dl-wave")
yield Button(
"Результаты поиска",
id="dl-search",
disabled=not self.app.search_results,
)
yield Static("", id="download-status")
yield ProgressBar(
total=100,
show_eta=False,
show_percentage=True,
id="download-progress",
)
def action_back(self) -> None:
self.app.pop_screen()
@on(Button.Pressed, "#back")
def back_pressed(self) -> None:
self.action_back()
@on(Button.Pressed)
def pressed(self, event: Button.Pressed) -> None:
bid = event.button.id or ""
kinds = {
"dl-current": "current",
"dl-likes": "likes",
"dl-wave": "wave",
"dl-search": "search",
}
kind = kinds.get(bid)
if not kind:
return
raw = self.query_one("#download-dir", Input).value.strip()
if not raw:
self.notify("Укажи папку для сохранения", severity="warning")
return
if kind == "current" and not self.app.current:
self.notify("Сначала выбери трек", severity="warning")
return
self.query_one("#download-progress", ProgressBar).update(progress=0)
self.query_one("#download-status", Static).update("Готовлю список…")
self.run_download(kind, Path(raw).expanduser())
def set_status(self, text: str) -> None:
if not self.is_mounted:
return
self.query_one("#download-status", Static).update(text)
def download_event(self, event: dict) -> None:
if not self.is_mounted:
return
track = event["track"]
title = f"{track_artist(track)} — {track.get('title') or '?'}"
self.query_one("#download-progress", ProgressBar).update(
progress=event["index"], total=event["total"]
)
self.set_status(f"[{event['index']}/{event['total']}] {title}")
def done(self, saved: int, skipped: int, failed: int,
directory: Path) -> None:
if not self.is_mounted:
return
message = (
f"Готово: {saved} сохранено, {skipped} уже было, "
f"{failed} ошибок"
)
self.set_status(f"{message}\n{directory}")
self.notify(message, timeout=6)
def failed(self, message: str) -> None:
if not self.is_mounted:
return
self.set_status(f"Ошибка: {message}")
self.notify(message, severity="error", timeout=8)
@work(thread=True, exclusive=True, group="download")
def run_download(self, kind: str, directory: Path) -> None:
api = self.app.api
if not api:
return
try:
if kind == "current":
track = self.app.current
if not track:
self.app.call_from_thread(self.failed, "трек не выбран")
return
tracks = [track]
elif kind == "likes":
self.app.call_from_thread(
self.set_status, "Получаю избранное…")
tracks = collect_download_tracks(api, likes=True)
elif kind == "wave":
self.app.call_from_thread(
self.set_status, "Получаю «Мою волну»…")
tracks = collect_download_tracks(api, wave=True, number=20)
else:
tracks = list(self.app.search_results)
if not tracks:
self.app.call_from_thread(self.failed, "нечего скачивать")
return
def progress(event: dict) -> None:
self.app.call_from_thread(self.download_event, event)
saved, skipped, failed = download_tracks(
api, tracks, directory, on_event=progress
)
self.app.call_from_thread(
self.done, saved, skipped, failed, directory)
except SystemExit as exc:
self.app.call_from_thread(self.failed, str(exc))
class MenuScreen(ModalScreen[str | None]):
BINDINGS = [Binding("escape", "close", "Закрыть")]
@@ -452,6 +582,7 @@ class MenuScreen(ModalScreen[str | None]):
yield Button("Избранное", id="m-favorites")
yield Button("История", id="m-history")
yield Button("Поиск", id="m-search")
yield Button("Скачать", id="m-download")
yield Button("Текст песни", id="m-lyrics")
yield Button("Настройки", id="m-settings")
yield Static("", id="menu-spacer")
@@ -613,6 +744,12 @@ class MusicTui(App):
.sstep { width: 5; min-width: 5; }
.stoggle { width: 16; min-width: 12; }
#settings-path { padding: 1 0; color: #6a6a6a; }
#download-dir { margin: 0 1; }
#download-actions { height: 3; padding: 0 1; margin-top: 1; }
#download-actions Button { margin-right: 1; }
#download-status { height: 2; padding: 1 1 0 1; color: #d0d0d0; }
#download-progress { margin: 1 1 0 1; }
"""
BINDINGS = [
@@ -643,6 +780,7 @@ class MusicTui(App):
"volume", 80)))
self.wave: list[dict] = []
self.favorites: list[dict] = []
self.search_results: list[dict] = []
self.liked_ids: set[str] = set()
self.history: list[dict] = load_history()
self.queue: list[dict] = []
@@ -978,7 +1116,7 @@ class MusicTui(App):
if not api:
return
try:
url, item = resolve_stream(api, str(track.get("id")))
path, item = fetch_track_file(api, str(track.get("id")))
except SystemExit as exc:
self.call_from_thread(
self.notify,
@@ -986,7 +1124,8 @@ class MusicTui(App):
severity="error")
return
self.call_from_thread(
self.start_playback, token, track, url, bool(item.get("preview"))
self.start_playback, token, track, str(path),
bool(item.get("preview")),
)
def start_playback(
@@ -1192,6 +1331,8 @@ class MusicTui(App):
self.push_screen(HistoryScreen(self.history))
elif action == "m-search":
self.push_screen(SearchScreen())
elif action == "m-download":
self.push_screen(DownloadScreen())
elif action == "m-lyrics":
self.action_show_lyrics()
elif action == "m-settings":