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