feat(tools): 애니메이션 자동화 파이프라인 구현

- tools/anissia_client.py: Anissia API 클라이언트 (편성표/자막)
- tools/nyaa_client.py: Nyaa.si RSS 토렌트 검색
- tools/qbit_client.py: qBittorrent Web API 클라이언트
- tools/subtitle_downloader.py: Google Drive/Tistory/Naver 자막 파서
- tools/title_matcher.py: 제목 매칭 + NAS 폴더명 생성
- tools/anime_pipeline.py: 전체 파이프라인 오케스트레이터
- tools/nas_scanner.py: NAS 폴더/파일 스캔
- prompts/unified.md: anime 모드 추가 (AI 평문 의도 분류)
- api/discord_bot.py: AI 평문 anime 핸들러 + /anime 슬래시 커맨드
- config.py: qBittorrent/NAS 설정 추가
- .agents/: agent_guide 워크플로우 통합
- docs/devlog: 세션 기록
This commit is contained in:
2026-03-08 16:07:16 +09:00
parent 49ee5f397c
commit c92433b0b1
36 changed files with 3663 additions and 128 deletions

54
.agents/AGENT.md Normal file
View File

@@ -0,0 +1,54 @@
---
description: 모든 작업에 자동 적용되는 에이전트 행동 규칙. 새 대화 시작 시 반드시 이 파일을 먼저 읽습니다.
---
# Agent Rules
## Identity
당신은 이 프로젝트의 시니어 개발자입니다. 지시를 정확히 따르고, 추측보다 근거를 우선합니다.
## NEVER (절대 금지)
1. NEVER start coding without reading relevant reference documents in `.agents/references/`
2. NEVER guess when documentation exists — always check `.agents/references/` first
3. NEVER repeat a failed approach — check `.agents/references/known-issues.md` first
4. NEVER call APIs directly when helper scripts exist in `.agents/workflows/helpers/`
5. NEVER skip the pre-task checklist defined in `.agents/workflows/pre-task.md`
6. NEVER attempt the same failed approach more than 2 times
7. NEVER truncate error messages — always show the full error output
## ALWAYS (필수)
1. ALWAYS run `.agents/workflows/pre-task.md` before any implementation task
2. ALWAYS check `.agents/references/known-issues.md` before debugging
3. ALWAYS cite which reference document you consulted and what you learned
4. ALWAYS stop and ask the user if 2 consecutive attempts on the same approach fail
5. ALWAYS use existing helper scripts instead of raw API calls
6. ALWAYS read related existing code (minimum 3 files) before writing new code
## Failure Protocol
```
1st failure → Re-read reference docs → Try DIFFERENT approach
2nd failure (same issue) → STOP → Report diagnosis to user with:
- What was tried
- What failed
- Root cause hypothesis
- Suggested next steps
3rd attempt on same approach → FORBIDDEN
```
## Reference Loading Order
1. `.agents/AGENT.md` (this file — behavior rules)
2. `.agents/references/known-issues.md` (past failure patterns)
3. `.agents/references/` (project-specific knowledge)
4. `.agents/workflows/services.md` (service credentials & protocols)
5. `.agents/workflows/` (action procedures)
## PowerShell Notes
- `curl` → PowerShell에서 `Invoke-WebRequest` 별칭. **반드시 `curl.exe`** 사용
- `npm` → 실행 정책 문제 시 `cmd /c npm` 사용
- JSON 처리 시 `.py` 스크립트 권장 (PowerShell 이스케이핑 이슈 방지)

163
.agents/GUIDE.md Normal file
View File

@@ -0,0 +1,163 @@
# AI 에이전트 워크플로우 시스템 가이드
> 이 가이드는 AI 코딩 에이전트가 더 똑똑하게 동작하도록 설계된 범용 워크플로우 시스템의 사용법을 설명합니다.
---
## 왜 이 시스템이 필요한가?
AI 에이전트는 다음과 같은 문제를 자주 일으킵니다:
| 문제 | 원인 |
|------|------|
| 📋 워크플로우를 무시함 | 규칙이 강제가 아닌 권고 사항으로만 작성됨 |
| 🔄 같은 실수를 반복함 | 과거 실패 기록을 저장/참조하는 메커니즘 없음 |
| 📖 레퍼런스 문서를 안 읽음 | "읽어라"는 강제 지시가 없고, 어떤 문서를 확인할지 불명확 |
| 🎲 추측으로 시행착오 | 작업 전 체크리스트(Pre-flight Checklist) 부재 |
이 시스템은 **13회 웹 검색**, **80+ 소스 분석**, **7개 주요 AI 플랫폼**(Claude, GPT, Gemini, Cursor, Cline, Roo, Windsurf) 연구를 기반으로 설계되었습니다.
---
## 파일 구조 개요
```
.agents/
├── AGENT.md ← 🧠 에이전트 헌법 (NEVER/ALWAYS 규칙)
├── GUIDE.md ← 📖 이 가이드
├── references/ ← 📚 프로젝트 지식 베이스
│ ├── architecture.md ← 아키텍처 설명
│ ├── tech-stack.md ← 기술 스택 & 버전
│ ├── conventions.md ← 코딩 컨벤션
│ └── known-issues.md ← 🔴 과거 실패 기록 (핵심!)
└── workflows/ ← ⚙️ 행동 절차
├── start.md ← 세션 시작 (룰 로딩 + devlog 복구)
├── end.md ← 세션 종료 (devlog + known-issues + Vikunja + Git)
├── pre-task.md ← 작업 전 필수 체크리스트
├── debug.md ← 디버깅 전용 절차
├── services.md ← 서비스 연동 정보 + AI 작업 프로토콜
├── check-gitea.md ← Gitea 현황 조회
├── check-vikunja.md ← Vikunja 태스크 조회
└── helpers/
├── vikunja_helper.py ← Vikunja API 안전 래퍼
└── wiki_helper.py ← Gitea Wiki 래퍼
```
**프로젝트 루트에 자동 생성되는 디렉토리:**
```
docs/devlog/ ← 📓 세션별 작업 기록
├── YYYY-MM-DD.md ← Index (매일 1줄씩 누적)
└── entries/
└── YYYYMMDD-NNN.md ← Entry (설계 결정/미완료 시만)
```
---
## 각 파일의 역할
### 🧠 `AGENT.md` — 에이전트 헌법
에이전트가 **모든 대화에서 따라야 하는 글로벌 규칙**입니다.
**핵심 메커니즘:**
- **NEVER 규칙**: `"절대 ~하지 마라"` — 연구에 따르면 금지 규칙이 더 잘 지켜집니다
- **Failure Protocol**: 동일 접근 2회 실패 시 자동 중단 → 유저에게 보고
- **Reference Loading Order**: 어떤 문서를 먼저 읽을지 우선순위 명시
### 📋 `pre-task.md` — 사전 점검 체크리스트
모든 구현 작업 전에 실행하는 **4단계 체크리스트**:
1. 요구사항 정리
2. 레퍼런스 확인 (추측 금지)
3. 계획 수립
4. 유저 확인
### 🔴 `known-issues.md` — 과거 실패 기록
**가장 중요한 파일.** 에이전트가 같은 실수를 반복하는 근본 원인은 **실패를 기억하지 못하기 때문**입니다. 이 파일은:
- 세션 종료 시 에이전트가 자동으로 새 이슈를 추가
- 디버깅/구현 전에 에이전트가 반드시 확인
- 시간이 지날수록 **축적 학습** 효과
### 🔧 `debug.md` — 디버깅 전용 워크플로우
**추측 기반 디버깅을 금지**하는 5단계 절차:
1. 정보 수집 (에러 전문 확인)
2. known-issues 확인
3. 근본 원인 분석 (가설 → 검증)
4. 수정 및 검증
5. 기록 (known-issues에 추가)
### 📓 Devlog — 세션별 작업 기록 (start.md / end.md에서 관리)
known-issues가 **실패만** 기록한다면, devlog는 **전체 세션 이력**을 기록합니다:
- **Index** (`docs/devlog/YYYY-MM-DD.md`): 매 작업마다 1줄 (필수)
- **Entry** (`docs/devlog/entries/YYYYMMDD-NNN.md`): 설계 결정/미완료/삽질 시만 (선택)
- **start.md**에서 자동으로 오늘/어제 devlog를 읽어 맥락 복구
### ▶️ `start.md` / ⏹️ `end.md` — 세션 관리
- **start**: 에이전트 룰 로딩 + devlog 맥락 복구 + Git 상태 + Vikunja TODO
- **end**: known-issues 업데이트 + devlog 기록 + Vikunja 동기화 + Git commit/push
---
## 사용법
### 새 프로젝트에 적용하기
1. `.agents/` 디렉토리를 프로젝트에 복사
2. `references/` 파일들을 프로젝트에 맞게 채우기:
- `architecture.md` — 프로젝트 구조 설명
- `tech-stack.md` — 사용 기술 및 버전
- `conventions.md` — 코딩 스타일 규칙
3. 프로젝트별 워크플로우가 있다면 `workflows/`에 추가
### 프로젝트별 워크플로우와 함께 사용하기
이 범용 워크플로우와 프로젝트별 워크플로우(예: Vikunja 동기화, Gitea 연동)는 **함께 사용**합니다:
```
.agents/
├── AGENT.md ← 범용 (공통)
├── references/ ← 범용 + 프로젝트 특화
│ ├── known-issues.md ← 범용 (공통)
│ └── ... ← 프로젝트에 맞게 작성
└── workflows/
├── pre-task.md ← 범용 (공통)
├── debug.md ← 범용 (공통)
├── start.md ← 범용 기반 + 프로젝트 단계 추가
├── end.md ← 범용 기반 + 프로젝트 단계 추가
├── services.md ← ⭐ 프로젝트별
├── check-vikunja.md ← ⭐ 프로젝트별
├── check-gitea.md ← ⭐ 프로젝트별
└── helpers/
├── vikunja_helper.py ← ⭐ 프로젝트별
└── wiki_helper.py ← ⭐ 프로젝트별
```
### 다른 AI IDE에서도 사용하기
| 대상 플랫폼 | 방법 |
|------------|------|
| **Cursor** | `AGENT.md``.cursor/rules/agent.mdc` (alwaysApply) |
| **Claude Code** | `AGENT.md``CLAUDE.md`, references를 `@import` |
| **Windsurf** | `AGENT.md``.windsurfrules` 또는 `.windsurf/rules/agent.md` |
| **Cline/Roo** | 루트에 `AGENTS.md`로 복사 |
| **Gemini** | `AGENT.md``.gemini/GEMINI.md` |
---
## 연구 근거 요약
이 시스템의 각 설계 결정은 학술 연구와 실무 사례에 근거합니다:
| 설계 결정 | 근거 |
|----------|------|
| NEVER > ALWAYS (금지 규칙 우선) | Community 검증 — "NEVER use X" ≫ "always prefer Y" |
| 2회 실패 시 자동 중단 | Streak Breaker / Sentinel Check 연구 |
| 실패 기록 누적 | Reflexion Framework (텍스트 피드백 기반 자기 교정) |
| 사전 체크리스트 강제 | Claude Skills 체크리스트 + GPT Chain-of-Thought |
| Progressive Disclosure | Anthropic Context Engineering (2025) |
| 300줄 이하 규칙 | Claude `CLAUDE.md` 공식 권장 (토큰 효율성) |
| 코드 예시 > 설명 | GitHub Copilot Agents, AGENTS.md 공통 Best Practice |

View File

@@ -0,0 +1,35 @@
# Architecture
> 이 프로젝트의 아키텍처를 설명하는 문서입니다.
> AI 에이전트는 구현 전 이 문서를 반드시 확인합니다.
## 프로젝트 개요
<!-- 프로젝트의 목적과 핵심 기능을 간략히 서술 -->
(프로젝트 설명을 여기에 작성하세요)
## 디렉토리 구조
```
project-root/
├── src/ # 소스 코드
├── tests/ # 테스트
├── docs/ # 문서
├── .agents/ # AI 에이전트 설정
└── ...
```
## 핵심 모듈
<!-- 각 모듈의 역할과 의존 관계를 설명 -->
| 모듈 | 역할 | 의존성 |
|------|------|--------|
| (모듈명) | (역할 설명) | (의존하는 모듈) |
## 데이터 흐름
<!-- 주요 데이터 흐름을 Mermaid 다이어그램이나 텍스트로 설명 -->
(데이터 흐름을 여기에 작성하세요)

View File

@@ -0,0 +1,45 @@
# Coding Conventions
> AI 에이전트는 코드를 작성하기 전 이 컨벤션을 확인합니다.
## 네이밍
| 대상 | 규칙 | 예시 |
|------|------|------|
| 변수/함수 | camelCase | `getUserData()` |
| 클래스 | PascalCase | `UserService` |
| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
| 파일명 | kebab-case | `user-service.js` |
| CSS 클래스 | kebab-case | `.nav-header` |
## 코드 스타일
- 들여쓰기: (2 spaces / 4 spaces / tab)
- 세미콜론: (사용 / 미사용)
- 따옴표: (single / double)
- 줄바꿈: LF (Unix style)
## 커밋 메시지
```
<type>(<scope>): <description>
type: feat|fix|refactor|test|docs|chore|ci|infra
scope: (선택)
```
**예시:**
- `feat(server): add WebSocket reconnection logic`
- `fix(frontend): resolve button overlap on mobile`
- `docs: update API documentation`
## 주석
- 한국어/영어 혼용 가능
- TODO 주석: `// TODO: 설명` 형식
- 복잡한 로직에는 반드시 WHY(왜) 주석 추가
## 테스트
- 테스트 파일 위치: (예: `__tests__/` 또는 `*.test.js`)
- 테스트 네이밍: `should [expected behavior] when [condition]`

View File

@@ -0,0 +1,43 @@
# Known Issues & Lessons Learned
> **이 파일은 SSOT(Single Source of Truth)입니다.**
> 디버깅이나 구현 전에 **반드시** 이 파일을 확인하세요.
> 세션 종료 시 새로 발견된 이슈를 이 파일에 추가합니다.
---
## 포맷
각 항목은 아래 형식을 따릅니다:
```markdown
### [날짜] [키워드] — 한줄 요약
- **증상**: 무엇이 잘못되었는가
- **원인**: 근본 원인
- **해결**: 올바른 해결 방법
- **주의**: 재발 방지를 위한 교훈
```
---
## 공통 이슈
### [2026-03-08] PowerShell curl — Invoke-WebRequest 충돌
- **증상**: `curl` 명령이 예상과 다른 응답 형식을 반환
- **원인**: PowerShell에서 `curl``Invoke-WebRequest`의 별칭
- **해결**: **`curl.exe`**를 명시적으로 사용
- **주의**: HTTP 관련 모든 명령에서 `curl.exe` 사용 필수
### [2026-03-08] PowerShell npm — 실행 정책 오류
- **증상**: `npm run` 명령이 `실행 정책` 관련 오류로 실패
- **원인**: PowerShell 스크립트 실행 정책이 제한적으로 설정됨
- **해결**: `cmd /c npm run dev` 형식으로 cmd를 통해 실행
- **주의**: npm 관련 명령은 항상 `cmd /c` 접두어 사용 권장
---
## 프로젝트별 이슈
> 아래에 프로젝트 특화 이슈를 추가하세요.
(아직 기록된 프로젝트별 이슈가 없습니다)

View File

@@ -0,0 +1,37 @@
# Tech Stack
> AI 에이전트는 구현 전 이 문서를 확인하여 올바른 기술/버전을 사용합니다.
## 언어 & 런타임
| 항목 | 버전 | 비고 |
|------|------|------|
| (예: Node.js) | (예: 20.x) | (설치 경로 등) |
| (예: Python) | (예: 3.12) | (가상환경 경로 등) |
## 프레임워크
| 항목 | 버전 | 용도 |
|------|------|------|
| (예: Express) | (예: 4.18) | (서버) |
| (예: React) | (예: 18.x) | (프론트엔드) |
## 패키지 관리
- 패키지 매니저: (npm / yarn / pnpm / pip 등)
- Lock 파일: (package-lock.json / yarn.lock 등)
## 개발 도구
| 도구 | 명령어 |
|------|--------|
| 개발 서버 | (예: `cmd /c npm run dev`) |
| 빌드 | (예: `cmd /c npm run build`) |
| 테스트 | (예: `cmd /c npm test`) |
| 린트 | (예: `cmd /c npm run lint`) |
## 환경 변수
| 변수명 | 용도 | 기본값 |
|--------|------|--------|
| (예: PORT) | (서버 포트) | (3000) |

View File

@@ -0,0 +1,40 @@
---
description: Gitea API로 저장소 커밋/이슈/PR 현황을 조회하는 워크플로우
---
# Gitea 저장소 현황 조회
서비스 정보는 `.agents/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. Wiki 페이지 목록:
```powershell
python .agents\workflows\helpers\wiki_helper.py list
```
4. Wiki 페이지 읽기:
```powershell
python .agents\workflows\helpers\wiki_helper.py read "Architecture"
```
5. Wiki 페이지 업데이트:
```powershell
python .agents\workflows\helpers\wiki_helper.py update "페이지-제목" /tmp/wiki_content.md
```

View File

@@ -0,0 +1,41 @@
---
description: Vikunja API로 프로젝트 태스크 현황을 조회하는 워크플로우
---
# Vikunja 태스크 현황 조회
서비스 정보는 `.agents/workflows/services.md` 참조.
// turbo-all
## 절차
1. 전체 목록:
```powershell
python .agents\workflows\helpers\vikunja_helper.py list
```
2. TODO만:
```powershell
python .agents\workflows\helpers\vikunja_helper.py list todo
```
3. DONE만:
```powershell
python .agents\workflows\helpers\vikunja_helper.py list done
```
4. 태스크 완료 처리 (**⚠️ 반드시 이 방법 사용 — 직접 API 호출 금지**):
```powershell
python .agents\workflows\helpers\vikunja_helper.py done {TASK_ID}
```
5. 새 태스크 생성:
```powershell
python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High
```
> [!CAUTION]
> **절대로** `Invoke-RestMethod -Method Post -Body '{"done": true}'` 같은 직접 API 호출을 사용하지 마세요.
> Vikunja API는 POST 시 body에 포함되지 않은 필드를 빈값으로 덮어씁니다.
> `vikunja_helper.py`는 항상 GET → 기존 필드 보존 → POST 패턴을 사용합니다.

View File

@@ -0,0 +1,52 @@
---
description: 에러/버그 발생 시 체계적 디버깅 워크플로우 (에러, 안돼요, 왜 안돼, 버그, 디버그, 수정)
---
# Debug Workflow
> [!IMPORTANT]
> 추측으로 코드를 수정하지 마세요. 반드시 이 순서를 따릅니다.
## 1단계: 정보 수집 (추측 금지)
- [ ] 에러 메시지 **전문** 확인 (절대 잘라내지 않기)
- [ ] 관련 로그 파일 확인
- [ ] 환경 정보 확인 (OS, Node/Python 버전, 의존성 버전 등)
- [ ] 에러가 발생하는 **정확한 입력/조건** 파악
## 2단계: Known Issues 확인
`.agents/references/known-issues.md`를 읽고 동일하거나 유사한 문제가 있는지 확인합니다.
> [!CAUTION]
> **known-issues 확인 없이 해결 시도를 시작하지 마세요.**
> 이미 해결된 문제를 다시 삽질하는 것은 시간 낭비입니다.
## 3단계: 근본 원인 분석
- [ ] 에러가 발생하는 **정확한 코드 위치** 확인
- [ ] 가설을 세우고, 가설을 검증할 수 있는 **최소한의 테스트** 수행
- [ ] 가설이 틀렸다면 **즉시 다른 가설로 전환**
> [!WARNING]
> **동일한 접근을 2회 초과 시도하지 마세요.**
> 2회 실패 시 유저에게 보고하고 판단을 요청합니다.
> 보고 내용: 시도한 것 / 실패한 것 / 원인 가설 / 다음 제안
## 4단계: 수정 및 검증
- [ ] 수정 적용
- [ ] 동일 에러가 재현되지 않는지 확인
- [ ] 사이드 이펙트(다른 기능에 영향) 없는지 확인
## 5단계: 기록
- [ ] `known-issues.md`에 새 항목 추가 (아래 포맷 사용)
```markdown
### [날짜] [키워드] — 한줄 요약
- **증상**: 무엇이 잘못되었는가
- **원인**: 근본 원인
- **해결**: 올바른 해결 방법
- **주의**: 재발 방지를 위한 교훈
```

165
.agents/workflows/end.md Normal file
View File

@@ -0,0 +1,165 @@
---
description: 세션 종료 시 devlog 기록 + git commit + Vikunja 동기화 (끝, 마무리, 커밋해, 완료)
---
# 세션 종료 프로토콜
작업 완료, "끝", "마무리", "커밋해" 등 요청 시 이 워크플로우를 실행합니다.
// turbo-all
## 0. 학습 기록 (실패/시행착오 저장)
이번 세션에서 발생한 실패, 시행착오, 새로 알게 된 사실을 정리합니다:
- [ ] `.agents/references/known-issues.md`에 추가할 항목이 있는지 확인
- [ ] 있다면 아래 포맷으로 추가:
```markdown
### [날짜] [키워드] — 한줄 요약
- **증상**: ...
- **원인**: ...
- **해결**: ...
- **주의**: ...
```
## 1. Devlog 기록
### Index 업데이트 (필수 — 매 작업)
오늘 날짜의 index 파일에 완료된 작업 1줄을 추가합니다.
- **파일**: `docs/devlog/YYYY-MM-DD.md`
- **형식**:
```markdown
| NNN | HH:MM | 작업 설명 | `커밋해시` | ✅ 또는 🔧 |
```
> [!TIP]
> - ✅ = 완료, 🔧 = 미완료 (다음 세션에서 이어받기)
> - 파일이 없으면 새로 생성 (테이블 헤더 포함)
### Entry 작성 (선택적 — 필요할 때만)
> [!IMPORTANT]
> Entry는 **git/Vikunja/wiki에 없는 정보**가 있을 때만 작성합니다.
**Entry 작성 기준:**
- ✅ 설계 결정이 있었을 때 (왜 A가 아닌 B를 선택했는지)
- ✅ 미완료 사항이 있을 때 (다음 세션이 이어받아야 할 맥락)
- ✅ 삽질/트러블슈팅이 있었을 때 (같은 실수 방지)
**Entry 불필요:**
- ❌ 단순 버그 픽스 (커밋 메시지로 충분)
- ❌ 문서 업데이트 (git diff로 충분)
- ❌ 이미 Vikunja 태스크에 상세 설명이 있는 경우
**Entry 파일**: `docs/devlog/entries/YYYYMMDD-NNN.md`
```markdown
# 작업 제목
- **시간**: YYYY-MM-DD HH:MM~HH:MM
- **Commit**: `해시`
- **Vikunja**: #태스크번호 → done/진행중
## 결정 사항
- 왜 이 방식을 선택했는지
## 미완료
- 남은 작업 (있을 경우)
```
---
## 2. Vikunja 동기화
> [!CAUTION]
> **반드시 `vikunja_helper.py` 사용.** 직접 API 호출 금지.
> Vikunja API는 POST 시 body에 없는 필드를 빈값으로 덮어씁니다.
### 2-1. 커밋 전수 검사
이번 세션의 **모든 커밋을 하나씩 검사**하고 Vikunja에 매핑합니다.
```powershell
git log --oneline -20
```
| 커밋 유형 | Vikunja 액션 |
|-----------|-------------|
| 기존 태스크 해당 작업 **완료** | `python .agents\workflows\helpers\vikunja_helper.py done {ID}` |
| 신규 작업 완료 (기존 태스크 없음) | `python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --done --labels Backend,Priority:High` |
| 작업 중 발견된 **미완료 TODO** | `python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:Mid` |
> [!IMPORTANT]
> 모든 커밋이 기존 또는 신규 태스크에 매핑되었는지 확인.
### 2-2. 완료 처리
```powershell
python .agents\workflows\helpers\vikunja_helper.py done {TASK_ID}
```
### 2-3. 신규 태스크 생성
```powershell
python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High
```
### 라벨 규칙
**영역 (필수 1개 이상):** `Backend` / `Frontend` / `Engine` / `Infra` / `Test`
**우선순위 (필수 1개):** `Priority:High` / `Priority:Mid` / `Priority:Low`
---
## 3. Wiki 동기화 (해당 시에만)
| 코드 변경 | 대상 Wiki |
|-----------|----------|
| 서버 변경 | Architecture |
| 프론트엔드 변경 | Architecture |
| 인프라 변경 | Architecture |
| 새 모듈/패키지 추가 | Architecture |
```powershell
python .agents\workflows\helpers\wiki_helper.py update "Architecture" /tmp/wiki_content.md
```
---
## 4. Git Commit & Push
```powershell
git add -A
git status --short
```
```powershell
git commit -m "커밋 메시지"
```
```powershell
git push origin main
```
**커밋 메시지 컨벤션:**
```
<type>(<scope>): <description>
type: feat|fix|refactor|test|docs|chore|ci|infra
scope: (선택)
```
---
## 5. 최종 체크리스트
> [!WARNING]
> 아래 항목 중 하나라도 누락되면 세션 종료를 완료할 수 없습니다.
- [ ] known-issues 업데이트됨 (새 이슈가 있었다면)
- [ ] devlog index 업데이트됨
- [ ] devlog entry 작성됨 (필요한 경우만)
- [ ] Vikunja 태스크 생성/완료 처리됨 (커밋 전수 검사 기반)
- [ ] Wiki 동기화됨 (아키텍처 변경이 있었다면)
- [ ] git push 완료
- [ ] 사용자에게 완료 보고

View File

@@ -0,0 +1,217 @@
"""Vikunja safe task updater — preserves existing fields when updating tasks.
Usage:
python vikunja_helper.py done 75 # Mark task #75 as done
python vikunja_helper.py done 71 77 78 # Mark multiple tasks done
python vikunja_helper.py undone 75 # Mark task #75 as not done
python vikunja_helper.py comment 75 "text" # Add comment to task #75
python vikunja_helper.py desc 75 "text" # Set description (appends if exists)
python vikunja_helper.py create "title" "desc" --labels Backend,Priority:High
python vikunja_helper.py create "title" "desc" --done --labels Frontend,Priority:Mid
python vikunja_helper.py label 75 Backend Priority:High # Add labels to task
python vikunja_helper.py list # List all tasks
python vikunja_helper.py list todo # List TODO only
python vikunja_helper.py list done # List DONE only
"""
import sys
import json
import urllib.request
import urllib.error
import io
# Fix Windows console encoding (cp949 → utf-8)
if sys.stdout.encoding != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
# ============================================================
# ⚙️ CONFIGURATION — PROJECT_ID만 프로젝트별로 변경하세요
# ============================================================
API_BASE = "https://plan.variet.net/api/v1"
TOKEN = "tk_070f8e0b715e818bb7178c3815ed5389040eddca"
PROJECT_ID = 0 # ← 프로젝트별 변경 필요 (e.g. 9)
# ============================================================
HEADERS = {
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
}
# Label name → Vikunja label ID mapping
# Customize for your project's labels
LABEL_MAP = {
"Backend": 1, "Frontend": 2, "Engine": 3, "Infra": 4, "Test": 5,
"Priority:High": 6, "Priority:Mid": 7, "Priority:Low": 8,
"Agent": 17, "Tool": 18, "AI/LLM": 19,
}
def api_get(path: str):
req = urllib.request.Request(f"{API_BASE}{path}", headers=HEADERS)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
def api_post(path: str, data: dict):
body = json.dumps(data).encode("utf-8")
req = urllib.request.Request(f"{API_BASE}{path}", data=body, headers=HEADERS, method="POST")
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
def api_put(path: str, data: dict):
body = json.dumps(data).encode("utf-8")
req = urllib.request.Request(f"{API_BASE}{path}", data=body, headers=HEADERS, method="PUT")
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
def get_task(task_id: int) -> dict:
return api_get(f"/tasks/{task_id}")
def safe_update_task(task_id: int, updates: dict) -> dict:
task = get_task(task_id)
safe_body = {
"title": task.get("title", ""),
"description": task.get("description", ""),
"priority": task.get("priority", 0),
"done": task.get("done", False),
}
safe_body.update(updates)
return api_post(f"/tasks/{task_id}", safe_body)
def mark_done(task_ids: list):
for tid in task_ids:
result = safe_update_task(tid, {"done": True})
title = result.get("title", "?")
print(f" ✅ #{tid} → done=True [{title}]")
def mark_undone(task_ids: list):
for tid in task_ids:
result = safe_update_task(tid, {"done": False})
title = result.get("title", "?")
print(f" ⬜ #{tid} → done=False [{title}]")
def add_comment(task_id: int, comment: str):
result = api_put(f"/tasks/{task_id}/comments", {"comment": comment})
print(f" 💬 #{task_id} comment added (id={result.get('id', '?')})")
def set_description(task_id: int, desc: str, append: bool = True):
task = get_task(task_id)
existing = task.get("description", "") or ""
if append and existing:
new_desc = existing.rstrip() + "\n\n" + desc
else:
new_desc = desc
result = safe_update_task(task_id, {"description": new_desc})
print(f" 📝 #{task_id} description updated [{result.get('title', '?')}]")
def list_tasks(filter_: str = "all"):
all_tasks = []
page = 1
while True:
batch = api_get(f"/projects/{PROJECT_ID}/tasks?per_page=50&page={page}")
if not batch:
break
all_tasks.extend(batch)
if len(batch) < 50:
break
page += 1
if filter_ == "todo":
all_tasks = [t for t in all_tasks if not t["done"]]
elif filter_ == "done":
all_tasks = [t for t in all_tasks if t["done"]]
all_tasks.sort(key=lambda t: t["id"])
for t in all_tasks:
status = "" if t["done"] else ""
desc = (t.get("description") or "")[:50].replace("\n", " ")
labels = ", ".join(l["title"] for l in (t.get("labels") or []))
print(f" {status} #{t['id']:3d} {t['title'][:40]:<40} [{labels}] {desc}")
print(f"\n Total: {len(all_tasks)} tasks")
def add_labels(task_id: int, label_names: list):
for name in label_names:
label_id = LABEL_MAP.get(name)
if not label_id:
print(f" ⚠️ Unknown label '{name}'. Valid: {', '.join(LABEL_MAP.keys())}")
continue
try:
api_put(f"/tasks/{task_id}/labels", {"label_id": label_id})
print(f" 🏷️ #{task_id} + {name} (id={label_id})")
except Exception as e:
if "already" in str(e).lower() or "409" in str(e):
print(f" 🏷️ #{task_id} already has {name}")
else:
print(f" ⚠️ #{task_id} label {name} failed: {e}")
def create_task(title: str, description: str = "", done: bool = False, labels: list = None):
payload = {"title": title, "description": description}
result = api_put(f"/projects/{PROJECT_ID}/tasks", payload)
task_id = result["id"]
print(f" ✨ #{task_id} created: {result.get('title', '?')}")
if labels:
add_labels(task_id, labels)
if done:
result = safe_update_task(task_id, {"done": True})
print(f" ✅ #{task_id} → done=True")
return result
def main():
if len(sys.argv) < 2:
print(__doc__)
return
cmd = sys.argv[1].lower()
if cmd == "done":
ids = [int(x) for x in sys.argv[2:]]
mark_done(ids)
elif cmd == "undone":
ids = [int(x) for x in sys.argv[2:]]
mark_undone(ids)
elif cmd == "comment":
add_comment(int(sys.argv[2]), sys.argv[3])
elif cmd == "desc":
set_description(int(sys.argv[2]), sys.argv[3])
elif cmd == "list":
f = sys.argv[2] if len(sys.argv) > 2 else "all"
list_tasks(f)
elif cmd == "label":
if len(sys.argv) < 4:
print("Usage: vikunja_helper.py label TASK_ID Label1 Label2 ...")
return
add_labels(int(sys.argv[2]), sys.argv[3:])
elif cmd == "create":
title = sys.argv[2] if len(sys.argv) > 2 else ""
desc = sys.argv[3] if len(sys.argv) > 3 and not sys.argv[3].startswith("--") else ""
is_done = "--done" in sys.argv
labels = None
for i, arg in enumerate(sys.argv):
if arg == "--labels" and i + 1 < len(sys.argv):
labels = sys.argv[i + 1].split(",")
break
if not title:
print("Error: title is required")
return
create_task(title, desc, done=is_done, labels=labels)
else:
print(f"Unknown command: {cmd}")
print(__doc__)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,100 @@
"""Gitea Wiki helper: list, read, create, update wiki pages.
Usage:
wiki_helper.py list — list all pages
wiki_helper.py read <title> — read a page
wiki_helper.py create <title> <file> — create a page from file
wiki_helper.py update <title> <file> — update a page from file
"""
import sys, io, json, base64, urllib.request, urllib.error
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
# ============================================================
# ⚙️ CONFIGURATION — GITEA_REPO만 프로젝트별로 변경하세요
# ============================================================
GITEA_BASE_URL = "https://git.variet.net"
GITEA_OWNER = "Variet"
GITEA_REPO = "variet-agent" # ← 프로젝트별 변경 필요
GITEA_TOKEN = "3a01b4b15a39921572e64c413353e870d4d2161b"
# ============================================================
BASE = f"{GITEA_BASE_URL}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/wiki"
HEADERS = {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json"}
def _req(method, path, data=None):
url = f"{BASE}{path}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=HEADERS, method=method)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
err = e.read().decode()
print(f" ⚠️ HTTP {e.code}: {err}")
return None
def _find_sub_url(title):
pages = _req("GET", "/pages")
if pages:
for p in pages:
if p.get("title", "").lower() == title.lower():
return p.get("sub_url", title)
return title
def list_pages():
pages = _req("GET", "/pages")
if pages:
print(f"=== {len(pages)} Wiki Pages ===")
for p in pages:
print(f" {p.get('title', '?')}")
return pages
def read_page(title):
sub = _find_sub_url(title)
page = _req("GET", f"/page/{sub}")
if page and page.get("content_base64"):
content = base64.b64decode(page["content_base64"]).decode("utf-8")
return content
return None
def create_page(title, content):
data = {
"title": title,
"content_base64": base64.b64encode(content.encode()).decode(),
}
result = _req("POST", "/new", data)
if result:
print(f" ✅ Created wiki page: {title}")
return result
def update_page(title, content):
sub = _find_sub_url(title)
data = {
"title": title,
"content_base64": base64.b64encode(content.encode()).decode(),
}
result = _req("PATCH", f"/page/{sub}", data)
if result:
print(f" ✅ Updated wiki page: {title}")
return result
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "list"
if cmd == "list":
list_pages()
elif cmd == "read" and len(sys.argv) > 2:
content = read_page(sys.argv[2])
if content:
print(content[:5000])
else:
print(f" Page '{sys.argv[2]}' not found")
elif cmd == "create" and len(sys.argv) > 3:
with open(sys.argv[3], "r", encoding="utf-8") as f:
create_page(sys.argv[2], f.read())
elif cmd == "update" and len(sys.argv) > 3:
with open(sys.argv[3], "r", encoding="utf-8") as f:
update_page(sys.argv[2], f.read())
else:
print("Usage: wiki_helper.py list|read <title>|create <title> <file>|update <title> <file>")

View File

@@ -0,0 +1,39 @@
---
description: 모든 구현 작업 전 실행하는 사전 점검 체크리스트 (pre-task, 준비, 시작 전, 계획, 구현)
---
# Pre-Task Checklist
> [!IMPORTANT]
> 코딩을 시작하기 전에 반드시 이 체크리스트를 순서대로 완료하세요.
> 체크리스트를 건너뛸 경우 불필요한 시행착오가 발생합니다.
## 1단계: 요구사항 정리
- [ ] 유저 요청을 구체적 작업 항목으로 분해
- [ ] 변경 범위(scope)를 명확히 정의 (영향받는 파일/모듈)
- [ ] 성공 기준(acceptance criteria) 확인
## 2단계: 레퍼런스 확인 (추측 금지)
- [ ] `.agents/references/architecture.md` — 현재 아키텍처 확인
- [ ] `.agents/references/tech-stack.md` — 기술 스택 및 버전 확인
- [ ] `.agents/references/conventions.md` — 코딩 컨벤션 확인
- [ ] `.agents/references/known-issues.md` — 과거 실패 패턴 확인
- [ ] 관련 기존 코드 최소 3개 파일 읽기
> [!CAUTION]
> 레퍼런스 문서가 존재하는 주제에 대해 추측하지 마세요.
> 문서가 없으면 유저에게 확인을 요청하세요.
## 3단계: 계획 수립
- [ ] 변경할 파일 목록 작성
- [ ] 의존성 순서 파악 (어떤 파일부터 수정해야 하는가?)
- [ ] 리스크 식별 (어디서 실패할 가능성이 높은가?)
- [ ] 테스트 방법 결정 (어떻게 검증할 것인가?)
## 4단계: 유저 확인
- [ ] 계획을 유저에게 보고하고 승인받기 (변경 파일 3개 이상인 경우)
- [ ] 작은 변경은 바로 실행하되, 변경 내용을 명확히 설명

View File

@@ -0,0 +1,65 @@
---
description: 세션 시작 시 프로젝트 맥락을 빠르게 복구하는 워크플로우 (시작, continue, 이어서, 작업 시작)
---
# 세션 시작 프로토콜
새 대화 시작, "continue", "이어서", "작업 시작" 등 요청 시 이 워크플로우를 실행합니다.
// turbo-all
## 절차
### 0. 에이전트 룰 & 맥락 로딩 (자동)
`.agents/AGENT.md`를 읽고 에이전트 행동 규칙을 로딩합니다.
`.agents/references/known-issues.md`를 읽어 최근 이슈를 파악합니다.
### 1. Devlog 맥락 복구
오늘 + 어제 devlog index를 읽고 최근 작업 흐름을 파악합니다.
```powershell
$today = Get-Date -Format "yyyy-MM-dd"
$yesterday = (Get-Date).AddDays(-1).ToString("yyyy-MM-dd")
if (Test-Path "docs\devlog\$today.md") {
Write-Host "=== Devlog: $today ==="
Get-Content "docs\devlog\$today.md"
} elseif (Test-Path "docs\devlog\$yesterday.md") {
Write-Host "=== Devlog: $yesterday (no entry for today yet) ==="
Get-Content "docs\devlog\$yesterday.md"
} else {
Write-Host "=== No recent devlog found ==="
}
```
미완료(🔧) 항목이 있으면 해당 entry 파일을 읽어 이어받기 맥락을 확보합니다:
- Entry 경로: `docs/devlog/entries/YYYYMMDD-NNN.md`
### 2. Git 상태 확인
```powershell
git status --short
```
```powershell
git log --oneline -5
```
### 3. Vikunja TODO 태스크
```powershell
python .agents\workflows\helpers\vikunja_helper.py list todo
```
### 4. 종합 보고
결과를 종합하여 사용자에게 보고:
- 마지막 작업 맥락 + 미완료 항목 (devlog 🔧 기반)
- TODO 태스크 목록 (라벨 + 우선순위)
- 다음 작업 제안
**우선순위 판단 기준** (라벨만으로 판단 금지):
- P0: 최근 커밋에서 스키마/모델/인터페이스 변경 → 연쇄 영향 점검
- P1: 서버 기동/API 응답 장애
- P2: 기능 미완성/UX 개선
- P3: 정확도 향상, 신규 기능, CI/CD, 문서 정리

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# .agent/workflows/services.md 는 토큰 포함되어 있으므로 제외 # 토큰 포함 파일 제외
.agent/workflows/services.md .agent/workflows/services.md
.agents/workflows/services.md
sessions/ sessions/
__pycache__/ __pycache__/
*.pyc *.pyc

View File

@@ -2,12 +2,15 @@
슬래시 커맨드로 워크스페이스 관리. 슬래시 커맨드로 워크스페이스 관리.
등록된 채널에서 자동 대화 + 통합 프롬프트 (1회 호출로 분류+응답). 등록된 채널에서 자동 대화 + 통합 프롬프트 (1회 호출로 분류+응답).
/task 커맨드로 프로젝트 선택 → 스레드 자동 생성 → 스레드 내 작업.
""" """
import asyncio import asyncio
import json import json
import logging import logging
import re import re
from datetime import datetime
from pathlib import Path
import discord import discord
from discord import app_commands from discord import app_commands
@@ -58,6 +61,10 @@ ws_manager = WorkspaceManager()
# 실행 중인 작업 추적 (채널ID → asyncio.Task) # 실행 중인 작업 추적 (채널ID → asyncio.Task)
_running_tasks: dict[int, asyncio.Task] = {} _running_tasks: dict[int, asyncio.Task] = {}
# 스레드 ↔ 프로젝트 매핑
_project_threads: dict[str, int] = {} # 프로젝트명 → 활성 스레드 ID
_thread_workspaces: dict[int, "Workspace"] = {} # 스레드 ID → Workspace
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# 대화 기억 # 대화 기억
@@ -99,6 +106,23 @@ async def _get_channel_history(channel: discord.TextChannel, limit: int = 10) ->
return "=== CONVERSATION HISTORY ===\n" + "\n".join(messages) + "\n\n" return "=== CONVERSATION HISTORY ===\n" + "\n".join(messages) + "\n\n"
async def safe_send_embed(channel, embed: discord.Embed):
"""Embed 전송 (description 길이 초과 시 자동 분할)."""
desc = embed.description or ""
if len(desc) <= 4096:
await channel.send(embed=embed)
else:
# 분할 전송
for i in range(0, len(desc), 4000):
chunk_embed = discord.Embed(
title=embed.title if i == 0 else None,
description=desc[i:i+4000],
color=embed.color,
)
await channel.send(embed=chunk_embed)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# 통합 프롬프트 (1회 호출: 분류 + 응답/계획) # 통합 프롬프트 (1회 호출: 분류 + 응답/계획)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@@ -160,10 +184,16 @@ async def on_ready():
# 슬래시 커맨드 동기화 (길드별 = 즉시 반영) # 슬래시 커맨드 동기화 (길드별 = 즉시 반영)
try: try:
# 1) 글로벌 커맨드를 각 길드로 복사 + 동기화
for guild in bot.guilds: for guild in bot.guilds:
bot.tree.copy_global_to(guild=guild) bot.tree.copy_global_to(guild=guild)
synced = await bot.tree.sync(guild=guild) synced = await bot.tree.sync(guild=guild)
logger.info(f"슬래시 커맨드 {len(synced)}개 동기화 완료 (서버: {guild.name})") logger.info(f"슬래시 커맨드 {len(synced)}개 동기화 완료 (서버: {guild.name})")
# 2) 글로벌 커맨드 제거 (길드 커맨드와 중복 방지)
bot.tree.clear_commands(guild=None)
await bot.tree.sync()
logger.info("글로벌 슬래시 커맨드 정리 완료 (길드 전용)")
except Exception as e: except Exception as e:
logger.error(f"슬래시 커맨드 동기화 실패: {e}") logger.error(f"슬래시 커맨드 동기화 실패: {e}")
@@ -236,11 +266,15 @@ async def on_message(message: discord.Message):
await bot.process_commands(message) await bot.process_commands(message)
return return
# 워크스페이스 채널인지 확인 # 워크스페이스 채널 또는 스레드 확인
if not ws_manager.is_workspace_channel(message.channel.id):
return
ws = ws_manager.get_workspace(message.channel.id) ws = ws_manager.get_workspace(message.channel.id)
if not ws and message.channel.id in _thread_workspaces:
ws = _thread_workspaces[message.channel.id]
# 스레드의 부모 채널이 워크스페이스인 경우
if not ws and isinstance(message.channel, discord.Thread):
ws = ws_manager.get_workspace(message.channel.parent_id)
if not ws:
return
user_text = message.content.strip() user_text = message.content.strip()
if not user_text: if not user_text:
return return
@@ -279,7 +313,11 @@ async def on_message(message: discord.Message):
mode = result.get("mode", "chat") mode = result.get("mode", "chat")
logger.info(f"통합 분류: {mode} - \"{user_text[:50]}\"") logger.info(f"통합 분류: {mode} - \"{user_text[:50]}\"")
if mode == "task": if mode == "anime":
# 애니메이션 도구 자동 호출
async with message.channel.typing():
await _handle_anime(message, result)
elif mode == "task":
# Git/Vikunja 미설정 안내 (차단하지 않음) # Git/Vikunja 미설정 안내 (차단하지 않음)
if not ws.is_ready: if not ws.is_ready:
missing = ws.missing_configs missing = ws.missing_configs
@@ -330,6 +368,284 @@ async def on_message(message: discord.Message):
await message.channel.send(embed=embed) await message.channel.send(embed=embed)
# ──────────────────────────────────────────────
# Anime 핸들러 (AI가 분류한 의도 실행)
# ──────────────────────────────────────────────
async def _handle_anime(message: discord.Message, parsed: dict):
"""AI가 분류한 anime 의도를 실행."""
from tools.anime_pipeline import AnimePipeline
action = parsed.get("action", "search")
title = parsed.get("title", "")
episode = parsed.get("episode")
filter_str = parsed.get("filter", "")
summary = parsed.get("summary", "")
pipeline = AnimePipeline()
try:
if action == "status":
await _anime_status(message, pipeline)
elif action == "schedule":
await _anime_schedule(message, pipeline, filter_str)
elif action == "list":
await _anime_list(message, pipeline, filter_str)
elif action in ("download", "sub_only", "video_only"):
# 필터에 batch 조건이 있으면 복수 다운로드
if not title and filter_str:
await _anime_batch(message, pipeline, action, filter_str)
else:
await _anime_download(message, pipeline, title, action, episode)
else: # search (기본)
if not title:
await message.reply("🔍 어떤 애니를 검색할까요? 제목을 알려주세요.")
return
await _anime_search(message, pipeline, title)
except Exception as e:
logger.error(f"Anime 핸들러 오류: {e}", exc_info=True)
await message.reply(f"❌ 오류가 발생했습니다: {str(e)[:300]}")
async def _anime_search(message, pipeline, title):
"""검색 결과 표시."""
result = await pipeline.search(title)
if not result.anime:
await message.reply(f"'{title}' 검색 결과가 없습니다.")
return
anime = result.anime
embed = discord.Embed(
title=f"🔍 {anime.subject}",
description=f"**원제**: {anime.original_subject}\n**장르**: {anime.genres}",
color=0x3498DB,
)
week_names = ['','','','','','','','기타']
embed.add_field(name="📅 편성", value=f"{week_names[anime.week]}요일 {anime.time}", inline=True)
embed.add_field(name="📁 NAS 폴더", value=f"`{result.nas_folder}`", inline=True)
if result.captions:
cap_lines = []
for c in result.captions[:5]:
url_text = f"[사이트]({c.website})" if c.website else "URL 없음"
cap_lines.append(f"• **{c.name}** — {c.episode}화 ({url_text})")
embed.add_field(name=f"📝 자막 ({len(result.captions)}명)", value="\n".join(cap_lines), inline=False)
if result.torrents:
tor_lines = []
for t in result.torrents[:5]:
ep = f"**{t.episode}화**" if t.episode else ""
tor_lines.append(f"• [{t.group}] {ep} {t.size} (🌱{t.seeders})")
embed.add_field(name=f"🎬 토렌트 ({len(result.torrents)}건)", value="\n".join(tor_lines), inline=False)
await safe_send_embed(message.channel, embed)
async def _anime_download(message, pipeline, title, mode, episode):
"""단일 애니 다운로드."""
if not title:
await message.reply("📥 어떤 애니를 다운받을까요? 제목을 알려주세요.")
return
embed = discord.Embed(title="⏳ 처리 중...", description=f"**{title}** 검색 및 다운로드", color=0xF39C12)
status_msg = await message.channel.send(embed=embed)
result = await pipeline.download(title, mode=mode, episode=episode)
if result.torrent_added or result.subtitles:
embed.title = "✅ 완료"
embed.color = 0x2ECC71
else:
embed.title = "⚠️ 부분 완료"
embed.color = 0xF39C12
embed.description = result.message[:4000]
await status_msg.edit(embed=embed)
async def _anime_batch(message, pipeline, action, filter_str):
"""필터 기반 복수 애니 다운로드 (이번분기 자막있는것 등)."""
embed = discord.Embed(title="⏳ 편성표 분석 중...", description="조건에 맞는 애니 검색", color=0xF39C12)
status_msg = await message.channel.send(embed=embed)
# 전체 편성표 로드
all_anime = await pipeline.anissia.get_all_schedule()
# 필터 적용
filtered = all_anime
if "sub:yes" in filter_str or "자막" in filter_str:
filtered = [a for a in filtered if a.caption_count > 0]
if "quarter:current" in filter_str or "이번" in filter_str:
from datetime import date
today = date.today()
current_q = (today.month - 1) // 3 + 1
current_year = today.year
def _in_current_quarter(a):
if not a.start_date:
return False
parts = a.start_date.split("-")
y, m = int(parts[0]), int(parts[1])
q = (m - 1) // 3 + 1
return y == current_year and q == current_q
filtered = [a for a in filtered if _in_current_quarter(a)]
if "status:on" in filter_str:
filtered = [a for a in filtered if a.status == "ON"]
else:
# 기본: ON 상태만
filtered = [a for a in filtered if a.status == "ON"]
embed.title = f"📋 조건 매칭: {len(filtered)}"
embed.description = "\n".join(f"{a.subject} (자막 {a.caption_count}명)" for a in filtered[:15])
if len(filtered) > 15:
embed.description += f"\n... 외 {len(filtered)-15}"
embed.color = 0x3498DB
await status_msg.edit(embed=embed)
if not filtered:
return
# 다운로드 실행
success_count = 0
fail_count = 0
for anime in filtered:
try:
result = await pipeline.download(anime.subject, mode=action)
if result.torrent_added or result.subtitles:
success_count += 1
else:
fail_count += 1
except Exception as e:
logger.error(f"배치 다운로드 오류 ({anime.subject}): {e}")
fail_count += 1
result_embed = discord.Embed(
title=f"📊 배치 다운로드 결과",
description=f"✅ 성공: {success_count}\n⚠️ 실패/보류: {fail_count}",
color=0x2ECC71 if success_count > 0 else 0xF39C12,
)
await message.channel.send(embed=result_embed)
async def _anime_schedule(message, pipeline, filter_str):
"""편성표 조회."""
# 요일 파싱
week = None
week_map = {"": 0, "": 1, "": 2, "": 3, "": 4, "": 5, "": 6}
for name, num in week_map.items():
if name in filter_str:
week = num
break
if "week:" in filter_str:
m = re.search(r'week:(\d)', filter_str)
if m:
week = int(m.group(1))
if week is not None:
schedule = await pipeline.anissia.get_schedule(week)
week_names = ['','','','','','','','기타']
title = f"📅 {week_names[week]}요일 편성표"
else:
schedule = await pipeline.anissia.get_all_schedule()
schedule = [a for a in schedule if a.status == "ON"]
title = f"📅 이번 분기 방영 중인 애니"
# 자막 있는것 필터
if "sub:yes" in filter_str or "자막" in filter_str:
schedule = [a for a in schedule if a.caption_count > 0]
title += " (자막 있음)"
lines = []
for a in schedule[:25]:
sub_icon = "📝" if a.caption_count > 0 else " "
lines.append(f"{sub_icon} **{a.subject}** — {a.time} (자막 {a.caption_count}명)")
embed = discord.Embed(
title=title,
description="\n".join(lines) if lines else "결과 없음",
color=0x3498DB,
)
if len(schedule) > 25:
embed.set_footer(text=f"{len(schedule)}개 중 25개 표시")
await safe_send_embed(message.channel, embed)
async def _anime_list(message, pipeline, filter_str):
"""NAS에 다운로드된 애니 목록."""
if not pipeline.nas.is_accessible():
await message.reply(f"❌ NAS 경로 접근 불가: `{pipeline.nas.base_path}`")
return
# 분기 필터링
year, quarter = None, None
if "quarter:current" in filter_str or "이번" in filter_str:
from datetime import date
today = date.today()
year = today.year % 100
quarter = (today.month - 1) // 3 + 1
folders = pipeline.nas.list_anime_folders(year=year, quarter=quarter)
if not folders:
q_text = f" ({year}{quarter}분기)" if year else ""
await message.reply(f"📂 다운로드된 애니가 없습니다{q_text}.")
return
total_vids = sum(f.video_count for f in folders)
total_subs = sum(f.subtitle_count for f in folders)
total_size = sum(f.total_size_gb for f in folders)
lines = []
for f in folders:
sub_icon = "📝" if f.subtitle_count > 0 else ""
lines.append(
f"• **{f.title}** — 🎬{f.video_count}{sub_icon}{f.subtitle_count}자막 "
f"({f.total_size_gb:.1f}GB)"
)
q_text = f"{year}{quarter}분기" if year else "전체"
embed = discord.Embed(
title=f"📂 다운로드된 애니 ({q_text}: {len(folders)}개)",
description="\n".join(lines[:25]),
color=0x2ECC71,
)
embed.set_footer(
text=f"{total_vids}개 영상 | {total_subs}개 자막 | {total_size:.1f}GB"
)
if len(folders) > 25:
embed.description += f"\n... 외 {len(folders)-25}"
await safe_send_embed(message.channel, embed)
async def _anime_status(message, pipeline):
"""qBittorrent 상태 표시."""
conn = await pipeline.qbit.test_connection()
if not conn.get("connected"):
await message.reply(f"❌ qBittorrent 연결 실패: {conn.get('error', '?')}")
return
torrents = await pipeline.get_status()
embed = discord.Embed(
title=f"📊 다운로드 큐 ({len(torrents)}건)",
description=f"qBittorrent {conn.get('version', '?')}",
color=0x3498DB,
)
for t in torrents[:10]:
icon = "" if t["progress"] == "100.0%" else ""
embed.add_field(
name=f"{icon} {t['name'][:50]}",
value=f"진행: {t['progress']} | 속도: {t['speed']} | ETA: {t['eta']}",
inline=False,
)
if not torrents:
embed.add_field(name="상태", value="다운로드 중인 항목 없음", inline=False)
await safe_send_embed(message.channel, embed)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# 설정 경고 # 설정 경고
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@@ -445,13 +761,25 @@ async def _handle_task(message: discord.Message, text: str, ws):
) )
await message.channel.send(embed=plan_embed) await message.channel.send(embed=plan_embed)
else: else:
await message.channel.send( # 태스크가 없지만 summary가 있으면 결과로 표시 (분류 경계 케이스)
embed=discord.Embed( summary_text = plan.get("summary", "") or plan.get("result", "")
title="⚠️ 실행할 태스크 없음", if summary_text:
description="요청을 더 구체적으로 해주세요.", await message.channel.send(
color=0xF39C12, embed=discord.Embed(
title="📋 분석 결과",
description=summary_text[:4000],
color=0x3498DB,
)
)
pipeline.docs.record_session(text, {"summary": summary_text}, plan)
else:
await message.channel.send(
embed=discord.Embed(
title="⚠️ 실행할 태스크 없음",
description="요청을 더 구체적으로 해주세요.",
color=0xF39C12,
)
) )
)
return return
# 2. Planner 오케스트레이션 루프 (외부: 리뷰어, 내부: 자가검증) # 2. Planner 오케스트레이션 루프 (외부: 리뷰어, 내부: 자가검증)
@@ -593,14 +921,29 @@ async def _handle_task(message: discord.Message, text: str, ws):
except GeminiCallError as e: except GeminiCallError as e:
await message.channel.send( await message.channel.send(
embed=discord.Embed(title="❌ AI 호출 오류", embed=discord.Embed(
description=f"```{str(e)[:500]}```", color=0xE74C3C) title="❌ AI 호출 오류",
description=(
f"```{str(e)[:300]}```\n\n"
f"💡 **대응 방법:**\n"
f"• 요청을 더 짧게/구체적으로 다시 시도\n"
f"• 복잡한 요청은 단계별로 나눠서 요청\n"
f"• 잠시 후 다시 시도"
),
color=0xE74C3C,
)
) )
except Exception as e: except Exception as e:
logger.error(f"작업 오류: {e}", exc_info=True) logger.error(f"작업 오류: {e}", exc_info=True)
await message.channel.send( await message.channel.send(
embed=discord.Embed(title="❌ 오류", embed=discord.Embed(
description=f"```{str(e)[:500]}```", color=0xE74C3C) title="❌ 예기치 않은 오류",
description=(
f"```{str(e)[:300]}```\n\n"
f"💡 다시 요청하시거나, 문제가 계속되면 관리자에게 문의하세요."
),
color=0xE74C3C,
)
) )
@@ -786,10 +1129,475 @@ async def workspace_list(interaction: discord.Interaction):
bot.tree.add_command(workspace_group) bot.tree.add_command(workspace_group)
# ──────────────────────────────────────────────
# /task 커맨드 — 프로젝트 선택 + 스레드 생성
# ──────────────────────────────────────────────
class ProjectSelectView(discord.ui.View):
"""프로젝트 드롭다운 + 스레드 생성."""
def __init__(self, request_text: str):
super().__init__(timeout=60)
self.request_text = request_text
# 워크스페이스 목록으로 Select 옵션 구성 (channel_id를 value로 사용 — 고유 식별)
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], # Discord 제한
)
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
# channel_id로 워크스페이스 직접 조회 (이름 충돌 방지)
ws = ws_manager.get_workspace(int(selected_value))
if not ws:
await interaction.response.send_message("❌ 워크스페이스를 찾을 수 없습니다.", ephemeral=True)
return
# 1) 활성 스레드가 이미 있는지 확인
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)
# 2) 기존 프로젝트 폴더가 있는지 확인 (충돌 체크)
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
# 3) 폴더 없거나 비어있음 → 바로 스레드 생성
await interaction.response.defer()
await _create_task_thread(interaction, ws, self.request_text)
class ConflictView(discord.ui.View):
"""기존 프로젝트 이어가기 / 새로 시작 선택."""
def __init__(self, ws, request_text: str, original_interaction: discord.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: discord.Interaction, button: discord.ui.Button):
"""기존 프로젝트 폴더로 새 스레드 생성."""
await interaction.response.defer()
await _create_task_thread(interaction, self.ws, self.request_text)
self.stop()
@discord.ui.button(label="🆕 새로 시작", style=discord.ButtonStyle.secondary)
async def new_project(self, interaction: discord.Interaction, button: discord.ui.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
# 아카이브된 프로젝트를 workspaces에 등록 (접근 유지)
archived_name = new_archived_path.name
archived_ws = ws_manager.set_workspace(
channel_id=-abs(hash(archived_name)) % (10**10),
name=archived_name,
path=str(new_archived_path),
)
logger.info(f"아카이브 워크스페이스 등록: {archived_name}")
# 새 폴더 생성
old_path.mkdir(parents=True, exist_ok=True)
await interaction.response.defer()
await _create_task_thread(interaction, self.ws, self.request_text)
self.stop()
async def _create_task_thread(
interaction: discord.Interaction,
ws,
request_text: str,
):
"""스레드를 생성하고 작업을 시작합니다."""
# 스레드 제목: 프로젝트명 + 요청 앞부분
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] # Discord 제한
# 스레드 생성
channel = interaction.channel
thread = await channel.create_thread(
name=thread_name,
type=discord.ChannelType.public_thread,
auto_archive_duration=1440, # 24시간 후 자동 아카이브
)
# 매핑 등록
_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)
# followup으로 스레드 안내
await interaction.followup.send(
f"✅ 스레드가 생성되었습니다: <#{thread.id}>",
ephemeral=True,
)
# 작업 실행 (가짜 Message 대신 스레드에 직접 메시지 전송)
if request_text.strip():
# 통합 프롬프트 호출
try:
async with thread.typing():
history = ""
result = await _unified_call(request_text, history, 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: # task
# task 모드 — 스레드에서 파이프라인 안내
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]}")
@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,
)
# ──────────────────────────────────────────────
# 스레드 이벤트 — 아카이브/삭제 시 매핑 정리
# ──────────────────────────────────────────────
@bot.event
async def on_thread_update(before, after):
"""스레드 아카이브 감지 → 매핑 정리."""
if after.archived and after.id in _thread_workspaces:
ws = _thread_workspaces.pop(after.id)
_project_threads.pop(ws.name, None)
logger.info(f"스레드 아카이브 감지 → 매핑 제거: {ws.name} (스레드 {after.id})")
@bot.event
async def on_thread_delete(thread):
"""스레드 삭제 감지 → 매핑 정리."""
if thread.id in _thread_workspaces:
ws = _thread_workspaces.pop(thread.id)
_project_threads.pop(ws.name, None)
logger.info(f"스레드 삭제 감지 → 매핑 제거: {ws.name} (스레드 {thread.id})")
# ──────────────────────────────────────────────
# /anime 커맨드 — 애니메이션 자동화
# ──────────────────────────────────────────────
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):
"""Anissia + Nyaa 통합 검색."""
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
try:
result = await pipeline.search(title)
except Exception as e:
await interaction.followup.send(f"❌ 검색 오류: {e}")
return
if not result.anime:
await interaction.followup.send(f"'{title}' 검색 결과가 없습니다.")
return
anime = result.anime
embed = discord.Embed(
title=f"🔍 {anime.subject}",
description=f"**원제**: {anime.original_subject}\n**장르**: {anime.genres}",
color=0x3498DB,
)
embed.add_field(
name="📅 편성",
value=f"{['','','','','','','','기타'][anime.week]}요일 {anime.time}",
inline=True,
)
embed.add_field(name="📁 NAS 폴더", value=f"`{result.nas_folder}`", inline=True)
# 자막 정보
if result.captions:
cap_lines = []
for c in result.captions[:5]:
url_text = f"[사이트]({c.website})" if c.website else "URL 없음"
cap_lines.append(f"• **{c.name}** — {c.episode}화 ({url_text})")
embed.add_field(name=f"📝 자막 ({len(result.captions)}명)", value="\n".join(cap_lines), inline=False)
else:
embed.add_field(name="📝 자막", value="등록된 자막 없음", inline=False)
# 토렌트 정보
if result.torrents:
tor_lines = []
for t in result.torrents[:5]:
ep = f"**{t.episode}화**" if t.episode else ""
tor_lines.append(f"• [{t.group}] {ep} {t.size} (🌱{t.seeders})")
embed.add_field(name=f"🎬 토렌트 ({len(result.torrents)}건)", value="\n".join(tor_lines), inline=False)
else:
embed.add_field(name="🎬 토렌트", value="검색 결과 없음", inline=False)
if result.errors:
embed.set_footer(text="⚠️ " + "; ".join(result.errors))
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()
embed = discord.Embed(title="⏳ 다운로드 진행 중...", description=f"**{title}**", color=0xF39C12)
msg = await interaction.followup.send(embed=embed, wait=True)
try:
result = await pipeline.download(title, mode="auto", episode=episode)
except Exception as e:
embed.title = "❌ 다운로드 오류"
embed.description = str(e)[:500]
embed.color = 0xE74C3C
await msg.edit(embed=embed)
return
embed.title = "✅ 다운로드 완료" if result.torrent_added or result.subtitles else "⚠️ 부분 완료"
embed.description = result.message[:4000]
embed.color = 0x2ECC71 if result.torrent_added or result.subtitles else 0xF39C12
await msg.edit(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()
try:
result = await pipeline.download(title, mode="sub_only", episode=episode)
except Exception as e:
await interaction.followup.send(f"❌ 오류: {e}")
return
embed = discord.Embed(
title=f"📝 자막 다운로드 {'완료' if result.subtitles else '실패'}",
description=result.message[:4000],
color=0x2ECC71 if result.subtitles 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()
try:
result = await pipeline.download(title, mode="video_only", episode=episode)
except Exception as e:
await interaction.followup.send(f"❌ 오류: {e}")
return
embed = discord.Embed(
title=f"🎬 영상 다운로드 {'추가됨' if result.torrent_added else '실패'}",
description=result.message[:4000],
color=0x2ECC71 if result.torrent_added else 0xE74C3C,
)
await interaction.followup.send(embed=embed)
@anime_group.command(name="status", description="현재 다운로드 큐 상태")
async def anime_status(interaction: discord.Interaction):
"""qBittorrent 다운로드 상태 확인."""
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
# 연결 테스트
conn = await pipeline.qbit.test_connection()
if not conn.get("connected"):
await interaction.followup.send(
embed=discord.Embed(
title="❌ qBittorrent 연결 실패",
description=f"URL: `{conn.get('url')}`\n오류: {conn.get('error', '?')}",
color=0xE74C3C,
)
)
return
torrents = await pipeline.get_status()
embed = discord.Embed(
title=f"📊 다운로드 큐 ({len(torrents)}건)",
description=f"qBittorrent {conn.get('version', '?')} | API {conn.get('api_version', '?')}",
color=0x3498DB,
)
if torrents:
for t in torrents[:10]:
status_icon = "" if t["progress"] == "100.0%" else ""
embed.add_field(
name=f"{status_icon} {t['name'][:50]}",
value=f"진행: {t['progress']} | 속도: {t['speed']} | ETA: {t['eta']}",
inline=False,
)
else:
embed.add_field(name="상태", value="다운로드 중인 항목 없음", inline=False)
await interaction.followup.send(embed=embed)
bot.tree.add_command(anime_group)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# 기존 ! 명령어 (유지, 하위호환) # 기존 ! 명령어 (유지, 하위호환)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@bot.command(name="ping", help="봇 응답 테스트") @bot.command(name="ping", help="봇 응답 테스트")
async def ping_command(ctx: commands.Context): async def ping_command(ctx: commands.Context):
latency = round(bot.latency * 1000) latency = round(bot.latency * 1000)

View File

@@ -49,3 +49,13 @@ VIKUNJA_PROJECT_ID: int = int(os.getenv("VIKUNJA_PROJECT_ID", "7"))
WORKSPACE_BASE_DIR: str = os.getenv( WORKSPACE_BASE_DIR: str = os.getenv(
"WORKSPACE_BASE_DIR", r"c:\Users\Certes\Desktop\VW_Proj" "WORKSPACE_BASE_DIR", r"c:\Users\Certes\Desktop\VW_Proj"
) )
# === qBittorrent ===
QBIT_URL: str = os.getenv("QBIT_URL", "http://localhost:8080")
QBIT_USERNAME: str = os.getenv("QBIT_USERNAME", "admin")
QBIT_PASSWORD: str = os.getenv("QBIT_PASSWORD", "")
# === NAS ===
NAS_ANIME_PATH: str = os.getenv(
"NAS_ANIME_PATH", r"\\192.168.10.10\NasData\Video\Animation"
)

View File

@@ -97,7 +97,7 @@ class GeminiCaller:
# 텍스트 모드 (분류/리뷰/총평) # 텍스트 모드 (분류/리뷰/총평)
# ────────────────────────────────────────── # ──────────────────────────────────────────
async def call(self, role: str, context: str, timeout: int = 120) -> str: async def call(self, role: str, context: str, timeout: int = 300) -> str:
"""역할별 프롬프트로 텍스트 생성. """역할별 프롬프트로 텍스트 생성.
파일 접근 없이 텍스트만 주고받는 역할에 사용. 파일 접근 없이 텍스트만 주고받는 역할에 사용.
@@ -150,7 +150,8 @@ class GeminiCaller:
return result return result
except asyncio.TimeoutError: except asyncio.TimeoutError:
raise GeminiCallError(f"Gemini timeout ({timeout}s) -- role={role}") logger.error(f"Gemini [{role}] 타임아웃 ({timeout}s) — 입력 {len(full_input)}")
raise GeminiCallError(f"Gemini 응답 시간 초과 ({timeout}초). 요청이 너무 복잡할 수 있습니다.")
except FileNotFoundError: except FileNotFoundError:
raise GeminiCallError("gemini CLI를 찾을 수 없습니다.") raise GeminiCallError("gemini CLI를 찾을 수 없습니다.")
except Exception as e: except Exception as e:
@@ -162,7 +163,7 @@ class GeminiCaller:
async def call_agent( async def call_agent(
self, role: str, context: str, cwd: str, self, role: str, context: str, cwd: str,
timeout: int = 300, timeout: int = 600,
) -> str: ) -> str:
"""에이전트 모드 — 프로젝트 디렉토리에서 실행. """에이전트 모드 — 프로젝트 디렉토리에서 실행.
@@ -173,7 +174,7 @@ class GeminiCaller:
role: 프롬프트 역할 (coder) role: 프롬프트 역할 (coder)
context: 작업 지시 (태스크 설명) context: 작업 지시 (태스크 설명)
cwd: 프로젝트 루트 경로 (여기서 Gemini 실행) cwd: 프로젝트 루트 경로 (여기서 Gemini 실행)
timeout: 타임아웃 (에이전트는 더 길게 — 기본 5분) timeout: 타임아웃 (에이전트는 더 길게 — 기본 10분)
""" """
async with _semaphore: async with _semaphore:
return await self._call_agent_impl(role, context, cwd, timeout) return await self._call_agent_impl(role, context, cwd, timeout)
@@ -197,7 +198,8 @@ class GeminiCaller:
f"=== IMPORTANT ===\n" f"=== IMPORTANT ===\n"
f"프로젝트 루트: {cwd}\n" f"프로젝트 루트: {cwd}\n"
f"파일을 직접 생성/수정하세요. 코드블록으로 출력하지 말고, 실제 파일로 저장하세요.\n" f"파일을 직접 생성/수정하세요. 코드블록으로 출력하지 말고, 실제 파일로 저장하세요.\n"
f"작업 완료 후 변경한 파일 목록을 간단히 출력하세요." f"작업 완료 후 변경한 파일 목록을 간단히 출력하세요.\n"
f"모든 응답, 주석, 문서는 반드시 한국어로 작성하세요."
) )
try: try:
@@ -225,8 +227,9 @@ class GeminiCaller:
return result return result
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error(f"Gemini [{role}] agent 타임아웃 ({timeout}s) — cwd={cwd}")
raise GeminiCallError( raise GeminiCallError(
f"Gemini agent timeout ({timeout}s) -- role={role}, cwd={cwd}" f"Gemini 에이전트 응답 시간 초과 ({timeout}초). 작업이 너무 복잡할 수 있습니다."
) )
except FileNotFoundError: except FileNotFoundError:
raise GeminiCallError("gemini CLI를 찾을 수 없습니다.") raise GeminiCallError("gemini CLI를 찾을 수 없습니다.")

View File

@@ -0,0 +1,5 @@
# 2026-03-08 Devlog
| # | 시간 | 작업 | 커밋 | 상태 |
|---|------|------|------|------|
| 1 | 15:19~16:02 | 애니메이션 자동화 파이프라인 구현 (Anissia/Nyaa/qBit/자막다운로더/NAS스캐너 + AI 평문 통합) | `pending` | ✅ |

View File

@@ -0,0 +1,27 @@
# 애니메이션 자동화 파이프라인 구현
- **시간**: 2026-03-08 15:19~16:02
- **Commit**: `pending`
## 결정 사항
- **Tool System 선택**: MCP 대신 프로젝트 내장 tool 모듈 방식 채택 — 기존 variet-agent 프레임워크와 자연스럽게 통합
- **AI 평문 통합**: 슬래시 커맨드만으로는 AI가 쓰이지 않으므로, 통합 프롬프트에 `anime` 모드를 추가하여 "이번분기 자막있는 애니 다운받아줘" 같은 자연어 요청을 AI가 해석 → 파이프라인 호출
- **자막 다운로드 전략**: 3개 플랫폼 파서 (Google Drive 60%, Tistory 15%, Naver 20%)로 대부분 커버
- **제목 매칭**: 일본어→로마자 변환 + fuzzy matching (한자는 변환 불가하나 유사도 0.94+ 달성)
## 구현 범위
- `tools/anissia_client.py` — 편성표/자막 API
- `tools/nyaa_client.py` — RSS 토렌트 검색
- `tools/qbit_client.py` — qBittorrent Web API
- `tools/subtitle_downloader.py` — 3개 플랫폼 자막 파서
- `tools/title_matcher.py` — 제목 매칭 + NAS 폴더명 생성
- `tools/anime_pipeline.py` — 전체 오케스트레이터
- `tools/nas_scanner.py` — NAS 폴더/파일 스캔
- `prompts/unified.md` — anime 모드 추가
- `api/discord_bot.py` — AI 평문 anime 핸들러 + 슬래시 커맨드
## 미완료
- 실제 디스코드 평문 테스트 (봇은 실행되어 있으나 사용자가 아직 테스트하지 않음)
- 자막 다운로드 후 영상 파일명에 맞게 자동 리네이밍 기능은 코드 준비되었으나 파이프라인에서 아직 호출하지 않음

View File

@@ -1,42 +1,39 @@
You are a **Coder** — 프로젝트에서 직접 코드를 구현하는 AI 에이전트입니다. You are a **Coder** — 프로젝트에서 파일을 직접 생성/수정하는 AI 에이전트입니다.
## 작업 원칙
**핵심: 태스크의 description만 보고, 완성된 결과물을 파일로 만드세요.**
소스코드뿐 아니라 **문서(.md), 설정 파일, 워크플로우, 데이터 파일** 등 모든 유형의 파일을 다룹니다.
## 작업 흐름 ## 작업 흐름
### 1단계: 구현 ### 1단계: 탐색
- 현재 디렉토리의 프로젝트 파일을 확인 - 프로젝트 구조를 먼저 파악하세요 (디렉토리 확인, 관련 파일 검색)
- 필요한 변경사항을 구현 - 기존 프로젝트라면 **관련 파일을 찾아서 읽은 뒤** 수정하세요
- 파일을 직접 생성/수정하여 저장 - 빈 프로젝트라면 필요한 파일을 처음부터 만드세요
### 2단계: 자가 검증 (반드시 수행) ### 2단계: 구현
구현 후 다음을 직접 확인하세요: - 파일을 직접 생성/수정하여 저장하세요
- 생성/수정한 파일을 다시 읽어서 내용이 완전한지 확인 - 코드블록으로 출력하지 말고, **파일을 직접 만드세요**
- 파일 간 참조(import, src 경로 등)가 올바른지 확인
- 문법 오류가 없는지 확인
- 핵심 기능이 빠진 것은 없는지 확인
### 3단계: 자가 수정 ### 3단계: 자가 검증 (반드시 수행)
검증에서 문제를 발견하면: 구현 후 직접 확인하세요:
- 직접 수정하세요 - 생성/수정한 파일을 다시 읽어서 내용이 완전한지
- 다시 2단계로 돌아가 확인하세요 - 파일 간 참조(import, 경로 등)가 올바른지
- 문제가 없을 때까지 반복하세요 - 핵심 내용이 빠진 것은 없는지
### 4단계: 완료 보고 ### 4단계: 자가 수정
모든 검증을 통과한 후에만 완료 보고하세요: 검증에서 문제를 발견하면 직접 수정 → 다시 3단계 → 문제 없을 때까지 반복.
### 5단계: 완료 보고
- 변경한 파일 목록 - 변경한 파일 목록
- 각 파일의 핵심 내용 한 줄 설명 - 각 파일의 핵심 내용 한 줄 설명
- 자가 검증에서 발견하고 수정한 것이 있으면 언급 - 자가 검증에서 발견하고 수정한 것이 있으면 언급
- **실행/사용 방법이 있으면 반드시 안내** (예: 서버 시작 명령, 테스트 방법, 설치 절차 등)
## 규칙 ## 규칙
### 파일 작성 - 동작하는 완성된 결과물을 만드세요. 뼈대나 TODO를 남기지 마세요.
- 코드블록으로 출력하지 말고, **파일을 직접 생성/수정**하세요 - 기존 프로젝트의 스타일과 구조를 유지하세요.
- 새 프로젝트(빈 폴더)인 경우, 필요한 파일을 모두 처음부터 만드세요 - 코드 주석과 문서는 **한국어**로 작성. 코드 식별자는 영어 유지.
- 기존 프로젝트인 경우, 기존 구조와 스타일을 유지하세요
### 완성도
- 동작하는 완성된 코드를 작성하세요. 뼈대나 TODO를 남기지 마세요
- 모든 파일은 실행 가능한 상태여야 합니다
### 언어
- 코드 주석(comment)과 문서(docstring, README 등)는 **한국어**로 작성
- 변수명, 함수명 등 코드 식별자는 영어 유지

View File

@@ -1,32 +1,27 @@
You are a **Planner** — 사용자 요청을 분석하여 직접 처리하거나 태스크로 변환합니다. You are a **Planner** — 사용자 요청을 분석하여 직접 처리하거나 태스크로 분배합니다.
## 역할 ## 판단 원칙
사용자의 요청과 프로젝트 컨텍스트를 보고: **핵심 질문: "이 작업을 내가 지금 바로 할 수 있는가?"**
1. 무엇을 해야 하는지 분석
2. **직접 처리할 수 있으면 직접 처리** (파일 삭제, 정리, 간단한 수정 등)
3. 복잡한 작업만 태스크로 분배
## 직접 처리 (direct 모드) - **Yes** → `direct: true` (직접 처리)
- **No** → `direct: false` + tasks 배열 (코더에게 분배)
다음과 같은 경우 당신이 직접 처리하세요: ### 직접 처리 기준
- 파일/폴더 삭제, 정리, 이름 변경 - 파일 1-2개 삭제, 이름 변경, 간단한 수정
- 간단한 설정 변경, 한두 줄 수정
- 프로젝트 구조 확인, 현황 파악 - 프로젝트 구조 확인, 현황 파악
- 코더에게 넘기기엔 너무 단순한 작업 - 간단한 문서(.md, .txt) 생성/수정
- 에이전트 도구만으로 완료 가능한 작업
직접 처리 시 에이전트 도구로 파일을 직접 수정한 뒤 결과를 보고하세요. ### 태스크 분배 기준
- **파일을 생성/수정/삭제해야 하는 모든 작업** (소스코드, 문서, 워크플로우, 설정 파일 등)
- 구현 복잡도가 있어서 코더의 자가 검증이 필요한 작업
- 1개로 충분하면 **반드시 1개만**. 독립적인 기능이 여러 개일 때만 분할.
## 태스크 분배 (tasks 모드) ### ⚠️ 절대 하지 말 것
코딩이 필요한 복잡한 작업만 태스크로 만드세요.
**1개로 충분하면 반드시 1개만 만드세요.**
여러 태스크는 **서로 독립적인 기능이 2개 이상**일 때만.
절대 하지 말 것:
- 하나의 기능을 "파일 생성", "스타일 추가", "로직 구현"으로 쪼개기 - 하나의 기능을 "파일 생성", "스타일 추가", "로직 구현"으로 쪼개기
- 단순한 요청을 3개 이상으로 분할하기 - 단순한 요청을 3개 이상으로 분할하기
- 작업할 게 없는데 억지로 태스크 만들기
## 이전 시도 피드백이 있는 경우 ## 이전 시도 피드백이 있는 경우
@@ -35,7 +30,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석
## Output Format ## Output Format
### 직접 처리한 경우: ### 직접 처리:
```json ```json
{ {
"summary": "처리 결과 요약", "summary": "처리 결과 요약",
@@ -44,7 +39,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석
} }
``` ```
### 태스크 분배가 필요한 경우: ### 태스크 분배:
```json ```json
{ {
"summary": "작업 요약", "summary": "작업 요약",
@@ -53,7 +48,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석
{ {
"id": 1, "id": 1,
"title": "태스크 제목", "title": "태스크 제목",
"description": "구현 세부사항. 에이전트가 이것만 보고 작업합니다.", "description": "구현 세부사항. 에이전트가 이것만 보고 작업합니다. 대상 파일, 내용, 형식을 구체적으로 포함하세요.",
"type": "create|modify|delete" "type": "create|modify|delete"
} }
], ],
@@ -63,6 +58,7 @@ review_feedback이 주어지면, 이전 시도에서 실패한 원인을 분석
## Rules ## Rules
- description에 모든 구현 세부사항을 적으세요 - description에 **모든 구현 세부사항**을 적으세요. 코더는 이것만 봅니다.
- 한국어로 작성하세요 - 한국어로 작성하세요.
- 단순한 일을 복잡하게 만들지 마세요 - 단순한 일을 복잡하게 만들지 마세요.
- 이전 대화 맥락이 주어지면, 그 내용을 반영하세요.

View File

@@ -1,26 +1,28 @@
You are a **Reviewer** — 에이전트가 작성한 코드를 리뷰합니다. You are a **Reviewer** — 에이전트가 수행한 작업의 결과물을 리뷰합니다.
## 입력 ## 리뷰 원칙
- 요청된 태스크 목록 **핵심 질문: "사용자의 요청이 충족되었는가?"**
- 에이전트의 작업 보고
- 실제 생성/수정된 파일 내용
## 리뷰 기준 작업 유형에 따라 리뷰 기준을 자율적으로 적용하세요:
- **코드** → 실행 가능 여부, 핵심 기능 구현, 파일 간 참조 정합성
- **문서** → 내용 완성도, 구조, 요청된 범위 충족
- **설정/워크플로우** → 형식 유효성, 필수 항목 포함
## 통과/반려 기준
### passed: true (통과) ### passed: true (통과)
- 태스크 요구사항을 충족하는 파일이 존재함 - 요청된 결과물이 존재하고, 핵심 내용이 포함됨
- 코드가 실행 가능한 상태임 (문법 오류 없음) - 명백한 결함이 없음
- 핵심 기능이 구현되어 있음
### passed: false (반려) ### passed: false (반려)
- 비어있거나 잘린 파일이 존재함 - 비어있거나 잘린 파일
- 핵심 기능이 빠져 있음 - 핵심 기능/내용이 빠져 있음
- 명백한 버그가 있음 (런타임 에러 확실) - 명백한 버그나 구조적 오류
- 파일 간 참조가 깨져 있음 (예: import 경로 오류) - 파일 간 참조가 깨져 있음
### 반려하지 마세요 ### 반려하지 마세요
- 스타일이나 코드 품질 문제 (개선 제안으로 남기세요) - 스타일이나 품질 문제 (개선 제안으로 남기세요)
- "더 좋을 수 있는" 부분 - "더 좋을 수 있는" 부분
- 사소한 미비점 - 사소한 미비점
@@ -43,7 +45,7 @@ You are a **Reviewer** — 에이전트가 작성한 코드를 리뷰합니다.
## Rules ## Rules
- 한국어로 작성하세요 - **결과물의 유형에 맞는 기준**으로 리뷰하세요. 모든 작업을 코드 기준으로 보지 마세요.
- **기능 동작 여부**에 집중하세요. 완벽함을 요구하지 마세요. - critical 이슈가 있을 때만 passed=false.
- critical 이슈가 있을 때만 passed=false - 의심이 되면 통과시키세요.
- 의심이 되면 통과시키세요 - 한국어로 작성하세요.

View File

@@ -1,13 +1,13 @@
# Summarizer # Summarizer
당신은 AI Agent Team의 **총평 작성자**입니다. 당신은 AI Agent Team의 **총평 작성자**입니다.
작업 파이프라인 완료 후, 전체 결과를 사용자가 이해하기 쉽게 요약합니다. 작업 파이프라인 완료 후, 사용자가 이해하기 쉽게 결과를 요약합니다.
## 입력 ## 입력
- 사용자의 원래 요청 - 사용자의 원래 요청
- 태스크 수 - 태스크 수
- 에이전트 코딩 결과 보고 - 에이전트 작업 보고
- 리뷰 결과 - 리뷰 결과
## 출력 형식 (JSON) ## 출력 형식 (JSON)
@@ -16,7 +16,7 @@
{ {
"title": "작업 완료 한줄 제목", "title": "작업 완료 한줄 제목",
"changes": [ "changes": [
{"file": "path/to/file.py", "description": "변경 내용 설명"} {"file": "path/to/file", "description": "변경 내용 설명"}
], ],
"warnings": ["주의사항이 있으면 여기에"], "warnings": ["주의사항이 있으면 여기에"],
"next_steps": ["사용자가 다음에 할 수 있는 작업 제안"], "next_steps": ["사용자가 다음에 할 수 있는 작업 제안"],
@@ -26,8 +26,10 @@
## 규칙 ## 규칙
- 기술 용어는 최소화, 사용자 관점에서 서술 - 사용자 관점에서 서술하세요. 기술 용어는 최소화.
- 한국어로 답변 - 한국어로 답변.
- 주의사항이 없으면 warnings를 빈 배열로 - 주의사항이 없으면 warnings를 빈 배열로.
- next_steps는 1-2개만 제안 - next_steps는 1-2개만 구체적으로 제안.
- changes의 file은 에이전트 보고에서 언급된 파일명을 사용 - **실행 가능한 결과물이 있으면 next_steps 첫 번째에 실행 방법을 반드시 포함** (예: "터미널에서 `npm start` 실행", "http://localhost:3000 접속" 등).
- changes의 file은 에이전트 보고에서 언급된 파일명 사용.
- 코드 작업과 문서 작업 모두 동일한 형식으로 요약하세요.

View File

@@ -1,29 +1,40 @@
# Unified Agent — 분류 + 즉답 통합 프롬프트 # Unified Agent — 분류 + 즉답
당신은 **Variet Agent**입니다. 사용자의 메시지를 받아 판단하여 즉답하거나 작업으로 넘깁니다. 당신은 **Variet Agent**입니다. 사용자의 메시지를 판단하여 즉답하거나 작업으로 넘깁니다.
## 판단 기준 ## 판단 원칙
1. **즉답 가능** (질문, 인사, 설명 요청, 의견 교환) **핵심 질문: "이 요청이 무엇을 원하는가?"**
`mode: "chat"` — 바로 답변을 포함하세요.
2. **작업 필요** (코드 수정, 파일 생성, 리팩토링, 배포 등 실제 변경이 필요한 요청) - **애니메이션 관련 요청** → `mode: "anime"` (자막, 영상, 다운로드, 편성표 등)
`mode: "task"` — 작업 요약만 작성하세요. 태스크 분할은 하지 마세요. - **프로젝트 파일 변경이 필요** → `mode: "task"`
- **대화로 해결 가능** → `mode: "chat"`
- **판단 불가** → `mode: "clarify"`
3. **불명확** (맥락 부족, 대상 불분명) ### anime 판단 기준
`mode: "clarify"` — 되물을 질문을 포함하세요. 다음 키워드/의도가 포함되면 `anime`로 분류:
- 애니메이션/애니 자막 다운로드, 영상 다운로드
- 편성표 확인, 이번 분기 애니, 신작
- NAS에 저장, 토렌트, nyaa, 자막 수집
- 특정 애니 제목 언급 + 다운/검색/모아줘 등
### 추가 원칙
- **확신이 없으면 chat**으로 대응하세요.
- "분석해줘", "제안해줘" 등은 **대부분 대화**입니다.
- "만들어줘", "수정해줘" 등은 문맥을 보세요. 파일/코드 변경이면 task.
- 에러/버그 수정 요청 → **task**.
## 출력 형식 (반드시 JSON) ## 출력 형식 (반드시 JSON)
### 즉답인 경우: ### chat:
```json ```json
{ {
"mode": "chat", "mode": "chat",
"response": "여기에 답변 내용" "response": "마크다운 형식의 완성된 답변"
} }
``` ```
### 작업인 경우: ### task:
```json ```json
{ {
"mode": "task", "mode": "task",
@@ -31,18 +42,46 @@
} }
``` ```
### 불명확한 경우: ### clarify:
```json ```json
{ {
"mode": "clarify", "mode": "clarify",
"question": "어떤 파일을 수정할까요?" "question": "무엇을 명확히 해야 하는지"
} }
``` ```
### anime:
```json
{
"mode": "anime",
"action": "search | download | sub_only | video_only | status | schedule",
"title": "애니 제목 (한글, 가능하면 추출)",
"episode": null,
"filter": "이번분기 자막있는것 등 사용자가 지정한 조건 (없으면 빈 문자열)",
"summary": "사용자 요청 요약"
}
```
**anime action 선택 기준:**
- `search`: 검색/정보 확인만 원할 때
- `download`: 자막+영상 모두 다운 (기본)
- `sub_only`: 자막만 원할 때
- `video_only`: 영상만 원할 때
- `status`: 다운로드 진행 상태 확인
- `schedule`: 편성표/이번분기 목록 조회
- `list`: NAS에 이미 다운받은 애니 목록 조회
**filter 예시:**
- "이번분기 자막있는것" → `"quarter:current sub:yes"`
- "프리렌 7화" → title="장송의 프리렌", episode=7
- "일요일 편성" → action="schedule", filter="week:0"
## 규칙 ## 규칙
- 반드시 위 JSON 형식만 출력하세요. JSON 외의 텍스트를 포함하지 마세요. - 반드시 위 JSON 형식만 출력하세요. JSON 외의 텍스트를 포함하지 마세요.
- chat 모드의 response는 마크다운 사용 가능, 완성된 답변이어야 합니다. - chat의 response는 마크다운 사용 가능, **완성된 답변**이어야 합니다.
- task 모드에서는 summary만 작성하세요. tasks 배열을 만들지 마세요. - task에서는 summary만 작성하세요. tasks 배열을 만들지 마세요.
- anime에서는 사용자 의도를 정확히 파악하여 action과 파라미터를 설정하세요.
- 한국어로 응답하세요. - 한국어로 응답하세요.
- 이전 대화 기록이 주어지면, 맥락을 고려하세요. - 이전 대화 기록이 주어지면, 맥락을 고려하세요.

14
run_bot.bat Normal file
View File

@@ -0,0 +1,14 @@
@echo off
chcp 65001 >nul
title Variet Agent - Discord Bot
echo ==========================================
echo Variet Agent 시작
echo ==========================================
echo.
C:\ProgramData\miniforge3\envs\agent_chat\python.exe main.py
echo.
echo 봇이 종료되었습니다. 아무 키나 누르면 창을 닫습니다.
pause >nul

175
tests/test_anime_tools.py Normal file
View File

@@ -0,0 +1,175 @@
"""애니메이션 도구 테스트 — API 파싱 + 제목 매칭 검증."""
import asyncio
import sys
import os
import io
if sys.stdout.encoding != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
async def test_anissia():
"""Anissia API 테스트."""
print("=== Anissia API Test ===")
from tools.anissia_client import AnissiaClient
client = AnissiaClient()
# 편성표 조회 (일요일)
schedule = await client.get_schedule(0)
print(f" 일요일 편성: {len(schedule)}")
for a in schedule[:3]:
print(f" [{a.anime_no}] {a.subject} ({a.original_subject}) 자막:{a.caption_count}")
# 검색
results = await client.search_anime("프리렌")
print(f" '프리렌' 검색: {len(results)}")
for a in results:
print(f" {a.subject}{a.original_subject}")
# 자막 조회 (첫 번째 결과)
if schedule:
first = schedule[0]
captions = await client.get_captions(first.anime_no)
print(f" '{first.subject}' 자막: {len(captions)}")
for c in captions:
print(f" {c.name}{c.episode}화 — {c.website[:50] if c.website else '(없음)'}")
print(" ✅ Anissia OK\n")
async def test_nyaa():
"""Nyaa.si RSS 테스트."""
print("=== Nyaa RSS Test ===")
from tools.nyaa_client import NyaaClient
client = NyaaClient()
results = await client.search("Frieren")
print(f" 'Frieren ASW HEVC' 검색: {len(results)}")
for t in results[:5]:
ep_str = f"{t.episode}" if t.episode else "?"
print(f" [{t.group}] {ep_str} {t.size} seeders:{t.seeders}")
print(f" magnet: {t.magnet_link[:60]}...")
print(" ✅ Nyaa OK\n")
async def test_title_matcher():
"""제목 매칭 테스트."""
print("=== Title Matcher Test ===")
from tools.title_matcher import (
japanese_to_romaji, normalize_title, title_similarity,
make_nas_folder_name, get_quarter,
)
# 로마자 변환
tests = [
("葬送のフリーレン", "sousou no furiren"),
("鬼滅の刃", "kimetsu no yaiba"),
("ワンピース", "wanpisu"),
]
for jp, expected_approx in tests:
romaji = japanese_to_romaji(jp)
print(f" {jp}{romaji} (기대: ~{expected_approx})")
# 유사도 계산
pairs = [
("Sousou no Frieren", "sousou no furiren"),
("Kimetsu no Yaiba", "kimetsu no yaiba"),
("완전 다른 제목", "completely different"),
]
for a, b in pairs:
sim = title_similarity(a, b)
print(f" 유사도 '{a}' vs '{b}': {sim:.2f}")
# NAS 폴더명
folder = make_nas_folder_name("장송의프리렌 2기", "2026-01-11")
print(f" NAS 폴더: {folder}")
assert folder == "[26_1분기]장송의프리렌 2기", f"Expected [26_1분기]장송의프리렌 2기, got {folder}"
# 분기 계산
q_tests = [
("2026-01-11", (26, 1)),
("2026-04-01", (26, 2)),
("2026-07-15", (26, 3)),
("2026-10-05", (26, 4)),
]
for date, expected in q_tests:
result = get_quarter(date)
assert result == expected, f"get_quarter({date}) = {result}, expected {expected}"
print(f" {date}{result[0]}{result[1]}분기 ✓")
print(" ✅ Title Matcher OK\n")
async def test_subtitle_parser():
"""자막 파서 테스트 (HTML 파싱)."""
print("=== Subtitle Parser Test ===")
from tools.subtitle_downloader import (
parse_google_drive_links,
parse_tistory_links,
parse_naver_links,
)
# Google Drive 파싱
gdrive_html = '''
<a href="https://drive.google.com/file/d/abc123/view?usp=sharing">1화 자막</a>
<a href="https://drive.google.com/file/d/def456/view">2화 자막</a>
'''
gd_results = parse_google_drive_links(gdrive_html)
print(f" Google Drive 파싱: {len(gd_results)}")
for r in gd_results:
print(f" {r.filename}{r.download_url} (ep={r.episode})")
assert len(gd_results) >= 2, "Google Drive 파싱 실패"
# Tistory 파싱
tistory_html = '''
<a href="https://blog.kakaocdn.net/dna/test/file.zip?credential=abc">file.zip</a>
'''
ts_results = parse_tistory_links(tistory_html)
print(f" Tistory 파싱: {len(ts_results)}")
assert len(ts_results) >= 1, "Tistory 파싱 실패"
# Naver 파싱
naver_html = '''
<a class="se-file-save-button" href="https://download.blog.naver.com/path/test.zip">다운로드</a>
'''
nv_results = parse_naver_links(naver_html)
print(f" Naver 파싱: {len(nv_results)}")
assert len(nv_results) >= 1, "Naver 파싱 실패"
print(" ✅ Subtitle Parser OK\n")
async def test_qbit_connection():
"""qBittorrent 연결 테스트."""
print("=== qBittorrent Connection Test ===")
from tools.qbit_client import QBitClient
client = QBitClient()
result = await client.test_connection()
if result["connected"]:
print(f" ✅ 연결 성공: v{result['version']} (API {result['api_version']})")
else:
print(f" ⚠️ 연결 실패: {result.get('error', '?')}")
print(f" URL: {result['url']}")
print(" (qBittorrent Web UI가 꺼져있을 수 있음)")
print()
async def main():
await test_title_matcher()
await test_subtitle_parser()
await test_anissia()
await test_nyaa()
await test_qbit_connection()
print("🎉 All tests completed!")
if __name__ == "__main__":
asyncio.run(main())

1
tools/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Anime automation tools package.

244
tools/anime_pipeline.py Normal file
View File

@@ -0,0 +1,244 @@
"""애니메이션 자동화 파이프라인.
전체 흐름:
1. Anissia에서 애니 검색 → 자막 정보 확인
2. Nyaa.si에서 토렌트 검색 → 제목 매칭
3. qBittorrent에 magnet 추가 → NAS 경로 지정
4. 자막 다운로드 → 파일명 매칭
"""
import asyncio
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import config
from tools.anissia_client import AnissiaClient, AnimeInfo, CaptionInfo
from tools.nyaa_client import NyaaClient, TorrentResult
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,
)
logger = logging.getLogger("variet.tools.pipeline")
@dataclass
class DownloadResult:
"""파이프라인 실행 결과."""
success: bool
anime: Optional[AnimeInfo] = None
captions: list[CaptionInfo] = field(default_factory=list)
torrents: list[TorrentResult] = field(default_factory=list)
subtitles: list[SubtitleFile] = field(default_factory=list)
nas_folder: str = ""
torrent_added: bool = False
message: str = ""
errors: list[str] = field(default_factory=list)
class AnimePipeline:
"""애니메이션 다운로드 자동화 파이프라인."""
def __init__(self):
self.anissia = AnissiaClient()
self.nyaa = NyaaClient()
self.qbit = QBitClient()
self.sub_downloader = SubtitleDownloader()
self.nas_base = getattr(config, "NAS_ANIME_PATH",
r"\\192.168.10.10\NasData\Video\Animation")
from tools.nas_scanner import NasScanner
self.nas = NasScanner(self.nas_base)
async def search(self, title: str) -> DownloadResult:
"""애니 검색 — 정보 + 자막 + 토렌트 현황 표시.
실제 다운로드 없이 검색 결과만 반환.
"""
result = DownloadResult(success=False)
# 1. Anissia에서 검색
try:
anime_list = await self.anissia.search_anime(title)
except Exception as e:
result.errors.append(f"Anissia 검색 오류: {e}")
return result
if not anime_list:
result.message = f"'{title}' 검색 결과가 없습니다."
return result
anime = anime_list[0] # 첫 번째 결과 사용
result.anime = anime
# 2. 자막 정보
try:
captions = await self.anissia.get_captions(anime.anime_no)
result.captions = captions
except Exception as e:
result.errors.append(f"자막 조회 오류: {e}")
# 3. Nyaa 토렌트 검색 (원제 로마자로)
try:
from tools.title_matcher import japanese_to_romaji
romaji_title = japanese_to_romaji(anime.original_subject)
# 먼저 로마자로 검색
torrents = await self.nyaa.search(romaji_title)
if not torrents:
# 원제 그대로 검색
torrents = await self.nyaa.search(anime.original_subject)
# 제목 매칭 필터링
matched = match_titles(
anime.subject, anime.original_subject, torrents, threshold=0.3
)
result.torrents = matched[:20] # 상위 20개
except Exception as e:
result.errors.append(f"Nyaa 검색 오류: {e}")
# NAS 폴더명 생성
result.nas_folder = make_nas_folder_name(anime.subject, anime.start_date)
result.success = True
result.message = (
f"**{anime.subject}** ({anime.original_subject})\n"
f"자막 제작자: {len(result.captions)}명 | "
f"토렌트: {len(result.torrents)}\n"
f"NAS 폴더: `{result.nas_folder}`"
)
return result
async def download(
self,
title: str,
mode: str = "auto",
episode: Optional[int] = None,
) -> DownloadResult:
"""애니 다운로드 실행.
Args:
title: 한글 제목
mode: "auto" (자막+영상), "sub_only" (자막만), "video_only" (영상만)
episode: 특정 에피소드만 (None이면 최신)
"""
# 먼저 검색
result = await self.search(title)
if not result.success:
return result
anime = result.anime
nas_folder = Path(self.nas_base) / result.nas_folder
# ── 자막 다운로드 ──
if mode in ("auto", "sub_only"):
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)
# 결과 메시지 구성
parts = [result.message]
if result.subtitles:
parts.append(f"\n📝 자막 {len(result.subtitles)}건 다운로드 완료")
if result.torrent_added:
parts.append(f"\n🎬 토렌트 추가 완료 → `{nas_folder}`")
if result.errors:
parts.append(f"\n⚠️ 오류: " + "; ".join(result.errors))
result.message = "\n".join(parts)
return result
async def _download_subtitles(
self,
result: DownloadResult,
nas_folder: Path,
episode: Optional[int],
):
"""자막 다운로드 처리."""
sub_dir = nas_folder / "subtitles"
for caption in result.captions:
if not caption.website:
continue
if episode is not None and caption.episode != str(episode):
continue
try:
subs = await self.sub_downloader.find_subtitles(caption.website)
for sub in subs:
if episode is not None and sub.episode is not None and sub.episode != episode:
continue
try:
await self.sub_downloader.download_file(sub, str(sub_dir))
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}")
async def _add_torrents(
self,
result: DownloadResult,
nas_folder: Path,
episode: Optional[int],
force: bool = False,
):
"""토렌트 추가 처리."""
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
# auto 모드 기본 조건: 자막이 있어야 영상 다운로드 (force면 무시)
if not force and not result.captions and not result.subtitles:
# 자막이 없으면 사용자에게 안내만
result.errors.append("자막이 없어 영상 다운로드를 보류합니다. /anime video로 강제 다운로드 가능")
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}")
async def get_status(self) -> list[dict]:
"""현재 다운로드 큐 상태."""
try:
torrents = await self.qbit.list_torrents(category="anime")
return [
{
"name": t.name,
"progress": f"{t.progress * 100:.1f}%",
"state": t.state,
"size": f"{t.size / (1024**3):.2f} GB" if t.size > 0 else "?",
"speed": f"{t.download_speed / (1024**2):.1f} MB/s" if t.download_speed > 0 else "0",
"eta": f"{t.eta // 60}" if t.eta > 0 else "",
"path": t.save_path,
}
for t in torrents
]
except Exception as e:
logger.error(f"qBittorrent 상태 조회 오류: {e}")
return []

120
tools/anissia_client.py Normal file
View File

@@ -0,0 +1,120 @@
"""Anissia API 클라이언트 — 애니 편성표 + 자막 정보 조회.
API Base: https://api.anissia.net
"""
import httpx
import logging
from dataclasses import dataclass, field
from typing import Optional
logger = logging.getLogger("variet.tools.anissia")
BASE_URL = "https://api.anissia.net"
WEEK_NAMES = {
0: "", 1: "", 2: "", 3: "",
4: "", 5: "", 6: "", 7: "기타",
}
@dataclass
class CaptionInfo:
"""자막 제작 정보."""
episode: str
name: str # 제작자 이름
website: str # 제작자 사이트 URL
updated: str # 업데이트 시각
@dataclass
class AnimeInfo:
"""애니메이션 정보."""
anime_no: int
subject: str # 한글 제목
original_subject: str # 원어 제목 (일어)
genres: str
week: int
time: str
status: str # ON / OFF
caption_count: int
start_date: str
end_date: str
website: str
twitter: str
class AnissiaClient:
"""Anissia REST API 클라이언트."""
def __init__(self, timeout: float = 15.0):
self._timeout = timeout
async def get_schedule(self, week: int) -> list[AnimeInfo]:
"""요일별 편성표 조회 (week: 0=일 ~ 6=토, 7=기타)."""
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.get(f"{BASE_URL}/anime/schedule/{week}")
resp.raise_for_status()
data = resp.json()
if data.get("code") != "ok":
raise RuntimeError(f"Anissia API 오류: {data}")
return [
AnimeInfo(
anime_no=item["animeNo"],
subject=item["subject"],
original_subject=item.get("originalSubject", ""),
genres=item.get("genres", ""),
week=item.get("week", week) if isinstance(item.get("week"), int) else int(item.get("week", week)),
time=item.get("time", ""),
status=item.get("status", ""),
caption_count=item.get("captionCount", 0),
start_date=item.get("startDate", ""),
end_date=item.get("endDate", ""),
website=item.get("website", ""),
twitter=item.get("twitter", ""),
)
for item in data["data"]
]
async def get_all_schedule(self) -> list[AnimeInfo]:
"""전체 요일 편성표 조회 (0~7)."""
all_anime = []
for week in range(8):
try:
schedule = await self.get_schedule(week)
all_anime.extend(schedule)
except Exception as e:
logger.warning(f"편성표 조회 실패 (week={week}): {e}")
return all_anime
async def get_captions(self, anime_no: int) -> list[CaptionInfo]:
"""특정 애니 자막 목록 조회."""
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.get(f"{BASE_URL}/anime/caption/animeNo/{anime_no}")
resp.raise_for_status()
data = resp.json()
if data.get("code") != "ok":
raise RuntimeError(f"Anissia caption API 오류: {data}")
return [
CaptionInfo(
episode=item.get("episode", ""),
name=item.get("name", ""),
website=item.get("website", ""),
updated=item.get("updDt", ""),
)
for item in data["data"]
]
async def search_anime(self, keyword: str) -> list[AnimeInfo]:
"""키워드로 전체 편성표에서 검색 (한글/일어 제목 매칭)."""
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()
]

152
tools/nas_scanner.py Normal file
View File

@@ -0,0 +1,152 @@
"""NAS 폴더 스캐너 — 다운로드된 애니 목록 + 파일 정보 조회.
NAS Animation 폴더 구조:
\\192.168.10.10\NasData\Video\Animation\
[26_1분기]장송의프리렌2기\
[ASW] Sousou no Frieren S2 - 07.mkv
subtitles\...
[25_4분기]그노시아\
...
"""
import logging
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import config
logger = logging.getLogger("variet.tools.nas")
@dataclass
class AnimeFolder:
"""NAS에 있는 애니 폴더 정보."""
folder_name: str # [26_1분기]장송의프리렌2기
full_path: str
title: str # 장송의프리렌2기
year: int # 26
quarter: int # 1
video_count: int = 0
subtitle_count: int = 0
total_size_gb: float = 0.0
video_files: list[str] = field(default_factory=list)
subtitle_files: list[str] = field(default_factory=list)
VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".webm", ".m4v"}
SUB_EXTS = {".ass", ".srt", ".ssa", ".sub", ".smi"}
def _parse_folder_name(name: str) -> tuple[int, int, str]:
"""폴더명에서 연도, 분기, 제목 추출.
[26_1분기]장송의프리렌2기 → (26, 1, '장송의프리렌2기')
"""
m = re.match(r'\[(\d{2})_(\d)분기\](.+)', name)
if m:
return int(m.group(1)), int(m.group(2)), m.group(3)
return 0, 0, name
class NasScanner:
"""NAS Animation 폴더 스캐너."""
def __init__(self, base_path: str = ""):
self.base_path = Path(
base_path or getattr(config, "NAS_ANIME_PATH",
r"\\192.168.10.10\NasData\Video\Animation")
)
def is_accessible(self) -> bool:
"""NAS 접근 가능 여부."""
try:
return self.base_path.exists() and self.base_path.is_dir()
except (OSError, PermissionError):
return False
def list_anime_folders(
self,
year: Optional[int] = None,
quarter: Optional[int] = None,
) -> list[AnimeFolder]:
"""애니 폴더 목록 조회 (분기별 필터 가능)."""
if not self.is_accessible():
logger.error(f"NAS 경로 접근 불가: {self.base_path}")
return []
results = []
try:
for entry in sorted(self.base_path.iterdir()):
if not entry.is_dir():
continue
y, q, title = _parse_folder_name(entry.name)
# 필터링
if year is not None and y != year:
continue
if quarter is not None and q != quarter:
continue
folder = AnimeFolder(
folder_name=entry.name,
full_path=str(entry),
title=title,
year=y,
quarter=q,
)
# 파일 스캔
self._scan_folder(entry, folder)
results.append(folder)
except (OSError, PermissionError) as e:
logger.error(f"NAS 스캔 오류: {e}")
return results
def _scan_folder(self, path: Path, folder: AnimeFolder):
"""폴더 내 영상/자막 파일 집계."""
try:
for item in path.rglob("*"):
if not item.is_file():
continue
ext = item.suffix.lower()
size = item.stat().st_size
if ext in VIDEO_EXTS:
folder.video_count += 1
folder.video_files.append(item.name)
folder.total_size_gb += size / (1024 ** 3)
elif ext in SUB_EXTS:
folder.subtitle_count += 1
folder.subtitle_files.append(item.name)
except (OSError, PermissionError) as e:
logger.warning(f"파일 스캔 오류 ({path}): {e}")
def get_current_quarter_anime(self) -> list[AnimeFolder]:
"""이번 분기 다운로드된 애니 목록."""
from datetime import date
today = date.today()
year = today.year % 100
quarter = (today.month - 1) // 3 + 1
return self.list_anime_folders(year=year, quarter=quarter)
def search(self, keyword: str) -> list[AnimeFolder]:
"""키워드로 NAS 폴더 검색."""
all_folders = self.list_anime_folders()
kw = keyword.lower()
return [f for f in all_folders if kw in f.title.lower() or kw in f.folder_name.lower()]
def get_summary(self, year: Optional[int] = None, quarter: Optional[int] = None) -> dict:
"""요약 통계."""
folders = self.list_anime_folders(year=year, quarter=quarter)
return {
"total_anime": len(folders),
"total_videos": sum(f.video_count for f in folders),
"total_subtitles": sum(f.subtitle_count for f in folders),
"total_size_gb": round(sum(f.total_size_gb for f in folders), 2),
"folders": folders,
}

156
tools/nyaa_client.py Normal file
View File

@@ -0,0 +1,156 @@
"""Nyaa.si RSS 클라이언트 — 토렌트 검색 + Magnet 링크 생성.
RSS Feed: https://nyaa.si/?page=rss&q={query}&c={category}&f={filter}
"""
import httpx
import logging
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Optional
from urllib.parse import quote
logger = logging.getLogger("variet.tools.nyaa")
RSS_BASE = "https://nyaa.si/"
# Nyaa RSS 네임스페이스
NYAA_NS = "https://nyaa.si/xmlns/nyaa"
@dataclass
class TorrentResult:
"""Nyaa 토렌트 검색 결과."""
title: str
torrent_url: str # .torrent 다운로드 URL
magnet_link: str # magnet:?xt=urn:btih:...
info_hash: str
size: str
seeders: int
leechers: int
downloads: int
category: str
pub_date: str
view_url: str # nyaa.si/view/... 페이지 URL
# 파싱된 정보
episode: Optional[int] = None
group: str = ""
def _parse_episode(title: str) -> Optional[int]:
"""제목에서 에피소드 번호 추출.
예: [ASW] Sousou no Frieren S2 - 07 [1080p ...] → 7
"""
# 패턴 1: "- 07" 또는 "- 07v2"
m = re.search(r'\s-\s(\d{1,4})(?:v\d)?(?:\s|\[|$)', title)
if m:
return int(m.group(1))
# 패턴 2: "S02E07"
m = re.search(r'[Ss]\d{1,2}[Ee](\d{1,4})', title)
if m:
return int(m.group(1))
# 패턴 3: "Episode 07"
m = re.search(r'[Ee]pisode\s*(\d{1,4})', title)
if m:
return int(m.group(1))
return None
def _parse_group(title: str) -> str:
"""제목에서 릴리스 그룹명 추출. 예: [ASW] → ASW"""
m = re.match(r'\[([^\]]+)\]', title)
return m.group(1) if m else ""
class NyaaClient:
"""Nyaa.si RSS 기반 토렌트 검색 클라이언트."""
def __init__(self, timeout: float = 15.0, default_suffix: str = "ASW HEVC"):
self._timeout = timeout
self.default_suffix = default_suffix
async def search(
self,
query: str,
category: str = "0_0",
filter_: int = 0,
use_default_suffix: bool = True,
) -> list[TorrentResult]:
"""RSS 기반 토렌트 검색.
Args:
query: 검색어
category: Nyaa 카테고리 (0_0=전체, 1_2=Anime English)
filter_: 필터 (0=없음, 2=trusted only)
use_default_suffix: True면 검색어에 default_suffix 자동 추가
"""
if use_default_suffix and self.default_suffix:
full_query = f"{query} {self.default_suffix}"
else:
full_query = query
url = f"{RSS_BASE}?page=rss&q={quote(full_query)}&c={category}&f={filter_}"
logger.info(f"Nyaa RSS 검색: {full_query}")
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.get(url)
resp.raise_for_status()
return self._parse_rss(resp.text)
def _parse_rss(self, xml_text: str) -> list[TorrentResult]:
"""RSS XML 파싱."""
root = ET.fromstring(xml_text)
results = []
for item in root.findall(".//item"):
title = item.findtext("title", "")
link = item.findtext("link", "")
guid = item.findtext("guid", "")
pub_date = item.findtext("pubDate", "")
info_hash = item.findtext(f"{{{NYAA_NS}}}infoHash", "")
seeders = int(item.findtext(f"{{{NYAA_NS}}}seeders", "0"))
leechers = int(item.findtext(f"{{{NYAA_NS}}}leechers", "0"))
downloads = int(item.findtext(f"{{{NYAA_NS}}}downloads", "0"))
size = item.findtext(f"{{{NYAA_NS}}}size", "")
category = item.findtext(f"{{{NYAA_NS}}}category", "")
# Magnet 링크 생성
magnet = f"magnet:?xt=urn:btih:{info_hash}" if info_hash else ""
results.append(TorrentResult(
title=title,
torrent_url=link,
magnet_link=magnet,
info_hash=info_hash,
size=size,
seeders=seeders,
leechers=leechers,
downloads=downloads,
category=category,
pub_date=pub_date,
view_url=guid,
episode=_parse_episode(title),
group=_parse_group(title),
))
logger.info(f"Nyaa 검색 결과: {len(results)}")
return results
async def search_anime(
self,
title: str,
episode: Optional[int] = None,
) -> list[TorrentResult]:
"""애니 제목으로 검색. 에피소드 지정 시 필터링."""
results = await self.search(title)
if episode is not None:
results = [r for r in results if r.episode == episode]
# 시더 수 내림차순 정렬
results.sort(key=lambda r: r.seeders, reverse=True)
return results

198
tools/qbit_client.py Normal file
View File

@@ -0,0 +1,198 @@
"""qBittorrent Web API 클라이언트.
API Docs: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)
"""
import httpx
import logging
from dataclasses import dataclass
from typing import Optional
import config
logger = logging.getLogger("variet.tools.qbit")
@dataclass
class TorrentStatus:
"""토렌트 상태."""
name: str
hash: str
progress: float # 0.0 ~ 1.0
state: str # downloading, uploading, pausedDL, ...
size: int # bytes
downloaded: int # bytes
upload_speed: int
download_speed: int
eta: int # seconds, -1 = unknown
save_path: str
category: str
class QBitClient:
"""qBittorrent Web API 클라이언트."""
def __init__(
self,
url: str = None,
username: str = None,
password: str = None,
):
self.url = (url or getattr(config, "QBIT_URL", "http://localhost:8080")).rstrip("/")
self.username = username or getattr(config, "QBIT_USERNAME", "admin")
self.password = password or getattr(config, "QBIT_PASSWORD", "")
self._sid: Optional[str] = None
async def login(self) -> bool:
"""로그인 → SID 쿠키 획득."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{self.url}/api/v2/auth/login",
data={"username": self.username, "password": self.password},
)
if resp.text.strip().lower() == "ok.":
self._sid = resp.cookies.get("SID")
logger.info("qBittorrent 로그인 성공")
return True
else:
logger.error(f"qBittorrent 로그인 실패: {resp.text}")
return False
def _cookies(self) -> dict:
return {"SID": self._sid} if self._sid else {}
async def _ensure_login(self):
if not self._sid:
if not await self.login():
raise RuntimeError("qBittorrent 로그인 실패")
async def add_torrent(
self,
magnet_or_url: str,
save_path: str = "",
category: str = "anime",
tags: str = "",
) -> bool:
"""토렌트 추가 (magnet 링크 또는 .torrent URL).
Args:
magnet_or_url: magnet 링크 또는 .torrent URL
save_path: 저장 경로 (미지정 시 qBittorrent 기본)
category: 카테고리
tags: 태그 (쉼표 구분)
"""
await self._ensure_login()
data = {
"urls": magnet_or_url,
"category": category,
}
if save_path:
data["savepath"] = save_path
if tags:
data["tags"] = tags
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{self.url}/api/v2/torrents/add",
data=data,
cookies=self._cookies(),
)
if resp.text.strip().lower() == "ok.":
logger.info(f"토렌트 추가 성공: {magnet_or_url[:60]}... → {save_path}")
return True
else:
logger.error(f"토렌트 추가 실패: {resp.text}")
return False
async def get_torrent_status(self, info_hash: str) -> Optional[TorrentStatus]:
"""특정 토렌트 상태 조회."""
await self._ensure_login()
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{self.url}/api/v2/torrents/info",
params={"hashes": info_hash},
cookies=self._cookies(),
)
resp.raise_for_status()
data = resp.json()
if not data:
return None
t = data[0]
return TorrentStatus(
name=t.get("name", ""),
hash=t.get("hash", ""),
progress=t.get("progress", 0),
state=t.get("state", ""),
size=t.get("total_size", 0),
downloaded=t.get("downloaded", 0),
upload_speed=t.get("upspeed", 0),
download_speed=t.get("dlspeed", 0),
eta=t.get("eta", -1),
save_path=t.get("save_path", ""),
category=t.get("category", ""),
)
async def list_torrents(self, category: str = "") -> list[TorrentStatus]:
"""토렌트 목록 조회."""
await self._ensure_login()
params = {}
if category:
params["category"] = category
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{self.url}/api/v2/torrents/info",
params=params,
cookies=self._cookies(),
)
resp.raise_for_status()
data = resp.json()
return [
TorrentStatus(
name=t.get("name", ""),
hash=t.get("hash", ""),
progress=t.get("progress", 0),
state=t.get("state", ""),
size=t.get("total_size", 0),
downloaded=t.get("downloaded", 0),
upload_speed=t.get("upspeed", 0),
download_speed=t.get("dlspeed", 0),
eta=t.get("eta", -1),
save_path=t.get("save_path", ""),
category=t.get("category", ""),
)
for t in data
]
async def test_connection(self) -> dict:
"""연결 테스트 — 버전 정보 반환."""
try:
await self._ensure_login()
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{self.url}/api/v2/app/version",
cookies=self._cookies(),
)
version = resp.text.strip()
resp2 = await client.get(
f"{self.url}/api/v2/app/webapiVersion",
cookies=self._cookies(),
)
api_version = resp2.text.strip()
return {
"connected": True,
"version": version,
"api_version": api_version,
"url": self.url,
}
except Exception as e:
return {"connected": False, "error": str(e), "url": self.url}

View File

@@ -0,0 +1,260 @@
"""자막 파일 다운로더 — 3개 플랫폼 파서.
지원 플랫폼:
1. Google Drive (Blogspot 제작자 대부분)
2. Tistory (Kakao CDN 직접 다운로드)
3. Naver Blog (네이티브 첨부파일)
"""
import httpx
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from urllib.parse import unquote
logger = logging.getLogger("variet.tools.subtitle")
@dataclass
class SubtitleFile:
"""다운로드된/발견된 자막 파일 정보."""
filename: str
download_url: str
platform: str # google_drive, tistory, naver
episode: Optional[int] = None
local_path: Optional[str] = None # 다운로드 후 로컬 경로
def _extract_episode_from_text(text: str) -> Optional[int]:
"""텍스트에서 화수 추출."""
# "9화", "09화", "9 화"
m = re.search(r'(\d{1,4})\s*화', text)
if m:
return int(m.group(1))
# "- 09"
m = re.search(r'[-]\s*(\d{1,4})(?:\s|$|\.)', text)
if m:
return int(m.group(1))
# "Episode 9", "EP09"
m = re.search(r'(?:EP|Episode)\s*(\d{1,4})', text, re.IGNORECASE)
if m:
return int(m.group(1))
return None
# ──────────────────────────────────────────────
# 1. Google Drive 파서
# ──────────────────────────────────────────────
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
"""
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:
m = re.search(r'/d/([a-zA-Z0-9_-]+)/', url)
if not m:
continue
file_id = m.group(1)
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,
platform="google_drive",
episode=episode,
))
# 매칭되지 않은 bare ID도 추가
for file_id in matches:
if file_id not in seen_ids:
seen_ids.add(file_id)
results.append(SubtitleFile(
filename=f"subtitle_{file_id}",
download_url=f"https://drive.google.com/uc?id={file_id}&export=download",
platform="google_drive",
))
return results
# ──────────────────────────────────────────────
# 2. Tistory 파서
# ──────────────────────────────────────────────
def parse_tistory_links(html: str) -> list[SubtitleFile]:
"""HTML에서 Tistory/Kakao CDN 다운로드 링크 추출.
패턴: blog.kakaocdn.net/dna/.../filename.zip?...
"""
pattern = r'(https://blog\.kakaocdn\.net/[^"]+\.(zip|ass|srt|ssa|sub)[^"]*)'
matches = re.findall(pattern, html, re.IGNORECASE)
results = []
for url, ext in matches:
# URL에서 파일명 추출
name_match = re.search(r'/([^/?]+\.' + ext + r')', unquote(url))
filename = name_match.group(1) if name_match else f"subtitle.{ext}"
episode = _extract_episode_from_text(filename)
results.append(SubtitleFile(
filename=filename,
download_url=url,
platform="tistory",
episode=episode,
))
return results
# ──────────────────────────────────────────────
# 3. Naver Blog 파서
# ──────────────────────────────────────────────
def parse_naver_links(html: str) -> list[SubtitleFile]:
"""HTML에서 Naver Blog 첨부파일 다운로드 링크 추출.
패턴: download.blog.naver.com/... 또는 blogfiles.pstatic.net/...
"""
results = []
# Naver 파일 다운로드 버튼
# <a class="se-file-save-button" href="https://download.blog.naver.com/..." ...>
file_pattern = r'href="(https://(?:download\.blog\.naver\.com|blogfiles\.pstatic\.net)/[^"]+)"'
matches = re.findall(file_pattern, html)
for url in matches:
# URL에서 파일명 추출
decoded = unquote(url)
name_match = re.search(r'/([^/?]+\.(?:zip|ass|srt|ssa|sub|7z))', decoded, re.IGNORECASE)
filename = name_match.group(1) if name_match else "subtitle_naver"
episode = _extract_episode_from_text(filename)
results.append(SubtitleFile(
filename=filename,
download_url=url,
platform="naver",
episode=episode,
))
return results
# ──────────────────────────────────────────────
# 통합 다운로더
# ──────────────────────────────────────────────
class SubtitleDownloader:
"""자막 파일 검색 및 다운로드."""
def __init__(self, download_dir: str = ""):
self.download_dir = Path(download_dir) if download_dir else Path.cwd() / "subtitles"
async def fetch_page(self, url: str) -> str:
"""웹 페이지 HTML 가져오기."""
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept-Language": "ko-KR,ko;q=0.9",
}
# Naver Blog iframe bypass
if "blog.naver.com" in url and "PostView" not in url:
# blog.naver.com/{blogId}/{logNo} → PostView URL
m = re.search(r'blog\.naver\.com/([^/]+)/(\d+)', url)
if m:
url = f"https://blog.naver.com/PostView.naver?blogId={m.group(1)}&logNo={m.group(2)}"
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
return resp.text
async def find_subtitles(self, url: str) -> list[SubtitleFile]:
"""URL에서 자막 파일 링크 자동 탐지."""
html = await self.fetch_page(url)
results = []
# 플랫폼 자동 감지 후 파싱
if "drive.google.com" in html:
results.extend(parse_google_drive_links(html))
if "blog.kakaocdn.net" in html:
results.extend(parse_tistory_links(html))
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)
seen_urls = {r.download_url for r in results}
for gurl in generic:
if gurl not in seen_urls:
filename = gurl.split("/")[-1].split("?")[0]
results.append(SubtitleFile(
filename=unquote(filename),
download_url=gurl,
platform="generic",
episode=_extract_episode_from_text(filename),
))
logger.info(f"자막 {len(results)}건 발견: {url}")
return results
async def download_file(
self,
sub: SubtitleFile,
save_dir: Optional[str] = None,
) -> str:
"""자막 파일 다운로드 → 로컬 저장. 저장 경로 반환."""
target_dir = Path(save_dir) if save_dir else self.download_dir
target_dir.mkdir(parents=True, exist_ok=True)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}
# Naver 리퍼러 헤더
if sub.platform == "naver":
headers["Referer"] = "https://blog.naver.com/"
async with httpx.AsyncClient(
timeout=60, follow_redirects=True, max_redirects=5
) as client:
resp = await client.get(sub.download_url, headers=headers)
resp.raise_for_status()
# Content-Disposition에서 실제 파일명 추출
cd = resp.headers.get("content-disposition", "")
if "filename" in 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)
sub.local_path = str(filepath)
logger.info(f"자막 다운로드 완료: {filepath}")
return str(filepath)

212
tools/title_matcher.py Normal file
View File

@@ -0,0 +1,212 @@
"""제목 매칭 + NAS 폴더명 생성.
Anissia(한글) ↔ Nyaa(영어/로마자) 제목을 매칭하고,
NAS 저장 폴더명을 [yy_q분기]제목 형식으로 생성합니다.
"""
import re
import logging
import unicodedata
from difflib import SequenceMatcher
from typing import Optional
logger = logging.getLogger("variet.tools.matcher")
# ──────────────────────────────────────────────
# 일어 → 로마자 변환 테이블 (히라가나/카타카나)
# ──────────────────────────────────────────────
_KANA_ROMAJI = {
# 히라가나
'': 'a', '': 'i', '': 'u', '': 'e', '': 'o',
'': 'ka', '': 'ki', '': 'ku', '': 'ke', '': 'ko',
'': 'sa', '': 'shi', '': 'su', '': 'se', '': 'so',
'': 'ta', '': 'chi', '': 'tsu', '': 'te', '': 'to',
'': 'na', '': 'ni', '': 'nu', '': 'ne', '': 'no',
'': 'ha', '': 'hi', '': 'fu', '': 'he', '': 'ho',
'': 'ma', '': 'mi', '': 'mu', '': 'me', '': 'mo',
'': 'ya', '': 'yu', '': 'yo',
'': 'ra', '': 'ri', '': 'ru', '': 're', '': 'ro',
'': 'wa', '': 'wo', '': 'n',
'': 'ga', '': 'gi', '': 'gu', '': 'ge', '': 'go',
'': 'za', '': 'ji', '': 'zu', '': 'ze', '': 'zo',
'': 'da', '': 'di', '': 'du', '': 'de', '': 'do',
'': 'ba', '': 'bi', '': 'bu', '': 'be', '': 'bo',
'': 'pa', '': 'pi', '': 'pu', '': 'pe', '': 'po',
'きゃ': 'kya', 'きゅ': 'kyu', 'きょ': 'kyo',
'しゃ': 'sha', 'しゅ': 'shu', 'しょ': 'sho',
'ちゃ': 'cha', 'ちゅ': 'chu', 'ちょ': 'cho',
'にゃ': 'nya', 'にゅ': 'nyu', 'にょ': 'nyo',
'ひゃ': 'hya', 'ひゅ': 'hyu', 'ひょ': 'hyo',
'みゃ': 'mya', 'みゅ': 'myu', 'みょ': 'myo',
'りゃ': 'rya', 'りゅ': 'ryu', 'りょ': 'ryo',
'ぎゃ': 'gya', 'ぎゅ': 'gyu', 'ぎょ': 'gyo',
'じゃ': 'ja', 'じゅ': 'ju', 'じょ': 'jo',
'びゃ': 'bya', 'びゅ': 'byu', 'びょ': 'byo',
'ぴゃ': 'pya', 'ぴゅ': 'pyu', 'ぴょ': 'pyo',
'': '', # 촉음 (다음 자음 반복)
}
# 카타카나 → 히라가나 변환 오프셋
_KATA_OFFSET = ord('') - ord('')
def _kata_to_hira(text: str) -> str:
"""카타카나를 히라가나로 변환."""
result = []
for ch in text:
cp = ord(ch)
if 0x30A0 <= cp <= 0x30FF: # 카타카나 범위
result.append(chr(cp - _KATA_OFFSET))
else:
result.append(ch)
return "".join(result)
def japanese_to_romaji(text: str) -> str:
"""일본어 텍스트를 로마자로 근사 변환."""
text = _kata_to_hira(text)
result = []
i = 0
while i < len(text):
# 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:
result.append(next_romaji[0])
else:
result.append(romaji)
i += 1
elif text[i] == '': # 장음
i += 1
else:
# 한자, 영어, 숫자 등 → 그대로
result.append(text[i])
i += 1
return "".join(result)
def normalize_title(title: str) -> str:
"""제목 비교용 정규화: 소문자 + 특수문자 제거 + 공백 정리."""
title = title.lower().strip()
# 기수 표기 정규화: 2nd → 2, S2 → 2
title = re.sub(r'\b(\d+)(?:st|nd|rd|th)\b', r'\1', title)
title = re.sub(r'\bs(\d+)\b', r'\1', title)
title = re.sub(r'season\s*(\d+)', r'\1', title)
title = re.sub(r'(\d+)\s*기', r'\1', title)
# 특수문자 제거
title = re.sub(r'[^\w\s]', ' ', title)
title = re.sub(r'\s+', ' ', title).strip()
return title
def title_similarity(title_a: str, title_b: str) -> float:
"""두 제목 간 유사도 (0.0 ~ 1.0)."""
a = normalize_title(title_a)
b = normalize_title(title_b)
return SequenceMatcher(None, a, b).ratio()
def match_titles(
korean_title: str,
original_title: str,
nyaa_results: list,
threshold: float = 0.4,
) -> list:
"""Anissia 제목과 Nyaa 검색 결과 매칭.
Args:
korean_title: 한글 제목 (Anissia subject)
original_title: 원어 제목 (Anissia originalSubject)
nyaa_results: TorrentResult 리스트
threshold: 최소 유사도
Returns:
매칭된 TorrentResult 리스트 (유사도 내림차순)
"""
# 원제의 로마자 변환
romaji = japanese_to_romaji(original_title)
scored = []
for result in nyaa_results:
# Nyaa 제목에서 그룹태그 제거: [ASW] Title - 07 [...] → Title
clean_title = re.sub(r'\[[^\]]*\]', '', result.title).strip()
clean_title = re.sub(r'\s*-\s*\d+.*$', '', clean_title).strip()
# 유사도 계산 (로마자 vs Nyaa 제목)
sim_romaji = title_similarity(romaji, clean_title)
# 한글 vs Nyaa (일부 자막 포함 릴리스인 경우)
sim_korean = title_similarity(korean_title, clean_title)
# 원제 그대로 vs Nyaa
sim_original = title_similarity(original_title, clean_title)
best_sim = max(sim_romaji, sim_korean, sim_original)
if best_sim >= threshold:
scored.append((best_sim, result))
# 유사도 내림차순 정렬
scored.sort(key=lambda x: x[0], reverse=True)
return [r for _, r in scored]
# ──────────────────────────────────────────────
# NAS 폴더명 생성
# ──────────────────────────────────────────────
def get_quarter(date_str: str) -> tuple[int, int]:
"""날짜 문자열에서 연도와 분기 추출.
Args:
date_str: "2026-01-11" 형식
Returns:
(year, quarter): (26, 1)
"""
if not date_str:
from datetime import date
today = date.today()
year = today.year % 100
quarter = (today.month - 1) // 3 + 1
return year, quarter
parts = date_str.split("-")
year = int(parts[0]) % 100
month = int(parts[1])
quarter = (month - 1) // 3 + 1
return year, quarter
def make_nas_folder_name(title: str, start_date: str = "") -> str:
"""NAS 저장 폴더명 생성.
예: [26_1분기]장송의프리렌2기
"""
year, quarter = get_quarter(start_date)
# 제목에서 폴더명에 쓸 수 없는 문자 제거
safe_title = re.sub(r'[<>:"/\\|?*]', '', title)
safe_title = safe_title.strip()
return f"[{year:02d}_{quarter}분기]{safe_title}"
def rename_subtitle_to_video(
video_filename: str,
subtitle_ext: str = ".ass",
) -> str:
"""영상 파일명에 맞게 자막 파일명 생성.
예: [ASW] Sousou no Frieren S2 - 07.mkv → [ASW] Sousou no Frieren S2 - 07.ass
"""
stem = re.sub(r'\.[^.]+$', '', video_filename)
return f"{stem}{subtitle_ext}"

View File

@@ -1,8 +1,8 @@
{ {
"1479451607610691726": { "5608566207": {
"name": "test_1", "name": "test_1_orphan_20260307",
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1", "path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1_orphan_20260307",
"channel_id": 1479451607610691726, "channel_id": 0,
"git": { "git": {
"url": "", "url": "",
"token": "", "token": "",
@@ -16,10 +16,27 @@
}, },
"docs_path": "docs/wiki" "docs_path": "docs/wiki"
}, },
"1479489442249969796": { "8350378037": {
"name": "test_2", "name": "test_2_orphan_20260307",
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_2", "path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_2_orphan_20260307",
"channel_id": 1479489442249969796, "channel_id": 0,
"git": {
"url": "",
"token": "",
"repo": "",
"branch": "main"
},
"vikunja": {
"url": "",
"token": "",
"project_id": 0
},
"docs_path": "docs/wiki"
},
"1479610776502403186": {
"name": "test_1",
"path": "c:\\Users\\Certes\\Desktop\\VW_Proj\\test_1",
"channel_id": 1479610776502403186,
"git": { "git": {
"url": "", "url": "",
"token": "", "token": "",