Bump version to v0.7.0: Enhanced GDM integration, status sync, and notification system
This commit is contained in:
10
README.md
10
README.md
@@ -81,6 +81,16 @@
|
||||
|
||||
## 📝 변경 이력 (Changelog)
|
||||
|
||||
### v0.7.0 (2026-01-11)
|
||||
- **GDM(Gommi Downloader Manager) 통합 고도화**:
|
||||
- **통합 큐 페이지**: 링크애니, 애니라이프, 오클리24의 큐 페이지에서 GDM 작업을 실시간으로 확인 및 중지/삭제 가능하도록 통합.
|
||||
- **상태 자동 동기화**: GDM 다운로드 완료 시 콜백을 통해 로컬 DB 상태를 자동으로 '컴플리트'로 업데이트하여 목록 페이지(`list`)에 즉시 반영.
|
||||
- **GDM 작업 매핑**: GDM의 다양한 상태 코드 및 진행률을 각 플러그인 UI 형식에 맞게 변환 처리.
|
||||
- **안정성 강화**:
|
||||
- **백그라운드 DB 안정화**: 스케줄러 및 비동기 작업 중 데이터베이스 접근 시 `app_context` 오류 방지를 위해 전역적인 컨텍스트 래핑 적용.
|
||||
- **자동 다운로드 로직 개선**: 링크애니 '전체(all)' 모드 모니터링 및 자동 에피소드 등록 로직 보강.
|
||||
- **알림 시스템**: 링크애니 새 에피소드 감지 시 Discord/Telegram 알림 기능 및 설정 UI 추가.
|
||||
|
||||
### v0.6.25 (2026-01-09)
|
||||
- **자가 업데이트 기능 추가**: 모든 설정 페이지 (Ohli24, Anilife, Linkkf)에서 "업데이트" 버튼 클릭으로 Git Pull 및 플러그인 핫 리로드 지원
|
||||
- **버전 체크 API**: GitHub에서 최신 버전 정보를 가져와 업데이트 알림 표시 (1시간 캐싱)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
title: "애니 다운로더"
|
||||
version: 0.6.25
|
||||
version: 0.7.0
|
||||
package_name: "anime_downloader"
|
||||
developer: "projectdx"
|
||||
description: "anime downloader"
|
||||
|
||||
251
mod_anilife.py
251
mod_anilife.py
@@ -216,19 +216,58 @@ class LogicAniLife(AnimeModuleBase):
|
||||
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()
|
||||
# 이 모듈(anilife)이 추가한 작업만 필터링
|
||||
anilife_tasks = [t for t in gdm_tasks if t.caller_plugin == f"{P.package_name}_{self.name}"]
|
||||
|
||||
for task in anilife_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 == "stop":
|
||||
entity_id = int(arg1) if arg1 else -1
|
||||
result = self.queue.command("cancel", entity_id) if self.queue else {"ret": "error"}
|
||||
return jsonify(result)
|
||||
elif command == "remove":
|
||||
entity_id = int(arg1) if arg1 else -1
|
||||
result = self.queue.command("remove", entity_id) if self.queue else {"ret": "error"}
|
||||
return jsonify(result)
|
||||
elif command in ["reset", "delete_completed"]:
|
||||
result = self.queue.command(command, 0) if self.queue else {"ret": "error"}
|
||||
|
||||
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" or command == "delete":
|
||||
# GDM에서 삭제 처리
|
||||
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}"})
|
||||
|
||||
# 자체 큐 처리
|
||||
entity_id = int(arg1) if arg1 and str(arg1).isdigit() else -1
|
||||
command_to_call = "cancel" if command == "stop" else command
|
||||
if self.queue:
|
||||
result = self.queue.command(command_to_call, entity_id)
|
||||
else:
|
||||
result = {"ret": "error", "log": "Queue not initialized"}
|
||||
return jsonify(result)
|
||||
|
||||
elif command == "merge_subtitle":
|
||||
# AniUtil already imported at module level
|
||||
db_id = int(arg1)
|
||||
@@ -248,6 +287,73 @@ class LogicAniLife(AnimeModuleBase):
|
||||
self.P.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"
|
||||
}
|
||||
|
||||
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": "anilife",
|
||||
"start_time": t_dict["start_time"] or t_dict["created_time"],
|
||||
"percent": t_dict["progress"],
|
||||
"save_fullpath": t_dict["filepath"],
|
||||
"is_gdm": True
|
||||
}
|
||||
|
||||
def plugin_callback(self, data):
|
||||
"""GDM 모듈로부터 다운로드 상태 업데이트 수신"""
|
||||
try:
|
||||
callback_id = data.get('callback_id')
|
||||
status = data.get('status')
|
||||
|
||||
logger.info(f"[AniLife] Received GDM callback: id={callback_id}, status={status}")
|
||||
|
||||
if callback_id:
|
||||
from framework import F
|
||||
with F.app.app_context():
|
||||
db_item = ModelAniLifeItem.get_by_anilife_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"[AniLife] Updated DB item {db_item.id} to COMPLETED via GDM callback")
|
||||
elif status == "error":
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"[AniLife] Callback processing error: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
# @staticmethod
|
||||
def get_html(
|
||||
self,
|
||||
@@ -971,6 +1077,35 @@ class LogicAniLife(AnimeModuleBase):
|
||||
logger.error(f"reset_db error: {e}")
|
||||
return jsonify({"ret": "error", "msg": str(e)})
|
||||
|
||||
elif sub == "add_schedule":
|
||||
# 스케쥴 등록 (자동 다운로드 목록에 코드 추가)
|
||||
try:
|
||||
code = request.form.get("code", "")
|
||||
title = request.form.get("title", "")
|
||||
logger.debug(f"add_schedule: code={code}, title={title}")
|
||||
|
||||
if not code:
|
||||
return jsonify({"ret": "error", "msg": "코드가 없습니다."})
|
||||
|
||||
# 기존 whitelist 가져오기
|
||||
whitelist = P.ModelSetting.get("anilife_auto_code_list") or ""
|
||||
code_list = [c.strip() for c in whitelist.replace("\n", "|").split("|") if c.strip()]
|
||||
|
||||
if code in code_list:
|
||||
return jsonify({"ret": "exist", "msg": "이미 등록되어 있습니다."})
|
||||
|
||||
# 코드 추가
|
||||
code_list.append(code)
|
||||
new_whitelist = "|".join(code_list)
|
||||
P.ModelSetting.set("anilife_auto_code_list", new_whitelist)
|
||||
|
||||
logger.info(f"[Anilife] Schedule added: {code} ({title})")
|
||||
return jsonify({"ret": "success", "msg": f"스케쥴 등록 완료: {title}"})
|
||||
except Exception as e:
|
||||
logger.error(f"add_schedule error: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({"ret": "error", "msg": str(e)})
|
||||
|
||||
# Fallback to base class for common subs (queue_command, entity_list, browse_dir, command, etc.)
|
||||
return super().process_ajax(sub, req)
|
||||
|
||||
@@ -1087,12 +1222,73 @@ class LogicAniLife(AnimeModuleBase):
|
||||
return False
|
||||
|
||||
def scheduler_function(self):
|
||||
logger.debug(f"ohli24 scheduler_function::=========================")
|
||||
"""스케줄러 함수 - anilife 자동 다운로드 처리"""
|
||||
logger.info("anilife scheduler_function::=========================")
|
||||
|
||||
try:
|
||||
content_code_list = P.ModelSetting.get_list("anilife_auto_code_list", "|")
|
||||
url = f'{P.ModelSetting.get("anilife_url")}/dailyani'
|
||||
if "all" in content_code_list:
|
||||
ret_data = LogicAniLife.get_auto_anime_info(self, url=url)
|
||||
auto_mode_all = P.ModelSetting.get_bool("anilife_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":
|
||||
# TODO: 전체 최신 에피소드 스캔 로직 (추후 구현)
|
||||
logger.info("[Scheduler] 'all' mode - skipping for now")
|
||||
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
|
||||
for episode_info in episodes:
|
||||
try:
|
||||
result = self.add(episode_info)
|
||||
if result and result.startswith("enqueue"):
|
||||
added_count += 1
|
||||
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
|
||||
|
||||
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 reset_db(self):
|
||||
db.session.query(ModelAniLifeItem).delete()
|
||||
@@ -2073,19 +2269,27 @@ class ModelAniLifeItem(db.Model):
|
||||
return ret
|
||||
|
||||
def save(self):
|
||||
from framework import F
|
||||
with F.app.app_context():
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, idx):
|
||||
from framework import F
|
||||
with F.app.app_context():
|
||||
return db.session.query(cls).filter_by(id=idx).first()
|
||||
|
||||
@classmethod
|
||||
def get_by_anilife_id(cls, anilife_id):
|
||||
from framework import F
|
||||
with F.app.app_context():
|
||||
return db.session.query(cls).filter_by(anilife_id=anilife_id).first()
|
||||
|
||||
@classmethod
|
||||
def delete_by_id(cls, idx):
|
||||
from framework import F
|
||||
with F.app.app_context():
|
||||
try:
|
||||
logger.debug(f"delete_by_id: {idx} (type: {type(idx)})")
|
||||
if isinstance(idx, str) and ',' in idx:
|
||||
@@ -2100,22 +2304,26 @@ class ModelAniLifeItem(db.Model):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Exception: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
# logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def delete_all(cls):
|
||||
from framework import F
|
||||
with F.app.app_context():
|
||||
try:
|
||||
db.session.query(cls).delete()
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Exception: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
# logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def web_list(cls, req):
|
||||
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
|
||||
@@ -2133,6 +2341,8 @@ class ModelAniLifeItem(db.Model):
|
||||
|
||||
@classmethod
|
||||
def make_query(cls, search="", order="desc", option="all"):
|
||||
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:
|
||||
@@ -2159,10 +2369,14 @@ class ModelAniLifeItem(db.Model):
|
||||
|
||||
@classmethod
|
||||
def get_list_uncompleted(cls):
|
||||
from framework import F
|
||||
with F.app.app_context():
|
||||
return db.session.query(cls).filter(cls.status != "completed").all()
|
||||
|
||||
@classmethod
|
||||
def append(cls, q):
|
||||
from framework import F
|
||||
with F.app.app_context():
|
||||
# 중복 체크
|
||||
existing = cls.get_by_anilife_id(q["_id"])
|
||||
if existing:
|
||||
@@ -2184,7 +2398,8 @@ class ModelAniLifeItem(db.Model):
|
||||
item.savepath = q.get("savepath")
|
||||
item.video_url = q.get("url")
|
||||
item.vtt_url = q.get("vtt")
|
||||
item.thumbnail = q.get("thumbnail")
|
||||
item.thumbnail = q.get("image", "")
|
||||
item.status = "wait"
|
||||
item.anilife_info = q.get("anilife_info")
|
||||
item.anilife_info = q["anilife_info"]
|
||||
item.save()
|
||||
return item
|
||||
|
||||
43
mod_base.py
43
mod_base.py
@@ -139,11 +139,23 @@ class AnimeModuleBase(PluginModuleBase):
|
||||
# 자가 업데이트 (Git Pull) 및 모듈 리로드
|
||||
try:
|
||||
import subprocess
|
||||
plugin_path = os.path.dirname(os.path.dirname(__file__)) if '__file__' in dir() else os.path.dirname(__file__)
|
||||
# 실제 플러그인 루트 디렉토리
|
||||
plugin_path = os.path.dirname(__file__)
|
||||
self.P.logger.info(f"애니 다운로더 자가 업데이트 시작: {plugin_path}")
|
||||
|
||||
# 먼저 변경될 파일 목록 확인 (model 파일 변경 감지)
|
||||
diff_cmd = ['git', '-C', plugin_path, 'diff', '--name-only', 'HEAD', 'origin/main']
|
||||
subprocess.run(['git', '-C', plugin_path, 'fetch'], capture_output=True) # fetch first
|
||||
diff_result = subprocess.run(diff_cmd, capture_output=True, text=True)
|
||||
changed_files = diff_result.stdout.strip().split('\n') if diff_result.stdout.strip() else []
|
||||
|
||||
# 모델 파일 변경 여부 확인
|
||||
model_patterns = ['model', 'db', 'migration']
|
||||
needs_restart = any(
|
||||
any(pattern in f.lower() for pattern in model_patterns)
|
||||
for f in changed_files if f
|
||||
)
|
||||
|
||||
# Git Pull 실행
|
||||
cmd = ['git', '-C', plugin_path, 'pull']
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
stdout, stderr = process.communicate()
|
||||
@@ -153,10 +165,20 @@ class AnimeModuleBase(PluginModuleBase):
|
||||
|
||||
self.P.logger.info(f"Git pull 결과: {stdout}")
|
||||
|
||||
# 모듈 리로드
|
||||
# 모델 변경 없으면 리로드 시도
|
||||
if not needs_restart:
|
||||
self.reload_plugin()
|
||||
msg = f"업데이트 완료! 새로고침하세요.<br><pre>{stdout}</pre>"
|
||||
else:
|
||||
self.P.logger.warning("모델 파일 변경 감지 - 서버 재시작 필요")
|
||||
msg = f"<strong>모델 변경 감지!</strong> 서버 재시작이 필요합니다.<br><pre>{stdout}</pre>"
|
||||
|
||||
return jsonify({'ret': 'success', 'msg': f"업데이트 및 리로드 완료!<br><pre>{stdout}</pre>", 'data': stdout})
|
||||
return jsonify({
|
||||
'ret': 'success',
|
||||
'msg': msg,
|
||||
'data': stdout,
|
||||
'needs_restart': needs_restart
|
||||
})
|
||||
except Exception as e:
|
||||
self.P.logger.error(f"자가 업데이트 중 오류: {str(e)}")
|
||||
self.P.logger.error(traceback.format_exc())
|
||||
@@ -299,24 +321,33 @@ class AnimeModuleBase(PluginModuleBase):
|
||||
package_name = self.P.package_name
|
||||
self.P.logger.info(f"플러그인 리로드 시작: {package_name}")
|
||||
|
||||
# 리로드에서 제외할 패턴 (모델/DB 관련 - SQLAlchemy 충돌 방지)
|
||||
skip_patterns = ['model', 'db', 'migration', 'setup', 'create_plugin']
|
||||
|
||||
# 관련 모듈 찾기 및 리로드
|
||||
modules_to_reload = []
|
||||
for module_name in list(sys.modules.keys()):
|
||||
if module_name.startswith(package_name):
|
||||
# 모델 관련 모듈은 건너뛰기
|
||||
should_skip = any(pattern in module_name.lower() for pattern in skip_patterns)
|
||||
if not should_skip:
|
||||
modules_to_reload.append(module_name)
|
||||
|
||||
# 의존성 역순으로 정렬 (깊은 모듈 먼저)
|
||||
modules_to_reload.sort(key=lambda x: x.count('.'), reverse=True)
|
||||
|
||||
reloaded_count = 0
|
||||
for module_name in modules_to_reload:
|
||||
try:
|
||||
module = sys.modules[module_name]
|
||||
importlib.reload(module)
|
||||
self.P.logger.debug(f"Reloaded: {module_name}")
|
||||
reloaded_count += 1
|
||||
except Exception as e:
|
||||
self.P.logger.warning(f"Failed to reload {module_name}: {e}")
|
||||
self.P.logger.warning(f"Skip reload {module_name}: {e}")
|
||||
|
||||
self.P.logger.info(f"플러그인 모듈 [{package_name}] 리로드 완료")
|
||||
self.P.logger.info(f"플러그인 [{package_name}] 리로드 완료: {reloaded_count}개 모듈")
|
||||
self.P.logger.info("템플릿/정적 파일은 새로고침 시 자동 적용됩니다.")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.P.logger.error(f"모듈 리로드 중 실패: {str(e)}")
|
||||
|
||||
430
mod_linkkf.py
430
mod_linkkf.py
@@ -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,19 +2562,27 @@ class ModelLinkkfItem(db.Model):
|
||||
return ret
|
||||
|
||||
def save(self):
|
||||
from framework import F
|
||||
with F.app.app_context():
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, idx):
|
||||
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):
|
||||
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):
|
||||
from framework import F
|
||||
with F.app.app_context():
|
||||
logger.debug(q)
|
||||
item = ModelLinkkfItem()
|
||||
item.content_code = q["program_code"]
|
||||
@@ -2208,12 +2630,16 @@ class ModelLinkkfItem(db.Model):
|
||||
|
||||
@classmethod
|
||||
def delete_by_id(cls, idx):
|
||||
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):
|
||||
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
|
||||
@@ -2231,6 +2657,8 @@ class ModelLinkkfItem(db.Model):
|
||||
|
||||
@classmethod
|
||||
def make_query(cls, search="", order="desc", option="all"):
|
||||
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:
|
||||
|
||||
120
mod_ohli24.py
120
mod_ohli24.py
@@ -1186,9 +1186,56 @@ class LogicOhli24(AnimeModuleBase):
|
||||
self, command: str, arg1: str, arg2: str, arg3: str, req: Any
|
||||
) -> Any:
|
||||
"""커맨드 처리."""
|
||||
ret: Dict[str, Any] = {"ret": "success"}
|
||||
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()
|
||||
# 이 모듈(ohli24)이 추가한 작업만 필터링
|
||||
ohli24_tasks = [t for t in gdm_tasks if t.caller_plugin == f"{P.package_name}_{self.name}"]
|
||||
|
||||
for task in ohli24_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" or command == "delete":
|
||||
# GDM에서 삭제 처리
|
||||
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)
|
||||
|
||||
if command == "download_program":
|
||||
ret: Dict[str, Any] = {"ret": "success"}
|
||||
_pass = arg2
|
||||
db_item = ModelOhli24Program.get(arg1)
|
||||
if _pass == "false" and db_item is not None:
|
||||
@@ -1211,6 +1258,77 @@ class LogicOhli24(AnimeModuleBase):
|
||||
return jsonify(ret)
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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": "ohli24",
|
||||
"start_time": t_dict["start_time"] or t_dict["created_time"],
|
||||
"percent": t_dict["progress"],
|
||||
"save_fullpath": t_dict["filepath"],
|
||||
"is_gdm": True
|
||||
}
|
||||
|
||||
def plugin_callback(self, data):
|
||||
"""GDM 모듈로부터 다운로드 상태 업데이트 수신"""
|
||||
try:
|
||||
callback_id = data.get('callback_id')
|
||||
status = data.get('status')
|
||||
|
||||
logger.info(f"[Ohli24] Received GDM callback: id={callback_id}, status={status}")
|
||||
|
||||
if callback_id:
|
||||
from framework import F
|
||||
with F.app.app_context():
|
||||
db_item = ModelOhli24Item.get_by_ohli24_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"[Ohli24] Updated DB item {db_item.id} to COMPLETED via GDM callback")
|
||||
elif status == "error":
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"[Ohli24] Callback processing error: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
@staticmethod
|
||||
def add_whitelist(*args: str) -> Dict[str, Any]:
|
||||
|
||||
@@ -77,6 +77,56 @@
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
/* Artplayer Container */
|
||||
#artplayer-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
#artplayer-container.art-zoomed .art-video {
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
/* Plyr Container */
|
||||
#plyr-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#plyr-container .plyr {
|
||||
height: 100%;
|
||||
}
|
||||
#plyr-container .plyr--video {
|
||||
height: 100%;
|
||||
}
|
||||
#plyr-container video.vjs-zoomed {
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
/* Player Select Dropdown in Header */
|
||||
#player-select {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
#player-select:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
#player-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
#player-select option {
|
||||
background: #1e293b;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Zoom Button */
|
||||
.video-zoom-btn {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
/**
|
||||
* Video Modal Component JavaScript
|
||||
* Reusable video player modal for Anime Downloader
|
||||
*
|
||||
* Usage:
|
||||
* VideoModal.init({ package_name: 'anime_downloader', sub: 'ohli24' });
|
||||
* VideoModal.openWithPath('/path/to/video.mp4');
|
||||
*/
|
||||
|
||||
var VideoModal = (function() {
|
||||
'use strict';
|
||||
|
||||
@@ -15,28 +6,45 @@ var VideoModal = (function() {
|
||||
sub: 'ohli24'
|
||||
};
|
||||
|
||||
var videoPlayer = null;
|
||||
var videoPlayer = null; // Video.js instance
|
||||
var artPlayer = null; // Artplayer instance
|
||||
var plyrPlayer = null; // Plyr instance
|
||||
var currentPlayer = 'videojs'; // 'videojs', 'artplayer', 'plyr'
|
||||
var playlist = [];
|
||||
var currentPlaylistIndex = 0;
|
||||
var currentPlayingPath = '';
|
||||
var currentStreamUrl = '';
|
||||
var isVideoZoomed = false;
|
||||
|
||||
/**
|
||||
* Initialize the video modal
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.package_name - Package name (default: 'anime_downloader')
|
||||
* @param {string} options.sub - Sub-module name (e.g., 'ohli24', 'linkkf')
|
||||
*/
|
||||
function init(options) {
|
||||
config = Object.assign(config, options || {});
|
||||
|
||||
// Load saved player preference
|
||||
var savedPlayer = localStorage.getItem('anime_downloader_preferred_player');
|
||||
if (savedPlayer && ['videojs', 'artplayer', 'plyr'].indexOf(savedPlayer) >= 0) {
|
||||
currentPlayer = savedPlayer;
|
||||
$('#player-select').val(currentPlayer);
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
console.log('[VideoModal] Initialized with config:', config);
|
||||
console.log('[VideoModal] Initialized with player:', currentPlayer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind all event handlers
|
||||
*/
|
||||
function bindEvents() {
|
||||
// Player selector change
|
||||
$('#player-select').off('change').on('change', function() {
|
||||
var newPlayer = $(this).val();
|
||||
if (newPlayer !== currentPlayer) {
|
||||
switchPlayer(newPlayer);
|
||||
}
|
||||
});
|
||||
|
||||
// Dropdown episode selection
|
||||
$('#episode-dropdown').off('change').on('change', function() {
|
||||
var index = parseInt($(this).val());
|
||||
@@ -50,10 +58,12 @@ var VideoModal = (function() {
|
||||
$('#btn-video-zoom').off('click').on('click', function() {
|
||||
isVideoZoomed = !isVideoZoomed;
|
||||
if (isVideoZoomed) {
|
||||
$('#video-player').addClass('vjs-zoomed');
|
||||
$('#video-player, #plyr-player').addClass('vjs-zoomed');
|
||||
$('#artplayer-container').addClass('art-zoomed');
|
||||
$(this).addClass('active').find('i').removeClass('fa-expand').addClass('fa-compress');
|
||||
} else {
|
||||
$('#video-player').removeClass('vjs-zoomed');
|
||||
$('#video-player, #plyr-player').removeClass('vjs-zoomed');
|
||||
$('#artplayer-container').removeClass('art-zoomed');
|
||||
$(this).removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand');
|
||||
}
|
||||
});
|
||||
@@ -64,87 +74,81 @@ var VideoModal = (function() {
|
||||
});
|
||||
|
||||
$('#videoModal').off('hide.bs.modal').on('hide.bs.modal', function() {
|
||||
if (videoPlayer) {
|
||||
videoPlayer.pause();
|
||||
}
|
||||
pauseAllPlayers();
|
||||
});
|
||||
|
||||
$('#videoModal').off('hidden.bs.modal').on('hidden.bs.modal', function() {
|
||||
$('body').removeClass('modal-video-open');
|
||||
if (isVideoZoomed) {
|
||||
isVideoZoomed = false;
|
||||
$('#video-player').removeClass('vjs-zoomed');
|
||||
$('#video-player, #plyr-player').removeClass('vjs-zoomed');
|
||||
$('#artplayer-container').removeClass('art-zoomed');
|
||||
$('#btn-video-zoom').removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal with a file path (fetches playlist from server)
|
||||
* @param {string} filePath - Path to the video file
|
||||
* Switch between players
|
||||
*/
|
||||
function openWithPath(filePath) {
|
||||
$.ajax({
|
||||
url: '/' + config.package_name + '/ajax/' + config.sub + '/get_playlist?path=' + encodeURIComponent(filePath),
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function(data) {
|
||||
playlist = data.playlist || [];
|
||||
currentPlaylistIndex = data.current_index || 0;
|
||||
currentPlayingPath = filePath;
|
||||
function switchPlayer(newPlayer) {
|
||||
pauseAllPlayers();
|
||||
|
||||
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
||||
initPlayer(streamUrl);
|
||||
updatePlaylistUI();
|
||||
$('#videoModal').modal('show');
|
||||
},
|
||||
error: function() {
|
||||
// Fallback: single file
|
||||
playlist = [{ name: filePath.split('/').pop(), path: filePath }];
|
||||
currentPlaylistIndex = 0;
|
||||
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
||||
initPlayer(streamUrl);
|
||||
updatePlaylistUI();
|
||||
$('#videoModal').modal('show');
|
||||
currentPlayer = newPlayer;
|
||||
localStorage.setItem('anime_downloader_preferred_player', newPlayer);
|
||||
|
||||
// Hide all player containers
|
||||
$('#videojs-container').hide();
|
||||
$('#artplayer-container').hide();
|
||||
$('#plyr-container').hide();
|
||||
|
||||
// Show selected player and reinitialize with current URL
|
||||
if (currentStreamUrl) {
|
||||
initPlayerWithUrl(currentStreamUrl);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[VideoModal] Switched to:', newPlayer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal with a direct stream URL
|
||||
* @param {string} streamUrl - Direct URL to stream
|
||||
* @param {string} title - Optional title
|
||||
* Pause all players
|
||||
*/
|
||||
function openWithUrl(streamUrl, title) {
|
||||
playlist = [{ name: title || 'Video', path: streamUrl }];
|
||||
currentPlaylistIndex = 0;
|
||||
initPlayer(streamUrl);
|
||||
updatePlaylistUI();
|
||||
$('#videoModal').modal('show');
|
||||
function pauseAllPlayers() {
|
||||
try {
|
||||
if (videoPlayer) videoPlayer.pause();
|
||||
} catch(e) {}
|
||||
try {
|
||||
if (artPlayer) artPlayer.pause();
|
||||
} catch(e) {}
|
||||
try {
|
||||
if (plyrPlayer) plyrPlayer.pause();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal with a playlist array
|
||||
* @param {Array} playlistData - Array of {name, path} objects
|
||||
* @param {number} startIndex - Index to start playing from
|
||||
* Initialize player with URL based on current player selection
|
||||
*/
|
||||
function openWithPlaylist(playlistData, startIndex) {
|
||||
playlist = playlistData || [];
|
||||
currentPlaylistIndex = startIndex || 0;
|
||||
if (playlist.length > 0) {
|
||||
var filePath = playlist[currentPlaylistIndex].path;
|
||||
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
||||
initPlayer(streamUrl);
|
||||
updatePlaylistUI();
|
||||
$('#videoModal').modal('show');
|
||||
function initPlayerWithUrl(streamUrl) {
|
||||
currentStreamUrl = streamUrl;
|
||||
|
||||
if (currentPlayer === 'videojs') {
|
||||
initVideoJS(streamUrl);
|
||||
} else if (currentPlayer === 'artplayer') {
|
||||
initArtplayer(streamUrl);
|
||||
} else if (currentPlayer === 'plyr') {
|
||||
initPlyr(streamUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize or update Video.js player
|
||||
* @param {string} streamUrl - URL to play
|
||||
* Initialize Video.js player
|
||||
*/
|
||||
function initPlayer(streamUrl) {
|
||||
function initVideoJS(streamUrl) {
|
||||
// Hide other containers
|
||||
$('#artplayer-container').hide();
|
||||
$('#plyr-container').hide();
|
||||
$('#videojs-container').show();
|
||||
|
||||
if (!videoPlayer) {
|
||||
videoPlayer = videojs('video-player', {
|
||||
controls: true,
|
||||
@@ -157,22 +161,84 @@ var VideoModal = (function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-next on video end
|
||||
videoPlayer.on('ended', function() {
|
||||
var autoNextEnabled = $('#auto-next-checkbox').is(':checked');
|
||||
if (autoNextEnabled && currentPlaylistIndex < playlist.length - 1) {
|
||||
currentPlaylistIndex++;
|
||||
playVideoAtIndex(currentPlaylistIndex);
|
||||
}
|
||||
});
|
||||
videoPlayer.on('ended', handleVideoEnded);
|
||||
}
|
||||
|
||||
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Artplayer
|
||||
*/
|
||||
function initArtplayer(streamUrl) {
|
||||
// Hide other containers
|
||||
$('#videojs-container').hide();
|
||||
$('#plyr-container').hide();
|
||||
$('#artplayer-container').show().empty();
|
||||
|
||||
if (artPlayer) {
|
||||
artPlayer.destroy();
|
||||
artPlayer = null;
|
||||
}
|
||||
|
||||
artPlayer = new Artplayer({
|
||||
container: '#artplayer-container',
|
||||
url: streamUrl,
|
||||
autoplay: false,
|
||||
pip: true,
|
||||
screenshot: true,
|
||||
setting: true,
|
||||
playbackRate: true,
|
||||
aspectRatio: true,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
theme: '#3b82f6'
|
||||
});
|
||||
|
||||
artPlayer.on('video:ended', handleVideoEnded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Plyr player
|
||||
*/
|
||||
function initPlyr(streamUrl) {
|
||||
// Hide other containers
|
||||
$('#videojs-container').hide();
|
||||
$('#artplayer-container').hide();
|
||||
$('#plyr-container').show();
|
||||
|
||||
// Set source
|
||||
$('#plyr-player').attr('src', streamUrl);
|
||||
|
||||
if (!plyrPlayer) {
|
||||
plyrPlayer = new Plyr('#plyr-player', {
|
||||
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'settings', 'pip', 'fullscreen'],
|
||||
settings: ['quality', 'speed'],
|
||||
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 2] }
|
||||
});
|
||||
|
||||
plyrPlayer.on('ended', handleVideoEnded);
|
||||
} else {
|
||||
plyrPlayer.source = {
|
||||
type: 'video',
|
||||
sources: [{ src: streamUrl, type: 'video/mp4' }]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle video ended event (auto-next)
|
||||
*/
|
||||
function handleVideoEnded() {
|
||||
var autoNextEnabled = $('#auto-next-checkbox').is(':checked');
|
||||
if (autoNextEnabled && currentPlaylistIndex < playlist.length - 1) {
|
||||
currentPlaylistIndex++;
|
||||
playVideoAtIndex(currentPlaylistIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play video at specific playlist index
|
||||
* @param {number} index - Playlist index
|
||||
*/
|
||||
function playVideoAtIndex(index) {
|
||||
if (index < 0 || index >= playlist.length) return;
|
||||
@@ -180,14 +246,73 @@ var VideoModal = (function() {
|
||||
var item = playlist[index];
|
||||
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(item.path);
|
||||
|
||||
if (videoPlayer) {
|
||||
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
|
||||
videoPlayer.play();
|
||||
}
|
||||
initPlayerWithUrl(streamUrl);
|
||||
|
||||
// Try to auto-play
|
||||
setTimeout(function() {
|
||||
if (currentPlayer === 'videojs' && videoPlayer) videoPlayer.play();
|
||||
else if (currentPlayer === 'artplayer' && artPlayer) artPlayer.play = true;
|
||||
else if (currentPlayer === 'plyr' && plyrPlayer) plyrPlayer.play();
|
||||
}, 100);
|
||||
|
||||
updatePlaylistUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal with a file path (fetches playlist from server)
|
||||
*/
|
||||
function openWithPath(filePath) {
|
||||
$.ajax({
|
||||
url: '/' + config.package_name + '/ajax/' + config.sub + '/get_playlist?path=' + encodeURIComponent(filePath),
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function(data) {
|
||||
playlist = data.playlist || [];
|
||||
currentPlaylistIndex = data.current_index || 0;
|
||||
currentPlayingPath = filePath;
|
||||
|
||||
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
||||
initPlayerWithUrl(streamUrl);
|
||||
updatePlaylistUI();
|
||||
$('#videoModal').modal('show');
|
||||
},
|
||||
error: function() {
|
||||
playlist = [{ name: filePath.split('/').pop(), path: filePath }];
|
||||
currentPlaylistIndex = 0;
|
||||
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
||||
initPlayerWithUrl(streamUrl);
|
||||
updatePlaylistUI();
|
||||
$('#videoModal').modal('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal with a direct stream URL
|
||||
*/
|
||||
function openWithUrl(streamUrl, title) {
|
||||
playlist = [{ name: title || 'Video', path: streamUrl }];
|
||||
currentPlaylistIndex = 0;
|
||||
initPlayerWithUrl(streamUrl);
|
||||
updatePlaylistUI();
|
||||
$('#videoModal').modal('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal with a playlist array
|
||||
*/
|
||||
function openWithPlaylist(playlistData, startIndex) {
|
||||
playlist = playlistData || [];
|
||||
currentPlaylistIndex = startIndex || 0;
|
||||
if (playlist.length > 0) {
|
||||
var filePath = playlist[currentPlaylistIndex].path;
|
||||
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
||||
initPlayerWithUrl(streamUrl);
|
||||
updatePlaylistUI();
|
||||
$('#videoModal').modal('show');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update playlist UI (dropdown, external player buttons)
|
||||
*/
|
||||
|
||||
@@ -5,21 +5,44 @@
|
||||
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
|
||||
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
|
||||
|
||||
<!-- Artplayer CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/artplayer/dist/artplayer.js"></script>
|
||||
|
||||
<!-- Plyr CDN -->
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
||||
<script src="https://cdn.plyr.io/3.7.8/plyr.js"></script>
|
||||
|
||||
<!-- Video Player Modal -->
|
||||
<div class="modal fade" id="videoModal" tabindex="-1" role="dialog" aria-labelledby="videoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content" style="background: #0f172a; border-radius: 12px;">
|
||||
<div class="modal-header" style="border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<h5 class="modal-title" id="videoModalLabel" style="color: #f1f5f9;">비디오 플레이어</h5>
|
||||
<div class="ml-auto d-flex align-items-center">
|
||||
<select id="player-select" class="form-control form-control-sm mr-3" style="width: auto; background: rgba(255,255,255,0.1); color: white; border: 1px solid rgba(255,255,255,0.2);">
|
||||
<option value="videojs">VideoJS</option>
|
||||
<option value="artplayer">Artplayer</option>
|
||||
<option value="plyr">Plyr</option>
|
||||
</select>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close" style="color: #f1f5f9;">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 0;">
|
||||
<div class="video-container">
|
||||
<!-- Video.js Player -->
|
||||
<div id="videojs-container" style="width: 100%; height: 100%;">
|
||||
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy m-auto" controls preload="auto" playsinline webkit-playsinline>
|
||||
<p class="vjs-no-js">JavaScript가 필요합니다.</p>
|
||||
</video>
|
||||
</div>
|
||||
<!-- Artplayer Container -->
|
||||
<div id="artplayer-container" style="display: none; width: 100%; height: 100%; min-height: 450px;"></div>
|
||||
<!-- Plyr Container -->
|
||||
<div id="plyr-container" style="display: none; width: 100%; height: 100%;">
|
||||
<video id="plyr-player" playsinline controls style="width: 100%; height: 100%;"></video>
|
||||
</div>
|
||||
<!-- 화면 꽉 채우기 토글 버튼 (모바일용) -->
|
||||
<button id="btn-video-zoom" class="video-zoom-btn" title="화면 비율 조절">
|
||||
<i class="fa fa-expand"></i>
|
||||
|
||||
@@ -197,7 +197,10 @@
|
||||
tmp += '</div>';
|
||||
tmp += '<div class=\"card-body\">'
|
||||
tmp += '<h5 class=\"card-title\">' + data.anime_list[i].title + '</h5>';
|
||||
tmp += '<a href=\"./request?code=' + data.anime_list[i].code + '\" class=\"btn btn-primary cut-text\">' + data.anime_list[i].title + '</a>';
|
||||
tmp += '<div class=\"card-actions\">';
|
||||
tmp += '<a href=\"./request?code=' + data.anime_list[i].code + '\" class=\"btn btn-primary cut-text\"><i class="fa fa-info-circle"></i> 상세</a>';
|
||||
tmp += '<button type=\"button\" class=\"btn btn-sch btn-add-schedule\" data-code=\"' + data.anime_list[i].code + '\" data-title=\"' + data.anime_list[i].title.replace(/"/g, '"') + '\"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
@@ -262,7 +265,10 @@
|
||||
tmp += '</div>';
|
||||
tmp += '<div class="card-body">'
|
||||
tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>';
|
||||
tmp += '<a href="' + request_url + '" class="btn btn-primary cut-text">' + data.anime_list[i].title + '</a>';
|
||||
tmp += '<div class="card-actions">';
|
||||
tmp += '<a href="' + request_url + '" class="btn btn-primary cut-text"><i class="fa fa-info-circle"></i> 상세</a>';
|
||||
tmp += '<button type="button" class="btn btn-sch btn-add-schedule" data-code="' + data.anime_list[i].code + '" data-title="' + data.anime_list[i].title.replace(/"/g, '"') + '"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
@@ -314,7 +320,10 @@
|
||||
tmp += '</div>';
|
||||
tmp += '<div class="card-body">'
|
||||
tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>';
|
||||
tmp += '<a href="./request?code=' + data.anime_list[i].code + '" class="btn btn-primary cut-text">' + data.anime_list[i].title + '</a>';
|
||||
tmp += '<div class="card-actions">';
|
||||
tmp += '<a href="./request?code=' + data.anime_list[i].code + '" class="btn btn-primary cut-text"><i class="fa fa-info-circle"></i> 상세</a>';
|
||||
tmp += '<button type="button" class="btn btn-sch btn-add-schedule" data-code="' + data.anime_list[i].code + '" data-title="' + data.anime_list[i].title.replace(/"/g, '"') + '"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
@@ -578,6 +587,38 @@
|
||||
};
|
||||
|
||||
document.addEventListener("scroll", debounce(onScroll, 300));
|
||||
|
||||
// ================================
|
||||
// 스케쥴 등록 버튼 핸들러
|
||||
// ================================
|
||||
$('body').on('click', '.btn-add-schedule', function(e) {
|
||||
e.preventDefault();
|
||||
var code = $(this).data('code');
|
||||
var title = $(this).data('title');
|
||||
var btn = $(this);
|
||||
|
||||
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i>');
|
||||
|
||||
$.ajax({
|
||||
url: '/' + package_name + '/ajax/' + sub + '/add_schedule',
|
||||
type: 'POST',
|
||||
data: { code: code, title: title },
|
||||
dataType: 'json',
|
||||
success: function(ret) {
|
||||
if (ret.ret === 'success' || ret.ret === 'exist') {
|
||||
$.notify('<strong>' + (ret.ret === 'exist' ? '이미 등록됨' : '스케쥴 등록 완료') + '</strong>', { type: ret.ret === 'exist' ? 'info' : 'success' });
|
||||
} else {
|
||||
$.notify('<strong>등록 실패: ' + (ret.msg || ret.ret) + '</strong>', { type: 'warning' });
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$.notify('<strong>스케쥴 등록 중 오류</strong>', { type: 'danger' });
|
||||
},
|
||||
complete: function() {
|
||||
btn.prop('disabled', false).html('<i class="fa fa-calendar-plus-o"></i> 스케쥴');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
button.code-button {
|
||||
@@ -1221,6 +1262,57 @@
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
/* Card Actions Layout */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.card-actions .btn {
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.card-actions .btn-sch {
|
||||
background: linear-gradient(135deg, #f472b6 0%, #ec4899 100%) !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
}
|
||||
.card-actions .btn-sch:hover {
|
||||
background: linear-gradient(135deg, #ec4899 0%, #db2777 100%) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Reduced Wrapper Margins */
|
||||
#yommi_wrapper {
|
||||
max-width: 100% !important;
|
||||
padding: 10px 8px !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
#yommi_wrapper {
|
||||
max-width: 95% !important;
|
||||
padding: 20px 15px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.row.infinite-scroll > [class*="col-"] {
|
||||
padding: 6px !important;
|
||||
}
|
||||
.card-body {
|
||||
padding: 10px !important;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
.card-actions .btn {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
@@ -1231,6 +1323,5 @@ $(document).ready(function(){
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -82,6 +82,93 @@
|
||||
</div>
|
||||
</div>
|
||||
{{ macros.setting_checkbox('linkkf_auto_mode_all', '에피소드 모두 받기', value=arg['linkkf_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }}
|
||||
{{ macros.setting_checkbox('linkkf_auto_download_new', '새 에피소드 자동 다운로드', value=arg['linkkf_auto_download_new'], desc=['On : 새 에피소드 감지 시 자동으로 큐에 추가합니다.', 'Off : 알림만 보내고 다운로드는 수동으로 합니다.']) }}
|
||||
|
||||
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
|
||||
<div class="col-sm-3 set-left"><strong>모니터링 주기</strong></div>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-control form-control-sm col-sm-3" id="linkkf_monitor_interval" name="linkkf_monitor_interval">
|
||||
<option value="5" {% if arg.get('linkkf_monitor_interval', '10') == '5' %}selected{% endif %}>5분</option>
|
||||
<option value="10" {% if arg.get('linkkf_monitor_interval', '10') == '10' or not arg.get('linkkf_monitor_interval') %}selected{% endif %}>10분 (기본)</option>
|
||||
<option value="15" {% if arg.get('linkkf_monitor_interval', '10') == '15' %}selected{% endif %}>15분</option>
|
||||
<option value="30" {% if arg.get('linkkf_monitor_interval', '10') == '30' %}selected{% endif %}>30분</option>
|
||||
<option value="60" {% if arg.get('linkkf_monitor_interval', '10') == '60' %}selected{% endif %}>1시간</option>
|
||||
</select>
|
||||
<div style="padding-top:5px;"><em class="text-muted">'all' 모드 사용 시 사이트를 확인하는 주기입니다.</em></div>
|
||||
</div>
|
||||
</div>
|
||||
{{ macros.m_tab_content_end() }}
|
||||
|
||||
{{ macros.m_tab_content_start('action', false) }}
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-12">
|
||||
<h5 class="text-white mb-3"><i class="bi bi-lightning-fill mr-2"></i>수동 작업</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-sm-3 set-left"><strong>스케줄러 1회 실행</strong></div>
|
||||
<div class="col-sm-9">
|
||||
<button type="button" class="btn btn-outline-success btn-sm" id="global_one_execute_btn">
|
||||
<i class="bi bi-play-circle mr-1"></i> 1회 실행
|
||||
</button>
|
||||
<div style="padding-top:5px;"><em class="text-muted">자동 다운로드 스케줄러를 즉시 1회 실행합니다.</em></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-sm-3 set-left"><strong>DB 초기화</strong></div>
|
||||
<div class="col-sm-9">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" id="global_reset_db_btn">
|
||||
<i class="bi bi-trash mr-1"></i> DB 초기화
|
||||
</button>
|
||||
<div style="padding-top:5px;"><em class="text-muted">다운로드 기록 DB를 초기화합니다.</em></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="border-color: rgba(255,255,255,0.1); margin: 30px 0;">
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-12">
|
||||
<h5 class="text-white mb-3"><i class="bi bi-bell-fill mr-2"></i>알림 설정</h5>
|
||||
</div>
|
||||
</div>
|
||||
{{ macros.setting_checkbox('linkkf_notify_enabled', '알림 활성화', value=arg['linkkf_notify_enabled'], desc='새 에피소드가 큐에 추가되면 알림을 보냅니다.') }}
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-12">
|
||||
<h6 class="text-info mb-2"><i class="bi bi-discord mr-1"></i> Discord</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
|
||||
<div class="col-sm-3 set-left"><strong>Discord Webhook URL</strong></div>
|
||||
<div class="col-sm-9">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control form-control-sm" id="linkkf_discord_webhook_url" name="linkkf_discord_webhook_url" value="{{arg['linkkf_discord_webhook_url']}}">
|
||||
<div class="input-group-append">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="copy_discord_url_btn" title="URL 복사">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding-top:5px;"><em class="text-muted">Discord 서버 설정 → 연동 → 웹훅에서 URL을 복사하세요.</em></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3 mt-4">
|
||||
<div class="col-sm-12">
|
||||
<h6 class="text-info mb-2"><i class="bi bi-telegram mr-1"></i> Telegram</h6>
|
||||
</div>
|
||||
</div>
|
||||
{{ macros.setting_input_text('linkkf_telegram_bot_token', 'Telegram Bot Token', col='9', value=arg['linkkf_telegram_bot_token'], desc='@BotFather에서 생성한 봇 토큰입니다.') }}
|
||||
{{ macros.setting_input_text('linkkf_telegram_chat_id', 'Telegram Chat ID', col='4', value=arg['linkkf_telegram_chat_id'], desc='알림을 받을 채팅방 ID (개인: 숫자, 그룹: -숫자)') }}
|
||||
|
||||
<div class="row mb-3 mt-3">
|
||||
<div class="col-sm-3"></div>
|
||||
<div class="col-sm-9">
|
||||
<button type="button" class="btn btn-outline-info btn-sm" id="test_notify_btn">
|
||||
<i class="bi bi-send mr-1"></i> 테스트 알림 전송
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ macros.m_tab_content_end() }}
|
||||
|
||||
</div><!--tab-content-->
|
||||
@@ -371,6 +458,94 @@ $("body").on('click', '#go_btn', function(e){
|
||||
window.open(url, "_blank");
|
||||
});
|
||||
|
||||
// 1회 실행 버튼
|
||||
$(document).on('click', '#global_one_execute_btn', function(e){
|
||||
e.preventDefault();
|
||||
$.ajax({
|
||||
url: '/'+package_name+'/ajax/'+sub+'/immediately_execute',
|
||||
type: "POST",
|
||||
cache: false,
|
||||
dataType: "json",
|
||||
success: function(ret) {
|
||||
if (ret.ret == 'success') {
|
||||
$.notify('스케줄러 1회 실행을 시작합니다.', {type:'success'});
|
||||
} else {
|
||||
$.notify(ret.msg || '실행 실패', {type:'danger'});
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
$.notify('에러: ' + error, {type:'danger'});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// DB 초기화 버튼
|
||||
$(document).on('click', '#global_reset_db_btn', function(e){
|
||||
e.preventDefault();
|
||||
if (!confirm('정말 DB를 초기화하시겠습니까?')) return;
|
||||
$.ajax({
|
||||
url: '/'+package_name+'/ajax/'+sub+'/reset_db',
|
||||
type: "POST",
|
||||
cache: false,
|
||||
dataType: "json",
|
||||
success: function(ret) {
|
||||
if (ret.ret == 'success') {
|
||||
$.notify('DB가 초기화되었습니다.', {type:'success'});
|
||||
} else {
|
||||
$.notify(ret.msg || '초기화 실패', {type:'danger'});
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
$.notify('에러: ' + error, {type:'danger'});
|
||||
}
|
||||
});
|
||||
});
|
||||
// Discord Webhook URL 복사 버튼
|
||||
$(document).on('click', '#copy_discord_url_btn', function(e){
|
||||
e.preventDefault();
|
||||
var url = $('#linkkf_discord_webhook_url').val();
|
||||
if (!url) {
|
||||
$.notify('복사할 URL이 없습니다.', {type:'warning'});
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
$.notify('URL이 클립보드에 복사되었습니다.', {type:'success'});
|
||||
}).catch(function() {
|
||||
// Fallback for older browsers
|
||||
var temp = $('<input>').val(url).appendTo('body').select();
|
||||
document.execCommand('copy');
|
||||
temp.remove();
|
||||
$.notify('URL이 클립보드에 복사되었습니다.', {type:'success'});
|
||||
});
|
||||
});
|
||||
|
||||
// 테스트 알림 버튼
|
||||
$(document).on('click', '#test_notify_btn', function(e){
|
||||
e.preventDefault();
|
||||
var btn = $(this);
|
||||
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin mr-1"></i> 전송 중...');
|
||||
|
||||
$.ajax({
|
||||
url: '/'+package_name+'/ajax/'+sub+'/test_notification',
|
||||
type: "POST",
|
||||
cache: false,
|
||||
dataType: "json",
|
||||
success: function(ret) {
|
||||
if (ret.ret == 'success') {
|
||||
$.notify('테스트 알림을 전송했습니다!', {type:'success'});
|
||||
} else {
|
||||
$.notify(ret.msg || '알림 전송 실패', {type:'danger'});
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
$.notify('에러: ' + error, {type:'danger'});
|
||||
},
|
||||
complete: function() {
|
||||
btn.prop('disabled', false).html('<i class="bi bi-send mr-1"></i> 테스트 알림 전송');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================
|
||||
// 폴더 탐색 기능
|
||||
// ======================================
|
||||
@@ -562,10 +737,15 @@ function getDragAfterElement(container, x) {
|
||||
// ======================================
|
||||
// 자가 업데이트 기능
|
||||
// ======================================
|
||||
$('#btn-self-update').on('click', function() {
|
||||
if (!confirm('최신 코드를 다운로드하고 플러그인을 리로드하시겠습니까?')) return;
|
||||
$(document).on('click', '#btn-self-update', function() {
|
||||
$('#updateConfirmModal').modal('show');
|
||||
});
|
||||
|
||||
var btn = $(this);
|
||||
// 실제 업데이트 실행 (모달에서 확인 버튼 클릭 시)
|
||||
$(document).on('click', '#confirmUpdateBtn', function() {
|
||||
$('#updateConfirmModal').modal('hide');
|
||||
|
||||
var btn = $('#btn-self-update');
|
||||
var originalHTML = btn.html();
|
||||
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin"></i> 업데이트 중...');
|
||||
|
||||
@@ -590,4 +770,68 @@ $('#btn-self-update').on('click', function() {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Update Confirmation Modal (Linkkf Green Theme) -->
|
||||
<div class="modal fade" id="updateConfirmModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content animate__animated animate__zoomIn" style="background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%); border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);">
|
||||
<div class="modal-body text-center" style="padding: 40px 30px;">
|
||||
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(5, 150, 105, 0.2) 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; border: 2px solid rgba(16, 185, 129, 0.3);">
|
||||
<i class="bi bi-arrow-repeat" style="color: #10b981; font-size: 36px;"></i>
|
||||
</div>
|
||||
<h4 style="color: #f1f5f9; font-weight: 700; margin-bottom: 12px;">플러그인 업데이트</h4>
|
||||
<p style="color: #94a3b8; font-size: 15px; margin-bottom: 8px;">최신 코드를 다운로드하고 플러그인을 리로드합니다.</p>
|
||||
<p style="color: #64748b; font-size: 13px; margin-bottom: 32px;"><i class="bi bi-info-circle"></i> 서버 재시작 없이 즉시 적용됩니다.</p>
|
||||
<div style="display: flex; gap: 12px; justify-content: center;">
|
||||
<button type="button" class="btn" data-dismiss="modal" style="width: 120px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: #94a3b8; border-radius: 10px; padding: 12px 24px; font-weight: 600;">취소</button>
|
||||
<button type="button" id="confirmUpdateBtn" class="btn" style="width: 140px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; border-radius: 10px; padding: 12px 24px; font-weight: 600; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);">
|
||||
<i class="bi bi-download"></i> 업데이트
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Update Button Enhanced Visibility (Linkkf Green) */
|
||||
#btn-self-update {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
font-weight: 600;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
#btn-self-update:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
#btn-self-update:disabled {
|
||||
background: linear-gradient(135deg, #475569 0%, #334155 100%) !important;
|
||||
color: #94a3b8 !important;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
#btn-self-update .bi-arrow-repeat.spin,
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Animate.css for modal */
|
||||
.animate__animated { animation-duration: 0.3s; }
|
||||
.animate__zoomIn { animation-name: zoomIn; }
|
||||
@keyframes zoomIn {
|
||||
from { opacity: 0; transform: scale3d(0.3, 0.3, 0.3); }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -861,10 +861,15 @@ function getDragAfterElement(container, x) {
|
||||
// ======================================
|
||||
// 자가 업데이트 기능
|
||||
// ======================================
|
||||
$('#btn-self-update').on('click', function() {
|
||||
if (!confirm('최신 코드를 다운로드하고 플러그인을 리로드하시겠습니까?')) return;
|
||||
$(document).on('click', '#btn-self-update', function() {
|
||||
$('#updateConfirmModal').modal('show');
|
||||
});
|
||||
|
||||
var btn = $(this);
|
||||
// 실제 업데이트 실행 (이벤트 위임 - 모달이 스크립트 이후에 있으므로)
|
||||
$(document).on('click', '#confirmUpdateBtn', function() {
|
||||
$('#updateConfirmModal').modal('hide');
|
||||
|
||||
var btn = $('#btn-self-update');
|
||||
var originalHTML = btn.html();
|
||||
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin"></i> 업데이트 중...');
|
||||
|
||||
@@ -874,8 +879,11 @@ $('#btn-self-update').on('click', function() {
|
||||
dataType: 'json',
|
||||
success: function(ret) {
|
||||
if (ret.ret === 'success') {
|
||||
$.notify('<strong>업데이트 완료!</strong> 페이지를 새로고침합니다.', {type: 'success'});
|
||||
setTimeout(function() { location.reload(); }, 1500);
|
||||
if (ret.needs_restart) {
|
||||
$.notify('<strong>⚠️ 모델 변경 감지!</strong><br>서버 재시작이 필요합니다.', {type: 'warning', delay: 10000});
|
||||
} else {
|
||||
$.notify('<strong>✅ 업데이트 완료!</strong><br>페이지를 새로고침하세요.', {type: 'success', delay: 5000});
|
||||
}
|
||||
} else {
|
||||
$.notify('<strong>업데이트 실패: ' + ret.msg + '</strong>', {type: 'danger'});
|
||||
}
|
||||
@@ -891,4 +899,64 @@ $('#btn-self-update').on('click', function() {
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Update Confirmation Modal -->
|
||||
<div class="modal fade" id="updateConfirmModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content animate__animated animate__zoomIn" style="background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);">
|
||||
<div class="modal-body text-center" style="padding: 40px 30px;">
|
||||
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(99, 102, 241, 0.2) 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; border: 2px solid rgba(59, 130, 246, 0.3);">
|
||||
<i class="bi bi-arrow-repeat" style="color: #3b82f6; font-size: 36px;"></i>
|
||||
</div>
|
||||
<h4 style="color: #f1f5f9; font-weight: 700; margin-bottom: 12px;">플러그인 업데이트</h4>
|
||||
<p style="color: #94a3b8; font-size: 15px; margin-bottom: 8px;">최신 코드를 다운로드하고 플러그인을 리로드합니다.</p>
|
||||
<p style="color: #64748b; font-size: 13px; margin-bottom: 32px;"><i class="bi bi-info-circle"></i> 서버 재시작 없이 즉시 적용됩니다.</p>
|
||||
<div style="display: flex; gap: 12px; justify-content: center;">
|
||||
<button type="button" class="btn" data-dismiss="modal" style="width: 120px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: #94a3b8; border-radius: 10px; padding: 12px 24px; font-weight: 600;">취소</button>
|
||||
<button type="button" id="confirmUpdateBtn" class="btn" style="width: 140px; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); border: none; color: white; border-radius: 10px; padding: 12px 24px; font-weight: 600; box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);">
|
||||
<i class="bi bi-download"></i> 업데이트
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Update Button Enhanced Visibility */
|
||||
#btn-self-update {
|
||||
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%) !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
font-weight: 600;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
#btn-self-update:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.4);
|
||||
}
|
||||
#btn-self-update:disabled {
|
||||
background: linear-gradient(135deg, #475569 0%, #334155 100%) !important;
|
||||
color: #94a3b8 !important;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
#btn-self-update .bi-arrow-repeat.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Animate.css for modal */
|
||||
.animate__zoomIn {
|
||||
animation-duration: 0.3s;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user