Files
MTS-music-another-client/downloads.py
jads de9bf80d11 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.
2026-10-25 20:06:59 +03:00

141 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Скачивание треков в 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