Files
variet-agent/docs/anime_pipeline.md

14 KiB

애니 자동 다운로드 파이프라인 — 아키텍처 & 운영 가이드

최종 업데이트: 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()가 완성하는 정보 세트

@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 — 실행 결과

@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)

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 에피소드 선택 + 점수

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 오프셋 적용

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 자막 있을 때만 자막 있는 경우만 영상도 다운