# Architecture > Variet Agent — Hybrid Skill-Based AI Agent (v3) ## 전체 메시지 흐름 ``` Discord 메시지 → discord_bot.py:_classify_and_route() → GeminiCaller("unified") — timeout 120s → _parse_unified_response() — JSON 추출 → mode 분기: ├── "chat" → 즉답 Embed ├── "clarify" → 질문 Embed ├── "nextcloud" → nc_handler.handle_nc_message() ├── "anime" → anime_handler.handle_anime_message() └── "task" → _agent_call() → Gemini agent 모드 ``` --- ## 애니메이션 파이프라인 — 전체 실행 순서 ### 1단계: NLU 분류 (`prompts/unified.md`) Gemini가 반환하는 JSON: ```json { "mode": "anime", "action": "search|download|sub_only|video_only|status|schedule|list", "title": "한글 제목 (또는 빈 문자열)", "episode": null, "filter": "quarter:current sub:yes", "download_mode": "auto" } ``` > ⚠️ **주의**: `title`에 "이번분기" 같은 범위 한정자가 들어올 수 있음. 코드에서 반드시 방어 필요. ### 2단계: Handler 라우팅 (`handlers/anime_handler.py`) ```python pipeline = AnimePipeline() # 매 호출 새 인스턴스 (캐시 리셋됨) action = parsed["action"] title = parsed["title"] ``` | action | title | 실행 경로 | 반환 타입 | |--------|-------|-----------|-----------| | `list/scan/schedule` | any | NasScanner 직접 호출 → Embed + return | (없음, 직접 전송) | | `search` | 있음 | `pipeline.search(title)` | `DownloadResult` | | `search` | 없음 | 에러 Embed + return | (없음) | | `download` | 있음 | `pipeline.download(title)` → 실패 시 **batch fallback** | `DownloadResult` → `list[DownloadResult]` | | `download` | 없음 | `pipeline.batch_download(mode="auto")` | `list[DownloadResult]` | | `sub_only` | 있음 | `pipeline.download(title, mode="sub_only")` | `DownloadResult` | | `sub_only` | 없음 | `pipeline.batch_download(mode="sub_only")` | `list[DownloadResult]` | | `video_only` | 있음 | `pipeline.download(title, mode="video_only")` | `DownloadResult` | | `video_only` | 없음 | `pipeline.batch_download(mode="video_only")` | `list[DownloadResult]` | | `status` | - | `pipeline.get_status()` → Embed + return | (없음) | > ⚠️ **반환 타입 주의**: `batch_download`는 **`list[DownloadResult]`**, `download`는 **`DownloadResult`**. 렌더링 시 `isinstance(result, list)` 체크 필수. ### 3단계: resolve() — 4요소 세트 완성 (`anime_pipeline.py`) ``` resolve(title) → AnimeWorkUnit | None Step 1: NAS 기존 폴더 검색 └── _find_existing_nas_folder(title) └── _nas_folder_cache or nas.list_anime_folders() ← 동기 I/O ⚠️ └── title substring 매칭 + year/quarter 필터 └── 매칭 시: nas_folder, existing_videos, existing_subs, existing_eps, release_group 세팅 Step 2: Anissia 매칭 (fallback 체인) └── _resolve_anissia(title, unit) ├── 1차: anissia.search_anime(title) — 직접 substring + fuzzy ├── 2차: web_search_anime_title(title) — DuckDuckGo → 후보 → Anissia 재검색 └── 3차: fetch_title_via_jikan(title) — Jikan API → 일본어 제목 → Anissia 재검색 └── 실패 시 return None Step 3: NAS 폴더 재검증 └── anime.start_date로 정확한 시즌 폴더 재매칭 └── 없으면 make_nas_folder_name()으로 새 폴더명 생성 Step 4: 자막 정보 조회 └── anissia.get_captions(anime_no) → unit.captions Step 5: Nyaa 토렌트 검색 └── _resolve_nyaa(unit) └── 영어 제목 → Nyaa RSS 검색 → title 매칭 필터 → unit.torrents Step 6: 에피소드 오프셋 감지 └── _detect_episode_offset(unit) └── NAS 파일 에피소드 vs Nyaa 에피소드 비교 → offset 계산 └── _get_anilist_offset() — AniList API (동기 httpx ⚠️) ``` ### 4단계: _execute_download() — 다운로드 실행 ``` _execute_download(unit, mode, episode) → DownloadResult mode에 따른 실행: ├── mode in ("auto", "sub_only", "sub_required") │ └── _download_subtitles(result, unit, episode) │ ├── mode in ("auto", "video_only") │ └── _add_torrents(result, unit, episode) │ └── mode == "sub_required" └── 자막 있으면 → _add_torrents └── 자막 없으면 → "보류" 에러 ``` > ⚠️ **mode 결정이 핵심**: `"auto"` = 자막+영상 모두, `"sub_only"` = 자막만, `"video_only"` = 영상만. filter의 "sub"은 "자막도 포함" 의미 → `auto`여야 함. `sub_only`는 명시적 `action="sub_only"`일 때만. --- ## 자막 다운로드 상세 (`_download_subtitles`) ``` 1. NAS 폴더 실시간 스캔 → existing_sub_eps (에피소드 셋), existing_sub_files (파일명 셋) 2. unit.captions 순회: 각 caption에서: ├── _discover_episode_urls(caption.website, caption.episode) │ ├── blogspot → Atom Feed에서 전체 에피소드 URL 수집 │ └── 기타 → [(base_url, ep_num)] 단일 반환 │ └── 각 (url, discovered_ep): ├── 스킵 체크 1: discovered_ep in existing_sub_eps → 스킵 │ └── sub_downloader.find_subtitles(url) → list[SubtitleFile] 각 sub에 대해: ├── sub.episode 결정: filename → discovered_ep → None ├── 스킵 체크 2: sub.episode in existing_sub_eps → 스킵 ├── 스킵 체크 3: sub.filename in existing_sub_files → 스킵 │ └── sub_downloader.download_file(sub, nas_folder) ├── 1차 스킵: sub.filename(가짜)으로 존재 체크 ├── HTTP GET → Content-Disposition → 실제 파일명 결정 ├── 2차 스킵: 실제 파일명으로 존재 체크 ← 핵심 방어선 └── 존재 안 하면 write_bytes 3. _rename_subtitles_to_match_videos() — 자막을 영상 파일명에 맞게 리네임 ``` > ⚠️ **자막 스킵 5단계 체크**: (1) URL 에피소드, (2) 파일 에피소드, (3) 파일명 직접비교, (4) 가짜 파일명 존재, (5) **실제 파일명 존재** (Content-Disposition 후). Google Drive는 (1)(2)(3) 우회 가능하므로 **(5)가 최종 안전장치**. --- ## 토렌트 추가 상세 (`_add_torrents`) ``` 1. unit.torrents 순회: 각 torrent에서: ├── episode 추출: _extract_episode(t.title) ├── offset 적용: raw_ep - offset → season_ep ├── 스킵: raw_ep in unit.existing_eps (NAS 기존 에피소드) ├── 스킵: VOSTFR/VOSTA (프랑스자막 제외) ├── 스킵: required_group 불일치 └── 점수 매기기: ASW +100, HEVC +50, 1080p +20, seeders log 2. ep_best[season_ep] = 최고 점수 토렌트 3. 에피소드 순서대로 qbit.add_torrent() └── save_path = unit.nas_path, category = "anime" ``` --- ## batch_download() — 일괄 실행 ``` 1. nas.get_current_quarter_anime() → 이번 분기 NAS 폴더 목록 2. 캐시 미리 로드: ├── _nas_folder_cache = run_in_executor(nas.list_anime_folders) └── anissia.search_anime("_cache_warmup_") → 스케줄 캐시 3. asyncio.gather(*[resolve(folder.title) for folder in folders]) ⚠️ 동시성 제한 없음 — Jikan rate limit 주의 4. 모드 결정: └── sub_filter=True + captions 없음 → effective_mode = "video_only" 5. 순차 _execute_download(unit, effective_mode) 6. return list[DownloadResult] ``` --- ## 개별 도구 클라이언트 정리 ### AnissiaClient (`tools/anissia_client.py`) - `get_schedule(week)` → 요일별 편성표 (0=일~7=기타) - `get_all_schedule()` → 전체 요일 편성표 (세션당 1회 캐시) - `search_anime(keyword)` → substring + romaji + fuzzy 매칭 - `get_captions(anime_no)` → 자막 제작자 URL 목록 - **캐시**: `_schedule_cache` — 세션당 1회 로드 (매 pipeline 인스턴스 리셋) ### NyaaClient (`tools/nyaa_client.py`) - `search(query)` → RSS XML 파싱 → `list[TorrentResult]` - **기본 suffix**: "ASW HEVC" 자동 추가 - **magnet**: `urn:btih:{hash}` — tracker URL 없음 (DHT 의존) ### NasScanner (`tools/nas_scanner.py`) - `list_anime_folders(year, quarter)` → `rglob("*")` + `stat()` **동기 I/O** ⚠️ - `_scan_folder()` → video_files, subtitle_files 수집 (rglob 재귀) - `get_current_quarter_anime()` → 현재 분기 필터 - **폴더 형식**: `[YY_Q분기]제목` — `_parse_folder_name()`으로 파싱 ### SubtitleDownloader (`tools/subtitle_downloader.py`) - `find_subtitles(url)` → HTML 파싱: - Google Drive (`drive.google.com/file/d/` → `/uc?id=&export=download`) - Tistory (`blog.kakaocdn.net`) - Naver Blog (`download.blog.naver.com`) - 범용 (href에서 .ass/.srt/.zip 등) - `download_file(sub, save_dir)`: 1. 1차 스킵: `sub.filename` (Google Drive: `subtitle_{fileId}`) 2. HTTP GET + Content-Disposition → **실제 파일명 결정** 3. **2차 스킵: 실제 파일명으로 존재 체크** (핵심) 4. write_bytes → ZIP이면 자동 해제 ### QBitClient (`tools/qbit_client.py`) - `login()` → SID 쿠키 획득 - `add_torrent(magnet, save_path, category, tags)` → torrents/add - `list_torrents(category)` → 상태 조회 - `delete_torrent(hash, delete_files)` → 완료 후 정리 ### TitleMatcher (`tools/title_matcher.py`) - `web_search_anime_title(query)` → DuckDuckGo HTML → 한글 제목 후보 - `fetch_title_via_jikan(query)` → Jikan API → 영어/일본어/로마자 제목 - `japanese_to_romaji(text)` → pykakasi 또는 카나 테이블 fallback - `normalize_title()` → 소문자 + 특수문자 제거 + 시즌 정규화 - `title_similarity()` → SequenceMatcher 유사도 - `match_titles()` → Anissia↔Nyaa 제목 매칭 - `make_nas_folder_name(title, start_date)` → `[YY_Q분기]제목` - `get_quarter(date_str)` → (year, quarter) 추출 --- ## 아키텍처 결정 (변경 불가) - **Gemini CLI subprocess 영구 유지** (SDK/API 금지) - 상세: `.agent/references/conventions.md` 참조 ## 새 도구 추가 1. `tools/`에 `BaseTool` 상속 클래스 생성 → 자동 등록 2. `.gemini/skills/`에 SKILL.md 생성 → Gemini CLI가 자동 발견