1340 lines
51 KiB
Python
1340 lines
51 KiB
Python
"""
|
||
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 = """<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Yandex Music Downloader · Web</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0e1428;
|
||
--panel: #131c33;
|
||
--muted: #95a3bf;
|
||
--text: #f4f6ff;
|
||
--accent: #2dd4bf;
|
||
--accent-2: #7c3aed;
|
||
--border: #23304d;
|
||
--error: #ff6b6b;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||
background: radial-gradient(120% 120% at 20% 20%, #1c2651, #0b1022 60%);
|
||
color: var(--text);
|
||
}
|
||
.page { max-width: 960px; margin: 0 auto; padding: 28px 20px 48px; display: grid; gap: 18px; }
|
||
header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.title { margin: 0; font-size: 24px; font-weight: 800; letter-spacing: 0.2px; }
|
||
.hint { margin: 0; color: var(--muted); font-size: 14px; }
|
||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 16px 16px 18px; }
|
||
form { display: grid; gap: 12px; }
|
||
label { display: block; color: var(--muted); font-size: 13px; margin-bottom: 4px; }
|
||
input, select {
|
||
width: 100%; border-radius: 10px; border: 1px solid var(--border);
|
||
background: rgba(255,255,255,0.04); color: var(--text); padding: 10px 12px;
|
||
font-size: 15px; transition: border 0.12s ease, box-shadow 0.12s ease;
|
||
}
|
||
input:focus, select:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px rgba(45,212,191,0.18); }
|
||
.split { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
|
||
.btn-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
button { border: none; padding: 11px 14px; border-radius: 11px; font-weight: 700; cursor: pointer; }
|
||
.primary { background: linear-gradient(135deg, var(--accent), #22c55e); color: #0b1022; }
|
||
.ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||
details { border: 1px dashed var(--border); border-radius: 12px; padding: 10px 12px; background: rgba(255,255,255,0.02); }
|
||
summary { cursor: pointer; font-weight: 700; color: var(--muted); }
|
||
.checkboxes { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
|
||
.inline { display: flex; gap: 8px; align-items: center; color: var(--muted); font-size: 14px; }
|
||
.inline input { width: auto; }
|
||
.jobs { display: grid; gap: 12px; }
|
||
.job { border: 1px solid var(--border); border-radius: 12px; padding: 12px 12px 14px; background: rgba(255,255,255,0.02); }
|
||
.job-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
||
.job-title { font-weight: 800; }
|
||
.tag { padding: 4px 10px; border-radius: 10px; font-weight: 700; font-size: 12px; color: #0b1022; text-transform: lowercase; }
|
||
.running { background: var(--accent); }
|
||
.done { background: #a3e635; }
|
||
.failed { background: var(--error); color: #1a0a0a; }
|
||
.pending { background: #fbbf24; }
|
||
.progress { height: 12px; background: rgba(255,255,255,0.06); border-radius: 999px; overflow: hidden; margin: 10px 0 8px; border: 1px solid var(--border); }
|
||
.bar { height: 100%; width: 0%; background: linear-gradient(135deg, var(--accent), var(--accent-2)); transition: width 0.3s ease; }
|
||
.job-track { color: #e2e8f0; font-weight: 600; margin-top: 2px; }
|
||
.job-count { color: var(--muted); font-size: 13px; }
|
||
.tracks { list-style: none; padding: 0; margin: 10px 0 0; max-height: 160px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; background: rgba(255,255,255,0.03); }
|
||
.tracks li { padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||
.tracks li:last-child { border-bottom: none; }
|
||
.logs { font-family: "JetBrains Mono", "Fira Code", monospace; font-size: 13px; line-height: 1.4; background: #0b1022; border: 1px solid var(--border); border-radius: 10px; padding: 10px; max-height: 220px; overflow-y: auto; white-space: pre-wrap; color: #cbd5f5; }
|
||
.likes-grid { display: grid; gap: 12px; grid-template-columns: minmax(240px, 0.85fr) minmax(360px, 1.15fr); margin-top: 12px; align-items: start; }
|
||
.likes-panel { border: 1px solid var(--border); border-radius: 12px; background: rgba(255,255,255,0.02); padding: 12px; }
|
||
.likes-title { font-weight: 800; margin-bottom: 10px; }
|
||
.likes-list { list-style: none; padding: 0; margin: 0; max-height: 320px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; background: rgba(255,255,255,0.03); }
|
||
.likes-item { padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||
.likes-item:last-child { border-bottom: none; }
|
||
.likes-name { font-weight: 700; color: #eef2ff; }
|
||
.likes-name a { color: inherit; text-decoration: none; }
|
||
.likes-name a:hover { text-decoration: underline; }
|
||
.likes-meta { color: var(--muted); font-size: 13px; margin-top: 4px; line-height: 1.35; }
|
||
.likes-empty { color: var(--muted); padding: 12px; }
|
||
.likes-actions { margin-top: 12px; }
|
||
.likes-summary { margin-top: 10px; }
|
||
@media (max-width: 820px) { .likes-grid { grid-template-columns: 1fr; } }
|
||
@media (max-width: 640px) { .page { padding: 16px 14px 32px; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<header>
|
||
<div>
|
||
<p class="title">Yandex Music Downloader · Web</p>
|
||
<p class="hint">Минимум полей: ссылка. Токен берется только из YMD_TOKEN, остальное раскрывайте при необходимости.</p>
|
||
</div>
|
||
<p class="hint">⚡ FastAPI + чистый JS</p>
|
||
</header>
|
||
|
||
<section class="card">
|
||
<form id="download-form">
|
||
<div>
|
||
<label for="url">Ссылка на артиста / альбом / трек / плейлист</label>
|
||
<input id="url" name="url" type="text" placeholder="https://music.yandex.ru/artist/208167" />
|
||
</div>
|
||
<div class="split">
|
||
<input id="artist-id" name="artist_id" type="text" placeholder="artist_id (опционально)">
|
||
<input id="album-id" name="album_id" type="text" placeholder="album_id (опционально)">
|
||
<input id="track-id" name="track_id" type="text" placeholder="track_id (опционально)">
|
||
<input id="playlist-id" name="playlist_id" type="text" placeholder="playlist_id (user/playlists/123)">
|
||
</div>
|
||
|
||
<details>
|
||
<summary>Доп. настройки (по умолчанию из .env)</summary>
|
||
<div class="split" style="margin-top:10px;">
|
||
<div><label for="dir">Папка</label><input id="dir" name="dir" type="text" /></div>
|
||
<div><label for="path-pattern">Паттерн пути</label><input id="path-pattern" name="path_pattern" type="text" /></div>
|
||
</div>
|
||
<div class="split">
|
||
<div>
|
||
<label for="quality">Качество</label>
|
||
<select id="quality" name="quality">
|
||
<option value="2">Лучшее (FLAC)</option>
|
||
<option value="1">Оптимальное (AAC 192)</option>
|
||
<option value="0">Низкое (AAC 64)</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label for="lyrics-format">Тексты</label>
|
||
<select id="lyrics-format" name="lyrics_format">
|
||
<option value="none">Не сохранять</option>
|
||
<option value="text">Текст</option>
|
||
<option value="lrc">LRC</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label for="cover-resolution">Обложка (px)</label>
|
||
<select id="cover-resolution" name="cover_resolution">
|
||
<option value="400">400</option>
|
||
<option value="800">800</option>
|
||
<option value="-1">Оригинал</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label for="compatibility">Совместимость тегов</label>
|
||
<select id="compatibility" name="compatibility_level">
|
||
<option value="1">Уровень 1</option>
|
||
<option value="0">Уровень 0</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="checkboxes">
|
||
<label class="inline"><input type="checkbox" id="skip-existing">Пропускать существующие</label>
|
||
<label class="inline"><input type="checkbox" id="embed-cover">Встраивать обложку</label>
|
||
<label class="inline"><input type="checkbox" id="stick-artist">Только этого артиста</label>
|
||
<label class="inline"><input type="checkbox" id="only-music">Только музыка</label>
|
||
<label class="inline"><input type="checkbox" id="unsafe-path">Не чистить путь</label>
|
||
</div>
|
||
<div class="split">
|
||
<label class="inline">Таймаут <input type="number" id="timeout" min="1" style="width:90px"></label>
|
||
<label class="inline">Повторов <input type="number" id="tries" min="0" style="width:90px"></label>
|
||
<label class="inline">Пауза <input type="number" id="retry-delay" min="0" style="width:90px"> c</label>
|
||
<label class="inline">Задержка <input type="number" id="delay" min="0" style="width:90px"> c</label>
|
||
</div>
|
||
</details>
|
||
|
||
<div class="btn-row">
|
||
<button class="primary" type="submit">Скачать</button>
|
||
<button class="ghost" type="button" id="export-discography">Дискография JSON</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="job-head">
|
||
<div>
|
||
<div class="job-title">Мои лайки</div>
|
||
<p class="hint" style="margin:2px 0 0;">Отдельный список лайкнутых треков и артистов из этих лайков.</p>
|
||
</div>
|
||
</div>
|
||
<div class="btn-row likes-actions">
|
||
<button class="ghost" type="button" id="refresh-likes">Обновить лайки</button>
|
||
<button class="ghost" type="button" id="download-liked-artists-albums">Скачать альбомы артистов</button>
|
||
<button class="primary" type="button" id="download-liked-tracks">Скачать всё понравившееся</button>
|
||
</div>
|
||
<p class="hint likes-summary" id="likes-summary">Лайки еще не загружены.</p>
|
||
<div class="likes-grid">
|
||
<div class="likes-panel">
|
||
<div class="likes-title">Артисты из лайков</div>
|
||
<ul id="liked-artists" class="likes-list"></ul>
|
||
</div>
|
||
<div class="likes-panel">
|
||
<div class="likes-title">Лайкнутые треки</div>
|
||
<ul id="liked-tracks" class="likes-list"></ul>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="job-head">
|
||
<div>
|
||
<div class="job-title">Активные задачи</div>
|
||
<p class="hint" style="margin:2px 0 0;">Смотрим какой трек качается и прогресс.</p>
|
||
</div>
|
||
</div>
|
||
<div id="jobs" class="jobs"></div>
|
||
<div class="logs" id="logs" aria-live="polite"></div>
|
||
</section>
|
||
</div>
|
||
|
||
<script id="config" type="application/json">__CONFIG_JSON__</script>
|
||
<script>
|
||
const jobsEl = document.getElementById('jobs');
|
||
const logsEl = document.getElementById('logs');
|
||
const form = document.getElementById('download-form');
|
||
const discBtn = document.getElementById('export-discography');
|
||
const likesSummaryEl = document.getElementById('likes-summary');
|
||
const likedArtistsEl = document.getElementById('liked-artists');
|
||
const likedTracksEl = document.getElementById('liked-tracks');
|
||
const refreshLikesBtn = document.getElementById('refresh-likes');
|
||
const downloadLikedArtistsBtn = document.getElementById('download-liked-artists-albums');
|
||
const downloadLikedTracksBtn = document.getElementById('download-liked-tracks');
|
||
const defaults = JSON.parse(document.getElementById('config').textContent || '{}');
|
||
const state = { timers: new Map(), lastLog: '' };
|
||
const JOB_TITLES = {
|
||
download: 'Загрузка',
|
||
discography: 'Дискография',
|
||
likes_tracks: 'Лайки: всё понравившееся',
|
||
likes_artists_albums: 'Лайки: альбомы артистов'
|
||
};
|
||
|
||
function applyDefaults() {
|
||
const set = (id, value) => {
|
||
const el = document.getElementById(id);
|
||
if (!el || value === undefined || value === null) return;
|
||
if (el.type === 'checkbox') el.checked = Boolean(value);
|
||
else el.value = value;
|
||
};
|
||
set('dir', defaults.dir);
|
||
set('path-pattern', defaults.path_pattern);
|
||
set('quality', defaults.quality);
|
||
set('lyrics-format', defaults.lyrics_format);
|
||
set('cover-resolution', defaults.cover_resolution);
|
||
set('compatibility', defaults.compatibility_level);
|
||
set('skip-existing', defaults.skip_existing);
|
||
set('embed-cover', defaults.embed_cover);
|
||
set('stick-artist', defaults.stick_to_artist);
|
||
set('only-music', defaults.only_music);
|
||
set('unsafe-path', defaults.unsafe_path);
|
||
set('delay', defaults.delay);
|
||
set('timeout', defaults.timeout);
|
||
set('tries', defaults.tries);
|
||
set('retry-delay', defaults.retry_delay);
|
||
}
|
||
applyDefaults();
|
||
|
||
function showError(message) {
|
||
logsEl.textContent = '[error] ' + message;
|
||
}
|
||
|
||
function escapeHTML(value) {
|
||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
}[char]));
|
||
}
|
||
|
||
function linkHTML(url, text) {
|
||
const label = escapeHTML(text);
|
||
if (!url) return label;
|
||
return `<a href="${escapeHTML(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||
}
|
||
|
||
function collectPayload() {
|
||
return {
|
||
url: document.getElementById('url').value.trim() || null,
|
||
artist_id: document.getElementById('artist-id').value.trim() || null,
|
||
album_id: document.getElementById('album-id').value.trim() || null,
|
||
track_id: document.getElementById('track-id').value.trim() || null,
|
||
playlist_id: document.getElementById('playlist-id').value.trim() || null,
|
||
dir: document.getElementById('dir').value.trim() || defaults.dir || '.',
|
||
path_pattern: document.getElementById('path-pattern').value.trim() || defaults.path_pattern || '#album-artist/#album/#number - #title',
|
||
quality: Number(document.getElementById('quality').value || defaults.quality || 2),
|
||
lyrics_format: document.getElementById('lyrics-format').value || defaults.lyrics_format || 'none',
|
||
cover_resolution: Number(document.getElementById('cover-resolution').value || defaults.cover_resolution || 400),
|
||
compatibility_level: Number(document.getElementById('compatibility').value || defaults.compatibility_level || 1),
|
||
skip_existing: document.getElementById('skip-existing').checked,
|
||
embed_cover: document.getElementById('embed-cover').checked,
|
||
stick_to_artist: document.getElementById('stick-artist').checked,
|
||
only_music: document.getElementById('only-music').checked,
|
||
unsafe_path: document.getElementById('unsafe-path').checked,
|
||
delay: Number(document.getElementById('delay').value || defaults.delay || 0),
|
||
timeout: Number(document.getElementById('timeout').value || defaults.timeout || 20),
|
||
tries: Number(document.getElementById('tries').value || defaults.tries || 20),
|
||
retry_delay: Number(document.getElementById('retry-delay').value || defaults.retry_delay || 5)
|
||
};
|
||
}
|
||
|
||
async function getJSON(url) {
|
||
const res = await fetch(url);
|
||
if (!res.ok) {
|
||
let detail = res.statusText;
|
||
try { const data = await res.json(); detail = data.detail || JSON.stringify(data); } catch {}
|
||
throw new Error(detail);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
async function postJSON(url, payload) {
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (!res.ok) {
|
||
let detail = res.statusText;
|
||
try { const data = await res.json(); detail = data.detail || JSON.stringify(data); } catch {}
|
||
throw new Error(detail);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
function renderLikesList(el, items, emptyText, renderItem) {
|
||
if (!items || !items.length) {
|
||
el.innerHTML = `<li class="likes-empty">${escapeHTML(emptyText)}</li>`;
|
||
return;
|
||
}
|
||
el.innerHTML = items.map(renderItem).join('');
|
||
}
|
||
|
||
function renderLikes(data) {
|
||
likesSummaryEl.textContent = `Лайкнутых треков: ${data.summary?.tracks || 0} · артистов в лайках: ${data.summary?.artists || 0}`;
|
||
|
||
renderLikesList(
|
||
likedArtistsEl,
|
||
data.artists || [],
|
||
'Артисты из лайкнутых треков не найдены.',
|
||
(artist) => `
|
||
<li class="likes-item">
|
||
<div class="likes-name">${linkHTML(`https://music.yandex.ru/artist/${artist.id}`, artist.name)}</div>
|
||
<div class="likes-meta">id ${escapeHTML(artist.id)} · треков в лайках: ${artist.track_count || 0}</div>
|
||
</li>
|
||
`
|
||
);
|
||
|
||
renderLikesList(
|
||
likedTracksEl,
|
||
data.tracks || [],
|
||
'Лайкнутые треки не найдены.',
|
||
(track) => {
|
||
const artists = (track.artists || []).map((artist) => linkHTML(
|
||
artist.id ? `https://music.yandex.ru/artist/${artist.id}` : '',
|
||
artist.name
|
||
)).join(', ');
|
||
const album = track.album
|
||
? ` · ${linkHTML(`https://music.yandex.ru/album/${track.album.id}`, track.album.title)}`
|
||
: '';
|
||
const url = track.id ? `https://music.yandex.ru/track/${track.id}` : '';
|
||
return `
|
||
<li class="likes-item">
|
||
<div class="likes-name">${linkHTML(url, track.title)}</div>
|
||
<div class="likes-meta">${artists || 'Unknown artist'}${album}</div>
|
||
</li>
|
||
`;
|
||
}
|
||
);
|
||
}
|
||
|
||
function renderJob(job) {
|
||
let node = document.querySelector(`[data-job="${job.id}"]`);
|
||
if (!node) {
|
||
node = document.createElement('div');
|
||
node.className = 'job';
|
||
node.dataset.job = job.id;
|
||
node.innerHTML = `
|
||
<div class="job-head">
|
||
<div class="job-title"></div>
|
||
<span class="tag pending">pending</span>
|
||
</div>
|
||
<div class="job-track"></div>
|
||
<div class="progress"><div class="bar"></div></div>
|
||
<div class="job-count"></div>
|
||
<ul class="tracks"></ul>
|
||
`;
|
||
jobsEl.prepend(node);
|
||
}
|
||
node.querySelector('.job-title').textContent = job.title;
|
||
const tag = node.querySelector('.tag');
|
||
tag.textContent = job.status;
|
||
tag.className = 'tag ' + job.status;
|
||
node.querySelector('.job-track').textContent = job.current_track || job.meta || 'Ожидание';
|
||
node.querySelector('.job-count').textContent = `${job.progress || 0}${job.total ? ' / ' + job.total : ''}`;
|
||
node.querySelector('.bar').style.width = job.percent + '%';
|
||
const list = node.querySelector('.tracks');
|
||
list.innerHTML = '';
|
||
(job.recent_logs || []).filter(l => l.includes('[track]')).forEach(line => {
|
||
const item = document.createElement('li');
|
||
item.textContent = line.replace('[track]', '').trim();
|
||
list.prepend(item);
|
||
});
|
||
}
|
||
|
||
function updateLogs(logs) {
|
||
if (!logs || !logs.length) return;
|
||
const slice = logs.slice(-80).join('\\n');
|
||
if (slice === state.lastLog) return;
|
||
state.lastLog = slice;
|
||
logsEl.textContent = slice;
|
||
logsEl.scrollTop = logsEl.scrollHeight;
|
||
}
|
||
|
||
function handleJobResponse(data) {
|
||
const percent = data.percent ?? 0;
|
||
const title = JOB_TITLES[data.type] || 'Загрузка';
|
||
const meta = data.message || '';
|
||
renderJob({
|
||
id: data.id,
|
||
title,
|
||
meta,
|
||
status: data.status,
|
||
percent,
|
||
progress: data.progress,
|
||
total: data.total,
|
||
current_track: data.current_track,
|
||
recent_logs: data.recent_logs
|
||
});
|
||
updateLogs(data.logs || []);
|
||
if (['done', 'failed'].includes(data.status)) {
|
||
clearInterval(state.timers.get(data.id));
|
||
}
|
||
}
|
||
|
||
async function loadLikes() {
|
||
if (!defaults.has_token) {
|
||
likesSummaryEl.textContent = 'Задайте YMD_TOKEN в окружении.';
|
||
renderLikesList(likedArtistsEl, [], 'Нет доступа к лайкам без YMD_TOKEN.', () => '');
|
||
renderLikesList(likedTracksEl, [], 'Нет доступа к лайкам без YMD_TOKEN.', () => '');
|
||
return;
|
||
}
|
||
refreshLikesBtn.disabled = true;
|
||
likesSummaryEl.textContent = 'Загружаем лайки...';
|
||
try {
|
||
const data = await getJSON('/api/likes');
|
||
renderLikes(data);
|
||
} catch (err) {
|
||
likesSummaryEl.textContent = '[error] ' + err.message;
|
||
showError(err.message);
|
||
} finally {
|
||
refreshLikesBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function pollJob(id) {
|
||
try {
|
||
const data = await getJSON(`/api/jobs/${id}`);
|
||
handleJobResponse(data);
|
||
} catch (err) {
|
||
showError(err.message);
|
||
}
|
||
}
|
||
|
||
async function startLikesDownload(url) {
|
||
const payload = collectPayload();
|
||
if (!defaults.has_token) { showError('Задайте YMD_TOKEN в окружении'); return; }
|
||
try {
|
||
const data = await postJSON(url, payload);
|
||
handleJobResponse(data);
|
||
const timer = setInterval(() => pollJob(data.id), 1200);
|
||
state.timers.set(data.id, timer);
|
||
} catch (err) {
|
||
showError(err.message);
|
||
}
|
||
}
|
||
|
||
form.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const payload = collectPayload();
|
||
if (!defaults.has_token) { showError('Задайте YMD_TOKEN в окружении'); return; }
|
||
const hasTarget = payload.url || payload.artist_id || payload.album_id || payload.track_id || payload.playlist_id;
|
||
if (!hasTarget) { showError('Нужен URL или хотя бы один ID'); return; }
|
||
try {
|
||
const data = await postJSON('/api/download', payload);
|
||
handleJobResponse(data);
|
||
const timer = setInterval(() => pollJob(data.id), 1200);
|
||
state.timers.set(data.id, timer);
|
||
} catch (err) {
|
||
showError(err.message);
|
||
}
|
||
});
|
||
|
||
refreshLikesBtn.addEventListener('click', loadLikes);
|
||
|
||
downloadLikedArtistsBtn.addEventListener('click', async () => {
|
||
await startLikesDownload('/api/download/likes/artists-albums');
|
||
});
|
||
|
||
downloadLikedTracksBtn.addEventListener('click', async () => {
|
||
await startLikesDownload('/api/download/likes/all');
|
||
});
|
||
|
||
discBtn.addEventListener('click', async () => {
|
||
const payload = collectPayload();
|
||
if (!defaults.has_token) { showError('Задайте YMD_TOKEN в окружении'); return; }
|
||
if (!payload.url && !payload.artist_id) { showError('Для дискографии нужен artist_id или URL'); return; }
|
||
try {
|
||
const data = await postJSON('/api/discography', {
|
||
url: payload.url,
|
||
artist_id: payload.artist_id,
|
||
only_music: payload.only_music,
|
||
stick_to_artist: payload.stick_to_artist,
|
||
timeout: payload.timeout,
|
||
tries: payload.tries,
|
||
retry_delay: payload.retry_delay
|
||
});
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = ((data.artist?.name) || 'discography') + '.json';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
updateLogs([`[discography] Альбомов: ${data.albums.length}, Треков: ${data.albums.reduce((s,a)=>s+(a.tracks?.length||0),0)}`]);
|
||
} catch (err) {
|
||
showError(err.message);
|
||
}
|
||
});
|
||
|
||
loadLikes();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
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()
|