wiki: add anime pipeline architecture guide
353
Anime-Pipeline.md
Normal file
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` | ✅ | 자막 있을 때만 | 자막 있는 경우만 영상도 다운 |
|
||||||
Reference in New Issue
Block a user