- 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.
141 lines
4.9 KiB
Python
141 lines
4.9 KiB
Python
"""Скачивание треков в 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
|