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