v0.2.0: 플러그인 콜백 시스템, 버그 수정, UI 개선

This commit is contained in:
2026-01-06 18:55:06 +09:00
parent fac33cff0b
commit e33e568cb2
17 changed files with 1158 additions and 405 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
*.pyo
.DS_Store

View File

@@ -1,12 +1,24 @@
# gommi_download_manager (GDM) # gommi_download_manager (GDM)
FlaskFarm 범용 다운로더 큐 플러그인 (v0.1.0) FlaskFarm 범용 다운로더 큐 플러그인 (v0.2.0)
## 🆕 0.1.0 업데이트 (Latest) ## 🆕 0.2.0 업데이트 (2026-01-06)
- **다운로드 속도 제한**: 설정 페이지에서 대역폭 제한 설정 가능 (무제한, 1MB/s, 5MB/s...)
- **UI 리뉴얼**: 고급스러운 Dark Metallic 디자인 & 반응형 웹 지원 ### 새 기능
- **안정성 강화**: 서버 재시작 시 대기 중인 다운로드 상태 복원 (Queue Persistence) - **플러그인 콜백 시스템**: 다운로드 완료 시 호출 플러그인에 상태 알림
- **목록 관리**: 전체 삭제 및 자동 목록 갱신 기능 (Flickr-free) - **외부 플러그인 통합 강화**: `caller_plugin`, `callback_id` 파라미터로 호출자 추적
- **HLS ffmpeg 헤더 수정**: None 값 필터링으로 에러 방지
### 버그 수정
- PluginManager API 호환성 수정 (`F.plugin_instance_list``F.PluginManager.all_package_list`)
- 완료된 다운로드 진행률 100% 표시 수정
- 큐 목록 URL 표시 제거 (깔끔한 UI)
### UI 개선
- 다크 메탈릭 디자인 유지
- 완료 상태 표시 개선
---
## 주요 기능 ## 주요 기능
@@ -20,16 +32,29 @@ FlaskFarm 범용 다운로더 큐 플러그인 (v0.1.0)
```python ```python
from gommi_download_manager.mod_queue import ModuleQueue from gommi_download_manager.mod_queue import ModuleQueue
# 다운로드 추가 (속도 제한은 사용자가 설정한 값 자동 적용) # 다운로드 추가 (콜백 지원)
task = ModuleQueue.add_download( task = ModuleQueue.add_download(
url='https://www.youtube.com/watch?v=...', url='https://www.youtube.com/watch?v=...',
save_path='/path/to/save', # 플러그인별 저장 경로 우선 적용 save_path='/path/to/save',
filename='video.mp4', # 선택 filename='video.mp4',
source_type='auto', # 자동 감지 source_type='auto',
caller_plugin='youtube', # 호출자 식별 caller_plugin='my_plugin_name', # 콜백 호출 시 식별
callback_id='unique_item_id', # 콜백 데이터에 포함
) )
``` ```
## 콜백 수신하기
호출 플러그인에서 `plugin_callback` 메서드를 정의하면 다운로드 완료 시 자동 호출됩니다:
```python
class MyModule:
def plugin_callback(self, data):
# data = {'callback_id': ..., 'status': 'completed', 'filepath': ..., 'error': ...}
if data['status'] == 'completed':
print(f"다운로드 완료: {data['filepath']}")
```
## 설정 가이드 ## 설정 가이드
웹 인터페이스 (`/gommi_download_manager/queue/setting`)에서 다음을 설정할 수 있습니다: 웹 인터페이스 (`/gommi_download_manager/queue/setting`)에서 다음을 설정할 수 있습니다:

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -51,10 +51,35 @@ class FfmpegHlsDownloader(BaseDownloader):
# 헤더 추가 # 헤더 추가
headers = options.get('headers', {}) headers = options.get('headers', {})
cookies_file = options.get('cookies_file')
if headers: if headers:
header_str = '\r\n'.join([f'{k}: {v}' for k, v in headers.items()]) header_str = '\r\n'.join([f'{k}: {v}' for k, v in headers.items() if v is not None])
if header_str:
cmd.extend(['-headers', header_str]) cmd.extend(['-headers', header_str])
if cookies_file and os.path.exists(cookies_file):
# FFmpeg basically uses custom headers for cookies if not using a library that supports it
# or we can pass it as a header
if 'Cookie' not in headers:
try:
with open(cookies_file, 'r') as f:
cookie_lines = []
for line in f:
if line.startswith('#') or not line.strip(): continue
parts = line.strip().split('\t')
if len(parts) >= 7:
cookie_lines.append(f"{parts[5]}={parts[6]}")
if cookie_lines:
cookie_str = '; '.join(cookie_lines)
if headers:
header_str += f'\r\nCookie: {cookie_str}'
cmd[-1] = header_str # Update headers
else:
cmd.extend(['-headers', f'Cookie: {cookie_str}'])
except Exception as ce:
logger.error(f"Failed to read cookies_file: {ce}")
# 입력 URL # 입력 URL
cmd.extend(['-i', url]) cmd.extend(['-i', url])
@@ -64,7 +89,7 @@ class FfmpegHlsDownloader(BaseDownloader):
# 출력 파일 # 출력 파일
cmd.append(filepath) cmd.append(filepath)
logger.debug(f'ffmpeg 명령어: {" ".join(cmd[:10])}...') logger.debug(f'ffmpeg 명령어: {" ".join(cmd[:15])}...')
# 먼저 duration 얻기 위해 ffprobe 실행 # 먼저 duration 얻기 위해 ffprobe 실행
duration = self._get_duration(url, options.get('ffprobe_path', 'ffprobe'), headers) duration = self._get_duration(url, options.get('ffprobe_path', 'ffprobe'), headers)
@@ -78,13 +103,17 @@ class FfmpegHlsDownloader(BaseDownloader):
bufsize=1 bufsize=1
) )
# 출력 파싱 # 출력 파싱 및 에러 메시지 캡처를 위한 변수
last_lines = []
for line in self._process.stdout: for line in self._process.stdout:
if self._cancelled: if self._cancelled:
self._process.terminate() self._process.terminate()
return {'success': False, 'error': 'Cancelled'} return {'success': False, 'error': 'Cancelled'}
line = line.strip() line = line.strip()
if line:
last_lines.append(line)
if len(last_lines) > 20: last_lines.pop(0)
# 진행률 계산 (time= 파싱) # 진행률 계산 (time= 파싱)
if duration > 0 and progress_callback: if duration > 0 and progress_callback:
@@ -109,7 +138,9 @@ class FfmpegHlsDownloader(BaseDownloader):
progress_callback(100, '', '') progress_callback(100, '', '')
return {'success': True, 'filepath': filepath} return {'success': True, 'filepath': filepath}
else: else:
return {'success': False, 'error': f'FFmpeg exit code: {self._process.returncode}'} error_log = "\n".join(last_lines)
logger.error(f"FFmpeg failed with return code {self._process.returncode}. Last output:\n{error_log}")
return {'success': False, 'error': f'FFmpeg Error({self._process.returncode}): {last_lines[-1] if last_lines else "Unknown"}'}
except Exception as e: except Exception as e:
logger.error(f'FfmpegHls download error: {e}') logger.error(f'FfmpegHls download error: {e}')

View File

@@ -46,9 +46,11 @@ class YtdlpAria2Downloader(BaseDownloader):
output_template = os.path.join(save_path, '%(title)s.%(ext)s') output_template = os.path.join(save_path, '%(title)s.%(ext)s')
# yt-dlp 명령어 구성 # yt-dlp 명령어 구성
# 기본 명령어 구성 (항상 verbose 로그 남기도록 수정)
cmd = [ cmd = [
'yt-dlp', 'yt-dlp',
'--newline', # 진행률 파싱용 '--newline', # 진행률 파싱용
'--no-check-certificate',
'-o', output_template, '-o', output_template,
] ]
@@ -76,10 +78,16 @@ class YtdlpAria2Downloader(BaseDownloader):
logger.debug(f'aria2c 사용: {connections}개 연결 (속도제한 {log_rate_msg})') logger.debug(f'aria2c 사용: {connections}개 연결 (속도제한 {log_rate_msg})')
# 포맷 선택 # 포맷 선택
format_spec = options.get('format', 'bestvideo+bestaudio/best') format_spec = options.get('format')
if not format_spec:
if options.get('extract_audio'):
format_spec = 'bestaudio/best'
else:
format_spec = 'bestvideo+bestaudio/best'
cmd.extend(['-f', format_spec]) cmd.extend(['-f', format_spec])
# 병합 포맷 # 병합 포맷 (비디오인 경우에만)
if not options.get('extract_audio'):
merge_format = options.get('merge_output_format', 'mp4') merge_format = options.get('merge_output_format', 'mp4')
cmd.extend(['--merge-output-format', merge_format]) cmd.extend(['--merge-output-format', merge_format])
@@ -91,10 +99,53 @@ class YtdlpAria2Downloader(BaseDownloader):
if options.get('proxy'): if options.get('proxy'):
cmd.extend(['--proxy', options['proxy']]) cmd.extend(['--proxy', options['proxy']])
# FFmpeg 경로 자동 감지 및 설정
ffmpeg_path = options.get('ffmpeg_path') or P.ModelSetting.get('ffmpeg_path')
# 경로가 비어있거나 'ffmpeg' 같은 단순 이름인 경우 자동 감지 시도
if not ffmpeg_path or ffmpeg_path == 'ffmpeg':
import shutil
detected_path = shutil.which('ffmpeg')
if detected_path:
ffmpeg_path = detected_path
else:
# Mac Homebrew 등 일반적인 경로 추가 탐색
common_paths = [
'/opt/homebrew/bin/ffmpeg',
'/usr/local/bin/ffmpeg',
'/usr/bin/ffmpeg'
]
for p in common_paths:
if os.path.exists(p):
ffmpeg_path = p
break
if ffmpeg_path:
# 파일 경로인 경우 폴더 경로로 변환하거나 그대로 사용 (yt-dlp는 둘 다 지원)
cmd.extend(['--ffmpeg-location', ffmpeg_path])
logger.debug(f'[GDM] 감지된 FFmpeg 경로: {ffmpeg_path}')
# 추가 인자 (extra_args: list)
extra_args = options.get('extra_args', [])
if isinstance(extra_args, list):
cmd.extend(extra_args)
# 후처리 옵션 간편 지원 (예: {'extract_audio': True, 'audio_format': 'mp3'})
if options.get('extract_audio'):
cmd.append('--extract-audio')
if options.get('audio_format'):
cmd.extend(['--audio-format', options['audio_format']])
if options.get('embed_thumbnail'):
cmd.append('--embed-thumbnail')
if options.get('add_metadata'):
cmd.append('--add-metadata')
# URL 추가 # URL 추가
cmd.append(url) cmd.append(url)
logger.debug(f'yt-dlp 명령어: {" ".join(cmd)}') logger.info(f'[GDM] yt-dlp command: {" ".join(cmd)}')
# 프로세스 실행 # 프로세스 실행
self._process = subprocess.Popen( self._process = subprocess.Popen(
@@ -106,6 +157,7 @@ class YtdlpAria2Downloader(BaseDownloader):
) )
final_filepath = '' final_filepath = ''
last_logged_pct = -1
# 출력 파싱 # 출력 파싱
for line in self._process.stdout: for line in self._process.stdout:
@@ -114,23 +166,34 @@ class YtdlpAria2Downloader(BaseDownloader):
return {'success': False, 'error': 'Cancelled'} return {'success': False, 'error': 'Cancelled'}
line = line.strip() line = line.strip()
# logger.debug(line) if not line:
continue
# 진행률 파싱 (yt-dlp default) # 진행률 파싱 (yt-dlp default)
progress_match = re.search(r'\[download\]\s+(\d+\.?\d*)%', line) progress_match = re.search(r'\[download\]\s+(\d+\.?\d*)%', line)
# 로그 출력 여부 결정 (진행률은 5% 단위로만)
should_log = True
if progress_match:
pct = float(progress_match.group(1))
if int(pct) >= last_logged_pct + 5 or pct >= 99.9:
last_logged_pct = int(pct)
else:
should_log = False
if should_log:
logger.info(f'[GDM][yt-dlp] {line}')
# 진행률 파싱 (aria2c) # 진행률 파싱 (aria2c)
if not progress_match: if not progress_match:
# logger.error(f'DEBUG LINE: {line}') # Log raw line to debug # aria2c match
aria2_match = re.search(r'\(\s*([\d.]+)%\)', line) # Allow spaces ( 7%) aria2_match = re.search(r'\(\s*([\d.]+)%\)', line)
if aria2_match and (('DL:' in line) or ('CN:' in line)): # DL or CN must be present if aria2_match and (('DL:' in line) or ('CN:' in line)):
try: try:
progress = int(float(aria2_match.group(1))) progress = int(float(aria2_match.group(1)))
# logger.error(f'MATCHED PROGRESS: {progress}%')
speed_match = re.search(r'DL:(\S+)', line) speed_match = re.search(r'DL:(\S+)', line)
speed = speed_match.group(1) if speed_match else '' speed = speed_match.group(1) if speed_match else ''
# Strip color codes from speed if needed? output is usually clean text if no TTY
eta_match = re.search(r'ETA:(\S+)', line) eta_match = re.search(r'ETA:(\S+)', line)
eta = eta_match.group(1) if eta_match else '' eta = eta_match.group(1) if eta_match else ''
@@ -158,11 +221,14 @@ class YtdlpAria2Downloader(BaseDownloader):
progress_callback(progress, speed, eta) progress_callback(progress, speed, eta)
# 최종 파일 경로 추출 # 최종 파일 경로 추출 (Merger, VideoConvertor, Destination 모두 대응)
if '[Merger]' in line or 'Destination:' in line: if any(x in line for x in ['[Merger]', '[VideoConvertor]', 'Destination:']):
path_match = re.search(r'(?:Destination:|into\s+["\'])(.+?)(?:["\']|$)', line) path_match = re.search(r'(?:Destination:|into|to)\s+["\']?(.+?)(?:["\']|$)', line)
if path_match: if path_match:
final_filepath = path_match.group(1).strip('"\'') potential_path = path_match.group(1).strip('"\'')
# 확장자가 있는 경우만 파일 경로로 간주
if '.' in os.path.basename(potential_path):
final_filepath = potential_path
self._process.wait() self._process.wait()

View File

@@ -1,6 +1,6 @@
name: gommi_download_manager name: gommi_download_manager
package_name: gommi_download_manager package_name: gommi_download_manager
version: '0.1.0' version: '0.1.1'
description: FlaskFarm 범용 다운로더 큐 - YouTube, 애니24, 링크애니, Anilife 지원 description: FlaskFarm 범용 다운로더 큐 - YouTube, 애니24, 링크애니, Anilife 지원
developer: projectdx developer: projectdx
home: https://gitea.yommi.duckdns.org/projectdx/gommi_downloader_manager home: https://gitea.yommi.duckdns.org/projectdx/gommi_downloader_manager

View File

@@ -12,7 +12,7 @@ from enum import Enum
from flask import render_template, jsonify from flask import render_template, jsonify
from framework import F, socketio from framework import F, socketio
from .setup import P, PluginModuleBase, default_route_socketio_module, ToolUtil from framework import F, socketio
class DownloadStatus(str, Enum): class DownloadStatus(str, Enum):
@@ -25,6 +25,8 @@ class DownloadStatus(str, Enum):
CANCELLED = "cancelled" CANCELLED = "cancelled"
from plugin import PluginModuleBase
class ModuleQueue(PluginModuleBase): class ModuleQueue(PluginModuleBase):
"""다운로드 큐 관리 모듈""" """다운로드 큐 관리 모듈"""
@@ -46,23 +48,24 @@ class ModuleQueue(PluginModuleBase):
_queue_lock = threading.Lock() _queue_lock = threading.Lock()
def __init__(self, P: Any) -> None: def __init__(self, P: Any) -> None:
from .setup import default_route_socketio_module
super(ModuleQueue, self).__init__(P, name='queue', first_menu='list') super(ModuleQueue, self).__init__(P, name='queue', first_menu='list')
default_route_socketio_module(self, attach='/queue') default_route_socketio_module(self, attach='/queue')
def process_menu(self, page_name: str, req: Any) -> Any: def process_menu(self, page_name: str, req: Any) -> Any:
"""메뉴 페이지 렌더링""" """메뉴 페이지 렌더링"""
P.logger.debug(f'Page Request: {page_name}') self.P.logger.debug(f'Page Request: {page_name}')
arg = P.ModelSetting.to_dict() arg = self.P.ModelSetting.to_dict()
try: try:
arg['module_name'] = self.name arg['module_name'] = self.name
arg['package_name'] = P.package_name # 명시적 추가 arg['package_name'] = self.P.package_name # 명시적 추가
arg['path_data'] = F.config['path_data'] arg['path_data'] = F.config['path_data']
return render_template(f'{P.package_name}_{self.name}_{page_name}.html', arg=arg) return render_template(f'{self.P.package_name}_{self.name}_{page_name}.html', arg=arg)
except Exception as e: except Exception as e:
P.logger.error(f'Exception:{str(e)}') self.P.logger.error(f'Exception:{str(e)}')
P.logger.error(traceback.format_exc()) self.P.logger.error(traceback.format_exc())
return render_template('sample.html', title=f"{P.package_name}/{self.name}/{page_name}") return render_template('sample.html', title=f"{self.P.package_name}/{self.name}/{page_name}")
def process_ajax(self, command: str, req: Any) -> Any: def process_ajax(self, command: str, req: Any) -> Any:
"""AJAX 명령 처리""" """AJAX 명령 처리"""
@@ -71,18 +74,39 @@ class ModuleQueue(PluginModuleBase):
try: try:
if command == 'add': if command == 'add':
# 큐에 다운로드 추가 # 큐에 다운로드 추가
from .setup import P, ToolUtil
url = req.form['url'] url = req.form['url']
save_path = req.form.get('save_path') or ToolUtil.make_path(P.ModelSetting.get('save_path')) save_path = req.form.get('save_path') or ToolUtil.make_path(self.P.ModelSetting.get('save_path'))
filename = req.form.get('filename') filename = req.form.get('filename')
item = self.add_download(url, save_path, filename) item = self.add_download(url, save_path, filename)
ret['data'] = item.as_dict() if item else None ret['data'] = item.as_dict() if item else None
elif command == 'list': elif command == 'list':
# 진행 중인 다운로드 목록 # 진행 중인 다운로드 목록 + 최근 DB 내역 (영속성 강화)
items = [d.get_status() for d in self._downloads.values()] active_items = [d.get_status() for d in self._downloads.values()]
P.logger.debug(f'List Command: {len(items)} items') active_ids = [i['id'] for i in active_items if 'id' in i]
ret['data'] = items
# DB에서 최근 50개 가져와서 합치기
from .model import ModelDownloadItem
with F.app.app_context():
db_items = F.db.session.query(ModelDownloadItem).order_by(ModelDownloadItem.id.desc()).limit(50).all()
for db_item in db_items:
# 이미 active에 있으면 스킵
is_active = False
for ai in active_items:
if ai.get('db_id') == db_item.id:
is_active = True
break
if not is_active:
item_dict = db_item.as_dict()
item_dict['id'] = f"db_{db_item.id}"
# completed 상태면 진행률 100%로 표시
if item_dict.get('status') == 'completed':
item_dict['progress'] = 100
active_items.append(item_dict)
ret['data'] = active_items
elif command == 'cancel': elif command == 'cancel':
# 다운로드 취소 # 다운로드 취소
@@ -119,8 +143,8 @@ class ModuleQueue(PluginModuleBase):
ret['msg'] = '목록을 초기화했습니다.' ret['msg'] = '목록을 초기화했습니다.'
except Exception as e: except Exception as e:
P.logger.error(f'Exception:{str(e)}') self.P.logger.error(f'Exception:{str(e)}')
P.logger.error(traceback.format_exc()) self.P.logger.error(traceback.format_exc())
ret['ret'] = 'error' ret['ret'] = 'error'
ret['msg'] = str(e) ret['msg'] = str(e)
@@ -140,30 +164,21 @@ class ModuleQueue(PluginModuleBase):
on_progress: Optional[Callable] = None, on_progress: Optional[Callable] = None,
on_complete: Optional[Callable] = None, on_complete: Optional[Callable] = None,
on_error: Optional[Callable] = None, on_error: Optional[Callable] = None,
title: Optional[str] = None,
thumbnail: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None,
**options **options
) -> Optional['DownloadTask']: ) -> Optional['DownloadTask']:
""" """다운로드를 큐에 추가 (외부 플러그인에서 호출)"""
다운로드를 큐에 추가 (외부 플러그인에서 호출)
Args:
url: 다운로드 URL
save_path: 저장 경로
filename: 파일명 (자동 감지 가능)
source_type: 소스 타입 (auto, youtube, ani24, linkkf, anilife, http)
caller_plugin: 호출 플러그인 이름
callback_id: 콜백 식별자
on_progress: 진행률 콜백 (progress, speed, eta)
on_complete: 완료 콜백 (filepath)
on_error: 에러 콜백 (error_message)
**options: 추가 옵션 (headers, cookies 등)
Returns:
DownloadTask 인스턴스
"""
try: try:
# 옵션 평탄화 (Nesting 방지)
if 'options' in options and isinstance(options['options'], dict):
inner_options = options.pop('options')
options.update(inner_options)
# 소스 타입 자동 감지 # 소스 타입 자동 감지
if not source_type or source_type == 'auto': if not source_type or source_type == 'auto':
source_type = cls._detect_source_type(url) source_type = cls._detect_source_type(url, caller_plugin, meta)
# DownloadTask 생성 # DownloadTask 생성
task = DownloadTask( task = DownloadTask(
@@ -176,6 +191,9 @@ class ModuleQueue(PluginModuleBase):
on_progress=on_progress, on_progress=on_progress,
on_complete=on_complete, on_complete=on_complete,
on_error=on_error, on_error=on_error,
title=title,
thumbnail=thumbnail,
meta=meta,
**options **options
) )
@@ -187,6 +205,7 @@ class ModuleQueue(PluginModuleBase):
task.start() task.start()
# DB 저장 # DB 저장
import json
from .model import ModelDownloadItem from .model import ModelDownloadItem
db_item = ModelDownloadItem() db_item = ModelDownloadItem()
db_item.created_time = datetime.now() db_item.created_time = datetime.now()
@@ -197,6 +216,10 @@ class ModuleQueue(PluginModuleBase):
db_item.status = DownloadStatus.PENDING db_item.status = DownloadStatus.PENDING
db_item.caller_plugin = caller_plugin db_item.caller_plugin = caller_plugin
db_item.callback_id = callback_id db_item.callback_id = callback_id
db_item.title = title or task.title
db_item.thumbnail = thumbnail or task.thumbnail
if meta:
db_item.meta = json.dumps(meta, ensure_ascii=False)
db_item.save() db_item.save()
task.db_id = db_item.id task.db_id = db_item.id
@@ -205,6 +228,7 @@ class ModuleQueue(PluginModuleBase):
return task return task
except Exception as e: except Exception as e:
from .setup import P
P.logger.error(f'add_download error: {e}') P.logger.error(f'add_download error: {e}')
P.logger.error(traceback.format_exc()) P.logger.error(traceback.format_exc())
return None return None
@@ -220,10 +244,26 @@ class ModuleQueue(PluginModuleBase):
return list(cls._downloads.values()) return list(cls._downloads.values())
@classmethod @classmethod
def _detect_source_type(cls, url: str) -> str: def _detect_source_type(cls, url: str, caller_plugin: Optional[str] = None, meta: Optional[Dict] = None) -> str:
"""URL에서 소스 타입 자동 감지""" """URL 및 호출자 정보를 기반으로 지능적 소스 타입 감지"""
url_lower = url.lower() url_lower = url.lower()
# 1. 호출자(Plugin) 기반 우선 판단
if caller_plugin:
cp_lower = caller_plugin.lower()
if 'anilife' in cp_lower: return 'anilife'
if 'ohli24' in cp_lower or 'ani24' in cp_lower: return 'ani24'
if 'linkkf' in cp_lower: return 'linkkf'
if 'youtube' in cp_lower: return 'youtube'
# 2. 메타데이터 기반 판단
if meta and meta.get('source'):
ms_lower = meta.get('source').lower()
if ms_lower in ['ani24', 'ohli24']: return 'ani24'
if ms_lower == 'anilife': return 'anilife'
if ms_lower == 'linkkf': return 'linkkf'
# 3. URL 기반 판단
if 'youtube.com' in url_lower or 'youtu.be' in url_lower: if 'youtube.com' in url_lower or 'youtu.be' in url_lower:
return 'youtube' return 'youtube'
elif 'ani24' in url_lower or 'ohli24' in url_lower: elif 'ani24' in url_lower or 'ohli24' in url_lower:
@@ -239,11 +279,13 @@ class ModuleQueue(PluginModuleBase):
def plugin_load(self) -> None: def plugin_load(self) -> None:
"""플러그인 로드 시 초기화""" """플러그인 로드 시 초기화"""
P.logger.info('gommi_downloader 플러그인 로드') self.P.logger.info('gommi_downloader 플러그인 로드')
try: try:
# DB에서 진행 중인 작업 로드 # DB에서 진행 중인 작업 로드
with F.app.app_context(): with F.app.app_context():
from .model import ModelDownloadItem from .model import ModelDownloadItem
ModelDownloadItem.P = self.P
ModelDownloadItem.check_migration()
# 간단하게 status != completed, cancelled, error # 간단하게 status != completed, cancelled, error
items = F.db.session.query(ModelDownloadItem).filter( items = F.db.session.query(ModelDownloadItem).filter(
@@ -262,8 +304,10 @@ class ModuleQueue(PluginModuleBase):
filename=item.filename, filename=item.filename,
source_type=item.source_type, source_type=item.source_type,
caller_plugin=item.caller_plugin, caller_plugin=item.caller_plugin,
callback_id=item.callback_id callback_id=item.callback_id,
# options? DB에 저장 안함. 필요하면 추가해야 함. title=item.title,
thumbnail=item.thumbnail,
meta=item.as_dict().get('meta')
) )
task.status = DownloadStatus(item.status) task.status = DownloadStatus(item.status)
task.db_id = item.id task.db_id = item.id
@@ -277,11 +321,11 @@ class ModuleQueue(PluginModuleBase):
self._downloads[task.id] = task self._downloads[task.id] = task
task.start() task.start()
P.logger.info(f'{len(items)}개의 중단된 다운로드 작업 복원됨') self.P.logger.info(f'{len(items)}개의 중단된 다운로드 작업 복원됨')
except Exception as e: except Exception as e:
P.logger.error(f'plugin_load error: {e}') self.P.logger.error(f'plugin_load error: {e}')
P.logger.error(traceback.format_exc()) self.P.logger.error(traceback.format_exc())
def plugin_unload(self) -> None: def plugin_unload(self) -> None:
"""플러그인 언로드 시 정리""" """플러그인 언로드 시 정리"""
@@ -307,6 +351,9 @@ class DownloadTask:
on_progress: Optional[Callable] = None, on_progress: Optional[Callable] = None,
on_complete: Optional[Callable] = None, on_complete: Optional[Callable] = None,
on_error: Optional[Callable] = None, on_error: Optional[Callable] = None,
title: Optional[str] = None,
thumbnail: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None,
**options **options
): ):
with self._counter_lock: with self._counter_lock:
@@ -319,6 +366,9 @@ class DownloadTask:
self.source_type = source_type self.source_type = source_type
self.caller_plugin = caller_plugin self.caller_plugin = caller_plugin
self.callback_id = callback_id self.callback_id = callback_id
self.title = title or ''
self.thumbnail = thumbnail or ''
self.meta = meta or {}
self.options = options self.options = options
# 콜백 # 콜백
@@ -332,7 +382,7 @@ class DownloadTask:
self.speed = '' self.speed = ''
self.eta = '' self.eta = ''
self.error_message = '' self.error_message = ''
self.filepath = '' self.filepath = os.path.join(save_path, filename) if filename else ''
# 메타데이터 # 메타데이터
self.title = '' self.title = ''
@@ -382,15 +432,26 @@ class DownloadTask:
self.status = DownloadStatus.COMPLETED self.status = DownloadStatus.COMPLETED
self.filepath = result.get('filepath', '') self.filepath = result.get('filepath', '')
self.progress = 100 self.progress = 100
# DB 업데이트
self._update_db_status()
# 실시간 콜백 처리
if self._on_complete: if self._on_complete:
self._on_complete(self.filepath) self._on_complete(self.filepath)
# 플러그인 간 영구적 콜백 처리
if self.caller_plugin and self.callback_id:
self._invoke_plugin_callback()
else: else:
self.status = DownloadStatus.ERROR self.status = DownloadStatus.ERROR
self.error_message = result.get('error', 'Unknown error') self.error_message = result.get('error', 'Unknown error')
self._update_db_status()
if self._on_error: if self._on_error:
self._on_error(self.error_message) self._on_error(self.error_message)
except Exception as e: except Exception as e:
from .setup import P
P.logger.error(f'Download error: {e}') P.logger.error(f'Download error: {e}')
P.logger.error(traceback.format_exc()) P.logger.error(traceback.format_exc())
self.status = DownloadStatus.ERROR self.status = DownloadStatus.ERROR
@@ -398,6 +459,9 @@ class DownloadTask:
if self._on_error: if self._on_error:
self._on_error(self.error_message) self._on_error(self.error_message)
# 0바이트 파일 정리 (실패 시)
self._cleanup_if_empty()
finally: finally:
self._emit_status() self._emit_status()
@@ -418,7 +482,7 @@ class DownloadTask:
socketio.emit( socketio.emit(
'download_status', 'download_status',
self.get_status(), self.get_status(),
namespace=f'/{P.package_name}' namespace=f'/gommi_download_manager'
) )
except: except:
pass pass
@@ -429,6 +493,7 @@ class DownloadTask:
if self._downloader: if self._downloader:
self._downloader.cancel() self._downloader.cancel()
self.status = DownloadStatus.CANCELLED self.status = DownloadStatus.CANCELLED
self._cleanup_if_empty()
self._emit_status() self._emit_status()
def pause(self): def pause(self):
@@ -445,6 +510,90 @@ class DownloadTask:
self.status = DownloadStatus.DOWNLOADING self.status = DownloadStatus.DOWNLOADING
self._emit_status() self._emit_status()
def _cleanup_if_empty(self):
"""출력 파일이 0바이트거나 존재하지 않으면 삭제 (정리)"""
try:
if self.filepath and os.path.exists(self.filepath):
if os.path.getsize(self.filepath) == 0:
from .setup import P
P.logger.info(f"Cleaning up 0-byte file: {self.filepath}")
os.remove(self.filepath)
except Exception as e:
from .setup import P
P.logger.error(f"Cleanup error: {e}")
def _update_db_status(self):
"""DB의 상태 정보를 동기화"""
try:
if self.db_id:
from .model import ModelDownloadItem
with F.app.app_context():
item = F.db.session.query(ModelDownloadItem).filter_by(id=self.db_id).first()
if item:
item.status = self.status
if self.status == DownloadStatus.COMPLETED:
item.completed_time = datetime.now()
if self.error_message:
item.error_message = self.error_message
F.db.session.add(item)
F.db.session.commit()
except Exception as e:
from .setup import P
P.logger.error(f"Failed to update DB status: {e}")
def _invoke_plugin_callback(self):
"""호출한 플러그인의 콜백 메서드 호출"""
try:
from .setup import P
P.logger.info(f"Invoking callback for plugin: {self.caller_plugin}, id: {self.callback_id}")
# 플러그인 인스턴스 찾기 (PluginManager 사용)
from framework import F
target_P = None
# caller_plugin은 "anime_downloader_ohli24" 형식이므로 패키지명 추출
parts = self.caller_plugin.split('_')
package_name = parts[0] if parts else self.caller_plugin
# 패키지 이름으로 여러 조합 시도
possible_names = [
self.caller_plugin, # anime_downloader_ohli24
'_'.join(parts[:2]) if len(parts) > 1 else self.caller_plugin, # anime_downloader
package_name # anime
]
for name in possible_names:
if name in F.PluginManager.all_package_list:
pkg_info = F.PluginManager.all_package_list[name]
if pkg_info.get('loading') and 'P' in pkg_info:
target_P = pkg_info['P']
break
if target_P:
# 모듈에서 콜백 메서드 찾기
callback_invoked = False
for module_name, module_instance in getattr(target_P, 'module_list', {}).items():
if hasattr(module_instance, 'plugin_callback'):
callback_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
}
module_instance.plugin_callback(callback_data)
callback_invoked = True
P.logger.info(f"Callback invoked on module {module_name}")
break
if not callback_invoked:
P.logger.debug(f"No plugin_callback method found in {self.caller_plugin}")
else:
P.logger.debug(f"Plugin {self.caller_plugin} not found in PluginManager")
except Exception as e:
P.logger.error(f"Error invoking plugin callback: {e}")
P.logger.error(traceback.format_exc())
def get_status(self) -> Dict[str, Any]: def get_status(self) -> Dict[str, Any]:
"""현재 상태 반환""" """현재 상태 반환"""
return { return {
@@ -459,8 +608,10 @@ class DownloadTask:
'eta': self.eta, 'eta': self.eta,
'title': self.title, 'title': self.title,
'thumbnail': self.thumbnail, 'thumbnail': self.thumbnail,
'meta': self.meta,
'error_message': self.error_message, 'error_message': self.error_message,
'filepath': self.filepath, 'filepath': self.filepath,
'caller_plugin': self.caller_plugin, 'caller_plugin': self.caller_plugin,
'callback_id': self.callback_id, 'callback_id': self.callback_id,
'db_id': self.db_id,
} }

View File

@@ -2,6 +2,7 @@
다운로드 큐 모델 정의 다운로드 큐 모델 정의
""" """
from plugin import ModelBase, db from plugin import ModelBase, db
from framework import F
package_name = 'gommi_download_manager' package_name = 'gommi_download_manager'
@@ -43,3 +44,44 @@ class ModelDownloadItem(ModelBase):
error_message: str = db.Column(db.Text) error_message: str = db.Column(db.Text)
retry_count: int = db.Column(db.Integer, default=0) retry_count: int = db.Column(db.Integer, default=0)
# 추가 메타데이터 (JSON 형태의 텍스트 저장)
meta: str = db.Column(db.Text)
def as_dict(self):
ret = super(ModelDownloadItem, self).as_dict()
import json
if self.meta:
try:
ret['meta'] = json.loads(self.meta)
except:
ret['meta'] = {}
else:
ret['meta'] = {}
return ret
@classmethod
def check_migration(cls):
"""DB 컬럼 누락 체크 및 추가"""
try:
from .setup import P
import sqlite3
db_file = F.app.config['SQLALCHEMY_BINDS'][package_name].replace('sqlite:///', '').split('?')[0]
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
# meta 컬럼 확인
cursor.execute(f"PRAGMA table_info({cls.__tablename__})")
columns = [info[1] for info in cursor.fetchall()]
if 'meta' not in columns:
P.logger.info(f"Adding 'meta' column to {cls.__tablename__}")
cursor.execute(f"ALTER TABLE {cls.__tablename__} ADD COLUMN meta TEXT")
conn.commit()
conn.close()
except Exception as e:
from .setup import P
P.logger.error(f"Migration Error: {e}")
import traceback
P.logger.error(traceback.format_exc())

View File

@@ -21,7 +21,7 @@ setting = {
'home_module': 'queue', 'home_module': 'queue',
'menu': { 'menu': {
'uri': __package__, 'uri': __package__,
'name': 'Gommi 다운로더', 'name': 'GDM',
'list': [ 'list': [
{ {
'uri': 'queue', 'uri': 'queue',

View File

@@ -2,281 +2,563 @@
{% import "macro.html" as macros %} {% import "macro.html" as macros %}
{% block content %} {% block content %}
<!-- Google Fonts: Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
/* 이 페이지에서만 전역 로딩 인디케이터 숨김 */ /* Premium Modern Design System */
:root {
--bg-body: #0f172a;
--surface: rgba(30, 41, 59, 0.7);
--surface-opaque: #1e293b;
--border: rgba(255, 255, 255, 0.1);
--text-main: #f8fafc;
--text-muted: #94a3b8;
--accent-primary: #38bdf8; /* Sky Blue */
--accent-secondary: #818cf8; /* Indigo */
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--glow: rgba(56, 189, 248, 0.3);
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
#loading { display: none !important; } #loading { display: none !important; }
/* Metallic Theme Variables */ #gommi_download_manager_queue_list {
:root { font-family: var(--font-sans);
--metal-dark: #1a1a1a; color: var(--text-main);
--metal-surface: linear-gradient(145deg, #2d2d2d, #1a1a1a); background-color: transparent;
--metal-border: #404040; padding-bottom: 2rem;
--metal-text: #e0e0e0;
--metal-text-muted: #888;
--metal-highlight: #00bcd4; /* Cyan/Blue Neon */
--metal-shadow: 0 4px 6px rgba(0,0,0,0.5);
} }
/* Card Override */ /* Header Styling */
.card { .page-header {
background: var(--metal-surface) !important; display: flex;
border: 1px solid var(--metal-border) !important; justify-content: space-between;
border-radius: 8px; align-items: center;
box-shadow: 0 10px 25px rgba(0,0,0,0.6); margin-bottom: 2rem;
color: var(--metal-text); flex-wrap: wrap;
gap: 1rem;
} }
.card-header { /* Redesigned Navigation Menu (tabs) */
background: rgba(0,0,0,0.2) !important; #menu_page_div .nav-pills {
border-bottom: 1px solid var(--metal-border) !important; margin-top: 2px !important;
color: var(--metal-text); margin-bottom: 12px !important;
font-weight: bold; background: rgba(255, 255, 255, 0.05) !important;
text-transform: uppercase; backdrop-filter: blur(12px);
letter-spacing: 1px; -webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px !important;
padding: 6px !important;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3) !important;
display: inline-flex !important;
width: auto !important;
} }
/* Table Override */ /* Global Navigation Spacing Adjustments (Fix for extra gap) */
.table { #menu_module_div {
color: var(--metal-text) !important; padding-top: 0 !important;
} }
.table thead th { #menu_module_div .nav-pills {
border-top: none; margin-top: 0 !important;
border-bottom: 2px solid var(--metal-border); margin-bottom: 5px !important;
color: var(--metal-text-muted);
font-size: 0.85rem;
} }
.table td { @media (min-width: 769px) {
border-top: 1px solid rgba(255,255,255,0.05); #main_container {
vertical-align: middle; margin-top: 0 !important;
padding-top: 0 !important;
} }
.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(255,255,255,0.02) !important;
} }
.table-hover tbody tr:hover { /* Navigation Override (SJVA Menu Page) */
background-color: rgba(255,255,255,0.05) !important; #menu_page_div {
margin-bottom: 2rem;
} }
/* Buttons */ #menu_page_div .nav-pills {
.btn-outline-primary { background: rgba(30, 41, 59, 0.5) !important;
color: var(--metal-highlight); backdrop-filter: blur(16px);
border-color: var(--metal-highlight); -webkit-backdrop-filter: blur(16px);
} border: 1px solid var(--border);
.btn-outline-primary:hover { border-radius: 14px;
background-color: var(--metal-highlight); padding: 6px;
color: #000; display: inline-flex;
box-shadow: 0 0 10px var(--metal-highlight); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.btn-outline-danger {
color: #ff5252;
border-color: #ff5252;
}
.btn-outline-danger:hover {
background-color: #ff5252;
color: white;
box-shadow: 0 0 10px #ff5252;
} }
/* Badges */ #menu_page_div .nav-link {
.badge { color: var(--text-muted);
font-weight: 500; font-weight: 600;
letter-spacing: 0.5px; font-size: 0.875rem;
} padding: 0.6rem 1.25rem;
.badge-outline-secondary {
border: 1px solid #666;
color: #aaa;
background: transparent;
}
.badge-outline-info {
border: 1px solid var(--metal-highlight);
color: var(--metal-highlight);
background: transparent;
}
/* Progress Bar */
.progress {
background-color: rgba(0,0,0,0.5);
border: 1px solid #333;
border-radius: 10px; border-radius: 10px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
}
#menu_page_div .nav-link:hover {
color: var(--text-main);
background: rgba(255, 255, 255, 0.05);
}
#menu_page_div .nav-link.active {
background: white !important;
color: #0f172a !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
@media (max-width: 576px) {
#menu_page_div .nav-pills {
display: flex;
width: 100%;
}
#menu_page_div .nav-link {
flex: 1;
text-align: center;
padding: 0.6rem 0.5rem;
}
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.025em;
background: linear-gradient(135deg, #fff 0%, #cbd5e1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-actions {
display: flex;
gap: 0.75rem;
}
/* Primary Modern Button */
.btn-premium {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
border-radius: 10px;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-main);
backdrop-filter: blur(8px);
}
.btn-premium:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
color: #fff;
text-decoration: none;
}
.btn-premium.danger:hover {
background: rgba(239, 68, 68, 0.2) !important;
border-color: var(--danger);
color: var(--danger);
}
.btn-premium i {
margin-right: 0.5rem;
}
/* Card List Layout */
.download-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 1.25rem;
}
@media (max-width: 576px) {
.download-grid {
grid-template-columns: 1fr;
}
}
/* Download Card Styling */
.dl-card {
background: var(--surface);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
transition: all 0.3s ease;
position: relative;
overflow: hidden; overflow: hidden;
} }
.progress-bar {
background: linear-gradient(90deg, #00acc1, #26c6da); .dl-card:hover {
box-shadow: 0 0 10px rgba(0, 188, 212, 0.5); border-color: rgba(255, 255, 255, 0.2);
font-size: 0.8rem; background: rgba(30, 41, 59, 0.85);
line-height: 20px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2), 0 10px 10px -5px rgba(0, 0, 0, 0.1);
}
/* ID & Meta Row */
.dl-meta {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.dl-id-badge {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dl-source-pill {
background: rgba(56, 189, 248, 0.15);
color: var(--accent-primary);
padding: 2px 8px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 700;
}
/* Title Section */
.dl-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dl-title {
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.dl-url {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.6;
}
/* Progress Section */
.dl-progress-container {
margin-top: 0.5rem;
}
.dl-progress-header {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.dl-speed {
color: var(--text-muted);
}
.dl-progress-bar-bg {
height: 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 99px;
overflow: hidden;
}
.dl-progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
width: 0%;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 99px;
box-shadow: 0 0 12px var(--glow);
}
/* Status & Controls */
.dl-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.dl-status-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.05);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* Status Colors */
.status-pending { color: var(--text-muted); }
.status-pending .status-dot { background-color: var(--text-muted); opacity: 0.5; }
.status-downloading { color: var(--accent-primary); }
.status-downloading .status-dot { background-color: var(--accent-primary); box-shadow: 0 0 8px var(--accent-primary); animation: pulse 1.5s infinite; }
.status-completed { color: var(--success); }
.status-completed .status-dot { background-color: var(--success); }
.status-error { color: var(--danger); }
.status-error .status-dot { background-color: var(--danger); }
.dl-actions {
display: flex;
gap: 0.5rem;
}
.btn-action-small {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
color: var(--text-main);
transition: all 0.2s;
cursor: pointer;
}
.btn-action-small:hover {
background: rgba(255, 255, 255, 0.15);
transform: scale(1.05);
}
.btn-action-small.cancel:hover {
background: rgba(239, 68, 68, 0.2);
color: var(--danger);
border-color: var(--danger);
}
@keyframes pulse {
0% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(1.2); }
100% { opacity: 1; transform: scale(1); }
}
/* Empty State */
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 4rem 2rem;
background: var(--surface);
border-radius: 16px;
border: 1px dashed var(--border);
color: var(--text-muted);
}
.empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.3;
} }
</style> </style>
<div id="gommi_download_manager_queue_list" class="mt-4"> <div id="gommi_download_manager_queue_list" class="mt-4">
<div class="card"> <div class="page-header">
<div class="card-header d-flex justify-content-between align-items-center"> <h1 class="page-title">GDM Queue</h1>
<h5 class="mb-0">다운로드 목록</h5> <div class="header-actions">
<div> <button type="button" class="btn-premium danger" onclick="resetList()">
<button type="button" class="btn btn-sm btn-outline-danger mr-2" onclick="resetList()"> <i class="fa fa-trash"></i> Reset All
<i class="fa fa-trash"></i> 전체 삭제
</button> </button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="refreshList()"> <button type="button" class="btn-premium" onclick="refreshList()">
<i class="fa fa-refresh"></i> 새로고침 <i class="fa fa-refresh"></i> Refresh
</button> </button>
</div> </div>
</div> </div>
<div class="card-body p-0">
<table class="table table-striped table-hover mb-0"> <div class="download-grid" id="download_list">
<thead> <!-- List will be rendered here -->
<tr> <div class="empty-state">
<th style="width: 5%">#</th> <i class="fa fa-cloud-download"></i>
<th style="width: 10%">요청</th> <p>No downloads in queue.</p>
<th style="width: 35%">제목/URL</th>
<th style="width: 10%">소스</th>
<th style="width: 15%">진행률</th>
<th style="width: 10%">속도</th>
<th style="width: 10%">상태</th>
<th style="width: 15%">작업</th>
</tr>
</thead>
<tbody id="download_list">
<tr>
<td colspan="8" class="text-center text-muted py-4">
다운로드 항목이 없습니다.
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
<script src="/{{arg.package_name}}/static/{{arg.package_name}}.js"></script>
<script> <script>
console.log("Start Gommi Queue List JS"); // PACKAGE_NAME and MODULE_NAME are already defined globally by framework
// alert("Check: JS Running");
// Functions first
function refreshList(silent) { function refreshList(silent) {
// Try multiple URLs to find the correct one
var attempts = [
'/{{arg.package_name}}/ajax/{{arg.module_name}}/list', // New Candidate
'/{{arg.package_name}}/{{arg.module_name}}/ajax/list', // Standard
'/{{arg.package_name}}/{{arg.module_name}}/queue/ajax/list', // Double Queue
'/{{arg.package_name}}/queue/ajax/list' // Direct Queue
];
function tryUrl(index) {
if (index >= attempts.length) {
console.error("All list fetch attempts failed");
return;
}
var url = attempts[index];
$.ajax({ $.ajax({
url: url, url: `/${PACKAGE_NAME}/ajax/${MODULE_NAME}/list`,
type: 'POST', type: 'POST',
dataType: 'json', dataType: 'json',
data: {}, data: {},
global: !silent, // Silent mode: suppress global loading indicator global: !silent,
success: function(ret) { success: function(ret) {
if (ret.ret === 'success') { if (ret.ret === 'success') {
renderList(ret.data || []); renderList(ret.data || []);
} else {
// tryUrl(index + 1);
} }
}, },
error: function(e) { error: function(e) {
// console.warn("Failed URL:", url); // Fallback for different URL patterns if needed
tryUrl(index + 1); $.ajax({
} url: `/${PACKAGE_NAME}/${MODULE_NAME}/ajax/list`,
type: 'POST',
dataType: 'json',
success: function(ret) { if (ret.ret === 'success') renderList(ret.data || []); }
}); });
} }
});
tryUrl(0);
} }
function renderList(items) { function renderList(items) {
var tbody = document.getElementById('download_list'); const container = document.getElementById('download_list');
if (!tbody) return; if (!container) return;
if (!items || items.length === 0) { if (!items || items.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">다운로드 항목이 없습니다.</td></tr>'; container.innerHTML = `
<div class="empty-state">
<i class="fa fa-cloud-download"></i>
<p>No downloads in queue.</p>
</div>`;
return; return;
} }
var html = ''; let html = '';
items.forEach(function(item, index) { items.forEach(function(item) {
html += createDownloadRow(item, index + 1); html += createDownloadCard(item);
}); });
tbody.innerHTML = html; container.innerHTML = html;
} }
function createDownloadRow(item, num) { function createDownloadCard(item) {
var statusClass = { const percent = (item.progress && !isNaN(item.progress)) ? item.progress : 0;
'pending': 'badge-secondary', const displayTitle = item.title || item.filename || item.url || 'No Title';
'extracting': 'badge-info', const source = item.source_type || 'auto';
'downloading': 'badge-primary', const status = item.status || 'pending';
'completed': 'badge-success', const thumbnail = item.thumbnail || '';
'error': 'badge-danger',
'cancelled': 'badge-warning',
'paused': 'badge-warning'
};
var percent = (item.progress && !isNaN(item.progress)) ? item.progress : 0; let statusClass = `status-${status}`;
var displayTitle = item.title ? item.title : (item.url || 'No Title'); let metaHtml = '';
if (displayTitle.length > 50) displayTitle = displayTitle.substring(0, 50) + '...'; if (item.meta) {
if (item.meta.series) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.series}</span>`;
return '<tr id="row_' + item.id + '">' + if (item.meta.season) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.season}기</span>`;
'<td>' + num + '</td>' + if (item.meta.episode) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.episode}화</span>`;
'<td><span class="badge badge-outline-secondary">' + (item.caller_plugin || 'User') + '</span></td>' +
'<td title="' + (item.url || '') + '">' +
'<div style="max-width: 300px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">' + displayTitle + '</div>' +
'</td>' +
'<td><span class="badge badge-outline-info">' + (item.source_type || 'auto') + '</span></td>' +
'<td>' +
'<div class="progress" style="height: 20px;">' +
'<div class="progress-bar" role="progressbar" style="width: ' + percent + '%;" aria-valuenow="' + percent + '" aria-valuemin="0" aria-valuemax="100">' + percent + '%</div>' +
'</div>' +
'</td>' +
'<td>' + (item.speed || '-') + '</td>' +
'<td><span class="badge ' + (statusClass[item.status] || 'badge-secondary') + '">' + (item.status || 'unknown') + '</span></td>' +
'<td>' +
(item.status === 'downloading' ? '<button class="btn btn-sm btn-warning" onclick="cancelDownload(\'' + item.id + '\')"><i class="fa fa-stop"></i></button>' : '') +
'</td>' +
'</tr>';
} }
function updateDownloadRow(item) { return `
var row = document.getElementById('row_' + item.id); <div class="dl-card" id="card_${item.id}">
if (row) { <div class="dl-meta">
row.outerHTML = createDownloadRow(item, row.rowIndex); <span class="dl-id-badge">#${item.id.toString().split('_').pop()}</span>
<span class="dl-source-pill">${source.toUpperCase()}</span>
</div>
<div style="display: flex; gap: 1rem; align-items: flex-start;">
${thumbnail ? `<img src="${thumbnail}" style="width: 80px; height: 45px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border);" onerror="this.style.display='none'">` : ''}
<div class="dl-info" style="flex: 1; min-width: 0;">
<div class="dl-title" title="${displayTitle}">${displayTitle}</div>
<div class="mt-2" style="font-size: 0.7rem; display: flex; flex-wrap: wrap; gap: 4px;">
${metaHtml}
</div>
</div>
</div>
<div class="dl-progress-container">
<div class="dl-progress-header">
<span class="dl-percent">${percent}%</span>
<span class="dl-speed">${item.speed || ''}</span>
</div>
<div class="dl-progress-bar-bg">
<div class="dl-progress-bar-fill" style="width: ${percent}%;"></div>
</div>
</div>
<div class="dl-footer">
<div class="dl-status-label ${statusClass}">
<div class="status-dot"></div>
<span>${status.charAt(0).toUpperCase() + status.slice(1)}</span>
</div>
<div class="dl-actions">
${status === 'downloading' || status === 'pending' || status === 'paused' ?
`<button class="btn-action-small cancel" title="Cancel Download" onclick="cancelDownload('${item.id}')">
<i class="fa fa-stop"></i>
</button>` : ''
}
</div>
</div>
</div>
`;
}
function updateDownloadCard(item) {
const card = document.getElementById('card_' + item.id);
if (card) {
// Smoothly update progress bar and stats without re-rendering entire card
const percentageText = card.querySelector('.dl-percent');
const progressBar = card.querySelector('.dl-progress-bar-fill');
const speedText = card.querySelector('.dl-speed');
const statusLabel = card.querySelector('.dl-status-label span');
const statusDot = card.querySelector('.dl-status-label');
const percent = (item.progress && !isNaN(item.progress)) ? item.progress : 0;
if (percentageText) percentageText.innerText = `${percent}%`;
if (progressBar) progressBar.style.width = `${percent}%`;
if (speedText) speedText.innerText = item.speed || '';
// If status changed, full replace might be easier to handle state animation
if (statusLabel && statusLabel.innerText.toLowerCase() !== item.status) {
const newContent = createDownloadCard(item);
card.outerHTML = newContent;
}
} else { } else {
refreshList(); refreshList(true);
} }
} }
function cancelDownload(id) { function cancelDownload(id) {
$.ajax({ $.ajax({
url: '/{{arg.package_name}}/ajax/{{arg.module_name}}/cancel', // Use new pattern url: `/${PACKAGE_NAME}/ajax/${MODULE_NAME}/cancel`,
type: 'POST', type: 'POST',
data: { id: id }, data: { id: id },
dataType: 'json', dataType: 'json',
success: function(ret) { success: function(ret) {
if (ret.msg) { if (ret.ret === 'success') {
$.notify('<strong>' + ret.msg + '</strong>', {type: 'success'}); $.notify('<strong>Download Cancelled</strong>', {type: 'success'});
refreshList(true);
} }
} }
}); });
} }
function resetList() { function resetList() {
if (!confirm('정말 전체 목록을 삭제하시겠습니까? (진행 중인 작업도 취소됩니다)')) { if (!confirm('Are you sure you want to clear the entire queue?')) return;
return;
}
$.ajax({ $.ajax({
url: '/{{arg.package_name}}/ajax/{{arg.module_name}}/reset', url: `/${PACKAGE_NAME}/ajax/${MODULE_NAME}/reset`,
type: 'POST', type: 'POST',
data: {}, data: {},
dataType: 'json', dataType: 'json',
success: function(ret) { success: function(ret) {
if (ret.msg) { if (ret.ret === 'success') {
$.notify('<strong>' + ret.msg + '</strong>', {type: 'success'}); $.notify('<strong>Queue Reset Successfully</strong>', {type: 'success'});
} }
refreshList(); refreshList(true);
} }
}); });
} }
@@ -284,33 +566,20 @@
// Socket Init // Socket Init
try { try {
if (typeof io !== 'undefined') { if (typeof io !== 'undefined') {
// Namespace needs to match default_route_socketio_module attach param const socket = io.connect(`/${PACKAGE_NAME}/queue`);
var socket = io.connect('/' + '{{ arg.package_name }}' + '/queue');
socket.on('download_status', function(data) { socket.on('download_status', function(data) {
updateDownloadRow(data); updateDownloadCard(data);
});
socket.on('connect', function() {
console.log('Socket connected!');
}); });
} }
} catch (e) { } catch (e) {
console.error('Socket.IO init error:', e); console.error('Socket.IO init error:', e);
} }
// Initial Load
$(document).ready(function() { $(document).ready(function() {
console.log("OnReady: Refresh List");
refreshList(); refreshList();
// Auto Refresh logic (fallback for Socket.IO)
setInterval(function() { setInterval(function() {
// refreshList(); // 전체 갱신 보다는 상태만 가져오는게 좋지만, 일단 전체 갱신 refreshList(true);
// 조용히 갱신 (Optional: modify refreshList to accept silent flag) }, 8000);
// 단순하게 목록 갱신 호출
refreshList(true); // Silent mode
}, 5000); // 5초마다
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -2,152 +2,317 @@
{% import "macro.html" as macros %} {% import "macro.html" as macros %}
{% block content %} {% block content %}
<!-- Google Fonts: Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
/* Metallic Theme Variables */ /* Premium Modern Design System (Sync with List View) */
:root { :root {
--metal-dark: #1a1a1a; --bg-body: #0f172a;
--metal-surface: linear-gradient(145deg, #2d2d2d, #1a1a1a); --surface: rgba(30, 41, 59, 0.7);
--metal-border: #404040; --surface-opaque: #1e293b;
--metal-text: #e0e0e0; --border: rgba(255, 255, 255, 0.1);
--metal-text-muted: #888; --text-main: #f8fafc;
--metal-highlight: #00bcd4; /* Cyan/Blue Neon */ --text-muted: #94a3b8;
--metal-input-bg: rgba(0, 0, 0, 0.3); --accent-primary: #38bdf8;
--accent-secondary: #818cf8;
--success: #10b981;
--glow: rgba(56, 189, 248, 0.3);
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
} }
/* Container Spacing */ #gommi_download_manager_queue_setting {
.container-fluid { font-family: var(--font-sans);
padding-top: 20px; color: var(--text-main);
padding-bottom: 3rem;
} }
/* Headers */ /* Navigation Override (SJVA Menu Page) */
h4 { #menu_page_div {
color: var(--metal-text); margin-bottom: 2rem;
font-weight: 300;
letter-spacing: 1px;
text-transform: uppercase;
border-bottom: 2px solid var(--metal-highlight);
display: inline-block;
padding-bottom: 5px;
} }
/* Form Controls */ /* Redesigned Navigation Menu (tabs) */
.form-control, .custom-select { #menu_page_div .nav-pills {
background-color: var(--metal-input-bg) !important; margin-top: 2px !important;
border: 1px solid var(--metal-border) !important; margin-bottom: 12px !important;
color: var(--metal-text) !important; background: rgba(255, 255, 255, 0.05) !important;
border-radius: 4px; backdrop-filter: blur(12px);
transition: all 0.3s ease; border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px !important;
padding: 6px !important;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3) !important;
display: inline-flex !important;
width: auto !important;
} }
.form-control:focus, .custom-select:focus {
background-color: rgba(0,0,0,0.5) !important; /* Global Navigation Spacing Adjustments (Fix for extra gap) */
border-color: var(--metal-highlight) !important; #menu_module_div {
box-shadow: 0 0 10px rgba(0, 188, 212, 0.3) !important; padding-top: 0 !important;
}
#menu_module_div .nav-pills {
margin-top: 0 !important;
margin-bottom: 5px !important;
}
@media (min-width: 769px) {
#main_container {
margin-top: 0 !important;
padding-top: 0 !important;
}
}
#menu_page_div .nav-link {
color: var(--text-muted);
font-weight: 600;
font-size: 0.875rem;
padding: 0.6rem 1.25rem;
border-radius: 10px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
}
#menu_page_div .nav-link:hover {
color: var(--text-main);
background: rgba(255, 255, 255, 0.05);
}
#menu_page_div .nav-link.active {
background: white !important;
color: #0f172a !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
@media (max-width: 576px) {
#menu_page_div .nav-pills {
display: flex;
width: 100%;
}
#menu_page_div .nav-link {
flex: 1;
text-align: center;
padding: 0.6rem 0.5rem;
}
}
/* Form Styling */
.settings-container {
background: var(--surface);
backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: 20px;
padding: 2rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.025em;
background: linear-gradient(135deg, #fff 0%, #cbd5e1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
} }
/* Buttons */ /* Buttons */
.btn-outline-primary { .btn-premium {
color: var(--metal-highlight); display: inline-flex;
border-color: var(--metal-highlight); align-items: center;
} padding: 0.6rem 1.25rem;
.btn-outline-primary:hover { border-radius: 12px;
background-color: var(--metal-highlight); font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s;
cursor: pointer;
border: 1px solid var(--accent-primary);
background: var(--accent-primary);
color: #000; color: #000;
box-shadow: 0 0 15px var(--metal-highlight); box-shadow: 0 4px 12px var(--glow);
}
.btn-premium:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--glow);
opacity: 0.9;
color: #000;
text-decoration: none;
}
/* Form Controls Override */
.form-control, .custom-select {
background-color: rgba(0, 0, 0, 0.2) !important;
border: 1px solid var(--border) !important;
color: var(--text-main) !important;
border-radius: 10px;
padding: 0.75rem 1rem;
height: auto;
}
.form-control:focus {
border-color: var(--accent-primary) !important;
box-shadow: 0 0 0 2px var(--glow) !important;
}
label {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-main);
margin-bottom: 0.5rem;
}
.form-text {
color: var(--text-muted) !important;
font-size: 0.8rem;
margin-top: 0.4rem;
}
h5.mb-0 {
font-weight: 700;
letter-spacing: -0.01em;
color: var(--accent-primary);
margin-bottom: 1.5rem !important;
display: block;
width: 100%;
border-left: 4px solid var(--accent-primary);
padding-left: 1rem;
} }
/* HR */
hr { hr {
border-top: 1px solid rgba(255,255,255,0.1) !important; border-top: 1px solid var(--border);
} margin: 2.5rem 0;
/* Labels */
label, strong {
color: #cfcfcf;
font-weight: 500;
}
/* Description text */
em {
color: var(--metal-text-muted);
font-style: normal;
font-size: 0.9em;
} }
</style> </style>
<div class="container-fluid">
{{ macros.m_row_start('5') }}
{{ macros.m_row_end() }}
<!-- Header & Save Button --> <div id="gommi_download_manager_queue_setting" class="mt-4">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="page-header">
<h4>GDM 설정</h4> <h1 class="page-title">GDM Settings</h1>
{{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']]) }} <div class="header-actions">
<button type="button" class="btn-premium" id="globalSettingSaveBtn">
<i class="fa fa-save"></i> Save Changes
</button>
</div>
</div> </div>
{{ macros.m_hr_head_bottom() }}
<div class="settings-container">
<form id="setting"> <form id="setting">
<!-- Basic Setting --> <!-- Basic Setting -->
{{ macros.setting_top_big('기본 설정') }} <h5 class="mb-4">General Settings</h5>
{{ macros.setting_bottom() }}
{{ macros.setting_input_text('save_path', '저장 경로', value=arg['save_path'], desc='{PATH_DATA}는 실제 데이터 경로로 치환됩니다.') }} <div class="form-group">
{{ macros.setting_input_text('temp_path', '임시 경로', value=arg['temp_path'], desc='다운로드 중 임시 파일 저장 경로') }} <label>Save Path</label>
{{ macros.setting_input_text('max_concurrent', '동시 다운로드 수', value=arg['max_concurrent'], desc='동시에 진행할 최대 다운로드 수') }} <input type="text" name="save_path" class="form-control" value="{{arg['save_path']}}">
{{ macros.setting_select('max_download_rate', '속도 제한', [['0', '무제한'], ['1M', '1 MB/s'], ['3M', '3 MB/s'], ['5M', '5 MB/s'], ['10M', '10 MB/s']], value=arg['max_download_rate'], desc='다운로드 속도를 제한합니다.') }} <small class="form-text">{PATH_DATA} will be replaced by the actual data path.</small>
</div>
{{ macros.m_hr() }} <div class="form-group">
<label>Temp Path</label>
<input type="text" name="temp_path" class="form-control" value="{{arg['temp_path']}}">
<small class="form-text">Temporary storage path for files during download.</small>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label>Max Concurrent Downloads</label>
<input type="number" name="max_concurrent" class="form-control" value="{{arg['max_concurrent']}}">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>Speed Limit</label>
<select name="max_download_rate" class="custom-select">
<option value="0" {% if arg['max_download_rate'] == '0' %}selected{% endif %}>Unlimited</option>
<option value="1M" {% if arg['max_download_rate'] == '1M' %}selected{% endif %}>1 MB/s</option>
<option value="3M" {% if arg['max_download_rate'] == '3M' %}selected{% endif %}>3 MB/s</option>
<option value="5M" {% if arg['max_download_rate'] == '5M' %}selected{% endif %}>5 MB/s</option>
<option value="10M" {% if arg['max_download_rate'] == '10M' %}selected{% endif %}>10 MB/s</option>
</select>
</div>
</div>
</div>
<hr>
<!-- Downloader Setting --> <!-- Downloader Setting -->
{{ macros.setting_top_big('다운로더 설정') }} <h5 class="mb-4">External Tool Paths</h5>
{{ macros.setting_bottom() }}
{{ macros.setting_input_text('aria2c_path', 'aria2c 경로', value=arg['aria2c_path'], desc='aria2c 실행 파일 경로 (고속 다운로드용)') }} <div class="form-group">
{{ macros.setting_input_text('aria2c_connections', 'aria2c 연결 수', value=arg['aria2c_connections'], desc='aria2c 동시 연결 수 (기본 16)') }} <label>aria2c Path</label>
{{ macros.setting_input_text('ffmpeg_path', 'ffmpeg 경로', value=arg['ffmpeg_path'], desc='ffmpeg 실행 파일 경로 (HLS 스트림용)') }} <input type="text" name="aria2c_path" class="form-control" value="{{arg['aria2c_path']}}">
{{ macros.setting_input_text('yt_dlp_path', 'yt-dlp 경로', value=arg['yt_dlp_path'], desc='비워두면 Python 모듈 사용') }} <small class="form-text">Executable path for aria2c (used for high-speed downloads).</small>
</div>
{{ macros.m_hr() }} <div class="form-group">
<label>aria2c Connections</label>
<input type="number" name="aria2c_connections" class="form-control" value="{{arg['aria2c_connections']}}">
<small class="form-text">Concurrent connections per download (default: 16).</small>
</div>
<div class="form-group">
<label>ffmpeg Path</label>
<input type="text" name="ffmpeg_path" class="form-control" value="{{arg['ffmpeg_path']}}">
<small class="form-text">Executable path for ffmpeg (used for HLS streams).</small>
</div>
<div class="form-group">
<label>yt-dlp Path</label>
<input type="text" name="yt_dlp_path" class="form-control" value="{{arg['yt_dlp_path']}}">
<small class="form-text">If empty, the Python module will be used.</small>
</div>
<hr>
<!-- Retry Setting --> <!-- Retry Setting -->
{{ macros.setting_top_big('재시도 설정') }} <h5 class="mb-4">Error Handling</h5>
{{ macros.setting_bottom() }}
{{ macros.setting_checkbox('auto_retry', '자동 재시도', value=arg['auto_retry'], desc='다운로드 실패 시 자동으로 재시도') }} <div class="form-group custom-control custom-switch mb-3">
{{ macros.setting_input_text('max_retry', '최대 재시도 횟수', value=arg['max_retry'], desc='최대 재시도 횟수') }} <input type="checkbox" name="auto_retry" class="custom-control-input" id="auto_retry" {% if arg['auto_retry'] == 'True' or arg['auto_retry'] == True %}checked{% endif %}>
<label class="custom-control-label" for="auto_retry">Enable Auto-Retry</label>
<small class="form-text d-block">Automatically retry failed downloads.</small>
</div>
<div class="form-group">
<label>Max Retry Count</label>
<input type="number" name="max_retry" class="form-control" value="{{arg['max_retry']}}">
</div>
</form> </form>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block tail_js %} {% block tail_js %}
<script type="text/javascript"> <script type="text/javascript">
var package_name = "{{arg['package_name'] }}"; const package_name = "{{arg['package_name']}}";
var sub = "{{arg['module_name'] }}"; // sub usually is module name like 'queue' const sub = "{{arg['module_name']}}";
// Save Button Logic (Standard FlaskFarm Plugin JS)
// Note: globalSettingSaveBtn logic is usually handled by framework's default plugin.js if available,
// OR we explicitly define it here.
// Gommi plugin loads '/package_name/static/package_name.js' ?
// I recall checking step 21445 it had `<script src="/{{package_name}}/static/{{package_name}}.js"></script>`
// I will explicitly add the save logic just in case the static JS relies on specific form IDs.
$(document).ready(function(){ $(document).ready(function(){
// Nothing special needed // Handled by common framework
}); });
$("body").on('click', '#globalSettingSaveBtn', function(e){ $("body").on('click', '#globalSettingSaveBtn', function(e){
e.preventDefault(); e.preventDefault();
var formData = get_formdata('#setting'); var formData = get_formdata('#setting');
$.ajax({ $.ajax({
url: '/' + package_name + '/ajax/' + sub + '/setting_save', url: `/${package_name}/ajax/${sub}/setting_save`,
type: "POST", type: "POST",
cache: false, cache: false,
data: formData, data: formData,
dataType: "json", dataType: "json",
success: function(ret) { success: function(ret) {
if (ret.ret == 'success') { if (ret.ret == 'success') {
$.notify('설정을 저장했습니다.', {type:'success'}); $.notify('<strong>Settings Saved Successfully</strong>', {type:'success'});
} else { } else {
$.notify('저장 실패: ' + ret.msg, {type:'danger'}); $.notify('<strong>Save Failed: ' + ret.msg + '</strong>', {type:'danger'});
} }
} }
}); });