import: anime utility scripts from /storage/scripts/

Перенос из vespaserver:/storage/scripts/ перед удалением оригиналов.
Скрипты разовые — клонируются по мере необходимости.
This commit is contained in:
shu 2026-04-29 18:24:58 +03:00
parent 67b460d40c
commit 70814ddc1f
5 changed files with 1918 additions and 2 deletions

924
tag_anime.py Normal file
View file

@ -0,0 +1,924 @@
#!/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()