refactor: enhance Optuna backtesting framework, optimize orderbook filtering, and update database management utilities.
This commit is contained in:
130
mm_butler.py
130
mm_butler.py
@@ -795,6 +795,131 @@ def handler_apply(args: str, db: TradeDB, hub: "CommandHub") -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# 유튜브 음원 추출 핸들러 (yt-dlp 연동)
|
||||
# ==================================================================
|
||||
# 사용법: !유튜브 [유튜브URL] [시작시간] [종료시간]
|
||||
# 예시: !유튜브 https://youtu.be/XXXX 00:01:00 00:02:30
|
||||
# 저장경로: /mnt/nc/share_drive/mp3/
|
||||
|
||||
_YOUTUBE_OUTPUT_DIR = "/mnt/nc/share_drive/mp3"
|
||||
_YOUTUBE_USAGE = "사용법: `!유튜브 [유튜브URL] [시작시간] [종료시간]`\n예시: `!유튜브 https://youtu.be/XXXX 00:01:00 00:02:30`"
|
||||
|
||||
|
||||
def handler_youtube(args: str, db: TradeDB, hub: "CommandHub") -> str:
|
||||
"""!유튜브 [URL] [시작] [종료] — 유튜브 구간 MP3 추출 후 공유드라이브에 저장."""
|
||||
import time as _time
|
||||
|
||||
parts = (args or "").strip().split()
|
||||
if len(parts) != 3:
|
||||
return (
|
||||
f"❌ **입력 오류**: 인자 3개가 필요합니다.\n"
|
||||
f"{_YOUTUBE_USAGE}"
|
||||
)
|
||||
|
||||
url, start_time, end_time = parts
|
||||
|
||||
# 기본 URL 형식 체크 (command injection 방지용 간단 검증)
|
||||
if not (url.startswith("https://") or url.startswith("http://")):
|
||||
return "❌ URL은 http:// 또는 https://로 시작해야 합니다."
|
||||
|
||||
# 시간 형식 체크 (HH:MM:SS 또는 MM:SS 또는 초 단위 정수)
|
||||
import re as _re
|
||||
_time_pattern = _re.compile(r'^(\d{1,2}:)?\d{1,2}:\d{2}(\.\d+)?$')
|
||||
for t_val, t_name in ((start_time, "시작시간"), (end_time, "종료시간")):
|
||||
if not _time_pattern.match(t_val):
|
||||
return f"❌ {t_name} 형식 오류: `{t_val}`\n`HH:MM:SS` 또는 `MM:SS` 형식으로 입력하세요."
|
||||
|
||||
# 출력 파일명 (타임스탬프로 중복 방지)
|
||||
ts = int(_time.time())
|
||||
safe_start = start_time.replace(":", "")
|
||||
safe_end = end_time.replace(":", "")
|
||||
output_filename = f"hymn_{safe_start}_{safe_end}_{ts}.mp3"
|
||||
|
||||
# 1. 독립된 로컬 폴더에 먼저 다운로드 (동시 요청 꼬임 방지)
|
||||
import os as _os
|
||||
import shutil as _shutil
|
||||
local_tmp_dir = f"/home/hoon/mp3/tmp_{ts}"
|
||||
_os.makedirs(local_tmp_dir, exist_ok=True)
|
||||
|
||||
# 영상 제목을 반영한 파일명 템플릿
|
||||
output_template = f"{local_tmp_dir}/%(title)s_[{safe_start}-{safe_end}].%(ext)s"
|
||||
|
||||
command = [
|
||||
"yt-dlp",
|
||||
"--no-progress", # 제어 문자 출력 방지
|
||||
"-x", # 오디오만 추출
|
||||
"--audio-format", "mp3", # MP3 포맷 지정
|
||||
"--audio-quality", "0", # 최고 음질
|
||||
"--no-playlist", # 플레이리스트 방지
|
||||
"--download-sections", f"*{start_time}-{end_time}", # 구간 지정
|
||||
"-o", output_template, # 임시 로컬 경로로 출력
|
||||
url, # 타겟 유튜브 URL
|
||||
]
|
||||
|
||||
logger.info("[유튜브추출] 시작: %s / %s~%s → (임시) %s", url, start_time, end_time, local_tmp_dir)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command, check=True, capture_output=True, text=True, timeout=300
|
||||
)
|
||||
logger.info("[유튜브추출] yt-dlp 정상 종료")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("[유튜브추출] 타임아웃 (5분 초과)")
|
||||
_shutil.rmtree(local_tmp_dir, ignore_errors=True)
|
||||
return "❌ **추출 실패**: 5분 제한 초과."
|
||||
except subprocess.CalledProcessError as e:
|
||||
err_msg = (e.stderr or "") + (e.stdout or "")
|
||||
err_msg = err_msg.replace('\r', '\n')
|
||||
err_msg = ''.join(c for c in err_msg if c.isprintable() or c in '\n\t')
|
||||
logger.error("[유튜브추출] yt-dlp 오류: %s", err_msg)
|
||||
err_snippet = err_msg[-500:].strip() or "알 수 없는 오류"
|
||||
_shutil.rmtree(local_tmp_dir, ignore_errors=True)
|
||||
return f"❌ **추출 실패**: yt-dlp 오류.\n```text\n{err_snippet}\n```"
|
||||
except Exception as e:
|
||||
logger.error("[유튜브추출] 예외: %s", e)
|
||||
_shutil.rmtree(local_tmp_dir, ignore_errors=True)
|
||||
return f"❌ **추출 실패**: 예외 발생 — {e}"
|
||||
|
||||
# 실제 다운로드된 파일명 찾기 (yt-dlp가 생성한 최종 파일)
|
||||
downloaded_files = _os.listdir(local_tmp_dir)
|
||||
if not downloaded_files:
|
||||
_shutil.rmtree(local_tmp_dir, ignore_errors=True)
|
||||
return "❌ **추출 실패**: 파일이 생성되지 않았습니다."
|
||||
|
||||
actual_filename = downloaded_files[0]
|
||||
actual_local_path = f"{local_tmp_dir}/{actual_filename}"
|
||||
|
||||
# rclone으로 클라우드 드라이브에 파일 이동
|
||||
rclone_cmd = [
|
||||
"rclone", "move", actual_local_path, "nc:/share_drive/mp3/"
|
||||
]
|
||||
logger.info("[유튜브추출] rclone move 시작: %s", rclone_cmd)
|
||||
try:
|
||||
rclone_res = subprocess.run(rclone_cmd, check=True, capture_output=True, text=True)
|
||||
logger.info("[유튜브추출] rclone 완료: %s", rclone_res.stdout)
|
||||
except subprocess.CalledProcessError as e:
|
||||
err_msg = (e.stderr or "") + (e.stdout or "")
|
||||
err_msg = err_msg.replace('\r', '\n')
|
||||
err_msg = ''.join(c for c in err_msg if c.isprintable() or c in '\n\t')
|
||||
logger.error("[유튜브추출] rclone 이동 실패: %s", err_msg)
|
||||
err_snippet = err_msg[-500:].strip() or "알 수 없는 오류"
|
||||
return f"❌ **이동 실패**: rclone 이동 중 오류 발생.\n```text\n{err_snippet}\n```"
|
||||
except Exception as e:
|
||||
logger.error("[유튜브추출] rclone 예외: %s", e)
|
||||
return f"❌ **이동 실패**: rclone 예외 발생 — {e}"
|
||||
|
||||
# 이동 완료 후 껍데기 폴더 삭제
|
||||
_shutil.rmtree(local_tmp_dir, ignore_errors=True)
|
||||
|
||||
return (
|
||||
f"🎵 **유튜브 추출 및 업로드 완료!**\n"
|
||||
f"- 구간: `{start_time}` ~ `{end_time}`\n"
|
||||
f"- 파일: `{actual_filename}`\n"
|
||||
f"- Nextcloud에서 `share_drive/mp3/` 폴더를 확인하세요 🎶"
|
||||
)
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# 봇 제어 핸들러 (systemctl + journalctl) — 화이트리스트 방식
|
||||
# ==================================================================
|
||||
@@ -1037,6 +1162,8 @@ class CommandHub:
|
||||
self.register_command("봇시작", handler_bot_start, "메인 봇 시작")
|
||||
self.register_command("봇상태", handler_bot_status, "메인 봇 상태 + status 출력 25줄")
|
||||
self.register_command("봇로그", handler_bot_log, "메인 봇 최근 로그 (예: !봇로그 200, 최대 500)")
|
||||
# ── 유튜브 음원 추출 ───────────────────────────────────────────────
|
||||
self.register_command("유튜브", handler_youtube, "유튜브 구간 MP3 추출 → /mnt/nc/share_drive/mp3 저장 (예: !유튜브 [URL] [시작] [종료])")
|
||||
|
||||
def register_command(self, cmd: str, handler: Callable, description: str = "") -> None:
|
||||
"""외부에서 동적으로 명령어 추가 가능."""
|
||||
@@ -1168,7 +1295,8 @@ class CommandHub:
|
||||
msg_text = (p.get("message") or "").strip()
|
||||
reply = self._dispatch(msg_text, p.get("id", ""))
|
||||
if reply:
|
||||
self._post_reply(reply, root_id=p.get("id"))
|
||||
r_id = p.get("root_id") or p.get("id")
|
||||
self._post_reply(reply, root_id=r_id)
|
||||
logger.info("명령 응답 전송: %s -> %s…", msg_text[:30], reply[:60])
|
||||
|
||||
self.db.set_kv(self.KV_LAST_SEEN_TS, str(last_seen_ts))
|
||||
|
||||
Reference in New Issue
Block a user