# 애니 자동 다운로드 파이프라인 — 아키텍처 & 운영 가이드 > **최종 업데이트**: 2026-03-15 > **파일**: `tools/anime_pipeline.py` (1252줄) > **의존**: `anissia_client.py`, `nyaa_client.py`, `qbit_client.py`, `subtitle_downloader.py`, `title_matcher.py`, `nas_scanner.py` --- ## 1. 전체 흐름 (Pipeline Flow) ``` Discord/CLI "프리렌 다운받아" │ ▼ ┌─── resolve() ──────────────────────────────────────────┐ │ Step 1: NAS 스캔 → 기존 영상/자막/릴리스그룹 파악 │ │ Step 2: Anissia 매칭 (3단계 fallback) │ │ Step 3: 자막 제작자 조회 │ │ Step 4: Nyaa 토렌트 검색 │ │ Step 5: 에피소드 오프셋 감지 (AniList) │ │ → AnimeWorkUnit 완성 │ └────────────────────┬───────────────────────────────────┘ │ ▼ ┌─── _execute_download() ───────────────────────────────┐ │ 1. 자막 다운로드 (_download_subtitles) │ │ ├─ 기존 자막 에피소드 스킵 (pre-skip) │ │ ├─ Blogspot Atom Feed → 전체 에피소드 URL 발견 │ │ └─ Google Drive/Tistory/Naver 자막 파싱 │ │ │ │ 2. 토렌트 추가 (_add_torrents) │ │ ├─ 오프셋 적용 (절대→시즌 번호 변환) │ │ ├─ 릴리스 그룹 필터 (ASW 강제 등) │ │ └─ 에피소드별 최고 점수 토렌트 선택 │ │ │ │ 3. 토렌트 완료 대기 + 자동 삭제 │ │ 4. 자막 리네임 (영상 파일명에 맞춤) │ └───────────────────────────────────────────────────────┘ ``` --- ## 2. 데이터 모델 ### AnimeWorkUnit — resolve()가 완성하는 정보 세트 ```python @dataclass class AnimeWorkUnit: # NAS 현황 nas_folder: str # "[26_1분기]장송의프리렌2기" nas_path: Path # \\192.168.10.10\NasData\Video\Animation\[26_...] existing_videos: list # 기존 영상 파일명 existing_subs: list # 기존 자막 파일명 existing_eps: set[int] # 에피소드 번호 (절대 번호) release_group: str # "ASW" (NAS 파일에서 추출) release_name: str # "Sousou no Frieren S2" (영문 릴리스명) episode_offset: int # 절대→시즌 변환 오프셋 (예: 12) # Anissia 정보 anime: AnimeInfo # 제목, start_date, 원제 등 captions: list[CaptionInfo] # 자막 제작자 + URL # Nyaa 검색 nyaa_keywords: list[str] torrents: list[TorrentResult] ``` ### DownloadResult — 실행 결과 ```python @dataclass class DownloadResult: success: bool subtitles: list[SubtitleFile] # 다운된 자막 torrent_added: bool torrent_hashes: list[str] # qBit 관리용 errors: list[str] message: str # 사용자 표시용 ``` --- ## 3. resolve() 상세 — 5단계 정보 수집 ### Step 1: NAS 스캔 ``` 입력: "장송의 프리렌" → NasScanner.list_anime_folders() → compact 매칭: "장송의프리렌" in "[26_1분기]장송의프리렌2기" → 기존 파일 분석: 영상: [ASW] Sousou no Frieren S2 - 01 ... .mkv → ep1 자막: [ASW] Sousou no Frieren S2 - 01 ... .ass → ep1 릴리스 그룹: "ASW" (Counter.most_common) 릴리스명: "Sousou no Frieren S2" (파일명에서 추출) ``` > **시행착오**: 초기에는 폴더명만으로 매칭했으나, "프리렌"이 1기/2기 모두 매칭되는 문제 발생. `start_date` 기반 재검증 로직 추가 (Step 2에서 Anissia start_date와 NAS 폴더의 분기를 비교). ### Step 2: Anissia 매칭 (3단계 Fallback) ``` 1차: Anissia API 직접 검색 - compact 매칭: 공백/특수문자 제거 후 비교 - "장송의프리렌" → "장송의 프리렌 2기" ✓ 2차: 웹 검색 fallback - DuckDuckGo "장송의프리렌 애니" → 후보 추출 - 후보별 Anissia 재검색 3차: NAS 파일명 → Jikan API → Anissia - release_name "Sousou no Frieren S2" → Jikan API (MyAnimeList) → 일본어 제목 → Anissia 재검색 ``` > **시행착오**: Anissia는 자체 ID(`anime_no`)를 사용하여 외부 매핑이 불가. 타이틀 매칭이 유일한 방법이라 3단계 fallback이 필수. ### Step 3: 자막 정보 조회 ``` Anissia API: GET /anime/caption/animeNo/{id} → 자막 제작자 목록 + 최신 에피소드 URL ``` > **주의**: Anissia는 **최신 에피소드 URL 1개만** 제공. 전체 에피소드 자막 확보는 Step이 아닌 다운로드 단계에서 Blogspot Atom Feed로 해결 (아래 참조). ### Step 4: Nyaa 토렌트 검색 ``` 전략 1 (정확): release_name으로 직접 검색 "Sousou no Frieren S2" → Nyaa RSS → 75건 중 51건 매칭 전략 2 (Fallback): Jikan 영어 제목 + "ASW HEVC" 검색 → 키워드 매칭으로 필터 ``` > **시행착오**: 초기에 `matched[:30]` 제한으로 ASW HEVC가 잘릴 수 있었음. ASW+HEVC 우선 정렬 후 50건으로 확대. ### Step 5: 에피소드 오프셋 감지 ``` 핵심 문제: Nyaa 번호 체계가 애니마다 다름 - 프리렌 S2: [ASW] Sousou no Frieren S2 - 01 → 시즌 상대 (ep1) - 공주님 고문 2기: [ASW] Hime-sama Goumon - 21 → 절대 번호 (ep21) 감지 알고리즘: 1. Nyaa 토렌트 제목에 "S2", "S3" 등 시즌 태그가 있는가? → 있으면: offset = 0 (시즌 상대 번호) → 없으면: 2번으로 2. AniList GraphQL API로 prequel 체인 역추적 - Page 쿼리로 검색 (복수 결과) - PREQUEL 관계가 있는 결과 우선 선택 (2기 이상) - prequel → 그 prequel → ... 역추적하며 episodes 합산 - 공주님 고문 2기 → prequel 12ep → offset = 12 3. 검증: Nyaa min_ep > offset 이면 절대 번호 확정 - min_ep=17 > offset=12 → 절대 번호 확정 ✓ ``` > **시행착오**: > - 처음에 AniList `Media` (단일) 쿼리 사용 → 1기가 반환됨 (prequel 없음 → offset=0). `Page` (복수) 쿼리 + prequel 우선으로 수정. > - 나무위키 등 외부 소스도 검토했으나, AniList GraphQL이 programmatic 접근에 가장 적합. --- ## 4. 자막 다운로드 상세 ### 4.1 기존 자막 스킵 (Pre-skip) ```python existing_sub_eps = set() for sf in unit.existing_subs: ep = extract_episode(sf) # 절대 번호 (ex: 13) existing_sub_eps.add(ep) if offset > 0 and ep > offset: existing_sub_eps.add(ep - offset) # 시즌 번호도 추가 (ex: 1) ``` > **시행착오**: NAS 파일명은 절대번호 `ep13`, Feed는 시즌번호 `ep1` → 번호 공간 불일치로 스킵 안 됨. **양쪽 번호를 모두 set에 추가**하는 것으로 해결. ### 4.2 Blogspot Atom Feed 자막 URL 발견 ``` 문제: Anissia는 최신 에피소드 URL만 제공 → ep8 URL만 있고, ep1~7 URL을 모름 시도한 접근들: ❌ URL 패턴 추론 (blogspot URL이 예측 불가능한 구조) ❌ Blogspot 아카이브 페이지 크롤링 (불안정) ✅ Blogspot Atom Feed API (/feeds/posts/default?alt=json) 동작: 1. Anissia가 준 URL에서 블로그 도메인 추출 2. Atom Feed API로 전체 포스트 목록 조회 3. 알려진 URL의 포스트에서 애니 이름 추출 4. 같은 애니 이름의 모든 포스트 → (url, episode) 튜플 5. 기존 자막 episode와 비교 → 없는 것만 다운로드 ``` ### 4.3 자막 파일 처리 ``` 자막 소스별 파싱: - Google Drive: parse_google_drive_links() → 직접 다운로드 URL - Tistory: download+cookie 기반 파일 추출 - Naver Blog: iframe → 실제 URL 추출 - ZIP 파일: 자동 해제 → .ass/.srt/.smi 추출 에피소드 번호 추출 우선순위: 1. sub.episode (파일명/링크 텍스트에서) 2. _extract_episode(filename) fallback 3. discovered_ep (Atom Feed에서) ``` --- ## 5. 토렌트 관리 상세 ### 5.1 에피소드 선택 + 점수 ```python score = 0 if "[ASW]" in title: score += 100 # 릴리스 그룹 if "HEVC" in title: score += 50 # 코덱 if "1080P" in title: score += 20 # 해상도 if seeders > 0: score += log(seeders) * 5 # 시드 수 # VOSTFR/VOSTA (프랑스어 자막) 자동 제외 # 릴리스 그룹 강제 (NAS에 2개 이상 같은 그룹이면) ``` ### 5.2 오프셋 적용 ```python raw_ep = extract_episode(torrent.title) # 절대 번호 (ex: 21) season_ep = raw_ep - offset # 시즌 번호 (ex: 9) # existing_eps는 절대번호 → raw_ep으로 비교 if raw_ep in existing_eps: skip # ep_best dict는 season_ep 키로 관리 (사용자 표시용) ``` ### 5.3 토렌트 라이프사이클 ``` 추가 → 완료 대기 (poll 10초, 타임아웃 600초) → 자동 삭제 상태 흐름: pending → completed (progress >= 1.0) → delete_torrent(files=False) pending → failed (error/missingFiles) → 에러 보고 pending → timeout → 에러 보고 ``` --- ## 6. 자막 리네임 (offset-aware) ``` 파일 매칭 로직: 1. videos = {ep → path} 영상 파일에서 ep 추출 2. subs에서 ep 추출 3. ep가 videos에 있으면 → 직접 매칭 4. ep + offset가 videos에 있으면 → offset 매칭 (ex: 자막 "8화" → ep8, 영상 "- 20" → ep20, 8+12=20 ✓) 5. 리네임: 고문시간2 8화.ass → [ASW] Hime-sama ... - 20 ... .ass 중복 처리: - 대상 파일이 이미 존재 → 원본(중복) 삭제 ``` --- ## 7. 배치 다운로드 (batch_download) ``` 1. NAS 이번 분기 폴더 스캔 (get_current_quarter_anime) 2. 캐시 워밍업 (NAS 폴더 + Anissia 스케줄) 3. 전체 resolve (asyncio.gather — 병렬) 4. 모드 결정: - sub_filter=True & 자막 없음 → video_only - 자막 있음 → 지정 모드 사용 5. 순차 다운로드 (토렌트 충돌 방지) ``` --- ## 8. 외부 API 의존 관계 | API | 용도 | 비고 | |-----|------|------| | **Anissia** (`api.anissia.net`) | 스케줄, 자막제작자 | 한국 애니 자막 DB. anime_no 기반 | | **Nyaa** (`nyaa.si`) | 토렌트 검색 | RSS feed. 인증 불필요 | | **qBittorrent** (로컬) | 토렌트 관리 | Web API. 로컬 인스턴스 | | **AniList** (`graphql.anilist.co`) | 시즌 오프셋 | GraphQL. 인증 불필요 | | **Jikan/MAL** | 제목 번역 | REST API. title_matcher에서 사용 | | **DuckDuckGo** | 타이틀 검색 | fallback용 | | **Blogspot Atom** | 자막 URL 발견 | 블로거별 Feed | --- ## 9. 시행착오 기록 (Lessons Learned) ### 9.1 에피소드 번호 체계 문제 - **증상**: 공주님 고문 2기에서 토렌트가 매칭되지 않음 - **원인**: Nyaa가 절대번호(ep21)를 사용하는데, NAS 폴더는 2기 기준으로 ep1부터 예상 - **해결**: AniList prequel 체인 + S-tag 감지로 자동 판별 - **교훈**: **모든 번호 비교에서 "어떤 번호 공간인가?"를 명확히 해야 함** ### 9.2 자막 재다운로드 문제 - **증상**: 이미 있는 자막을 매번 다시 다운로드 - **원인 1**: `sub.episode`이 None → 스킵 로직 미작동 → 파일명에서 fallback 추출 - **원인 2**: existing_sub_eps가 절대번호, Feed가 시즌번호 → 번호 공간 불일치 - **해결**: 3단계 episode 추출 + 양방향 번호 set - **교훈**: **스킵 로직은 HTTP 요청 전에 배치해야 함** (pre-skip) ### 9.3 Blogspot URL 예측 불가 - **증상**: URL 패턴(`/2026/01/title-ep1.html`)으로 이전 에피소드 URL 추론 시도 → 실패 - **원인**: Blogspot URL은 날짜+slug 기반, slug가 블로거마다 다름 - **해결**: Blogspot Atom Feed API (`/feeds/posts/default?alt=json`)로 전체 포스트 검색 - **교훈**: **크롤링보다 API를 먼저 찾아야 함** ### 9.4 AniList 검색 시 1기 매칭 - **증상**: "Hime-sama Goumon" 검색 시 1기가 반환 → prequel 없음 → offset=0 - **원인**: AniList `Media` 쿼리는 단일 결과만 반환 (보통 1기) - **해결**: `Page` 쿼리로 복수 결과 → PREQUEL 관계가 있는 결과 우선 선택 - **교훈**: **외부 API는 "가장 관련성 높은 결과"가 내가 원하는 결과가 아닐 수 있음** ### 9.5 자막 리네임 미작동 - **증상**: `고문시간2 8화.ass`가 `[ASW]...- 20...` 영상과 매칭되지 않음 - **원인**: 자막 ep=8, 영상 ep=20, 직접 비교만 존재 - **해결**: `ep + offset` 시도 (8+12=20) → 매칭 성공 - **교훈**: **offset은 파이프라인 전체에 전파해야 함** (resolve, skip, add_torrent, rename 전부) ### 9.6 ASW HEVC 토렌트 잘림 - **증상**: ep3-4 토렌트가 다운로드 대상에서 빠짐 - **원인**: `matched[:30]` 제한에서 non-ASW 결과가 앞에 오면 ASW가 잘림 - **해결**: ASW+HEVC 우선 정렬 후 50건으로 확대 - **교훈**: **정렬 → 필터 → 제한 순서가 중요** --- ## 10. 다운로드 모드 | 모드 | 자막 | 영상 | 용도 | |------|------|------|------| | `auto` | ✅ | ✅ | 기본 — 자막+영상 모두 | | `sub_only` | ✅ | ❌ | 자막만 업데이트 | | `video_only` | ❌ | ✅ | 영상만 다운로드 | | `sub_required` | ✅ | 자막 있을 때만 | 자막 있는 경우만 영상도 다운 |