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

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

View File

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