feat(anime): 자막/토렌트 파이프라인 대폭 개선

- Blogspot Atom Feed API로 전체 에피소드 자막 URL 발견
- AniList prequel 체인 기반 시즌 에피소드 오프셋 자동 감지
- Nyaa S-tag 감지 → 절대/시즌 번호 체계 자동 판별
- 기존 자막 에피소드 스킵 (URL 페치 전 pre-skip)
- 오프셋 적용 자막 리네임 (시즌번호→절대번호 매칭)
- ASW HEVC 토렌트 우선 정렬 (truncation 방지)
- 토렌트 완료 대기 → 자동 삭제 라이프사이클
- 중복 자막 자동 삭제
- .smi 자막 확장자 지원
This commit is contained in:
2026-03-15 18:23:57 +09:00
parent 9f74812710
commit 3618387b8e
8 changed files with 1386 additions and 532 deletions

View File

@@ -142,7 +142,7 @@ async def _agent_call(text: str, history: str, project_path: str) -> str:
)
response = await gemini.call_agent(
"agent", context, cwd=project_path, timeout=300,
"agent", context, cwd=project_path, timeout=1200,
)
return response
@@ -291,6 +291,39 @@ async def on_message(message: discord.Message):
)
async with message.channel.typing():
# 1단계: NLU 빠른 분류 (Gemini text 모드, ~5초)
intent = await _classify_intent(user_text, ws.path)
logger.info(f"NLU 분류: '{user_text[:50]}'{intent}")
# 2단계: anime 의도면 직접 실행 (Gemini agent 우회)
if intent and intent.get("mode") == "anime":
if progress_msg:
await progress_msg.edit(embed=discord.Embed(
title="📦 애니 작업 실행 중...",
description=f"```{user_text[:200]}```",
color=0x3498DB,
))
await _handle_anime(message, intent)
if progress_msg:
try: await progress_msg.delete()
except Exception: pass
return
# 2-2단계: research 의도면 조사+위키 등록
if intent and intent.get("mode") == "research":
if progress_msg:
await progress_msg.edit(embed=discord.Embed(
title="🔍 리서치 진행 중...",
description=f"주제: **{intent.get('topic', user_text[:50])}**",
color=0x9B59B6,
))
await _handle_research(message, intent, ws.path)
if progress_msg:
try: await progress_msg.delete()
except Exception: pass
return
# 3단계: 일반 → Gemini agent 모드
history = await _get_channel_history(message.channel, limit=10)
response = await _agent_call(user_text, history, ws.path)
@@ -337,7 +370,47 @@ async def on_message(message: discord.Message):
# ──────────────────────────────────────────────
# Anime 핸들러 (AI가 분류한 의도 실행)
# NLU 빠른 분류 (Gemini text 모드)
# ──────────────────────────────────────────────
_NLU_PROMPT = """\
사용자 메시지를 분류하여 JSON으로 응답하세요. 반드시 JSON만 출력.
분류:
- 애니메이션 다운로드/검색/상태 → mode: "anime"
- 조사+위키 등록/정리 요청 → mode: "research"
- 그 외 → mode: "chat"
anime 필드: action ("batch"|"download"|"search"|"status"|"schedule"|"list"), title, episode, filter
research 필드:
- action: "research" (조사+등록), "organize" (기존 정리), "update" (페이지 수정)
- topic: 주제
예시:
"이번분기 애니 자막 다운받아줘"{"mode":"anime","action":"batch","filter":"자막"}
"FX Forward 조사해서 위키에 정리해줘"{"mode":"research","action":"research","topic":"FX Forward"}
"위키에 서버 관련 내용 정리해줘"{"mode":"research","action":"organize","topic":"서버"}
"GraphQL이 뭐야?"{"mode":"chat"}
"""
async def _classify_intent(text: str, project_path: str) -> dict | None:
"""Gemini text 모드로 빠른 의도 분류 (~5초)."""
try:
gemini = GeminiCaller(project_path)
raw = await gemini.call("unified", f"{_NLU_PROMPT}\n\n사용자: {text}", timeout=30)
import json as _json
m = re.search(r'\{[^}]+\}', raw, re.DOTALL)
if m:
return _json.loads(m.group(0))
except Exception as e:
logger.warning(f"NLU 분류 실패 (agent fallback): {e}")
return None
# ──────────────────────────────────────────────
# Anime 핸들러 (NLU 분류 결과로 직접 실행)
# ──────────────────────────────────────────────
async def _handle_anime(message: discord.Message, parsed: dict):
@@ -362,6 +435,9 @@ async def _handle_anime(message: discord.Message, parsed: dict):
elif action == "list":
await _anime_list(message, pipeline, filter_str)
elif action == "batch":
await _anime_batch_direct(message, filter_str or "자막 배치")
elif action in ("download", "sub_only", "video_only"):
# 필터에 batch 조건이 있으면 복수 다운로드
if not title and filter_str:
@@ -437,66 +513,236 @@ async def _anime_download(message, pipeline, title, mode, episode):
async def _anime_batch(message, pipeline, action, filter_str):
"""필터 기반 복수 애니 다운로드 (이번분기 자막있는것 등)."""
embed = discord.Embed(title="⏳ 편성표 분석 중...", description="조건에 맞는 애니 검색", color=0xF39C12)
"""필터 기반 복수 애니 다운로드 — batch_download() 사용."""
await _anime_batch_direct(message, f"{filter_str} {action}")
async def _anime_batch_direct(message, user_text: str):
"""애니 배치 다운로드 — Gemini 없이 직접 실행."""
from tools.anime_pipeline import AnimePipeline
t = user_text.lower()
mode = "auto"
if "자막만" in t or "sub_only" in t:
mode = "sub_only"
elif "영상만" in t or "video_only" in t:
mode = "video_only"
sub_filter = "자막" in t or "sub:yes" in t # 자막 언급 시 자막 필터 활성
embed = discord.Embed(
title="📦 배치 다운로드 시작",
description=f"모드: `{mode}` | 자막 필터: `{'ON' if sub_filter else 'OFF'}`\n⏳ NAS 스캔 + Anissia 로딩 중...",
color=0xF39C12,
)
status_msg = await message.channel.send(embed=embed)
# 전체 편성표 로드
all_anime = await pipeline.anissia.get_all_schedule()
pipeline = AnimePipeline()
# 필터 적용
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:
try:
results = await pipeline.batch_download(mode=mode, sub_filter=sub_filter)
except Exception as e:
logger.error(f"배치 다운로드 오류: {e}", exc_info=True)
embed.title = "❌ 배치 다운로드 실패"
embed.description = str(e)[:500]
embed.color = 0xE74C3C
await status_msg.edit(embed=embed)
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
# 결과 정리
success = [r for r in results if r.success]
lines = []
for r in results:
icon = "" if r.success else ""
title = r.anime.subject if r.anime else "?"
detail = ""
if r.torrent_added:
detail += " 🎬토렌트추가"
if r.subtitles:
detail += f" 📝자막{len(r.subtitles)}"
if r.errors:
detail += f" ⚠️{r.errors[0][:50]}"
lines.append(f"{icon} {title}{detail}")
result_embed = discord.Embed(
title=f"📊 배치 다운로드 결과",
description=f"✅ 성공: {success_count}\n⚠️ 실패/보류: {fail_count}",
color=0x2ECC71 if success_count > 0 else 0xF39C12,
embed.title = f"📊 배치 결과: {len(success)}/{len(results)}건 성공"
embed.description = "\n".join(lines) or "처리할 애니가 없습니다."
embed.color = 0x2ECC71 if success else 0xF39C12
await status_msg.edit(embed=embed)
# ──────────────────────────────────────────────
# Research 핸들러 (조사+위키 등록 / 기존 정리)
# ──────────────────────────────────────────────
async def _handle_research(message: discord.Message, parsed: dict, project_path: str):
"""리서치 요청 처리 — 조사 후 위키 등록 또는 기존 위키 정리."""
from tools.wiki_client import WikiClient
action = parsed.get("action", "research")
topic = parsed.get("topic", "")
wiki = WikiClient()
if not topic:
await message.reply("🔍 어떤 주제를 조사할까요?")
return
try:
if action == "research":
await _research_and_publish(message, topic, wiki, project_path)
elif action == "organize":
await _organize_wiki(message, topic, wiki, project_path)
elif action == "update":
await _research_and_publish(message, topic, wiki, project_path)
else:
await message.reply(f"❓ 알 수 없는 리서치 액션: {action}")
except Exception as e:
logger.error(f"리서치 핸들러 오류: {e}", exc_info=True)
await message.reply(f"❌ 리서치 오류: {str(e)[:300]}")
async def _research_and_publish(
message: discord.Message, topic: str,
wiki, project_path: str,
):
"""주제 조사 → 위키 페이지 등록."""
from tools.wiki_client import WikiClient
status_msg = await message.channel.send(
embed=discord.Embed(
title="🔍 조사 중...",
description=f"**{topic}**\n\nGemini가 웹 검색 + 자료를 수집하고 있습니다.",
color=0x9B59B6,
)
)
await message.channel.send(embed=result_embed)
# Gemini agent로 조사 (google_web_search 자동 사용)
gemini = GeminiCaller(project_path)
research_prompt = (
f"다음 주제에 대해 깊이 있게 조사하고, 위키 페이지용 마크다운으로 정리하세요.\n\n"
f"주제: {topic}\n\n"
f"요구사항:\n"
f"1. 반드시 웹 검색을 통해 최신 정보를 확인하세요\n"
f"2. 핵심 개념, 장단점, 실무 활용법을 포함하세요\n"
f"3. 출처 URL을 반드시 포함하세요\n"
f"4. 마크다운 형식으로 작성하세요 (# 제목부터)\n"
f"5. 한국어로 작성하세요\n"
)
try:
content = await gemini.call_agent(
"agent", research_prompt, cwd=project_path, timeout=300,
)
except GeminiCallError as e:
await status_msg.edit(embed=discord.Embed(
title="❌ 조사 실패", description=str(e)[:500], color=0xE74C3C,
))
return
if not content or len(content) < 50:
await status_msg.edit(embed=discord.Embed(
title="⚠️ 결과 부족", description="충분한 정보를 수집하지 못했습니다.",
color=0xF39C12,
))
return
# 위키 등록
slug = WikiClient.slugify(topic)
path = f"research/{slug}"
page = await wiki.upsert_page(path, topic, content, description=f"리서치: {topic}")
# 대시보드 갱신
await wiki.update_dashboard()
await status_msg.edit(embed=discord.Embed(
title=f"✅ 위키 등록 완료: {topic}",
description=(
f"📄 [{path}](https://wiki.variet.net/{path})\n"
f"📊 대시보드 갱신됨\n"
f"📝 {len(content)}자 작성"
),
color=0x2ECC71,
))
async def _organize_wiki(
message: discord.Message, topic: str,
wiki, project_path: str,
):
"""기존 위키 페이지들을 주제별로 재정리."""
status_msg = await message.channel.send(
embed=discord.Embed(
title="📋 위키 분석 중...",
description=f"**{topic}** 관련 페이지를 수집하고 있습니다.",
color=0x9B59B6,
)
)
# 관련 페이지 수집
all_pages = await wiki.list_pages()
topic_lower = topic.lower()
related = [p for p in all_pages if topic_lower in p.title.lower() or topic_lower in p.path.lower()]
if not related:
await status_msg.edit(embed=discord.Embed(
title="⚠️ 관련 페이지 없음",
description=f"'{topic}' 관련 위키 페이지를 찾지 못했습니다.",
color=0xF39C12,
))
return
# 각 페이지 내용 수집
contents = []
for p in related:
full_page = await wiki.get_page(p.id)
contents.append(f"## 페이지: {full_page.title} (/{full_page.path})\n{full_page.content}\n")
combined = "\n---\n".join(contents)
await status_msg.edit(embed=discord.Embed(
title="🔄 재정리 중...",
description=f"{len(related)}개 페이지를 Gemini가 통합 정리합니다.",
color=0x9B59B6,
))
# Gemini로 통합 정리
gemini = GeminiCaller(project_path)
organize_prompt = (
f"다음은 '{topic}' 관련 기존 위키 페이지들입니다.\n"
f"이 내용을 하나의 통합된 위키 페이지로 재정리하세요.\n\n"
f"요구사항:\n"
f"1. 중복 제거, 논리적 구조화\n"
f"2. 누락된 정보가 있으면 웹 검색으로 보완\n"
f"3. 마크다운 형식, 한국어\n\n"
f"=== 기존 페이지들 ===\n{combined[:15000]}"
)
try:
content = await gemini.call_agent(
"agent", organize_prompt, cwd=project_path, timeout=300,
)
except GeminiCallError as e:
await status_msg.edit(embed=discord.Embed(
title="❌ 정리 실패", description=str(e)[:500], color=0xE74C3C,
))
return
# 통합 페이지 등록
from tools.wiki_client import WikiClient
slug = WikiClient.slugify(topic)
path = f"research/{slug}"
page = await wiki.upsert_page(path, f"{topic} (통합)", content, description=f"통합 정리: {topic}")
await wiki.update_dashboard()
await status_msg.edit(embed=discord.Embed(
title=f"✅ 위키 정리 완료: {topic}",
description=(
f"📄 [{path}](https://wiki.variet.net/{path})\n"
f"🔗 원본 {len(related)}개 → 통합 1개\n"
f"📝 {len(content)}"
),
color=0x2ECC71,
))
async def _anime_schedule(message, pipeline, filter_str):