10 KiB
10 KiB
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:
{
"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)
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 등)
- Google Drive (
download_file(sub, save_dir):- 1차 스킵:
sub.filename(Google Drive:subtitle_{fileId}) - HTTP GET + Content-Disposition → 실제 파일명 결정
- 2차 스킵: 실제 파일명으로 존재 체크 (핵심)
- write_bytes → ZIP이면 자동 해제
- 1차 스킵:
QBitClient (tools/qbit_client.py)
login()→ SID 쿠키 획득add_torrent(magnet, save_path, category, tags)→ torrents/addlist_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 또는 카나 테이블 fallbacknormalize_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참조
새 도구 추가
tools/에BaseTool상속 클래스 생성 → 자동 등록.gemini/skills/에 SKILL.md 생성 → Gemini CLI가 자동 발견