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:
140
downloads.py
Normal file
140
downloads.py
Normal 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
|
||||
Reference in New Issue
Block a user