fix(anime): 파이프라인 5건 수정 — 에피소드 정규식(v2/S01E), 릴리스 그룹 필터, 자막 보호, 배치 다운로드, 타임아웃
This commit is contained in:
@@ -1,75 +1,80 @@
|
||||
# Architecture
|
||||
|
||||
> Variet Agent — Gemini CLI 기반 AI Agent Team 시스템
|
||||
> Variet Agent — Hybrid Skill-Based AI Agent (v3)
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
사용자가 디스코드에서 자연어 명령 → AI Agent Team이 코드 분석/분해/실행 → Gitea CI로 PR/빌드/배포.
|
||||
Gemini CLI를 서브프로세스(`asyncio.create_subprocess_exec`)로 래핑하여 역할별 독립 컨텍스트로 호출.
|
||||
사용자가 디스코드에서 자연어 명령 → Orchestrator NLU 분류 → 도구 실행 또는 Agent 통합 실행.
|
||||
Gemini CLI를 subprocess(`asyncio.create_subprocess_exec`)로 호출. **SDK/API 전환 금지.**
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
variet-agent/
|
||||
├── main.py # 진입점 (FastAPI + Discord Bot 동시 실행)
|
||||
├── config.py # .env 기반 설정 관리
|
||||
├── main.py # 진입점 (FastAPI + Discord Bot)
|
||||
├── config.py # .env 기반 설정 관리
|
||||
├── api/
|
||||
│ ├── server.py # FastAPI REST 서버
|
||||
│ ├── discord_bot.py # Discord Bot (NLU + PCRS 파이프라인 + 애니 핸들러)
|
||||
│ └── models.py # 요청/응답 모델
|
||||
│ ├── server.py # FastAPI REST 서버
|
||||
│ ├── discord_bot.py # Discord Bot (이벤트 핸들러 + 라우팅, ~310줄)
|
||||
│ └── models.py # 요청/응답 모델
|
||||
├── core/
|
||||
│ ├── task_pipeline.py # PCRS: Plan → Code → Review → Summarize
|
||||
│ ├── gemini_caller.py # Gemini CLI 래퍼 (text/agent 모드)
|
||||
│ ├── context_manager.py # 관련 파일 선별 + 토큰 예산 제어
|
||||
│ ├── project_indexer.py # 프로젝트 구조 스캔/캐시
|
||||
│ ├── workspace.py # 워크스페이스 관리 (채널 ↔ 프로젝트 매핑)
|
||||
│ ├── file_applier.py # 코드 변경 적용
|
||||
│ └── docs_manager.py # 문서/세션 기록
|
||||
├── tools/ # 자동화 도구 (애니메이션 파이프라인)
|
||||
│ ├── anime_pipeline.py # 통합 파이프라인 (검색/다운/자막/상태)
|
||||
│ ├── anissia_client.py # Anissia 편성표 API
|
||||
│ ├── nyaa_client.py # Nyaa 토렌트 검색
|
||||
│ ├── qbit_client.py # qBittorrent 제어
|
||||
│ ├── nas_scanner.py # NAS 파일 스캔
|
||||
│ ├── title_matcher.py # 제목 매칭 (로마지/퍼지)
|
||||
│ └── subtitle_downloader.py # 자막 다운로더
|
||||
├── integrations/
|
||||
│ ├── gitea_client.py # Gitea API (PR/이슈)
|
||||
│ ├── vikunja_client.py # Vikunja 태스크 관리
|
||||
│ └── ci_monitor.py # CI 결과 모니터링
|
||||
└── prompts/ # AI 역할별 프롬프트
|
||||
├── unified.md # NLU 분류 (chat/task/anime/clarify)
|
||||
├── planner.md # 태스크 분해
|
||||
├── coder.md # 코드 구현
|
||||
├── reviewer.md # 코드 리뷰
|
||||
└── summarizer.md # 총평 생성
|
||||
│ ├── orchestrator.py # NLU 분류 + 도구 라우팅
|
||||
│ ├── task_pipeline.py # ★ execute() 1회 호출 + 선택적 review()
|
||||
│ ├── gemini_caller.py # Gemini CLI 래퍼 (text/agent 모드)
|
||||
│ ├── context_manager.py # 관련 파일 선별 + 토큰 예산
|
||||
│ ├── project_indexer.py # 프로젝트 구조 스캔
|
||||
│ ├── workspace.py # 워크스페이스 관리
|
||||
│ ├── file_applier.py # 코드 변경 적용
|
||||
│ └── docs_manager.py # 문서/세션 기록
|
||||
├── handlers/ # Discord 핸들러
|
||||
│ ├── anime_handler.py # 애니 NLU + /anime 슬래시
|
||||
│ ├── task_handler.py # ★ Agent 1회 실행 + 결과 임베드 (~110줄)
|
||||
│ ├── commands.py # /workspace, /task 슬래시
|
||||
│ └── renderer.py # ToolResult → Discord Embed
|
||||
├── tools/ # 자동화 도구 (Plugin 패턴)
|
||||
│ ├── base.py # BaseTool 추상 기반
|
||||
│ ├── registry.py # ToolRegistry 자동 발견
|
||||
│ ├── anime_tool.py # AnimeTool(BaseTool)
|
||||
│ ├── anime_pipeline.py # 통합 파이프라인
|
||||
│ └── ... # 개별 클라이언트들
|
||||
├── .gemini/
|
||||
│ └── skills/ # ★ Gemini CLI Skill v2
|
||||
│ └── anime/
|
||||
│ └── SKILL.md # 도구 설명 + 사용법
|
||||
├── prompts/
|
||||
│ ├── unified.md # NLU 분류
|
||||
│ ├── agent.md # ★ 통합 에이전트 (plan+code+verify)
|
||||
│ └── reviewer.md # 독립 리뷰 (선택적)
|
||||
└── logs/
|
||||
└── variet.log
|
||||
```
|
||||
|
||||
## 핵심 모듈
|
||||
|
||||
| 모듈 | 역할 | 의존성 |
|
||||
|------|------|--------|
|
||||
| `discord_bot.py` | 사용자 인터페이스 + NLU 분류 | `workspace.py`, `gemini_caller.py`, `task_pipeline.py` |
|
||||
| `task_pipeline.py` | PCRS 오케스트레이션 (Inner/Outer 루프) | `gemini_caller.py`, `context_manager.py`, `project_indexer.py` |
|
||||
| `gemini_caller.py` | Gemini CLI 서브프로세스 호출 (text/agent) | `prompts/` |
|
||||
| `context_manager.py` | 태스크 기반 파일 선별 + 토큰 예산 | `project_indexer.py` |
|
||||
| `workspace.py` | 채널 ↔ 프로젝트 경로 매핑, workspaces.json 관리 | — |
|
||||
| `anime_pipeline.py` | 애니 자동화 통합 | `anissia_client.py`, `nyaa_client.py`, `qbit_client.py`, `nas_scanner.py` |
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
```
|
||||
Discord 메시지
|
||||
→ on_message()
|
||||
→ _unified_call() — NLU 분류 (chat/task/anime/clarify)
|
||||
├─ chat → 즉답
|
||||
├─ clarify → 질문 임베드
|
||||
├─ anime → _handle_anime() → AnimePipeline
|
||||
└─ task → _handle_task()
|
||||
→ TaskPipeline.plan() — Planner (태스크 분해)
|
||||
→ TaskPipeline.code_parallel() — Coder (에이전트 모드, cwd=프로젝트)
|
||||
→ TaskPipeline.planner_verify() — 내부 자가검증 (Inner Loop)
|
||||
→ TaskPipeline.batch_review() — Reviewer (Outer Loop)
|
||||
→ TaskPipeline.summarize() — 총평
|
||||
→ Discord Embed 보고
|
||||
→ Orchestrator.classify() — NLU 분류
|
||||
├── chat → 즉답
|
||||
├── clarify → 질문
|
||||
├── anime → AnimeTool + renderer
|
||||
└── task → TaskPipeline.execute() ← Agent 1회
|
||||
→ Gemini agent 모드 (plan+code+verify 통합)
|
||||
→ JSON 보고서 → Discord Embed
|
||||
```
|
||||
|
||||
## vs 이전 버전 (PCRS)
|
||||
|
||||
```
|
||||
v2: NLU → Planner → Coder×N → PlannerVerify → Reviewer → Summarizer (5~7 호출)
|
||||
v3: NLU → Agent 1회 (plan+code+verify 통합) → 선택적 Review (1~2 호출)
|
||||
```
|
||||
|
||||
## 새 도구 추가
|
||||
|
||||
1. `tools/`에 `BaseTool` 상속 클래스 생성 → 자동 등록
|
||||
2. `.gemini/skills/`에 SKILL.md 생성 → Gemini CLI가 자동 발견
|
||||
|
||||
## 아키텍처 결정 (변경 불가)
|
||||
|
||||
- **Gemini CLI subprocess 영구 유지** (SDK/API 금지)
|
||||
- 상세: `.agents/references/conventions.md` 참조
|
||||
|
||||
@@ -39,6 +39,17 @@ scope: (선택)
|
||||
- TODO 주석: `// TODO: 설명` 형식
|
||||
- 복잡한 로직에는 반드시 WHY(왜) 주석 추가
|
||||
|
||||
## 아키텍처 결정 사항 (NEVER 변경)
|
||||
|
||||
> [!CAUTION]
|
||||
> 아래 결정은 사용자가 2026-03-14에 명시적으로 확정한 것입니다.
|
||||
> 어떤 상황에서도 재논의하거나 대안을 제안하지 마세요.
|
||||
|
||||
- **Gemini CLI subprocess 방식 유지**: `gemini_caller.py`는 Gemini CLI를 `asyncio.create_subprocess_exec`로 호출하는 현재 방식을 영구 유지합니다.
|
||||
- ❌ Gemini SDK (`google-generativeai`) 전환 금지
|
||||
- ❌ Gemini REST API 직접 호출 금지
|
||||
- ❌ 위 방향성에 대한 제안/언급 금지
|
||||
|
||||
## 테스트
|
||||
|
||||
- 테스트 파일 위치: (예: `__tests__/` 또는 `*.test.js`)
|
||||
|
||||
@@ -58,26 +58,20 @@
|
||||
- **해결**: 6단계 fallback 전략 (romaji±suffix → 원제±suffix → 한글±suffix)
|
||||
- **주의**: 외부 API 검색 시 반드시 다중 전략 + suffix 토글 구현
|
||||
|
||||
### [2026-03-12] Gemini CLI MCP — settings.json 위치
|
||||
- **증상**: MCP 도구가 인식되지 않음 (프로젝트 .gemini/settings.json에 설정했으나 실패)
|
||||
- **원인**: Gemini CLI는 `cwd` 기준으로 `.gemini/settings.json`을 탐색. cwd가 다른 워크스페이스면 MCP 설정 못 찾음
|
||||
- **해결**: `~/.gemini/settings.json` (홈 레벨)에 mcpServers 등록. `_set_thinking_budget`에서 자동 관리
|
||||
- **주의**: MCP 서버 설정은 반드시 홈 레벨 settings.json에 등록. 프로젝트 레벨은 불충분
|
||||
### [2026-03-15] _extract_episode — v2/S01E10 패턴 미인식 → 중복 다운로드
|
||||
- **증상**: NAS에 ep9, 10이 있는데 재다운로드. ASW `- 10v2` 릴리스 에피소드 추출 실패
|
||||
- **원인**: 정규식 `[-–]\s*(\d{1,4})(?:\s|$|\.|\[)`이 v2 접미사 미처리. `S01E10`은 하이픈 패턴이 `S01`의 `01`을 먼저 매칭
|
||||
- **해결**: (1) SxxExx 패턴을 최우선 체크, (2) `(?:v\d)?` 추가로 version suffix 허용, (3) `\(` 추가로 SubsPlease 포맷 지원
|
||||
- **주의**: 에피소드 추출 정규식 수정 시 반드시 v2/v3 릴리스 + SxxExx + 한글(N화) + false positive(29-sai) 테스트 포함
|
||||
|
||||
### [2026-03-12] MCP 역할별 접근 제어 — 모든 역할이 MCP 도구 접근
|
||||
- **증상**: coder, reviewer 등 텍스트 전용 역할도 anime/infra MCP 도구에 접근 가능
|
||||
- **원인**: `_set_thinking_budget()`이 역할 무관하게 모든 MCP 서버를 settings.json에 등록
|
||||
- **해결**: `ROLE_MCP_ACCESS` dict 추가, agent만 MCP 등록, 나머지는 제거. `asyncio.Lock` 추가로 settings.json 레이스 방지
|
||||
- **주의**: settings.json은 글로벌 파일이므로, 역할 전환 시 반드시 이전 설정을 정리해야 함
|
||||
### [2026-03-15] _add_torrents — 릴리스 그룹 불일치 다운로드
|
||||
- **증상**: NAS에 `[ASW] HEVC` 파일(~300MB)만 있는데 `CR WEB-DL DUAL`(1.4GB) 릴리스를 다운
|
||||
- **원인**: 스코어링이 ASW에 +100을 주지만, ASW 릴리스가 없는 에피소드에서 아무 릴리스나 선택
|
||||
- **해결**: NAS 기존 파일의 릴리스 그룹(`[ASW]`)을 감지하여 같은 그룹만 허용. 매칭 없으면 스킵
|
||||
- **주의**: Nyaa 토렌트 제목에 영어+일본어 제목이 모두 포함되어 키워드 필터만으로는 불충분
|
||||
|
||||
### [2026-03-12] Gemini CLI yolo — 에이전트 자율성 위험
|
||||
- **증상**: 애니 다운로드 요청 시 에이전트가 음악/만화를 다운로드하고 엉뚱한 폴더 생성
|
||||
- **원인**: `--approval-mode yolo`는 MCP 도구 + 쉘 + 파일 조작 모두 무승인 허용. 프롬프트 제한은 강제력 없음
|
||||
- **해결 (검토중)**: MCP 대신 Python 도구를 소스코드로 직접 제공하여 Gemini CLI가 읽고 사용하는 방식 검토
|
||||
- **주의**: 프롬프트는 "부탁"이지 "강제"가 아님. 안전장치는 코드(도구) 레벨에 구현해야 함
|
||||
|
||||
### [2026-03-12] Nyaa 검색 — anime 카테고리 미지정
|
||||
- **증상**: 애니 검색 시 음악, 만화, 라이트노벨 등 무관한 토렌트가 다운로드됨
|
||||
- **원인**: `NyaaClient.search()` 기본 category가 `0_0`(전체). Music, Manga 등 포함
|
||||
- **해결 (예정)**: 기본 category를 `1_2`(Anime English) 또는 `1_0`(Anime 전체)로 변경
|
||||
- **주의**: 외부 검색 API 사용 시 반드시 카테고리/필터를 명시적으로 지정
|
||||
### [2026-03-15] _download_subtitles — 기존 자막 덮어쓰기 위험
|
||||
- **증상**: 이미 수동으로 배치한 자막 파일을 Anissia 자막으로 덮어쓸 수 있음
|
||||
- **원인**: 기존 자막 파일 존재 여부를 확인하지 않고 전 에피소드 자막 다운로드 시도
|
||||
- **해결**: NAS 폴더의 기존 자막 파일을 에피소드별로 스캔, 이미 있으면 스킵
|
||||
- **주의**: 자막 처리 시 사용자 수동 입력 파일의 보존을 항상 고려
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
description: Gitea API로 저장소 커밋/이슈/PR 현황을 조회하는 워크플로우
|
||||
---
|
||||
|
||||
# Gitea 저장소 현황 조회
|
||||
|
||||
서비스 정보는 `.agent/workflows/services.md` 참조.
|
||||
|
||||
// turbo-all
|
||||
|
||||
## 절차
|
||||
|
||||
1. 최근 커밋 조회 (최신 10개):
|
||||
```powershell
|
||||
$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"}
|
||||
$commits = Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/variet-agent/commits?limit=10&sha=main" -Headers $h
|
||||
$commits | ForEach-Object { Write-Host "$($_.sha.Substring(0,7)) $($_.commit.message.Split("`n")[0])" }
|
||||
```
|
||||
|
||||
2. 열린 이슈 조회:
|
||||
```powershell
|
||||
$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"}
|
||||
$issues = Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/variet-agent/issues?state=open&type=issues" -Headers $h
|
||||
$issues | ForEach-Object { Write-Host "#$($_.number) $($_.title)" }
|
||||
```
|
||||
|
||||
3. PR 조회:
|
||||
```powershell
|
||||
$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"}
|
||||
Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/variet-agent/pulls?state=open" -Headers $h
|
||||
```
|
||||
|
||||
4. Wiki 페이지 목록:
|
||||
```powershell
|
||||
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\wiki_helper.py list
|
||||
```
|
||||
|
||||
5. Wiki 페이지 읽기:
|
||||
```powershell
|
||||
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\wiki_helper.py read "Architecture"
|
||||
```
|
||||
|
||||
6. Wiki 페이지 업데이트:
|
||||
```powershell
|
||||
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\wiki_helper.py update "페이지-제목" /tmp/wiki_content.md
|
||||
```
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
description: Vikunja API로 프로젝트 태스크 현황을 조회하는 워크플로우
|
||||
---
|
||||
|
||||
# Vikunja 태스크 현황 조회
|
||||
|
||||
서비스 정보는 `.agent/workflows/services.md` 참조.
|
||||
|
||||
// turbo-all
|
||||
|
||||
## 절차
|
||||
|
||||
1. 전체 목록:
|
||||
```powershell
|
||||
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py list
|
||||
```
|
||||
|
||||
2. TODO만:
|
||||
```powershell
|
||||
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py list todo
|
||||
```
|
||||
|
||||
3. DONE만:
|
||||
```powershell
|
||||
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py list done
|
||||
```
|
||||
|
||||
4. 태스크 완료 처리 (반드시 이 방법 사용, 직접 API 호출 금지):
|
||||
```powershell
|
||||
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py done {TASK_ID}
|
||||
```
|
||||
복수 태스크 처리:
|
||||
```powershell
|
||||
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py done 71 77 78
|
||||
```
|
||||
|
||||
5. 태스크에 코멘트 추가:
|
||||
```powershell
|
||||
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py comment {TASK_ID} "내용"
|
||||
```
|
||||
|
||||
6. 새 태스크 생성:
|
||||
```powershell
|
||||
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High
|
||||
```
|
||||
|
||||
7. 태스크에 라벨 추가:
|
||||
```powershell
|
||||
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py label {TASK_ID} Backend Priority:High
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> 절대로 Invoke-RestMethod로 직접 API를 호출하지 마세요.
|
||||
> Vikunja API는 POST 시 body에 포함되지 않은 필드를 빈값으로 덮어씁니다.
|
||||
> vikunja_helper.py는 항상 GET 후 기존 필드 보존 후 POST 패턴을 사용합니다.
|
||||
@@ -8,6 +8,11 @@ description: 모든 구현 작업 전 실행하는 사전 점검 체크리스트
|
||||
> 코딩을 시작하기 전에 반드시 이 체크리스트를 순서대로 완료하세요.
|
||||
> 체크리스트를 건너뛸 경우 불필요한 시행착오가 발생합니다.
|
||||
|
||||
> [!CAUTION]
|
||||
> **Python 실행 시 반드시 절대경로 사용** (conda 환경이라 `python`이 PATH에 없음):
|
||||
> `C:\ProgramData\miniforge3\envs\agent_chat\python.exe`
|
||||
> 절대로 `python`, `py`, `python.exe`를 단독으로 쓰지 마세요.
|
||||
|
||||
## 1단계: 요구사항 정리
|
||||
|
||||
- [ ] 유저 요청을 구체적 작업 항목으로 분해
|
||||
|
||||
81
.gemini/skills/anime/SKILL.md
Normal file
81
.gemini/skills/anime/SKILL.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: anime_automation
|
||||
description: >
|
||||
애니메이션 검색, 자막/영상 다운로드, 편성표 조회, qBittorrent 관리,
|
||||
NAS 파일 관리. "애니", "자막", "토렌트", "다운로드", "편성표" 등의 키워드 감지 시 활성화.
|
||||
---
|
||||
|
||||
# Anime Automation Skill
|
||||
|
||||
## 개요
|
||||
|
||||
이 스킬은 애니메이션 관련 자동화 작업을 수행합니다.
|
||||
|
||||
## 핵심 워크플로우
|
||||
|
||||
```
|
||||
1. NAS 현황 확인 → 어떤 애니가 있는지, 몇 화까지 있는지 파악
|
||||
2. 편성표(Anissia) 확인 → 최신 자막 등록 현황 조회
|
||||
3. 자막 수집 → 제작자 사이트에서 자막 다운로드
|
||||
4. 토렌트 검색(Nyaa) → ASW + HEVC + 제목으로 magnet 검색
|
||||
5. 영상 다운로드 → qBittorrent에 magnet 추가 → NAS 폴더에 저장
|
||||
6. 정리 → 자막 파일명 변경 + 완료된 마그넷 즉시 삭제
|
||||
```
|
||||
|
||||
## 제목 매칭 규칙
|
||||
|
||||
⚠️ 소스별로 언어가 다릅니다:
|
||||
- **NAS 폴더명**: 한글 (예: `[26_1분기]장송의프리렌2기`) — 수정 불가
|
||||
- **Anissia**: 한글 + 일본어 원제
|
||||
- **Nyaa**: 영문/로마자 (예: `[ASW] Sousou no Frieren S2 - 07`)
|
||||
|
||||
`tools/title_matcher.py`에 카나→로마자 변환 + SequenceMatcher 유사도 매칭이 구현되어 있습니다.
|
||||
|
||||
## 도구 사용법
|
||||
|
||||
### NAS 현황 확인
|
||||
```bash
|
||||
python tools/nas_scanner.py scan # 전체 목록
|
||||
python tools/nas_scanner.py scan --year 26 --quarter 1 # 분기 필터
|
||||
python tools/nas_scanner.py search "프리렌" # 키워드 검색
|
||||
python tools/nas_scanner.py summary # 요약 통계
|
||||
```
|
||||
|
||||
### 편성표 + 자막 조회
|
||||
```bash
|
||||
python tools/anissia_client.py schedule 3 # 수요일 편성표
|
||||
python tools/anissia_client.py search "프리렌" # 애니 검색
|
||||
python tools/anissia_client.py captions 12345 # 자막 제작자 목록
|
||||
```
|
||||
|
||||
### 토렌트 검색
|
||||
```bash
|
||||
python tools/nyaa_client.py search "Sousou no Frieren" --suffix "ASW HEVC"
|
||||
python tools/nyaa_client.py search "프리렌" --no-suffix # suffix 없이
|
||||
```
|
||||
|
||||
### qBittorrent 관리
|
||||
```bash
|
||||
python tools/qbit_client.py status # 다운로드 현황
|
||||
python tools/qbit_client.py add "magnet:?..." --path "\\NAS\path"
|
||||
python tools/qbit_client.py delete <hash> # 완료 후 정리
|
||||
python tools/qbit_client.py test # 연결 테스트
|
||||
```
|
||||
|
||||
### 통합 파이프라인
|
||||
```bash
|
||||
python tools/anime_pipeline.py search "프리렌" # 검색 (다운로드 안함)
|
||||
python tools/anime_pipeline.py download "프리렌" # 자막+영상 자동
|
||||
python tools/anime_pipeline.py download "프리렌" --episode 10 # 특정 화수
|
||||
python tools/anime_pipeline.py download "프리렌" --mode sub_only # 자막만
|
||||
python tools/anime_pipeline.py download "프리렌" --mode video_only # 영상만
|
||||
python tools/anime_pipeline.py status # 다운로드 큐
|
||||
```
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 다운로드 완료 후 마그넷은 **즉시 삭제**
|
||||
2. NAS 폴더 형식: `[yy_x분기]제목` (예: `[26_1분기]장송의프리렌2기`)
|
||||
3. 에피소드 선택은 사용자 지시에 따름 (특정 화수/최신화/일괄)
|
||||
4. 자막 제작자 우선순위 없음 — 모든 자막 수집
|
||||
5. 영상은 ASW + HEVC 릴리스 우선 검색
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -5,3 +5,8 @@ sessions/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
logs/
|
||||
.agent/
|
||||
tests/verify_output.txt
|
||||
tests/test_output.txt
|
||||
|
||||
|
||||
@@ -100,7 +100,10 @@ class DocsManager:
|
||||
if changes:
|
||||
lines.append("## 변경 파일")
|
||||
for c in changes:
|
||||
lines.append(f"- `{c.get('file', '?')}` - {c.get('description', '')}")
|
||||
if isinstance(c, dict):
|
||||
lines.append(f"- `{c.get('file', '?')}` - {c.get('description', '')}")
|
||||
else:
|
||||
lines.append(f"- {c}")
|
||||
lines.append("")
|
||||
|
||||
warnings = summary.get("warnings", [])
|
||||
|
||||
80
core/orchestrator.py
Normal file
80
core/orchestrator.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Orchestrator — 인터페이스 무관 작업 오케스트레이터.
|
||||
|
||||
사용자 입력을 받아 NLU 분류 → 모드 분기.
|
||||
Discord, API, CLI 등 어떤 인터페이스에서든 동일한 로직을 실행합니다.
|
||||
|
||||
도구 실행은 Gemini CLI agent가 SKILL.md를 참조하여 직접 수행합니다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from core.gemini_caller import GeminiCaller, GeminiCallError
|
||||
|
||||
logger = logging.getLogger("variet.orchestrator")
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
"""인터페이스-무관 오케스트레이터.
|
||||
|
||||
흐름:
|
||||
1. 사용자 입력 + 히스토리 → NLU 통합 분류
|
||||
2. mode에 따라 분기:
|
||||
- chat → 즉답 반환
|
||||
- clarify → 질문 반환
|
||||
- anime → AnimePipeline 직접 호출
|
||||
- task → TaskPipeline 실행 (핸들러에서 처리)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def classify(
|
||||
self,
|
||||
user_input: str,
|
||||
history: str = "",
|
||||
project_path: str = "",
|
||||
) -> dict:
|
||||
"""통합 프롬프트로 의도 분류.
|
||||
|
||||
Returns:
|
||||
분류 결과 dict: {mode, response?, action?, title?, ...}
|
||||
"""
|
||||
gemini = GeminiCaller(project_path)
|
||||
|
||||
from core.docs_manager import DocsManager
|
||||
docs = DocsManager(project_path) if project_path else None
|
||||
docs_index = docs.get_docs_index() if docs else ""
|
||||
|
||||
context = (
|
||||
f"{history}"
|
||||
f"## Workspace\nPath: {project_path}\n\n"
|
||||
f"## Project Docs\n{docs_index}\n\n"
|
||||
f"## User Message\n{user_input}"
|
||||
)
|
||||
|
||||
raw = await gemini.call("unified", context, timeout=120)
|
||||
|
||||
# JSON 추출
|
||||
try:
|
||||
match = re.search(r'```json\s*\n(.*?)\n\s*```', raw, re.DOTALL)
|
||||
if match:
|
||||
return json.loads(match.group(1))
|
||||
|
||||
brace_depth = 0
|
||||
start = -1
|
||||
for i, ch in enumerate(raw):
|
||||
if ch == '{':
|
||||
if brace_depth == 0:
|
||||
start = i
|
||||
brace_depth += 1
|
||||
elif ch == '}':
|
||||
brace_depth -= 1
|
||||
if brace_depth == 0 and start >= 0:
|
||||
return json.loads(raw[start:i + 1])
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
logger.warning(f"통합 프롬프트 JSON 파싱 실패: {raw[:100]}")
|
||||
return {"mode": "chat", "response": raw}
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Task Pipeline -- Plan -> Code(에이전트) -> Review -> 재시도 -> 총평 -> 기록.
|
||||
"""Task Pipeline -- Agent 1회 호출 + 선택적 Review.
|
||||
|
||||
Coder는 에이전트 모드로 프로젝트 디렉토리에서 실행되어
|
||||
Gemini가 직접 파일을 읽고/쓰고/명령을 실행합니다.
|
||||
리뷰 실패 시 최대 MAX_REVIEW_RETRIES회 재시도합니다.
|
||||
Gemini CLI agent 모드가 plan+code+verify를 한 세션에서 수행합니다.
|
||||
기존 5단계(Plan→Code→PlannerVerify→Review→Summarize) → 2단계로 단순화.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -15,12 +14,11 @@ from core.context_manager import ContextManager
|
||||
from core.gemini_caller import GeminiCaller, GeminiCallError
|
||||
from core.docs_manager import DocsManager
|
||||
|
||||
MAX_REVIEW_RETRIES = 2
|
||||
logger = logging.getLogger("variet.pipeline")
|
||||
|
||||
|
||||
class TaskPipeline:
|
||||
"""작업 파이프라인: Plan -> Code(에이전트) -> Review(재시도) -> 기록."""
|
||||
"""작업 파이프라인: Agent 1회 호출 → 선택적 Review."""
|
||||
|
||||
def __init__(self, project_path: str, token_budget: int = 50_000,
|
||||
docs_subpath: str = "docs/wiki"):
|
||||
@@ -37,7 +35,7 @@ class TaskPipeline:
|
||||
return self
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Docs 컨텍스트 (모든 호출에 주입)
|
||||
# Docs 컨텍스트
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def _docs_context(self) -> str:
|
||||
@@ -50,137 +48,79 @@ class TaskPipeline:
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Plan
|
||||
# Agent 통합 실행 (핵심)
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
async def plan(self, user_request: str) -> dict:
|
||||
"""Planner로 태스크 분해 (에이전트 모드 — 직접 처리 가능)."""
|
||||
context = self.ctx.gather(user_request)
|
||||
docs_ctx = self._docs_context()
|
||||
async def execute(self, user_request: str, history: str = "",
|
||||
progress_callback=None, role: str = "agent",
|
||||
timeout: int = None) -> dict:
|
||||
"""Agent 1회 호출 — plan+code+verify+report 통합.
|
||||
|
||||
prompt = (
|
||||
f"## User Request\n{user_request}\n\n"
|
||||
f"## Project Context\n{context}\n\n"
|
||||
f"## Project Docs\n{docs_ctx}\n\n"
|
||||
f"Analyze this request. If simple, handle it directly (direct: true). "
|
||||
f"If complex, decompose into tasks (direct: false)."
|
||||
)
|
||||
Args:
|
||||
role: 'agent'(코딩) 또는 'operator'(도구 실행)
|
||||
progress_callback: async callable(status_text) — 진행 상태 콜백
|
||||
timeout: 타임아웃(초). None이면 operator=180, agent=600
|
||||
|
||||
response = await self.gemini.call_agent(
|
||||
"planner", prompt, cwd=self.project_path, timeout=180,
|
||||
)
|
||||
self._log("plan", user_request, response)
|
||||
|
||||
plan = self._extract_json(response)
|
||||
return plan or {"summary": response, "tasks": [], "raw": response}
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Code (에이전트 모드 — Gemini가 직접 파일 쓰기)
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
async def code(self, task: dict) -> str:
|
||||
"""에이전트 모드로 코딩 — Gemini가 직접 파일 생성/수정."""
|
||||
docs_ctx = self._docs_context()
|
||||
|
||||
prompt = (
|
||||
f"## Task\n{json.dumps(task, ensure_ascii=False, indent=2)}\n\n"
|
||||
f"## Project Docs\n{docs_ctx}\n\n"
|
||||
f"위 태스크를 구현하세요. 파일을 직접 생성/수정하세요."
|
||||
)
|
||||
|
||||
response = await self.gemini.call_agent(
|
||||
"coder", prompt, cwd=self.project_path, timeout=300,
|
||||
)
|
||||
self._log("code", task.get("title", ""), response)
|
||||
return response
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Code 병렬 실행
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
async def code_parallel(self, tasks: list[dict]) -> list[str]:
|
||||
"""여러 태스크를 병렬로 코딩 (에이전트 모드)."""
|
||||
results = await asyncio.gather(
|
||||
*[self.code(task) for task in tasks],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
processed = []
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
error_msg = f"[ERROR] Task {i+1} 실패: {result}"
|
||||
self._log("code_error", tasks[i].get("title", ""), error_msg)
|
||||
processed.append(error_msg)
|
||||
else:
|
||||
processed.append(result)
|
||||
|
||||
return processed
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Planner 자가 검증 (오케스트레이션)
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
async def planner_verify(
|
||||
self, user_request: str, plan: dict,
|
||||
code_outputs: list[str],
|
||||
) -> dict:
|
||||
"""Planner가 자기 계획의 달성 여부를 에이전트 모드로 검증.
|
||||
|
||||
프로젝트 디렉토리에서 직접 파일을 읽어서 계획 충족 여부를 판단합니다.
|
||||
Returns:
|
||||
dict: {title, summary, changes, verified, warnings, next_steps}
|
||||
"""
|
||||
agent_reports = "\n".join(
|
||||
f"--- Agent {i+1} ---\n{output}"
|
||||
for i, output in enumerate(code_outputs)
|
||||
)
|
||||
if timeout is None:
|
||||
timeout = 600
|
||||
|
||||
prompt = (
|
||||
f"## 원래 사용자 요청\n{user_request}\n\n"
|
||||
f"## 내가 세운 계획\n{json.dumps(plan, ensure_ascii=False, indent=2)}\n\n"
|
||||
f"## 에이전트 보고\n{agent_reports}\n\n"
|
||||
f"## 판단 요청\n"
|
||||
f"현재 디렉토리의 프로젝트 파일을 직접 읽어서 계획이 충족되었는지 확인하세요.\n"
|
||||
f"필요한 파일만 선택적으로 읽으세요.\n\n"
|
||||
f"충족되었으면 satisfied=true.\n"
|
||||
f"미충족이면 satisfied=false + 부족한 부분을 해결할 추가 태스크를 생성하세요.\n\n"
|
||||
f"반드시 아래 JSON만 출력하세요:\n"
|
||||
f"```json\n"
|
||||
f'{{\n'
|
||||
f' "satisfied": true|false,\n'
|
||||
f' "feedback": "판단 근거 (한국어)",\n'
|
||||
f' "additional_tasks": [\n'
|
||||
f' {{"id": 1, "title": "추가 태스크", "description": "구현 내용", "type": "modify"}}\n'
|
||||
f' ]\n'
|
||||
f'}}\n'
|
||||
f"```"
|
||||
)
|
||||
# operator 모드는 프로젝트 컨텍스트/문서 불필요 (도구만 실행)
|
||||
if role == "operator":
|
||||
history_section = ""
|
||||
if history:
|
||||
history_section = f"## 대화 히스토리\n{history}\n\n"
|
||||
prompt = (
|
||||
f"{history_section}"
|
||||
f"## 사용자 요청\n{user_request}\n\n"
|
||||
f"위 요청에 맞는 도구를 바로 실행하고 결과를 JSON으로 보고하세요."
|
||||
)
|
||||
else:
|
||||
context = self.ctx.gather(user_request)
|
||||
docs_ctx = self._docs_context()
|
||||
history_section = ""
|
||||
if history:
|
||||
history_section = f"## 대화 히스토리\n{history}\n\n"
|
||||
prompt = (
|
||||
f"{history_section}"
|
||||
f"## 사용자 요청\n{user_request}\n\n"
|
||||
f"## 프로젝트 컨텍스트\n{context}\n\n"
|
||||
f"## 프로젝트 문서\n{docs_ctx}\n\n"
|
||||
f"위 요청을 분석하고, 계획을 세우고, 필요한 도구를 실행하고, "
|
||||
f"결과를 JSON 보고서로 출력하세요."
|
||||
)
|
||||
|
||||
response = await self.gemini.call_agent(
|
||||
"planner", prompt, cwd=self.project_path, timeout=180,
|
||||
role, prompt, cwd=self.project_path, timeout=timeout,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
self._log("planner_verify", user_request, response)
|
||||
self._log("execute", user_request, response)
|
||||
|
||||
result = self._extract_json(response)
|
||||
return result or {"satisfied": True, "feedback": response}
|
||||
return result or {
|
||||
"title": "작업 완료",
|
||||
"summary": response[:500],
|
||||
"changes": [],
|
||||
"verified": False,
|
||||
"warnings": ["JSON 보고서 파싱 실패 — 원본 응답 참조"],
|
||||
"next_steps": [],
|
||||
"raw": response,
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Batch Review
|
||||
# 선택적 독립 리뷰
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
async def batch_review(self, tasks: list[dict], code_outputs: list[str]) -> dict:
|
||||
"""에이전트 모드로 프로젝트 파일을 직접 읽어 리뷰."""
|
||||
task_summaries = []
|
||||
for i, task in enumerate(tasks):
|
||||
title = task.get("title", task.get("description", f"Task {i+1}"))
|
||||
task_summaries.append(f"### Task {i+1}: {title}")
|
||||
|
||||
agent_reports = []
|
||||
for i, output in enumerate(code_outputs):
|
||||
agent_reports.append(f"--- Agent {i+1} 보고 ---\n{output}")
|
||||
async def review(self, user_request: str, agent_report: str) -> dict:
|
||||
"""독립 리뷰어 — agent 작업 결과를 검증.
|
||||
|
||||
agent가 자체 검증을 하지만, 중요한 작업에는 독립 리뷰를 추가할 수 있음.
|
||||
"""
|
||||
prompt = (
|
||||
f"## 요청된 태스크\n{chr(10).join(task_summaries)}\n\n"
|
||||
f"## 에이전트 보고\n{chr(10).join(agent_reports)}\n\n"
|
||||
f"## 원래 사용자 요청\n{user_request}\n\n"
|
||||
f"## Agent 보고\n{agent_report}\n\n"
|
||||
f"현재 디렉토리의 프로젝트 파일을 직접 읽어서 리뷰하세요.\n"
|
||||
f"필요한 파일만 선택적으로 확인하세요."
|
||||
)
|
||||
@@ -188,41 +128,33 @@ class TaskPipeline:
|
||||
response = await self.gemini.call_agent(
|
||||
"reviewer", prompt, cwd=self.project_path, timeout=300,
|
||||
)
|
||||
self._log("batch_review", f"{len(tasks)} tasks", response)
|
||||
self._log("review", user_request, response)
|
||||
|
||||
review = self._extract_json(response)
|
||||
return review or {"passed": True, "summary": response, "raw": response}
|
||||
result = self._extract_json(response)
|
||||
return result or {"passed": True, "summary": response, "raw": response}
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 총평
|
||||
# NLU 분류 (chat/task/anime 구분에 사용)
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
async def summarize(self, user_request: str, plan: dict,
|
||||
code_outputs: list[str], review: dict) -> dict:
|
||||
"""전체 작업 결과 종합 총평."""
|
||||
prompt = (
|
||||
f"## 원래 요청\n{user_request}\n\n"
|
||||
f"## 태스크 수\n{len(plan.get('tasks', []))}개\n\n"
|
||||
f"## 리뷰 결과\n{review.get('summary', str(review))}\n\n"
|
||||
f"## 코딩 결과 요약\n"
|
||||
f"{chr(10).join(code_outputs)}\n\n"
|
||||
f"위 정보를 바탕으로 총평을 작성하세요."
|
||||
async def classify(self, user_input: str, history: str = "") -> dict:
|
||||
"""통합 프롬프트로 의도 분류 (Orchestrator에서 위임).
|
||||
|
||||
이전 _unified_call의 역할을 담당합니다.
|
||||
"""
|
||||
docs_ctx = self._docs_context()
|
||||
|
||||
context = (
|
||||
f"{history}"
|
||||
f"## Workspace\nPath: {self.project_path}\n\n"
|
||||
f"## Project Docs\n{docs_ctx}\n\n"
|
||||
f"## User Message\n{user_input}"
|
||||
)
|
||||
|
||||
response = await self.gemini.call_agent(
|
||||
"summarizer", prompt, cwd=self.project_path, timeout=120,
|
||||
)
|
||||
self._log("summarize", user_request, response)
|
||||
|
||||
summary = self._extract_json(response)
|
||||
return summary or {
|
||||
"title": "작업 완료",
|
||||
"summary": response,
|
||||
"changes": [],
|
||||
"warnings": [],
|
||||
"next_steps": [],
|
||||
}
|
||||
raw = await self.gemini.call("unified", context, timeout=120)
|
||||
|
||||
result = self._extract_json(raw)
|
||||
return result or {"mode": "chat", "response": raw}
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 유틸리티
|
||||
|
||||
5
docs/changelog.md
Normal file
5
docs/changelog.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
- [2026-03-14 20:40] 이번 분기 애니메이션 영상 및 자막 업데이트
|
||||
- [2026-03-14 21:20] 이번 분기 애니메이션 영상 및 자막 업데이트 완료
|
||||
- [2026-03-15 07:59] 이번 분기 애니메이션 영상 및 자막 업데이트 완료
|
||||
5
docs/devlog/2026-03-15.md
Normal file
5
docs/devlog/2026-03-15.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 2026-03-15 Devlog
|
||||
|
||||
| # | 시간 | 작업 | 커밋 | 상태 |
|
||||
|---|------|------|------|------|
|
||||
| 1 | 07:00~08:24 | 애니 파이프라인 중복 다운로드 버그 5건 수정 (v2 정규식, 릴리스 그룹 필터, 자막 보호, 배치 다운로드, 타임아웃) | `42d0d81` | ✅ |
|
||||
19
docs/devlog/entries/20260315-001.md
Normal file
19
docs/devlog/entries/20260315-001.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 애니 파이프라인 중복 다운로드 버그 5건 수정
|
||||
|
||||
- **시간**: 2026-03-15 07:00~08:24
|
||||
- **Commit**: `42d0d81`
|
||||
- **Vikunja**: 신규 생성
|
||||
|
||||
## 결정 사항
|
||||
- `_extract_episode()`: SxxExx 패턴을 최우선으로 체크하고 v2 suffix 허용
|
||||
- `_add_torrents()`: NAS 기존 파일의 릴리스 그룹(Counter로 과반 판정) 기준 필터링
|
||||
- `_download_subtitles()`: 기존 자막이 있는 에피소드는 무조건 스킵 (수동 자막 보호)
|
||||
- `batch_download()`: operator.md의 수동 루프 패턴 대신 단일 CLI 명령으로 통합
|
||||
- operator 타임아웃: anime/task 구분 없이 600초 통일
|
||||
|
||||
## 수정 파일
|
||||
- `tools/anime_pipeline.py` — 정규식 + 릴리스 그룹 필터 + 자막 스킵 + batch 메서드
|
||||
- `handlers/task_handler.py` — 타임아웃 180→600
|
||||
- `prompts/operator.md` — batch 명령 추가
|
||||
- `core/docs_manager.py` — changes 배열 str/dict 양쪽 처리
|
||||
- `.agents/references/known-issues.md` — 3건 추가
|
||||
1
handlers/__init__.py
Normal file
1
handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# handlers — Discord Bot 핸들러 패키지.
|
||||
229
handlers/anime_handler.py
Normal file
229
handlers/anime_handler.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""애니메이션 핸들러 — Discord Bot에서 분리된 애니 관련 처리.
|
||||
|
||||
AnimePipeline을 직접 호출하여 결과를 Discord Embed로 렌더링합니다.
|
||||
NLU에서 mode="anime"로 분류된 요청도 처리합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from handlers.renderer import safe_send_embed
|
||||
|
||||
logger = logging.getLogger("variet.handlers.anime")
|
||||
|
||||
|
||||
async def handle_anime_message(
|
||||
message: discord.Message,
|
||||
parsed: dict,
|
||||
):
|
||||
"""NLU에서 anime으로 분류된 메시지 처리.
|
||||
|
||||
Args:
|
||||
message: Discord 메시지
|
||||
parsed: NLU 분류 결과 dict {mode, action, title, episode?, ...}
|
||||
"""
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
|
||||
pipeline = AnimePipeline()
|
||||
action = parsed.get("action", "search")
|
||||
title = parsed.get("title", parsed.get("query", ""))
|
||||
|
||||
async with message.channel.typing():
|
||||
try:
|
||||
if action in ("list", "scan"):
|
||||
# NAS 현황 조회
|
||||
from tools.nas_scanner import NasScanner
|
||||
scanner = NasScanner()
|
||||
if not scanner.is_accessible():
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title="❌ NAS 접근 불가",
|
||||
description=f"경로: `{scanner.base_path}`",
|
||||
color=0xE74C3C,
|
||||
))
|
||||
return
|
||||
|
||||
# title이 있으면 키워드 검색, 없으면 이번 분기
|
||||
if title:
|
||||
folders = scanner.search(title)
|
||||
label = f"'{title}' 검색 결과"
|
||||
# 키워드 검색 0건이면 이번 분기로 fallback
|
||||
if not folders:
|
||||
folders = scanner.get_current_quarter_anime()
|
||||
label = "이번 분기 애니"
|
||||
else:
|
||||
folders = scanner.get_current_quarter_anime()
|
||||
label = "이번 분기 애니"
|
||||
|
||||
if not folders:
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title=f"📁 {label}",
|
||||
description="해당하는 폴더가 없습니다.",
|
||||
color=0xF39C12,
|
||||
))
|
||||
return
|
||||
|
||||
desc_lines = []
|
||||
for f in folders[:15]:
|
||||
sub_info = f"자막 {f.subtitle_count}" if f.subtitle_count else "자막 없음"
|
||||
desc_lines.append(
|
||||
f"• `{f.folder_name}`\n 영상 {f.video_count}개 | {sub_info} | {f.total_size_gb:.1f}GB"
|
||||
)
|
||||
embed = discord.Embed(
|
||||
title=f"📁 {label} ({len(folders)}개)",
|
||||
description="\n".join(desc_lines)[:2000],
|
||||
color=0x3498DB,
|
||||
)
|
||||
await safe_send_embed(message.channel, embed)
|
||||
return
|
||||
|
||||
elif action == "search" and title:
|
||||
result = await pipeline.search(title)
|
||||
elif action == "search" and not title:
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title="🔍 애니 검색",
|
||||
description="검색할 제목을 입력해주세요.\n예: `프리렌 검색해줘`",
|
||||
color=0xF39C12,
|
||||
))
|
||||
return
|
||||
elif action == "download" and title:
|
||||
mode = parsed.get("download_mode", "auto")
|
||||
episode = parsed.get("episode")
|
||||
result = await pipeline.download(title, mode=mode, episode=episode)
|
||||
elif action == "status":
|
||||
status = await pipeline.get_status()
|
||||
if not status:
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title="🎬 다운로드 현황",
|
||||
description="다운로드 중인 항목 없음",
|
||||
color=0x3498DB,
|
||||
))
|
||||
return
|
||||
desc = "\n".join(
|
||||
f"• {s['progress']} `{s['name'][:40]}` {s['speed']}"
|
||||
for s in status
|
||||
)
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title=f"🎬 다운로드 현황 ({len(status)}건)",
|
||||
description=desc[:2000],
|
||||
color=0x3498DB,
|
||||
))
|
||||
return
|
||||
else:
|
||||
# 알 수 없는 action → search로 fallback
|
||||
if title:
|
||||
result = await pipeline.search(title)
|
||||
else:
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title="❓ 애니 명령",
|
||||
description="무엇을 도와드릴까요?\n• 목록 조회: `NAS에 뭐있어?`\n• 검색: `프리렌 검색`\n• 다운로드: `프리렌 10화 받아줘`\n• 상태: `다운로드 현황`",
|
||||
color=0xF39C12,
|
||||
))
|
||||
return
|
||||
|
||||
# 결과 임베드
|
||||
embed = discord.Embed(
|
||||
title=f"🎬 {result.message[:100]}" if result.message else "🎬 결과",
|
||||
description=result.message[:2000] if result.message else "완료",
|
||||
color=0x2ECC71 if result.success else 0xE74C3C,
|
||||
)
|
||||
if result.errors:
|
||||
embed.add_field(
|
||||
name="⚠️ 오류",
|
||||
value="\n".join(f"• {e}" for e in result.errors[:5])[:1000],
|
||||
inline=False,
|
||||
)
|
||||
await safe_send_embed(message.channel, embed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"애니 핸들러 오류: {e}", exc_info=True)
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title="❌ 애니 처리 오류",
|
||||
description=f"```{str(e)[:300]}```",
|
||||
color=0xE74C3C,
|
||||
))
|
||||
|
||||
|
||||
def register_anime_commands(bot, ws_manager):
|
||||
"""애니메이션 슬래시 커맨드 등록."""
|
||||
anime_group = app_commands.Group(name="anime", description="애니메이션 자막/영상 자동화")
|
||||
|
||||
@anime_group.command(name="search", description="애니 검색 (편성표 + 자막 + 토렌트)")
|
||||
@app_commands.describe(title="검색할 애니 제목 (한글)")
|
||||
async def anime_search(interaction: discord.Interaction, title: str):
|
||||
await interaction.response.defer()
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
pipeline = AnimePipeline()
|
||||
result = await pipeline.search(title)
|
||||
embed = discord.Embed(
|
||||
title=f"🔍 {result.anime.subject}" if result.anime else f"🔍 {title}",
|
||||
description=result.message[:2000],
|
||||
color=0x2ECC71 if result.success else 0xE74C3C,
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
@anime_group.command(name="download", description="자막+영상 자동 다운로드")
|
||||
@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수 (없으면 최신)")
|
||||
async def anime_download(interaction: discord.Interaction, title: str, episode: int = None):
|
||||
await interaction.response.defer()
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
pipeline = AnimePipeline()
|
||||
result = await pipeline.download(title, mode="auto", episode=episode)
|
||||
embed = discord.Embed(
|
||||
title=f"📥 {title}",
|
||||
description=result.message[:2000],
|
||||
color=0x2ECC71 if result.success else 0xE74C3C,
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
@anime_group.command(name="sub", description="자막만 다운로드")
|
||||
@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수")
|
||||
async def anime_sub(interaction: discord.Interaction, title: str, episode: int = None):
|
||||
await interaction.response.defer()
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
pipeline = AnimePipeline()
|
||||
result = await pipeline.download(title, mode="sub_only", episode=episode)
|
||||
embed = discord.Embed(
|
||||
title=f"📝 자막: {title}",
|
||||
description=result.message[:2000],
|
||||
color=0x2ECC71 if result.success else 0xE74C3C,
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
@anime_group.command(name="video", description="영상만 다운로드 (자막 없어도 강제)")
|
||||
@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수")
|
||||
async def anime_video(interaction: discord.Interaction, title: str, episode: int = None):
|
||||
await interaction.response.defer()
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
pipeline = AnimePipeline()
|
||||
result = await pipeline.download(title, mode="video_only", episode=episode)
|
||||
embed = discord.Embed(
|
||||
title=f"🎬 영상: {title}",
|
||||
description=result.message[:2000],
|
||||
color=0x2ECC71 if result.success else 0xE74C3C,
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
@anime_group.command(name="status", description="현재 다운로드 큐 상태")
|
||||
async def anime_status(interaction: discord.Interaction):
|
||||
await interaction.response.defer()
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
pipeline = AnimePipeline()
|
||||
status = await pipeline.get_status()
|
||||
if not status:
|
||||
desc = "다운로드 중인 항목 없음"
|
||||
else:
|
||||
desc = "\n".join(
|
||||
f"• {s['progress']} `{s['name'][:40]}` {s['speed']}"
|
||||
for s in status
|
||||
)
|
||||
embed = discord.Embed(
|
||||
title=f"🎬 다운로드 현황 ({len(status)}건)",
|
||||
description=desc[:2000],
|
||||
color=0x3498DB,
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
bot.tree.add_command(anime_group)
|
||||
logger.info("애니메이션 슬래시 커맨드 등록 완료")
|
||||
406
handlers/commands.py
Normal file
406
handlers/commands.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""슬래시 커맨드 — /workspace, /task 등 (discord_bot.py에서 분리).
|
||||
|
||||
워크스페이스 관리, 프로젝트 선택, 스레드 생성 등
|
||||
Discord UI 인터랙션을 담당합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
logger = logging.getLogger("variet.handlers.commands")
|
||||
|
||||
|
||||
def register_workspace_commands(bot, ws_manager):
|
||||
"""워크스페이스 관련 슬래시 커맨드 등록."""
|
||||
import config
|
||||
|
||||
workspace_group = app_commands.Group(name="workspace", description="워크스페이스 관리")
|
||||
|
||||
@workspace_group.command(name="set", description="이 채널에 워크스페이스 등록")
|
||||
@app_commands.describe(name="프로젝트 이름 (미입력 시 채널 이름 사용)", path="로컬 경로 (미입력 시 VW_Proj/{name}에 자동 생성)")
|
||||
async def workspace_set(interaction: discord.Interaction, name: str = "", path: str = ""):
|
||||
if not name:
|
||||
name = interaction.channel.name
|
||||
|
||||
conflicts = ws_manager.find_by_name(name)
|
||||
if conflicts:
|
||||
old = conflicts[0]
|
||||
embed = discord.Embed(
|
||||
title="⚠️ 이름 충돌",
|
||||
description=(
|
||||
f"**{name}** 이름의 프로젝트가 이미 존재합니다.\n\n"
|
||||
f"기존 등록: 채널 <#{old.channel_id}>\n"
|
||||
f"경로: `{old.path}`\n\n"
|
||||
f"**선택지:**\n"
|
||||
f"1️⃣ 다른 이름으로 등록: `/workspace set name:새이름`\n"
|
||||
f"2️⃣ 기존 프로젝트를 삭제 후 재등록: 기존 채널에서 `/workspace remove`"
|
||||
),
|
||||
color=0xF39C12,
|
||||
)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
return
|
||||
|
||||
if path and not Path(path).parent.exists():
|
||||
await interaction.response.send_message(
|
||||
f"❌ 부모 경로가 존재하지 않습니다: `{path}`", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
ws = ws_manager.set_workspace(interaction.channel_id, name, path)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="✅ 워크스페이스 등록 완료",
|
||||
description=(
|
||||
f"**{name}** -> `{ws.path}`\n\n"
|
||||
f"이 채널에서 봇과 대화할 수 있습니다.\n"
|
||||
f"작업 실행을 위해 Git과 Vikunja를 설정해주세요:"
|
||||
),
|
||||
color=0x2ECC71,
|
||||
)
|
||||
embed.add_field(name="Git 설정", value="`/workspace git`", inline=True)
|
||||
embed.add_field(name="Vikunja 설정", value="`/workspace vikunja`", inline=True)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
@workspace_group.command(name="git", description="Git 연결 설정")
|
||||
@app_commands.describe(url="Git 서버 URL", token="API 토큰", repo="Owner/Repo", branch="기본 브랜치")
|
||||
async def workspace_git(interaction: discord.Interaction, url: str, token: str,
|
||||
repo: str = "", branch: str = "main"):
|
||||
ws = ws_manager.get_workspace(interaction.channel_id)
|
||||
if not ws:
|
||||
await interaction.response.send_message(
|
||||
"❌ 이 채널에 워크스페이스가 없습니다. `/workspace set`을 먼저 실행하세요.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
ws_manager.set_git(interaction.channel_id, url, token, repo, branch)
|
||||
embed = discord.Embed(
|
||||
title="✅ Git 연결 완료",
|
||||
description=f"**{ws.name}** → {url}\nRepo: `{repo or '미지정'}`\nBranch: `{branch}`",
|
||||
color=0x2ECC71,
|
||||
)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
@workspace_group.command(name="vikunja", description="Vikunja 프로젝트 관리 연결")
|
||||
@app_commands.describe(url="Vikunja URL", token="API 토큰", project_id="프로젝트 ID")
|
||||
async def workspace_vikunja(interaction: discord.Interaction, url: str, token: str,
|
||||
project_id: int):
|
||||
ws = ws_manager.get_workspace(interaction.channel_id)
|
||||
if not ws:
|
||||
await interaction.response.send_message(
|
||||
"❌ 이 채널에 워크스페이스가 없습니다. `/workspace set`을 먼저 실행하세요.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
ws_manager.set_vikunja(interaction.channel_id, url, token, project_id)
|
||||
embed = discord.Embed(
|
||||
title="✅ Vikunja 연결 완료",
|
||||
description=f"**{ws.name}** → {url}\nProject ID: `{project_id}`",
|
||||
color=0x2ECC71,
|
||||
)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
@workspace_group.command(name="info", description="현재 워크스페이스 정보 표시")
|
||||
async def workspace_info(interaction: discord.Interaction):
|
||||
ws = ws_manager.get_workspace(interaction.channel_id)
|
||||
if not ws:
|
||||
await interaction.response.send_message(
|
||||
"이 채널에 등록된 워크스페이스가 없습니다.\n`/workspace set`으로 등록하세요.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
git_status = f"✅ {ws.git.url}" if ws.git.is_configured else "❌ 미설정"
|
||||
vik_status = f"✅ {ws.vikunja.url} (project {ws.vikunja.project_id})" if ws.vikunja.is_configured else "❌ 미설정"
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"📂 {ws.name}",
|
||||
description=f"경로: `{ws.path}`\nDocs: `{ws.docs_path}`",
|
||||
color=0x3498DB if ws.is_ready else 0xF39C12,
|
||||
)
|
||||
embed.add_field(name="Git", value=git_status, inline=False)
|
||||
embed.add_field(name="Vikunja", value=vik_status, inline=False)
|
||||
embed.add_field(
|
||||
name="상태",
|
||||
value="✅ 모든 설정 완료 — 작업 가능" if ws.is_ready else "⚠️ 설정 미완료 — 작업 차단됨",
|
||||
inline=False,
|
||||
)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
@workspace_group.command(name="remove", description="워크스페이스 등록 해제")
|
||||
async def workspace_remove(interaction: discord.Interaction):
|
||||
ws = ws_manager.get_workspace(interaction.channel_id)
|
||||
if not ws:
|
||||
await interaction.response.send_message(
|
||||
"이 채널에 등록된 워크스페이스가 없습니다.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
name = ws.name
|
||||
ws_manager.remove_workspace(interaction.channel_id)
|
||||
await interaction.response.send_message(
|
||||
embed=discord.Embed(
|
||||
title="🗑️ 워크스페이스 해제",
|
||||
description=f"**{name}** 등록이 해제되었습니다.\n이 채널에서 봇이 더 이상 자동 응답하지 않습니다.",
|
||||
color=0x95A5A6,
|
||||
)
|
||||
)
|
||||
|
||||
@workspace_group.command(name="list", description="등록된 전체 워크스페이스 목록")
|
||||
async def workspace_list(interaction: discord.Interaction):
|
||||
all_ws = ws_manager.list_all()
|
||||
if not all_ws:
|
||||
await interaction.response.send_message(
|
||||
"등록된 워크스페이스가 없습니다.\n`/workspace set`으로 등록하세요.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
embed = discord.Embed(title="📂 워크스페이스 목록", color=0x3498DB)
|
||||
for ws in all_ws:
|
||||
status = "✅" if ws.is_ready else "⚠️"
|
||||
embed.add_field(
|
||||
name=f"{status} {ws.name}",
|
||||
value=f"채널: <#{ws.channel_id}>\n경로: `{ws.path}`",
|
||||
inline=False,
|
||||
)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
bot.tree.add_command(workspace_group)
|
||||
logger.info("워크스페이스 슬래시 커맨드 등록 완료")
|
||||
|
||||
|
||||
def register_task_command(bot, ws_manager, _project_threads, _thread_workspaces, _unified_call_fn):
|
||||
"""프로젝트 선택 + 스레드 생성 슬래시 커맨드 등록."""
|
||||
|
||||
class ProjectSelectView(discord.ui.View):
|
||||
def __init__(self, request_text: str):
|
||||
super().__init__(timeout=60)
|
||||
self.request_text = request_text
|
||||
|
||||
options = []
|
||||
for ws in ws_manager.list_all():
|
||||
label = ws.name[:100]
|
||||
desc = ws.path[:100]
|
||||
options.append(discord.SelectOption(label=label, description=desc, value=str(ws.channel_id)))
|
||||
|
||||
if not options:
|
||||
options.append(discord.SelectOption(label="(등록된 프로젝트 없음)", value="__none__"))
|
||||
|
||||
select = discord.ui.Select(
|
||||
placeholder="프로젝트를 선택하세요...",
|
||||
options=options[:25],
|
||||
)
|
||||
select.callback = self.on_select
|
||||
self.add_item(select)
|
||||
|
||||
async def on_select(self, interaction: discord.Interaction):
|
||||
selected_value = interaction.data["values"][0]
|
||||
|
||||
if selected_value == "__none__":
|
||||
await interaction.response.send_message(
|
||||
"❌ 등록된 프로젝트가 없습니다. `/workspace set`으로 먼저 등록하세요.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
ws = ws_manager.get_workspace(int(selected_value))
|
||||
if not ws:
|
||||
await interaction.response.send_message("❌ 워크스페이스를 찾을 수 없습니다.", ephemeral=True)
|
||||
return
|
||||
|
||||
if ws.name in _project_threads:
|
||||
thread_id = _project_threads[ws.name]
|
||||
try:
|
||||
thread = interaction.guild.get_thread(thread_id)
|
||||
if thread and not thread.archived:
|
||||
await interaction.response.send_message(
|
||||
f"📌 **{ws.name}** 프로젝트는 이미 열린 대화가 있습니다: <#{thread_id}>\n"
|
||||
f"요청을 해당 스레드에 전달합니다.",
|
||||
ephemeral=True,
|
||||
)
|
||||
await thread.send(
|
||||
f"📨 **새 요청** ({interaction.user.display_name}):\n```{self.request_text[:500]}```"
|
||||
)
|
||||
return
|
||||
else:
|
||||
_project_threads.pop(ws.name, None)
|
||||
_thread_workspaces.pop(thread_id, None)
|
||||
except Exception:
|
||||
_project_threads.pop(ws.name, None)
|
||||
|
||||
project_path = Path(ws.path)
|
||||
if project_path.exists() and any(project_path.iterdir()):
|
||||
view = ConflictView(ws, self.request_text, interaction)
|
||||
await interaction.response.send_message(
|
||||
embed=discord.Embed(
|
||||
title=f"📂 {ws.name} — 기존 프로젝트 발견",
|
||||
description=(
|
||||
f"경로: `{ws.path}`\n\n"
|
||||
f"기존 프로젝트를 이어가시겠습니까, 새로 시작하시겠습니까?"
|
||||
),
|
||||
color=0xF39C12,
|
||||
),
|
||||
view=view,
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
await interaction.response.defer()
|
||||
await _create_task_thread(interaction, ws, self.request_text,
|
||||
_project_threads, _thread_workspaces, _unified_call_fn)
|
||||
|
||||
class ConflictView(discord.ui.View):
|
||||
def __init__(self, ws, request_text, original_interaction):
|
||||
super().__init__(timeout=60)
|
||||
self.ws = ws
|
||||
self.request_text = request_text
|
||||
self.original_interaction = original_interaction
|
||||
|
||||
@discord.ui.button(label="🔄 이어가기", style=discord.ButtonStyle.primary)
|
||||
async def continue_project(self, interaction, button):
|
||||
await interaction.response.defer()
|
||||
await _create_task_thread(interaction, self.ws, self.request_text,
|
||||
_project_threads, _thread_workspaces, _unified_call_fn)
|
||||
self.stop()
|
||||
|
||||
@discord.ui.button(label="🆕 새로 시작", style=discord.ButtonStyle.secondary)
|
||||
async def new_project(self, interaction, button):
|
||||
old_path = Path(self.ws.path)
|
||||
suffix = f"_archived_{datetime.now().strftime('%Y%m%d')}"
|
||||
new_archived_path = old_path.parent / f"{old_path.name}{suffix}"
|
||||
counter = 1
|
||||
while new_archived_path.exists():
|
||||
new_archived_path = old_path.parent / f"{old_path.name}{suffix}_{counter}"
|
||||
counter += 1
|
||||
|
||||
try:
|
||||
old_path.rename(new_archived_path)
|
||||
logger.info(f"프로젝트 아카이브: {old_path} → {new_archived_path}")
|
||||
except OSError as e:
|
||||
logger.error(f"폴더 아카이브 실패: {e}")
|
||||
await interaction.response.send_message(f"❌ 폴더 아카이브 실패: {e}", ephemeral=True)
|
||||
return
|
||||
|
||||
archived_name = new_archived_path.name
|
||||
ws_manager.set_workspace(
|
||||
channel_id=-abs(hash(archived_name)) % (10**10),
|
||||
name=archived_name,
|
||||
path=str(new_archived_path),
|
||||
)
|
||||
|
||||
old_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
await interaction.response.defer()
|
||||
await _create_task_thread(interaction, self.ws, self.request_text,
|
||||
_project_threads, _thread_workspaces, _unified_call_fn)
|
||||
self.stop()
|
||||
|
||||
@bot.tree.command(name="task", description="프로젝트를 선택하고 작업 요청")
|
||||
@app_commands.describe(request="작업 요청 내용")
|
||||
async def task_command(interaction: discord.Interaction, request: str = ""):
|
||||
all_ws = ws_manager.list_all()
|
||||
if not all_ws:
|
||||
await interaction.response.send_message(
|
||||
"❌ 등록된 프로젝트가 없습니다. `/workspace set`으로 먼저 등록하세요.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
view = ProjectSelectView(request)
|
||||
await interaction.response.send_message(
|
||||
embed=discord.Embed(
|
||||
title="📂 프로젝트 선택",
|
||||
description=(
|
||||
f"작업할 프로젝트를 선택하세요.\n"
|
||||
+ (f"**요청:** {request[:200]}" if request else "선택 후 스레드에서 요청할 수 있습니다.")
|
||||
),
|
||||
color=0x3498DB,
|
||||
),
|
||||
view=view,
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
logger.info("/task 슬래시 커맨드 등록 완료")
|
||||
|
||||
|
||||
async def _create_task_thread(
|
||||
interaction, ws, request_text,
|
||||
_project_threads, _thread_workspaces, _unified_call_fn,
|
||||
):
|
||||
"""스레드를 생성하고 작업을 시작합니다."""
|
||||
thread_name = f"🔧 {ws.name}"
|
||||
if request_text:
|
||||
short_req = request_text[:40].replace("\n", " ")
|
||||
thread_name = f"🔧 {ws.name} — {short_req}"
|
||||
thread_name = thread_name[:100]
|
||||
|
||||
channel = interaction.channel
|
||||
thread = await channel.create_thread(
|
||||
name=thread_name,
|
||||
type=discord.ChannelType.public_thread,
|
||||
auto_archive_duration=1440,
|
||||
)
|
||||
|
||||
_project_threads[ws.name] = thread.id
|
||||
_thread_workspaces[thread.id] = ws
|
||||
|
||||
logger.info(f"작업 스레드 생성: {thread.name} (ID: {thread.id}) → {ws.name}")
|
||||
|
||||
start_embed = discord.Embed(
|
||||
title=f"📂 {ws.name}",
|
||||
description=(
|
||||
f"경로: `{ws.path}`\n\n"
|
||||
f"**요청:** {request_text[:500]}\n\n"
|
||||
f"이 스레드에서 대화를 이어갈 수 있습니다."
|
||||
),
|
||||
color=0x3498DB,
|
||||
)
|
||||
await thread.send(embed=start_embed)
|
||||
|
||||
await interaction.followup.send(
|
||||
f"✅ 스레드가 생성되었습니다: <#{thread.id}>",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
if request_text.strip():
|
||||
try:
|
||||
async with thread.typing():
|
||||
result = await _unified_call_fn(request_text, "", ws.path)
|
||||
|
||||
mode = result.get("mode", "chat")
|
||||
logger.info(f"[스레드] 통합 분류: {mode} - \"{request_text[:50]}\"")
|
||||
|
||||
if mode == "chat":
|
||||
response = result.get("response", "응답을 생성하지 못했습니다.")
|
||||
if len(response) <= 2000:
|
||||
await thread.send(response)
|
||||
else:
|
||||
for i in range(0, len(response), 4000):
|
||||
chunk = response[i:i + 4000]
|
||||
embed = discord.Embed(description=chunk, color=0x3498DB)
|
||||
await thread.send(embed=embed)
|
||||
elif mode == "clarify":
|
||||
question = result.get("question", "더 구체적으로 말씀해 주시겠어요?")
|
||||
embed = discord.Embed(
|
||||
title="🤔 확인이 필요해요",
|
||||
description=question,
|
||||
color=0xF39C12,
|
||||
)
|
||||
await thread.send(embed=embed)
|
||||
else:
|
||||
await thread.send(
|
||||
embed=discord.Embed(
|
||||
title="⚙️ 작업 모드 감지",
|
||||
description="이 스레드에서 작업 요청을 다시 입력해주세요.\n스레드 내 메시지는 자동으로 처리됩니다.",
|
||||
color=0xF39C12,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"스레드 초기 호출 오류: {e}", exc_info=True)
|
||||
await thread.send(f"⚠️ 초기 호출 오류: {str(e)[:200]}")
|
||||
25
handlers/renderer.py
Normal file
25
handlers/renderer.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Discord 렌더러 — Embed 렌더링 유틸리티.
|
||||
|
||||
Discord Embed의 제한(4096자)을 고려한 안전한 전송 함수.
|
||||
"""
|
||||
|
||||
import discord
|
||||
|
||||
|
||||
EMBED_DESC_LIMIT = 4096
|
||||
|
||||
|
||||
async def safe_send_embed(channel, embed: discord.Embed):
|
||||
"""Embed가 Discord 제한을 초과하면 나눠서 전송."""
|
||||
desc = embed.description or ""
|
||||
if len(desc) <= EMBED_DESC_LIMIT:
|
||||
await channel.send(embed=embed)
|
||||
return
|
||||
|
||||
chunks = [desc[i:i + 4000] for i in range(0, len(desc), 4000)]
|
||||
embed.description = chunks[0]
|
||||
await channel.send(embed=embed)
|
||||
|
||||
for chunk in chunks[1:]:
|
||||
cont = discord.Embed(description=chunk, color=embed.color)
|
||||
await channel.send(embed=cont)
|
||||
180
handlers/task_handler.py
Normal file
180
handlers/task_handler.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""태스크 핸들러 — Agent 1회 실행 (discord_bot.py에서 분리).
|
||||
|
||||
NLU에서 mode="task"로 분류된 요청을 처리합니다.
|
||||
Agent가 plan+code+verify를 한 세션에서 수행합니다.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
import discord
|
||||
|
||||
from core.gemini_caller import GeminiCallError
|
||||
from handlers.renderer import safe_send_embed
|
||||
|
||||
logger = logging.getLogger("variet.handlers.task")
|
||||
|
||||
|
||||
async def handle_task(
|
||||
message: discord.Message,
|
||||
text: str,
|
||||
ws,
|
||||
history: str = "",
|
||||
mode: str = "task",
|
||||
):
|
||||
"""작업 요청 — Agent 1회 통합 실행 + 결과 표시.
|
||||
|
||||
Args:
|
||||
message: Discord 메시지
|
||||
text: 사용자 입력 텍스트
|
||||
ws: Workspace 객체
|
||||
history: 대화 히스토리 문자열
|
||||
mode: 'task'(코딩) 또는 'anime'(도구 실행)
|
||||
"""
|
||||
from core.task_pipeline import TaskPipeline
|
||||
|
||||
task_id = uuid.uuid4().hex[:8]
|
||||
|
||||
# ── 1. 접수 ──
|
||||
embed = discord.Embed(
|
||||
title="⚙️ 작업 중...",
|
||||
description=f"```{text[:200]}```",
|
||||
color=0xF39C12,
|
||||
)
|
||||
embed.set_footer(text=f"ID: {task_id} | {ws.name}")
|
||||
status_msg = await message.channel.send(embed=embed)
|
||||
|
||||
try:
|
||||
pipeline = TaskPipeline(
|
||||
project_path=ws.path,
|
||||
docs_subpath=ws.docs_path,
|
||||
)
|
||||
pipeline.setup()
|
||||
|
||||
# ── 2. Agent 실행 (진행 상태 실시간 업데이트) ──
|
||||
import time as _time
|
||||
_start_time = _time.time()
|
||||
_last_update = [0.0]
|
||||
_current_status = ["작업 중..."]
|
||||
|
||||
async def _progress(status_text: str):
|
||||
now = _time.time()
|
||||
if now - _last_update[0] < 2.0:
|
||||
return
|
||||
_last_update[0] = now
|
||||
_current_status[0] = status_text
|
||||
elapsed = int(now - _start_time)
|
||||
try:
|
||||
embed.title = f"⚙️ {status_text}"
|
||||
embed.set_footer(text=f"ID: {task_id} | {ws.name} | {elapsed}초 경과")
|
||||
await status_msg.edit(embed=embed)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# heartbeat: 출력이 없어도 10초마다 경과시간 갱신
|
||||
_heartbeat_running = [True]
|
||||
|
||||
async def _heartbeat():
|
||||
while _heartbeat_running[0]:
|
||||
await asyncio.sleep(10)
|
||||
if not _heartbeat_running[0]:
|
||||
break
|
||||
elapsed = int(_time.time() - _start_time)
|
||||
try:
|
||||
embed.set_footer(text=f"ID: {task_id} | {ws.name} | {elapsed}초 경과")
|
||||
await status_msg.edit(embed=embed)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
heartbeat_task = asyncio.create_task(_heartbeat())
|
||||
|
||||
# mode→role 매핑: anime='operator'(도구 실행), task='agent'(코딩)
|
||||
role = "operator" if mode == "anime" else "agent"
|
||||
timeout = 600 # anime 배치 작업(20+건 순차)도 충분한 시간 확보
|
||||
|
||||
try:
|
||||
result = await pipeline.execute(
|
||||
text, history=history, progress_callback=_progress,
|
||||
role=role,
|
||||
)
|
||||
finally:
|
||||
_heartbeat_running[0] = False
|
||||
try:
|
||||
heartbeat_task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 3. 결과 표시 ──
|
||||
title = result.get("title", "작업 완료")
|
||||
summary = result.get("summary", "완료")
|
||||
verified = result.get("verified", False)
|
||||
|
||||
color = 0x2ECC71 if verified else 0xF39C12
|
||||
result_embed = discord.Embed(
|
||||
title=f"{'✅' if verified else '📋'} {title}",
|
||||
description=summary[:2000],
|
||||
color=color,
|
||||
)
|
||||
|
||||
# 변경 사항
|
||||
changes = result.get("changes", [])
|
||||
if changes:
|
||||
if isinstance(changes[0], dict):
|
||||
val = "\n".join(
|
||||
f"• **{c.get('title', c.get('file', c.get('name', '?')))}** — "
|
||||
f"{c.get('action', c.get('description', c.get('summary', '')))}"
|
||||
for c in changes[:10]
|
||||
)
|
||||
else:
|
||||
val = "\n".join(f"• {s}" for s in changes[:10])
|
||||
result_embed.add_field(name="변경 사항", value=val[:1000], inline=False)
|
||||
|
||||
# 주의사항
|
||||
warnings = result.get("warnings", [])
|
||||
if warnings:
|
||||
result_embed.add_field(
|
||||
name="⚠️ 주의",
|
||||
value="\n".join(f"• {w}" for w in warnings[:5])[:1000],
|
||||
inline=False,
|
||||
)
|
||||
|
||||
# 다음 단계
|
||||
next_steps = result.get("next_steps", [])
|
||||
if next_steps:
|
||||
result_embed.add_field(
|
||||
name="🔜 다음 단계",
|
||||
value="\n".join(f"• {s}" for s in next_steps[:5])[:1000],
|
||||
inline=False,
|
||||
)
|
||||
|
||||
result_embed.set_footer(text=f"ID: {task_id} | {ws.name}")
|
||||
await status_msg.edit(embed=result_embed)
|
||||
|
||||
# 기록 (실패해도 봇 종료 방지)
|
||||
try:
|
||||
pipeline.docs.record_session(text, result, {})
|
||||
pipeline.docs.append_changelog(title)
|
||||
except Exception as doc_err:
|
||||
logger.warning(f"세션 기록 실패: {doc_err}")
|
||||
|
||||
except GeminiCallError as e:
|
||||
await status_msg.edit(
|
||||
embed=discord.Embed(
|
||||
title="❌ AI 호출 오류",
|
||||
description=(
|
||||
f"```{str(e)[:300]}```\n\n"
|
||||
f"💡 요청을 더 짧게/구체적으로 다시 시도해보세요."
|
||||
),
|
||||
color=0xE74C3C,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"작업 오류: {e}", exc_info=True)
|
||||
await status_msg.edit(
|
||||
embed=discord.Embed(
|
||||
title="❌ 예기치 않은 오류",
|
||||
description=f"```{str(e)[:300]}```",
|
||||
color=0xE74C3C,
|
||||
)
|
||||
)
|
||||
119
main.py
119
main.py
@@ -1,24 +1,53 @@
|
||||
"""Variet Agent — 진입점.
|
||||
|
||||
FastAPI 서버 + Discord Bot을 동시 실행합니다.
|
||||
FastAPI 서버 + Discord Bot + APScheduler를 동시 실행합니다.
|
||||
상시 실행 안정화: 파일 로깅, graceful shutdown, 자가 헬스체크.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import signal
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
# config를 먼저 import → .env 로드
|
||||
import config
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 로깅 설정 (파일 + 콘솔)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
LOG_DIR = Path(__file__).parent / "logs"
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
handlers = [
|
||||
logging.StreamHandler(sys.stdout),
|
||||
RotatingFileHandler(
|
||||
LOG_DIR / "variet.log",
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
),
|
||||
]
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
handlers=handlers,
|
||||
)
|
||||
logger = logging.getLogger("variet")
|
||||
|
||||
# 외부 라이브러리 로그 레벨 조정
|
||||
logging.getLogger("discord").setLevel(logging.WARNING)
|
||||
logging.getLogger("uvicorn").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 서브시스템
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def run_api_server():
|
||||
"""FastAPI 서버를 uvicorn으로 실행."""
|
||||
@@ -28,7 +57,7 @@ async def run_api_server():
|
||||
"api.server:app",
|
||||
host=config.API_HOST,
|
||||
port=config.API_PORT,
|
||||
log_level="info",
|
||||
log_level="warning",
|
||||
reload=False,
|
||||
)
|
||||
server = uvicorn.Server(uvi_config)
|
||||
@@ -41,12 +70,49 @@ async def run_discord_bot():
|
||||
await start_bot()
|
||||
|
||||
|
||||
async def run_scheduler():
|
||||
"""APScheduler — 주기적 작업 실행."""
|
||||
try:
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
except ImportError:
|
||||
logger.warning("APScheduler 미설치 — 스케줄러 비활성화 (pip install apscheduler)")
|
||||
return
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
# 헬스체크: 5분마다
|
||||
async def health_check():
|
||||
logger.debug("헬스체크 OK")
|
||||
|
||||
scheduler.add_job(health_check, "interval", minutes=5, id="health_check")
|
||||
|
||||
# TODO: 여기에 주기적 작업 추가
|
||||
# 예: 편성표 체크, 완료 토렌트 정리 등
|
||||
# scheduler.add_job(check_schedule, "cron", hour=18, id="anime_schedule")
|
||||
|
||||
scheduler.start()
|
||||
logger.info(f"스케줄러 시작 — {len(scheduler.get_jobs())}개 작업 등록")
|
||||
|
||||
# 스케줄러가 종료되지 않도록 대기
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
scheduler.shutdown(wait=False)
|
||||
logger.info("스케줄러 종료")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 메인
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def main():
|
||||
"""API 서버 + Discord Bot 동시 실행."""
|
||||
"""API 서버 + Discord Bot + 스케줄러 동시 실행."""
|
||||
logger.info("=" * 50)
|
||||
logger.info("Variet Agent 시작")
|
||||
logger.info(f" API: http://{config.API_HOST}:{config.API_PORT}")
|
||||
logger.info(f" Discord Bot: {'토큰 설정됨' if config.DISCORD_BOT_TOKEN else '⚠ 토큰 없음'}")
|
||||
logger.info(f" 로그: {LOG_DIR / 'variet.log'}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
tasks = []
|
||||
@@ -60,16 +126,47 @@ async def main():
|
||||
else:
|
||||
logger.warning("DISCORD_BOT_TOKEN이 없습니다. Bot 없이 API만 실행합니다.")
|
||||
|
||||
# 스케줄러
|
||||
tasks.append(asyncio.create_task(run_scheduler()))
|
||||
|
||||
# Graceful shutdown 핸들러
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
def _signal_handler():
|
||||
logger.info("종료 신호 수신...")
|
||||
shutdown_event.set()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, _signal_handler)
|
||||
except NotImplementedError:
|
||||
# Windows에서는 add_signal_handler 미지원
|
||||
pass
|
||||
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
# 어느 하나가 종료되거나 shutdown 신호가 오면 종료
|
||||
done, pending = await asyncio.wait(
|
||||
[*tasks, asyncio.create_task(shutdown_event.wait())],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("종료 요청...")
|
||||
logger.info("KeyboardInterrupt 수신...")
|
||||
except Exception as e:
|
||||
logger.error(f"실행 오류: {e}")
|
||||
logger.error(f"실행 오류: {e}", exc_info=True)
|
||||
finally:
|
||||
# 정리
|
||||
from api.discord_bot import stop_bot
|
||||
await stop_bot()
|
||||
# 모든 태스크 정리
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
|
||||
# Discord Bot 정리
|
||||
try:
|
||||
from api.discord_bot import stop_bot
|
||||
await stop_bot()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("Variet Agent 종료")
|
||||
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
You are a **Coder** — 프로젝트에서 파일을 직접 생성/수정하는 AI 에이전트입니다.
|
||||
|
||||
## 작업 원칙
|
||||
|
||||
**핵심: 태스크의 description만 보고, 완성된 결과물을 파일로 만드세요.**
|
||||
|
||||
소스코드뿐 아니라 **문서(.md), 설정 파일, 워크플로우, 데이터 파일** 등 모든 유형의 파일을 다룹니다.
|
||||
|
||||
## 작업 흐름
|
||||
|
||||
### 1단계: 탐색
|
||||
- 프로젝트 구조를 먼저 파악하세요 (디렉토리 확인, 관련 파일 검색)
|
||||
- 기존 프로젝트라면 **관련 파일을 찾아서 읽은 뒤** 수정하세요
|
||||
- 빈 프로젝트라면 필요한 파일을 처음부터 만드세요
|
||||
|
||||
### 2단계: 구현
|
||||
- 파일을 직접 생성/수정하여 저장하세요
|
||||
- 코드블록으로 출력하지 말고, **파일을 직접 만드세요**
|
||||
|
||||
### 3단계: 자가 검증 (반드시 수행)
|
||||
구현 후 직접 확인하세요:
|
||||
- 생성/수정한 파일을 다시 읽어서 내용이 완전한지
|
||||
- 파일 간 참조(import, 경로 등)가 올바른지
|
||||
- 핵심 내용이 빠진 것은 없는지
|
||||
|
||||
### 4단계: 자가 수정
|
||||
검증에서 문제를 발견하면 직접 수정 → 다시 3단계 → 문제 없을 때까지 반복.
|
||||
|
||||
### 5단계: 완료 보고
|
||||
- 변경한 파일 목록
|
||||
- 각 파일의 핵심 내용 한 줄 설명
|
||||
- 자가 검증에서 발견하고 수정한 것이 있으면 언급
|
||||
- **실행/사용 방법이 있으면 반드시 안내** (예: 서버 시작 명령, 테스트 방법, 설치 절차 등)
|
||||
|
||||
## 규칙
|
||||
|
||||
- 동작하는 완성된 결과물을 만드세요. 뼈대나 TODO를 남기지 마세요.
|
||||
- 기존 프로젝트의 스타일과 구조를 유지하세요.
|
||||
- 코드 주석과 문서는 **한국어**로 작성. 코드 식별자는 영어 유지.
|
||||
81
prompts/operator.md
Normal file
81
prompts/operator.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Operator — 도구 실행 에이전트
|
||||
|
||||
> CLI 도구를 실행하고 결과를 보고합니다. 코드 수정 금지.
|
||||
|
||||
## Python
|
||||
|
||||
```
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe
|
||||
```
|
||||
|
||||
반드시 이 절대경로 사용. `python` 단독 사용 금지.
|
||||
|
||||
## 핵심 규칙
|
||||
|
||||
1. **설명하지 말고 바로 실행하세요.** "~하겠습니다" 없이 즉시 도구를 실행하세요.
|
||||
2. **파일 탐색/코드 분석 금지.** `ls`, `dir`, `find`, `cat` 등으로 프로젝트를 탐색하지 마세요.
|
||||
3. **아래 도구 명령만 사용하세요.** 다른 명령어 사용 금지.
|
||||
|
||||
## 사용 가능한 도구
|
||||
|
||||
```bash
|
||||
# 복합 작업 (대부분의 요청에 이것만 쓰면 됨)
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py search "제목"
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py download "제목"
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py download "제목" --episode 10
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py batch
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py batch --no-sub-filter
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py status
|
||||
|
||||
# NAS 폴더
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/nas_scanner.py scan
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/nas_scanner.py search "키워드"
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/nas_scanner.py summary
|
||||
|
||||
# 개별 도구 (세밀한 제어가 필요할 때)
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anissia_client.py search "제목"
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anissia_client.py captions <anime_no>
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/nyaa_client.py search "영문제목"
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/qbit_client.py status
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/qbit_client.py add "magnet:..." --path "경로"
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/qbit_client.py delete <hash>
|
||||
```
|
||||
|
||||
## 실행 패턴 예시
|
||||
|
||||
### "이번 분기 애니 뭐있어?"
|
||||
```bash
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/nas_scanner.py scan
|
||||
```
|
||||
|
||||
### "프리렌 검색해줘"
|
||||
```bash
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py search "프리렌"
|
||||
```
|
||||
|
||||
### "이번 분기 애니 자막 업데이트 된것들 다운받아줘"
|
||||
```bash
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py batch
|
||||
```
|
||||
> `batch`는 이번 분기 NAS 폴더 → Anissia 자막 확인 → 자막 있는 것만 다운을 자동 수행합니다.
|
||||
> 자막 필터 없이 전부 다운받으려면 `--no-sub-filter` 옵션 추가.
|
||||
|
||||
### "다운로드 현황 보여줘"
|
||||
```bash
|
||||
C:\ProgramData\miniforge3\envs\agent_chat\python.exe tools/anime_pipeline.py status
|
||||
```
|
||||
|
||||
## 보고서
|
||||
|
||||
작업 완료 후 반드시 이 JSON을 출력하세요:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "작업 제목",
|
||||
"summary": "결과 요약 1-3문장",
|
||||
"changes": [],
|
||||
"verified": true,
|
||||
"warnings": [],
|
||||
"next_steps": []
|
||||
}
|
||||
```
|
||||
@@ -1,64 +0,0 @@
|
||||
You are a **Planner** — 사용자 요청을 분석하여 직접 처리하거나 태스크로 분배합니다.
|
||||
|
||||
## 판단 원칙
|
||||
|
||||
**핵심 질문: "이 작업을 내가 지금 바로 할 수 있는가?"**
|
||||
|
||||
- **Yes** → `direct: true` (직접 처리)
|
||||
- **No** → `direct: false` + tasks 배열 (코더에게 분배)
|
||||
|
||||
### 직접 처리 기준
|
||||
- 파일 1-2개 삭제, 이름 변경, 간단한 수정
|
||||
- 프로젝트 구조 확인, 현황 파악
|
||||
- 간단한 문서(.md, .txt) 생성/수정
|
||||
- 에이전트 도구만으로 완료 가능한 작업
|
||||
|
||||
### 태스크 분배 기준
|
||||
- **파일을 생성/수정/삭제해야 하는 모든 작업** (소스코드, 문서, 워크플로우, 설정 파일 등)
|
||||
- 구현 복잡도가 있어서 코더의 자가 검증이 필요한 작업
|
||||
- 1개로 충분하면 **반드시 1개만**. 독립적인 기능이 여러 개일 때만 분할.
|
||||
|
||||
### ⚠️ 절대 하지 말 것
|
||||
- 하나의 기능을 "파일 생성", "스타일 추가", "로직 구현"으로 쪼개기
|
||||
- 단순한 요청을 3개 이상으로 분할하기
|
||||
- 작업할 게 없는데 억지로 태스크 만들기
|
||||
|
||||
## 이전 시도 피드백이 있는 경우
|
||||
|
||||
review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석하고
|
||||
태스크 구조를 재설계하세요. 같은 구조를 반복하지 마세요.
|
||||
|
||||
## Output Format
|
||||
|
||||
### 직접 처리:
|
||||
```json
|
||||
{
|
||||
"summary": "처리 결과 요약",
|
||||
"direct": true,
|
||||
"result": "구체적으로 무엇을 했는지"
|
||||
}
|
||||
```
|
||||
|
||||
### 태스크 분배:
|
||||
```json
|
||||
{
|
||||
"summary": "작업 요약",
|
||||
"direct": false,
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "태스크 제목",
|
||||
"description": "구현 세부사항. 에이전트가 이것만 보고 작업합니다. 대상 파일, 내용, 형식을 구체적으로 포함하세요.",
|
||||
"type": "create|modify|delete"
|
||||
}
|
||||
],
|
||||
"risk": "low|medium|high"
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- description에 **모든 구현 세부사항**을 적으세요. 코더는 이것만 봅니다.
|
||||
- 한국어로 작성하세요.
|
||||
- 단순한 일을 복잡하게 만들지 마세요.
|
||||
- 이전 대화 맥락이 주어지면, 그 내용을 반영하세요.
|
||||
@@ -1,35 +0,0 @@
|
||||
# Summarizer
|
||||
|
||||
당신은 AI Agent Team의 **총평 작성자**입니다.
|
||||
작업 파이프라인 완료 후, 사용자가 이해하기 쉽게 결과를 요약합니다.
|
||||
|
||||
## 입력
|
||||
|
||||
- 사용자의 원래 요청
|
||||
- 태스크 수
|
||||
- 에이전트 작업 보고
|
||||
- 리뷰 결과
|
||||
|
||||
## 출력 형식 (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "작업 완료 한줄 제목",
|
||||
"changes": [
|
||||
{"file": "path/to/file", "description": "변경 내용 설명"}
|
||||
],
|
||||
"warnings": ["주의사항이 있으면 여기에"],
|
||||
"next_steps": ["사용자가 다음에 할 수 있는 작업 제안"],
|
||||
"summary": "2-3문장 전체 요약"
|
||||
}
|
||||
```
|
||||
|
||||
## 규칙
|
||||
|
||||
- 사용자 관점에서 서술하세요. 기술 용어는 최소화.
|
||||
- 한국어로 답변.
|
||||
- 주의사항이 없으면 warnings를 빈 배열로.
|
||||
- next_steps는 1-2개만 구체적으로 제안.
|
||||
- **실행 가능한 결과물이 있으면 next_steps 첫 번째에 실행 방법을 반드시 포함** (예: "터미널에서 `npm start` 실행", "http://localhost:3000 접속" 등).
|
||||
- changes의 file은 에이전트 보고에서 언급된 파일명 사용.
|
||||
- 코드 작업과 문서 작업 모두 동일한 형식으로 요약하세요.
|
||||
@@ -4,3 +4,5 @@ uvicorn>=0.30.0
|
||||
discord.py>=2.4.0
|
||||
pydantic>=2.0.0
|
||||
httpx>=0.27.0
|
||||
apscheduler>=3.10.0
|
||||
|
||||
|
||||
61
tests/cli_test_output.txt
Normal file
61
tests/cli_test_output.txt
Normal file
@@ -0,0 +1,61 @@
|
||||
=== Architecture Imports ===
|
||||
OK core.gemini_caller
|
||||
OK core.orchestrator
|
||||
OK core.task_pipeline
|
||||
OK tools.anime_pipeline
|
||||
OK tools.anissia_client
|
||||
OK tools.nyaa_client
|
||||
OK tools.qbit_client
|
||||
OK tools.nas_scanner
|
||||
OK tools.title_matcher
|
||||
OK tools.subtitle_downloader
|
||||
OK handlers.renderer
|
||||
|
||||
=== Deleted Files ===
|
||||
OK deleted tools/base.py
|
||||
OK deleted tools/registry.py
|
||||
OK deleted tools/anime_tool.py
|
||||
OK deleted prompts/planner.md
|
||||
OK deleted prompts/coder.md
|
||||
OK deleted prompts/summarizer.md
|
||||
|
||||
=== Required Files ===
|
||||
OK prompts/agent.md
|
||||
OK prompts/unified.md
|
||||
OK prompts/reviewer.md
|
||||
OK .gemini/skills/anime/SKILL.md
|
||||
|
||||
=== Title Matcher ===
|
||||
pykakasi('sousouno furiiren'): sousouno furiiren
|
||||
sim('sousouno furiiren', 'sousou no furiiren'): 0.971
|
||||
sim('Sousou no Frieren', 'sousou no furiiren'): 0.914
|
||||
|
||||
=== NAS Scanner ===
|
||||
accessible: True
|
||||
total folders: 322
|
||||
current quarter: 7 anime
|
||||
[26_1분기]29세 독신 중견 모험가의 일상 vid:6 sub:6
|
||||
[26_1분기]공주님고문의시간입니다2기 vid:8 sub:6
|
||||
[26_1분기]귀족전생-축복받은태생으로- vid:9 sub:0
|
||||
[26_1분기]너따위가마왕을이길수있다고생각하지마-용사파티추방 vid:9 sub:9
|
||||
[26_1분기]용사파티에서쫒겨난다재무능 vid:10 sub:8
|
||||
|
||||
=== Anissia Search ===
|
||||
'frieren': 2 results
|
||||
'sousou': 1 results
|
||||
|
||||
=== Nyaa Search ===
|
||||
'Sousou no Frieren ASW HEVC': 36 results
|
||||
top: [ASW] [ASW] Sousou no Frieren S2 - 08 [1080p HEVC x265 1 S:717
|
||||
|
||||
=== Pipeline Search ===
|
||||
success: True
|
||||
anime: 장송의 프리렌 2기
|
||||
captions: 2
|
||||
torrents: 20
|
||||
nas_folder: [26_1분기]장송의 프리렌 2기
|
||||
|
||||
=== qBittorrent ===
|
||||
connected: False
|
||||
|
||||
=== ALL TESTS DONE ===
|
||||
4
tests/run_test.bat
Normal file
4
tests/run_test.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
cd /d c:\Users\Certes\Desktop\variet-agent
|
||||
python tests/test_cli_tools.py 2>&1
|
||||
type tests\cli_test_output.txt
|
||||
107
tests/test_architecture.py
Normal file
107
tests/test_architecture.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""아키텍처 v3 검증 스크립트.
|
||||
|
||||
사용법: 프로젝트 루트 또는 tests/ 디렉토리 어디서든 실행 가능.
|
||||
cd variet-agent && python tests/test_architecture.py
|
||||
cd variet-agent/tests && python test_architecture.py
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 프로젝트 루트를 sys.path에 추가
|
||||
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _root not in sys.path:
|
||||
sys.path.insert(0, _root)
|
||||
|
||||
errors = []
|
||||
|
||||
# 1. core 계층
|
||||
try:
|
||||
from core.gemini_caller import GeminiCaller, GeminiCallError
|
||||
print("✅ core.gemini_caller OK")
|
||||
except Exception as e:
|
||||
errors.append(f"❌ core.gemini_caller: {e}")
|
||||
|
||||
try:
|
||||
from core.orchestrator import Orchestrator
|
||||
print("✅ core.orchestrator OK")
|
||||
except Exception as e:
|
||||
errors.append(f"❌ core.orchestrator: {e}")
|
||||
|
||||
try:
|
||||
from core.task_pipeline import TaskPipeline
|
||||
print("✅ core.task_pipeline OK")
|
||||
except Exception as e:
|
||||
errors.append(f"❌ core.task_pipeline: {e}")
|
||||
|
||||
# 2. tools 계층 (CLI 스크립트)
|
||||
try:
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
print("✅ tools.anime_pipeline OK")
|
||||
except Exception as e:
|
||||
errors.append(f"❌ tools.anime_pipeline: {e}")
|
||||
|
||||
try:
|
||||
from tools.anissia_client import AnissiaClient, AnimeInfo
|
||||
print("✅ tools.anissia_client OK")
|
||||
except Exception as e:
|
||||
errors.append(f"❌ tools.anissia_client: {e}")
|
||||
|
||||
try:
|
||||
from tools.nyaa_client import NyaaClient, TorrentResult
|
||||
print("✅ tools.nyaa_client OK")
|
||||
except Exception as e:
|
||||
errors.append(f"❌ tools.nyaa_client: {e}")
|
||||
|
||||
try:
|
||||
from tools.qbit_client import QBitClient
|
||||
print("✅ tools.qbit_client OK")
|
||||
except Exception as e:
|
||||
errors.append(f"❌ tools.qbit_client: {e}")
|
||||
|
||||
try:
|
||||
from tools.nas_scanner import NasScanner
|
||||
print("✅ tools.nas_scanner OK")
|
||||
except Exception as e:
|
||||
errors.append(f"❌ tools.nas_scanner: {e}")
|
||||
|
||||
try:
|
||||
from tools.title_matcher import match_titles, japanese_to_romaji
|
||||
print("✅ tools.title_matcher OK")
|
||||
except Exception as e:
|
||||
errors.append(f"❌ tools.title_matcher: {e}")
|
||||
|
||||
# 3. handlers 계층
|
||||
try:
|
||||
from handlers.renderer import safe_send_embed
|
||||
print("✅ handlers.renderer OK")
|
||||
except Exception as e:
|
||||
errors.append(f"❌ handlers.renderer: {e}")
|
||||
|
||||
# 4. 삭제 확인
|
||||
for should_not_exist in ["tools/base.py", "tools/registry.py", "tools/anime_tool.py",
|
||||
"prompts/planner.md", "prompts/coder.md", "prompts/summarizer.md"]:
|
||||
path = os.path.join(_root, should_not_exist)
|
||||
if os.path.exists(path):
|
||||
errors.append(f"❌ 삭제해야 할 파일 존재: {should_not_exist}")
|
||||
else:
|
||||
print(f"✅ {should_not_exist} 삭제됨")
|
||||
|
||||
# 5. 필수 파일 확인
|
||||
for should_exist in ["prompts/agent.md", "prompts/unified.md", "prompts/reviewer.md",
|
||||
".gemini/skills/anime/SKILL.md"]:
|
||||
path = os.path.join(_root, should_exist)
|
||||
if os.path.exists(path):
|
||||
print(f"✅ {should_exist} 존재")
|
||||
else:
|
||||
errors.append(f"❌ 필수 파일 없음: {should_exist}")
|
||||
|
||||
# 결과
|
||||
print(f"\n{'='*40}")
|
||||
if errors:
|
||||
print(f"❌ {len(errors)}개 오류:")
|
||||
for e in errors:
|
||||
print(f" {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ v3 아키텍처 검증 통과!")
|
||||
sys.exit(0)
|
||||
119
tests/test_cli_tools.py
Normal file
119
tests/test_cli_tools.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Full integration test — all tools + architecture + pipeline."""
|
||||
import sys, os, asyncio
|
||||
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, _root)
|
||||
OUT = os.path.join(_root, "tests", "cli_test_output.txt")
|
||||
|
||||
lines = []
|
||||
|
||||
# ===== 1. Architecture: imports =====
|
||||
lines.append("=== Architecture Imports ===")
|
||||
tests = [
|
||||
("core.gemini_caller", "GeminiCaller, GeminiCallError"),
|
||||
("core.orchestrator", "Orchestrator"),
|
||||
("core.task_pipeline", "TaskPipeline"),
|
||||
("tools.anime_pipeline", "AnimePipeline"),
|
||||
("tools.anissia_client", "AnissiaClient"),
|
||||
("tools.nyaa_client", "NyaaClient"),
|
||||
("tools.qbit_client", "QBitClient"),
|
||||
("tools.nas_scanner", "NasScanner"),
|
||||
("tools.title_matcher", "match_titles, japanese_to_romaji"),
|
||||
("tools.subtitle_downloader", "SubtitleDownloader"),
|
||||
("handlers.renderer", "safe_send_embed"),
|
||||
]
|
||||
for mod, names in tests:
|
||||
try:
|
||||
exec(f"from {mod} import {names}")
|
||||
lines.append(f" OK {mod}")
|
||||
except Exception as e:
|
||||
lines.append(f" FAIL {mod}: {e}")
|
||||
|
||||
# ===== 2. Deleted files =====
|
||||
lines.append("\n=== Deleted Files ===")
|
||||
for f in ["tools/base.py", "tools/registry.py", "tools/anime_tool.py",
|
||||
"prompts/planner.md", "prompts/coder.md", "prompts/summarizer.md"]:
|
||||
exists = os.path.exists(os.path.join(_root, f))
|
||||
lines.append(f" {'FAIL exists' if exists else 'OK deleted'} {f}")
|
||||
|
||||
# ===== 3. Required files =====
|
||||
lines.append("\n=== Required Files ===")
|
||||
for f in ["prompts/agent.md", "prompts/unified.md", "prompts/reviewer.md",
|
||||
".gemini/skills/anime/SKILL.md"]:
|
||||
exists = os.path.exists(os.path.join(_root, f))
|
||||
lines.append(f" {'OK' if exists else 'FAIL missing'} {f}")
|
||||
|
||||
# ===== 4. Title Matcher (pykakasi) =====
|
||||
lines.append("\n=== Title Matcher ===")
|
||||
from tools.title_matcher import japanese_to_romaji, title_similarity
|
||||
cases = [
|
||||
("sousouno furiiren", "sousou no furiiren"),
|
||||
("Sousou no Frieren", "sousou no furiiren"),
|
||||
]
|
||||
romaji = japanese_to_romaji("sousouno furiiren")
|
||||
lines.append(f" pykakasi('sousouno furiiren'): {romaji}")
|
||||
for a, b in cases:
|
||||
sim = title_similarity(a, b)
|
||||
lines.append(f" sim('{a[:30]}', '{b[:30]}'): {sim:.3f}")
|
||||
|
||||
# ===== 5. NAS Scanner =====
|
||||
lines.append("\n=== NAS Scanner ===")
|
||||
from tools.nas_scanner import NasScanner
|
||||
scanner = NasScanner()
|
||||
lines.append(f" accessible: {scanner.is_accessible()}")
|
||||
if scanner.is_accessible():
|
||||
folders = scanner.list_anime_folders()
|
||||
lines.append(f" total folders: {len(folders)}")
|
||||
current = scanner.get_current_quarter_anime()
|
||||
lines.append(f" current quarter: {len(current)} anime")
|
||||
for f in current[:5]:
|
||||
lines.append(f" {f.folder_name} vid:{f.video_count} sub:{f.subtitle_count}")
|
||||
|
||||
# ===== 6. Async tests =====
|
||||
async def async_tests():
|
||||
# 6a. Anissia search
|
||||
lines.append("\n=== Anissia Search ===")
|
||||
from tools.anissia_client import AnissiaClient
|
||||
ac = AnissiaClient()
|
||||
for kw in ["frieren", "sousou"]:
|
||||
r = await ac.search_anime(kw)
|
||||
lines.append(f" '{kw}': {len(r)} results")
|
||||
|
||||
# 6b. Nyaa search
|
||||
lines.append("\n=== Nyaa Search ===")
|
||||
from tools.nyaa_client import NyaaClient
|
||||
nc = NyaaClient()
|
||||
r = await nc.search("Sousou no Frieren", use_default_suffix=True)
|
||||
lines.append(f" 'Sousou no Frieren ASW HEVC': {len(r)} results")
|
||||
if r:
|
||||
lines.append(f" top: [{r[0].group}] {r[0].title[:50]} S:{r[0].seeders}")
|
||||
|
||||
# 6c. Pipeline search
|
||||
lines.append("\n=== Pipeline Search ===")
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
pipe = AnimePipeline()
|
||||
try:
|
||||
result = await pipe.search("sousou")
|
||||
lines.append(f" success: {result.success}")
|
||||
lines.append(f" anime: {result.anime.subject if result.anime else 'None'}")
|
||||
lines.append(f" captions: {len(result.captions)}")
|
||||
lines.append(f" torrents: {len(result.torrents)}")
|
||||
lines.append(f" nas_folder: {result.nas_folder}")
|
||||
if result.errors:
|
||||
lines.append(f" errors: {'; '.join(result.errors[:3])}")
|
||||
except Exception as e:
|
||||
lines.append(f" ERROR: {e}")
|
||||
|
||||
# 6d. qBittorrent
|
||||
lines.append("\n=== qBittorrent ===")
|
||||
from tools.qbit_client import QBitClient
|
||||
qb = QBitClient()
|
||||
info = await qb.test_connection()
|
||||
lines.append(f" connected: {info.get('connected')}")
|
||||
if info.get('connected'):
|
||||
lines.append(f" version: {info.get('version')}")
|
||||
|
||||
asyncio.run(async_tests())
|
||||
|
||||
lines.append("\n=== ALL TESTS DONE ===")
|
||||
with open(OUT, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(lines))
|
||||
227
tetris/game.js
227
tetris/game.js
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* Tetris Game Logic
|
||||
* Implements core mechanics: movement, rotation, collision, line clearing, and scoring.
|
||||
*/
|
||||
|
||||
class Tetris {
|
||||
constructor(width = 10, height = 20) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.grid = this.createGrid();
|
||||
this.score = 0;
|
||||
this.linesCleared = 0;
|
||||
this.gameOver = false;
|
||||
|
||||
// Tetromino shapes definitions
|
||||
this.shapes = {
|
||||
'I': [[1, 1, 1, 1]],
|
||||
'J': [[1, 0, 0], [1, 1, 1]],
|
||||
'L': [[0, 0, 1], [1, 1, 1]],
|
||||
'O': [[1, 1], [1, 1]],
|
||||
'S': [[0, 1, 1], [1, 1, 0]],
|
||||
'T': [[0, 1, 0], [1, 1, 1]],
|
||||
'Z': [[1, 1, 0], [0, 1, 1]]
|
||||
};
|
||||
|
||||
this.colors = {
|
||||
'I': '#00f0f0',
|
||||
'J': '#0000f0',
|
||||
'L': '#f0a000',
|
||||
'O': '#f0f000',
|
||||
'S': '#00f000',
|
||||
'T': '#a000f0',
|
||||
'Z': '#f00000'
|
||||
};
|
||||
|
||||
this.currentPiece = null;
|
||||
this.nextPiece = null;
|
||||
this.spawnPiece();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty game grid
|
||||
*/
|
||||
createGrid() {
|
||||
return Array.from({ length: this.height }, () => Array(this.width).fill(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a new random tetromino
|
||||
*/
|
||||
spawnPiece() {
|
||||
const types = Object.keys(this.shapes);
|
||||
if (!this.nextPiece) {
|
||||
this.nextPiece = types[Math.floor(Math.random() * types.length)];
|
||||
}
|
||||
|
||||
const type = this.nextPiece;
|
||||
this.nextPiece = types[Math.floor(Math.random() * types.length)];
|
||||
|
||||
const shape = this.shapes[type];
|
||||
this.currentPiece = {
|
||||
type: type,
|
||||
shape: shape,
|
||||
x: Math.floor((this.width - shape[0].length) / 2),
|
||||
y: 0
|
||||
};
|
||||
|
||||
// Check for immediate collision (Game Over)
|
||||
if (this.checkCollision(this.currentPiece.x, this.currentPiece.y, this.currentPiece.shape)) {
|
||||
this.gameOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a piece collides with boundaries or other pieces
|
||||
*/
|
||||
checkCollision(x, y, shape) {
|
||||
for (let row = 0; row < shape.length; row++) {
|
||||
for (let col = 0; col < shape[row].length; col++) {
|
||||
if (shape[row][col] !== 0) {
|
||||
const newX = x + col;
|
||||
const newY = y + row;
|
||||
|
||||
if (newX < 0 || newX >= this.width ||
|
||||
newY >= this.height ||
|
||||
(newY >= 0 && this.grid[newY][newX] !== 0)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the current piece in a given direction
|
||||
*/
|
||||
move(dx, dy) {
|
||||
if (this.gameOver) return false;
|
||||
|
||||
if (!this.checkCollision(this.currentPiece.x + dx, this.currentPiece.y + dy, this.currentPiece.shape)) {
|
||||
this.currentPiece.x += dx;
|
||||
this.currentPiece.y += dy;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If moving down and collision occurs, lock the piece
|
||||
if (dy > 0) {
|
||||
this.lockPiece();
|
||||
this.clearLines();
|
||||
this.spawnPiece();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates the current piece clockwise
|
||||
*/
|
||||
rotate() {
|
||||
if (this.gameOver) return;
|
||||
|
||||
const originalShape = this.currentPiece.shape;
|
||||
const newShape = originalShape[0].map((_, index) =>
|
||||
originalShape.map(row => row[index]).reverse()
|
||||
);
|
||||
|
||||
// Basic "Wall Kick" check
|
||||
let offset = 0;
|
||||
if (this.checkCollision(this.currentPiece.x, this.currentPiece.y, newShape)) {
|
||||
// Try shifting left/right to see if it fits
|
||||
if (!this.checkCollision(this.currentPiece.x - 1, this.currentPiece.y, newShape)) {
|
||||
offset = -1;
|
||||
} else if (!this.checkCollision(this.currentPiece.x + 1, this.currentPiece.y, newShape)) {
|
||||
offset = 1;
|
||||
} else {
|
||||
return; // Can't rotate
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPiece.x += offset;
|
||||
this.currentPiece.shape = newShape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard drop the current piece
|
||||
*/
|
||||
hardDrop() {
|
||||
while (this.move(0, 1)) {
|
||||
// Keep moving down
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks the piece into the grid
|
||||
*/
|
||||
lockPiece() {
|
||||
const { shape, x, y, type } = this.currentPiece;
|
||||
shape.forEach((row, rowIndex) => {
|
||||
row.forEach((value, colIndex) => {
|
||||
if (value !== 0) {
|
||||
const gridY = y + rowIndex;
|
||||
const gridX = x + colIndex;
|
||||
if (gridY >= 0) {
|
||||
this.grid[gridY][gridX] = type;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears full lines and updates score
|
||||
*/
|
||||
clearLines() {
|
||||
let linesCount = 0;
|
||||
for (let row = this.height - 1; row >= 0; row--) {
|
||||
if (this.grid[row].every(cell => cell !== 0)) {
|
||||
this.grid.splice(row, 1);
|
||||
this.grid.unshift(Array(this.width).fill(0));
|
||||
linesCount++;
|
||||
row++; // Check the same row index again after splice
|
||||
}
|
||||
}
|
||||
|
||||
if (linesCount > 0) {
|
||||
const scoring = [0, 100, 300, 500, 800]; // Standard scoring
|
||||
this.score += scoring[linesCount];
|
||||
this.linesCleared += linesCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current state for rendering
|
||||
*/
|
||||
getState() {
|
||||
// Return a copy of the grid with the current piece superimposed
|
||||
const displayGrid = this.grid.map(row => [...row]);
|
||||
|
||||
if (this.currentPiece && !this.gameOver) {
|
||||
const { shape, x, y, type } = this.currentPiece;
|
||||
shape.forEach((row, rowIndex) => {
|
||||
row.forEach((value, colIndex) => {
|
||||
if (value !== 0) {
|
||||
const gridY = y + rowIndex;
|
||||
const gridX = x + colIndex;
|
||||
if (gridY >= 0 && gridY < this.height && gridX >= 0 && gridX < this.width) {
|
||||
displayGrid[gridY][gridX] = type;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
grid: displayGrid,
|
||||
score: this.score,
|
||||
lines: this.linesCleared,
|
||||
nextPiece: this.nextPiece,
|
||||
gameOver: this.gameOver
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for usage
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = Tetris;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
# Anime automation tools package.
|
||||
# tools 패키지
|
||||
|
||||
@@ -20,6 +20,7 @@ from tools.qbit_client import QBitClient
|
||||
from tools.subtitle_downloader import SubtitleDownloader, SubtitleFile
|
||||
from tools.title_matcher import (
|
||||
match_titles, make_nas_folder_name, rename_subtitle_to_video,
|
||||
fetch_english_title,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("variet.tools.pipeline")
|
||||
@@ -81,48 +82,58 @@ class AnimePipeline:
|
||||
except Exception as e:
|
||||
result.errors.append(f"자막 조회 오류: {e}")
|
||||
|
||||
# 3. Nyaa 토렌트 검색 (다중 전략 — suffix 있는/없는 조합)
|
||||
# 3. NAS 기존 폴더 확인 → 검색 전략 결정 (방영 시점 기반)
|
||||
nas_existing = self._find_existing_nas_folder(anime.subject, anime.start_date)
|
||||
|
||||
# 3. Nyaa 토렌트 검색
|
||||
try:
|
||||
from tools.title_matcher import japanese_to_romaji
|
||||
import re as _re
|
||||
if nas_existing and nas_existing.video_files:
|
||||
# ── 기존 파일명에서 릴리스명 추출 → Nyaa 검색 (안전) ──
|
||||
release_name = self._extract_release_name(nas_existing.video_files[0])
|
||||
if release_name:
|
||||
logger.info(f"NAS 기존 릴리스명: '{release_name}'")
|
||||
found = await self.nyaa.search(release_name, use_default_suffix=False)
|
||||
matched = [t for t in found
|
||||
if self._title_contains_keyword(t.title, [release_name.lower()])]
|
||||
result.torrents = matched[:30]
|
||||
if matched:
|
||||
logger.info(f"NAS 릴리스명 검색 → {len(found)}건 중 {len(matched)}건 매칭")
|
||||
|
||||
romaji_full = japanese_to_romaji(anime.original_subject)
|
||||
# 한자/비ASCII 잔류 문자 제거 → 순수 로마자만 추출
|
||||
romaji_clean = _re.sub(r'[^\x00-\x7F]+', ' ', romaji_full).strip()
|
||||
romaji_clean = _re.sub(r'\s+', ' ', romaji_clean)
|
||||
if not result.torrents:
|
||||
# ── 신규 애니: Jikan API + ASW HEVC 전략 ──
|
||||
eng_titles = await fetch_english_title(anime.original_subject)
|
||||
eng_default = eng_titles.get("default", "")
|
||||
eng_english = eng_titles.get("english", "")
|
||||
synonyms = eng_titles.get("synonyms", [])
|
||||
|
||||
# 검색 전략 (query, use_default_suffix) 순서
|
||||
strategies: list[tuple[str, bool]] = []
|
||||
if romaji_clean and len(romaji_clean) >= 3:
|
||||
strategies.append((romaji_clean, True)) # romaji + ASW HEVC
|
||||
strategies.append((romaji_clean, False)) # romaji only
|
||||
strategies.append((anime.original_subject, True)) # 원제 + suffix
|
||||
strategies.append((anime.original_subject, False)) # 원제 only
|
||||
strategies.append((anime.subject, True)) # 한글 + suffix
|
||||
strategies.append((anime.subject, False)) # 한글 only
|
||||
|
||||
torrents = []
|
||||
for query, use_suffix in strategies:
|
||||
torrents = await self.nyaa.search(
|
||||
query, use_default_suffix=use_suffix,
|
||||
keywords = self._build_match_keywords(
|
||||
eng_default, eng_english, synonyms, anime.original_subject,
|
||||
)
|
||||
if torrents:
|
||||
suffix_label = " +suffix" if use_suffix else ""
|
||||
logger.info(
|
||||
f"Nyaa 검색 성공: '{query}'{suffix_label} → {len(torrents)}건"
|
||||
)
|
||||
break
|
||||
logger.info(f"매칭 키워드: {keywords}")
|
||||
|
||||
# 제목 매칭 필터링
|
||||
matched = match_titles(
|
||||
anime.subject, anime.original_subject, torrents, threshold=0.3
|
||||
)
|
||||
result.torrents = matched[:20] # 상위 20개
|
||||
# STEP 1: "ASW HEVC"로 검색 → 키워드로 필터
|
||||
asw_results = await self.nyaa.search("ASW HEVC", use_default_suffix=False)
|
||||
matched = [t for t in asw_results
|
||||
if self._title_contains_keyword(t.title, keywords)]
|
||||
if matched:
|
||||
logger.info(f"ASW HEVC 검색 → {len(asw_results)}건 중 {len(matched)}건 매칭")
|
||||
else:
|
||||
# ASW 릴리스 없음 — 사용자에게 안내
|
||||
result.errors.append(
|
||||
f"⚠️ ASW HEVC 릴리스가 없습니다.\n"
|
||||
f"영어 제목: {eng_default or '(조회 실패)'}\n"
|
||||
f"Nyaa에서 직접 검색해주세요."
|
||||
)
|
||||
|
||||
result.torrents = matched[:30]
|
||||
except Exception as e:
|
||||
result.errors.append(f"Nyaa 검색 오류: {e}")
|
||||
|
||||
# NAS 폴더명 생성
|
||||
result.nas_folder = make_nas_folder_name(anime.subject, anime.start_date)
|
||||
# NAS 폴더: 기존 폴더 있으면 재사용, 없으면 새로 생성
|
||||
if nas_existing:
|
||||
result.nas_folder = nas_existing.folder_name
|
||||
else:
|
||||
result.nas_folder = make_nas_folder_name(anime.subject, anime.start_date)
|
||||
|
||||
result.success = True
|
||||
result.message = (
|
||||
@@ -143,8 +154,12 @@ class AnimePipeline:
|
||||
|
||||
Args:
|
||||
title: 한글 제목
|
||||
mode: "auto" (자막+영상), "sub_only" (자막만), "video_only" (영상만)
|
||||
episode: 특정 에피소드만 (None이면 최신)
|
||||
mode:
|
||||
"auto" — 영상+자막 무조건 다운 (기본)
|
||||
"sub_required" — 자막 있는 에피소드만 영상 다운
|
||||
"sub_only" — 자막만
|
||||
"video_only" — 영상만
|
||||
episode: 특정 에피소드만 (None이면 빠진 것 전부)
|
||||
"""
|
||||
# 먼저 검색
|
||||
result = await self.search(title)
|
||||
@@ -155,13 +170,18 @@ class AnimePipeline:
|
||||
nas_folder = Path(self.nas_base) / result.nas_folder
|
||||
|
||||
# ── 자막 다운로드 ──
|
||||
if mode in ("auto", "sub_only"):
|
||||
if mode in ("auto", "sub_only", "sub_required"):
|
||||
await self._download_subtitles(result, nas_folder, episode)
|
||||
|
||||
# ── 영상 토렌트 추가 ──
|
||||
if mode in ("auto", "video_only"):
|
||||
force = (mode == "video_only")
|
||||
await self._add_torrents(result, nas_folder, episode, force=force)
|
||||
await self._add_torrents(result, nas_folder, episode)
|
||||
elif mode == "sub_required":
|
||||
# 자막이 실제로 다운됐을 때만 영상 추가
|
||||
if result.subtitles:
|
||||
await self._add_torrents(result, nas_folder, episode)
|
||||
else:
|
||||
result.errors.append("자막이 없어 영상 다운로드를 보류합니다.")
|
||||
|
||||
# 결과 메시지 구성
|
||||
parts = [result.message]
|
||||
@@ -181,8 +201,25 @@ class AnimePipeline:
|
||||
nas_folder: Path,
|
||||
episode: Optional[int],
|
||||
):
|
||||
"""자막 다운로드 처리."""
|
||||
sub_dir = nas_folder / "subtitles"
|
||||
"""자막 다운로드 → 영상 폴더에 직접 저장 + 영상명 매칭 리네임.
|
||||
|
||||
기존 자막이 있는 에피소드는 건너뜀 (수동 자막 보호).
|
||||
"""
|
||||
# 영상 폴더에 직접 저장 (subtitles/ 하위 아님)
|
||||
nas_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 기존 자막 파일이 있는 에피소드 스캔 → 스킵 대상
|
||||
existing_sub_eps = set()
|
||||
sub_exts = {".ass", ".srt", ".ssa", ".sub", ".smi"}
|
||||
if nas_folder.exists():
|
||||
for f in nas_folder.iterdir():
|
||||
if f.suffix.lower() in sub_exts:
|
||||
ep = self._extract_episode(f.stem)
|
||||
if ep is not None:
|
||||
existing_sub_eps.add(ep)
|
||||
|
||||
if existing_sub_eps:
|
||||
logger.info(f"기존 자막 에피소드 (스킵): {sorted(existing_sub_eps)}")
|
||||
|
||||
for caption in result.captions:
|
||||
if not caption.website:
|
||||
@@ -195,54 +232,379 @@ class AnimePipeline:
|
||||
for sub in subs:
|
||||
if episode is not None and sub.episode is not None and sub.episode != episode:
|
||||
continue
|
||||
|
||||
# 기존 자막이 있는 에피소드 스킵
|
||||
if sub.episode is not None and sub.episode in existing_sub_eps:
|
||||
logger.info(f"자막 스킵 (기존 존재): {sub.episode}화 - {sub.filename}")
|
||||
continue
|
||||
|
||||
try:
|
||||
await self.sub_downloader.download_file(sub, str(sub_dir))
|
||||
await self.sub_downloader.download_file(sub, str(nas_folder))
|
||||
result.subtitles.append(sub)
|
||||
except Exception as e:
|
||||
result.errors.append(f"자막 다운로드 실패 ({sub.filename}): {e}")
|
||||
except Exception as e:
|
||||
result.errors.append(f"자막 사이트 접근 실패 ({caption.name}): {e}")
|
||||
|
||||
# 다운로드 후: 기존 영상 파일과 매칭하여 자막 리네임
|
||||
self._rename_subtitles_to_match_videos(nas_folder, result)
|
||||
|
||||
def _rename_subtitles_to_match_videos(
|
||||
self, folder: Path, result: DownloadResult
|
||||
):
|
||||
"""폴더 내 자막 파일을 영상 파일명에 맞게 리네임.
|
||||
|
||||
예: [ASW] Sousou no Frieren S2 - 03.mkv
|
||||
→ 3화.ass 를 [ASW] Sousou no Frieren S2 - 03.ass 로 변경
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
# 영상 파일 목록 (에피소드 → 파일명)
|
||||
video_exts = {".mkv", ".mp4", ".avi", ".webm"}
|
||||
videos = {} # episode_num -> video_path
|
||||
for f in folder.iterdir():
|
||||
if f.suffix.lower() in video_exts:
|
||||
ep = self._extract_episode(f.stem)
|
||||
if ep is not None:
|
||||
videos[ep] = f
|
||||
|
||||
if not videos:
|
||||
return
|
||||
|
||||
# 자막 파일 리네임
|
||||
sub_exts = {".ass", ".srt", ".ssa", ".sub"}
|
||||
for f in folder.iterdir():
|
||||
if f.suffix.lower() not in sub_exts:
|
||||
continue
|
||||
ep = self._extract_episode(f.stem)
|
||||
if ep is not None and ep in videos:
|
||||
video_stem = videos[ep].stem
|
||||
new_name = f"{video_stem}{f.suffix}"
|
||||
new_path = folder / new_name
|
||||
if new_path != f and not new_path.exists():
|
||||
try:
|
||||
f.rename(new_path)
|
||||
logger.info(f"자막 리네임: {f.name} → {new_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"자막 리네임 실패: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _extract_episode(text: str) -> Optional[int]:
|
||||
"""텍스트에서 에피소드 번호 추출."""
|
||||
import re as _re
|
||||
# 패턴 1: S01E03, S02E07 (SxxExx — 시즌+에피소드, 가장 먼저 체크)
|
||||
m = _re.search(r'[Ss]\d{1,2}[Ee](\d{1,4})', text)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
# 패턴 2: "- 03", "- 06", "- 10v2" (torrent 파일명, v2 등 version suffix 허용)
|
||||
m = _re.search(r'[-–]\s*(\d{1,4})(?:v\d)?(?:\s|$|\.|\[|\()', text)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
# 패턴 3: "3화", "03화"
|
||||
m = _re.search(r'(\d{1,4})\s*화', text)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
# 패턴 4: "EP03", "Episode 3"
|
||||
m = _re.search(r'(?:EP|Episode)\s*(\d{1,4})', text, _re.IGNORECASE)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
def _find_existing_nas_folder(self, korean_title: str, start_date: str = ""):
|
||||
"""NAS에서 기존 폴더 찾기 — 제목 + 방영 시점(year/quarter) 기반.
|
||||
|
||||
같은 애니라도 방영 분기가 다르면 다른 시즌 → 매칭하지 않음.
|
||||
예: '최애의 아이 3기'(26_1분기) ≠ [23_2분기]최애의아이 (1기)
|
||||
"""
|
||||
import re as _re
|
||||
from tools.title_matcher import get_quarter
|
||||
|
||||
title_norm = _re.sub(r'[^\w]', '', korean_title.lower())
|
||||
if len(title_norm) < 2:
|
||||
return None
|
||||
|
||||
# 방영 분기 계산
|
||||
anime_year, anime_quarter = get_quarter(start_date)
|
||||
|
||||
try:
|
||||
all_folders = self.nas.list_anime_folders()
|
||||
except Exception as e:
|
||||
logger.warning(f"NAS 폴더 검색 실패: {e}")
|
||||
return None
|
||||
|
||||
candidates = []
|
||||
for folder in all_folders:
|
||||
folder_norm = _re.sub(r'[^\w]', '', folder.title.lower())
|
||||
# 제목 부분 매칭 (양방향)
|
||||
if not (title_norm in folder_norm or folder_norm in title_norm):
|
||||
continue
|
||||
# 방영 분기 일치 확인
|
||||
if anime_year and folder.year != anime_year:
|
||||
continue
|
||||
if anime_quarter and folder.quarter != anime_quarter:
|
||||
continue
|
||||
candidates.append(folder)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
best = candidates[0]
|
||||
logger.info(f"NAS 기존 폴더 발견: {best.folder_name}")
|
||||
return best
|
||||
|
||||
@staticmethod
|
||||
def _extract_release_name(filename: str) -> str:
|
||||
"""영상 파일명에서 릴리스 이름 추출.
|
||||
|
||||
[ASW] Hime-sama Goumon no Jikan desu - 21 [1080p HEVC].mkv
|
||||
→ 'Hime-sama Goumon no Jikan desu'
|
||||
"""
|
||||
import re as _re
|
||||
# 확장자 제거
|
||||
name = _re.sub(r'\.[^.]+$', '', filename)
|
||||
# [그룹태그] 제거
|
||||
name = _re.sub(r'^\[[^\]]*\]\s*', '', name)
|
||||
# 에피소드 번호 이후 제거: " - 21 [...]"
|
||||
name = _re.sub(r'\s*[-–]\s*\d+.*$', '', name).strip()
|
||||
# S02E09 패턴 제거
|
||||
name = _re.sub(r'\s*S\d+E\d+.*$', '', name, flags=_re.IGNORECASE).strip()
|
||||
return name
|
||||
|
||||
@staticmethod
|
||||
def _build_match_keywords(
|
||||
eng_default: str, eng_english: str,
|
||||
synonyms: list[str], original_title: str,
|
||||
) -> list[str]:
|
||||
"""Jikan 제목들에서 매칭용 키워드 추출.
|
||||
|
||||
예: "Sousou no Frieren 2nd Season" → ["Frieren", "Sousou no Frieren"]
|
||||
synonyms: ["Omagoto"] → ["Omagoto"]
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
keywords = []
|
||||
|
||||
# synonyms 중 짧은 것 (Omagoto 같은 약칭)
|
||||
for syn in synonyms:
|
||||
cleaned = syn.strip()
|
||||
if 3 <= len(cleaned) <= 30:
|
||||
keywords.append(cleaned.lower())
|
||||
|
||||
# eng_default에서 키워드 추출 (시즌 표기 제거)
|
||||
if eng_default:
|
||||
clean = _re.sub(r'\s*(2nd|3rd|\d+th)\s*Season.*$', '', eng_default, flags=_re.IGNORECASE).strip()
|
||||
clean = _re.sub(r'\s*S\d+$', '', clean).strip()
|
||||
if len(clean) >= 3:
|
||||
keywords.append(clean.lower())
|
||||
|
||||
# eng_english에서 콜론 앞 핵심 단어
|
||||
if eng_english:
|
||||
short = eng_english.split(":")[0].strip()
|
||||
if len(short) >= 3:
|
||||
keywords.append(short.lower())
|
||||
|
||||
# 원제 (일본어) — 정규화 없이 원본으로 비교
|
||||
if original_title and len(original_title) >= 2:
|
||||
import re as _re2
|
||||
clean = _re2.sub(r'\s*第\d+期$', '', original_title).strip()
|
||||
if clean:
|
||||
keywords.append(clean.lower())
|
||||
|
||||
# 중복 제거
|
||||
seen = set()
|
||||
unique = []
|
||||
for k in keywords:
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
unique.append(k)
|
||||
return unique
|
||||
|
||||
@staticmethod
|
||||
def _title_contains_keyword(nyaa_title: str, keywords: list[str]) -> bool:
|
||||
"""Nyaa 토렌트 제목에 키워드 중 하나라도 포함되는지 체크.
|
||||
|
||||
영문 키워드: 특수문자(하이픈, 따옴표) 제거 후 비교.
|
||||
일본어 키워드: 원본 그대로 비교.
|
||||
"""
|
||||
import re as _re
|
||||
title_lower = nyaa_title.lower()
|
||||
# 영문 정규화 버전
|
||||
title_norm = _re.sub(r'[^a-z0-9\s]', '', title_lower)
|
||||
|
||||
for kw in keywords:
|
||||
if not kw or len(kw) < 2:
|
||||
continue
|
||||
# ASCII만 포함된 키워드 → 정규화 비교
|
||||
kw_norm = _re.sub(r'[^a-z0-9\s]', '', kw)
|
||||
if kw_norm and len(kw_norm) >= 3 and kw_norm in title_norm:
|
||||
return True
|
||||
# 비ASCII(일본어 등) → 원본 비교
|
||||
if not kw.isascii() and kw in title_lower:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _add_torrents(
|
||||
self,
|
||||
result: DownloadResult,
|
||||
nas_folder: Path,
|
||||
episode: Optional[int],
|
||||
force: bool = False,
|
||||
):
|
||||
"""토렌트 추가 처리."""
|
||||
"""토렌트 추가 — 빠진 에피소드 전부 다운로드.
|
||||
|
||||
릴리스 그룹 일관성: NAS 기존 파일의 릴리스 그룹(예: ASW)이 있으면
|
||||
같은 그룹의 토렌트만 추가. 매칭 없으면 스킵.
|
||||
"""
|
||||
if not result.torrents:
|
||||
result.errors.append("매칭되는 토렌트가 없습니다.")
|
||||
return
|
||||
|
||||
# 에피소드 필터링
|
||||
candidates = result.torrents
|
||||
if episode is not None:
|
||||
candidates = [t for t in candidates if t.episode == episode]
|
||||
if not candidates:
|
||||
result.errors.append(f"{episode}화 토렌트를 찾지 못했습니다.")
|
||||
return
|
||||
import math
|
||||
import re as _re
|
||||
|
||||
# auto 모드 기본 조건: 자막이 있어야 영상 다운로드 (force면 무시)
|
||||
if not force and not result.captions and not result.subtitles:
|
||||
# 자막이 없으면 사용자에게 안내만
|
||||
result.errors.append("자막이 없어 영상 다운로드를 보류합니다. /anime video로 강제 다운로드 가능")
|
||||
# NAS 기존 에피소드 + 릴리스 그룹 스캔
|
||||
existing_eps = set()
|
||||
existing_groups = [] # 기존 파일들의 릴리스 그룹
|
||||
if nas_folder.exists():
|
||||
video_exts = {".mkv", ".mp4", ".avi", ".webm", ".ts"}
|
||||
for f in nas_folder.iterdir():
|
||||
if f.suffix.lower() in video_exts:
|
||||
ep = self._extract_episode(f.stem)
|
||||
if ep is not None:
|
||||
existing_eps.add(ep)
|
||||
# 릴리스 그룹 추출: [ASW], [SubsPlease] 등
|
||||
m = _re.match(r'\[([^\]]+)\]', f.name)
|
||||
if m:
|
||||
existing_groups.append(m.group(1))
|
||||
|
||||
if existing_eps:
|
||||
logger.info(f"NAS 기존 에피소드: {sorted(existing_eps)}")
|
||||
|
||||
# 기존 릴리스 그룹 결정 (가장 많이 등장하는 그룹)
|
||||
required_group = None
|
||||
if existing_groups:
|
||||
from collections import Counter
|
||||
group_counts = Counter(existing_groups)
|
||||
dominant_group, count = group_counts.most_common(1)[0]
|
||||
if count >= 2: # 2개 이상 파일에서 동일 그룹이면 확정
|
||||
required_group = dominant_group
|
||||
logger.info(f"NAS 릴리스 그룹: [{required_group}] ({count}개 파일)")
|
||||
|
||||
# 에피소드별 최고 점수 토렌트 그룹핑
|
||||
ep_best: dict[int, tuple[int, object]] = {} # ep → (score, torrent)
|
||||
for t in result.torrents:
|
||||
ep = self._extract_episode(t.title)
|
||||
if ep is None:
|
||||
continue
|
||||
|
||||
# 특정 에피소드 요청 시 해당 에피소드만
|
||||
if episode is not None and ep != episode:
|
||||
continue
|
||||
|
||||
# NAS 중복 스킵
|
||||
if ep in existing_eps:
|
||||
continue
|
||||
|
||||
# VOSTFR 제외
|
||||
title_upper = t.title.upper()
|
||||
if "VOSTFR" in title_upper or "VOSTA" in title_upper:
|
||||
continue
|
||||
|
||||
# 릴리스 그룹 일관성 필터: NAS에 특정 그룹이 있으면 같은 그룹만 허용
|
||||
if required_group:
|
||||
if f"[{required_group}]" not in t.title:
|
||||
continue
|
||||
|
||||
# 스코어링
|
||||
score = 0
|
||||
if "[ASW]" in t.title:
|
||||
score += 100
|
||||
if "HEVC" in title_upper or "X265" in title_upper:
|
||||
score += 50
|
||||
if "1080P" in title_upper:
|
||||
score += 20
|
||||
if t.seeders > 0:
|
||||
score += int(math.log(t.seeders) * 5)
|
||||
|
||||
if ep not in ep_best or score > ep_best[ep][0]:
|
||||
ep_best[ep] = (score, t)
|
||||
|
||||
if not ep_best:
|
||||
if episode is not None:
|
||||
result.errors.append(f"{episode}화 토렌트를 찾지 못했습니다.")
|
||||
elif required_group:
|
||||
result.errors.append(
|
||||
f"새로 다운로드할 에피소드가 없습니다 "
|
||||
f"([{required_group}] 릴리스 기준, 모두 NAS에 존재하거나 미출시)."
|
||||
)
|
||||
else:
|
||||
result.errors.append("새로 다운로드할 에피소드가 없습니다 (모두 NAS에 존재).")
|
||||
return
|
||||
|
||||
# 최상위 1개 (가장 시더 많은) 추가
|
||||
best = candidates[0]
|
||||
try:
|
||||
success = await self.qbit.add_torrent(
|
||||
magnet_or_url=best.magnet_link,
|
||||
save_path=str(nas_folder),
|
||||
category="anime",
|
||||
tags=result.anime.subject if result.anime else "",
|
||||
)
|
||||
result.torrent_added = success
|
||||
if not success:
|
||||
result.errors.append("qBittorrent 토렌트 추가 실패")
|
||||
except Exception as e:
|
||||
result.errors.append(f"qBittorrent 오류: {e}")
|
||||
# 에피소드 순서대로 추가
|
||||
added_count = 0
|
||||
for ep in sorted(ep_best.keys()):
|
||||
_, torrent = ep_best[ep]
|
||||
try:
|
||||
success = await self.qbit.add_torrent(
|
||||
magnet_or_url=torrent.magnet_link,
|
||||
save_path=str(nas_folder),
|
||||
category="anime",
|
||||
tags=result.anime.subject if result.anime else "",
|
||||
)
|
||||
if success:
|
||||
added_count += 1
|
||||
logger.info(f"토렌트 추가: ep{ep} - {torrent.title[:50]}")
|
||||
else:
|
||||
result.errors.append(f"ep{ep} 토렌트 추가 실패")
|
||||
except Exception as e:
|
||||
result.errors.append(f"ep{ep} qBittorrent 오류: {e}")
|
||||
|
||||
result.torrent_added = added_count > 0
|
||||
if added_count > 0:
|
||||
new_eps = sorted(ep_best.keys())
|
||||
result.message += f"\n📥 {added_count}개 에피소드 추가: {new_eps}"
|
||||
|
||||
@staticmethod
|
||||
def _select_best_torrent(candidates: list, existing_eps: set = None):
|
||||
"""ASW HEVC 우선으로 최적 토렌트 선택 (검색 결과 표시용).
|
||||
|
||||
스코어링:
|
||||
+100 [ASW] 그룹
|
||||
+50 HEVC / x265 코덱
|
||||
+20 1080p 해상도
|
||||
+log(시더수) * 5 (최대 ~30)
|
||||
VOSTFR / non-English 릴리스는 완전 제외.
|
||||
기존 에피소드는 스킵.
|
||||
"""
|
||||
import re as _re
|
||||
import math
|
||||
|
||||
if existing_eps is None:
|
||||
existing_eps = set()
|
||||
|
||||
scored = []
|
||||
for t in candidates:
|
||||
title_upper = t.title.upper()
|
||||
if "VOSTFR" in title_upper or "VOSTA" in title_upper:
|
||||
continue
|
||||
|
||||
score = 0
|
||||
if "[ASW]" in t.title:
|
||||
score += 100
|
||||
if "HEVC" in title_upper or "X265" in title_upper:
|
||||
score += 50
|
||||
if "1080P" in title_upper:
|
||||
score += 20
|
||||
if t.seeders > 0:
|
||||
score += int(math.log(t.seeders) * 5)
|
||||
|
||||
scored.append((score, t))
|
||||
|
||||
if not scored:
|
||||
return None
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return scored[0][1]
|
||||
|
||||
async def get_status(self) -> list[dict]:
|
||||
"""현재 다운로드 큐 상태."""
|
||||
@@ -263,3 +625,163 @@ class AnimePipeline:
|
||||
except Exception as e:
|
||||
logger.error(f"qBittorrent 상태 조회 오류: {e}")
|
||||
return []
|
||||
|
||||
async def batch_download(
|
||||
self,
|
||||
mode: str = "auto",
|
||||
sub_filter: bool = True,
|
||||
) -> list[DownloadResult]:
|
||||
"""이번 분기 애니 일괄 다운로드.
|
||||
|
||||
Args:
|
||||
mode: 다운로드 모드 ("auto", "sub_only", "video_only")
|
||||
sub_filter: True면 Anissia에 자막이 등록된 애니만 처리
|
||||
|
||||
Returns:
|
||||
각 애니별 DownloadResult 리스트
|
||||
"""
|
||||
# 1. NAS에서 이번 분기 애니 폴더 스캔
|
||||
current_folders = self.nas.get_current_quarter_anime()
|
||||
if not current_folders:
|
||||
logger.warning("이번 분기 NAS 폴더 없음")
|
||||
return []
|
||||
|
||||
logger.info(f"이번 분기 NAS 폴더: {len(current_folders)}개")
|
||||
results = []
|
||||
|
||||
for folder in current_folders:
|
||||
title = folder.title
|
||||
logger.info(f"\n{'='*40}")
|
||||
logger.info(f"처리 중: {folder.folder_name}")
|
||||
|
||||
try:
|
||||
# 2. Anissia에서 검색 → 자막 정보 확인
|
||||
anime_list = await self.anissia.search_anime(title)
|
||||
if not anime_list:
|
||||
logger.info(f" Anissia 검색 결과 없음 → 건너뜀")
|
||||
continue
|
||||
|
||||
anime = anime_list[0]
|
||||
|
||||
# 3. 자막 필터: Anissia에 자막 제작자가 있는지 확인
|
||||
if sub_filter:
|
||||
captions = await self.anissia.get_captions(anime.anime_no)
|
||||
if not captions:
|
||||
logger.info(f" 자막 없음 → 건너뜀")
|
||||
continue
|
||||
logger.info(f" 자막 {len(captions)}건 발견 → 다운로드 진행")
|
||||
|
||||
# 4. 기존 에피소드 확인
|
||||
from pathlib import Path as _Path
|
||||
nas_path = _Path(self.nas_base) / folder.folder_name
|
||||
existing_eps = set()
|
||||
video_exts = {".mkv", ".mp4", ".avi", ".webm", ".ts"}
|
||||
if nas_path.exists():
|
||||
for f in nas_path.iterdir():
|
||||
if f.suffix.lower() in video_exts:
|
||||
ep = self._extract_episode(f.stem)
|
||||
if ep is not None:
|
||||
existing_eps.add(ep)
|
||||
|
||||
# 5. 다운로드 실행
|
||||
result = await self.download(title, mode=mode)
|
||||
results.append(result)
|
||||
|
||||
status = "✅" if result.success else "❌"
|
||||
logger.info(f" {status} {result.message[:100]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" 오류 ({folder.folder_name}): {e}")
|
||||
err_result = DownloadResult(
|
||||
success=False,
|
||||
message=f"{folder.folder_name}: 오류 - {e}",
|
||||
errors=[str(e)],
|
||||
)
|
||||
results.append(err_result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── CLI 진입점 ──
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
args = sys.argv[1:]
|
||||
pipeline = AnimePipeline()
|
||||
|
||||
async def main():
|
||||
if not args:
|
||||
print("사용법: python tools/anime_pipeline.py [search|download|batch|status] [옵션]")
|
||||
return
|
||||
|
||||
if args[0] == "search" and len(args) > 1:
|
||||
# python tools/anime_pipeline.py search "프리렌"
|
||||
title = " ".join(args[1:])
|
||||
result = await pipeline.search(title)
|
||||
print(result.message)
|
||||
if result.errors:
|
||||
print(f"⚠️ 오류: {'; '.join(result.errors)}")
|
||||
|
||||
elif args[0] == "download" and len(args) > 1:
|
||||
# python tools/anime_pipeline.py download "프리렌" [--mode auto] [--episode 10]
|
||||
title_parts = []
|
||||
mode = "auto"
|
||||
episode = None
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] == "--mode" and i + 1 < len(args):
|
||||
mode = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--episode" and i + 1 < len(args):
|
||||
episode = int(args[i + 1])
|
||||
i += 2
|
||||
else:
|
||||
title_parts.append(args[i])
|
||||
i += 1
|
||||
|
||||
title = " ".join(title_parts)
|
||||
result = await pipeline.download(title, mode=mode, episode=episode)
|
||||
print(result.message)
|
||||
|
||||
elif args[0] == "batch":
|
||||
# python tools/anime_pipeline.py batch [--no-sub-filter] [--mode auto]
|
||||
mode = "auto"
|
||||
sub_filter = True
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] == "--no-sub-filter":
|
||||
sub_filter = False
|
||||
i += 1
|
||||
elif args[i] == "--mode" and i + 1 < len(args):
|
||||
mode = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
print(f"📦 이번 분기 배치 다운로드 시작 (자막 필터: {'ON' if sub_filter else 'OFF'})")
|
||||
results = await pipeline.batch_download(mode=mode, sub_filter=sub_filter)
|
||||
|
||||
success = sum(1 for r in results if r.success)
|
||||
failed = sum(1 for r in results if not r.success)
|
||||
print(f"\n📊 완료: {success}건 성공, {failed}건 실패 (총 {len(results)}건)")
|
||||
for r in results:
|
||||
icon = "✅" if r.success else "❌"
|
||||
title = r.anime.subject if r.anime else "?"
|
||||
print(f" {icon} {title}: {r.message[:80]}")
|
||||
|
||||
elif args[0] == "status":
|
||||
# python tools/anime_pipeline.py status
|
||||
status = await pipeline.get_status()
|
||||
if not status:
|
||||
print("🎬 다운로드 중인 항목 없음")
|
||||
else:
|
||||
for s in status:
|
||||
print(f" {s['progress']} | {s['name'][:50]} | {s['speed']} | ETA: {s['eta']}")
|
||||
|
||||
else:
|
||||
print("사용법: python tools/anime_pipeline.py [search|download|batch|status] [옵션]")
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
@@ -110,11 +110,102 @@ class AnissiaClient:
|
||||
]
|
||||
|
||||
async def search_anime(self, keyword: str) -> list[AnimeInfo]:
|
||||
"""키워드로 전체 편성표에서 검색 (한글/일어 제목 매칭)."""
|
||||
"""키워드로 전체 편성표에서 검색 (한글/일어/영문 fuzzy 매칭)."""
|
||||
import re as _re
|
||||
|
||||
all_anime = await self.get_all_schedule()
|
||||
keyword_lower = keyword.lower()
|
||||
return [
|
||||
a for a in all_anime
|
||||
if keyword_lower in a.subject.lower()
|
||||
or keyword_lower in a.original_subject.lower()
|
||||
]
|
||||
# 특수문자 제거 버전 (따옴표, 괄호 등)
|
||||
keyword_norm = _re.sub(r'[^\w\s]', '', keyword_lower)
|
||||
|
||||
try:
|
||||
from tools.title_matcher import japanese_to_romaji, title_similarity
|
||||
use_romaji = True
|
||||
except ImportError:
|
||||
use_romaji = False
|
||||
|
||||
results = []
|
||||
fuzzy_candidates = []
|
||||
|
||||
for a in all_anime:
|
||||
subj_lower = a.subject.lower()
|
||||
orig_lower = a.original_subject.lower()
|
||||
# 특수문자 제거 버전
|
||||
subj_norm = _re.sub(r'[^\w\s]', '', subj_lower)
|
||||
orig_norm = _re.sub(r'[^\w\s]', '', orig_lower)
|
||||
|
||||
# 1차: substring 매칭 (원본 + 정규화)
|
||||
if (keyword_lower in subj_lower or keyword_norm in subj_norm):
|
||||
results.append(a)
|
||||
elif (keyword_lower in orig_lower or keyword_norm in orig_norm):
|
||||
results.append(a)
|
||||
elif use_romaji:
|
||||
romaji = japanese_to_romaji(a.original_subject).lower()
|
||||
# 2차: romaji substring
|
||||
if keyword_lower in romaji:
|
||||
results.append(a)
|
||||
else:
|
||||
# 3차: 단어 단위 fuzzy — 검색어와 romaji 개별 단어 비교
|
||||
words = romaji.split()
|
||||
best_word_sim = max(
|
||||
(title_similarity(keyword, w) for w in words),
|
||||
default=0.0,
|
||||
)
|
||||
# 전체 문자열 유사도도 참고
|
||||
full_sim = title_similarity(keyword, romaji)
|
||||
best_sim = max(best_word_sim, full_sim)
|
||||
if best_sim >= 0.6:
|
||||
fuzzy_candidates.append((best_sim, a))
|
||||
|
||||
# exact 결과가 없을 때만 fuzzy 결과 사용
|
||||
if not results and fuzzy_candidates:
|
||||
fuzzy_candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
results = [a for _, a in fuzzy_candidates[:10]]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── CLI 진입점 ──
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
client = AnissiaClient()
|
||||
args = sys.argv[1:]
|
||||
|
||||
async def main():
|
||||
if not args:
|
||||
print("사용법: python tools/anissia_client.py [schedule|search|captions] [인자]")
|
||||
return
|
||||
|
||||
if args[0] == "schedule":
|
||||
# python tools/anissia_client.py schedule 3 (수요일)
|
||||
week = int(args[1]) if len(args) > 1 else 0
|
||||
anime_list = await client.get_schedule(week)
|
||||
day = WEEK_NAMES.get(week, "?")
|
||||
print(f"📺 {day}요일 편성표 ({len(anime_list)}개):")
|
||||
for a in anime_list:
|
||||
cap = f"자막 {a.caption_count}명" if a.caption_count else "자막 없음"
|
||||
print(f" {a.time} {a.subject} ({a.original_subject}) [{cap}]")
|
||||
|
||||
elif args[0] == "search" and len(args) > 1:
|
||||
# python tools/anissia_client.py search "프리렌"
|
||||
keyword = " ".join(args[1:])
|
||||
results = await client.search_anime(keyword)
|
||||
print(f"🔍 '{keyword}' 검색 결과 ({len(results)}개):")
|
||||
for a in results:
|
||||
print(f" [{a.anime_no}] {a.subject} ({a.original_subject}) | {WEEK_NAMES.get(a.week, '?')} {a.time}")
|
||||
|
||||
elif args[0] == "captions" and len(args) > 1:
|
||||
# python tools/anissia_client.py captions 12345
|
||||
anime_no = int(args[1])
|
||||
captions = await client.get_captions(anime_no)
|
||||
print(f"📝 자막 목록 ({len(captions)}건):")
|
||||
for c in captions:
|
||||
print(f" {c.episode}화 | {c.name} | {c.website} | {c.updated}")
|
||||
|
||||
else:
|
||||
print("사용법: python tools/anissia_client.py [schedule|search|captions] [인자]")
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
@@ -150,3 +150,44 @@ class NasScanner:
|
||||
"total_size_gb": round(sum(f.total_size_gb for f in folders), 2),
|
||||
"folders": folders,
|
||||
}
|
||||
|
||||
|
||||
# ── CLI 진입점 ──
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import json
|
||||
|
||||
scanner = NasScanner()
|
||||
args = sys.argv[1:]
|
||||
|
||||
if not args or args[0] == "scan":
|
||||
# python tools/nas_scanner.py scan [--year 26] [--quarter 1]
|
||||
year = quarter = None
|
||||
for i, a in enumerate(args):
|
||||
if a == "--year" and i + 1 < len(args):
|
||||
year = int(args[i + 1])
|
||||
if a == "--quarter" and i + 1 < len(args):
|
||||
quarter = int(args[i + 1])
|
||||
|
||||
folders = scanner.list_anime_folders(year=year, quarter=quarter)
|
||||
for f in folders:
|
||||
print(f"📁 {f.folder_name} | 영상 {f.video_count}개 | 자막 {f.subtitle_count}개 | {f.total_size_gb:.1f}GB")
|
||||
print(f"\n총 {len(folders)}개 애니, 영상 {sum(f.video_count for f in folders)}개")
|
||||
|
||||
elif args[0] == "search" and len(args) > 1:
|
||||
# python tools/nas_scanner.py search "프리렌"
|
||||
keyword = " ".join(args[1:])
|
||||
results = scanner.search(keyword)
|
||||
for f in results:
|
||||
print(f"📁 {f.folder_name} | 영상 {f.video_count}개 | 자막 {f.subtitle_count}개")
|
||||
if not results:
|
||||
print(f"'{keyword}' 검색 결과 없음")
|
||||
|
||||
elif args[0] == "summary":
|
||||
# python tools/nas_scanner.py summary
|
||||
summary = scanner.get_summary()
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
else:
|
||||
print("사용법: python tools/nas_scanner.py [scan|search|summary] [옵션]")
|
||||
|
||||
|
||||
@@ -154,3 +154,48 @@ class NyaaClient:
|
||||
# 시더 수 내림차순 정렬
|
||||
results.sort(key=lambda r: r.seeders, reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
# ── CLI 진입점 ──
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
args = sys.argv[1:]
|
||||
client = NyaaClient()
|
||||
|
||||
async def main():
|
||||
if not args or args[0] == "search":
|
||||
# python tools/nyaa_client.py search "Sousou no Frieren" [--suffix "ASW HEVC"]
|
||||
query_parts = []
|
||||
suffix = "ASW HEVC"
|
||||
i = 1 if args and args[0] == "search" else 0
|
||||
while i < len(args):
|
||||
if args[i] == "--suffix" and i + 1 < len(args):
|
||||
suffix = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--no-suffix":
|
||||
suffix = ""
|
||||
i += 1
|
||||
else:
|
||||
query_parts.append(args[i])
|
||||
i += 1
|
||||
|
||||
if not query_parts:
|
||||
print("사용법: python tools/nyaa_client.py search \"제목\" [--suffix \"ASW HEVC\"]")
|
||||
return
|
||||
|
||||
query = " ".join(query_parts)
|
||||
client.default_suffix = suffix
|
||||
results = await client.search(query, use_default_suffix=bool(suffix))
|
||||
|
||||
print(f"🔍 Nyaa 검색: '{query}' +'{suffix}' → {len(results)}건")
|
||||
for r in results[:20]:
|
||||
ep = f" {r.episode}화" if r.episode else ""
|
||||
print(f" [{r.group}] {r.title[:60]}... | {r.size} | S:{r.seeders}{ep}")
|
||||
print(f" magnet: {r.magnet_link[:80]}...")
|
||||
else:
|
||||
print("사용법: python tools/nyaa_client.py search \"제목\" [--suffix \"ASW HEVC\"]")
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
@@ -196,3 +196,66 @@ class QBitClient:
|
||||
}
|
||||
except Exception as e:
|
||||
return {"connected": False, "error": str(e), "url": self.url}
|
||||
|
||||
async def delete_torrent(self, info_hash: str, delete_files: bool = False) -> bool:
|
||||
"""토렌트 삭제 (완료 후 정리용)."""
|
||||
await self._ensure_login()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{self.url}/api/v2/torrents/delete",
|
||||
data={
|
||||
"hashes": info_hash,
|
||||
"deleteFiles": str(delete_files).lower(),
|
||||
},
|
||||
cookies=self._cookies(),
|
||||
)
|
||||
return resp.status_code == 200
|
||||
|
||||
|
||||
# ── CLI 진입점 ──
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
args = sys.argv[1:]
|
||||
client = QBitClient()
|
||||
|
||||
async def main():
|
||||
if not args or args[0] == "status":
|
||||
# python tools/qbit_client.py status
|
||||
torrents = await client.list_torrents(category="anime")
|
||||
if not torrents:
|
||||
print("🎬 다운로드 중인 애니 없음")
|
||||
return
|
||||
print(f"🎬 다운로드 현황 ({len(torrents)}건):")
|
||||
for t in torrents:
|
||||
speed = f"{t.download_speed / (1024**2):.1f}MB/s" if t.download_speed > 0 else "-"
|
||||
eta = f"{t.eta // 60}분" if t.eta > 0 else "∞"
|
||||
print(f" {t.progress*100:.0f}% | {t.name[:50]} | {speed} | ETA: {eta}")
|
||||
|
||||
elif args[0] == "add" and len(args) > 1:
|
||||
# python tools/qbit_client.py add "magnet:..." --path "\\NAS\path"
|
||||
magnet = args[1]
|
||||
path = ""
|
||||
for i, a in enumerate(args):
|
||||
if a == "--path" and i + 1 < len(args):
|
||||
path = args[i + 1]
|
||||
ok = await client.add_torrent(magnet, save_path=path)
|
||||
print(f"{'✅ 추가 성공' if ok else '❌ 추가 실패'}")
|
||||
|
||||
elif args[0] == "delete" and len(args) > 1:
|
||||
# python tools/qbit_client.py delete <hash> [--files]
|
||||
hash_ = args[1]
|
||||
delete_files = "--files" in args
|
||||
ok = await client.delete_torrent(hash_, delete_files=delete_files)
|
||||
print(f"{'✅ 삭제 성공' if ok else '❌ 삭제 실패'}")
|
||||
|
||||
elif args[0] == "test":
|
||||
info = await client.test_connection()
|
||||
print(f"연결: {'✅' if info['connected'] else '❌'} {info}")
|
||||
|
||||
else:
|
||||
print("사용법: python tools/qbit_client.py [status|add|delete|test] [옵션]")
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
@@ -51,20 +51,16 @@ def _extract_episode_from_text(text: str) -> Optional[int]:
|
||||
def parse_google_drive_links(html: str) -> list[SubtitleFile]:
|
||||
"""HTML에서 Google Drive 다운로드 링크 추출.
|
||||
|
||||
패턴: drive.google.com/file/d/{fileId}/view
|
||||
→ 직접 다운로드: drive.google.com/uc?id={fileId}&export=download
|
||||
지원 패턴:
|
||||
1. drive.google.com/file/d/{fileId}/view
|
||||
2. drive.google.com/uc?id={fileId}&export=download (직접 다운로드)
|
||||
"""
|
||||
pattern = r'https://drive\.google\.com/file/d/([a-zA-Z0-9_-]+)/view[^"]*'
|
||||
matches = re.findall(pattern, html)
|
||||
|
||||
# 링크 주변 텍스트에서 에피소드 정보 추출
|
||||
link_pattern = r'<a[^>]*href="(https://drive\.google\.com/file/d/[^"]+)"[^>]*>([^<]*)</a>'
|
||||
link_matches = re.findall(link_pattern, html)
|
||||
|
||||
results = []
|
||||
seen_ids = set()
|
||||
|
||||
for url, text in link_matches:
|
||||
# 패턴 1: /file/d/{id}/view — HTML <a> 태그
|
||||
link_pattern = r'<a[^>]*href="(https://drive\.google\.com/file/d/[^"]+)"[^>]*>([^<]*)</a>'
|
||||
for url, text in re.findall(link_pattern, html):
|
||||
m = re.search(r'/d/([a-zA-Z0-9_-]+)/', url)
|
||||
if not m:
|
||||
continue
|
||||
@@ -72,19 +68,15 @@ def parse_google_drive_links(html: str) -> list[SubtitleFile]:
|
||||
if file_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(file_id)
|
||||
|
||||
episode = _extract_episode_from_text(text)
|
||||
download_url = f"https://drive.google.com/uc?id={file_id}&export=download"
|
||||
|
||||
results.append(SubtitleFile(
|
||||
filename=text.strip() or f"subtitle_{file_id}",
|
||||
download_url=download_url,
|
||||
download_url=f"https://drive.google.com/uc?id={file_id}&export=download",
|
||||
platform="google_drive",
|
||||
episode=episode,
|
||||
episode=_extract_episode_from_text(text),
|
||||
))
|
||||
|
||||
# 매칭되지 않은 bare ID도 추가
|
||||
for file_id in matches:
|
||||
# 패턴 1: bare ID (태그 밖)
|
||||
for file_id in re.findall(r'drive\.google\.com/file/d/([a-zA-Z0-9_-]+)/', html):
|
||||
if file_id not in seen_ids:
|
||||
seen_ids.add(file_id)
|
||||
results.append(SubtitleFile(
|
||||
@@ -93,6 +85,23 @@ def parse_google_drive_links(html: str) -> list[SubtitleFile]:
|
||||
platform="google_drive",
|
||||
))
|
||||
|
||||
# 패턴 2: uc?id={id} 직접 다운로드 URL (Blogspot 등)
|
||||
uc_pattern = r'drive\.google\.com/uc\?[^"\s\)]*id=([a-zA-Z0-9_-]+)[^"\s\)]*'
|
||||
for file_id in re.findall(uc_pattern, html):
|
||||
if file_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(file_id)
|
||||
# 주변 텍스트에서 파일명 추출 시도 (마크다운: [파일명](url))
|
||||
md_pattern = r'\[([^\]]+)\]\([^)]*' + re.escape(file_id) + r'[^)]*\)'
|
||||
md_match = re.search(md_pattern, html)
|
||||
filename = md_match.group(1).strip() if md_match else f"subtitle_{file_id}"
|
||||
results.append(SubtitleFile(
|
||||
filename=filename,
|
||||
download_url=f"https://drive.google.com/uc?id={file_id}&export=download",
|
||||
platform="google_drive",
|
||||
episode=_extract_episode_from_text(filename),
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -205,20 +214,32 @@ class SubtitleDownloader:
|
||||
if "download.blog.naver.com" in html or "blogfiles.pstatic.net" in html:
|
||||
results.extend(parse_naver_links(html))
|
||||
|
||||
# 범용: 직접 자막 파일 링크 탐지
|
||||
generic_pattern = r'href="([^"]+\.(?:ass|srt|ssa|sub|zip|7z))"'
|
||||
generic = re.findall(generic_pattern, html, re.IGNORECASE)
|
||||
# 범용: 직접 자막 파일 링크 탐지 (HTML href + 마크다운)
|
||||
seen_urls = {r.download_url for r in results}
|
||||
for gurl in generic:
|
||||
|
||||
# HTML <a href="...">
|
||||
for gurl in re.findall(r'href="([^"]+\.(?:ass|srt|ssa|sub|zip|7z)(?:\?[^"]*)?)"', html, re.IGNORECASE):
|
||||
if gurl not in seen_urls:
|
||||
filename = gurl.split("/")[-1].split("?")[0]
|
||||
seen_urls.add(gurl)
|
||||
filename = unquote(gurl.split("/")[-1].split("?")[0])
|
||||
results.append(SubtitleFile(
|
||||
filename=unquote(filename),
|
||||
filename=filename,
|
||||
download_url=gurl,
|
||||
platform="generic",
|
||||
episode=_extract_episode_from_text(filename),
|
||||
))
|
||||
|
||||
# 마크다운 [텍스트](url) — Blogspot 등
|
||||
for text, gurl in re.findall(r'\[([^\]]+)\]\((https?://[^)]+\.(?:ass|srt|ssa|sub|zip|7z)[^)]*)\)', html, re.IGNORECASE):
|
||||
if gurl not in seen_urls:
|
||||
seen_urls.add(gurl)
|
||||
results.append(SubtitleFile(
|
||||
filename=text.strip(),
|
||||
download_url=gurl,
|
||||
platform="generic",
|
||||
episode=_extract_episode_from_text(text),
|
||||
))
|
||||
|
||||
logger.info(f"자막 {len(results)}건 발견: {url}")
|
||||
return results
|
||||
|
||||
@@ -227,7 +248,7 @@ class SubtitleDownloader:
|
||||
sub: SubtitleFile,
|
||||
save_dir: Optional[str] = None,
|
||||
) -> str:
|
||||
"""자막 파일 다운로드 → 로컬 저장. 저장 경로 반환."""
|
||||
"""자막 파일 다운로드 → 로컬 저장. ZIP이면 자동 해제. 저장 경로 반환."""
|
||||
target_dir = Path(save_dir) if save_dir else self.download_dir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -248,13 +269,55 @@ class SubtitleDownloader:
|
||||
# Content-Disposition에서 실제 파일명 추출
|
||||
cd = resp.headers.get("content-disposition", "")
|
||||
if "filename" in cd:
|
||||
m = re.search(r'filename[*]?=["\']?(?:UTF-8\'\')?([^"\';\n]+)', cd)
|
||||
m = re.search(r'filename[*]?=["\']?(?:UTF-8\'\')?([^"\';\\n]+)', cd)
|
||||
if m:
|
||||
sub.filename = unquote(m.group(1).strip())
|
||||
|
||||
filepath = target_dir / sub.filename
|
||||
filepath.write_bytes(resp.content)
|
||||
|
||||
# ZIP/7z 자동 해제
|
||||
extracted = self._extract_archive(filepath, target_dir)
|
||||
if extracted:
|
||||
sub.local_path = extracted[0] # 첫 번째 자막 파일
|
||||
sub.filename = Path(extracted[0]).name
|
||||
logger.info(f"자막 ZIP 해제 완료: {len(extracted)}건 → {target_dir}")
|
||||
return extracted[0]
|
||||
|
||||
sub.local_path = str(filepath)
|
||||
logger.info(f"자막 다운로드 완료: {filepath}")
|
||||
return str(filepath)
|
||||
|
||||
@staticmethod
|
||||
def _extract_archive(filepath: Path, target_dir: Path) -> list[str]:
|
||||
"""ZIP/7z 파일 해제 → 자막 파일(.ass/.srt/.ssa/.sub) 경로 리스트 반환."""
|
||||
import zipfile
|
||||
|
||||
suffix = filepath.suffix.lower()
|
||||
if suffix not in (".zip", ".7z"):
|
||||
return []
|
||||
|
||||
extracted = []
|
||||
|
||||
if suffix == ".zip":
|
||||
try:
|
||||
with zipfile.ZipFile(filepath, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
# 디렉토리 건너뛰기
|
||||
if name.endswith("/"):
|
||||
continue
|
||||
ext = Path(name).suffix.lower()
|
||||
if ext in (".ass", ".srt", ".ssa", ".sub"):
|
||||
# 중첩 폴더 무시, 파일만 추출
|
||||
out_name = Path(name).name
|
||||
out_path = target_dir / out_name
|
||||
with zf.open(name) as src, open(out_path, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
extracted.append(str(out_path))
|
||||
# ZIP 원본 삭제
|
||||
filepath.unlink(missing_ok=True)
|
||||
except (zipfile.BadZipFile, Exception) as e:
|
||||
logger.warning(f"ZIP 해제 실패: {filepath} - {e}")
|
||||
|
||||
return extracted
|
||||
|
||||
|
||||
@@ -10,9 +10,70 @@ import unicodedata
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("variet.tools.matcher")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 영어 제목 조회 (Jikan API / MyAnimeList)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def fetch_english_title(japanese_title: str) -> dict[str, str]:
|
||||
"""Jikan API로 일본어 원제의 영어/로마자 제목 조회.
|
||||
|
||||
Returns:
|
||||
{"default": "Sousou no Frieren 2nd Season",
|
||||
"english": "Frieren: Beyond Journey's End Season 2",
|
||||
"synonyms": ["Frieren at the Funeral Season 2"]}
|
||||
실패 시 빈 dict.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
"https://api.jikan.moe/v4/anime",
|
||||
params={"q": japanese_title, "limit": 5},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
items = data.get("data", [])
|
||||
if not items:
|
||||
return {}
|
||||
|
||||
# 원제와 가장 잘 매칭되는 항목 선택
|
||||
best = None
|
||||
best_score = 0.0
|
||||
for item in items:
|
||||
jp = item.get("title_japanese", "")
|
||||
score = SequenceMatcher(None, japanese_title, jp).ratio()
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = item
|
||||
|
||||
if not best or best_score < 0.5:
|
||||
return {}
|
||||
|
||||
result = {
|
||||
"default": best.get("title", ""),
|
||||
"english": best.get("title_english") or "",
|
||||
"synonyms": [],
|
||||
}
|
||||
for t in best.get("titles", []):
|
||||
if t["type"] == "Synonym":
|
||||
result["synonyms"].append(t["title"])
|
||||
|
||||
logger.info(
|
||||
f"Jikan 영어 제목 조회: {japanese_title} → "
|
||||
f"default={result['default']}, english={result['english']}"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Jikan API 조회 실패: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 일어 → 로마자 변환 테이블 (히라가나/카타카나)
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -66,24 +127,35 @@ def _kata_to_hira(text: str) -> str:
|
||||
|
||||
|
||||
def japanese_to_romaji(text: str) -> str:
|
||||
"""일본어 텍스트를 로마자로 근사 변환."""
|
||||
"""일본어 텍스트를 로마자로 변환 (pykakasi 기반, fallback: 카나 테이블)."""
|
||||
try:
|
||||
import pykakasi
|
||||
kks = pykakasi.kakasi()
|
||||
result_items = kks.convert(text)
|
||||
romaji = " ".join(item["hepburn"] for item in result_items)
|
||||
# 연속 공백 정리
|
||||
romaji = re.sub(r'\s+', ' ', romaji).strip()
|
||||
return romaji
|
||||
except ImportError:
|
||||
logger.warning("pykakasi 미설치 — 카나 테이블 fallback 사용")
|
||||
return _japanese_to_romaji_fallback(text)
|
||||
|
||||
|
||||
def _japanese_to_romaji_fallback(text: str) -> str:
|
||||
"""일본어→로마자 fallback (카나만 변환, 한자는 그대로)."""
|
||||
text = _kata_to_hira(text)
|
||||
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
# 장음 기호 (ー U+30FC, ー가 히라가나로 안 변환되므로 여기서 처리)
|
||||
if text[i] == '\u30FC': # ー
|
||||
# 장음: 이전 모음 반복 (간략화: 스킵)
|
||||
i += 1
|
||||
continue
|
||||
# 2글자 매칭 우선 (きゃ 등)
|
||||
if i + 1 < len(text) and text[i:i+2] in _KANA_ROMAJI:
|
||||
result.append(_KANA_ROMAJI[text[i:i+2]])
|
||||
i += 2
|
||||
elif text[i] in _KANA_ROMAJI:
|
||||
romaji = _KANA_ROMAJI[text[i]]
|
||||
# 촉음(っ) 처리: 다음 자음 반복
|
||||
if text[i] == 'っ' and i + 1 < len(text):
|
||||
next_romaji = _KANA_ROMAJI.get(text[i+1], "")
|
||||
if next_romaji:
|
||||
@@ -92,13 +164,13 @@ def japanese_to_romaji(text: str) -> str:
|
||||
result.append(romaji)
|
||||
i += 1
|
||||
else:
|
||||
# 한자, 영어, 숫자 등 → 그대로
|
||||
result.append(text[i])
|
||||
i += 1
|
||||
|
||||
return "".join(result)
|
||||
|
||||
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
"""제목 비교용 정규화: 소문자 + 특수문자 제거 + 공백 정리."""
|
||||
title = title.lower().strip()
|
||||
@@ -155,8 +227,14 @@ def match_titles(
|
||||
|
||||
best_sim = max(sim_romaji, sim_korean, sim_original)
|
||||
|
||||
if best_sim >= threshold:
|
||||
scored.append((best_sim, result))
|
||||
# ASW HEVC 릴리스는 threshold 면제 (약칭으로 올라와도 포함)
|
||||
title_upper = result.title.upper()
|
||||
is_preferred = "[ASW]" in result.title and ("HEVC" in title_upper or "X265" in title_upper)
|
||||
|
||||
if best_sim >= threshold or is_preferred:
|
||||
# ASW HEVC 릴리스는 유사도 보너스 (+0.5) → 정렬 시 상위 배치
|
||||
effective_sim = best_sim + 0.5 if is_preferred else best_sim
|
||||
scored.append((effective_sim, result))
|
||||
|
||||
# 유사도 내림차순 정렬
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
@@ -1,7 +1,41 @@
|
||||
{
|
||||
"5608566207": {
|
||||
"name": "test_1_orphan_20260307",
|
||||
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1_orphan_20260307",
|
||||
"channel_id": 0,
|
||||
"git": {
|
||||
"url": "",
|
||||
"token": "",
|
||||
"repo": "",
|
||||
"branch": "main"
|
||||
},
|
||||
"vikunja": {
|
||||
"url": "",
|
||||
"token": "",
|
||||
"project_id": 0
|
||||
},
|
||||
"docs_path": "docs/wiki"
|
||||
},
|
||||
"8350378037": {
|
||||
"name": "test_2_orphan_20260307",
|
||||
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_2_orphan_20260307",
|
||||
"channel_id": 0,
|
||||
"git": {
|
||||
"url": "",
|
||||
"token": "",
|
||||
"repo": "",
|
||||
"branch": "main"
|
||||
},
|
||||
"vikunja": {
|
||||
"url": "",
|
||||
"token": "",
|
||||
"project_id": 0
|
||||
},
|
||||
"docs_path": "docs/wiki"
|
||||
},
|
||||
"1480113683849023661": {
|
||||
"name": "variet-agent",
|
||||
"path": "c:\\Users\\Variet-Worker\\Desktop\\VW_Proj\\variet-agent",
|
||||
"path": "c:\\Users\\Certes\\Desktop\\variet-agent",
|
||||
"channel_id": 1480113683849023661,
|
||||
"git": {
|
||||
"url": "",
|
||||
@@ -15,5 +49,22 @@
|
||||
"project_id": 0
|
||||
},
|
||||
"docs_path": "docs/wiki"
|
||||
},
|
||||
"3237068326": {
|
||||
"name": "test_1_orphan_20260314",
|
||||
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1_orphan_20260314",
|
||||
"channel_id": 0,
|
||||
"git": {
|
||||
"url": "",
|
||||
"token": "",
|
||||
"repo": "",
|
||||
"branch": "main"
|
||||
},
|
||||
"vikunja": {
|
||||
"url": "",
|
||||
"token": "",
|
||||
"project_id": 0
|
||||
},
|
||||
"docs_path": "docs/wiki"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user