feat: download tracks with full ID3 metadata

- Add downloads.py: "Artist - Title.mp3" naming, no-overwrite dedupe,
  configurable download_dir, progress and per-item callbacks.
- Save maximum metadata via tags.py (ID3v2.3, mutagen): artists, album
  and album artist, track/disc, year/date, raw genre, label, duration,
  explicit flag, cover 600x600, plain and synced lyrics (MTS then
  LRCLIB), track URLs and Yandex/MTS ids.
- TUI/Qt: download current track and queue from the menu, "d" key in
  lists, folder picker in settings; CLI download gains -d/--dir and
  --no-tags.
- Add mutagen to requirements and document download/metadata in README.
- Fix MPRIS update_metadata crashing without dbus-next.
This commit is contained in:
jads
2026-10-25 20:06:59 +03:00
parent e71f5623a7
commit de9bf80d11
9 changed files with 754 additions and 37 deletions

140
downloads.py Normal file
View File

@@ -0,0 +1,140 @@
"""Скачивание треков в MP3: имена файлов, папка и прогресс.
Как в мобильном приложении: трек сохраняется в папку из настройки
``download_dir``, имя файла — «Исполнитель - Название.mp3», а уже
существующие файлы не перезаписываются (добавляется « (2)» и т.д.).
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Callable
import requests
from media import track_artist
from mtsmusic import USER_AGENT, force_ipv4, resolve_stream
log = logging.getLogger("downloads")
# on_progress(downloaded_bytes, total_bytes); total может быть 0.
ProgressCallback = Callable[[int, int], None]
# on_item(index, total, track) — перед началом каждого трека.
ItemCallback = Callable[[int, int, dict], None]
_INVALID = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
def sanitize_filename(name: str, fallback: str = "track") -> str:
"""Убирает из имени символы, недопустимые в имени файла."""
clean = _INVALID.sub("_", str(name or ""))
clean = re.sub(r"\s+", " ", clean).strip().strip(".")
return clean[:120].strip() or fallback
def track_filename(track: dict) -> str:
"""«Исполнитель - Название.mp3» с запасным вариантом по id."""
title = str(track.get("title") or "").strip()
artist = track_artist(track)
if artist and artist != "?":
stem = f"{artist} - {title}" if title else artist
else:
stem = title
return sanitize_filename(stem, str(track.get("id") or "track")) + ".mp3"
def unique_path(directory: Path, filename: str) -> Path:
"""Путь, которого ещё нет: при совпадении добавляет « (2)», « (3)»."""
path = directory / filename
if not path.exists():
return path
stem, suffix = path.stem, path.suffix
for index in range(2, 1000):
candidate = directory / f"{stem} ({index}){suffix}"
if not candidate.exists():
return candidate
return path
def stream_to_file(
url: str,
output: Path,
on_progress: ProgressCallback | None = None,
) -> Path:
"""Скачивает поток по ссылке в конкретный файл."""
output = Path(output).expanduser()
output.parent.mkdir(parents=True, exist_ok=True)
force_ipv4()
with requests.get(
url,
stream=True,
headers={"User-Agent": USER_AGENT},
timeout=60,
) as response:
response.raise_for_status()
total = int(response.headers.get("Content-Length") or 0)
done = 0
with output.open("wb") as handle:
for chunk in response.iter_content(chunk_size=256 * 1024):
if not chunk:
continue
handle.write(chunk)
done += len(chunk)
if on_progress:
on_progress(done, total)
return output
def download_track(
api,
track: dict,
dest_dir: Path | str,
on_progress: ProgressCallback | None = None,
with_tags: bool = True,
) -> Path:
"""Скачивает один трек в папку и возвращает путь к файлу."""
url, _item = resolve_stream(api, str(track.get("id")))
dest_dir = Path(dest_dir).expanduser()
dest_dir.mkdir(parents=True, exist_ok=True)
output = unique_path(dest_dir, track_filename(track))
output = stream_to_file(url, output, on_progress)
if with_tags:
tag_downloaded(api, track, output)
return output
def tag_downloaded(api, track: dict, path: Path) -> None:
"""Проставляет максимум метаданных; ошибки тегов не срывают загрузку."""
try:
from tags import tag_track
except ImportError:
return
try:
tag_track(api, track, path)
except Exception: # noqa: BLE001 - теги не должны ломать скачивание
log.warning("не удалось записать теги для %s", path, exc_info=True)
def download_many(
api,
tracks: list[dict],
dest_dir: Path | str,
on_item: ItemCallback | None = None,
on_progress: ProgressCallback | None = None,
with_tags: bool = True,
) -> list[Path]:
"""Скачивает список треков; сбой одного не останавливает остальные."""
saved: list[Path] = []
total = len(tracks)
for index, track in enumerate(tracks, 1):
if on_item:
on_item(index, total, track)
try:
saved.append(
download_track(
api, track, dest_dir, on_progress, with_tags))
except (SystemExit, requests.RequestException, OSError):
continue
return saved