Merge remote-tracking branch 'origin/main'
# Conflicts: # README.md # mtsmusic.py # qt_app.py
This commit is contained in:
179
tui.py
179
tui.py
@@ -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
|
||||
@@ -307,6 +310,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:
|
||||
@@ -486,6 +490,138 @@ 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(
|
||||
self.app.settings.get("download_dir")
|
||||
or 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", "Закрыть")]
|
||||
|
||||
@@ -496,8 +632,9 @@ 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-download")
|
||||
yield Button("Скачать трек", id="m-download-current")
|
||||
yield Button("Скачать список", id="m-download-list")
|
||||
yield Button("Настройки", id="m-settings")
|
||||
yield Static("", id="menu-spacer")
|
||||
@@ -712,6 +849,12 @@ class MusicTui(App):
|
||||
.stoggle { width: 16; min-width: 12; }
|
||||
#dir-value { padding: 0 0 1 0; color: #8a8a8a; }
|
||||
#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 = [
|
||||
@@ -742,6 +885,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] = []
|
||||
@@ -1077,7 +1221,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,
|
||||
@@ -1085,7 +1229,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(
|
||||
@@ -1291,9 +1436,11 @@ 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-download":
|
||||
elif action == "m-download-current":
|
||||
self.action_download_current()
|
||||
elif action == "m-download-list":
|
||||
self.action_download_list()
|
||||
@@ -1331,22 +1478,30 @@ class MusicTui(App):
|
||||
|
||||
@work(thread=True, exclusive=False, group="download")
|
||||
def download_worker(self, tracks: list[dict], label: str) -> None:
|
||||
from downloads import download_many
|
||||
|
||||
dest = str(self.settings.get("download_dir") or "")
|
||||
if not dest:
|
||||
self.call_from_thread(
|
||||
self.notify,
|
||||
"Не задана папка загрузок",
|
||||
severity="warning",
|
||||
)
|
||||
return
|
||||
|
||||
def on_item(index: int, count: int, track: dict) -> None:
|
||||
title = str(track.get("title") or "")
|
||||
def on_event(event: dict) -> None:
|
||||
if event.get("status") != "start":
|
||||
return
|
||||
title = str(event["track"].get("title") or "")
|
||||
self.call_from_thread(
|
||||
self.set_topstatus,
|
||||
f"Скачиваю {index}/{count}: {title}",
|
||||
f"Скачиваю {event['index']}/{event['total']}: {title}",
|
||||
)
|
||||
|
||||
saved = download_many(self.api, tracks, dest, on_item=on_item)
|
||||
saved, _skipped, _failed = download_tracks(
|
||||
self.api, tracks, Path(dest).expanduser(), on_event=on_event)
|
||||
self.call_from_thread(
|
||||
self.download_finished,
|
||||
label,
|
||||
len(saved),
|
||||
saved,
|
||||
len(tracks),
|
||||
dest,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user