Add web UI and refresh project docs
This commit is contained in:
parent
7c9d503bc6
commit
613df10217
10 changed files with 1966 additions and 297 deletions
17
.dockerignore
Normal file
17
.dockerignore
Normal file
|
|
@ -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
|
||||||
18
.env.example
Normal file
18
.env.example
Normal file
|
|
@ -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
|
||||||
172
.gitignore
vendored
172
.gitignore
vendored
|
|
@ -1,160 +1,42 @@
|
||||||
# Byte-compiled / optimized / DLL files
|
# Local environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.python-version
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
# C extensions
|
# Build artifacts
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
|
||||||
dist/
|
dist/
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
pip-wheel-metadata/
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
# Tooling caches
|
||||||
# Usually these files are written by a python script from a template
|
.mypy_cache/
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
.pytest_cache/
|
||||||
*.manifest
|
.ruff_cache/
|
||||||
*.spec
|
.pyre/
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
.tox/
|
||||||
.nox/
|
.nox/
|
||||||
.coverage
|
.coverage
|
||||||
.coverage.*
|
.coverage.*
|
||||||
.cache
|
htmlcov/
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# Translations
|
# IDEs and OS files
|
||||||
*.mo
|
.idea/
|
||||||
*.pot
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Django stuff:
|
# Project runtime files
|
||||||
|
downloads/
|
||||||
*.log
|
*.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/
|
|
||||||
|
|
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
330
README.md
330
README.md
|
|
@ -1,154 +1,244 @@
|
||||||
# yandex-music-downloader
|
# yandex-music-downloader
|
||||||
|
|
||||||
> Внимание! В версии v3 был изменен способ авторизации и некоторые
|
Загрузчик музыки из Яндекс Музыки с двумя интерфейсами:
|
||||||
> аргументы. Смотрите [MIGRATION.md](MIGRATION.md) для получения информации
|
|
||||||
> об изменениях
|
|
||||||
|
|
||||||
## Содержание
|
- `CLI` для точечных и пакетных загрузок
|
||||||
1. [О программе](#О-программе)
|
- `Web UI` на FastAPI для загрузки, экспорта дискографии и работы с лайками
|
||||||
2. [Установка](#Установка)
|
|
||||||
3. [Получение данных для авторизации](#Получение-данных-для-авторизации)
|
|
||||||
4. [Примеры использования](#Примеры-использования)
|
|
||||||
5. [Использование](#Использование)
|
|
||||||
6. [Уровни совместимости](#Уровни-совместимости)
|
|
||||||
7. [Спасибо](#Спасибо)
|
|
||||||
8. [Дисклеймер](#Дисклеймер)
|
|
||||||
|
|
||||||
## О программе
|
Проект поддерживает загрузку в AAC и FLAC, сохранение тегов, обложек и текстов, а также запуск через Docker.
|
||||||
Загрузчик, созданный вследствие наличия *фатального недостатка* в проекте [yandex-music-download](https://github.com/kaimi-io/yandex-music-download).
|
|
||||||
|
|
||||||
### Возможности
|
> В `v3` изменился способ авторизации и часть аргументов. Если обновляетесь со старой версии, смотрите [MIGRATION.md](MIGRATION.md).
|
||||||
- Возможность загрузки:
|
|
||||||
- Всех треков исполнителя
|
## Возможности
|
||||||
- Всех треков из альбома
|
|
||||||
- Всех треков из плейлиста
|
- Скачать все треки артиста, альбом, плейлист или отдельный трек
|
||||||
- Отдельного трека
|
- Загружать в качестве `AAC 64`, `AAC 192` или `FLAC`
|
||||||
- Загрузка всех метаданных трека/альбома:
|
- Встраивать обложку в файл
|
||||||
- Номер трека
|
- Сохранять тексты песен в формате `text` или `lrc`
|
||||||
- Номер диска
|
- Настраивать путь сохранения через шаблон
|
||||||
- Название трека
|
- Пропускать уже скачанные треки
|
||||||
- Исполнитель
|
- Отдельно настраивать совместимость тегов
|
||||||
- Дополнительные исполнители
|
- Работать через веб-панель без ручного ввода токена
|
||||||
- Дата выпуска альбома
|
- Смотреть лайки в веб-панели
|
||||||
- Обложка альбома
|
- Скачивать из веб-панели:
|
||||||
- Название альбома
|
- все понравившиеся треки
|
||||||
- Текст песни (при использовании флага `--add-lyrics`)
|
- альбомы артистов из лайков
|
||||||
- Загрузка треков в lossless качестве
|
- дискографию артиста в 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 "<TOKEN>" \
|
||||||
|
--quality 2 \
|
||||||
|
--url "https://music.yandex.ru/artist/208167"
|
||||||
|
```
|
||||||
|
|
||||||
|
Скачать альбом и сохранить тексты в `LRC`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yandex-music-downloader \
|
||||||
|
--token "<TOKEN>" \
|
||||||
|
--quality 1 \
|
||||||
|
--lyrics-format lrc \
|
||||||
|
--url "https://music.yandex.ru/album/294912"
|
||||||
|
```
|
||||||
|
|
||||||
|
Скачать плейлист в свою папку:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yandex-music-downloader \
|
||||||
|
--token "<TOKEN>" \
|
||||||
|
--playlist-id "user/playlists/123" \
|
||||||
|
--dir "./downloads" \
|
||||||
|
--skip-existing
|
||||||
|
```
|
||||||
|
|
||||||
|
Скачать один трек:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yandex-music-downloader \
|
||||||
|
--token "<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
|
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="<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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Использование
|
2. Укажите токен и при необходимости остальные параметры.
|
||||||
```
|
|
||||||
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 <ID исполнителя> | --album-id <ID альбома> | --track-id <ID трека> | --playlist-id <владелец плейлиста>/<тип плейлиста> | -u URL)
|
|
||||||
[--unsafe-path] [--dir <Папка>]
|
|
||||||
[--path-pattern <Паттерн>] --token <Токен>
|
|
||||||
|
|
||||||
Загрузчик музыки с сервиса Яндекс.Музыка
|
3. Запустите контейнер:
|
||||||
|
|
||||||
options:
|
```bash
|
||||||
-h, --help show this help message and exit
|
docker compose up --build -d
|
||||||
|
|
||||||
Общие параметры:
|
|
||||||
--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 <ID исполнителя>
|
|
||||||
--album-id <ID альбома>
|
|
||||||
--track-id <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 для способов получения
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Уровни совместимости
|
4. Откройте веб-панель:
|
||||||
Уровень совместимости позволяет отойти от стандарта тегов, которого
|
|
||||||
придерживается библиотека mutagen. Сделано это для поддержки большего
|
|
||||||
количества музыкальных плееров. Ниже подробно описаны все уровни.
|
|
||||||
|
|
||||||
### 0
|
```text
|
||||||
Стандартные теги mutagen.
|
http://localhost:8228
|
||||||
|
```
|
||||||
|
|
||||||
### 1
|
Загрузка по умолчанию сохраняется в локальную папку `./downloads`.
|
||||||
Затрагиваемые форматы: `m4a`
|
|
||||||
|
|
||||||
- Теги с несколькими значениями (`\xa9ART` и `aART`) устанвливаются с
|
## Переменные окружения для Web UI / Docker
|
||||||
разделителем `;`. Пример: `Artist1; Artist2; Artist3`
|
|
||||||
|
|
||||||
|
Полный набор есть в [.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)
|
- Разработчикам проекта [yandex-music-api](https://github.com/MarshalX/yandex-music-api)
|
||||||
- @ArtemBay за [скрипт](https://github.com/MarshalX/yandex-music-api/issues/656#issuecomment-2306542725) получения ссылки на загрузку в lossless качестве
|
- @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)
|
- @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) при загрузке сборников
|
||||||
|
|
||||||
|
|
||||||
## Дисклеймер
|
## Дисклеймер
|
||||||
Данный проект является независимой разработкой и никак не связан с компанией Яндекс.
|
|
||||||
|
Проект не связан с Яндексом и является независимой разработкой.
|
||||||
|
|
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -15,8 +15,12 @@ dependencies = [
|
||||||
"yandex-music @ https://github.com/llistochek/yandex-music-api/archive/9623fbca7704f47766614efe51d66c9fd496714c.zip",
|
"yandex-music @ https://github.com/llistochek/yandex-music-api/archive/9623fbca7704f47766614efe51d66c9fd496714c.zip",
|
||||||
"mutagen>=1.47.0",
|
"mutagen>=1.47.0",
|
||||||
"StrEnum",
|
"StrEnum",
|
||||||
"pycryptodome"
|
"pycryptodome",
|
||||||
|
"fastapi>=0.110.0",
|
||||||
|
"uvicorn[standard]>=0.23.0",
|
||||||
|
"pydantic>=2.7.0"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
yandex-music-downloader = "ymd.cli:main"
|
yandex-music-downloader = "ymd.cli:main"
|
||||||
|
yandex-music-downloader-web = "ymd.webui:main"
|
||||||
|
|
|
||||||
11
ymd/cli.py
11
ymd/cli.py
|
|
@ -316,9 +316,11 @@ def main():
|
||||||
progress_status = ""
|
progress_status = ""
|
||||||
covers_cache = {}
|
covers_cache = {}
|
||||||
for track in result_tracks:
|
for track in result_tracks:
|
||||||
if total_track_count:
|
|
||||||
track_counter += 1
|
track_counter += 1
|
||||||
|
if total_track_count:
|
||||||
progress_status = f"[{track_counter}/{total_track_count}] "
|
progress_status = f"[{track_counter}/{total_track_count}] "
|
||||||
|
else:
|
||||||
|
progress_status = ""
|
||||||
|
|
||||||
if not track.available:
|
if not track.available:
|
||||||
print(f"{progress_status}Трек {track.title} не доступен для скачивания")
|
print(f"{progress_status}Трек {track.title} не доступен для скачивания")
|
||||||
|
|
@ -328,6 +330,8 @@ def main():
|
||||||
args.path_pattern,
|
args.path_pattern,
|
||||||
track,
|
track,
|
||||||
args.unsafe_path,
|
args.unsafe_path,
|
||||||
|
fallback_number=track_counter,
|
||||||
|
fallback_total=total_track_count,
|
||||||
)
|
)
|
||||||
if args.skip_existing:
|
if args.skip_existing:
|
||||||
if any(
|
if any(
|
||||||
|
|
@ -353,6 +357,11 @@ def main():
|
||||||
cover_resolution=args.cover_resolution,
|
cover_resolution=args.cover_resolution,
|
||||||
covers_cache=covers_cache,
|
covers_cache=covers_cache,
|
||||||
compatibility_level=args.compatibility_level,
|
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:
|
if args.delay > 0:
|
||||||
time.sleep(args.delay)
|
time.sleep(args.delay)
|
||||||
|
|
|
||||||
331
ymd/core.py
331
ymd/core.py
|
|
@ -122,16 +122,59 @@ def init_client(
|
||||||
|
|
||||||
|
|
||||||
def full_title(obj: YandexMusicModel) -> str:
|
def full_title(obj: YandexMusicModel) -> str:
|
||||||
result = obj["title"]
|
"""Return title with optional version; be tolerant to missing keys."""
|
||||||
if result is None:
|
title = getattr(obj, "title", None)
|
||||||
|
if title is None:
|
||||||
|
try:
|
||||||
|
title = obj["title"]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
title = None
|
||||||
|
if not title:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
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 ""
|
return ""
|
||||||
if version := obj["version"]:
|
|
||||||
result += f" ({version})"
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_base_path(
|
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:
|
||||||
path_str = str(path_pattern)
|
path_str = str(path_pattern)
|
||||||
album = None
|
album = None
|
||||||
|
|
@ -140,34 +183,83 @@ def prepare_base_path(
|
||||||
track_position = None
|
track_position = None
|
||||||
if albums := track.albums:
|
if albums := track.albums:
|
||||||
album = albums[0]
|
album = albums[0]
|
||||||
track_position = album.track_position
|
|
||||||
if artists := album.artists:
|
if artists := album.artists:
|
||||||
album_artist = artists[0]
|
album_artist = artists[0]
|
||||||
|
# позиция трека корректнее брать из самого трека
|
||||||
|
track_position = getattr(track, "track_position", None) or track_position
|
||||||
if artists := track.artists:
|
if artists := track.artists:
|
||||||
track_artist = artists[0]
|
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]] = {
|
repl_dict: dict[str, Union[str, int, None]] = {
|
||||||
"#number-padded": str(track_position.index).zfill(len(str(album.track_count)))
|
"#number-padded": number_padded,
|
||||||
if track_position and album
|
"#album-artist": album_artist_name,
|
||||||
else None,
|
"#track-artist": track_artist_name,
|
||||||
"#album-artist": album_artist.name if album_artist else None,
|
|
||||||
"#track-artist": track_artist.name if track_artist else None,
|
|
||||||
"#artist-id": track_artist.id if track_artist else None,
|
"#artist-id": track_artist.id if track_artist else None,
|
||||||
"#album-id": album.id if album else None,
|
"#album-id": album.id if album else None,
|
||||||
"#track-id": track.id,
|
"#track-id": track.id,
|
||||||
"#number": track_position.index if track_position else None,
|
"#number": number_value,
|
||||||
"#title": full_title(track),
|
"#title": track_title,
|
||||||
"#album": full_title(album) if album else None,
|
"#album": album_title,
|
||||||
"#year": album.year if album else None,
|
"#year": album.year if album else None,
|
||||||
}
|
}
|
||||||
for placeholder, replacement in repl_dict.items():
|
for placeholder, replacement in repl_dict.items():
|
||||||
replacement = str(replacement)
|
if replacement is None:
|
||||||
if not unsafe_path:
|
replacement = ""
|
||||||
clear_re = SAFE_PATH_CLEAR_RE
|
replacement = sanitize_path_component(str(replacement), unsafe_path)
|
||||||
else:
|
if not replacement:
|
||||||
clear_re = UNSAFE_PATH_CLEAR_RE
|
replacement = sanitize_path_component("untitled", unsafe_path)
|
||||||
replacement = clear_re.sub("_", replacement)
|
|
||||||
path_str = path_str.replace(placeholder, replacement)
|
path_str = path_str.replace(placeholder, replacement)
|
||||||
path = Path(path_str)
|
path = Path(path_str)
|
||||||
|
if not unsafe_path:
|
||||||
|
path = Path(
|
||||||
|
*(
|
||||||
|
re.sub(r"_+", "_", part.replace(" ", "_"))
|
||||||
|
for part in path.parts
|
||||||
|
)
|
||||||
|
)
|
||||||
trimmed_parts = [
|
trimmed_parts = [
|
||||||
part
|
part
|
||||||
if len(part) <= MAX_FILE_NAME_LENGTH_WITHOUT_SUFFIX
|
if len(part) <= MAX_FILE_NAME_LENGTH_WITHOUT_SUFFIX
|
||||||
|
|
@ -184,25 +276,57 @@ def set_tags(
|
||||||
lyrics: Optional[str],
|
lyrics: Optional[str],
|
||||||
album_cover: Optional[AlbumCover],
|
album_cover: Optional[AlbumCover],
|
||||||
compatibility_level: int,
|
compatibility_level: int,
|
||||||
|
source_tags: Optional[dict[str, Optional[Union[str, int]]]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
file_type = CONTAINER_MUTAGEN_MAPPING.get(container)
|
file_type = CONTAINER_MUTAGEN_MAPPING.get(container)
|
||||||
if file_type is None:
|
if file_type is None:
|
||||||
raise ValueError(f"Unknown container: {container}")
|
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)
|
tag = file_type(path)
|
||||||
album = track.albums[0] if track.albums else Album()
|
album = track.albums[0] if track.albums else Album()
|
||||||
album_title = full_title(album)
|
album_title = choose_first(
|
||||||
track_title = full_title(track)
|
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]
|
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]
|
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
|
genre = None
|
||||||
if album.genre:
|
if album.genre:
|
||||||
genre = album.genre
|
genre = album.genre
|
||||||
track_number = None
|
track_number = None
|
||||||
disc_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
|
track_number = position.index
|
||||||
disc_number = position.volume
|
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
|
iso8601_release_date = None
|
||||||
release_year: Optional[str] = None
|
release_year: Optional[str] = None
|
||||||
if album.release_date is not None:
|
if album.release_date is not None:
|
||||||
|
|
@ -308,6 +432,122 @@ def set_tags(
|
||||||
tag.save()
|
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(
|
def download_track(
|
||||||
track_info: DownloadableTrack,
|
track_info: DownloadableTrack,
|
||||||
cover_resolution: int = DEFAULT_COVER_RESOLUTION,
|
cover_resolution: int = DEFAULT_COVER_RESOLUTION,
|
||||||
|
|
@ -315,6 +555,11 @@ def download_track(
|
||||||
embed_cover: bool = False,
|
embed_cover: bool = False,
|
||||||
covers_cache: Optional[dict[int, AlbumCover]] = None,
|
covers_cache: Optional[dict[int, AlbumCover]] = None,
|
||||||
compatibility_level: int = 1,
|
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:
|
if embed_cover and covers_cache is None:
|
||||||
raise RuntimeError("covers_cache isn't provided")
|
raise RuntimeError("covers_cache isn't provided")
|
||||||
|
|
@ -367,19 +612,47 @@ def download_track(
|
||||||
download_info = track_info.download_info
|
download_info = track_info.download_info
|
||||||
track_data = api.download_track(client, download_info)
|
track_data = api.download_track(client, download_info)
|
||||||
|
|
||||||
write_via_temporary_file(
|
source_tags: dict[str, Optional[Union[str, int]]] = {}
|
||||||
track_data,
|
|
||||||
target_path,
|
def tag_hook(tmp_path: Path) -> None:
|
||||||
temporary_file_hook=lambda tmp_path: set_tags(
|
nonlocal source_tags
|
||||||
|
try:
|
||||||
|
source_tags = extract_basic_tags(tmp_path, download_info.file_format.container)
|
||||||
|
except Exception:
|
||||||
|
source_tags = {}
|
||||||
|
set_tags(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
track,
|
track,
|
||||||
download_info.file_format.container,
|
download_info.file_format.container,
|
||||||
text_lyrics,
|
text_lyrics,
|
||||||
cover,
|
cover,
|
||||||
compatibility_level,
|
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(
|
def to_downloadable_track(
|
||||||
track: Track, quality: CoreTrackQuality, base_path: Path
|
track: Track, quality: CoreTrackQuality, base_path: Path
|
||||||
|
|
|
||||||
1340
ymd/webui.py
Normal file
1340
ymd/webui.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue