244 lines
10 KiB
Markdown
244 lines
10 KiB
Markdown
# 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가 자동 발견
|