diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..30519dc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.db +*.sqlite3 +.env +.git +.gitignore +.mypy_cache +.pytest_cache +dist +build +*.egg-info +downloads +*.log +node_modules diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0cfa362 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +YMD_TOKEN=put_your_yandex_music_token_here +YMD_DOWNLOAD_DIR=/app/downloads +YMD_PATH_PATTERN=#album-artist/#album/#number-#title +YMD_QUALITY=2 +YMD_LYRICS_FORMAT=none +YMD_COVER_RESOLUTION=400 +YMD_COMPATIBILITY_LEVEL=1 +YMD_TIMEOUT=20 +YMD_TRIES=20 +YMD_RETRY_DELAY=5 +YMD_DELAY=0 +YMD_SKIP_EXISTING=false +YMD_EMBED_COVER=false +YMD_STICK_TO_ARTIST=false +YMD_ONLY_MUSIC=false +YMD_UNSAFE_PATH=false +YMD_DISCO_TRIES=10 +YMD_DISCO_RETRY_DELAY=5 diff --git a/.gitignore b/.gitignore index 68bc17f..89b92e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,160 +1,42 @@ -# Byte-compiled / optimized / DLL files +# Local environment +.env +.env.* +!.env.example + +# Python __pycache__/ *.py[cod] -*$py.class +*.pyo +*.pyd +.python-version +.venv/ +venv/ +env/ -# C extensions -*.so - -# Distribution / packaging -.Python +# Build artifacts build/ -develop-eggs/ dist/ -downloads/ -eggs/ .eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ *.egg-info/ -.installed.cfg -*.egg -MANIFEST +pip-wheel-metadata/ -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ +# Tooling caches +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.pyre/ .tox/ .nox/ .coverage .coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ +htmlcov/ -# Translations -*.mo -*.pot +# IDEs and OS files +.idea/ +.vscode/ +.DS_Store +Thumbs.db -# Django stuff: +# Project runtime files +downloads/ *.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6864e83 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_NO_CACHE_DIR=1 \ + YMD_DOWNLOAD_DIR=/app/downloads \ + YMD_PATH_PATTERN="#album-artist/#album/#number-#title" + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY . . + +RUN pip install --upgrade pip \ + && pip install . + +EXPOSE 8000 + +CMD ["uvicorn", "ymd.webui:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 8f00d66..d8cf5b8 100644 --- a/README.md +++ b/README.md @@ -1,154 +1,244 @@ # yandex-music-downloader -> Внимание! В версии v3 был изменен способ авторизации и некоторые -> аргументы. Смотрите [MIGRATION.md](MIGRATION.md) для получения информации -> об изменениях +Загрузчик музыки из Яндекс Музыки с двумя интерфейсами: -## Содержание -1. [О программе](#О-программе) -2. [Установка](#Установка) -3. [Получение данных для авторизации](#Получение-данных-для-авторизации) -4. [Примеры использования](#Примеры-использования) -5. [Использование](#Использование) -6. [Уровни совместимости](#Уровни-совместимости) -7. [Спасибо](#Спасибо) -8. [Дисклеймер](#Дисклеймер) +- `CLI` для точечных и пакетных загрузок +- `Web UI` на FastAPI для загрузки, экспорта дискографии и работы с лайками -## О программе -Загрузчик, созданный вследствие наличия *фатального недостатка* в проекте [yandex-music-download](https://github.com/kaimi-io/yandex-music-download). +Проект поддерживает загрузку в AAC и FLAC, сохранение тегов, обложек и текстов, а также запуск через Docker. -### Возможности -- Возможность загрузки: - - Всех треков исполнителя - - Всех треков из альбома - - Всех треков из плейлиста - - Отдельного трека -- Загрузка всех метаданных трека/альбома: - - Номер трека - - Номер диска - - Название трека - - Исполнитель - - Дополнительные исполнители - - Дата выпуска альбома - - Обложка альбома - - Название альбома - - Текст песни (при использовании флага `--add-lyrics`) -- Загрузка треков в lossless качестве -- Поддержка паттерна для пути сохранения музыки +> В `v3` изменился способ авторизации и часть аргументов. Если обновляетесь со старой версии, смотрите [MIGRATION.md](MIGRATION.md). + +## Возможности + +- Скачать все треки артиста, альбом, плейлист или отдельный трек +- Загружать в качестве `AAC 64`, `AAC 192` или `FLAC` +- Встраивать обложку в файл +- Сохранять тексты песен в формате `text` или `lrc` +- Настраивать путь сохранения через шаблон +- Пропускать уже скачанные треки +- Отдельно настраивать совместимость тегов +- Работать через веб-панель без ручного ввода токена +- Смотреть лайки в веб-панели +- Скачивать из веб-панели: + - все понравившиеся треки + - альбомы артистов из лайков + - дискографию артиста в JSON + +## Требования + +- Python `3.9+` +- Токен Яндекс Музыки ## Установка -Для запуска скрипта требуется Python 3.9+ + +Локально: + +```bash +pip install . ``` -pip install -U https://github.com/llistochek/yandex-music-downloader/archive/main.zip + +После установки доступны две команды: + +```bash +yandex-music-downloader --help +yandex-music-downloader-web --help +``` + +## Получение токена + +Инструкция по получению токена: + +https://yandex-music.readthedocs.io/en/main/token.html + +## Быстрый старт CLI + +Скачать все треки артиста в FLAC: + +```bash +yandex-music-downloader \ + --token "" \ + --quality 2 \ + --url "https://music.yandex.ru/artist/208167" +``` + +Скачать альбом и сохранить тексты в `LRC`: + +```bash +yandex-music-downloader \ + --token "" \ + --quality 1 \ + --lyrics-format lrc \ + --url "https://music.yandex.ru/album/294912" +``` + +Скачать плейлист в свою папку: + +```bash +yandex-music-downloader \ + --token "" \ + --playlist-id "user/playlists/123" \ + --dir "./downloads" \ + --skip-existing +``` + +Скачать один трек: + +```bash +yandex-music-downloader \ + --token "" \ + --url "https://music.yandex.ru/album/11644078/track/6705392" +``` + +## Основные аргументы CLI + +- `--url` или один из `--artist-id`, `--album-id`, `--track-id`, `--playlist-id` +- `--token` для авторизации +- `--quality 0|1|2` +- `--lyrics-format none|text|lrc` +- `--embed-cover` +- `--cover-resolution 400|800|original` +- `--skip-existing` +- `--dir` +- `--path-pattern` +- `--unsafe-path` +- `--stick-to-artist` +- `--only-music` +- `--timeout` +- `--tries` +- `--retry-delay` + +Полный список: + +```bash yandex-music-downloader --help ``` -## Получение данных для авторизации -https://yandex-music.readthedocs.io/en/main/token.html +## Веб-панель -## Примеры использования -Во всех примерах замените `<Токен>` на ваш токен. +Веб-панель использует токен только из переменной окружения `YMD_TOKEN`. В интерфейсе поле токена отсутствует. -### Скачать все треки [Arctic Monkeys](https://music.yandex.ru/artist/208167) в наилучшем качестве -``` -yandex-music-downloader --token "<Токен>" --quality 2 --url "https://music.yandex.ru/artist/208167" +Локальный запуск: + +```bash +export YMD_TOKEN="" +yandex-music-downloader-web --host 127.0.0.1 --port 8000 ``` -### Скачать альбом [Nevermind](https://music.yandex.ru/album/294912) в высоком качестве, загружая тексты песен в формате LRC (с временными метками) -``` -yandex-music-downloader --token "<Токен>" --quality 1 --lyrics-format lrc --url "https://music.yandex.ru/album/294912" +После запуска откройте: + +```text +http://127.0.0.1:8000 ``` -### Скачать трек [Seven Nation Army](https://music.yandex.ru/album/11644078/track/6705392) -``` -yandex-music-downloader --token "<Токен>" --url "https://music.yandex.ru/album/11644078/track/6705392" +Что умеет веб-панель: + +- загрузка по ссылке, `artist_id`, `album_id`, `track_id`, `playlist_id` +- экспорт дискографии артиста в JSON +- просмотр лайкнутых треков +- отдельный список артистов из лайков +- кнопка `Скачать всё понравившееся` +- кнопка `Скачать альбомы артистов` +- отображение активных задач и прогресса + +## Запуск через Docker + +1. Создайте `.env` из шаблона: + +```bash +cp .env.example .env ``` -## Использование -``` -usage: yandex-music-downloader [-h] [--quality <Качество>] [--skip-existing] - [--lyrics-format {none,text,lrc}] - [--embed-cover] - [--cover-resolution <Разрешение обложки>] - [--delay <Задержка>] [--stick-to-artist] - [--only-music] - [--compatibility-level <Уровень совместимости>] - [--timeout <Время ожидания>] - [--tries <Количество попыток>] - [--retry-delay <Задержка>] - (--artist-id | --album-id | --track-id | --playlist-id <владелец плейлиста>/<тип плейлиста> | -u URL) - [--unsafe-path] [--dir <Папка>] - [--path-pattern <Паттерн>] --token <Токен> +2. Укажите токен и при необходимости остальные параметры. -Загрузчик музыки с сервиса Яндекс.Музыка +3. Запустите контейнер: -options: - -h, --help show this help message and exit - -Общие параметры: - --quality <Качество> Качество трека: - 0 - Низкое (AAC 64kbps) - 1 - Оптимальное (AAC 192kbps) - 2 - Лучшее (FLAC) - (по умолчанию: 0) - --skip-existing Пропускать уже загруженные треки - --lyrics-format {none,text,lrc} - Формат текста песни (по умолчанию: none) - --embed-cover Встраивать обложку в аудиофайл - --cover-resolution <Разрешение обложки> - Разрешение обложки (в пикселях). Передайте "original" для загрузки в оригинальном (наилучшем) разрешении (по умолчанию: 400) - --delay <Задержка> Задержка между запросами, в секундах (по умолчанию: 0) - --stick-to-artist Загружать альбомы, созданные только данным исполнителем - --only-music Загружать только музыкальные альбомы (пропускать подкасты и аудиокниги) - --compatibility-level <Уровень совместимости> - Уровень совместимости, от 0 до 1. См. README для подробного описания (по умолчанию: 1) - -Сетевые параметры: - --timeout <Время ожидания> - Время ожидания ответа от сервера, в секундах. Увеличьте если возникают сетевые ошибки (по умолчанию: 20) - --tries <Количество попыток> - Количество попыток при возникновении сетевых ошибок. 0 - бесконечное количество попыток (по умолчанию: 20) - --retry-delay <Задержка> - Задержка между повторными запросами при сетевых ошибках (по умолчанию: 5) - -ID: - --artist-id - --album-id - --track-id - --playlist-id <владелец плейлиста>/<тип плейлиста> - -u URL, --url URL URL исполнителя/альбома/трека/плейлиста - -Указание пути: - --unsafe-path Не очищать путь от недопустимых символов - --dir <Папка> Папка для загрузки музыки (по умолчанию: .) - --path-pattern <Паттерн> - Поддерживает следующие заполнители: #number, #track-artist, #album-artist, #title, #album, #year, #artist-id, #album-id, #track-id, #number-padded (по умолчанию: #album-artist/#album/#number - #title) - -Авторизация: - --token <Токен> Токен для авторизации. См. README для способов получения +```bash +docker compose up --build -d ``` -## Уровни совместимости -Уровень совместимости позволяет отойти от стандарта тегов, которого -придерживается библиотека mutagen. Сделано это для поддержки большего -количества музыкальных плееров. Ниже подробно описаны все уровни. +4. Откройте веб-панель: -### 0 -Стандартные теги mutagen. +```text +http://localhost:8228 +``` -### 1 -Затрагиваемые форматы: `m4a` +Загрузка по умолчанию сохраняется в локальную папку `./downloads`. -- Теги с несколькими значениями (`\xa9ART` и `aART`) устанвливаются с -разделителем `;`. Пример: `Artist1; Artist2; Artist3` +## Переменные окружения для Web UI / Docker +Полный набор есть в [.env.example](.env.example). Основные переменные: + +- `YMD_TOKEN` — токен Яндекс Музыки +- `YMD_DOWNLOAD_DIR` — корневая папка загрузки +- `YMD_PATH_PATTERN` — шаблон пути +- `YMD_QUALITY` — качество по умолчанию (`0`, `1`, `2`) +- `YMD_LYRICS_FORMAT` — `none`, `text`, `lrc` +- `YMD_COVER_RESOLUTION` — размер обложки +- `YMD_COMPATIBILITY_LEVEL` — уровень совместимости тегов +- `YMD_SKIP_EXISTING` — пропускать существующие файлы +- `YMD_EMBED_COVER` — встраивать обложку +- `YMD_STICK_TO_ARTIST` — брать только альбомы самого артиста +- `YMD_ONLY_MUSIC` — исключать подкасты и аудиокниги +- `YMD_TIMEOUT` — таймаут запросов +- `YMD_TRIES` — число повторов +- `YMD_RETRY_DELAY` — пауза между повторами +- `YMD_DELAY` — задержка между треками +- `YMD_UNSAFE_PATH` — не чистить путь от небезопасных символов + +## Шаблон пути + +По умолчанию используется: + +```text +#album-artist/#album/#number-#title +``` + +Поддерживаемые заполнители: + +- `#number` +- `#number-padded` +- `#track-artist` +- `#album-artist` +- `#title` +- `#album` +- `#year` +- `#artist-id` +- `#album-id` +- `#track-id` + +Пример: + +```text +#album-artist/#album/#number-padded-#title +``` + +## Уровни совместимости тегов + +### `0` + +Стандартное поведение `mutagen`. + +### `1` + +Для `m4a` многозначные поля (`©ART`, `aART`) записываются через `;`, что лучше работает в части плееров. + +## Изменения в текущей версии + +- Добавлен Web UI на FastAPI +- Добавлен Docker-образ и `docker compose` +- Токен для веб-панели переведен только в `YMD_TOKEN` +- Добавлен просмотр лайков +- Добавлены действия `Скачать всё понравившееся` и `Скачать альбомы артистов` +- Улучшена логика построения путей и переименования по тегам ## Спасибо + - Разработчикам проекта [yandex-music-api](https://github.com/MarshalX/yandex-music-api) - @ArtemBay за [скрипт](https://github.com/MarshalX/yandex-music-api/issues/656#issuecomment-2306542725) получения ссылки на загрузку в lossless качестве - @keltecc за [метод дешифрования файлов](https://github.com/llistochek/yandex-music-downloader/issues/112#issuecomment-2812535100) -- @leowerd за [корректные имена исполнителей](https://github.com/llistochek/yandex-music-downloader/issues/93#issuecomment-2960210879) при загрузке сборников - +- @leowerd за [идею корректных имен исполнителей](https://github.com/llistochek/yandex-music-downloader/issues/93#issuecomment-2960210879) при загрузке сборников ## Дисклеймер -Данный проект является независимой разработкой и никак не связан с компанией Яндекс. + +Проект не связан с Яндексом и является независимой разработкой. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..925e422 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.9" + +services: + ymd: + build: . + container_name: ymd + env_file: .env + ports: + - "8228:8000" + volumes: + - ./downloads:/app/downloads + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml index ec0fd0c..cfb5ee1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,12 @@ dependencies = [ "yandex-music @ https://github.com/llistochek/yandex-music-api/archive/9623fbca7704f47766614efe51d66c9fd496714c.zip", "mutagen>=1.47.0", "StrEnum", - "pycryptodome" + "pycryptodome", + "fastapi>=0.110.0", + "uvicorn[standard]>=0.23.0", + "pydantic>=2.7.0" ] [project.scripts] yandex-music-downloader = "ymd.cli:main" +yandex-music-downloader-web = "ymd.webui:main" diff --git a/ymd/cli.py b/ymd/cli.py index 01dd1df..acc55dd 100644 --- a/ymd/cli.py +++ b/ymd/cli.py @@ -316,9 +316,11 @@ def main(): progress_status = "" covers_cache = {} for track in result_tracks: + track_counter += 1 if total_track_count: - track_counter += 1 progress_status = f"[{track_counter}/{total_track_count}] " + else: + progress_status = "" if not track.available: print(f"{progress_status}Трек {track.title} не доступен для скачивания") @@ -328,6 +330,8 @@ def main(): args.path_pattern, track, args.unsafe_path, + fallback_number=track_counter, + fallback_total=total_track_count, ) if args.skip_existing: if any( @@ -353,6 +357,11 @@ def main(): cover_resolution=args.cover_resolution, covers_cache=covers_cache, compatibility_level=args.compatibility_level, + path_pattern=args.path_pattern, + unsafe_path=args.unsafe_path, + fallback_number=track_counter, + fallback_total=total_track_count, + rename_using_tags=True, ) if args.delay > 0: time.sleep(args.delay) diff --git a/ymd/core.py b/ymd/core.py index b9b9bb3..01322c5 100644 --- a/ymd/core.py +++ b/ymd/core.py @@ -48,7 +48,7 @@ from ymd.mime_utils import MimeType, guess_mime_type UNSAFE_PATH_CLEAR_RE = re.compile(r"[/\\]+") SAFE_PATH_CLEAR_RE = re.compile(r"([^\w\-\'() ]|^\s+|\s+$)") -DEFAULT_PATH_PATTERN = Path("#album-artist", "#album", "#number - #title") +DEFAULT_PATH_PATTERN = Path("#album-artist", "#album", "#number-#title") DEFAULT_COVER_RESOLUTION = 400 MIN_COMPATIBILITY_LEVEL = 0 @@ -122,16 +122,59 @@ def init_client( def full_title(obj: YandexMusicModel) -> str: - result = obj["title"] - if result is None: + """Return title with optional version; be tolerant to missing keys.""" + title = getattr(obj, "title", None) + if title is None: + try: + title = obj["title"] + except Exception: # noqa: BLE001 + title = None + if not title: return "" - if version := obj["version"]: - result += f" ({version})" - return result + + version = getattr(obj, "version", None) + if version is None: + try: + version = obj["version"] + except Exception: # noqa: BLE001 + version = None + if version: + title = f"{title} ({version})" + return title + + +def sanitize_path_component(text: str, unsafe_path: bool) -> str: + """Return path-safe component.""" + if unsafe_path: + return UNSAFE_PATH_CLEAR_RE.sub("_", text) + text = text.strip() + text = SAFE_PATH_CLEAR_RE.sub("_", text) + text = re.sub(r"\s+", "_", text) + text = re.sub(r"_+", "_", text) + return text + + +def choose_first(*values: Optional[Union[str, int]]) -> str: + for candidate in values: + if candidate is None: + continue + text = str(candidate).strip() + if text: + return text + return "" def prepare_base_path( - path_pattern: Path, track: Track, unsafe_path: bool = False + path_pattern: Path, + track: Track, + unsafe_path: bool = False, + fallback_number: Optional[int] = None, + fallback_total: Optional[int] = None, + override_title: Optional[str] = None, + override_album: Optional[str] = None, + override_album_artist: Optional[str] = None, + override_track_artist: Optional[str] = None, + override_track_number: Optional[int] = None, ) -> Path: path_str = str(path_pattern) album = None @@ -140,34 +183,83 @@ def prepare_base_path( track_position = None if albums := track.albums: album = albums[0] - track_position = album.track_position if artists := album.artists: album_artist = artists[0] + # позиция трека корректнее брать из самого трека + track_position = getattr(track, "track_position", None) or track_position if artists := track.artists: track_artist = artists[0] + + track_id = getattr(track, "id", None) + track_title = choose_first( + override_title, + full_title(track), + f"track_{track_id}" if track_id is not None else None, + f"track_{fallback_number}" if fallback_number is not None else None, + "track", + ) + album_title = choose_first( + override_album, + full_title(album) if album else None, + str(album.id) if album and album.id is not None else None, + "album", + ) + album_artist_name = choose_first( + override_album_artist, + album_artist.name if album_artist else None, + "artist", + ) + track_artist_name = choose_first( + override_track_artist, + track_artist.name if track_artist else None, + album_artist_name, + "artist", + ) + pad_width = None + if track_position and album and album.track_count: + pad_width = max(2, len(str(album.track_count))) + elif fallback_total: + pad_width = max(2, len(str(fallback_total))) + elif fallback_number: + pad_width = max(2, len(str(fallback_number))) + + number = ( + override_track_number + if override_track_number is not None + else (track_position.index if track_position else fallback_number) + ) + number_padded = ( + str(number).zfill(pad_width) if number is not None and pad_width is not None else number + ) + + number_value = number_padded if number_padded is not None else number repl_dict: dict[str, Union[str, int, None]] = { - "#number-padded": str(track_position.index).zfill(len(str(album.track_count))) - if track_position and album - else None, - "#album-artist": album_artist.name if album_artist else None, - "#track-artist": track_artist.name if track_artist else None, + "#number-padded": number_padded, + "#album-artist": album_artist_name, + "#track-artist": track_artist_name, "#artist-id": track_artist.id if track_artist else None, "#album-id": album.id if album else None, "#track-id": track.id, - "#number": track_position.index if track_position else None, - "#title": full_title(track), - "#album": full_title(album) if album else None, + "#number": number_value, + "#title": track_title, + "#album": album_title, "#year": album.year if album else None, } for placeholder, replacement in repl_dict.items(): - replacement = str(replacement) - if not unsafe_path: - clear_re = SAFE_PATH_CLEAR_RE - else: - clear_re = UNSAFE_PATH_CLEAR_RE - replacement = clear_re.sub("_", replacement) + if replacement is None: + replacement = "" + replacement = sanitize_path_component(str(replacement), unsafe_path) + if not replacement: + replacement = sanitize_path_component("untitled", unsafe_path) path_str = path_str.replace(placeholder, replacement) path = Path(path_str) + if not unsafe_path: + path = Path( + *( + re.sub(r"_+", "_", part.replace(" ", "_")) + for part in path.parts + ) + ) trimmed_parts = [ part if len(part) <= MAX_FILE_NAME_LENGTH_WITHOUT_SUFFIX @@ -184,25 +276,57 @@ def set_tags( lyrics: Optional[str], album_cover: Optional[AlbumCover], compatibility_level: int, + source_tags: Optional[dict[str, Optional[Union[str, int]]]] = None, ) -> None: file_type = CONTAINER_MUTAGEN_MAPPING.get(container) if file_type is None: raise ValueError(f"Unknown container: {container}") + source_tags = source_tags or {} + + def as_list(value: Optional[Union[str, list[str], tuple[str, ...]]]) -> list[str]: + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + return [str(v).strip() for v in value if str(v).strip()] + text = str(value).strip() + return [text] if text else [] + tag = file_type(path) album = track.albums[0] if track.albums else Album() - album_title = full_title(album) - track_title = full_title(track) + album_title = choose_first( + source_tags.get("album"), + full_title(album), + str(album.id) if getattr(album, "id", None) is not None else None, + ) + track_title = choose_first( + source_tags.get("title"), + full_title(track), + f"track_{track.id}" if getattr(track, "id", None) is not None else None, + "track", + ) track_artists = [a.name for a in track.artists if a.name] + if not track_artists: + track_artists = as_list(source_tags.get("track_artist")) album_artists = [a.name for a in album.artists if a.name] + if not album_artists: + album_artists = as_list(source_tags.get("album_artist")) + if not track_artists: + track_artists = album_artists genre = None if album.genre: genre = album.genre track_number = None disc_number = None - if position := album.track_position: + position = getattr(track, "track_position", None) or album.track_position + if position: track_number = position.index disc_number = position.volume + if track_number is None and (tag_track := source_tags.get("track_number")): + try: + track_number = int(tag_track) + except (TypeError, ValueError): + track_number = None iso8601_release_date = None release_year: Optional[str] = None if album.release_date is not None: @@ -308,6 +432,122 @@ def set_tags( tag.save() +def extract_basic_tags(path: Path, container: Container) -> dict[str, Optional[Union[str, int]]]: + """Read minimal tags to be used for renaming.""" + title = album = album_artist = track_artist = None + track_number: Optional[int] = None + tag = CONTAINER_MUTAGEN_MAPPING[container](path) + + def first_text(value) -> Optional[str]: + if value is None: + return None + if isinstance(value, list): + if not value: + return None + value = value[0] + if hasattr(value, "text"): + value = value.text + if isinstance(value, list): + value = value[0] if value else None + if isinstance(value, bytes): + try: + value = value.decode("utf-8", "ignore") + except Exception: + return None + return str(value) if value else None + + if isinstance(tag, MP3): + title = first_text(tag.get("TIT2")) + album = first_text(tag.get("TALB")) + track_artist = first_text(tag.get("TPE1")) + album_artist = first_text(tag.get("TPE2")) + if trck := first_text(tag.get("TRCK")): + try: + track_number = int(str(trck).split("/")[0]) + except ValueError: + track_number = None + elif isinstance(tag, MP4): + title = first_text(tag.tags.get("\xa9nam") if tag.tags else None) + album = first_text(tag.tags.get("\xa9alb") if tag.tags else None) + track_artist = first_text(tag.tags.get("\xa9ART") if tag.tags else None) + album_artist = first_text(tag.tags.get("aART") if tag.tags else None) + if trkn := tag.tags.get("trkn") if tag.tags else None: + try: + track_number = int(trkn[0][0]) + except Exception: + track_number = None + elif isinstance(tag, FLAC): + def get_flac(key: str) -> Optional[str]: + values = tag.get(key) + if not values: + return None + return values[0] + + title = get_flac("title") + album = get_flac("album") + artists_val = get_flac("artist") + if artists_val: + track_artist = artists_val + album_artist = get_flac("albumartist") + if trck := get_flac("tracknumber"): + try: + track_number = int(str(trck).split("/")[0]) + except ValueError: + track_number = None + + return { + "title": title, + "album": album, + "album_artist": album_artist, + "track_artist": track_artist, + "track_number": track_number, + } + + +def retarget_with_tags( + path_pattern: Path, + track: Track, + container: Container, + current_path: Path, + unsafe_path: bool, + fallback_number: Optional[int], + fallback_total: Optional[int], + override_tags: Optional[dict[str, Optional[Union[str, int]]]] = None, +) -> Path: + tags: dict[str, Optional[Union[str, int]]] = {} + if override_tags: + tags.update(override_tags) + try: + extracted = extract_basic_tags(current_path, container) + except Exception: + extracted = {} + for key, value in extracted.items(): + if key not in tags or not tags[key]: + tags[key] = value + new_base = prepare_base_path( + path_pattern, + track, + unsafe_path=unsafe_path, + fallback_number=fallback_number, + fallback_total=fallback_total, + override_title=tags.get("title"), + override_album=tags.get("album"), + override_album_artist=tags.get("album_artist"), + override_track_artist=tags.get("track_artist"), + override_track_number=tags.get("track_number"), + ) + new_target = new_base.with_suffix(current_path.suffix) + try: + same = new_target.resolve() == current_path.resolve() + except FileNotFoundError: + same = False + if same: + return current_path + new_target.parent.mkdir(parents=True, exist_ok=True) + current_path.replace(new_target) + return new_target + + def download_track( track_info: DownloadableTrack, cover_resolution: int = DEFAULT_COVER_RESOLUTION, @@ -315,6 +555,11 @@ def download_track( embed_cover: bool = False, covers_cache: Optional[dict[int, AlbumCover]] = None, compatibility_level: int = 1, + path_pattern: Optional[Path] = None, + unsafe_path: bool = False, + fallback_number: Optional[int] = None, + fallback_total: Optional[int] = None, + rename_using_tags: bool = False, ): if embed_cover and covers_cache is None: raise RuntimeError("covers_cache isn't provided") @@ -367,19 +612,47 @@ def download_track( download_info = track_info.download_info track_data = api.download_track(client, download_info) - write_via_temporary_file( - track_data, - target_path, - temporary_file_hook=lambda tmp_path: set_tags( + source_tags: dict[str, Optional[Union[str, int]]] = {} + + def tag_hook(tmp_path: Path) -> None: + nonlocal source_tags + try: + source_tags = extract_basic_tags(tmp_path, download_info.file_format.container) + except Exception: + source_tags = {} + set_tags( tmp_path, track, download_info.file_format.container, text_lyrics, cover, compatibility_level, - ), + source_tags=source_tags, + ) + + write_via_temporary_file( + track_data, + target_path, + temporary_file_hook=tag_hook, ) + if rename_using_tags and path_pattern is not None: + try: + renamed_path = retarget_with_tags( + path_pattern=path_pattern, + track=track, + container=download_info.file_format.container, + current_path=target_path, + unsafe_path=unsafe_path, + fallback_number=fallback_number, + fallback_total=fallback_total, + override_tags=source_tags if source_tags else None, + ) + target_path = renamed_path + except Exception: + # Renaming is non-critical; ignore failures to keep download intact + pass + def to_downloadable_track( track: Track, quality: CoreTrackQuality, base_path: Path diff --git a/ymd/webui.py b/ymd/webui.py new file mode 100644 index 0000000..22145a8 --- /dev/null +++ b/ymd/webui.py @@ -0,0 +1,1340 @@ +""" +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()