v0.2.0: 플러그인 콜백 시스템, 버그 수정, UI 개선
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.DS_Store
|
||||||
47
README.md
47
README.md
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -51,10 +51,35 @@ class FfmpegHlsDownloader(BaseDownloader):
|
|||||||
|
|
||||||
# 헤더 추가
|
# 헤더 추가
|
||||||
headers = options.get('headers', {})
|
headers = options.get('headers', {})
|
||||||
if headers:
|
cookies_file = options.get('cookies_file')
|
||||||
header_str = '\r\n'.join([f'{k}: {v}' for k, v in headers.items()])
|
|
||||||
cmd.extend(['-headers', header_str])
|
|
||||||
|
|
||||||
|
if headers:
|
||||||
|
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])
|
||||||
|
|
||||||
|
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}')
|
||||||
|
|||||||
@@ -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,12 +78,18 @@ 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])
|
||||||
|
|
||||||
# 병합 포맷
|
# 병합 포맷 (비디오인 경우에만)
|
||||||
merge_format = options.get('merge_output_format', 'mp4')
|
if not options.get('extract_audio'):
|
||||||
cmd.extend(['--merge-output-format', merge_format])
|
merge_format = options.get('merge_output_format', 'mp4')
|
||||||
|
cmd.extend(['--merge-output-format', merge_format])
|
||||||
|
|
||||||
# 쿠키 파일
|
# 쿠키 파일
|
||||||
if options.get('cookiefile'):
|
if options.get('cookiefile'):
|
||||||
@@ -90,11 +98,54 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
239
mod_queue.py
239
mod_queue.py
@@ -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,21 +432,35 @@ 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
|
||||||
self.error_message = str(e)
|
self.error_message = str(e)
|
||||||
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):
|
||||||
@@ -444,6 +509,90 @@ class DownloadTask:
|
|||||||
self._downloader.resume()
|
self._downloader.resume()
|
||||||
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]:
|
||||||
"""현재 상태 반환"""
|
"""현재 상태 반환"""
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
42
model.py
42
model.py
@@ -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'
|
||||||
|
|
||||||
@@ -42,4 +43,45 @@ 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())
|
||||||
|
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
background: rgba(0,0,0,0.2) !important;
|
|
||||||
border-bottom: 1px solid var(--metal-border) !important;
|
|
||||||
color: var(--metal-text);
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Table Override */
|
/* Redesigned Navigation Menu (tabs) */
|
||||||
.table {
|
#menu_page_div .nav-pills {
|
||||||
color: var(--metal-text) !important;
|
margin-top: 2px !important;
|
||||||
}
|
margin-bottom: 12px !important;
|
||||||
.table thead th {
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
border-top: none;
|
backdrop-filter: blur(12px);
|
||||||
border-bottom: 2px solid var(--metal-border);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
color: var(--metal-text-muted);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
font-size: 0.85rem;
|
border-radius: 12px !important;
|
||||||
}
|
padding: 6px !important;
|
||||||
.table td {
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3) !important;
|
||||||
border-top: 1px solid rgba(255,255,255,0.05);
|
display: inline-flex !important;
|
||||||
vertical-align: middle;
|
width: auto !important;
|
||||||
}
|
|
||||||
.table-striped tbody tr:nth-of-type(odd) {
|
|
||||||
background-color: rgba(255,255,255,0.02) !important;
|
|
||||||
}
|
|
||||||
.table-hover tbody tr:hover {
|
|
||||||
background-color: rgba(255,255,255,0.05) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Global Navigation Spacing Adjustments (Fix for extra gap) */
|
||||||
.btn-outline-primary {
|
#menu_module_div {
|
||||||
color: var(--metal-highlight);
|
padding-top: 0 !important;
|
||||||
border-color: var(--metal-highlight);
|
|
||||||
}
|
}
|
||||||
.btn-outline-primary:hover {
|
#menu_module_div .nav-pills {
|
||||||
background-color: var(--metal-highlight);
|
margin-top: 0 !important;
|
||||||
color: #000;
|
margin-bottom: 5px !important;
|
||||||
box-shadow: 0 0 10px var(--metal-highlight);
|
|
||||||
}
|
}
|
||||||
.btn-outline-danger {
|
@media (min-width: 769px) {
|
||||||
color: #ff5252;
|
#main_container {
|
||||||
border-color: #ff5252;
|
margin-top: 0 !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.btn-outline-danger:hover {
|
/* Navigation Override (SJVA Menu Page) */
|
||||||
background-color: #ff5252;
|
#menu_page_div {
|
||||||
color: white;
|
margin-bottom: 2rem;
|
||||||
box-shadow: 0 0 10px #ff5252;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Badges */
|
#menu_page_div .nav-pills {
|
||||||
.badge {
|
background: rgba(30, 41, 59, 0.5) !important;
|
||||||
font-weight: 500;
|
backdrop-filter: blur(16px);
|
||||||
letter-spacing: 0.5px;
|
-webkit-backdrop-filter: blur(16px);
|
||||||
}
|
border: 1px solid var(--border);
|
||||||
.badge-outline-secondary {
|
border-radius: 14px;
|
||||||
border: 1px solid #666;
|
padding: 6px;
|
||||||
color: #aaa;
|
display: inline-flex;
|
||||||
background: transparent;
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
|
||||||
.badge-outline-info {
|
|
||||||
border: 1px solid var(--metal-highlight);
|
|
||||||
color: var(--metal-highlight);
|
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progress Bar */
|
#menu_page_div .nav-link {
|
||||||
.progress {
|
color: var(--text-muted);
|
||||||
background-color: rgba(0,0,0,0.5);
|
font-weight: 600;
|
||||||
border: 1px solid #333;
|
font-size: 0.875rem;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
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-premium" onclick="refreshList()">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="refreshList()">
|
<i class="fa fa-refresh"></i> Refresh
|
||||||
<i class="fa fa-refresh"></i> 새로고침
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
</div>
|
||||||
<table class="table table-striped table-hover mb-0">
|
|
||||||
<thead>
|
<div class="download-grid" id="download_list">
|
||||||
<tr>
|
<!-- List will be rendered here -->
|
||||||
<th style="width: 5%">#</th>
|
<div class="empty-state">
|
||||||
<th style="width: 10%">요청</th>
|
<i class="fa fa-cloud-download"></i>
|
||||||
<th style="width: 35%">제목/URL</th>
|
<p>No downloads in queue.</p>
|
||||||
<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
|
$.ajax({
|
||||||
var attempts = [
|
url: `/${PACKAGE_NAME}/ajax/${MODULE_NAME}/list`,
|
||||||
'/{{arg.package_name}}/ajax/{{arg.module_name}}/list', // New Candidate
|
type: 'POST',
|
||||||
'/{{arg.package_name}}/{{arg.module_name}}/ajax/list', // Standard
|
dataType: 'json',
|
||||||
'/{{arg.package_name}}/{{arg.module_name}}/queue/ajax/list', // Double Queue
|
data: {},
|
||||||
'/{{arg.package_name}}/queue/ajax/list' // Direct Queue
|
global: !silent,
|
||||||
];
|
success: function(ret) {
|
||||||
|
if (ret.ret === 'success') {
|
||||||
function tryUrl(index) {
|
renderList(ret.data || []);
|
||||||
if (index >= attempts.length) {
|
|
||||||
console.error("All list fetch attempts failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var url = attempts[index];
|
|
||||||
$.ajax({
|
|
||||||
url: url,
|
|
||||||
type: 'POST',
|
|
||||||
dataType: 'json',
|
|
||||||
data: {},
|
|
||||||
global: !silent, // Silent mode: suppress global loading indicator
|
|
||||||
success: function(ret) {
|
|
||||||
if (ret.ret === 'success') {
|
|
||||||
renderList(ret.data || []);
|
|
||||||
} else {
|
|
||||||
// tryUrl(index + 1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: function(e) {
|
|
||||||
// console.warn("Failed URL:", url);
|
|
||||||
tryUrl(index + 1);
|
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
error: function(e) {
|
||||||
|
// Fallback for different URL patterns if needed
|
||||||
tryUrl(0);
|
$.ajax({
|
||||||
|
url: `/${PACKAGE_NAME}/${MODULE_NAME}/ajax/list`,
|
||||||
|
type: 'POST',
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(ret) { if (ret.ret === 'success') renderList(ret.data || []); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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>`;
|
||||||
|
if (item.meta.season) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.season}기</span>`;
|
||||||
|
if (item.meta.episode) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.episode}화</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
return '<tr id="row_' + item.id + '">' +
|
return `
|
||||||
'<td>' + num + '</td>' +
|
<div class="dl-card" id="card_${item.id}">
|
||||||
'<td><span class="badge badge-outline-secondary">' + (item.caller_plugin || 'User') + '</span></td>' +
|
<div class="dl-meta">
|
||||||
'<td title="' + (item.url || '') + '">' +
|
<span class="dl-id-badge">#${item.id.toString().split('_').pop()}</span>
|
||||||
'<div style="max-width: 300px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">' + displayTitle + '</div>' +
|
<span class="dl-source-pill">${source.toUpperCase()}</span>
|
||||||
'</td>' +
|
</div>
|
||||||
'<td><span class="badge badge-outline-info">' + (item.source_type || 'auto') + '</span></td>' +
|
<div style="display: flex; gap: 1rem; align-items: flex-start;">
|
||||||
'<td>' +
|
${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="progress" style="height: 20px;">' +
|
<div class="dl-info" style="flex: 1; min-width: 0;">
|
||||||
'<div class="progress-bar" role="progressbar" style="width: ' + percent + '%;" aria-valuenow="' + percent + '" aria-valuemin="0" aria-valuemax="100">' + percent + '%</div>' +
|
<div class="dl-title" title="${displayTitle}">${displayTitle}</div>
|
||||||
'</div>' +
|
<div class="mt-2" style="font-size: 0.7rem; display: flex; flex-wrap: wrap; gap: 4px;">
|
||||||
'</td>' +
|
${metaHtml}
|
||||||
'<td>' + (item.speed || '-') + '</td>' +
|
</div>
|
||||||
'<td><span class="badge ' + (statusClass[item.status] || 'badge-secondary') + '">' + (item.status || 'unknown') + '</span></td>' +
|
</div>
|
||||||
'<td>' +
|
</div>
|
||||||
(item.status === 'downloading' ? '<button class="btn btn-sm btn-warning" onclick="cancelDownload(\'' + item.id + '\')"><i class="fa fa-stop"></i></button>' : '') +
|
<div class="dl-progress-container">
|
||||||
'</td>' +
|
<div class="dl-progress-header">
|
||||||
'</tr>';
|
<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 updateDownloadRow(item) {
|
function updateDownloadCard(item) {
|
||||||
var row = document.getElementById('row_' + item.id);
|
const card = document.getElementById('card_' + item.id);
|
||||||
if (row) {
|
if (card) {
|
||||||
row.outerHTML = createDownloadRow(item, row.rowIndex);
|
// 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 %}
|
||||||
|
|||||||
@@ -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() }}
|
|
||||||
|
|
||||||
<form id="setting">
|
<div class="settings-container">
|
||||||
<!-- Basic Setting -->
|
<form id="setting">
|
||||||
{{ macros.setting_top_big('기본 설정') }}
|
<!-- Basic Setting -->
|
||||||
{{ macros.setting_bottom() }}
|
<h5 class="mb-4">General Settings</h5>
|
||||||
|
|
||||||
{{ 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>
|
||||||
|
|
||||||
<!-- Downloader Setting -->
|
<div class="row">
|
||||||
{{ macros.setting_top_big('다운로더 설정') }}
|
<div class="col-md-6">
|
||||||
{{ macros.setting_bottom() }}
|
<div class="form-group">
|
||||||
|
<label>Max Concurrent Downloads</label>
|
||||||
{{ macros.setting_input_text('aria2c_path', 'aria2c 경로', value=arg['aria2c_path'], desc='aria2c 실행 파일 경로 (고속 다운로드용)') }}
|
<input type="number" name="max_concurrent" class="form-control" value="{{arg['max_concurrent']}}">
|
||||||
{{ macros.setting_input_text('aria2c_connections', 'aria2c 연결 수', value=arg['aria2c_connections'], desc='aria2c 동시 연결 수 (기본 16)') }}
|
</div>
|
||||||
{{ macros.setting_input_text('ffmpeg_path', 'ffmpeg 경로', value=arg['ffmpeg_path'], desc='ffmpeg 실행 파일 경로 (HLS 스트림용)') }}
|
</div>
|
||||||
{{ macros.setting_input_text('yt_dlp_path', 'yt-dlp 경로', value=arg['yt_dlp_path'], desc='비워두면 Python 모듈 사용') }}
|
<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>
|
||||||
|
|
||||||
{{ macros.m_hr() }}
|
<hr>
|
||||||
|
|
||||||
<!-- Retry Setting -->
|
<!-- Downloader Setting -->
|
||||||
{{ macros.setting_top_big('재시도 설정') }}
|
<h5 class="mb-4">External Tool Paths</h5>
|
||||||
{{ macros.setting_bottom() }}
|
|
||||||
|
<div class="form-group">
|
||||||
{{ macros.setting_checkbox('auto_retry', '자동 재시도', value=arg['auto_retry'], desc='다운로드 실패 시 자동으로 재시도') }}
|
<label>aria2c Path</label>
|
||||||
{{ macros.setting_input_text('max_retry', '최대 재시도 횟수', value=arg['max_retry'], desc='최대 재시도 횟수') }}
|
<input type="text" name="aria2c_path" class="form-control" value="{{arg['aria2c_path']}}">
|
||||||
|
<small class="form-text">Executable path for aria2c (used for high-speed downloads).</small>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
<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 -->
|
||||||
|
<h5 class="mb-4">Error Handling</h5>
|
||||||
|
|
||||||
|
<div class="form-group custom-control custom-switch mb-3">
|
||||||
|
<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>
|
||||||
|
</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'});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user