""" FastAPI-based web UI for yandex-music-downloader. The module exposes ``app`` for running under uvicorn and provides a small entrypoint ``main`` that bootstraps the server. It reuses the existing core download logic so command-line behaviour stays consistent. """ from __future__ import annotations import json import logging import os import re import threading import time import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Iterable, Literal, Optional, Tuple from fastapi import FastAPI, HTTPException from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel, Field from yandex_music import Album, Playlist, Track from ymd import core logger = logging.getLogger("yandex-music-downloader.webui") def env_bool(name: str, default: bool = False) -> bool: value = os.getenv(name) if value is None: return default return value.strip().lower() in {"1", "true", "yes", "y", "on"} def env_int( name: str, default: int, min_value: Optional[int] = None, max_value: Optional[int] = None, ) -> int: raw = os.getenv(name) if raw is None: return default try: value = int(raw) except ValueError: return default if min_value is not None: value = max(min_value, value) if max_value is not None: value = min(max_value, value) return value def env_choice(name: str, allowed: set[str], default: str) -> str: value = os.getenv(name) if value is None: return default value = value.strip().lower() return value if value in allowed else default DEFAULT_TOKEN = os.getenv("YMD_TOKEN") DEFAULT_DIR = os.getenv("YMD_DOWNLOAD_DIR", "downloads") DEFAULT_PATH_PATTERN = os.getenv("YMD_PATH_PATTERN", str(core.DEFAULT_PATH_PATTERN)) DEFAULT_QUALITY = env_int("YMD_QUALITY", 2, 0, 2) DEFAULT_LYRICS_FORMAT = env_choice( "YMD_LYRICS_FORMAT", {v.value for v in core.LyricsFormat}, core.LyricsFormat.NONE.value ) DEFAULT_COVER_RESOLUTION = env_int("YMD_COVER_RESOLUTION", core.DEFAULT_COVER_RESOLUTION) DEFAULT_COMPATIBILITY_LEVEL = env_int( "YMD_COMPATIBILITY_LEVEL", 1, core.MIN_COMPATIBILITY_LEVEL, core.MAX_COMPATIBILITY_LEVEL ) DEFAULT_TIMEOUT = env_int("YMD_TIMEOUT", 20, 1) DEFAULT_TRIES = env_int("YMD_TRIES", 20, 0) DEFAULT_RETRY_DELAY = env_int("YMD_RETRY_DELAY", 5, 0) DEFAULT_DELAY = env_int("YMD_DELAY", 0, 0) DEFAULT_SKIP_EXISTING = env_bool("YMD_SKIP_EXISTING", False) DEFAULT_EMBED_COVER = env_bool("YMD_EMBED_COVER", False) DEFAULT_STICK_TO_ARTIST = env_bool("YMD_STICK_TO_ARTIST", False) DEFAULT_ONLY_MUSIC = env_bool("YMD_ONLY_MUSIC", False) DEFAULT_UNSAFE_PATH = env_bool("YMD_UNSAFE_PATH", False) DEFAULT_DISCO_TRIES = env_int("YMD_DISCO_TRIES", 10, 0) DEFAULT_DISCO_RETRY_DELAY = env_int("YMD_DISCO_RETRY_DELAY", 5, 0) def require_default_token() -> str: if not DEFAULT_TOKEN: raise HTTPException(400, detail="Нужен YMD_TOKEN в окружении") return DEFAULT_TOKEN def frontend_config() -> dict: return { "has_token": bool(DEFAULT_TOKEN), "dir": DEFAULT_DIR, "path_pattern": DEFAULT_PATH_PATTERN, "quality": DEFAULT_QUALITY, "lyrics_format": DEFAULT_LYRICS_FORMAT, "cover_resolution": DEFAULT_COVER_RESOLUTION, "compatibility_level": DEFAULT_COMPATIBILITY_LEVEL, "skip_existing": DEFAULT_SKIP_EXISTING, "embed_cover": DEFAULT_EMBED_COVER, "stick_to_artist": DEFAULT_STICK_TO_ARTIST, "only_music": DEFAULT_ONLY_MUSIC, "unsafe_path": DEFAULT_UNSAFE_PATH, "delay": DEFAULT_DELAY, "timeout": DEFAULT_TIMEOUT, "tries": DEFAULT_TRIES, "retry_delay": DEFAULT_RETRY_DELAY, } def init_web_client(timeout: int, tries: int, retry_delay: int): return core.init_client( token=require_default_token(), timeout=timeout, max_try_count=tries, retry_delay=retry_delay, ) def unique_ids(values: Iterable[object]) -> list[str]: result: list[str] = [] seen: set[str] = set() for value in values: if value is None: continue item = str(value) if item in seen: continue seen.add(item) result.append(item) return result def fetch_liked_tracks(client) -> list[Track]: likes = client.users_likes_tracks() if not likes: return [] return [track for track in likes.fetch_tracks() if track is not None] def summarize_artists_from_likes(tracks: Iterable[Track]) -> list[dict]: counts: dict[str, int] = {} names: dict[str, str] = {} for track in tracks: for artist in track.artists or []: if artist.id is None: continue artist_id = str(artist.id) counts[artist_id] = counts.get(artist_id, 0) + 1 if artist_id not in names: names[artist_id] = artist.name or f"Artist {artist_id}" rows = [ {"id": artist_id, "name": names[artist_id], "track_count": count} for artist_id, count in counts.items() ] rows.sort(key=lambda row: (-row["track_count"], row["name"].lower())) return rows def serialize_liked_tracks(tracks: Iterable[Track]) -> list[dict]: rows = [] for track in tracks: album = (track.albums or [None])[0] rows.append( { "id": str(track.id) if track.id is not None else "", "title": core.full_title(track), "artists": [ { "id": str(artist.id) if artist.id is not None else "", "name": artist.name or "Unknown artist", } for artist in (track.artists or []) ], "album": ( { "id": str(album.id), "title": core.full_title(album), } if album is not None and album.id is not None else None ), } ) return rows def export_likes() -> dict: client = init_web_client( timeout=DEFAULT_TIMEOUT, tries=DEFAULT_TRIES, retry_delay=DEFAULT_RETRY_DELAY, ) tracks = fetch_liked_tracks(client) artists = summarize_artists_from_likes(tracks) return { "summary": { "tracks": len(tracks), "artists": len(artists), }, "artists": artists, "tracks": serialize_liked_tracks(tracks), } TRACK_RE = re.compile(r"track/(\d+)") ALBUM_RE = re.compile(r"album/(\d+)$") ARTIST_RE = re.compile(r"artist/(\d+)$") PLAYLIST_RE = re.compile(r"([\w\-._@]+)/playlists/(\d+)$") FETCH_PAGE_SIZE = 10 INDEX_HTML = """ Yandex Music Downloader · Web

Yandex Music Downloader · Web

Минимум полей: ссылка. Токен берется только из YMD_TOKEN, остальное раскрывайте при необходимости.

⚡ FastAPI + чистый JS

Доп. настройки (по умолчанию из .env)
Мои лайки

Отдельный список лайкнутых треков и артистов из этих лайков.

Активные задачи

Смотрим какой трек качается и прогресс.

""" class DownloadRequest(BaseModel): url: Optional[str] = None artist_id: Optional[str] = None album_id: Optional[str] = None track_id: Optional[str] = None playlist_id: Optional[str] = None dir: str = Field(default=DEFAULT_DIR, description="Папка для загрузки музыки") path_pattern: str = Field( default=DEFAULT_PATH_PATTERN, description="Паттерн сохранения файла", ) quality: int = Field(default=DEFAULT_QUALITY, ge=0, le=2) skip_existing: bool = DEFAULT_SKIP_EXISTING lyrics_format: Literal["none", "text", "lrc"] = DEFAULT_LYRICS_FORMAT embed_cover: bool = DEFAULT_EMBED_COVER cover_resolution: int = Field(default=DEFAULT_COVER_RESOLUTION) delay: int = Field(default=DEFAULT_DELAY, ge=0) stick_to_artist: bool = DEFAULT_STICK_TO_ARTIST only_music: bool = DEFAULT_ONLY_MUSIC compatibility_level: int = Field( default=DEFAULT_COMPATIBILITY_LEVEL, ge=core.MIN_COMPATIBILITY_LEVEL, le=core.MAX_COMPATIBILITY_LEVEL, ) timeout: int = Field(default=DEFAULT_TIMEOUT, ge=1) tries: int = Field(default=DEFAULT_TRIES, ge=0) retry_delay: int = Field(default=DEFAULT_RETRY_DELAY, ge=0) unsafe_path: bool = DEFAULT_UNSAFE_PATH class DiscographyRequest(BaseModel): url: Optional[str] = None artist_id: Optional[str] = None stick_to_artist: bool = DEFAULT_STICK_TO_ARTIST only_music: bool = DEFAULT_ONLY_MUSIC timeout: int = Field(default=DEFAULT_TIMEOUT, ge=1) tries: int = Field(default=DEFAULT_DISCO_TRIES, ge=0) retry_delay: int = Field(default=DEFAULT_DISCO_RETRY_DELAY, ge=0) JobStatus = Literal["pending", "running", "done", "failed"] JobType = Literal[ "download", "discography", "likes_tracks", "likes_artists_albums", ] @dataclass class Job: id: str type: JobType status: JobStatus = "pending" message: str = "" total: Optional[int] = None progress: int = 0 created_at: float = field(default_factory=time.time) finished_at: Optional[float] = None logs: list[str] = field(default_factory=list) current_track: Optional[str] = None def log(self, text: str) -> None: timestamp = time.strftime("%H:%M:%S", time.localtime()) line = f"[{timestamp}] {text}" logger.debug(line) self.logs.append(line) job_lock = threading.Lock() jobs: dict[str, Job] = {} def register_job(job: Job) -> Job: with job_lock: jobs[job.id] = job return job def snapshot_job(job_id: str) -> Job: with job_lock: job = jobs.get(job_id) if job is None: raise KeyError(job_id) return job def parse_ids_from_url(url: str) -> dict[str, str]: parsed = {} if match := ARTIST_RE.search(url): parsed["artist_id"] = match.group(1) if match := ALBUM_RE.search(url): parsed["album_id"] = match.group(1) if match := TRACK_RE.search(url): parsed["track_id"] = match.group(1) if match := PLAYLIST_RE.search(url): parsed["playlist_id"] = match.group(1) + "/" + match.group(2) return parsed def validate_and_fill_request(req: DownloadRequest) -> DownloadRequest: require_default_token() if req.url: parsed = parse_ids_from_url(req.url) for key, value in parsed.items(): if getattr(req, key) is None: setattr(req, key, value) return req def album_matches_request( album: Album, req: DownloadRequest, artist_id: Optional[str] = None ) -> bool: if album.id is None or not album.available: return False if req.only_music and album.meta_type != "music": return False if ( artist_id is not None and req.stick_to_artist and album.artists and album.artists[0].id != int(artist_id) ): return False return True def build_track_iter( client, req: DownloadRequest ) -> Tuple[Iterable[Track], Optional[int]]: def album_tracks_gen(album_ids: Iterable[str]) -> Iterable[Track]: for album_id in album_ids: if full_album := client.albums_with_tracks(album_id): if volumes := full_album.volumes: for volume in volumes: for track in volume: yield track total_track_count = None if req.artist_id is not None: def filter_album(album: Album) -> bool: return album_matches_request(album, req, req.artist_id) def albums_id_gen() -> Iterable[str]: has_next = True page = 0 while has_next: albums_info = client.artists_direct_albums(req.artist_id, page) if not albums_info: break for album in albums_info.albums: if filter_album(album) and album.id: yield str(album.id) else: nonlocal total_track_count if album.track_count and total_track_count is not None: total_track_count -= album.track_count if pager := albums_info.pager: page = pager.page + 1 has_next = pager.per_page * page < pager.total else: break result_tracks = album_tracks_gen(albums_id_gen()) artist = client.artists(req.artist_id)[0] if counts := artist.counts: total_track_count = counts.tracks elif req.album_id is not None: result_tracks = album_tracks_gen((req.album_id,)) if album := client.albums_with_tracks(req.album_id): total_track_count = album.track_count elif req.track_id is not None: result_tracks = client.tracks(req.track_id) total_track_count = 1 elif req.playlist_id is not None: user, kind = req.playlist_id.split("/") playlist = client.users_playlists(kind, user) playlist = playlist if isinstance(playlist, Playlist) else None if playlist is None: raise HTTPException(404, detail="Плейлист не найден") total_track_count = playlist.track_count def playlist_tracks_gen() -> Iterable[Track]: tracks = playlist.fetch_tracks() for i in range(0, len(tracks), FETCH_PAGE_SIZE): chunk = tracks[i : i + FETCH_PAGE_SIZE] yield from client.tracks([track.id for track in chunk]) result_tracks = playlist_tracks_gen() else: raise HTTPException(400, detail="Нужно указать URL или ID") return result_tracks, total_track_count def to_core_quality(quality: int) -> core.CoreTrackQuality: return core.CoreTrackQuality(quality) def build_track_iter_from_ids(client, track_ids: list[str]) -> Iterable[Track]: def generator() -> Iterable[Track]: for i in range(0, len(track_ids), FETCH_PAGE_SIZE): chunk = track_ids[i : i + FETCH_PAGE_SIZE] for track in client.tracks(chunk): if track is not None: yield track return generator() def collect_artist_album_ids( client, artist_ids: Iterable[str], req: DownloadRequest ) -> list[str]: result: list[str] = [] seen: set[str] = set() for artist_id in artist_ids: has_next = True page = 0 while has_next: albums_info = client.artists_direct_albums(artist_id, page) if not albums_info: break for album in albums_info.albums: if not album_matches_request(album, req, artist_id): continue album_id = str(album.id) if album_id in seen: continue seen.add(album_id) result.append(album_id) if pager := albums_info.pager: page = pager.page + 1 has_next = pager.per_page * page < pager.total else: break return result def collect_album_track_ids(client, album_ids: Iterable[str]) -> list[str]: result: list[str] = [] seen: set[str] = set() for album_id in album_ids: if full_album := client.albums_with_tracks(album_id): if volumes := full_album.volumes: for volume in volumes: for track in volume: if track.id is None: continue track_id = str(track.id) if track_id in seen: continue seen.add(track_id) result.append(track_id) return result def collect_liked_track_ids(client) -> list[str]: likes = client.users_likes_tracks() if not likes: return [] return unique_ids(likes.tracks_ids) def collect_liked_artist_ids(client) -> list[str]: artist_ids: list[str] = [] seen: set[str] = set() for track in fetch_liked_tracks(client): for artist in track.artists or []: if artist.id is None: continue artist_id = str(artist.id) if artist_id in seen: continue seen.add(artist_id) artist_ids.append(artist_id) return artist_ids def download_tracks_for_job( job: Job, req: DownloadRequest, tracks: Iterable[Track], total: Optional[int], ) -> None: job.total = total covers_cache: dict[int, core.AlbumCover] = {} base_dir = Path(req.dir).expanduser() base_dir.mkdir(parents=True, exist_ok=True) path_pattern = Path(req.path_pattern) lyrics_format = core.LyricsFormat(req.lyrics_format) quality = to_core_quality(req.quality) downloaded = 0 seq = 0 for track in tracks: seq += 1 if job.status == "failed": break if job.total: job.progress += 1 if not track.available: job.current_track = core.full_title(track) job.message = f"Пропущено: {job.current_track}" job.log(f"[track] {job.message}") continue save_path = base_dir / core.prepare_base_path( path_pattern, track, req.unsafe_path, fallback_number=seq, fallback_total=job.total, ) if req.skip_existing: if any(Path(str(save_path) + s).is_file() for s in core.AUDIO_FILE_SUFFIXES): job.current_track = core.full_title(track) job.message = f"Пропуск: уже есть {job.current_track}" job.log(f"[track] {job.message}") continue save_path.parent.mkdir(parents=True, exist_ok=True) downloadable = core.to_downloadable_track(track, quality, save_path) bitrate = downloadable.download_info.bitrate format_info = "[" + downloadable.download_info.file_format.codec.name if bitrate > 0: format_info += f" {bitrate}kbps" format_info += "]" display_title = core.full_title(track) job.current_track = display_title job.message = f"Скачивается: {display_title}" job.log(f"[track] {job.message}") job.log(f"{format_info} {downloadable.path}") core.download_track( track_info=downloadable, lyrics_format=lyrics_format, embed_cover=req.embed_cover, cover_resolution=req.cover_resolution, covers_cache=covers_cache if req.embed_cover else None, compatibility_level=req.compatibility_level, path_pattern=path_pattern, unsafe_path=req.unsafe_path, fallback_number=seq, fallback_total=job.total, rename_using_tags=True, ) downloaded += 1 job.current_track = None if req.delay: time.sleep(req.delay) job.status = "done" job.message = f"Готово. Скачано {downloaded} трек(ов)" job.finished_at = time.time() job.log(job.message) def start_download_job(req: DownloadRequest) -> Job: req = validate_and_fill_request(req) job = register_job(Job(id=str(uuid.uuid4()), type="download", status="running")) def worker(): try: job.log("Инициализация клиента") client = init_web_client(req.timeout, req.tries, req.retry_delay) tracks, total = build_track_iter(client, req) download_tracks_for_job(job, req, tracks, total) except Exception as exc: # noqa: BLE001 job.status = "failed" job.message = str(exc) job.finished_at = time.time() job.log(f"Ошибка: {exc}") thread = threading.Thread(target=worker, name=f"download-{job.id}", daemon=True) thread.start() return job def start_likes_download_job( req: DownloadRequest, job_type: Literal["likes_tracks", "likes_artists_albums"] ) -> Job: require_default_token() job = register_job(Job(id=str(uuid.uuid4()), type=job_type, status="running")) def worker(): try: job.log("Инициализация клиента") client = init_web_client(req.timeout, req.tries, req.retry_delay) if job_type == "likes_tracks": track_ids = collect_liked_track_ids(client) if not track_ids: raise ValueError("Лайкнутые треки не найдены") job.log(f"Лайкнутых треков: {len(track_ids)}") else: job.log("Собираем артистов из лайкнутых треков") artist_ids = collect_liked_artist_ids(client) if not artist_ids: raise ValueError("Артисты в лайках не найдены") job.log(f"Артистов из лайков: {len(artist_ids)}") album_ids = collect_artist_album_ids(client, artist_ids, req) if not album_ids: raise ValueError("У артистов из лайков не найдено подходящих альбомов") job.log(f"Подходящих альбомов: {len(album_ids)}") track_ids = collect_album_track_ids(client, album_ids) if not track_ids: raise ValueError("Не удалось собрать треки из альбомов артистов") job.log(f"Собрано треков: {len(track_ids)}") tracks = build_track_iter_from_ids(client, track_ids) download_tracks_for_job(job, req, tracks, len(track_ids)) except Exception as exc: # noqa: BLE001 job.status = "failed" job.message = str(exc) job.finished_at = time.time() job.log(f"Ошибка: {exc}") thread = threading.Thread( target=worker, name=f"{job_type}-{job.id}", daemon=True ) thread.start() return job def export_discography(req: DiscographyRequest): require_default_token() if req.url and not req.artist_id: parsed = parse_ids_from_url(req.url) req.artist_id = parsed.get("artist_id") if not req.artist_id: raise HTTPException(400, detail="Нужен artist_id или ссылка на артиста") client = init_web_client(req.timeout, req.tries, req.retry_delay) artist = client.artists(req.artist_id)[0] result = { "artist": { "id": artist.id, "name": artist.name, "counts": artist.counts.dict() if artist.counts else None, }, "albums": [], } page = 0 while True: albums_info = client.artists_direct_albums(req.artist_id, page) if not albums_info: break for album in albums_info.albums: if not album.id or not album.available: continue if req.only_music and album.meta_type != "music": continue if req.stick_to_artist and album.artists and album.artists[0].id != int( req.artist_id ): continue album_full = client.albums_with_tracks(album.id) tracks_data = [] if album_full and album_full.volumes: for volume in album_full.volumes: for track in volume: tracks_data.append( { "id": track.id, "title": core.full_title(track), "available": track.available, "duration_ms": track.duration_ms, "version": track.version, "has_lyrics": bool(track.lyrics_info), } ) result["albums"].append( { "id": album.id, "title": core.full_title(album), "year": album.year, "genre": album.genre, "meta_type": album.meta_type, "track_count": album.track_count, "tracks": tracks_data, } ) if pager := albums_info.pager: page = pager.page + 1 if pager.per_page * page >= pager.total: break else: break return result app = FastAPI(title="Yandex Music Downloader Web", version="1.0.0") @app.get("/", response_class=HTMLResponse) def index(): config_json = json.dumps(frontend_config(), ensure_ascii=False) return HTMLResponse(INDEX_HTML.replace("__CONFIG_JSON__", config_json)) @app.get("/health") def health(): return {"status": "ok"} @app.get("/api/likes") def api_likes(): return JSONResponse(export_likes()) @app.post("/api/download") def api_download(req: DownloadRequest): job = start_download_job(req) return serialize_job(job) @app.post("/api/download/likes/all") def api_download_likes_all(req: DownloadRequest): job = start_likes_download_job(req, "likes_tracks") return serialize_job(job) @app.post("/api/download/likes/artists-albums") def api_download_likes_artists(req: DownloadRequest): job = start_likes_download_job(req, "likes_artists_albums") return serialize_job(job) @app.get("/api/jobs") def api_jobs(): with job_lock: return [serialize_job(job) for job in jobs.values()] @app.get("/api/jobs/{job_id}") def api_job(job_id: str): try: job = snapshot_job(job_id) except KeyError: raise HTTPException(404, detail="Задача не найдена") return serialize_job(job) @app.post("/api/discography") def api_discography(req: DiscographyRequest): data = export_discography(req) return JSONResponse(data) def serialize_job(job: Job) -> dict: percent = 0 if job.total: percent = int(min(100, round(job.progress / job.total * 100))) return { "id": job.id, "type": job.type, "status": job.status, "message": job.message, "progress": job.progress, "total": job.total, "percent": percent, "created_at": job.created_at, "finished_at": job.finished_at, "current_track": job.current_track, "logs": job.logs, "recent_logs": job.logs[-8:], } def main(): import argparse import uvicorn parser = argparse.ArgumentParser(description="Yandex Music Downloader Web UI") parser.add_argument("--host", default="127.0.0.1", help="Хост для прослушивания") parser.add_argument("--port", default=8000, type=int, help="Порт") parser.add_argument( "--reload", action="store_true", help="Перезагрузка при изменении файлов" ) args = parser.parse_args() uvicorn.run( "ymd.webui:app", host=args.host, port=args.port, reload=args.reload, factory=False ) if __name__ == "__main__": main()