Перенос из vespaserver:/storage/scripts/ перед удалением оригиналов. Скрипты разовые — клонируются по мере необходимости.
924 lines
No EOL
34 KiB
Python
924 lines
No EOL
34 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Записывает теги и обложки в аудиофайлы аниме саундтреков.
|
||
|
||
Теги:
|
||
ANIME_SOURCE = название аниме (из директории)
|
||
ANIME_SOURCE_FULL = "название аниме, тип"
|
||
COMMENT = "название аниме, тип"
|
||
|
||
Обложка:
|
||
Определяет сезон/тип из имени файла (TV-2, Movie 3, OVA и т.д.)
|
||
Ищет соответствующий сезон через Jikan API (MyAnimeList)
|
||
Если не находит конкретный сезон — берёт обложку основного аниме
|
||
Обрезает до квадрата (центрированный кроп)
|
||
Кешируется в директории аниме (_raw.jpg и .jpg)
|
||
|
||
Использование:
|
||
python3 tag_anime.py test
|
||
python3 tag_anime.py run --dry-run /path/to/music
|
||
python3 tag_anime.py run /path/to/music
|
||
python3 tag_anime.py run --no-covers /path/to/music
|
||
python3 tag_anime.py run --proxy "socks5h://127.0.0.1:1080" /path/to/music
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import sys
|
||
import time
|
||
import argparse
|
||
from pathlib import Path
|
||
from io import BytesIO
|
||
|
||
try:
|
||
import requests
|
||
except ImportError:
|
||
print("pip install requests")
|
||
sys.exit(1)
|
||
|
||
try:
|
||
from PIL import Image
|
||
except ImportError:
|
||
print("pip install Pillow")
|
||
sys.exit(1)
|
||
|
||
try:
|
||
import mutagen
|
||
from mutagen.flac import FLAC, Picture
|
||
from mutagen.mp3 import MP3
|
||
from mutagen.oggvorbis import OggVorbis
|
||
from mutagen.oggopus import OggOpus
|
||
from mutagen.mp4 import MP4, MP4Cover
|
||
from mutagen.id3 import TXXX, APIC, COMM
|
||
except ImportError:
|
||
print("pip install mutagen")
|
||
sys.exit(1)
|
||
|
||
AUDIO_EXTENSIONS = {".flac", ".mp3", ".ogg", ".opus", ".m4a"}
|
||
|
||
JIKAN_SEARCH_URL = "https://api.jikan.moe/v4/anime"
|
||
JIKAN_RATE_LIMIT = 1.5
|
||
|
||
_last_jikan_request = 0.0
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Обрезка изображения до квадрата
|
||
# ─────────────────────────────────────────────
|
||
|
||
def crop_to_square(image_data: bytes) -> bytes:
|
||
"""
|
||
Обрезает изображение до квадрата (центрированный кроп).
|
||
Возвращает JPEG bytes.
|
||
"""
|
||
try:
|
||
img = Image.open(BytesIO(image_data))
|
||
w, h = img.size
|
||
|
||
if w == h:
|
||
# Уже квадратное — просто конвертируем в JPEG
|
||
buf = BytesIO()
|
||
img = img.convert("RGB")
|
||
img.save(buf, format="JPEG", quality=95)
|
||
return buf.getvalue()
|
||
|
||
# Центрированный кроп
|
||
side = min(w, h)
|
||
left = (w - side) // 2
|
||
top = (h - side) // 2
|
||
right = left + side
|
||
bottom = top + side
|
||
|
||
img = img.crop((left, top, right, bottom))
|
||
img = img.convert("RGB")
|
||
|
||
buf = BytesIO()
|
||
img.save(buf, format="JPEG", quality=95)
|
||
|
||
print(f" ✂ Обрезано: {w}x{h} → {side}x{side}")
|
||
return buf.getvalue()
|
||
|
||
except Exception as e:
|
||
print(f" ⚠ Ошибка обрезки: {e}")
|
||
return image_data
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Извлечение типа из имени файла
|
||
# ─────────────────────────────────────────────
|
||
|
||
def extract_type(filename: str) -> str:
|
||
"""
|
||
Берёт всё между '] ' и ' - '
|
||
[AniTousen] TV-1 ED01 - I'm ALIVE! (BECCA).mp3 → "TV-1 ED01"
|
||
"""
|
||
stem = Path(filename).stem
|
||
|
||
bracket_end = stem.find('] ')
|
||
if bracket_end < 0:
|
||
return ""
|
||
|
||
after_bracket = stem[bracket_end + 2:]
|
||
|
||
dash_pos = after_bracket.find(' - ')
|
||
if dash_pos < 0:
|
||
return ""
|
||
|
||
return after_bracket[:dash_pos].strip()
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Определение сезона/типа из строки типа
|
||
# ─────────────────────────────────────────────
|
||
|
||
def parse_media_info(track_type: str) -> dict:
|
||
"""
|
||
Парсит строку типа и возвращает информацию о медиа.
|
||
|
||
Примеры:
|
||
"TV-1 ED01" → {"media": "TV", "season": 1}
|
||
"TV-2 OP01" → {"media": "TV", "season": 2}
|
||
"TV ED01" → {"media": "TV", "season": None}
|
||
"Movie 1 ED01" → {"media": "Movie", "season": 1}
|
||
"OVA ED01" → {"media": "OVA", "season": None}
|
||
"ONA ED01" → {"media": "ONA", "season": None}
|
||
"Special ED01" → {"media": "Special", "season": None}
|
||
"Game 2 OP01" → {"media": "Game", "season": 2}
|
||
"""
|
||
if not track_type:
|
||
return {"media": None, "season": None}
|
||
|
||
m = re.match(
|
||
r'^(TV|Movie|OVA|ONA|Special|Game)(?:[-\s](\d+))?',
|
||
track_type,
|
||
re.IGNORECASE
|
||
)
|
||
|
||
if not m:
|
||
return {"media": None, "season": None}
|
||
|
||
media = m.group(1)
|
||
if media.lower() == "tv":
|
||
media = "TV"
|
||
elif media.lower() == "ova":
|
||
media = "OVA"
|
||
elif media.lower() == "ona":
|
||
media = "ONA"
|
||
else:
|
||
media = media.capitalize()
|
||
|
||
season = int(m.group(2)) if m.group(2) else None
|
||
|
||
return {"media": media, "season": season}
|
||
|
||
|
||
def build_search_queries(anime_name: str, media_info: dict) -> list:
|
||
"""
|
||
Формирует список поисковых запросов от конкретного к общему.
|
||
"""
|
||
queries = []
|
||
media = media_info.get("media")
|
||
season = media_info.get("season")
|
||
|
||
if media and season and season > 1:
|
||
ordinals = {2: "2nd", 3: "3rd", 4: "4th", 5: "5th", 6: "6th"}
|
||
roman = {2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI"}
|
||
|
||
if media == "TV":
|
||
ordinal = ordinals.get(season, f"{season}th")
|
||
queries.append(f"{anime_name} {ordinal} Season")
|
||
queries.append(f"{anime_name} Season {season}")
|
||
queries.append(f"{anime_name} {season}")
|
||
if season in roman:
|
||
queries.append(f"{anime_name} {roman[season]}")
|
||
|
||
elif media == "Movie":
|
||
queries.append(f"{anime_name} Movie {season}")
|
||
queries.append(f"{anime_name} Movie")
|
||
|
||
elif media == "Game":
|
||
queries.append(f"{anime_name} Game {season}")
|
||
queries.append(f"{anime_name} Game")
|
||
|
||
else:
|
||
queries.append(f"{anime_name} {media} {season}")
|
||
queries.append(f"{anime_name} {media}")
|
||
|
||
elif media and media != "TV":
|
||
queries.append(f"{anime_name} {media}")
|
||
|
||
queries.append(anime_name)
|
||
|
||
seen = set()
|
||
unique = []
|
||
for q in queries:
|
||
if q not in seen:
|
||
seen.add(q)
|
||
unique.append(q)
|
||
|
||
return unique
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Поиск и загрузка обложки
|
||
# ─────────────────────────────────────────────
|
||
|
||
def jikan_rate_limit():
|
||
"""Соблюдать лимит запросов к Jikan API."""
|
||
global _last_jikan_request
|
||
now = time.time()
|
||
elapsed = now - _last_jikan_request
|
||
if elapsed < JIKAN_RATE_LIMIT:
|
||
time.sleep(JIKAN_RATE_LIMIT - elapsed)
|
||
_last_jikan_request = time.time()
|
||
|
||
|
||
def fetch_cover_url(query: str) -> str:
|
||
"""Найти URL обложки аниме через Jikan API."""
|
||
jikan_rate_limit()
|
||
|
||
try:
|
||
resp = requests.get(
|
||
JIKAN_SEARCH_URL,
|
||
params={"q": query, "limit": 5},
|
||
timeout=15,
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
|
||
results = data.get("data", [])
|
||
if not results:
|
||
return ""
|
||
|
||
best = results[0]
|
||
query_lower = query.lower()
|
||
|
||
for entry in results:
|
||
title = (entry.get("title", "") or "").lower()
|
||
title_en = (entry.get("title_english", "") or "").lower()
|
||
|
||
if query_lower == title or query_lower == title_en:
|
||
best = entry
|
||
break
|
||
|
||
images = best.get("images", {})
|
||
jpg = images.get("jpg", {})
|
||
|
||
for key in ("large_image_url", "image_url"):
|
||
url = jpg.get(key, "")
|
||
if url:
|
||
return url
|
||
|
||
return ""
|
||
|
||
except Exception as e:
|
||
print(f" ⚠ Jikan API ошибка: {e}")
|
||
return ""
|
||
|
||
|
||
def search_cover_with_fallback(anime_name: str, media_info: dict) -> tuple:
|
||
"""
|
||
Ищет обложку, пробуя запросы от конкретного к общему.
|
||
Возвращает (url, matched_query) или ("", "").
|
||
"""
|
||
queries = build_search_queries(anime_name, media_info)
|
||
|
||
for query in queries:
|
||
print(f" 🔍 Поиск: \"{query}\"")
|
||
url = fetch_cover_url(query)
|
||
if url:
|
||
print(f" ✓ Найдено по запросу: \"{query}\"")
|
||
return url, query
|
||
|
||
return "", ""
|
||
|
||
|
||
def download_image(url: str) -> bytes:
|
||
"""Скачать изображение по URL."""
|
||
try:
|
||
resp = requests.get(url, timeout=15)
|
||
resp.raise_for_status()
|
||
return resp.content
|
||
except Exception as e:
|
||
print(f" ⚠ Ошибка загрузки обложки: {e}")
|
||
return b""
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Кеш обложек
|
||
# ─────────────────────────────────────────────
|
||
|
||
def get_cache_base(media_info: dict) -> str:
|
||
"""
|
||
Базовое имя кеша без расширения.
|
||
|
||
cover — базовая обложка
|
||
cover_TV-2 — TV сезон 2
|
||
cover_Movie — Movie без номера
|
||
cover_Movie-1 — Movie 1
|
||
cover_OVA — OVA
|
||
"""
|
||
media = media_info.get("media")
|
||
season = media_info.get("season")
|
||
|
||
if not media or (media == "TV" and not season):
|
||
return "cover"
|
||
|
||
if season:
|
||
return f"cover_{media}-{season}"
|
||
else:
|
||
return f"cover_{media}"
|
||
|
||
|
||
def is_square(image_data: bytes) -> bool:
|
||
"""Проверить, квадратное ли изображение."""
|
||
try:
|
||
img = Image.open(BytesIO(image_data))
|
||
w, h = img.size
|
||
return w == h
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def ensure_cropped(image_data: bytes, cache_path: Path, dry_run: bool) -> bytes:
|
||
"""
|
||
Если изображение не квадратное — обрезать и перезаписать кеш.
|
||
Перед перезаписью сохранить оригинал в _raw.
|
||
"""
|
||
if not image_data:
|
||
return b""
|
||
|
||
if is_square(image_data):
|
||
return image_data
|
||
|
||
# Сохранить оригинал как _raw перед перезаписью
|
||
raw_path = cache_path.with_name(
|
||
cache_path.stem + "_raw" + cache_path.suffix
|
||
)
|
||
|
||
if not dry_run and not raw_path.exists():
|
||
try:
|
||
raw_path.write_bytes(image_data)
|
||
print(f" 💾 Оригинал сохранён: {raw_path.name}")
|
||
except Exception as e:
|
||
print(f" ⚠ Не удалось сохранить оригинал: {e}")
|
||
|
||
cropped = crop_to_square(image_data)
|
||
|
||
if not dry_run:
|
||
try:
|
||
cache_path.write_bytes(cropped)
|
||
print(f" 💾 Перезаписан обрезанным: {cache_path.name}")
|
||
except Exception as e:
|
||
print(f" ⚠ Не удалось перезаписать: {e}")
|
||
|
||
return cropped
|
||
|
||
|
||
def get_cover(anime_name: str, anime_dir: Path, media_info: dict,
|
||
dry_run: bool = False) -> bytes:
|
||
"""
|
||
Получить обложку (обрезанную до квадрата).
|
||
|
||
Логика кеша:
|
||
1. Есть cover_XX.jpg → проверить квадрат → если нет — обрезать, перезаписать
|
||
2. Есть cover_XX_raw.jpg → обрезать, сохранить .jpg
|
||
3. Нет ничего → скачать, сохранить _raw.jpg + обрезанный .jpg
|
||
4. Fallback на cover.jpg / cover_raw.jpg
|
||
"""
|
||
cache_base = get_cache_base(media_info)
|
||
cache_cropped = anime_dir / f"{cache_base}.jpg"
|
||
cache_raw = anime_dir / f"{cache_base}_raw.jpg"
|
||
|
||
fallback_base = "cover"
|
||
fallback_cropped = anime_dir / f"{fallback_base}.jpg"
|
||
fallback_raw = anime_dir / f"{fallback_base}_raw.jpg"
|
||
|
||
# 1. Кеш .jpg существует → проверить и при необходимости обрезать
|
||
if cache_cropped.exists() and cache_cropped.stat().st_size > 0:
|
||
print(f" 🖼 Обложка из кеша: {cache_cropped.name}")
|
||
data = cache_cropped.read_bytes()
|
||
return ensure_cropped(data, cache_cropped, dry_run)
|
||
|
||
# 2. Есть _raw → обрезать
|
||
if cache_raw.exists() and cache_raw.stat().st_size > 0:
|
||
print(f" 🖼 Найден raw кеш: {cache_raw.name}, обрезаю...")
|
||
raw_data = cache_raw.read_bytes()
|
||
cropped = crop_to_square(raw_data)
|
||
if not dry_run:
|
||
try:
|
||
cache_cropped.write_bytes(cropped)
|
||
print(f" 💾 Сохранён: {cache_cropped.name}")
|
||
except Exception as e:
|
||
print(f" ⚠ Не удалось сохранить: {e}")
|
||
return cropped
|
||
|
||
# 3. Скачать через API (только если не базовый запрос)
|
||
is_base = (cache_base == fallback_base)
|
||
|
||
if not is_base:
|
||
print(f" 🔍 Поиск обложки: {anime_name} "
|
||
f"[{media_info.get('media', '')} "
|
||
f"{media_info.get('season', '') or ''}]")
|
||
url, matched = search_cover_with_fallback(anime_name, media_info)
|
||
|
||
if url:
|
||
print(f" 🌐 Скачиваю: {url}")
|
||
raw_data = download_image(url)
|
||
|
||
if raw_data:
|
||
cropped = crop_to_square(raw_data)
|
||
if not dry_run:
|
||
try:
|
||
cache_raw.write_bytes(raw_data)
|
||
cache_cropped.write_bytes(cropped)
|
||
print(f" 💾 Кеш: {cache_raw.name} + "
|
||
f"{cache_cropped.name}")
|
||
except Exception as e:
|
||
print(f" ⚠ Не удалось сохранить кеш: {e}")
|
||
return cropped
|
||
|
||
# 4. Fallback: .jpg → проверить и обрезать
|
||
if fallback_cropped.exists() and fallback_cropped.stat().st_size > 0:
|
||
print(f" 🖼 Fallback: {fallback_cropped.name}")
|
||
data = fallback_cropped.read_bytes()
|
||
return ensure_cropped(data, fallback_cropped, dry_run)
|
||
|
||
# 5. Fallback: _raw → обрезать
|
||
if fallback_raw.exists() and fallback_raw.stat().st_size > 0:
|
||
print(f" 🖼 Fallback raw: {fallback_raw.name}, обрезаю...")
|
||
raw_data = fallback_raw.read_bytes()
|
||
cropped = crop_to_square(raw_data)
|
||
if not dry_run:
|
||
try:
|
||
fallback_cropped.write_bytes(cropped)
|
||
print(f" 💾 Сохранён: {fallback_cropped.name}")
|
||
except Exception as e:
|
||
print(f" ⚠ Не удалось сохранить: {e}")
|
||
return cropped
|
||
|
||
# 6. Скачать базовую
|
||
print(f" 🔍 Поиск базовой обложки: {anime_name}")
|
||
base_info = {"media": None, "season": None}
|
||
url, matched = search_cover_with_fallback(anime_name, base_info)
|
||
|
||
if not url:
|
||
print(f" ⚠ Обложка не найдена")
|
||
return b""
|
||
|
||
print(f" 🌐 Скачиваю: {url}")
|
||
raw_data = download_image(url)
|
||
|
||
if not raw_data:
|
||
return b""
|
||
|
||
cropped = crop_to_square(raw_data)
|
||
|
||
if not dry_run:
|
||
try:
|
||
fallback_raw.write_bytes(raw_data)
|
||
fallback_cropped.write_bytes(cropped)
|
||
print(f" 💾 Кеш: {fallback_raw.name} + "
|
||
f"{fallback_cropped.name}")
|
||
except Exception as e:
|
||
print(f" ⚠ Не удалось сохранить кеш: {e}")
|
||
|
||
return cropped
|
||
|
||
|
||
def detect_mime(image_data: bytes) -> str:
|
||
"""Определить MIME-тип по заголовку файла."""
|
||
if image_data[:3] == b'\xff\xd8\xff':
|
||
return "image/jpeg"
|
||
elif image_data[:8] == b'\x89PNG\r\n\x1a\n':
|
||
return "image/png"
|
||
elif image_data[:4] == b'RIFF' and image_data[8:12] == b'WEBP':
|
||
return "image/webp"
|
||
return "image/jpeg"
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Запись тегов и обложки
|
||
# ─────────────────────────────────────────────
|
||
|
||
def write_tags(filepath: Path, anime_source: str, anime_source_full: str,
|
||
cover_data: bytes = b"", dry_run: bool = False) -> bool:
|
||
"""Записать теги и обложку в аудиофайл."""
|
||
if dry_run:
|
||
return True
|
||
|
||
ext = filepath.suffix.lower()
|
||
mime = detect_mime(cover_data) if cover_data else ""
|
||
|
||
try:
|
||
if ext == ".mp3":
|
||
audio = MP3(str(filepath))
|
||
if audio.tags is None:
|
||
audio.add_tags()
|
||
audio.tags.add(TXXX(encoding=3, desc="ANIME_SOURCE", text=[anime_source]))
|
||
audio.tags.add(TXXX(encoding=3, desc="ANIME_SOURCE_FULL", text=[anime_source_full]))
|
||
audio.tags.add(COMM(encoding=3, lang='eng', desc='', text=[anime_source_full]))
|
||
if cover_data:
|
||
audio.tags.delall("APIC")
|
||
audio.tags.add(APIC(
|
||
encoding=3,
|
||
mime=mime,
|
||
type=3,
|
||
desc='Cover',
|
||
data=cover_data,
|
||
))
|
||
audio.save()
|
||
|
||
elif ext == ".flac":
|
||
audio = FLAC(str(filepath))
|
||
audio["ANIME_SOURCE"] = anime_source
|
||
audio["ANIME_SOURCE_FULL"] = anime_source_full
|
||
audio["COMMENT"] = anime_source_full
|
||
if cover_data:
|
||
pic = Picture()
|
||
pic.type = 3
|
||
pic.mime = mime
|
||
pic.desc = 'Cover'
|
||
pic.data = cover_data
|
||
audio.clear_pictures()
|
||
audio.add_picture(pic)
|
||
audio.save()
|
||
|
||
elif ext == ".ogg":
|
||
audio = OggVorbis(str(filepath))
|
||
audio["ANIME_SOURCE"] = anime_source
|
||
audio["ANIME_SOURCE_FULL"] = anime_source_full
|
||
audio["COMMENT"] = anime_source_full
|
||
if cover_data:
|
||
import base64
|
||
pic = Picture()
|
||
pic.type = 3
|
||
pic.mime = mime
|
||
pic.desc = 'Cover'
|
||
pic.data = cover_data
|
||
audio["metadata_block_picture"] = [
|
||
base64.b64encode(pic.write()).decode("ascii")
|
||
]
|
||
audio.save()
|
||
|
||
elif ext == ".opus":
|
||
audio = OggOpus(str(filepath))
|
||
audio["ANIME_SOURCE"] = anime_source
|
||
audio["ANIME_SOURCE_FULL"] = anime_source_full
|
||
audio["COMMENT"] = anime_source_full
|
||
if cover_data:
|
||
import base64
|
||
pic = Picture()
|
||
pic.type = 3
|
||
pic.mime = mime
|
||
pic.desc = 'Cover'
|
||
pic.data = cover_data
|
||
audio["metadata_block_picture"] = [
|
||
base64.b64encode(pic.write()).decode("ascii")
|
||
]
|
||
audio.save()
|
||
|
||
elif ext == ".m4a":
|
||
audio = MP4(str(filepath))
|
||
audio["----:com.apple.iTunes:ANIME_SOURCE"] = [anime_source.encode("utf-8")]
|
||
audio["----:com.apple.iTunes:ANIME_SOURCE_FULL"] = [anime_source_full.encode("utf-8")]
|
||
audio["\xa9cmt"] = [anime_source_full]
|
||
if cover_data:
|
||
fmt = MP4Cover.FORMAT_JPEG
|
||
if mime == "image/png":
|
||
fmt = MP4Cover.FORMAT_PNG
|
||
audio["covr"] = [MP4Cover(cover_data, imageformat=fmt)]
|
||
audio.save()
|
||
|
||
else:
|
||
return False
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f" ✗ Ошибка: {e}")
|
||
return False
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Сбор уникальных обложек по директории
|
||
# ─────────────────────────────────────────────
|
||
|
||
def collect_unique_covers(audio_files: list, anime_name: str, anime_dir: Path,
|
||
dry_run: bool) -> dict:
|
||
"""
|
||
Собирает все уникальные media_info из файлов директории,
|
||
скачивает обложки и возвращает словарь {cache_base: cover_bytes}.
|
||
"""
|
||
covers = {}
|
||
|
||
for filepath in audio_files:
|
||
track_type = extract_type(filepath.name)
|
||
media_info = parse_media_info(track_type)
|
||
cache_base = get_cache_base(media_info)
|
||
|
||
if cache_base not in covers:
|
||
cover_data = get_cover(anime_name, anime_dir, media_info, dry_run)
|
||
covers[cache_base] = cover_data
|
||
|
||
return covers
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Обработка директории
|
||
# ─────────────────────────────────────────────
|
||
|
||
def process_directory(root_path: str, dry_run: bool = False,
|
||
no_covers: bool = False, proxy: str = ""):
|
||
"""Обработать директорию."""
|
||
if proxy:
|
||
os.environ["http_proxy"] = proxy
|
||
os.environ["https_proxy"] = proxy
|
||
|
||
root = Path(root_path)
|
||
|
||
if not root.is_dir():
|
||
print(f"Директория не найдена: {root_path}")
|
||
sys.exit(1)
|
||
|
||
prefix = "[DRY] " if dry_run else ""
|
||
tagged = 0
|
||
errors = 0
|
||
covers_found = 0
|
||
covers_missing = 0
|
||
|
||
for anime_dir in sorted(root.iterdir()):
|
||
if not anime_dir.is_dir():
|
||
continue
|
||
|
||
anime_source = anime_dir.name
|
||
|
||
audio_files = sorted([
|
||
f for f in anime_dir.rglob("*")
|
||
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS
|
||
])
|
||
|
||
if not audio_files:
|
||
continue
|
||
|
||
print(f"\n{prefix}📁 {anime_source} ({len(audio_files)} файлов)")
|
||
|
||
# Собрать и скачать все уникальные обложки
|
||
covers_cache = {}
|
||
if not no_covers:
|
||
covers_cache = collect_unique_covers(
|
||
audio_files, anime_source, anime_dir, dry_run
|
||
)
|
||
|
||
for filepath in audio_files:
|
||
track_type = extract_type(filepath.name)
|
||
media_info = parse_media_info(track_type)
|
||
|
||
if track_type:
|
||
anime_source_full = f"{anime_source}, {track_type}"
|
||
else:
|
||
anime_source_full = anime_source
|
||
|
||
cover_data = b""
|
||
if not no_covers:
|
||
cache_base = get_cache_base(media_info)
|
||
cover_data = covers_cache.get(cache_base, b"")
|
||
|
||
success = write_tags(filepath, anime_source, anime_source_full,
|
||
cover_data, dry_run)
|
||
|
||
if success:
|
||
tagged += 1
|
||
cover_status = " 🖼" if cover_data else " ⚠ без обложки"
|
||
media_str = ""
|
||
if media_info.get("media"):
|
||
media_str = f" [{media_info['media']}"
|
||
if media_info.get("season"):
|
||
media_str += f"-{media_info['season']}"
|
||
media_str += "]"
|
||
print(f" {prefix}✓ {filepath.name}{media_str}{cover_status}")
|
||
print(f" ANIME_SOURCE = {anime_source}")
|
||
print(f" ANIME_SOURCE_FULL = {anime_source_full}")
|
||
|
||
if cover_data:
|
||
covers_found += 1
|
||
else:
|
||
covers_missing += 1
|
||
else:
|
||
errors += 1
|
||
|
||
print(f"\n{'=' * 60}")
|
||
print(f" {'Будет обработано' if dry_run else 'Обработано'}: {tagged}")
|
||
print(f" Ошибок: {errors}")
|
||
if not no_covers:
|
||
print(f" Треков с обложкой: {covers_found}")
|
||
print(f" Треков без обложки: {covers_missing}")
|
||
print(f"{'=' * 60}")
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Тест
|
||
# ─────────────────────────────────────────────
|
||
|
||
def test():
|
||
"""Тест парсера и API."""
|
||
|
||
# Тест extract_type
|
||
type_examples = [
|
||
("[AniTousen] TV-1 ED01 - I'm ALIVE! (BECCA).mp3", "TV-1 ED01"),
|
||
("[AniTousen] TV-2 ED EP12 - Get up! (Asano Masumi).mp3", "TV-2 ED EP12"),
|
||
("[AniTousen] TV ED01 - Other Side (MIYAVI).mp3", "TV ED01"),
|
||
("[AniTousen] Movie 1 ED01 - Onaji Sora (Iguchi Yuka).mp3", "Movie 1 ED01"),
|
||
("[AniTousen] OVA ED01 - Wonderful Carnival (Endou).mp3", "OVA ED01"),
|
||
("[AniTousen] ONA ED01 - Collage (Sangatsu).mp3", "ONA ED01"),
|
||
("[AniTousen] Special ED01 - Hirogare Power (Uchida).mp3", "Special ED01"),
|
||
("[AniTousen] Game 2 OP01 - Gyakko (Sakamoto).mp3", "Game 2 OP01"),
|
||
]
|
||
|
||
print("═══ Тест extract_type ═══\n")
|
||
ok = 0
|
||
for filename, expected in type_examples:
|
||
got = extract_type(filename)
|
||
match = got == expected
|
||
status = "✓" if match else "✗"
|
||
if match:
|
||
ok += 1
|
||
print(f" {status} {filename}")
|
||
print(f" → \"{got}\"" + ("" if match else f" (ожидалось \"{expected}\")"))
|
||
print(f"\n Пройдено: {ok}/{len(type_examples)}\n")
|
||
|
||
# Тест parse_media_info
|
||
media_examples = [
|
||
("TV-1 ED01", {"media": "TV", "season": 1}),
|
||
("TV-2 OP01", {"media": "TV", "season": 2}),
|
||
("TV ED01", {"media": "TV", "season": None}),
|
||
("Movie 1 ED01", {"media": "Movie", "season": 1}),
|
||
("Movie ED01", {"media": "Movie", "season": None}),
|
||
("OVA ED01", {"media": "OVA", "season": None}),
|
||
("ONA ED01", {"media": "ONA", "season": None}),
|
||
("Special ED01", {"media": "Special", "season": None}),
|
||
("Game 2 OP01", {"media": "Game", "season": 2}),
|
||
("", {"media": None, "season": None}),
|
||
]
|
||
|
||
print("═══ Тест parse_media_info ═══\n")
|
||
ok = 0
|
||
for track_type, expected in media_examples:
|
||
got = parse_media_info(track_type)
|
||
match = got == expected
|
||
status = "✓" if match else "✗"
|
||
if match:
|
||
ok += 1
|
||
print(f" {status} \"{track_type}\" → {got}")
|
||
if not match:
|
||
print(f" ожидалось: {expected}")
|
||
print(f"\n Пройдено: {ok}/{len(media_examples)}\n")
|
||
|
||
# Тест build_search_queries
|
||
query_examples = [
|
||
("Kuroshitsuji", {"media": "TV", "season": 2}, [
|
||
"Kuroshitsuji 2nd Season",
|
||
"Kuroshitsuji Season 2",
|
||
"Kuroshitsuji 2",
|
||
"Kuroshitsuji II",
|
||
"Kuroshitsuji",
|
||
]),
|
||
("Fate", {"media": "Movie", "season": 1}, [
|
||
"Fate Movie 1",
|
||
"Fate Movie",
|
||
"Fate",
|
||
]),
|
||
("Naruto", {"media": "OVA", "season": None}, [
|
||
"Naruto OVA",
|
||
"Naruto",
|
||
]),
|
||
("Naruto", {"media": "TV", "season": None}, [
|
||
"Naruto",
|
||
]),
|
||
("Naruto", {"media": None, "season": None}, [
|
||
"Naruto",
|
||
]),
|
||
]
|
||
print("═══ Тест build_search_queries ═══\n")
|
||
ok = 0
|
||
for name, info, expected in query_examples:
|
||
got = build_search_queries(name, info)
|
||
match = got == expected
|
||
status = "✓" if match else "✗"
|
||
if match:
|
||
ok += 1
|
||
print(f" {status} {name} {info}")
|
||
print(f" → {got}")
|
||
if not match:
|
||
print(f" ожидалось: {expected}")
|
||
print(f"\n Пройдено: {ok}/{len(query_examples)}\n")
|
||
|
||
# Тест crop_to_square
|
||
print("═══ Тест crop_to_square ═══\n")
|
||
crop_tests = [
|
||
("Прямоугольник 300x400", (300, 400), (300, 300)),
|
||
("Прямоугольник 400x300", (400, 300), (300, 300)),
|
||
("Квадрат 500x500", (500, 500), (500, 500)),
|
||
]
|
||
ok = 0
|
||
for desc, (w, h), (ew, eh) in crop_tests:
|
||
img = Image.new("RGB", (w, h), color="red")
|
||
buf = BytesIO()
|
||
img.save(buf, format="JPEG")
|
||
raw = buf.getvalue()
|
||
|
||
cropped = crop_to_square(raw)
|
||
result = Image.open(BytesIO(cropped))
|
||
rw, rh = result.size
|
||
|
||
match = rw == ew and rh == eh
|
||
status = "✓" if match else "✗"
|
||
if match:
|
||
ok += 1
|
||
print(f" {status} {desc}: {w}x{h} → {rw}x{rh}" +
|
||
("" if match else f" (ожидалось {ew}x{eh})"))
|
||
print(f"\n Пройдено: {ok}/{len(crop_tests)}\n")
|
||
|
||
# Тест get_cache_base
|
||
print("═══ Тест get_cache_base ═══\n")
|
||
cache_tests = [
|
||
({"media": None, "season": None}, "cover"),
|
||
({"media": "TV", "season": None}, "cover"),
|
||
({"media": "TV", "season": 1}, "cover_TV-1"),
|
||
({"media": "TV", "season": 2}, "cover_TV-2"),
|
||
({"media": "Movie", "season": None}, "cover_Movie"),
|
||
({"media": "Movie", "season": 1}, "cover_Movie-1"),
|
||
({"media": "OVA", "season": None}, "cover_OVA"),
|
||
({"media": "ONA", "season": None}, "cover_ONA"),
|
||
({"media": "Special", "season": None}, "cover_Special"),
|
||
({"media": "Game", "season": 2}, "cover_Game-2"),
|
||
]
|
||
ok = 0
|
||
for info, expected in cache_tests:
|
||
got = get_cache_base(info)
|
||
match = got == expected
|
||
status = "✓" if match else "✗"
|
||
if match:
|
||
ok += 1
|
||
print(f" {status} {info} → \"{got}\"" +
|
||
("" if match else f" (ожидалось \"{expected}\")"))
|
||
print(f"\n Пройдено: {ok}/{len(cache_tests)}\n")
|
||
|
||
# Тест API
|
||
print("═══ Тест Jikan API ═══\n")
|
||
api_tests = [
|
||
("Kuroshitsuji", {"media": None, "season": None}),
|
||
("Kuroshitsuji", {"media": "TV", "season": 2}),
|
||
("Naruto", {"media": "Movie", "season": None}),
|
||
]
|
||
for name, info in api_tests:
|
||
queries = build_search_queries(name, info)
|
||
cache_base = get_cache_base(info)
|
||
print(f" {name} {info}")
|
||
print(f" Кеш: {cache_base}.jpg / {cache_base}_raw.jpg")
|
||
found = False
|
||
for q in queries:
|
||
url = fetch_cover_url(q)
|
||
if url:
|
||
print(f" ✓ \"{q}\" → {url}")
|
||
found = True
|
||
break
|
||
else:
|
||
print(f" ✗ \"{q}\" → не найдено")
|
||
if not found:
|
||
print(f" ⚠ Обложка не найдена ни по одному запросу")
|
||
print()
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Точка входа
|
||
# ─────────────────────────────────────────────
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Тегирование аниме саундтреков")
|
||
sub = parser.add_subparsers(dest="command")
|
||
|
||
p_run = sub.add_parser("run", help="Записать теги")
|
||
p_run.add_argument("directory", help="Директория с музыкой")
|
||
p_run.add_argument("--dry-run", action="store_true",
|
||
help="Только показать, не записывать")
|
||
p_run.add_argument("--no-covers", action="store_true",
|
||
help="Не искать и не записывать обложки")
|
||
p_run.add_argument("--proxy", default="",
|
||
help="HTTP/SOCKS5 прокси (например socks5h://127.0.0.1:1080)")
|
||
|
||
sub.add_parser("test", help="Тест парсера и API")
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.command == "test":
|
||
test()
|
||
elif args.command == "run":
|
||
process_directory(args.directory, args.dry_run, args.no_covers,
|
||
getattr(args, 'proxy', ''))
|
||
else:
|
||
parser.print_help()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main() |