"""Скачивание треков в 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