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

View file

@ -1,3 +1,30 @@
# utility-scripts # Utility Scripts
One-shot utility scripts (anime tagging, cover downloads). Pull when needed. Одноразовые утилиты для homelab Vespa. Не предназначены для постоянной работы — запускаются при необходимости.
## Скрипты
### `rename_anime.py`
Массовое переименование файлов аниме (приведение к стандартному формату).
### `tag_anime.py`
Тегирование аниме-файлов через MyAnimeList/Jikan API.
### `download_anime_covers.py`
Скачивание обложек аниме напрямую с источников.
### `download_anime_covers_proxy.py`
Версия с прокси (через xray на vespaserver: `http://192.168.31.216:1080`).
## Использование
```bash
git clone https://git.vespahomelab.ru/shu/utility-scripts.git
cd utility-scripts
python3 -m pip install -r requirements.txt # если будет создан
python3 <script>.py
```
## Контекст
Перенесены сюда из `/storage/scripts/` на vespaserver в рамках реструктуризации homelab (см. Obsidian project `Реструктуризация-homelab`, 2026-04-29).

367
download_anime_covers.py Normal file
View file

@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""
Скачивает обложки аниме через Jikan API (jikan.moe) и встраивает их в аудиофайлы.
Название аниме берётся из первых квадратных скобок в названии файла:
TV-2 OP01 - Nobody Knows (Suga Shikao) [xxxHOLiC].mp3
^^^^^^^^
извлекаем это
Jikan API - бесплатный API без аутентификации (парсит MyAnimeList)
https://jikan.moe/
Использование:
python3 download_anime_covers.py run --dry-run /path/to/music
python3 download_anime_covers.py run /path/to/music
python3 download_anime_covers.py search "xxxHOLiC" # Поиск обложки для теста
"""
import os
import sys
import argparse
import re
import time
import requests
from pathlib import Path
from typing import Optional, Tuple, Dict
try:
from mutagen.flac import FLAC
from mutagen.mp3 import MP3
from mutagen.oggvorbis import OggVorbis
from mutagen.oggopus import OggOpus
from mutagen.mp4 import MP4
from mutagen.id3 import APIC, ID3
except ImportError:
print("Установите mutagen: pip install mutagen")
sys.exit(1)
AUDIO_EXTENSIONS = {".flac", ".mp3", ".ogg", ".opus", ".m4a"}
# ============================================================================
# КОНФИГУРАЦИЯ
# ============================================================================
# Jikan API (бесплатный, без ключа)
JIKAN_BASE_URL = "https://api.jikan.moe/v4"
# Кэш обложек (чтобы не скачивать одну и ту же много раз)
COVER_CACHE: Dict[str, bytes] = {}
# Задержки для API (Jikan требует ~2 секунды между запросами)
JIKAN_DELAY = 2.0
# ============================================================================
# ФУНКЦИИ
# ============================================================================
def extract_anime_name(filepath: Path) -> Optional[str]:
"""
Извлекает название аниме из первых квадратных скобок в названии файла.
Пример:
"TV-2 OP01 - Nobody Knows (Suga) [xxxHOLiC].mp3" "xxxHOLiC"
"Song [Fullmetal Alchemist Brotherhood].flac" "Fullmetal Alchemist Brotherhood"
"""
stem = filepath.stem
# Ищем первые квадратные скобки
match = re.search(r'\[([^\]]+)\]', stem)
if match:
return match.group(1).strip()
return None
class JikanAPI:
"""Класс для работы с Jikan API (jikan.moe)"""
def __init__(self):
self.base_url = JIKAN_BASE_URL
self.session = requests.Session()
self.last_request_time = 0
# Jikan требует User-Agent
self.session.headers.update({
'User-Agent': 'Anime-Cover-Downloader/1.0 (shu@vespahomelab.ru)'
})
def _rate_limit(self):
"""Соблюдает лимиты API Jikan (~2 секунды между запросами)"""
elapsed = time.time() - self.last_request_time
if elapsed < JIKAN_DELAY:
time.sleep(JIKAN_DELAY - elapsed)
self.last_request_time = time.time()
def search_anime(self, query: str, limit: int = 5) -> list:
"""Ищет аниме по названию"""
self._rate_limit()
url = f"{self.base_url}/anime"
params = {
'q': query,
'limit': limit
}
try:
response = self.session.get(url, params=params, timeout=30)
response.raise_for_status()
data = response.json()
results = []
for item in data.get('data', []):
title = item.get('title', '')
title_english = item.get('title_english', '')
images = item.get('images', {})
jpg = images.get('jpg', {})
image_url = jpg.get('large', jpg.get('image_url', ''))
if image_url:
results.append({
'title': title,
'title_english': title_english,
'image_url': image_url,
'source': 'Jikan'
})
return results
except Exception as e:
print(f" Ошибка поиска в Jikan: {e}")
return []
def get_anime_cover(self, anime_name: str) -> Optional[str]:
"""Получает URL обложки для аниме"""
results = self.search_anime(anime_name)
if results:
# Возвращаем первый результат (наиболее релевантный)
return results[0]['image_url']
return None
def download_image(url: str) -> Optional[bytes]:
"""Скачивает изображение по URL"""
try:
response = requests.get(url, timeout=30)
response.raise_for_status()
return response.content
except Exception as e:
print(f" Ошибка скачивания изображения: {e}")
return None
def get_cover_image(anime_name: str, jikan_api: JikanAPI) -> Optional[bytes]:
"""
Получает обложку для аниме через Jikan API.
Использует кэш для повторных запросов.
"""
# Проверяем кэш
cache_key = anime_name.lower()
if cache_key in COVER_CACHE:
print(f" Найдено в кэше: {anime_name}")
return COVER_CACHE[cache_key]
print(f" Поиск обложки для: {anime_name}")
# Ищем через Jikan
image_url = jikan_api.get_anime_cover(anime_name)
if image_url:
print(f" Найдено: {image_url}")
image_data = download_image(image_url)
if image_data:
COVER_CACHE[cache_key] = image_data
return image_data
print(f" Обложка не найдена")
return None
def embed_cover(filepath: Path, image_data: bytes, dry_run: bool = False) -> bool:
"""
Встраивает обложку в аудиофайл.
"""
if dry_run:
return True
ext = filepath.suffix.lower()
try:
if ext == ".mp3":
audio = MP3(str(filepath), ID3=ID3)
if audio.tags is None:
audio.add_tags()
# Удаляем старую обложку если есть
audio.tags.delall('APIC')
# Добавляем новую
audio.tags.add(APIC(
encoding=3,
mime='image/jpeg',
type=3, # Front cover
desc='Cover',
data=image_data
))
audio.save()
elif ext == ".flac":
audio = FLAC(str(filepath))
audio.clear_pictures()
audio.add_picture(image_data)
audio.save()
elif ext == ".ogg":
audio = OggVorbis(str(filepath))
# Для Ogg Vorbis обложка хранится в METADATA_BLOCK_PICTURE
from mutagen.flac import Picture
pic = Picture()
pic.type = 3
pic.mime = 'image/jpeg'
pic.data = image_data
audio.add_picture(pic)
audio.save()
elif ext == ".opus":
audio = OggOpus(str(filepath))
from mutagen.flac import Picture
pic = Picture()
pic.type = 3
pic.mime = 'image/jpeg'
pic.data = image_data
audio.add_picture(pic)
audio.save()
elif ext == ".m4a":
audio = MP4(str(filepath))
audio['covr'] = [image_data]
audio.save()
else:
return False
return True
except Exception as e:
print(f" ✗ Ошибка встраивания обложки: {e}")
return False
def process_directory(root_path: str, dry_run: bool = False):
"""Обработать директорию."""
root = Path(root_path)
if not root.is_dir():
print(f"Директория не найдена: {root_path}")
sys.exit(1)
# Инициализируем Jikan API
jikan_api = JikanAPI()
prefix_str = "[DRY] " if dry_run else ""
processed = 0
skipped = 0
errors = 0
no_cover = 0
audio_files = sorted([
f for f in root.rglob("*")
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS
])
if not audio_files:
print("Аудиофайлы не найдены")
return
print(f"{prefix_str}Найдено файлов: {len(audio_files)}\n")
# Группируем файлы по названию аниме для кэширования обложек
anime_covers: Dict[str, Optional[bytes]] = {}
for idx, filepath in enumerate(audio_files, 1):
anime_name = extract_anime_name(filepath)
if not anime_name:
skipped += 1
continue
print(f"[{idx}/{len(audio_files)}] {filepath.name}")
# Получаем обложку (с кэшированием)
if anime_name not in anime_covers:
cover_data = get_cover_image(anime_name, jikan_api)
anime_covers[anime_name] = cover_data
else:
cover_data = anime_covers[anime_name]
print(f" Используем кэш для: {anime_name}")
if cover_data is None:
no_cover += 1
continue
success = embed_cover(filepath, cover_data, dry_run)
if success:
processed += 1
if dry_run:
print(f" ✓ Будет встроена обложка")
else:
print(f" ✓ Обложка встроена")
else:
errors += 1
print(f"\n{'=' * 60}")
print(f" {'Будет обработано' if dry_run else 'Обработано'}: {processed}")
print(f" Пропущено (нет названия аниме): {skipped}")
print(f" Не найдено обложек: {no_cover}")
print(f" Ошибок: {errors}")
print(f"{'=' * 60}")
def search_cover(anime_name: str):
"""Тестовый поиск обложки для аниме"""
jikan_api = JikanAPI()
print(f"Поиск обложки для: {anime_name}\n")
# Jikan
print("Jikan (MyAnimeList):")
results = jikan_api.search_anime(anime_name, limit=5)
for i, result in enumerate(results, 1):
print(f" {i}. {result['title']}")
if result['title_english']:
print(f" English: {result['title_english']}")
print(f" {result['image_url']}")
if not results:
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_search = sub.add_parser("search", help="Поиск обложки для теста")
p_search.add_argument("anime_name", help="Название аниме для поиска")
args = parser.parse_args()
if args.command == "search":
search_cover(args.anime_name)
elif args.command == "run":
process_directory(args.directory, args.dry_run)
else:
parser.print_help()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""
Скачивает обложки аниме через Jikan API (jikan.moe) и встраивает их в аудиофайлы.
Использует SOCKS5 прокси для доступа к API и скачивания изображений.
Название аниме берётся из первых квадратных скобок в названии файла:
TV-2 OP01 - Nobody Knows (Suga Shikao) [xxxHOLiC].mp3
^^^^^^^^
извлекаем это
Jikan API - бесплатный API без аутентификации (парсит MyAnimeList)
https://jikan.moe/
Использование:
python3 download_anime_covers_proxy.py run --dry-run /path/to/music
python3 download_anime_covers_proxy.py run /path/to/music
python3 download_anime_covers_proxy.py search "xxxHOLiC" # Поиск обложки для теста
"""
import os
import sys
import argparse
import re
import time
import requests
from pathlib import Path
from typing import Optional, Tuple, Dict
try:
from mutagen.flac import FLAC
from mutagen.mp3 import MP3
from mutagen.oggvorbis import OggVorbis
from mutagen.oggopus import OggOpus
from mutagen.mp4 import MP4
from mutagen.id3 import APIC, ID3
except ImportError:
print("Установите mutagen: pip install mutagen")
sys.exit(1)
try:
from urllib3.contrib.socks import SOCKSProxyManager
SOCKS_AVAILABLE = True
except ImportError:
SOCKS_AVAILABLE = False
print("Warning: SOCKS proxy not available. Install: pip install requests[socks]")
AUDIO_EXTENSIONS = {".flac", ".mp3", ".ogg", ".opus", ".m4a"}
# ============================================================================
# КОНФИГУРАЦИЯ
# ============================================================================
# Jikan API (бесплатный, без ключа)
JIKAN_BASE_URL = "https://api.jikan.moe/v4"
# Прокси для доступа к API (SOCKS5)
PROXY_URL = "socks5://192.168.31.216:1080"
# Кэш обложек (чтобы не скачивать одну и ту же много раз)
COVER_CACHE: Dict[str, bytes] = {}
# Задержки для API (Jikan требует ~2 секунды между запросами)
JIKAN_DELAY = 2.0
# ============================================================================
# ФУНКЦИИ
# ============================================================================
def extract_anime_name(filepath: Path) -> Optional[str]:
"""
Извлекает название аниме из первых квадратных скобок в названии файла.
"""
stem = filepath.stem
match = re.search(r'\[([^\]]+)\]', stem)
if match:
return match.group(1).strip()
return None
class JikanAPI:
"""Класс для работы с Jikan API (jikan.moe) через прокси"""
def __init__(self, proxy_url: str = None):
self.base_url = JIKAN_BASE_URL
self.session = self._create_session(proxy_url)
self.last_request_time = 0
def _create_session(self, proxy_url: str = None) -> requests.Session:
"""Создаёт сессию с прокси (если указан)"""
session = requests.Session()
# Jikan требует User-Agent
session.headers.update({
'User-Agent': 'Anime-Cover-Downloader/1.0 (shu@vespahomelab.ru)'
})
# Настраиваем прокси через SOCKS5
if proxy_url and SOCKS_AVAILABLE:
try:
proxy_manager = SOCKSProxyManager(proxy_url)
session.mount('http://', proxy_manager)
session.mount('https://', proxy_manager)
print(f" ✓ Прокси настроен: {proxy_url}")
except Exception as e:
print(f" ⚠ Warning: Не удалось настроить прокси: {e}. Будет использовано прямое соединение.")
return session
def _rate_limit(self):
"""Соблюдает лимиты API Jikan (~2 секунды между запросами)"""
elapsed = time.time() - self.last_request_time
if elapsed < JIKAN_DELAY:
time.sleep(JIKAN_DELAY - elapsed)
self.last_request_time = time.time()
def search_anime(self, query: str, limit: int = 5) -> list:
"""Ищет аниме по названию"""
self._rate_limit()
url = f"{self.base_url}/anime"
params = {
'q': query,
'limit': limit
}
try:
response = self.session.get(url, params=params, timeout=30)
response.raise_for_status()
data = response.json()
results = []
for item in data.get('data', []):
title = item.get('title', '')
title_english = item.get('title_english', '')
images = item.get('images', {})
jpg = images.get('jpg', {})
image_url = jpg.get('large', jpg.get('image_url', ''))
if image_url:
results.append({
'title': title,
'title_english': title_english,
'image_url': image_url,
'source': 'Jikan'
})
return results
except Exception as e:
print(f" ✗ Ошибка поиска в Jikan: {e}")
return []
def get_anime_cover(self, anime_name: str) -> Optional[str]:
"""Получает URL обложки для аниме"""
results = self.search_anime(anime_name)
if results:
return results[0]['image_url']
return None
def download_image(url: str, proxy_url: str = None) -> Optional[bytes]:
"""Скачивает изображение по URL через прокси"""
try:
session = requests.Session()
if proxy_url and SOCKS_AVAILABLE:
try:
proxy_manager = SOCKSProxyManager(proxy_url)
session.mount('http://', proxy_manager)
session.mount('https://', proxy_manager)
except:
pass
response = session.get(url, timeout=30)
response.raise_for_status()
return response.content
except Exception as e:
print(f" ✗ Ошибка скачивания изображения: {e}")
return None
def get_cover_image(anime_name: str, jikan_api: JikanAPI, proxy_url: str = None) -> Optional[bytes]:
"""
Получает обложку для аниме через Jikan API.
Использует кэш для повторных запросов.
"""
# Проверяем кэш
cache_key = anime_name.lower()
if cache_key in COVER_CACHE:
print(f" ✓ Найдено в кэше: {anime_name}")
return COVER_CACHE[cache_key]
print(f" Поиск обложки для: {anime_name}")
# Ищем через Jikan
image_url = jikan_api.get_anime_cover(anime_name)
if image_url:
print(f" ✓ Найдено: {image_url}")
image_data = download_image(image_url, proxy_url)
if image_data:
COVER_CACHE[cache_key] = image_data
return image_data
print(f" ✗ Обложка не найдена")
return None
def embed_cover(filepath: Path, image_data: bytes, dry_run: bool = False) -> bool:
"""Встраивает обложку в аудиофайл."""
if dry_run:
return True
ext = filepath.suffix.lower()
try:
if ext == ".mp3":
audio = MP3(str(filepath), ID3=ID3)
if audio.tags is None:
audio.add_tags()
# Удаляем старую обложку если есть
audio.tags.delall('APIC')
# Добавляем новую
audio.tags.add(APIC(
encoding=3,
mime='image/jpeg',
type=3, # Front cover
desc='Cover',
data=image_data
))
audio.save()
elif ext == ".flac":
audio = FLAC(str(filepath))
audio.clear_pictures()
audio.add_picture(image_data)
audio.save()
elif ext == ".ogg":
audio = OggVorbis(str(filepath))
from mutagen.flac import Picture
pic = Picture()
pic.type = 3
pic.mime = 'image/jpeg'
pic.data = image_data
audio.add_picture(pic)
audio.save()
elif ext == ".opus":
audio = OggOpus(str(filepath))
from mutagen.flac import Picture
pic = Picture()
pic.type = 3
pic.mime = 'image/jpeg'
pic.data = image_data
audio.add_picture(pic)
audio.save()
elif ext == ".m4a":
audio = MP4(str(filepath))
audio['covr'] = [image_data]
audio.save()
else:
return False
return True
except Exception as e:
print(f" ✗ Ошибка встраивания обложки: {e}")
return False
def process_directory(root_path: str, dry_run: bool = False):
"""Обработать директорию."""
root = Path(root_path)
if not root.is_dir():
print(f"Директория не найдена: {root_path}")
sys.exit(1)
# Инициализируем Jikan API с прокси
jikan_api = JikanAPI(PROXY_URL)
prefix_str = "[DRY] " if dry_run else ""
processed = 0
skipped = 0
errors = 0
no_cover = 0
audio_files = sorted([
f for f in root.rglob("*")
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS
])
if not audio_files:
print("Аудиофайлы не найдены")
return
print(f"{prefix_str}Найдено файлов: {len(audio_files)}\n")
# Группируем файлы по названию аниме для кэширования обложек
anime_covers: Dict[str, Optional[bytes]] = {}
for idx, filepath in enumerate(audio_files, 1):
anime_name = extract_anime_name(filepath)
if not anime_name:
skipped += 1
continue
print(f"[{idx}/{len(audio_files)}] {filepath.name}")
# Получаем обложку (с кэшированием)
if anime_name not in anime_covers:
cover_data = get_cover_image(anime_name, jikan_api, PROXY_URL)
anime_covers[anime_name] = cover_data
else:
cover_data = anime_covers[anime_name]
print(f" ✓ Используем кэш для: {anime_name}")
if cover_data is None:
no_cover += 1
continue
success = embed_cover(filepath, cover_data, dry_run)
if success:
processed += 1
if dry_run:
print(f" ✓ Будет встроена обложка")
else:
print(f" ✓ Обложка встроена")
else:
errors += 1
print(f"\n{'=' * 60}")
print(f" {'Будет обработано' if dry_run else 'Обработано'}: {processed}")
print(f" Пропущено (нет названия аниме): {skipped}")
print(f" Не найдено обложек: {no_cover}")
print(f" Ошибок: {errors}")
print(f"{'=' * 60}")
def search_cover(anime_name: str):
"""Тестовый поиск обложки для аниме"""
jikan_api = JikanAPI(PROXY_URL)
print(f"Поиск обложки для: {anime_name}\n")
print("Jikan (MyAnimeList):")
results = jikan_api.search_anime(anime_name, limit=5)
for i, result in enumerate(results, 1):
print(f" {i}. {result['title']}")
if result['title_english']:
print(f" English: {result['title_english']}")
print(f" {result['image_url']}")
if not results:
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_search = sub.add_parser("search", help="Поиск обложки для теста")
p_search.add_argument("anime_name", help="Название аниме для поиска")
args = parser.parse_args()
if args.command == "search":
search_cover(args.anime_name)
elif args.command == "run":
process_directory(args.directory, args.dry_run)
else:
parser.print_help()
if __name__ == "__main__":
main()

209
rename_anime.py Normal file
View file

@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""
Добавляет название аниме (из директории) в конец названия файла перед расширением
и удаляет "[AniTousen]" из начала названия.
Пример:
Директория: Kuroshitsuji
Файл: [AniTousen] TV-1 ED01 - I'm ALIVE! (BECCA).mp3
Результат:
TV-1 ED01 - I'm ALIVE! (BECCA) [Kuroshitsuji].mp3
Использование:
python3 rename_anime.py test
python3 rename_anime.py run --dry-run /path/to/music
python3 rename_anime.py run /path/to/music
"""
import os
import sys
import argparse
from pathlib import Path
AUDIO_EXTENSIONS = {".flac", ".mp3", ".ogg", ".opus", ".m4a"}
def remove_ani_tousen(stem: str) -> str:
"""
Удаляет "[AniTousen]" из начала названия файла.
Было: [AniTousen] TV-1 ED01 - I'm ALIVE! (BECCA)
Стало: TV-1 ED01 - I'm ALIVE! (BECCA)
"""
# Проверяем начинается ли с [AniTousen]
if stem.startswith("[AniTousen] "):
return stem[len("[AniTousen] "):]
elif stem.startswith("[AniTousen]"):
return stem[len("[AniTousen]"):].lstrip()
return stem
def rename_file(filepath: Path, anime_source: str, dry_run: bool = False) -> bool:
"""
Переименовать файл: добавить название аниме в конец и удалить [AniTousen] из начала.
Было: [AniTousen] TV-1 ED01 - I'm ALIVE! (BECCA).mp3
Стало: TV-1 ED01 - I'm ALIVE! (BECCA) [Kuroshitsuji].mp3
"""
if dry_run:
return True
old_name = filepath.name
stem = filepath.stem
suffix = filepath.suffix
# Удаляем [AniTousen] из начала
new_stem = remove_ani_tousen(stem)
# Добавляем название аниме в конец
new_stem = f"{new_stem} [{anime_source}]"
new_name = f"{new_stem}{suffix}"
new_path = filepath.parent / new_name
# Проверяем, не существует ли уже файл с таким именем
if new_path.exists():
print(f" ⚠ Файл уже существует: {new_name}")
return False
try:
filepath.rename(new_path)
return True
except Exception as e:
print(f" ✗ Ошибка переименования: {e}")
return False
def process_directory(root_path: str, dry_run: bool = False):
"""Обработать директорию."""
root = Path(root_path)
if not root.is_dir():
print(f"Директория не найдена: {root_path}")
sys.exit(1)
prefix = "[DRY] " if dry_run else ""
renamed = 0
errors = 0
skipped = 0
for anime_dir in sorted(root.iterdir()):
if not anime_dir.is_dir():
continue
# Пропускаем скрытые директории
if anime_dir.name.startswith('.'):
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)} файлов)")
for filepath in audio_files:
# Проверяем, не добавлено ли уже название аниме
stem = filepath.stem
if stem.endswith(f" [{anime_source}]"):
skipped += 1
print(f"{filepath.name} (уже переименован)")
continue
success = rename_file(filepath, anime_source, dry_run)
if success:
renamed += 1
old_name = filepath.name
new_name = f"{filepath.stem} [{anime_source}]{filepath.suffix}"
print(f"{old_name}")
print(f"{new_name}")
else:
errors += 1
print(f"\n{'=' * 60}")
print(f" {'Будет переименовано' if dry_run else 'Переименовано'}: {renamed}")
print(f" Пропущено (уже переименовано): {skipped}")
print(f" Ошибок: {errors}")
print(f"{'=' * 60}")
def test():
"""Тест логики переименования."""
examples = [
# (старое_имя, название_папки, ожидаемоеовое_имя)
(
"[AniTousen] TV-1 ED01 - I'm ALIVE! (BECCA).mp3",
"Kuroshitsuji",
"TV-1 ED01 - I'm ALIVE! (BECCA) [Kuroshitsuji].mp3"
),
(
"[AniTousen] TV-2 ED EP12 - Get up! (Asano Masumi).flac",
"Ikkitousen",
"TV-2 ED EP12 - Get up! (Asano Masumi) [Ikkitousen].flac"
),
(
"[AniTousen] Movie ED - Song Name.wav",
"Your Name",
"Movie ED - Song Name [Your Name].wav"
),
# Файл без [AniTousen] - просто добавляем аниме
(
"TV OP - Song.mp3",
"Test Anime",
"TV OP - Song [Test Anime].mp3"
),
]
print("Тест переименования:\n")
ok = 0
for old_name, anime, expected_new in examples:
stem = Path(old_name).stem
suffix = Path(old_name).suffix
new_stem = f"{stem} [{anime}]"
new_name = f"{new_stem}{suffix}"
match = new_name == expected_new
status = "" if match else ""
if match:
ok += 1
print(f" {status} {old_name}")
print(f"{new_name}")
if not match:
print(f" ОЖИДАЛОСЬ: {expected_new}")
print()
print(f"Пройдено: {ok}/{len(examples)}")
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="Показать что будет сделано без изменений")
sub.add_parser("test", help="Тест логики")
args = parser.parse_args()
if args.command == "test":
test()
elif args.command == "run":
process_directory(args.directory, args.dry_run)
else:
parser.print_help()
if __name__ == "__main__":
main()

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()