From 8aee8b2b6eed978c0af627ed3e256ac71226c137 Mon Sep 17 00:00:00 2001 From: CD Date: Sun, 15 Mar 2026 18:30:33 +0900 Subject: [PATCH] wiki: add anime pipeline architecture guide --- Anime-Pipeline.md | 353 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 Anime-Pipeline.md diff --git a/Anime-Pipeline.md b/Anime-Pipeline.md new file mode 100644 index 0000000..420b5ff --- /dev/null +++ b/Anime-Pipeline.md @@ -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` | ✅ | 자막 있을 때만 | 자막 있는 경우만 영상도 다운 |