Files
variet-agent/tools/anime_pipeline.py

788 lines
30 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.
"""애니메이션 자동화 파이프라인.
전체 흐름:
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())