wiki: add anime pipeline architecture guide

2026-03-15 18:30:33 +09:00
parent 32403f9a85
commit 8aee8b2b6e

353
Anime-Pipeline.md Normal file

@@ -0,0 +1,353 @@
# 애니 자동 다운로드 파이프라인 — 아키텍처 & 운영 가이드
> **최종 업데이트**: 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` | ✅ | 자막 있을 때만 | 자막 있는 경우만 영상도 다운 |