Bump version to v0.7.0: Enhanced GDM integration, status sync, and notification system

This commit is contained in:
2026-01-11 14:00:27 +09:00
parent 1175acd16e
commit 02d26a104d
12 changed files with 1708 additions and 305 deletions

View File

@@ -107,9 +107,13 @@ class LogicLinkkf(AnimeModuleBase):
"linkkf_uncompleted_auto_enqueue": "False",
"linkkf_image_url_prefix_series": "",
"linkkf_image_url_prefix_episode": "",
"linkkf_discord_notify": "True",
"linkkf_download_method": "ffmpeg", # ffmpeg, ytdlp, aria2c
"linkkf_download_threads": "16", # yt-dlp/aria2c 병렬 쓰레드 수
# 알림 설정
"linkkf_notify_enabled": "False",
"linkkf_discord_webhook_url": "",
"linkkf_telegram_bot_token": "",
"linkkf_telegram_chat_id": "",
}
# default_route_socketio(P, self)
self.web_list_model = ModelLinkkfItem
@@ -470,6 +474,32 @@ class LogicLinkkf(AnimeModuleBase):
logger.error(f"browse_dir error: {e}")
return jsonify({"ret": "error", "error": str(e)}), 500
elif sub == "test_notification":
# 테스트 알림 전송
try:
discord_url = P.ModelSetting.get("linkkf_discord_webhook_url")
telegram_token = P.ModelSetting.get("linkkf_telegram_bot_token")
telegram_chat_id = P.ModelSetting.get("linkkf_telegram_chat_id")
if not discord_url and not (telegram_token and telegram_chat_id):
return jsonify({"ret": "error", "msg": "Discord Webhook URL 또는 Telegram 설정을 입력하세요."})
test_message = "🔔 **테스트 알림**\nLinkkf 알림 설정이 완료되었습니다!\n\n알림이 정상적으로 수신되고 있습니다."
sent_to = []
if discord_url:
self.send_discord_notification(discord_url, "테스트", test_message)
sent_to.append("Discord")
if telegram_token and telegram_chat_id:
self.send_telegram_notification(telegram_token, telegram_chat_id, test_message)
sent_to.append("Telegram")
return jsonify({"ret": "success", "msg": f"{', '.join(sent_to)}으로 알림 전송 완료!"})
except Exception as e:
logger.error(f"test_notification error: {e}")
return jsonify({"ret": "error", "msg": str(e)})
return super().process_ajax(sub, req)
except Exception as e:
@@ -477,6 +507,144 @@ class LogicLinkkf(AnimeModuleBase):
P.logger.error(traceback.format_exc())
return jsonify({"ret": "error", "log": str(e)})
def process_command(self, command, arg1, arg2, arg3, req):
try:
if command == "list":
# 1. 자체 큐 목록 가져오기
ret = self.queue.get_entity_list() if self.queue else []
# 2. GDM 태스크 가져오기 (설치된 경우)
try:
from gommi_downloader_manager.mod_queue import ModuleQueue
if ModuleQueue:
gdm_tasks = ModuleQueue.get_all_downloads()
# 이 모듈(linkkf)이 추가한 작업만 필터링
linkkf_tasks = [t for t in gdm_tasks if t.caller_plugin == f"{P.package_name}_{self.name}"]
for task in linkkf_tasks:
# 템플릿 호환 형식으로 변환
gdm_item = self._convert_gdm_task_to_queue_item(task)
ret.append(gdm_item)
except Exception as e:
logger.debug(f"GDM tasks fetch error: {e}")
return jsonify(ret)
elif command in ["stop", "remove", "cancel"]:
entity_id = arg1
if entity_id and str(entity_id).startswith("dl_"):
# GDM 작업 처리
try:
from gommi_downloader_manager.mod_queue import ModuleQueue
if ModuleQueue:
if command == "stop" or command == "cancel":
task = ModuleQueue.get_download(entity_id)
if task:
task.cancel()
return jsonify({"ret": "success", "log": "GDM 작업을 중지하였습니다."})
elif command == "remove":
# GDM에서 삭제 처리 (명령어 'delete' 사용)
# process_ajax의 delete 로직 참고
class DummyReq:
def __init__(self, id):
self.form = {"id": id}
ModuleQueue.process_ajax("delete", DummyReq(entity_id))
return jsonify({"ret": "success", "log": "GDM 작업을 삭제하였습니다."})
except Exception as e:
logger.error(f"GDM command error: {e}")
return jsonify({"ret": "error", "log": f"GDM 명령 실패: {e}"})
# 자체 큐 처리
return super().process_command(command, arg1, arg2, arg3, req)
return super().process_command(command, arg1, arg2, arg3, req)
except Exception as e:
logger.error(f"process_command Error: {e}")
logger.error(traceback.format_exc())
return jsonify({'ret': 'fail', 'log': str(e)})
def _convert_gdm_task_to_queue_item(self, task):
"""GDM DownloadTask 객체를 FfmpegQueueEntity.as_dict() 호환 형식으로 변환"""
# 상태 맵핑
status_kor_map = {
"pending": "대기중",
"extracting": "분석중",
"downloading": "다운로드중",
"paused": "일시정지",
"completed": "완료",
"error": "실패",
"cancelled": "취소됨"
}
status_str_map = {
"pending": "WAITING",
"extracting": "ANALYZING",
"downloading": "DOWNLOADING",
"paused": "PAUSED",
"completed": "COMPLETED",
"error": "FAILED",
"cancelled": "FAILED"
}
# GDM task는 as_dict()를 제공함
t_dict = task.as_dict()
return {
"entity_id": t_dict["id"],
"url": t_dict["url"],
"filename": t_dict["filename"] or t_dict["title"],
"ffmpeg_status_kor": status_kor_map.get(t_dict["status"], "알수없음"),
"ffmpeg_percent": t_dict["progress"],
"created_time": t_dict["created_time"],
"current_speed": t_dict["speed"],
"download_time": t_dict["eta"],
"status_str": status_str_map.get(t_dict["status"], "WAITING"),
"idx": t_dict["id"],
"callback_id": "linkkf",
"start_time": t_dict["start_time"] or t_dict["created_time"],
"percent": t_dict["progress"],
"save_fullpath": t_dict["filepath"],
"is_gdm": True # GDM 작업임을 표시 (디버깅용)
}
def plugin_callback(self, data):
"""
GDM 모듈로부터 다운로드 상태 업데이트 수신
data = {
'callback_id': self.callback_id,
'status': self.status,
'filepath': self.filepath,
'filename': os.path.basename(self.filepath) if self.filepath else '',
'error': self.error_message
}
"""
try:
callback_id = data.get('callback_id')
status = data.get('status')
logger.info(f"[Linkkf] Received GDM callback: id={callback_id}, status={status}")
# DB 상태 업데이트
if callback_id:
from framework import F
with F.app.app_context():
db_item = ModelLinkkfItem.get_by_linkkf_id(callback_id)
if db_item:
if status == "completed":
db_item.status = "completed"
db_item.completed_time = datetime.now()
db_item.filepath = data.get('filepath')
db_item.save()
logger.info(f"[Linkkf] Updated DB item {db_item.id} to COMPLETED via GDM callback")
# 알림 전송 (필요 시)
# self.socketio_callback("list_refresh", "")
elif status == "error":
# 필요 시 에러 처리
pass
except Exception as e:
logger.error(f"[Linkkf] Callback processing error: {e}")
logger.error(traceback.format_exc())
def socketio_callback(self, refresh_type, data):
"""
@@ -1780,6 +1948,29 @@ class LogicLinkkf(AnimeModuleBase):
def plugin_load(self):
try:
logger.debug("%s plugin_load", P.package_name)
# 새 설정 초기화 (기존 설치에서 누락된 설정 추가)
new_settings = {
"linkkf_notify_enabled": "False",
"linkkf_discord_webhook_url": "",
"linkkf_telegram_bot_token": "",
"linkkf_telegram_chat_id": "",
}
for key, default_value in new_settings.items():
if P.ModelSetting.get(key) is None:
P.ModelSetting.set(key, default_value)
logger.info(f"[Linkkf] Initialized new setting: {key}")
# 추가 설정: 자동 다운로드 vs 알림만
if P.ModelSetting.get("linkkf_auto_download_new") is None:
P.ModelSetting.set("linkkf_auto_download_new", "True")
logger.info("[Linkkf] Initialized setting: linkkf_auto_download_new")
# 모니터링 주기 설정 (기본 10분)
if P.ModelSetting.get("linkkf_monitor_interval") is None:
P.ModelSetting.set("linkkf_monitor_interval", "10")
logger.info("[Linkkf] Initialized setting: linkkf_monitor_interval")
# 클래스 레벨 큐 초기화
if LogicLinkkf.queue is None:
LogicLinkkf.queue = FfmpegQueue(
@@ -1806,6 +1997,229 @@ class LogicLinkkf(AnimeModuleBase):
def plugin_unload(self):
pass
def scheduler_function(self):
"""스케줄러 함수 - linkkf 자동 다운로드 처리"""
from framework import F
logger.info("linkkf scheduler_function::=========================")
# Flask 앱 컨텍스트 내에서 실행 (스케줄러는 별도 스레드)
with F.app.app_context():
try:
content_code_list = P.ModelSetting.get_list("linkkf_auto_code_list", "|")
auto_mode_all = P.ModelSetting.get_bool("linkkf_auto_mode_all")
logger.info(f"Auto-download codes: {content_code_list}")
logger.info(f"Auto mode all episodes: {auto_mode_all}")
if not content_code_list:
logger.info("[Scheduler] No auto-download codes configured")
return
# 각 작품 코드별 처리
for code in content_code_list:
code = code.strip()
if not code:
continue
if code.lower() == "all":
# 사이트 전체 최신 에피소드 스캔
logger.info("[Scheduler] 'all' mode - scanning latest episodes from site")
self.scan_latest_episodes(auto_mode_all)
continue
logger.info(f"[Scheduler] Processing code: {code}")
try:
# 작품 정보 조회
series_info = self.get_series_info(code)
if not series_info or "episode" not in series_info:
logger.warning(f"[Scheduler] No episode info for: {code}")
continue
episodes = series_info.get("episode", [])
logger.info(f"[Scheduler] Found {len(episodes)} episodes for: {series_info.get('title', code)}")
# 에피소드 순회 및 자동 등록
added_count = 0
added_episodes = []
for episode_info in episodes:
try:
result = self.add(episode_info)
if result and result.startswith("enqueue"):
added_count += 1
added_episodes.append(episode_info.get('title', 'Unknown'))
logger.info(f"[Scheduler] Auto-enqueued: {episode_info.get('title', 'Unknown')}")
self.socketio_callback("list_refresh", "")
# auto_mode_all이 False면 최신 1개만 (리스트가 최신순이라고 가정)
if not auto_mode_all and added_count > 0:
logger.info(f"[Scheduler] Auto mode: latest only - stopping after 1 episode")
break
except Exception as ep_err:
logger.error(f"[Scheduler] Episode add error: {ep_err}")
continue
# 새 에피소드 추가됨 → 알림 전송
if added_count > 0:
self.send_notification(
title=series_info.get('title', code),
episodes=added_episodes,
count=added_count
)
logger.info(f"[Scheduler] Completed {code}: added {added_count} episodes")
except Exception as code_err:
logger.error(f"[Scheduler] Error processing {code}: {code_err}")
logger.error(traceback.format_exc())
continue
except Exception as e:
logger.error(f"[Scheduler] Fatal error: {e}")
logger.error(traceback.format_exc())
def send_notification(self, title, episodes, count):
"""Discord/Telegram 알림 전송"""
if not P.ModelSetting.get_bool("linkkf_notify_enabled"):
return
# 메시지 생성
episode_list = "\n".join([f"{ep}" for ep in episodes[:5]])
if count > 5:
episode_list += f"\n... 외 {count - 5}"
message = f"🎬 **{title}**\n새 에피소드 {count}개가 다운로드 큐에 추가되었습니다!\n\n{episode_list}"
# Discord Webhook
discord_url = P.ModelSetting.get("linkkf_discord_webhook_url")
if discord_url:
self.send_discord_notification(discord_url, title, message)
# Telegram Bot
telegram_token = P.ModelSetting.get("linkkf_telegram_bot_token")
telegram_chat_id = P.ModelSetting.get("linkkf_telegram_chat_id")
if telegram_token and telegram_chat_id:
self.send_telegram_notification(telegram_token, telegram_chat_id, message)
def scan_latest_episodes(self, auto_mode_all):
"""사이트에서 최신 에피소드 목록을 스캔하고 새 에피소드 감지"""
try:
auto_download = P.ModelSetting.get_bool("linkkf_auto_download_new")
# 최신 방영 목록 가져오기 (1페이지만 - 가장 최신)
latest_data = self.get_anime_info("ing", 1)
if not latest_data or "episode" not in latest_data:
logger.warning("[Scheduler] Failed to fetch latest anime list")
return
items = latest_data.get("episode", [])
logger.info(f"[Scheduler] Scanned {len(items)} items from 'ing' page")
total_added = 0
all_new_episodes = []
# 각 작품의 최신 에피소드 확인
for item in items[:20]: # 상위 20개만 처리 (성능 고려)
try:
code = item.get("code")
if not code:
continue
# 해당 작품의 에피소드 목록 조회
series_info = self.get_series_info(code)
if not series_info or "episode" not in series_info:
continue
episodes = series_info.get("episode", [])
series_title = series_info.get("title", code)
# 새 에피소드만 추가 (add 메서드가 중복 체크함)
for ep in episodes[:5]: # 최신 5개만 확인
try:
if auto_download:
result = self.add(ep)
if result and result.startswith("enqueue"):
total_added += 1
all_new_episodes.append(f"{series_title} - {ep.get('title', '')}")
self.socketio_callback("list_refresh", "")
else:
# 알림만 (다운로드 안함) - DB 체크로 새 에피소드인지 확인
ep_code = ep.get("code", "")
existing = ModelLinkkfItem.get_by_code(ep_code) if ep_code else None
if not existing:
all_new_episodes.append(f"{series_title} - {ep.get('title', '')}")
if not auto_mode_all and total_added > 0:
break
except Exception:
continue
if not auto_mode_all and total_added > 0:
break
except Exception as e:
logger.debug(f"[Scheduler] Error scanning {item.get('code', 'unknown')}: {e}")
continue
# 결과 알림
if all_new_episodes:
mode_text = "자동 다운로드" if auto_download else "새 에피소드 감지"
self.send_notification(
title=f"[{mode_text}] 사이트 모니터링",
episodes=all_new_episodes,
count=len(all_new_episodes)
)
logger.info(f"[Scheduler] 'all' mode completed: {len(all_new_episodes)} new episodes found")
else:
logger.info("[Scheduler] 'all' mode: No new episodes found")
except Exception as e:
logger.error(f"[Scheduler] scan_latest_episodes error: {e}")
logger.error(traceback.format_exc())
def send_discord_notification(self, webhook_url, title, message):
"""Discord Webhook으로 알림 전송"""
try:
payload = {
"embeds": [{
"title": f"📺 Linkkf 자동 다운로드",
"description": message,
"color": 0x10B981, # 초록색
"footer": {"text": "FlaskFarm Anime Downloader"}
}]
}
response = requests.post(webhook_url, json=payload, timeout=10)
if response.status_code in [200, 204]:
logger.info(f"[Notify] Discord 알림 전송 성공: {title}")
else:
logger.warning(f"[Notify] Discord 알림 실패: {response.status_code}")
except Exception as e:
logger.error(f"[Notify] Discord 알림 오류: {e}")
def send_telegram_notification(self, bot_token, chat_id, message):
"""Telegram Bot API로 알림 전송"""
try:
# Markdown 형식으로 변환 (** -> *)
telegram_message = message.replace("**", "*")
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {
"chat_id": chat_id,
"text": telegram_message,
"parse_mode": "Markdown"
}
response = requests.post(url, json=payload, timeout=10)
result = response.json()
if result.get("ok"):
logger.info(f"[Notify] Telegram 알림 전송 성공")
else:
logger.warning(f"[Notify] Telegram 알림 실패: {result.get('description', 'Unknown')}")
except Exception as e:
logger.error(f"[Notify] Telegram 알림 오류: {e}")
def download_thread_function(self):
while True:
try:
@@ -2148,39 +2562,47 @@ class ModelLinkkfItem(db.Model):
return ret
def save(self):
db.session.add(self)
db.session.commit()
from framework import F
with F.app.app_context():
db.session.add(self)
db.session.commit()
@classmethod
def get_by_id(cls, idx):
return db.session.query(cls).filter_by(id=idx).first()
from framework import F
with F.app.app_context():
return db.session.query(cls).filter_by(id=idx).first()
@classmethod
def get_by_linkkf_id(cls, linkkf_id):
return db.session.query(cls).filter_by(linkkf_id=linkkf_id).first()
from framework import F
with F.app.app_context():
return db.session.query(cls).filter_by(linkkf_id=linkkf_id).first()
@classmethod
def append(cls, q):
logger.debug(q)
item = ModelLinkkfItem()
item.content_code = q["program_code"]
item.season = q["season"]
item.episode_no = q["epi_queue"]
item.title = q["content_title"]
item.episode_title = q["title"]
# item.linkkf_va = q["va"]
item.linkkf_code = q["code"]
item.linkkf_id = q["_id"]
item.quality = q["quality"]
item.filepath = q["filepath"]
item.filename = q["filename"]
item.savepath = q["savepath"]
item.video_url = q["url"]
item.vtt_url = q["vtt"]
item.thumbnail = q.get("image", "")
item.status = "wait"
item.linkkf_info = q["linkkf_info"]
item.save()
from framework import F
with F.app.app_context():
logger.debug(q)
item = ModelLinkkfItem()
item.content_code = q["program_code"]
item.season = q["season"]
item.episode_no = q["epi_queue"]
item.title = q["content_title"]
item.episode_title = q["title"]
# item.linkkf_va = q["va"]
item.linkkf_code = q["code"]
item.linkkf_id = q["_id"]
item.quality = q["quality"]
item.filepath = q["filepath"]
item.filename = q["filename"]
item.savepath = q["savepath"]
item.video_url = q["url"]
item.vtt_url = q["vtt"]
item.thumbnail = q.get("image", "")
item.status = "wait"
item.linkkf_info = q["linkkf_info"]
item.save()
@classmethod
def get_paging_info(cls, count, page, page_size):
@@ -2208,51 +2630,57 @@ class ModelLinkkfItem(db.Model):
@classmethod
def delete_by_id(cls, idx):
db.session.query(cls).filter_by(id=idx).delete()
db.session.commit()
from framework import F
with F.app.app_context():
db.session.query(cls).filter_by(id=idx).delete()
db.session.commit()
return True
@classmethod
def web_list(cls, req):
ret = {}
page = int(req.form["page"]) if "page" in req.form else 1
page_size = 30
job_id = ""
search = req.form["search_word"] if "search_word" in req.form else ""
option = req.form["option"] if "option" in req.form else "all"
order = req.form["order"] if "order" in req.form else "desc"
query = cls.make_query(search=search, order=order, option=option)
count = query.count()
query = query.limit(page_size).offset((page - 1) * page_size)
lists = query.all()
ret["list"] = [item.as_dict() for item in lists]
ret["paging"] = cls.get_paging_info(count, page, page_size)
return ret
from framework import F
with F.app.app_context():
ret = {}
page = int(req.form["page"]) if "page" in req.form else 1
page_size = 30
job_id = ""
search = req.form["search_word"] if "search_word" in req.form else ""
option = req.form["option"] if "option" in req.form else "all"
order = req.form["order"] if "order" in req.form else "desc"
query = cls.make_query(search=search, order=order, option=option)
count = query.count()
query = query.limit(page_size).offset((page - 1) * page_size)
lists = query.all()
ret["list"] = [item.as_dict() for item in lists]
ret["paging"] = cls.get_paging_info(count, page, page_size)
return ret
@classmethod
def make_query(cls, search="", order="desc", option="all"):
query = db.session.query(cls)
if search is not None and search != "":
if search.find("|") != -1:
tmp = search.split("|")
conditions = []
for tt in tmp:
if tt != "":
conditions.append(cls.filename.like("%" + tt.strip() + "%"))
query = query.filter(or_(*conditions))
elif search.find(",") != -1:
tmp = search.split(",")
for tt in tmp:
if tt != "":
query = query.filter(cls.filename.like("%" + tt.strip() + "%"))
else:
query = query.filter(cls.filename.like("%" + search + f"%"))
if option == "completed":
query = query.filter(cls.status == "completed")
from framework import F
with F.app.app_context():
query = db.session.query(cls)
if search is not None and search != "":
if search.find("|") != -1:
tmp = search.split("|")
conditions = []
for tt in tmp:
if tt != "":
conditions.append(cls.filename.like("%" + tt.strip() + "%"))
query = query.filter(or_(*conditions))
elif search.find(",") != -1:
tmp = search.split(",")
for tt in tmp:
if tt != "":
query = query.filter(cls.filename.like("%" + tt.strip() + "%"))
else:
query = query.filter(cls.filename.like("%" + search + f"%"))
if order == "desc":
query = query.order_by(desc(cls.id))
else:
query = query.order_by(cls.id)
return query
if option == "completed":
query = query.filter(cls.status == "completed")
if order == "desc":
query = query.order_by(desc(cls.id))
else:
query = query.order_by(cls.id)
return query