utility-scripts/download_anime_covers.py
shu 70814ddc1f import: anime utility scripts from /storage/scripts/
Перенос из vespaserver:/storage/scripts/ перед удалением оригиналов.
Скрипты разовые — клонируются по мере необходимости.
2026-04-29 18:24:58 +03:00

367 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()