- tools/anissia_client.py: Anissia API 클라이언트 (편성표/자막) - tools/nyaa_client.py: Nyaa.si RSS 토렌트 검색 - tools/qbit_client.py: qBittorrent Web API 클라이언트 - tools/subtitle_downloader.py: Google Drive/Tistory/Naver 자막 파서 - tools/title_matcher.py: 제목 매칭 + NAS 폴더명 생성 - tools/anime_pipeline.py: 전체 파이프라인 오케스트레이터 - tools/nas_scanner.py: NAS 폴더/파일 스캔 - prompts/unified.md: anime 모드 추가 (AI 평문 의도 분류) - api/discord_bot.py: AI 평문 anime 핸들러 + /anime 슬래시 커맨드 - config.py: qBittorrent/NAS 설정 추가 - .agents/: agent_guide 워크플로우 통합 - docs/devlog: 세션 기록
245 lines
8.7 KiB
Python
245 lines
8.7 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,
|
|
)
|
|
|
|
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. Nyaa 토렌트 검색 (원제 로마자로)
|
|
try:
|
|
from tools.title_matcher import japanese_to_romaji
|
|
romaji_title = japanese_to_romaji(anime.original_subject)
|
|
|
|
# 먼저 로마자로 검색
|
|
torrents = await self.nyaa.search(romaji_title)
|
|
if not torrents:
|
|
# 원제 그대로 검색
|
|
torrents = await self.nyaa.search(anime.original_subject)
|
|
|
|
# 제목 매칭 필터링
|
|
matched = match_titles(
|
|
anime.subject, anime.original_subject, torrents, threshold=0.3
|
|
)
|
|
result.torrents = matched[:20] # 상위 20개
|
|
except Exception as e:
|
|
result.errors.append(f"Nyaa 검색 오류: {e}")
|
|
|
|
# NAS 폴더명 생성
|
|
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_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"):
|
|
await self._download_subtitles(result, nas_folder, episode)
|
|
|
|
# ── 영상 토렌트 추가 ──
|
|
if mode in ("auto", "video_only"):
|
|
force = (mode == "video_only")
|
|
await self._add_torrents(result, nas_folder, episode, force=force)
|
|
|
|
# 결과 메시지 구성
|
|
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],
|
|
):
|
|
"""자막 다운로드 처리."""
|
|
sub_dir = nas_folder / "subtitles"
|
|
|
|
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
|
|
try:
|
|
await self.sub_downloader.download_file(sub, str(sub_dir))
|
|
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}")
|
|
|
|
async def _add_torrents(
|
|
self,
|
|
result: DownloadResult,
|
|
nas_folder: Path,
|
|
episode: Optional[int],
|
|
force: bool = False,
|
|
):
|
|
"""토렌트 추가 처리."""
|
|
if not result.torrents:
|
|
result.errors.append("매칭되는 토렌트가 없습니다.")
|
|
return
|
|
|
|
# 에피소드 필터링
|
|
candidates = result.torrents
|
|
if episode is not None:
|
|
candidates = [t for t in candidates if t.episode == episode]
|
|
if not candidates:
|
|
result.errors.append(f"{episode}화 토렌트를 찾지 못했습니다.")
|
|
return
|
|
|
|
# auto 모드 기본 조건: 자막이 있어야 영상 다운로드 (force면 무시)
|
|
if not force and not result.captions and not result.subtitles:
|
|
# 자막이 없으면 사용자에게 안내만
|
|
result.errors.append("자막이 없어 영상 다운로드를 보류합니다. /anime video로 강제 다운로드 가능")
|
|
return
|
|
|
|
# 최상위 1개 (가장 시더 많은) 추가
|
|
best = candidates[0]
|
|
try:
|
|
success = await self.qbit.add_torrent(
|
|
magnet_or_url=best.magnet_link,
|
|
save_path=str(nas_folder),
|
|
category="anime",
|
|
tags=result.anime.subject if result.anime else "",
|
|
)
|
|
result.torrent_added = success
|
|
if not success:
|
|
result.errors.append("qBittorrent 토렌트 추가 실패")
|
|
except Exception as e:
|
|
result.errors.append(f"qBittorrent 오류: {e}")
|
|
|
|
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 []
|