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

View File

@@ -71,8 +71,15 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
История хранится в `.mts-music/history.json` (последние 100 треков). История хранится в `.mts-music/history.json` (последние 100 треков).
Плеер — `ffplay` (или `mpv`, если установлен). Пауза реализована через Плеер — `ffplay` (или `mpv`, если установлен). Пауза реализована через
`SIGSTOP`/`SIGCONT`, а перемотка перезапускает поток с нужной секунды, `SIGSTOP`/`SIGCONT`, а перемотка перезапускает воспроизведение с нужной
поэтому отдельный демон не нужен. секунды, поэтому отдельный демон не нужен.
Треки не транслируются напрямую: сначала они скачиваются в локальный кэш
`.mts-music/cache/` (до 512 МБ, старые файлы удаляются автоматически), а плеер
играет уже готовый MP3. Так надёжнее — сервер Яндекса закрывает соединение,
если клиент читает поток медленнее реального времени, из-за чего трек
обрывался через полминуты и клиент переключался на следующий. Заодно кэш
убирает задержку при повторном прослушивании и делает перемотку мгновенной.
## Qt-приложение ## Qt-приложение
@@ -114,6 +121,13 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
Для этого нужен `dbus-next` (он уже в `requirements.txt`). Без сессионного Для этого нужен `dbus-next` (он уже в `requirements.txt`). Без сессионного
D-Bus или без `dbus-next` приложение просто работает как обычно, без интеграции. D-Bus или без `dbus-next` приложение просто работает как обычно, без интеграции.
## Скачивание из интерфейса
В меню обоих клиентов есть пункт «Скачать»: текущий трек, всё избранное,
20 треков «Моей волны» или последние результаты поиска. Папка выбирается
диалогом в Qt и вводится вручную в TUI, прогресс и число ошибок видны на
экране. Скачивание идёт тем же кодом, что и в CLI.
## Настройки ## Настройки
Настройки открываются из меню (пункт «Настройки» в TUI и Qt) или из командной Настройки открываются из меню (пункт «Настройки» в TUI и Qt) или из командной
@@ -154,10 +168,12 @@ D-Bus или без `dbus-next` приложение просто работае
# сразу слушать подборку (Ctrl-C — остановить) # сразу слушать подборку (Ctrl-C — остановить)
.venv/bin/python mtsmusic.py mix -n 5 --play .venv/bin/python mtsmusic.py mix -n 5 --play
# избранное: добавить / убрать / показать # избранное: добавить / убрать / показать текстом
.venv/bin/python mtsmusic.py like 12345678 .venv/bin/python mtsmusic.py like 12345678
.venv/bin/python mtsmusic.py unlike 12345678 .venv/bin/python mtsmusic.py unlike 12345678
.venv/bin/python mtsmusic.py likes -n 20 .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
@@ -173,8 +189,20 @@ D-Bus или без `dbus-next` приложение просто работае
# воспроизведение (нужен mpv или ffplay) # воспроизведение (нужен mpv или ffplay)
.venv/bin/python mtsmusic.py play 12345678 .venv/bin/python mtsmusic.py play 12345678
# скачивание # скачивание одного или нескольких треков в свою папку
.venv/bin/python mtsmusic.py download 12345678 -d ~/Music/MTS
.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 12345678 -o track.mp3
.venv/bin/python mtsmusic.py download --likes -d ~/Music/MTS --force
# то же самое есть в меню TUI и Qt — пункт «Скачать»
# настройки # настройки
.venv/bin/python mtsmusic.py settings .venv/bin/python mtsmusic.py settings
@@ -216,6 +244,11 @@ TUI нужно запускать в настоящем терминале (stdi
- Использовать только для личного прослушивания, не перераздавать контент. - Использовать только для личного прослушивания, не перераздавать контент.
- Если `api.music.yandex.net` недоступен, проверь IPv6: клиент принудительно - Если `api.music.yandex.net` недоступен, проверь IPv6: клиент принудительно
ходит по IPv4. ходит по IPv4.
- Кэш треков лежит в `.mts-music/cache/`. Его можно удалить в любой момент —
при следующем запуске треки скачаются заново.
- `download` (и пункт «Скачать» в TUI/Qt) по умолчанию не перезаписывает уже
скачанные файлы: повторный запуск докачает только новое. Флаг `--force`
в CLI включает перезапись.
## Лицензия ## Лицензия

File diff suppressed because it is too large Load Diff

206
qt_app.py
View File

@@ -4,6 +4,7 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from pathlib import Path
from PySide6.QtCore import ( from PySide6.QtCore import (
QEasingCurve, QEasingCurve,
@@ -44,6 +45,7 @@ from PySide6.QtWidgets import (
QAbstractButton, QAbstractButton,
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QFileDialog,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@@ -52,6 +54,7 @@ from PySide6.QtWidgets import (
QListWidgetItem, QListWidgetItem,
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QProgressBar,
QPushButton, QPushButton,
QSizePolicy, QSizePolicy,
QSlider, QSlider,
@@ -74,6 +77,8 @@ from media import (
from mtsmusic import ( from mtsmusic import (
USER_AGENT, USER_AGENT,
Api, Api,
collect_download_tracks,
download_tracks,
fetch_liked_tracks, fetch_liked_tracks,
fetch_mix_batch, fetch_mix_batch,
fmt_duration, fmt_duration,
@@ -82,7 +87,7 @@ from mtsmusic import (
lrclib_lyrics, lrclib_lyrics,
lyrics_response, lyrics_response,
lyrics_text, lyrics_text,
resolve_stream, fetch_track_file,
) )
from settings import ( from settings import (
START_QUEUE_LABELS, START_QUEUE_LABELS,
@@ -313,6 +318,46 @@ class Task(QRunnable):
pass pass
class ProgressTask(QRunnable):
"""Задача, которая умеет присылать прогресс в GUI-поток."""
class Signals(QObject):
progress = Signal(object)
done = Signal(object, object)
failed = Signal(object, str)
def __init__(self, fn) -> None:
super().__init__()
self.fn = fn
self.signals = ProgressTask.Signals()
self.setAutoDelete(False)
def run(self) -> None:
try:
result = self.fn(self._emit_progress)
except SystemExit as exc:
self._emit_failed(str(exc) or "ошибка")
except Exception as exc: # noqa: BLE001 - сообщение показываем в UI
self._emit_failed(str(exc) or exc.__class__.__name__)
else:
try:
self.signals.done.emit(self, result)
except RuntimeError:
pass
def _emit_progress(self, event: dict) -> None:
try:
self.signals.progress.emit(event)
except RuntimeError:
pass
def _emit_failed(self, message: str) -> None:
try:
self.signals.failed.emit(self, message)
except RuntimeError:
pass
class CoverView(QWidget): class CoverView(QWidget):
"""Обложка со скруглениями и аккуратным плейсхолдером.""" """Обложка со скруглениями и аккуратным плейсхолдером."""
@@ -549,6 +594,7 @@ class QtMusicApp(QMainWindow):
self.current_index = -1 self.current_index = -1
self.liked_ids: set[str] = set() self.liked_ids: set[str] = set()
self.favorites: list[dict] = [] self.favorites: list[dict] = []
self.search_results: list[dict] = []
self.history: list[dict] = load_history() self.history: list[dict] = load_history()
self.cover_cache: dict[str, QPixmap] = {} self.cover_cache: dict[str, QPixmap] = {}
self.current_cover_url = "" self.current_cover_url = ""
@@ -680,6 +726,9 @@ class QtMusicApp(QMainWindow):
self.settings_page = self._build_settings_page() self.settings_page = self._build_settings_page()
self.pages.addWidget(self.settings_page) self.pages.addWidget(self.settings_page)
self.download_page = self._build_download_page()
self.pages.addWidget(self.download_page)
self._setup_tab_order() self._setup_tab_order()
def _setup_tab_order(self) -> None: def _setup_tab_order(self) -> None:
@@ -1013,6 +1062,143 @@ class QtMusicApp(QMainWindow):
layout.addStretch(1) layout.addStretch(1)
return page return page
def _build_download_page(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(18, 10, 18, 14)
layout.setSpacing(12)
layout.addLayout(self._page_header("Скачать"))
dir_label = QLabel("Папка для сохранения")
dir_label.setObjectName("settingLabel")
layout.addWidget(dir_label)
dir_row = QHBoxLayout()
dir_row.setSpacing(8)
self.download_dir = QLineEdit(str(Path.home() / "Music" / "MTS"))
self.download_dir.setObjectName("downloadDir")
dir_row.addWidget(self.download_dir, 1)
browse = QPushButton("Обзор…")
browse.setObjectName("actionBtn")
browse.setCursor(Qt.CursorShape.PointingHandCursor)
browse.clicked.connect(self.browse_download_dir)
dir_row.addWidget(browse)
layout.addLayout(dir_row)
source_label = QLabel("Что скачать")
source_label.setObjectName("settingLabel")
layout.addWidget(source_label)
self.download_source = QComboBox()
self.download_source.setObjectName("settingCombo")
for text, kind in (
("Текущий трек", "current"),
("Всё избранное", "likes"),
("Моя волна (20 треков)", "wave"),
("Результаты поиска", "search"),
):
self.download_source.addItem(text, kind)
layout.addWidget(self.download_source)
self.download_btn = QPushButton("Скачать")
self.download_btn.setObjectName("actionBtn")
self.download_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.download_btn.clicked.connect(self.start_download)
layout.addWidget(self.download_btn)
self.download_progress = QProgressBar()
self.download_progress.setRange(0, 1)
self.download_progress.setValue(0)
self.download_progress.setTextVisible(False)
layout.addWidget(self.download_progress)
self.download_status = QLabel(
"Уже скачанные файлы пропускаются, имена — "
"«Исполнитель - Название.mp3»."
)
self.download_status.setObjectName("pageStatus")
self.download_status.setWordWrap(True)
layout.addWidget(self.download_status)
layout.addStretch(1)
return page
# -- скачивание -------------------------------------------------------
def show_download(self) -> None:
self._show(self.download_page)
def browse_download_dir(self) -> None:
directory = QFileDialog.getExistingDirectory(
self, "Папка для скачивания", self.download_dir.text().strip()
)
if directory:
self.download_dir.setText(directory)
def start_download(self) -> None:
raw = self.download_dir.text().strip()
if not raw:
self.toast("Укажи папку для сохранения")
return
destination = Path(raw).expanduser()
kind = self.download_source.currentData()
if kind == "current" and not self.current:
self.toast("Сначала выбери трек")
return
if kind == "search" and not self.search_results:
self.toast("Сначала выполни поиск")
return
self.download_btn.setEnabled(False)
self.download_progress.setRange(0, 0)
self.download_status.setText("Готовлю список…")
self.run_progress_task(
lambda progress: self._download_job(kind, destination, progress),
self.download_done,
self.download_failed,
)
def _download_job(self, kind: str, destination: Path, progress) -> tuple:
if kind == "current":
tracks = [self.current]
elif kind == "likes":
tracks = collect_download_tracks(self.api, likes=True)
elif kind == "wave":
tracks = collect_download_tracks(self.api, wave=True, number=20)
else:
tracks = list(self.search_results)
if not tracks:
raise ValueError("нечего скачивать")
saved, skipped, failed = download_tracks(
self.api, tracks, destination, on_event=progress
)
return saved, skipped, failed, destination
def on_download_progress(self, event: dict) -> None:
track = event["track"]
self.download_progress.setRange(0, event["total"])
self.download_progress.setValue(event["index"] - 1)
title = f"{track_artist(track)} — {track.get('title') or '?'}"
self.download_status.setText(
f"[{event['index']}/{event['total']}] {title}")
def download_done(self, result: tuple) -> None:
saved, skipped, failed, destination = result
self.download_btn.setEnabled(True)
self.download_progress.setRange(0, 1)
self.download_progress.setValue(1)
message = (
f"Готово: {saved} сохранено, {skipped} уже было, "
f"{failed} ошибок"
)
self.download_status.setText(f"{message}\n{destination}")
self.toast(message)
def download_failed(self, message: str) -> None:
self.download_btn.setEnabled(True)
self.download_progress.setRange(0, 1)
self.download_progress.setValue(0)
self.download_status.setText(f"Ошибка: {message}")
self.toast(message)
# -- настройки -------------------------------------------------------- # -- настройки --------------------------------------------------------
def update_setting(self, key: str, value) -> None: def update_setting(self, key: str, value) -> None:
@@ -1070,6 +1256,7 @@ class QtMusicApp(QMainWindow):
("Избранное", self.show_favorites), ("Избранное", self.show_favorites),
("История", self.show_history), ("История", self.show_history),
("Поиск", self.show_search), ("Поиск", self.show_search),
("Скачать", self.show_download),
("Текст песни", self.show_lyrics), ("Текст песни", self.show_lyrics),
("Настройки", self.show_settings), ("Настройки", self.show_settings),
): ):
@@ -1191,6 +1378,16 @@ class QtMusicApp(QMainWindow):
self._tasks.add(task) self._tasks.add(task)
self.pool.start(task) self.pool.start(task)
def run_progress_task(self, fn, done, failed) -> None:
task = ProgressTask(fn)
task.done_cb = done
task.failed_cb = failed
task.signals.done.connect(self._task_done)
task.signals.failed.connect(self._task_failed)
task.signals.progress.connect(self.on_download_progress)
self._tasks.add(task)
self.pool.start(task)
def _task_done(self, task: Task, result) -> None: def _task_done(self, task: Task, result) -> None:
if task not in self._tasks: if task not in self._tasks:
return return
@@ -1373,7 +1570,7 @@ class QtMusicApp(QMainWindow):
self.set_top_status("Загрузка…") self.set_top_status("Загрузка…")
self.run_task( self.run_task(
lambda: resolve_stream(self.api, str(track.get("id"))), lambda: fetch_track_file(self.api, str(track.get("id"))),
lambda result: self.start_playback(token, track, result), lambda result: self.start_playback(token, track, result),
self.on_error, self.on_error,
) )
@@ -1383,9 +1580,9 @@ class QtMusicApp(QMainWindow):
) -> None: ) -> None:
if token != self.play_token: if token != self.play_token:
return return
url, item = result path, item = result
self._preview = bool(item.get("preview")) self._preview = bool(item.get("preview"))
self.player.setSource(QUrl(url)) self.player.setSource(QUrl.fromLocalFile(str(path)))
self.player.play() self.player.play()
self.history = record_history(track) self.history = record_history(track)
title = str(track.get("title") or "") title = str(track.get("title") or "")
@@ -1808,6 +2005,7 @@ class QtMusicApp(QMainWindow):
self.run_task(search, self.apply_search, self.search_failed) self.run_task(search, self.apply_search, self.search_failed)
def apply_search(self, tracks: list[dict]) -> None: def apply_search(self, tracks: list[dict]) -> None:
self.search_results = tracks
self.fill_track_list(self.search_list, tracks, "Поиск") self.fill_track_list(self.search_list, tracks, "Поиск")
self.search_status.setText( self.search_status.setText(
f"{len(tracks)} результат(ов)" if tracks else "Ничего не найдено" f"{len(tracks)} результат(ов)" if tracks else "Ничего не найдено"

147
tui.py
View File

@@ -4,6 +4,7 @@
from __future__ import annotations from __future__ import annotations
import io import io
from pathlib import Path
import requests import requests
from PIL import Image from PIL import Image
@@ -34,13 +35,15 @@ from media import (
from mtsmusic import ( from mtsmusic import (
USER_AGENT, USER_AGENT,
Api, Api,
collect_download_tracks,
download_tracks,
fetch_track_file,
fmt_duration, fmt_duration,
force_ipv4, force_ipv4,
get_uid, get_uid,
lrclib_lyrics, lrclib_lyrics,
lyrics_response, lyrics_response,
lyrics_text, lyrics_text,
resolve_stream,
) )
from player import MusicPlayer from player import MusicPlayer
from mpris import MprisService, track_object_path from mpris import MprisService, track_object_path
@@ -282,6 +285,7 @@ class SearchScreen(Screen):
def fill(self, tracks: list[dict]) -> None: def fill(self, tracks: list[dict]) -> None:
self.results = tracks self.results = tracks
self.app.search_results = tracks
table = self.query_one("#list-table", DataTable) table = self.query_one("#list-table", DataTable)
table.clear() table.clear()
for track in tracks: for track in tracks:
@@ -442,6 +446,132 @@ class SettingsScreen(Screen):
self.refresh_values() 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]): class MenuScreen(ModalScreen[str | None]):
BINDINGS = [Binding("escape", "close", "Закрыть")] BINDINGS = [Binding("escape", "close", "Закрыть")]
@@ -452,6 +582,7 @@ class MenuScreen(ModalScreen[str | None]):
yield Button("Избранное", id="m-favorites") yield Button("Избранное", id="m-favorites")
yield Button("История", id="m-history") yield Button("История", id="m-history")
yield Button("Поиск", id="m-search") yield Button("Поиск", id="m-search")
yield Button("Скачать", id="m-download")
yield Button("Текст песни", id="m-lyrics") yield Button("Текст песни", id="m-lyrics")
yield Button("Настройки", id="m-settings") yield Button("Настройки", id="m-settings")
yield Static("", id="menu-spacer") yield Static("", id="menu-spacer")
@@ -613,6 +744,12 @@ class MusicTui(App):
.sstep { width: 5; min-width: 5; } .sstep { width: 5; min-width: 5; }
.stoggle { width: 16; min-width: 12; } .stoggle { width: 16; min-width: 12; }
#settings-path { padding: 1 0; color: #6a6a6a; } #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 = [ BINDINGS = [
@@ -643,6 +780,7 @@ class MusicTui(App):
"volume", 80))) "volume", 80)))
self.wave: list[dict] = [] self.wave: list[dict] = []
self.favorites: list[dict] = [] self.favorites: list[dict] = []
self.search_results: list[dict] = []
self.liked_ids: set[str] = set() self.liked_ids: set[str] = set()
self.history: list[dict] = load_history() self.history: list[dict] = load_history()
self.queue: list[dict] = [] self.queue: list[dict] = []
@@ -978,7 +1116,7 @@ class MusicTui(App):
if not api: if not api:
return return
try: try:
url, item = resolve_stream(api, str(track.get("id"))) path, item = fetch_track_file(api, str(track.get("id")))
except SystemExit as exc: except SystemExit as exc:
self.call_from_thread( self.call_from_thread(
self.notify, self.notify,
@@ -986,7 +1124,8 @@ class MusicTui(App):
severity="error") severity="error")
return return
self.call_from_thread( self.call_from_thread(
self.start_playback, token, track, url, bool(item.get("preview")) self.start_playback, token, track, str(path),
bool(item.get("preview")),
) )
def start_playback( def start_playback(
@@ -1192,6 +1331,8 @@ class MusicTui(App):
self.push_screen(HistoryScreen(self.history)) self.push_screen(HistoryScreen(self.history))
elif action == "m-search": elif action == "m-search":
self.push_screen(SearchScreen()) self.push_screen(SearchScreen())
elif action == "m-download":
self.push_screen(DownloadScreen())
elif action == "m-lyrics": elif action == "m-lyrics":
self.action_show_lyrics() self.action_show_lyrics()
elif action == "m-settings": elif action == "m-settings":