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