yandex-music-downloader/ymd/webui.py

1340 lines
51 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[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()