"""애니메이션 자동화 파이프라인. 전체 흐름: 1. Anissia에서 애니 검색 → 자막 정보 확인 2. Nyaa.si에서 토렌트 검색 → 제목 매칭 3. qBittorrent에 magnet 추가 → NAS 경로 지정 4. 자막 다운로드 → 파일명 매칭 """ import asyncio import logging from dataclasses import dataclass, field from pathlib import Path from typing import Optional import config from tools.anissia_client import AnissiaClient, AnimeInfo, CaptionInfo from tools.nyaa_client import NyaaClient, TorrentResult from tools.qbit_client import QBitClient from tools.subtitle_downloader import SubtitleDownloader, SubtitleFile from tools.title_matcher import ( match_titles, make_nas_folder_name, rename_subtitle_to_video, fetch_english_title, ) logger = logging.getLogger("variet.tools.pipeline") @dataclass class DownloadResult: """파이프라인 실행 결과.""" success: bool anime: Optional[AnimeInfo] = None captions: list[CaptionInfo] = field(default_factory=list) torrents: list[TorrentResult] = field(default_factory=list) subtitles: list[SubtitleFile] = field(default_factory=list) nas_folder: str = "" torrent_added: bool = False message: str = "" errors: list[str] = field(default_factory=list) class AnimePipeline: """애니메이션 다운로드 자동화 파이프라인.""" def __init__(self): self.anissia = AnissiaClient() self.nyaa = NyaaClient() self.qbit = QBitClient() self.sub_downloader = SubtitleDownloader() self.nas_base = getattr(config, "NAS_ANIME_PATH", r"\\192.168.10.10\NasData\Video\Animation") from tools.nas_scanner import NasScanner self.nas = NasScanner(self.nas_base) async def search(self, title: str) -> DownloadResult: """애니 검색 — 정보 + 자막 + 토렌트 현황 표시. 실제 다운로드 없이 검색 결과만 반환. """ result = DownloadResult(success=False) # 1. Anissia에서 검색 try: anime_list = await self.anissia.search_anime(title) except Exception as e: result.errors.append(f"Anissia 검색 오류: {e}") return result if not anime_list: result.message = f"'{title}' 검색 결과가 없습니다." return result anime = anime_list[0] # 첫 번째 결과 사용 result.anime = anime # 2. 자막 정보 try: captions = await self.anissia.get_captions(anime.anime_no) result.captions = captions except Exception as e: result.errors.append(f"자막 조회 오류: {e}") # 3. NAS 기존 폴더 확인 → 검색 전략 결정 (방영 시점 기반) nas_existing = self._find_existing_nas_folder(anime.subject, anime.start_date) # 3. Nyaa 토렌트 검색 try: if nas_existing and nas_existing.video_files: # ── 기존 파일명에서 릴리스명 추출 → Nyaa 검색 (안전) ── release_name = self._extract_release_name(nas_existing.video_files[0]) if release_name: logger.info(f"NAS 기존 릴리스명: '{release_name}'") found = await self.nyaa.search(release_name, use_default_suffix=False) matched = [t for t in found if self._title_contains_keyword(t.title, [release_name.lower()])] result.torrents = matched[:30] if matched: logger.info(f"NAS 릴리스명 검색 → {len(found)}건 중 {len(matched)}건 매칭") if not result.torrents: # ── 신규 애니: Jikan API + ASW HEVC 전략 ── eng_titles = await fetch_english_title(anime.original_subject) eng_default = eng_titles.get("default", "") eng_english = eng_titles.get("english", "") synonyms = eng_titles.get("synonyms", []) keywords = self._build_match_keywords( eng_default, eng_english, synonyms, anime.original_subject, ) logger.info(f"매칭 키워드: {keywords}") # STEP 1: "ASW HEVC"로 검색 → 키워드로 필터 asw_results = await self.nyaa.search("ASW HEVC", use_default_suffix=False) matched = [t for t in asw_results if self._title_contains_keyword(t.title, keywords)] if matched: logger.info(f"ASW HEVC 검색 → {len(asw_results)}건 중 {len(matched)}건 매칭") else: # ASW 릴리스 없음 — 사용자에게 안내 result.errors.append( f"⚠️ ASW HEVC 릴리스가 없습니다.\n" f"영어 제목: {eng_default or '(조회 실패)'}\n" f"Nyaa에서 직접 검색해주세요." ) result.torrents = matched[:30] except Exception as e: result.errors.append(f"Nyaa 검색 오류: {e}") # NAS 폴더: 기존 폴더 있으면 재사용, 없으면 새로 생성 if nas_existing: result.nas_folder = nas_existing.folder_name else: result.nas_folder = make_nas_folder_name(anime.subject, anime.start_date) result.success = True result.message = ( f"**{anime.subject}** ({anime.original_subject})\n" f"자막 제작자: {len(result.captions)}명 | " f"토렌트: {len(result.torrents)}건\n" f"NAS 폴더: `{result.nas_folder}`" ) return result async def download( self, title: str, mode: str = "auto", episode: Optional[int] = None, ) -> DownloadResult: """애니 다운로드 실행. Args: title: 한글 제목 mode: "auto" — 영상+자막 무조건 다운 (기본) "sub_required" — 자막 있는 에피소드만 영상 다운 "sub_only" — 자막만 "video_only" — 영상만 episode: 특정 에피소드만 (None이면 빠진 것 전부) """ # 먼저 검색 result = await self.search(title) if not result.success: return result anime = result.anime nas_folder = Path(self.nas_base) / result.nas_folder # ── 자막 다운로드 ── if mode in ("auto", "sub_only", "sub_required"): await self._download_subtitles(result, nas_folder, episode) # ── 영상 토렌트 추가 ── if mode in ("auto", "video_only"): await self._add_torrents(result, nas_folder, episode) elif mode == "sub_required": # 자막이 실제로 다운됐을 때만 영상 추가 if result.subtitles: await self._add_torrents(result, nas_folder, episode) else: result.errors.append("자막이 없어 영상 다운로드를 보류합니다.") # 결과 메시지 구성 parts = [result.message] if result.subtitles: parts.append(f"\n📝 자막 {len(result.subtitles)}건 다운로드 완료") if result.torrent_added: parts.append(f"\n🎬 토렌트 추가 완료 → `{nas_folder}`") if result.errors: parts.append(f"\n⚠️ 오류: " + "; ".join(result.errors)) result.message = "\n".join(parts) return result async def _download_subtitles( self, result: DownloadResult, nas_folder: Path, episode: Optional[int], ): """자막 다운로드 → 영상 폴더에 직접 저장 + 영상명 매칭 리네임. 기존 자막이 있는 에피소드는 건너뜀 (수동 자막 보호). """ # 영상 폴더에 직접 저장 (subtitles/ 하위 아님) nas_folder.mkdir(parents=True, exist_ok=True) # 기존 자막 파일이 있는 에피소드 스캔 → 스킵 대상 existing_sub_eps = set() sub_exts = {".ass", ".srt", ".ssa", ".sub", ".smi"} if nas_folder.exists(): for f in nas_folder.iterdir(): if f.suffix.lower() in sub_exts: ep = self._extract_episode(f.stem) if ep is not None: existing_sub_eps.add(ep) if existing_sub_eps: logger.info(f"기존 자막 에피소드 (스킵): {sorted(existing_sub_eps)}") for caption in result.captions: if not caption.website: continue if episode is not None and caption.episode != str(episode): continue try: subs = await self.sub_downloader.find_subtitles(caption.website) for sub in subs: if episode is not None and sub.episode is not None and sub.episode != episode: continue # 기존 자막이 있는 에피소드 스킵 if sub.episode is not None and sub.episode in existing_sub_eps: logger.info(f"자막 스킵 (기존 존재): {sub.episode}화 - {sub.filename}") continue try: await self.sub_downloader.download_file(sub, str(nas_folder)) result.subtitles.append(sub) except Exception as e: result.errors.append(f"자막 다운로드 실패 ({sub.filename}): {e}") except Exception as e: result.errors.append(f"자막 사이트 접근 실패 ({caption.name}): {e}") # 다운로드 후: 기존 영상 파일과 매칭하여 자막 리네임 self._rename_subtitles_to_match_videos(nas_folder, result) def _rename_subtitles_to_match_videos( self, folder: Path, result: DownloadResult ): """폴더 내 자막 파일을 영상 파일명에 맞게 리네임. 예: [ASW] Sousou no Frieren S2 - 03.mkv → 3화.ass 를 [ASW] Sousou no Frieren S2 - 03.ass 로 변경 """ import re as _re # 영상 파일 목록 (에피소드 → 파일명) video_exts = {".mkv", ".mp4", ".avi", ".webm"} videos = {} # episode_num -> video_path for f in folder.iterdir(): if f.suffix.lower() in video_exts: ep = self._extract_episode(f.stem) if ep is not None: videos[ep] = f if not videos: return # 자막 파일 리네임 sub_exts = {".ass", ".srt", ".ssa", ".sub"} for f in folder.iterdir(): if f.suffix.lower() not in sub_exts: continue ep = self._extract_episode(f.stem) if ep is not None and ep in videos: video_stem = videos[ep].stem new_name = f"{video_stem}{f.suffix}" new_path = folder / new_name if new_path != f and not new_path.exists(): try: f.rename(new_path) logger.info(f"자막 리네임: {f.name} → {new_name}") except Exception as e: logger.warning(f"자막 리네임 실패: {e}") @staticmethod def _extract_episode(text: str) -> Optional[int]: """텍스트에서 에피소드 번호 추출.""" import re as _re # 패턴 1: S01E03, S02E07 (SxxExx — 시즌+에피소드, 가장 먼저 체크) m = _re.search(r'[Ss]\d{1,2}[Ee](\d{1,4})', text) if m: return int(m.group(1)) # 패턴 2: "- 03", "- 06", "- 10v2" (torrent 파일명, v2 등 version suffix 허용) m = _re.search(r'[-–]\s*(\d{1,4})(?:v\d)?(?:\s|$|\.|\[|\()', text) if m: return int(m.group(1)) # 패턴 3: "3화", "03화" m = _re.search(r'(\d{1,4})\s*화', text) if m: return int(m.group(1)) # 패턴 4: "EP03", "Episode 3" m = _re.search(r'(?:EP|Episode)\s*(\d{1,4})', text, _re.IGNORECASE) if m: return int(m.group(1)) return None def _find_existing_nas_folder(self, korean_title: str, start_date: str = ""): """NAS에서 기존 폴더 찾기 — 제목 + 방영 시점(year/quarter) 기반. 같은 애니라도 방영 분기가 다르면 다른 시즌 → 매칭하지 않음. 예: '최애의 아이 3기'(26_1분기) ≠ [23_2분기]최애의아이 (1기) """ import re as _re from tools.title_matcher import get_quarter title_norm = _re.sub(r'[^\w]', '', korean_title.lower()) if len(title_norm) < 2: return None # 방영 분기 계산 anime_year, anime_quarter = get_quarter(start_date) try: all_folders = self.nas.list_anime_folders() except Exception as e: logger.warning(f"NAS 폴더 검색 실패: {e}") return None candidates = [] for folder in all_folders: folder_norm = _re.sub(r'[^\w]', '', folder.title.lower()) # 제목 부분 매칭 (양방향) if not (title_norm in folder_norm or folder_norm in title_norm): continue # 방영 분기 일치 확인 if anime_year and folder.year != anime_year: continue if anime_quarter and folder.quarter != anime_quarter: continue candidates.append(folder) if not candidates: return None best = candidates[0] logger.info(f"NAS 기존 폴더 발견: {best.folder_name}") return best @staticmethod def _extract_release_name(filename: str) -> str: """영상 파일명에서 릴리스 이름 추출. [ASW] Hime-sama Goumon no Jikan desu - 21 [1080p HEVC].mkv → 'Hime-sama Goumon no Jikan desu' """ import re as _re # 확장자 제거 name = _re.sub(r'\.[^.]+$', '', filename) # [그룹태그] 제거 name = _re.sub(r'^\[[^\]]*\]\s*', '', name) # 에피소드 번호 이후 제거: " - 21 [...]" name = _re.sub(r'\s*[-–]\s*\d+.*$', '', name).strip() # S02E09 패턴 제거 name = _re.sub(r'\s*S\d+E\d+.*$', '', name, flags=_re.IGNORECASE).strip() return name @staticmethod def _build_match_keywords( eng_default: str, eng_english: str, synonyms: list[str], original_title: str, ) -> list[str]: """Jikan 제목들에서 매칭용 키워드 추출. 예: "Sousou no Frieren 2nd Season" → ["Frieren", "Sousou no Frieren"] synonyms: ["Omagoto"] → ["Omagoto"] """ import re as _re keywords = [] # synonyms 중 짧은 것 (Omagoto 같은 약칭) for syn in synonyms: cleaned = syn.strip() if 3 <= len(cleaned) <= 30: keywords.append(cleaned.lower()) # eng_default에서 키워드 추출 (시즌 표기 제거) if eng_default: clean = _re.sub(r'\s*(2nd|3rd|\d+th)\s*Season.*$', '', eng_default, flags=_re.IGNORECASE).strip() clean = _re.sub(r'\s*S\d+$', '', clean).strip() if len(clean) >= 3: keywords.append(clean.lower()) # eng_english에서 콜론 앞 핵심 단어 if eng_english: short = eng_english.split(":")[0].strip() if len(short) >= 3: keywords.append(short.lower()) # 원제 (일본어) — 정규화 없이 원본으로 비교 if original_title and len(original_title) >= 2: import re as _re2 clean = _re2.sub(r'\s*第\d+期$', '', original_title).strip() if clean: keywords.append(clean.lower()) # 중복 제거 seen = set() unique = [] for k in keywords: if k not in seen: seen.add(k) unique.append(k) return unique @staticmethod def _title_contains_keyword(nyaa_title: str, keywords: list[str]) -> bool: """Nyaa 토렌트 제목에 키워드 중 하나라도 포함되는지 체크. 영문 키워드: 특수문자(하이픈, 따옴표) 제거 후 비교. 일본어 키워드: 원본 그대로 비교. """ import re as _re title_lower = nyaa_title.lower() # 영문 정규화 버전 title_norm = _re.sub(r'[^a-z0-9\s]', '', title_lower) for kw in keywords: if not kw or len(kw) < 2: continue # ASCII만 포함된 키워드 → 정규화 비교 kw_norm = _re.sub(r'[^a-z0-9\s]', '', kw) if kw_norm and len(kw_norm) >= 3 and kw_norm in title_norm: return True # 비ASCII(일본어 등) → 원본 비교 if not kw.isascii() and kw in title_lower: return True return False async def _add_torrents( self, result: DownloadResult, nas_folder: Path, episode: Optional[int], ): """토렌트 추가 — 빠진 에피소드 전부 다운로드. 릴리스 그룹 일관성: NAS 기존 파일의 릴리스 그룹(예: ASW)이 있으면 같은 그룹의 토렌트만 추가. 매칭 없으면 스킵. """ if not result.torrents: result.errors.append("매칭되는 토렌트가 없습니다.") return import math import re as _re # NAS 기존 에피소드 + 릴리스 그룹 스캔 existing_eps = set() existing_groups = [] # 기존 파일들의 릴리스 그룹 if nas_folder.exists(): video_exts = {".mkv", ".mp4", ".avi", ".webm", ".ts"} for f in nas_folder.iterdir(): if f.suffix.lower() in video_exts: ep = self._extract_episode(f.stem) if ep is not None: existing_eps.add(ep) # 릴리스 그룹 추출: [ASW], [SubsPlease] 등 m = _re.match(r'\[([^\]]+)\]', f.name) if m: existing_groups.append(m.group(1)) if existing_eps: logger.info(f"NAS 기존 에피소드: {sorted(existing_eps)}") # 기존 릴리스 그룹 결정 (가장 많이 등장하는 그룹) required_group = None if existing_groups: from collections import Counter group_counts = Counter(existing_groups) dominant_group, count = group_counts.most_common(1)[0] if count >= 2: # 2개 이상 파일에서 동일 그룹이면 확정 required_group = dominant_group logger.info(f"NAS 릴리스 그룹: [{required_group}] ({count}개 파일)") # 에피소드별 최고 점수 토렌트 그룹핑 ep_best: dict[int, tuple[int, object]] = {} # ep → (score, torrent) for t in result.torrents: ep = self._extract_episode(t.title) if ep is None: continue # 특정 에피소드 요청 시 해당 에피소드만 if episode is not None and ep != episode: continue # NAS 중복 스킵 if ep in existing_eps: continue # VOSTFR 제외 title_upper = t.title.upper() if "VOSTFR" in title_upper or "VOSTA" in title_upper: continue # 릴리스 그룹 일관성 필터: NAS에 특정 그룹이 있으면 같은 그룹만 허용 if required_group: if f"[{required_group}]" not in t.title: continue # 스코어링 score = 0 if "[ASW]" in t.title: score += 100 if "HEVC" in title_upper or "X265" in title_upper: score += 50 if "1080P" in title_upper: score += 20 if t.seeders > 0: score += int(math.log(t.seeders) * 5) if ep not in ep_best or score > ep_best[ep][0]: ep_best[ep] = (score, t) if not ep_best: if episode is not None: result.errors.append(f"{episode}화 토렌트를 찾지 못했습니다.") elif required_group: result.errors.append( f"새로 다운로드할 에피소드가 없습니다 " f"([{required_group}] 릴리스 기준, 모두 NAS에 존재하거나 미출시)." ) else: result.errors.append("새로 다운로드할 에피소드가 없습니다 (모두 NAS에 존재).") return # 에피소드 순서대로 추가 added_count = 0 for ep in sorted(ep_best.keys()): _, torrent = ep_best[ep] try: success = await self.qbit.add_torrent( magnet_or_url=torrent.magnet_link, save_path=str(nas_folder), category="anime", tags=result.anime.subject if result.anime else "", ) if success: added_count += 1 logger.info(f"토렌트 추가: ep{ep} - {torrent.title[:50]}") else: result.errors.append(f"ep{ep} 토렌트 추가 실패") except Exception as e: result.errors.append(f"ep{ep} qBittorrent 오류: {e}") result.torrent_added = added_count > 0 if added_count > 0: new_eps = sorted(ep_best.keys()) result.message += f"\n📥 {added_count}개 에피소드 추가: {new_eps}" @staticmethod def _select_best_torrent(candidates: list, existing_eps: set = None): """ASW HEVC 우선으로 최적 토렌트 선택 (검색 결과 표시용). 스코어링: +100 [ASW] 그룹 +50 HEVC / x265 코덱 +20 1080p 해상도 +log(시더수) * 5 (최대 ~30) VOSTFR / non-English 릴리스는 완전 제외. 기존 에피소드는 스킵. """ import re as _re import math if existing_eps is None: existing_eps = set() scored = [] for t in candidates: title_upper = t.title.upper() if "VOSTFR" in title_upper or "VOSTA" in title_upper: continue score = 0 if "[ASW]" in t.title: score += 100 if "HEVC" in title_upper or "X265" in title_upper: score += 50 if "1080P" in title_upper: score += 20 if t.seeders > 0: score += int(math.log(t.seeders) * 5) scored.append((score, t)) if not scored: return None scored.sort(key=lambda x: x[0], reverse=True) return scored[0][1] async def get_status(self) -> list[dict]: """현재 다운로드 큐 상태.""" try: torrents = await self.qbit.list_torrents(category="anime") return [ { "name": t.name, "progress": f"{t.progress * 100:.1f}%", "state": t.state, "size": f"{t.size / (1024**3):.2f} GB" if t.size > 0 else "?", "speed": f"{t.download_speed / (1024**2):.1f} MB/s" if t.download_speed > 0 else "0", "eta": f"{t.eta // 60}분" if t.eta > 0 else "∞", "path": t.save_path, } for t in torrents ] except Exception as e: logger.error(f"qBittorrent 상태 조회 오류: {e}") return [] async def batch_download( self, mode: str = "auto", sub_filter: bool = True, ) -> list[DownloadResult]: """이번 분기 애니 일괄 다운로드. Args: mode: 다운로드 모드 ("auto", "sub_only", "video_only") sub_filter: True면 Anissia에 자막이 등록된 애니만 처리 Returns: 각 애니별 DownloadResult 리스트 """ # 1. NAS에서 이번 분기 애니 폴더 스캔 current_folders = self.nas.get_current_quarter_anime() if not current_folders: logger.warning("이번 분기 NAS 폴더 없음") return [] logger.info(f"이번 분기 NAS 폴더: {len(current_folders)}개") results = [] for folder in current_folders: title = folder.title logger.info(f"\n{'='*40}") logger.info(f"처리 중: {folder.folder_name}") try: # 2. Anissia에서 검색 → 자막 정보 확인 anime_list = await self.anissia.search_anime(title) if not anime_list: logger.info(f" Anissia 검색 결과 없음 → 건너뜀") continue anime = anime_list[0] # 3. 자막 필터: Anissia에 자막 제작자가 있는지 확인 if sub_filter: captions = await self.anissia.get_captions(anime.anime_no) if not captions: logger.info(f" 자막 없음 → 건너뜀") continue logger.info(f" 자막 {len(captions)}건 발견 → 다운로드 진행") # 4. 기존 에피소드 확인 from pathlib import Path as _Path nas_path = _Path(self.nas_base) / folder.folder_name existing_eps = set() video_exts = {".mkv", ".mp4", ".avi", ".webm", ".ts"} if nas_path.exists(): for f in nas_path.iterdir(): if f.suffix.lower() in video_exts: ep = self._extract_episode(f.stem) if ep is not None: existing_eps.add(ep) # 5. 다운로드 실행 result = await self.download(title, mode=mode) results.append(result) status = "✅" if result.success else "❌" logger.info(f" {status} {result.message[:100]}") except Exception as e: logger.error(f" 오류 ({folder.folder_name}): {e}") err_result = DownloadResult( success=False, message=f"{folder.folder_name}: 오류 - {e}", errors=[str(e)], ) results.append(err_result) return results # ── CLI 진입점 ── if __name__ == "__main__": import sys import asyncio import json args = sys.argv[1:] pipeline = AnimePipeline() async def main(): if not args: print("사용법: python tools/anime_pipeline.py [search|download|batch|status] [옵션]") return if args[0] == "search" and len(args) > 1: # python tools/anime_pipeline.py search "프리렌" title = " ".join(args[1:]) result = await pipeline.search(title) print(result.message) if result.errors: print(f"⚠️ 오류: {'; '.join(result.errors)}") elif args[0] == "download" and len(args) > 1: # python tools/anime_pipeline.py download "프리렌" [--mode auto] [--episode 10] title_parts = [] mode = "auto" episode = None i = 1 while i < len(args): if args[i] == "--mode" and i + 1 < len(args): mode = args[i + 1] i += 2 elif args[i] == "--episode" and i + 1 < len(args): episode = int(args[i + 1]) i += 2 else: title_parts.append(args[i]) i += 1 title = " ".join(title_parts) result = await pipeline.download(title, mode=mode, episode=episode) print(result.message) elif args[0] == "batch": # python tools/anime_pipeline.py batch [--no-sub-filter] [--mode auto] mode = "auto" sub_filter = True i = 1 while i < len(args): if args[i] == "--no-sub-filter": sub_filter = False i += 1 elif args[i] == "--mode" and i + 1 < len(args): mode = args[i + 1] i += 2 else: i += 1 print(f"📦 이번 분기 배치 다운로드 시작 (자막 필터: {'ON' if sub_filter else 'OFF'})") results = await pipeline.batch_download(mode=mode, sub_filter=sub_filter) success = sum(1 for r in results if r.success) failed = sum(1 for r in results if not r.success) print(f"\n📊 완료: {success}건 성공, {failed}건 실패 (총 {len(results)}건)") for r in results: icon = "✅" if r.success else "❌" title = r.anime.subject if r.anime else "?" print(f" {icon} {title}: {r.message[:80]}") elif args[0] == "status": # python tools/anime_pipeline.py status status = await pipeline.get_status() if not status: print("🎬 다운로드 중인 항목 없음") else: for s in status: print(f" {s['progress']} | {s['name'][:50]} | {s['speed']} | ETA: {s['eta']}") else: print("사용법: python tools/anime_pipeline.py [search|download|batch|status] [옵션]") asyncio.run(main())