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