feat: download tracks with full ID3 metadata
- Add downloads.py: "Artist - Title.mp3" naming, no-overwrite dedupe, configurable download_dir, progress and per-item callbacks. - Save maximum metadata via tags.py (ID3v2.3, mutagen): artists, album and album artist, track/disc, year/date, raw genre, label, duration, explicit flag, cover 600x600, plain and synced lyrics (MTS then LRCLIB), track URLs and Yandex/MTS ids. - TUI/Qt: download current track and queue from the menu, "d" key in lists, folder picker in settings; CLI download gains -d/--dir and --no-tags. - Add mutagen to requirements and document download/metadata in README. - Fix MPRIS update_metadata crashing without dbus-next.
This commit is contained in:
239
tags.py
Normal file
239
tags.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""ID3-теги для скачанных треков: максимум метаданных.
|
||||
|
||||
Пишем ID3v2.3: название, исполнителей, альбом и альбомного исполнителя,
|
||||
позицию трека и диска, год и дату, жанр, лейбл, длительность, ссылку,
|
||||
служебные поля (id в Яндекс.Музыке и MTS, explicit), обложку 600×600 и
|
||||
текст песни (обычный USLT и синхронный SYLT, если он есть).
|
||||
|
||||
Модуль необязательный: без `mutagen` теги просто не пишутся.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from media import track_cover_url
|
||||
from mtsmusic import (
|
||||
USER_AGENT,
|
||||
force_ipv4,
|
||||
lrclib_lyrics,
|
||||
lyrics_response,
|
||||
lyrics_text,
|
||||
)
|
||||
|
||||
try:
|
||||
from mutagen.id3 import (
|
||||
APIC,
|
||||
COMM,
|
||||
SYLT,
|
||||
TALB,
|
||||
TCON,
|
||||
TDAT,
|
||||
TIT2,
|
||||
TLEN,
|
||||
TPE1,
|
||||
TPE2,
|
||||
TPOS,
|
||||
TPUB,
|
||||
TRCK,
|
||||
TXXX,
|
||||
TYER,
|
||||
USLT,
|
||||
WOAR,
|
||||
ID3,
|
||||
)
|
||||
|
||||
HAVE_MUTAGEN = True
|
||||
except Exception: # pragma: no cover - зависит от окружения
|
||||
HAVE_MUTAGEN = False
|
||||
|
||||
log = logging.getLogger("tags")
|
||||
|
||||
COVER_SIZE = 600
|
||||
_LRC_LINE = re.compile(r"\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]")
|
||||
|
||||
|
||||
def _names(items) -> str:
|
||||
return ", ".join(str(item.get("name") or "") for item in items or [])
|
||||
|
||||
|
||||
def fetch_cover(track: dict) -> bytes | None:
|
||||
"""Скачивает обложку 600×600 для встраивания в APIC."""
|
||||
url = track_cover_url(track, COVER_SIZE)
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
force_ipv4()
|
||||
response = requests.get(
|
||||
url, headers={"User-Agent": USER_AGENT}, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
|
||||
def _mts_lyrics(api, track_id: str, fmt: str) -> str:
|
||||
try:
|
||||
response = lyrics_response(api, track_id, fmt)
|
||||
except Exception:
|
||||
return ""
|
||||
if response.status_code != 200:
|
||||
return ""
|
||||
try:
|
||||
text = lyrics_text(api, response)
|
||||
except Exception:
|
||||
return ""
|
||||
if "Подключите подписку" in text:
|
||||
return ""
|
||||
return text.strip()
|
||||
|
||||
|
||||
def fetch_lyrics(api, track: dict) -> tuple[str, str]:
|
||||
"""(обычный текст, синхронный LRC). Пробуем MTS, затем LRCLIB."""
|
||||
track_id = str(track.get("id"))
|
||||
plain = _mts_lyrics(api, track_id, "TEXT")
|
||||
synced = _mts_lyrics(api, track_id, "LRC")
|
||||
if not plain:
|
||||
plain = (lrclib_lyrics(track, want_sync=False) or "").strip()
|
||||
if not synced:
|
||||
synced = (lrclib_lyrics(track, want_sync=True) or "").strip()
|
||||
if synced and not _LRC_LINE.search(synced):
|
||||
synced = ""
|
||||
return plain, synced
|
||||
|
||||
|
||||
def parse_lrc(text: str) -> list[tuple[str, int]]:
|
||||
"""LRC → [(строка, миллисекунды)] для синхронного SYLT."""
|
||||
entries: list[tuple[str, int]] = []
|
||||
for line in text.splitlines():
|
||||
stamps = list(_LRC_LINE.finditer(line))
|
||||
if not stamps:
|
||||
continue
|
||||
content = _LRC_LINE.sub("", line).strip()
|
||||
for stamp in stamps:
|
||||
minutes = int(stamp.group(1))
|
||||
seconds = int(stamp.group(2))
|
||||
fraction = (stamp.group(3) or "0").ljust(3, "0")[:3]
|
||||
millis = (minutes * 60 + seconds) * 1000 + int(fraction)
|
||||
entries.append((content, millis))
|
||||
entries.sort(key=lambda item: item[1])
|
||||
return entries
|
||||
|
||||
|
||||
def _cover_mime(data: bytes) -> str:
|
||||
return "image/png" if data[:8].startswith(b"\x89PNG") else "image/jpeg"
|
||||
|
||||
|
||||
def write_tags(
|
||||
track: dict,
|
||||
path: Path,
|
||||
plain_lyrics: str = "",
|
||||
synced_lyrics: str = "",
|
||||
cover: bytes | None = None,
|
||||
) -> None:
|
||||
"""Проставляет ID3v2.3-теги в уже скачанном файле."""
|
||||
if not HAVE_MUTAGEN:
|
||||
return
|
||||
album = (track.get("albums") or [{}])[0]
|
||||
artist = _names(track.get("artists"))
|
||||
album_artist = _names(album.get("artists")) or artist
|
||||
track_id = str(track.get("id") or "")
|
||||
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except Exception:
|
||||
tags = ID3()
|
||||
|
||||
def add(frame) -> None:
|
||||
tags.add(frame)
|
||||
|
||||
if track.get("title"):
|
||||
add(TIT2(encoding=3, text=str(track["title"])))
|
||||
if artist:
|
||||
add(TPE1(encoding=3, text=artist))
|
||||
if album_artist:
|
||||
add(TPE2(encoding=3, text=album_artist))
|
||||
if album.get("title"):
|
||||
add(TALB(encoding=3, text=str(album["title"])))
|
||||
|
||||
position = album.get("trackPosition") or {}
|
||||
index = position.get("index")
|
||||
if index:
|
||||
count = album.get("trackCount")
|
||||
add(TRCK(encoding=3, text=f"{index}/{count}" if count else str(index)))
|
||||
volume = position.get("volume")
|
||||
if volume:
|
||||
add(TPOS(encoding=3, text=str(volume)))
|
||||
|
||||
if album.get("year"):
|
||||
add(TYER(encoding=3, text=str(album["year"])))
|
||||
release = str(album.get("releaseDate") or "")
|
||||
if release[:10].count("-") == 2:
|
||||
_year, month, day = release[:10].split("-")
|
||||
add(TDAT(encoding=3, text=f"{day}{month}")) # формат DDMM
|
||||
|
||||
if album.get("genre"):
|
||||
add(TCON(encoding=3, text=str(album["genre"])))
|
||||
labels = _names(album.get("labels"))
|
||||
if labels:
|
||||
add(TPUB(encoding=3, text=labels))
|
||||
if track.get("durationMs"):
|
||||
add(TLEN(encoding=3, text=str(int(track["durationMs"]))))
|
||||
|
||||
if track_id:
|
||||
add(COMM(
|
||||
encoding=3,
|
||||
lang="XXX",
|
||||
desc="",
|
||||
text=f"https://music.yandex.ru/track/{track_id}",
|
||||
))
|
||||
add(WOAR(url=f"https://music.yandex.ru/track/{track_id}"))
|
||||
|
||||
def add_text(desc: str, value) -> None:
|
||||
if value not in (None, ""):
|
||||
add(TXXX(encoding=3, desc=desc, text=str(value)))
|
||||
|
||||
add_text("YANDEX_MUSIC_TRACK_ID", track.get("id"))
|
||||
add_text("YANDEX_MUSIC_REAL_ID", track.get("realId"))
|
||||
add_text("YANDEX_MUSIC_ALBUM_ID", album.get("id"))
|
||||
add_text("MTS_MUSIC_SOURCE", track.get("trackSource"))
|
||||
add_text("YANDEX_MUSIC_TYPE", track.get("type"))
|
||||
if track.get("contentWarning") == "explicit":
|
||||
add_text("ITUNESADVISORY", 1)
|
||||
|
||||
if cover:
|
||||
add(APIC(
|
||||
encoding=3,
|
||||
mime=_cover_mime(cover),
|
||||
type=3,
|
||||
desc="Cover",
|
||||
data=cover,
|
||||
))
|
||||
if plain_lyrics:
|
||||
add(USLT(encoding=3, lang="XXX", desc="", text=plain_lyrics))
|
||||
if synced_lyrics:
|
||||
entries = parse_lrc(synced_lyrics)
|
||||
if entries:
|
||||
add(SYLT(
|
||||
encoding=3,
|
||||
lang="XXX",
|
||||
format=2,
|
||||
type=1,
|
||||
desc="",
|
||||
text=entries,
|
||||
))
|
||||
|
||||
tags.save(path, v2_version=3)
|
||||
|
||||
|
||||
def tag_track(api, track: dict, path: Path) -> None:
|
||||
"""Собирает метаданные (текст, обложка) и пишет их в файл."""
|
||||
if not HAVE_MUTAGEN:
|
||||
return
|
||||
plain, synced = fetch_lyrics(api, track)
|
||||
cover = fetch_cover(track)
|
||||
write_tags(track, Path(path), plain, synced, cover)
|
||||
Reference in New Issue
Block a user