Release v0.1.0: GDM Refactor, Rate Limit, Metallic UI
This commit is contained in:
46
README.md
Normal file
46
README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# gommi_download_manager (GDM)
|
||||
|
||||
FlaskFarm 범용 다운로더 큐 플러그인 (v0.1.0)
|
||||
|
||||
## 🆕 0.1.0 업데이트 (Latest)
|
||||
- **다운로드 속도 제한**: 설정 페이지에서 대역폭 제한 설정 가능 (무제한, 1MB/s, 5MB/s...)
|
||||
- **UI 리뉴얼**: 고급스러운 Dark Metallic 디자인 & 반응형 웹 지원
|
||||
- **안정성 강화**: 서버 재시작 시 대기 중인 다운로드 상태 복원 (Queue Persistence)
|
||||
- **목록 관리**: 전체 삭제 및 자동 목록 갱신 기능 (Flickr-free)
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- **YouTube/일반 사이트**: yt-dlp + aria2c 지원 (고속 분할 다운로드)
|
||||
- **스트리밍 사이트**: 애니24, 링크애니, Anilife (ffmpeg HLS / Camoufox) 지원
|
||||
- **중앙 집중식 관리**: 여러 플러그인의 다운로드 요청을 한곳에서 통합 관리
|
||||
- **전역 속도 제한 (Smart Limiter)**: 모든 다운로드에 공통 적용되는 속도 제한 기능
|
||||
|
||||
## 외부 플러그인에서 사용하기
|
||||
|
||||
```python
|
||||
from gommi_download_manager.mod_queue import ModuleQueue
|
||||
|
||||
# 다운로드 추가 (속도 제한은 사용자가 설정한 값 자동 적용)
|
||||
task = ModuleQueue.add_download(
|
||||
url='https://www.youtube.com/watch?v=...',
|
||||
save_path='/path/to/save', # 플러그인별 저장 경로 우선 적용
|
||||
filename='video.mp4', # 선택
|
||||
source_type='auto', # 자동 감지
|
||||
caller_plugin='youtube', # 호출자 식별
|
||||
)
|
||||
```
|
||||
|
||||
## 설정 가이드
|
||||
|
||||
웹 인터페이스 (`/gommi_download_manager/queue/setting`)에서 다음을 설정할 수 있습니다:
|
||||
- **속도 제한**: 네트워크 상황에 맞춰 최대 다운로드 속도 조절
|
||||
- **동시 다운로드 수**: 한 번에 몇 개를 받을지 설정
|
||||
- **기본 저장 경로**: 경로 미지정 요청에 대한 백업 경로
|
||||
|
||||
## 성능 비교
|
||||
|
||||
| 다운로더 | 방식 | 특징 |
|
||||
|---------|------|------|
|
||||
| **yt-dlp (Native)** | 안정적 | 속도 제한 기능 완벽 지원 |
|
||||
| **aria2c** | 고속 (분할) | 대용량 파일에 최적화 (현재 실험적 지원) |
|
||||
| **ffmpeg** | 스트림 | HLS/M3U8 영상 저장에 사용 |
|
||||
1
__init__.py
Normal file
1
__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# gommi_download_manager - Universal Downloader Queue Plugin
|
||||
BIN
__pycache__/__init__.cpython-314.pyc
Normal file
BIN
__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/mod_queue.cpython-314.pyc
Normal file
BIN
__pycache__/mod_queue.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/model.cpython-314.pyc
Normal file
BIN
__pycache__/model.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/setup.cpython-314.pyc
Normal file
BIN
__pycache__/setup.cpython-314.pyc
Normal file
Binary file not shown.
30
downloader/__init__.py
Normal file
30
downloader/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
다운로더 모듈 패키지
|
||||
"""
|
||||
from typing import Optional
|
||||
from .base import BaseDownloader
|
||||
|
||||
|
||||
def get_downloader(source_type: str) -> Optional[BaseDownloader]:
|
||||
"""소스 타입에 맞는 다운로더 인스턴스 반환"""
|
||||
|
||||
if source_type in ('youtube', 'general'):
|
||||
from .ytdlp_aria2 import YtdlpAria2Downloader
|
||||
return YtdlpAria2Downloader()
|
||||
|
||||
elif source_type in ('ani24', 'linkkf', 'hls'):
|
||||
from .ffmpeg_hls import FfmpegHlsDownloader
|
||||
return FfmpegHlsDownloader()
|
||||
|
||||
elif source_type == 'anilife':
|
||||
from .anilife import AnilifeDnloader
|
||||
return AnilifeDnloader()
|
||||
|
||||
elif source_type == 'http':
|
||||
from .http_direct import HttpDirectDownloader
|
||||
return HttpDirectDownloader()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ['get_downloader', 'BaseDownloader']
|
||||
BIN
downloader/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
downloader/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
downloader/__pycache__/base.cpython-314.pyc
Normal file
BIN
downloader/__pycache__/base.cpython-314.pyc
Normal file
Binary file not shown.
BIN
downloader/__pycache__/ytdlp_aria2.cpython-314.pyc
Normal file
BIN
downloader/__pycache__/ytdlp_aria2.cpython-314.pyc
Normal file
Binary file not shown.
144
downloader/anilife.py
Normal file
144
downloader/anilife.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Anilife 전용 다운로더
|
||||
- Camoufox로 _aldata 추출 후 ffmpeg 다운로드
|
||||
- 기존 anime_downloader의 camoufox_anilife.py 로직 활용
|
||||
"""
|
||||
import os
|
||||
import traceback
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
|
||||
from .base import BaseDownloader
|
||||
from .ffmpeg_hls import FfmpegHlsDownloader
|
||||
|
||||
try:
|
||||
from ..setup import P
|
||||
logger = P.logger
|
||||
except:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnilifeDnloader(BaseDownloader):
|
||||
"""Anilife 전용 다운로더 (Camoufox + FFmpeg)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._ffmpeg_downloader = FfmpegHlsDownloader()
|
||||
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
filename: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
**options
|
||||
) -> Dict[str, Any]:
|
||||
"""Anilife 다운로드 (추출 + 다운로드)"""
|
||||
try:
|
||||
# 1. 스트림 URL 추출
|
||||
if progress_callback:
|
||||
progress_callback(0, 'Extracting...', '')
|
||||
|
||||
stream_url = self._extract_stream_url(url, options)
|
||||
|
||||
if not stream_url:
|
||||
return {'success': False, 'error': 'Failed to extract stream URL'}
|
||||
|
||||
logger.info(f'Anilife 스트림 URL 추출 완료: {stream_url[:50]}...')
|
||||
|
||||
# 2. FFmpeg로 다운로드
|
||||
return self._ffmpeg_downloader.download(
|
||||
url=stream_url,
|
||||
save_path=save_path,
|
||||
filename=filename,
|
||||
progress_callback=progress_callback,
|
||||
**options
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Anilife download error: {e}')
|
||||
logger.error(traceback.format_exc())
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_info(self, url: str) -> Dict[str, Any]:
|
||||
"""URL 정보 추출"""
|
||||
return {'source': 'anilife'}
|
||||
|
||||
def cancel(self):
|
||||
"""다운로드 취소"""
|
||||
super().cancel()
|
||||
self._ffmpeg_downloader.cancel()
|
||||
|
||||
def _extract_stream_url(self, url: str, options: Dict) -> Optional[str]:
|
||||
"""Camoufox를 사용하여 스트림 URL 추출"""
|
||||
try:
|
||||
# anime_downloader의 기존 로직 활용 시도
|
||||
try:
|
||||
from anime_downloader.lib.camoufox_anilife import extract_aldata
|
||||
import asyncio
|
||||
|
||||
# URL에서 detail_url과 episode_num 파싱
|
||||
detail_url = options.get('detail_url', url)
|
||||
episode_num = options.get('episode_num', '1')
|
||||
|
||||
# 비동기 추출 실행
|
||||
result = asyncio.run(extract_aldata(detail_url, episode_num))
|
||||
|
||||
if result.get('success') and result.get('aldata'):
|
||||
# aldata 디코딩하여 실제 스트림 URL 획득
|
||||
return self._decode_aldata(result['aldata'])
|
||||
|
||||
except ImportError:
|
||||
logger.warning('anime_downloader 모듈을 찾을 수 없습니다. 기본 추출 로직 사용')
|
||||
|
||||
# 폴백: 직접 Camoufox 사용
|
||||
return self._extract_with_camoufox(url, options)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Stream URL extraction error: {e}')
|
||||
return None
|
||||
|
||||
def _decode_aldata(self, aldata: str) -> Optional[str]:
|
||||
"""_aldata base64 디코딩"""
|
||||
try:
|
||||
import base64
|
||||
import json
|
||||
|
||||
decoded = base64.b64decode(aldata).decode('utf-8')
|
||||
data = json.loads(decoded)
|
||||
|
||||
# 스트림 URL 추출 (구조에 따라 다를 수 있음)
|
||||
if isinstance(data, dict):
|
||||
return data.get('url') or data.get('stream') or data.get('file')
|
||||
elif isinstance(data, str):
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'_aldata decode error: {e}')
|
||||
return None
|
||||
|
||||
def _extract_with_camoufox(self, url: str, options: Dict) -> Optional[str]:
|
||||
"""직접 Camoufox 사용하여 추출"""
|
||||
try:
|
||||
from camoufox.async_api import AsyncCamoufox
|
||||
import asyncio
|
||||
|
||||
async def extract():
|
||||
async with AsyncCamoufox(headless=True) as browser:
|
||||
page = await browser.new_page()
|
||||
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
||||
|
||||
# _aldata 변수 추출 시도
|
||||
aldata = await page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null")
|
||||
|
||||
await page.close()
|
||||
return aldata
|
||||
|
||||
aldata = asyncio.run(extract())
|
||||
if aldata:
|
||||
return self._decode_aldata(aldata)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Camoufox extraction error: {e}')
|
||||
|
||||
return None
|
||||
77
downloader/base.py
Normal file
77
downloader/base.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
다운로더 베이스 클래스
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
|
||||
|
||||
class BaseDownloader(ABC):
|
||||
"""모든 다운로더의 추상 베이스 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self._cancelled = False
|
||||
self._paused = False
|
||||
|
||||
@abstractmethod
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
filename: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
**options
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
다운로드 실행
|
||||
|
||||
Args:
|
||||
url: 다운로드 URL
|
||||
save_path: 저장 경로
|
||||
filename: 파일명 (None이면 자동 감지)
|
||||
progress_callback: 진행률 콜백 (progress, speed, eta)
|
||||
**options: 추가 옵션
|
||||
|
||||
Returns:
|
||||
{
|
||||
'success': bool,
|
||||
'filepath': str, # 완료된 파일 경로
|
||||
'error': str, # 에러 메시지 (실패 시)
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_info(self, url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
URL 정보 추출 (메타데이터)
|
||||
|
||||
Returns:
|
||||
{
|
||||
'title': str,
|
||||
'thumbnail': str,
|
||||
'duration': int,
|
||||
'formats': list,
|
||||
...
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
def cancel(self):
|
||||
"""다운로드 취소"""
|
||||
self._cancelled = True
|
||||
|
||||
def pause(self):
|
||||
"""다운로드 일시정지"""
|
||||
self._paused = True
|
||||
|
||||
def resume(self):
|
||||
"""다운로드 재개"""
|
||||
self._paused = False
|
||||
|
||||
@property
|
||||
def is_cancelled(self) -> bool:
|
||||
return self._cancelled
|
||||
|
||||
@property
|
||||
def is_paused(self) -> bool:
|
||||
return self._paused
|
||||
153
downloader/ffmpeg_hls.py
Normal file
153
downloader/ffmpeg_hls.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
FFmpeg HLS 다운로더
|
||||
- ani24, 링크애니 등 HLS 스트림용
|
||||
- 기존 SupportFfmpeg 로직 재사용
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
import traceback
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
|
||||
from .base import BaseDownloader
|
||||
|
||||
try:
|
||||
from ..setup import P
|
||||
logger = P.logger
|
||||
except:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FfmpegHlsDownloader(BaseDownloader):
|
||||
"""FFmpeg HLS 다운로더"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
filename: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
**options
|
||||
) -> Dict[str, Any]:
|
||||
"""ffmpeg로 HLS 스트림 다운로드"""
|
||||
try:
|
||||
os.makedirs(save_path, exist_ok=True)
|
||||
|
||||
# 파일명 결정
|
||||
if not filename:
|
||||
filename = f"download_{int(__import__('time').time())}.mp4"
|
||||
|
||||
filepath = os.path.join(save_path, filename)
|
||||
|
||||
# ffmpeg 명령어 구성
|
||||
ffmpeg_path = options.get('ffmpeg_path', 'ffmpeg')
|
||||
|
||||
cmd = [ffmpeg_path, '-y']
|
||||
|
||||
# 헤더 추가
|
||||
headers = options.get('headers', {})
|
||||
if headers:
|
||||
header_str = '\r\n'.join([f'{k}: {v}' for k, v in headers.items()])
|
||||
cmd.extend(['-headers', header_str])
|
||||
|
||||
# 입력 URL
|
||||
cmd.extend(['-i', url])
|
||||
|
||||
# 코덱 복사 (트랜스코딩 없이 빠르게)
|
||||
cmd.extend(['-c', 'copy'])
|
||||
|
||||
# 출력 파일
|
||||
cmd.append(filepath)
|
||||
|
||||
logger.debug(f'ffmpeg 명령어: {" ".join(cmd[:10])}...')
|
||||
|
||||
# 먼저 duration 얻기 위해 ffprobe 실행
|
||||
duration = self._get_duration(url, options.get('ffprobe_path', 'ffprobe'), headers)
|
||||
|
||||
# 프로세스 실행
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
# 출력 파싱
|
||||
for line in self._process.stdout:
|
||||
if self._cancelled:
|
||||
self._process.terminate()
|
||||
return {'success': False, 'error': 'Cancelled'}
|
||||
|
||||
line = line.strip()
|
||||
|
||||
# 진행률 계산 (time= 파싱)
|
||||
if duration > 0 and progress_callback:
|
||||
time_match = re.search(r'time=(\d+):(\d+):(\d+)', line)
|
||||
if time_match:
|
||||
h, m, s = map(int, time_match.groups())
|
||||
current_time = h * 3600 + m * 60 + s
|
||||
progress = min(int(current_time / duration * 100), 99)
|
||||
|
||||
# 속도 파싱
|
||||
speed = ''
|
||||
speed_match = re.search(r'speed=\s*([\d.]+)x', line)
|
||||
if speed_match:
|
||||
speed = f'{speed_match.group(1)}x'
|
||||
|
||||
progress_callback(progress, speed, '')
|
||||
|
||||
self._process.wait()
|
||||
|
||||
if self._process.returncode == 0 and os.path.exists(filepath):
|
||||
if progress_callback:
|
||||
progress_callback(100, '', '')
|
||||
return {'success': True, 'filepath': filepath}
|
||||
else:
|
||||
return {'success': False, 'error': f'FFmpeg exit code: {self._process.returncode}'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'FfmpegHls download error: {e}')
|
||||
logger.error(traceback.format_exc())
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_info(self, url: str) -> Dict[str, Any]:
|
||||
"""스트림 정보 추출"""
|
||||
try:
|
||||
duration = self._get_duration(url, 'ffprobe', {})
|
||||
return {
|
||||
'duration': duration,
|
||||
'type': 'hls',
|
||||
}
|
||||
except:
|
||||
return {}
|
||||
|
||||
def cancel(self):
|
||||
"""다운로드 취소"""
|
||||
super().cancel()
|
||||
if self._process:
|
||||
self._process.terminate()
|
||||
|
||||
def _get_duration(self, url: str, ffprobe_path: str, headers: Dict) -> float:
|
||||
"""ffprobe로 영상 길이 획득"""
|
||||
try:
|
||||
cmd = [ffprobe_path, '-v', 'error', '-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1']
|
||||
|
||||
if headers:
|
||||
header_str = '\r\n'.join([f'{k}: {v}' for k, v in headers.items()])
|
||||
cmd.extend(['-headers', header_str])
|
||||
|
||||
cmd.append(url)
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode == 0:
|
||||
return float(result.stdout.strip())
|
||||
except:
|
||||
pass
|
||||
return 0
|
||||
91
downloader/http_direct.py
Normal file
91
downloader/http_direct.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
HTTP 직접 다운로더
|
||||
- 단순 HTTP 파일 다운로드
|
||||
- aiohttp 비동기 사용 (고성능)
|
||||
"""
|
||||
import os
|
||||
import traceback
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
|
||||
from .base import BaseDownloader
|
||||
|
||||
try:
|
||||
from ..setup import P
|
||||
logger = P.logger
|
||||
except:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HttpDirectDownloader(BaseDownloader):
|
||||
"""HTTP 직접 다운로더"""
|
||||
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
filename: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
**options
|
||||
) -> Dict[str, Any]:
|
||||
"""HTTP로 직접 다운로드"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
os.makedirs(save_path, exist_ok=True)
|
||||
|
||||
# 파일명 결정
|
||||
if not filename:
|
||||
filename = url.split('/')[-1].split('?')[0] or f"download_{int(__import__('time').time())}"
|
||||
|
||||
filepath = os.path.join(save_path, filename)
|
||||
|
||||
# 헤더 설정
|
||||
headers = options.get('headers', {})
|
||||
if 'User-Agent' not in headers:
|
||||
headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
|
||||
# 스트리밍 다운로드
|
||||
response = requests.get(url, headers=headers, stream=True, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
chunk_size = 1024 * 1024 # 1MB 청크
|
||||
|
||||
with open(filepath, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
if self._cancelled:
|
||||
return {'success': False, 'error': 'Cancelled'}
|
||||
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
if total_size > 0 and progress_callback:
|
||||
progress = int(downloaded / total_size * 100)
|
||||
speed = '' # TODO: 속도 계산
|
||||
progress_callback(progress, speed, '')
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(100, '', '')
|
||||
|
||||
return {'success': True, 'filepath': filepath}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'HTTP download error: {e}')
|
||||
logger.error(traceback.format_exc())
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_info(self, url: str) -> Dict[str, Any]:
|
||||
"""URL 정보 추출"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
response = requests.head(url, timeout=10)
|
||||
return {
|
||||
'content_length': response.headers.get('content-length'),
|
||||
'content_type': response.headers.get('content-type'),
|
||||
}
|
||||
except:
|
||||
return {}
|
||||
222
downloader/ytdlp_aria2.py
Normal file
222
downloader/ytdlp_aria2.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
yt-dlp + aria2c 다운로더 (최고속)
|
||||
- aria2c 16개 연결로 3-5배 속도 향상
|
||||
- YouTube 및 yt-dlp 지원 사이트 전용
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import traceback
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
|
||||
from .base import BaseDownloader
|
||||
|
||||
# 상위 모듈에서 로거 가져오기
|
||||
try:
|
||||
from ..setup import P
|
||||
logger = P.logger
|
||||
except:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YtdlpAria2Downloader(BaseDownloader):
|
||||
"""yt-dlp + aria2c 다운로더"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
filename: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
**options
|
||||
) -> Dict[str, Any]:
|
||||
"""yt-dlp + aria2c로 다운로드"""
|
||||
try:
|
||||
os.makedirs(save_path, exist_ok=True)
|
||||
|
||||
# 출력 템플릿
|
||||
if filename:
|
||||
output_template = os.path.join(save_path, filename)
|
||||
else:
|
||||
output_template = os.path.join(save_path, '%(title)s.%(ext)s')
|
||||
|
||||
# yt-dlp 명령어 구성
|
||||
cmd = [
|
||||
'yt-dlp',
|
||||
'--newline', # 진행률 파싱용
|
||||
'-o', output_template,
|
||||
]
|
||||
|
||||
# aria2c 사용 (설치되어 있으면)
|
||||
aria2c_path = options.get('aria2c_path', 'aria2c')
|
||||
# TODO: 나중에 설정에서 쓰레드 수 지정 (기본값 4로 변경)
|
||||
connections = options.get('connections', 4)
|
||||
|
||||
# 속도 제한 설정
|
||||
max_rate = P.ModelSetting.get('max_download_rate')
|
||||
if max_rate == '0':
|
||||
max_rate_arg = ''
|
||||
log_rate_msg = '무제한'
|
||||
else:
|
||||
max_rate_arg = f'--max-download-limit={max_rate}'
|
||||
log_rate_msg = max_rate
|
||||
cmd.extend(['--limit-rate', max_rate]) # Native downloader limit
|
||||
|
||||
# aria2c 사용 (일시 중지: 진행률 파싱 문제 해결 전까지 Native 사용)
|
||||
if False and self._check_aria2c(aria2c_path):
|
||||
cmd.extend([
|
||||
'--downloader', 'aria2c',
|
||||
'--downloader-args', f'aria2c:-x {connections} -s {connections} -k 1M {max_rate_arg}',
|
||||
])
|
||||
logger.debug(f'aria2c 사용: {connections}개 연결 (속도제한 {log_rate_msg})')
|
||||
|
||||
# 포맷 선택
|
||||
format_spec = options.get('format', 'bestvideo+bestaudio/best')
|
||||
cmd.extend(['-f', format_spec])
|
||||
|
||||
# 병합 포맷
|
||||
merge_format = options.get('merge_output_format', 'mp4')
|
||||
cmd.extend(['--merge-output-format', merge_format])
|
||||
|
||||
# 쿠키 파일
|
||||
if options.get('cookiefile'):
|
||||
cmd.extend(['--cookies', options['cookiefile']])
|
||||
|
||||
# 프록시
|
||||
if options.get('proxy'):
|
||||
cmd.extend(['--proxy', options['proxy']])
|
||||
|
||||
# URL 추가
|
||||
cmd.append(url)
|
||||
|
||||
logger.debug(f'yt-dlp 명령어: {" ".join(cmd)}')
|
||||
|
||||
# 프로세스 실행
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
final_filepath = ''
|
||||
|
||||
# 출력 파싱
|
||||
for line in self._process.stdout:
|
||||
if self._cancelled:
|
||||
self._process.terminate()
|
||||
return {'success': False, 'error': 'Cancelled'}
|
||||
|
||||
line = line.strip()
|
||||
# logger.debug(line)
|
||||
|
||||
# 진행률 파싱 (yt-dlp default)
|
||||
progress_match = re.search(r'\[download\]\s+(\d+\.?\d*)%', line)
|
||||
|
||||
# 진행률 파싱 (aria2c)
|
||||
if not progress_match:
|
||||
# logger.error(f'DEBUG LINE: {line}') # Log raw line to debug
|
||||
aria2_match = re.search(r'\(\s*([\d.]+)%\)', line) # Allow spaces ( 7%)
|
||||
if aria2_match and (('DL:' in line) or ('CN:' in line)): # DL or CN must be present
|
||||
try:
|
||||
progress = int(float(aria2_match.group(1)))
|
||||
# logger.error(f'MATCHED PROGRESS: {progress}%')
|
||||
|
||||
speed_match = re.search(r'DL:(\S+)', line)
|
||||
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 = eta_match.group(1) if eta_match else ''
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(progress, speed, eta)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f'Parsing Error: {e}')
|
||||
|
||||
if progress_match and progress_callback:
|
||||
progress = int(float(progress_match.group(1)))
|
||||
|
||||
# 속도 파싱
|
||||
speed = ''
|
||||
speed_match = re.search(r'at\s+([\d.]+\s*[KMG]?i?B/s)', line)
|
||||
if speed_match:
|
||||
speed = speed_match.group(1)
|
||||
|
||||
# ETA 파싱
|
||||
eta = ''
|
||||
eta_match = re.search(r'ETA\s+([\d:]+)', line)
|
||||
if eta_match:
|
||||
eta = eta_match.group(1)
|
||||
|
||||
progress_callback(progress, speed, eta)
|
||||
|
||||
# 최종 파일 경로 추출
|
||||
if '[Merger]' in line or 'Destination:' in line:
|
||||
path_match = re.search(r'(?:Destination:|into\s+["\'])(.+?)(?:["\']|$)', line)
|
||||
if path_match:
|
||||
final_filepath = path_match.group(1).strip('"\'')
|
||||
|
||||
self._process.wait()
|
||||
|
||||
if self._process.returncode == 0:
|
||||
if progress_callback:
|
||||
progress_callback(100, '', '')
|
||||
return {'success': True, 'filepath': final_filepath}
|
||||
else:
|
||||
return {'success': False, 'error': f'Exit code: {self._process.returncode}'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'YtdlpAria2 download error: {e}')
|
||||
logger.error(traceback.format_exc())
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_info(self, url: str) -> Dict[str, Any]:
|
||||
"""URL 정보 추출"""
|
||||
try:
|
||||
import yt_dlp
|
||||
|
||||
ydl_opts = {
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'extract_flat': False,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
return {
|
||||
'title': info.get('title', ''),
|
||||
'thumbnail': info.get('thumbnail', ''),
|
||||
'duration': info.get('duration', 0),
|
||||
'formats': info.get('formats', []),
|
||||
'uploader': info.get('uploader', ''),
|
||||
'view_count': info.get('view_count', 0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'get_info error: {e}')
|
||||
return {}
|
||||
|
||||
def cancel(self):
|
||||
"""다운로드 취소"""
|
||||
super().cancel()
|
||||
if self._process:
|
||||
self._process.terminate()
|
||||
|
||||
def _check_aria2c(self, aria2c_path: str) -> bool:
|
||||
"""aria2c 설치 확인"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[aria2c_path, '--version'],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
return result.returncode == 0
|
||||
except:
|
||||
return False
|
||||
7
info.yaml
Normal file
7
info.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
name: gommi_download_manager
|
||||
package_name: gommi_download_manager
|
||||
version: '0.1.0'
|
||||
description: FlaskFarm 범용 다운로더 큐 - YouTube, 애니24, 링크애니, Anilife 지원
|
||||
developer: projectdx
|
||||
home: https://gitea.yommi.duckdns.org/projectdx/gommi_downloader_manager
|
||||
category: tool
|
||||
466
mod_queue.py
Normal file
466
mod_queue.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
gommi_download_manager - 다운로드 큐 관리 모듈
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List, Callable
|
||||
from enum import Enum
|
||||
|
||||
from flask import render_template, jsonify
|
||||
from framework import F, socketio
|
||||
|
||||
from .setup import P, PluginModuleBase, default_route_socketio_module, ToolUtil
|
||||
|
||||
|
||||
class DownloadStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
EXTRACTING = "extracting" # 메타데이터 추출 중
|
||||
DOWNLOADING = "downloading"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
ERROR = "error"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ModuleQueue(PluginModuleBase):
|
||||
"""다운로드 큐 관리 모듈"""
|
||||
|
||||
db_default = {
|
||||
'aria2c_path': 'aria2c',
|
||||
'aria2c_connections': '16', # 동시 연결 수
|
||||
'ffmpeg_path': 'ffmpeg',
|
||||
'yt_dlp_path': '', # 비어있으면 python module 사용
|
||||
'save_path': '{PATH_DATA}/download',
|
||||
'temp_path': '{PATH_DATA}/download_tmp',
|
||||
'max_concurrent': '3', # 동시 다운로드 수
|
||||
'max_download_rate': '0', # 최대 다운로드 속도 (0: 무제한, 5M, 10M...)
|
||||
'auto_retry': 'true',
|
||||
'max_retry': '3',
|
||||
}
|
||||
|
||||
# 진행 중인 다운로드 인스턴스들
|
||||
_downloads: Dict[str, 'DownloadTask'] = {}
|
||||
_queue_lock = threading.Lock()
|
||||
|
||||
def __init__(self, P: Any) -> None:
|
||||
super(ModuleQueue, self).__init__(P, name='queue', first_menu='list')
|
||||
default_route_socketio_module(self, attach='/queue')
|
||||
|
||||
|
||||
def process_menu(self, page_name: str, req: Any) -> Any:
|
||||
"""메뉴 페이지 렌더링"""
|
||||
P.logger.debug(f'Page Request: {page_name}')
|
||||
arg = P.ModelSetting.to_dict()
|
||||
try:
|
||||
arg['module_name'] = self.name
|
||||
arg['package_name'] = P.package_name # 명시적 추가
|
||||
arg['path_data'] = F.config['path_data']
|
||||
return render_template(f'{P.package_name}_{self.name}_{page_name}.html', arg=arg)
|
||||
except Exception as e:
|
||||
P.logger.error(f'Exception:{str(e)}')
|
||||
P.logger.error(traceback.format_exc())
|
||||
return render_template('sample.html', title=f"{P.package_name}/{self.name}/{page_name}")
|
||||
|
||||
def process_ajax(self, command: str, req: Any) -> Any:
|
||||
"""AJAX 명령 처리"""
|
||||
# P.logger.debug(f'Command: {command}')
|
||||
ret = {'ret': 'success'}
|
||||
try:
|
||||
if command == 'add':
|
||||
# 큐에 다운로드 추가
|
||||
url = req.form['url']
|
||||
save_path = req.form.get('save_path') or ToolUtil.make_path(P.ModelSetting.get('save_path'))
|
||||
filename = req.form.get('filename')
|
||||
|
||||
item = self.add_download(url, save_path, filename)
|
||||
ret['data'] = item.as_dict() if item else None
|
||||
|
||||
elif command == 'list':
|
||||
# 진행 중인 다운로드 목록
|
||||
items = [d.get_status() for d in self._downloads.values()]
|
||||
P.logger.debug(f'List Command: {len(items)} items')
|
||||
ret['data'] = items
|
||||
|
||||
elif command == 'cancel':
|
||||
# 다운로드 취소
|
||||
download_id = req.form['id']
|
||||
if download_id in self._downloads:
|
||||
self._downloads[download_id].cancel()
|
||||
ret['msg'] = '다운로드가 취소되었습니다.'
|
||||
|
||||
elif command == 'pause':
|
||||
download_id = req.form['id']
|
||||
if download_id in self._downloads:
|
||||
self._downloads[download_id].pause()
|
||||
|
||||
elif command == 'resume':
|
||||
download_id = req.form['id']
|
||||
if download_id in self._downloads:
|
||||
self._downloads[download_id].resume()
|
||||
|
||||
elif command == 'reset':
|
||||
# 전체 목록 초기화 (진행중인건 취소)
|
||||
for task in list(self._downloads.values()):
|
||||
task.cancel()
|
||||
self._downloads.clear()
|
||||
|
||||
# DB에서도 삭제
|
||||
try:
|
||||
with F.app.app_context():
|
||||
from .model import ModelDownloadItem
|
||||
F.db.session.query(ModelDownloadItem).delete()
|
||||
F.db.session.commit()
|
||||
except Exception as e:
|
||||
P.logger.error(f'DB Clear Error: {e}')
|
||||
|
||||
ret['msg'] = '목록을 초기화했습니다.'
|
||||
|
||||
except Exception as e:
|
||||
P.logger.error(f'Exception:{str(e)}')
|
||||
P.logger.error(traceback.format_exc())
|
||||
ret['ret'] = 'error'
|
||||
ret['msg'] = str(e)
|
||||
|
||||
return jsonify(ret)
|
||||
|
||||
# ===== 외부 플러그인용 API =====
|
||||
|
||||
@classmethod
|
||||
def add_download(
|
||||
cls,
|
||||
url: str,
|
||||
save_path: str,
|
||||
filename: Optional[str] = None,
|
||||
source_type: Optional[str] = None,
|
||||
caller_plugin: Optional[str] = None,
|
||||
callback_id: Optional[str] = None,
|
||||
on_progress: Optional[Callable] = None,
|
||||
on_complete: Optional[Callable] = None,
|
||||
on_error: Optional[Callable] = None,
|
||||
**options
|
||||
) -> 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:
|
||||
# 소스 타입 자동 감지
|
||||
if not source_type or source_type == 'auto':
|
||||
source_type = cls._detect_source_type(url)
|
||||
|
||||
# DownloadTask 생성
|
||||
task = DownloadTask(
|
||||
url=url,
|
||||
save_path=save_path,
|
||||
filename=filename,
|
||||
source_type=source_type,
|
||||
caller_plugin=caller_plugin,
|
||||
callback_id=callback_id,
|
||||
on_progress=on_progress,
|
||||
on_complete=on_complete,
|
||||
on_error=on_error,
|
||||
**options
|
||||
)
|
||||
|
||||
with cls._queue_lock:
|
||||
cls._downloads[task.id] = task
|
||||
|
||||
|
||||
# 비동기 시작
|
||||
task.start()
|
||||
|
||||
# DB 저장
|
||||
from .model import ModelDownloadItem
|
||||
db_item = ModelDownloadItem()
|
||||
db_item.created_time = datetime.now()
|
||||
db_item.url = url
|
||||
db_item.save_path = save_path
|
||||
db_item.filename = filename
|
||||
db_item.source_type = source_type
|
||||
db_item.status = DownloadStatus.PENDING
|
||||
db_item.caller_plugin = caller_plugin
|
||||
db_item.callback_id = callback_id
|
||||
db_item.save()
|
||||
|
||||
task.db_id = db_item.id
|
||||
|
||||
|
||||
return task
|
||||
|
||||
except Exception as e:
|
||||
P.logger.error(f'add_download error: {e}')
|
||||
P.logger.error(traceback.format_exc())
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_download(cls, download_id: str) -> Optional['DownloadTask']:
|
||||
"""다운로드 태스크 조회"""
|
||||
return cls._downloads.get(download_id)
|
||||
|
||||
@classmethod
|
||||
def get_all_downloads(cls) -> List['DownloadTask']:
|
||||
"""모든 다운로드 태스크 조회"""
|
||||
return list(cls._downloads.values())
|
||||
|
||||
@classmethod
|
||||
def _detect_source_type(cls, url: str) -> str:
|
||||
"""URL에서 소스 타입 자동 감지"""
|
||||
url_lower = url.lower()
|
||||
|
||||
if 'youtube.com' in url_lower or 'youtu.be' in url_lower:
|
||||
return 'youtube'
|
||||
elif 'ani24' in url_lower or 'ohli24' in url_lower:
|
||||
return 'ani24'
|
||||
elif 'linkkf' in url_lower:
|
||||
return 'linkkf'
|
||||
elif 'anilife' in url_lower:
|
||||
return 'anilife'
|
||||
elif url_lower.endswith('.m3u8') or 'manifest' in url_lower:
|
||||
return 'hls'
|
||||
else:
|
||||
return 'http'
|
||||
|
||||
def plugin_load(self) -> None:
|
||||
"""플러그인 로드 시 초기화"""
|
||||
P.logger.info('gommi_downloader 플러그인 로드')
|
||||
try:
|
||||
# DB에서 진행 중인 작업 로드
|
||||
with F.app.app_context():
|
||||
from .model import ModelDownloadItem
|
||||
|
||||
# 간단하게 status != completed, cancelled, error
|
||||
items = F.db.session.query(ModelDownloadItem).filter(
|
||||
ModelDownloadItem.status.in_([
|
||||
DownloadStatus.PENDING,
|
||||
DownloadStatus.DOWNLOADING,
|
||||
DownloadStatus.EXTRACTING
|
||||
])
|
||||
).all()
|
||||
|
||||
for item in items:
|
||||
# DownloadTask 복원
|
||||
task = DownloadTask(
|
||||
url=item.url,
|
||||
save_path=item.save_path,
|
||||
filename=item.filename,
|
||||
source_type=item.source_type,
|
||||
caller_plugin=item.caller_plugin,
|
||||
callback_id=item.callback_id
|
||||
# options? DB에 저장 안함. 필요하면 추가해야 함.
|
||||
)
|
||||
task.status = DownloadStatus(item.status)
|
||||
task.db_id = item.id
|
||||
task.title = item.title or ''
|
||||
|
||||
# 상태가 downloading/extracting이었다면 pending으로 되돌려서 재시작하거나,
|
||||
# 바로 시작
|
||||
# 여기서는 pending으로 변경 후 다시 start 호출
|
||||
task.status = DownloadStatus.PENDING
|
||||
|
||||
self._downloads[task.id] = task
|
||||
task.start()
|
||||
|
||||
P.logger.info(f'{len(items)}개의 중단된 다운로드 작업 복원됨')
|
||||
|
||||
except Exception as e:
|
||||
P.logger.error(f'plugin_load error: {e}')
|
||||
P.logger.error(traceback.format_exc())
|
||||
|
||||
def plugin_unload(self) -> None:
|
||||
"""플러그인 언로드 시 정리"""
|
||||
# 모든 다운로드 중지
|
||||
for task in self._downloads.values():
|
||||
task.cancel()
|
||||
|
||||
|
||||
class DownloadTask:
|
||||
"""개별 다운로드 태스크"""
|
||||
|
||||
_counter = 0
|
||||
_counter_lock = threading.Lock()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
filename: Optional[str] = None,
|
||||
source_type: str = 'auto',
|
||||
caller_plugin: Optional[str] = None,
|
||||
callback_id: Optional[str] = None,
|
||||
on_progress: Optional[Callable] = None,
|
||||
on_complete: Optional[Callable] = None,
|
||||
on_error: Optional[Callable] = None,
|
||||
**options
|
||||
):
|
||||
with self._counter_lock:
|
||||
DownloadTask._counter += 1
|
||||
self.id = f"dl_{int(time.time())}_{DownloadTask._counter}"
|
||||
|
||||
self.url = url
|
||||
self.save_path = save_path
|
||||
self.filename = filename
|
||||
self.source_type = source_type
|
||||
self.caller_plugin = caller_plugin
|
||||
self.callback_id = callback_id
|
||||
self.options = options
|
||||
|
||||
# 콜백
|
||||
self._on_progress = on_progress
|
||||
self._on_complete = on_complete
|
||||
self._on_error = on_error
|
||||
|
||||
# 상태
|
||||
self.status = DownloadStatus.PENDING
|
||||
self.progress = 0
|
||||
self.speed = ''
|
||||
self.eta = ''
|
||||
self.error_message = ''
|
||||
self.filepath = ''
|
||||
|
||||
# 메타데이터
|
||||
self.title = ''
|
||||
self.thumbnail = ''
|
||||
self.duration = 0
|
||||
self.filesize = 0
|
||||
|
||||
# 내부
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._downloader = None
|
||||
self._cancelled = False
|
||||
self.db_id: Optional[int] = None
|
||||
|
||||
def start(self):
|
||||
"""다운로드 시작 (비동기)"""
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _run(self):
|
||||
"""다운로드 실행"""
|
||||
try:
|
||||
self.status = DownloadStatus.EXTRACTING
|
||||
self._emit_status()
|
||||
|
||||
# 다운로더 선택 및 실행
|
||||
from .downloader import get_downloader
|
||||
self._downloader = get_downloader(self.source_type)
|
||||
|
||||
if not self._downloader:
|
||||
raise Exception(f"지원하지 않는 소스 타입: {self.source_type}")
|
||||
|
||||
self.status = DownloadStatus.DOWNLOADING
|
||||
self._emit_status()
|
||||
|
||||
# 다운로드 실행
|
||||
result = self._downloader.download(
|
||||
url=self.url,
|
||||
save_path=self.save_path,
|
||||
filename=self.filename,
|
||||
progress_callback=self._progress_callback,
|
||||
**self.options
|
||||
)
|
||||
|
||||
if self._cancelled:
|
||||
self.status = DownloadStatus.CANCELLED
|
||||
elif result.get('success'):
|
||||
self.status = DownloadStatus.COMPLETED
|
||||
self.filepath = result.get('filepath', '')
|
||||
self.progress = 100
|
||||
if self._on_complete:
|
||||
self._on_complete(self.filepath)
|
||||
else:
|
||||
self.status = DownloadStatus.ERROR
|
||||
self.error_message = result.get('error', 'Unknown error')
|
||||
if self._on_error:
|
||||
self._on_error(self.error_message)
|
||||
|
||||
except Exception as e:
|
||||
P.logger.error(f'Download error: {e}')
|
||||
P.logger.error(traceback.format_exc())
|
||||
self.status = DownloadStatus.ERROR
|
||||
self.error_message = str(e)
|
||||
if self._on_error:
|
||||
self._on_error(self.error_message)
|
||||
|
||||
finally:
|
||||
self._emit_status()
|
||||
|
||||
def _progress_callback(self, progress: int, speed: str = '', eta: str = ''):
|
||||
"""진행률 콜백"""
|
||||
self.progress = progress
|
||||
self.speed = speed
|
||||
self.eta = eta
|
||||
|
||||
if self._on_progress:
|
||||
self._on_progress(progress, speed, eta)
|
||||
|
||||
self._emit_status()
|
||||
|
||||
def _emit_status(self):
|
||||
"""Socket.IO로 상태 전송"""
|
||||
try:
|
||||
socketio.emit(
|
||||
'download_status',
|
||||
self.get_status(),
|
||||
namespace=f'/{P.package_name}'
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
def cancel(self):
|
||||
"""다운로드 취소"""
|
||||
self._cancelled = True
|
||||
if self._downloader:
|
||||
self._downloader.cancel()
|
||||
self.status = DownloadStatus.CANCELLED
|
||||
self._emit_status()
|
||||
|
||||
def pause(self):
|
||||
"""다운로드 일시정지"""
|
||||
if self._downloader and hasattr(self._downloader, 'pause'):
|
||||
self._downloader.pause()
|
||||
self.status = DownloadStatus.PAUSED
|
||||
self._emit_status()
|
||||
|
||||
def resume(self):
|
||||
"""다운로드 재개"""
|
||||
if self._downloader and hasattr(self._downloader, 'resume'):
|
||||
self._downloader.resume()
|
||||
self.status = DownloadStatus.DOWNLOADING
|
||||
self._emit_status()
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""현재 상태 반환"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'url': self.url,
|
||||
'filename': self.filename,
|
||||
'save_path': self.save_path,
|
||||
'source_type': self.source_type,
|
||||
'status': self.status,
|
||||
'progress': self.progress,
|
||||
'speed': self.speed,
|
||||
'eta': self.eta,
|
||||
'title': self.title,
|
||||
'thumbnail': self.thumbnail,
|
||||
'error_message': self.error_message,
|
||||
'filepath': self.filepath,
|
||||
'caller_plugin': self.caller_plugin,
|
||||
'callback_id': self.callback_id,
|
||||
}
|
||||
45
model.py
Normal file
45
model.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
다운로드 큐 모델 정의
|
||||
"""
|
||||
from plugin import ModelBase, db
|
||||
|
||||
package_name = 'gommi_download_manager'
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
class ModelDownloadItem(ModelBase):
|
||||
"""다운로드 아이템 DB 모델"""
|
||||
__tablename__ = f'{package_name}_download_item'
|
||||
__table_args__ = {'mysql_collate': 'utf8_general_ci'}
|
||||
__bind_key__ = package_name
|
||||
|
||||
id: int = db.Column(db.Integer, primary_key=True)
|
||||
created_time: datetime = db.Column(db.DateTime)
|
||||
|
||||
# 다운로드 정보
|
||||
url: str = db.Column(db.String)
|
||||
filename: str = db.Column(db.String)
|
||||
save_path: str = db.Column(db.String)
|
||||
source_type: str = db.Column(db.String) # youtube, ani24, linkkf, anilife, http
|
||||
|
||||
# 상태
|
||||
status: str = db.Column(db.String) # pending, downloading, paused, completed, error
|
||||
progress: int = db.Column(db.Integer, default=0)
|
||||
speed: str = db.Column(db.String)
|
||||
eta: str = db.Column(db.String)
|
||||
|
||||
# 메타데이터
|
||||
title: str = db.Column(db.String)
|
||||
thumbnail: str = db.Column(db.String)
|
||||
duration: int = db.Column(db.Integer)
|
||||
filesize: int = db.Column(db.Integer)
|
||||
|
||||
# 호출자 정보
|
||||
caller_plugin: str = db.Column(db.String)
|
||||
callback_id: str = db.Column(db.String)
|
||||
|
||||
# 에러 정보
|
||||
error_message: str = db.Column(db.Text)
|
||||
retry_count: int = db.Column(db.Integer, default=0)
|
||||
|
||||
56
setup.py
Normal file
56
setup.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
gommi_download_manager - FlaskFarm 범용 다운로더 큐 플러그인
|
||||
|
||||
지원 소스:
|
||||
- YouTube (yt-dlp + aria2c)
|
||||
- 애니24/링크애니 (ffmpeg HLS)
|
||||
- Anilife (Camoufox + ffmpeg)
|
||||
- 기타 HTTP 직접 다운로드
|
||||
|
||||
성능 최적화:
|
||||
- aria2c 멀티커넥션 (16개 동시 연결)
|
||||
- 직접 import 방식 (API 오버헤드 제거)
|
||||
- asyncio 큐 처리
|
||||
"""
|
||||
import traceback
|
||||
|
||||
setting = {
|
||||
'filepath': __file__,
|
||||
'use_db': True,
|
||||
'use_default_setting': True,
|
||||
'home_module': 'queue',
|
||||
'menu': {
|
||||
'uri': __package__,
|
||||
'name': 'Gommi 다운로더',
|
||||
'list': [
|
||||
{
|
||||
'uri': 'queue',
|
||||
'name': '다운로드 큐',
|
||||
'list': [
|
||||
{'uri': 'setting', 'name': '설정'},
|
||||
{'uri': 'list', 'name': '다운로드 목록'},
|
||||
]
|
||||
},
|
||||
{
|
||||
'uri': 'manual',
|
||||
'name': '매뉴얼',
|
||||
'list': [
|
||||
{'uri': 'README.md', 'name': 'README'},
|
||||
]
|
||||
},
|
||||
{'uri': 'log', 'name': '로그'},
|
||||
]
|
||||
},
|
||||
'default_route': 'normal',
|
||||
}
|
||||
|
||||
from plugin import *
|
||||
|
||||
P = create_plugin_instance(setting)
|
||||
|
||||
try:
|
||||
from .mod_queue import ModuleQueue
|
||||
P.set_module_list([ModuleQueue])
|
||||
except Exception as e:
|
||||
P.logger.error(f'Exception:{str(e)}')
|
||||
P.logger.error(traceback.format_exc())
|
||||
19
static/gommi_download_manager.js
Normal file
19
static/gommi_download_manager.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* gommi_download_manager 플러그인 JavaScript
|
||||
*/
|
||||
|
||||
// 설정 저장
|
||||
function setting_save() {
|
||||
var form_data = getFormdata('#setting_form');
|
||||
FF.ajax({
|
||||
url: '/gommi_download_manager/queue/command/setting_save',
|
||||
data: form_data,
|
||||
success: function(ret) {
|
||||
if (ret.ret === 'success') {
|
||||
notify.success('설정이 저장되었습니다.');
|
||||
} else {
|
||||
notify.danger(ret.msg || '저장 실패');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
316
templates/gommi_download_manager_queue_list.html
Normal file
316
templates/gommi_download_manager_queue_list.html
Normal file
@@ -0,0 +1,316 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macro.html" as macros %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
/* 이 페이지에서만 전역 로딩 인디케이터 숨김 */
|
||||
#loading { display: none !important; }
|
||||
|
||||
/* Metallic Theme Variables */
|
||||
:root {
|
||||
--metal-dark: #1a1a1a;
|
||||
--metal-surface: linear-gradient(145deg, #2d2d2d, #1a1a1a);
|
||||
--metal-border: #404040;
|
||||
--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 */
|
||||
.card {
|
||||
background: var(--metal-surface) !important;
|
||||
border: 1px solid var(--metal-border) !important;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.6);
|
||||
color: var(--metal-text);
|
||||
}
|
||||
|
||||
.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 */
|
||||
.table {
|
||||
color: var(--metal-text) !important;
|
||||
}
|
||||
.table thead th {
|
||||
border-top: none;
|
||||
border-bottom: 2px solid var(--metal-border);
|
||||
color: var(--metal-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.table td {
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.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 */
|
||||
.btn-outline-primary {
|
||||
color: var(--metal-highlight);
|
||||
border-color: var(--metal-highlight);
|
||||
}
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--metal-highlight);
|
||||
color: #000;
|
||||
box-shadow: 0 0 10px var(--metal-highlight);
|
||||
}
|
||||
.btn-outline-danger {
|
||||
color: #ff5252;
|
||||
border-color: #ff5252;
|
||||
}
|
||||
.btn-outline-danger:hover {
|
||||
background-color: #ff5252;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px #ff5252;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.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;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
background: linear-gradient(90deg, #00acc1, #26c6da);
|
||||
box-shadow: 0 0 10px rgba(0, 188, 212, 0.5);
|
||||
font-size: 0.8rem;
|
||||
line-height: 20px;
|
||||
}
|
||||
</style>
|
||||
<div id="gommi_download_manager_queue_list" class="mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">다운로드 목록</h5>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger mr-2" onclick="resetList()">
|
||||
<i class="fa fa-trash"></i> 전체 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="refreshList()">
|
||||
<i class="fa fa-refresh"></i> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5%">#</th>
|
||||
<th style="width: 10%">요청</th>
|
||||
<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>
|
||||
<script src="/{{arg.package_name}}/static/{{arg.package_name}}.js"></script>
|
||||
<script>
|
||||
console.log("Start Gommi Queue List JS");
|
||||
// alert("Check: JS Running");
|
||||
|
||||
// Functions first
|
||||
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({
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tryUrl(0);
|
||||
}
|
||||
|
||||
function renderList(items) {
|
||||
var tbody = document.getElementById('download_list');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">다운로드 항목이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
items.forEach(function(item, index) {
|
||||
html += createDownloadRow(item, index + 1);
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
function createDownloadRow(item, num) {
|
||||
var statusClass = {
|
||||
'pending': 'badge-secondary',
|
||||
'extracting': 'badge-info',
|
||||
'downloading': 'badge-primary',
|
||||
'completed': 'badge-success',
|
||||
'error': 'badge-danger',
|
||||
'cancelled': 'badge-warning',
|
||||
'paused': 'badge-warning'
|
||||
};
|
||||
|
||||
var percent = (item.progress && !isNaN(item.progress)) ? item.progress : 0;
|
||||
var displayTitle = item.title ? item.title : (item.url || 'No Title');
|
||||
if (displayTitle.length > 50) displayTitle = displayTitle.substring(0, 50) + '...';
|
||||
|
||||
return '<tr id="row_' + item.id + '">' +
|
||||
'<td>' + num + '</td>' +
|
||||
'<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) {
|
||||
var row = document.getElementById('row_' + item.id);
|
||||
if (row) {
|
||||
row.outerHTML = createDownloadRow(item, row.rowIndex);
|
||||
} else {
|
||||
refreshList();
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDownload(id) {
|
||||
$.ajax({
|
||||
url: '/{{arg.package_name}}/ajax/{{arg.module_name}}/cancel', // Use new pattern
|
||||
type: 'POST',
|
||||
data: { id: id },
|
||||
dataType: 'json',
|
||||
success: function(ret) {
|
||||
if (ret.msg) {
|
||||
$.notify('<strong>' + ret.msg + '</strong>', {type: 'success'});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetList() {
|
||||
if (!confirm('정말 전체 목록을 삭제하시겠습니까? (진행 중인 작업도 취소됩니다)')) {
|
||||
return;
|
||||
}
|
||||
$.ajax({
|
||||
url: '/{{arg.package_name}}/ajax/{{arg.module_name}}/reset',
|
||||
type: 'POST',
|
||||
data: {},
|
||||
dataType: 'json',
|
||||
success: function(ret) {
|
||||
if (ret.msg) {
|
||||
$.notify('<strong>' + ret.msg + '</strong>', {type: 'success'});
|
||||
}
|
||||
refreshList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Socket Init
|
||||
try {
|
||||
if (typeof io !== 'undefined') {
|
||||
// Namespace needs to match default_route_socketio_module attach param
|
||||
var socket = io.connect('/' + '{{ arg.package_name }}' + '/queue');
|
||||
socket.on('download_status', function(data) {
|
||||
updateDownloadRow(data);
|
||||
});
|
||||
|
||||
socket.on('connect', function() {
|
||||
console.log('Socket connected!');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Socket.IO init error:', e);
|
||||
}
|
||||
|
||||
// Initial Load
|
||||
$(document).ready(function() {
|
||||
console.log("OnReady: Refresh List");
|
||||
refreshList();
|
||||
|
||||
// Auto Refresh logic (fallback for Socket.IO)
|
||||
setInterval(function() {
|
||||
// refreshList(); // 전체 갱신 보다는 상태만 가져오는게 좋지만, 일단 전체 갱신
|
||||
// 조용히 갱신 (Optional: modify refreshList to accept silent flag)
|
||||
|
||||
// 단순하게 목록 갱신 호출
|
||||
refreshList(true); // Silent mode
|
||||
}, 5000); // 5초마다
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
156
templates/gommi_download_manager_queue_setting.html
Normal file
156
templates/gommi_download_manager_queue_setting.html
Normal file
@@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macro.html" as macros %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
/* Metallic Theme Variables */
|
||||
:root {
|
||||
--metal-dark: #1a1a1a;
|
||||
--metal-surface: linear-gradient(145deg, #2d2d2d, #1a1a1a);
|
||||
--metal-border: #404040;
|
||||
--metal-text: #e0e0e0;
|
||||
--metal-text-muted: #888;
|
||||
--metal-highlight: #00bcd4; /* Cyan/Blue Neon */
|
||||
--metal-input-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Container Spacing */
|
||||
.container-fluid {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
/* Headers */
|
||||
h4 {
|
||||
color: var(--metal-text);
|
||||
font-weight: 300;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 2px solid var(--metal-highlight);
|
||||
display: inline-block;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Form Controls */
|
||||
.form-control, .custom-select {
|
||||
background-color: var(--metal-input-bg) !important;
|
||||
border: 1px solid var(--metal-border) !important;
|
||||
color: var(--metal-text) !important;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-control:focus, .custom-select:focus {
|
||||
background-color: rgba(0,0,0,0.5) !important;
|
||||
border-color: var(--metal-highlight) !important;
|
||||
box-shadow: 0 0 10px rgba(0, 188, 212, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-outline-primary {
|
||||
color: var(--metal-highlight);
|
||||
border-color: var(--metal-highlight);
|
||||
}
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--metal-highlight);
|
||||
color: #000;
|
||||
box-shadow: 0 0 15px var(--metal-highlight);
|
||||
}
|
||||
|
||||
/* HR */
|
||||
hr {
|
||||
border-top: 1px solid rgba(255,255,255,0.1) !important;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
label, strong {
|
||||
color: #cfcfcf;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Description text */
|
||||
em {
|
||||
color: var(--metal-text-muted);
|
||||
font-style: normal;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
<div class="container-fluid">
|
||||
{{ macros.m_row_start('5') }}
|
||||
{{ macros.m_row_end() }}
|
||||
|
||||
<!-- Header & Save Button -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4>GDM 설정</h4>
|
||||
{{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']]) }}
|
||||
</div>
|
||||
{{ macros.m_hr_head_bottom() }}
|
||||
|
||||
<form id="setting">
|
||||
<!-- Basic Setting -->
|
||||
{{ macros.setting_top_big('기본 설정') }}
|
||||
{{ macros.setting_bottom() }}
|
||||
|
||||
{{ macros.setting_input_text('save_path', '저장 경로', value=arg['save_path'], desc='{PATH_DATA}는 실제 데이터 경로로 치환됩니다.') }}
|
||||
{{ macros.setting_input_text('temp_path', '임시 경로', value=arg['temp_path'], desc='다운로드 중 임시 파일 저장 경로') }}
|
||||
{{ macros.setting_input_text('max_concurrent', '동시 다운로드 수', value=arg['max_concurrent'], desc='동시에 진행할 최대 다운로드 수') }}
|
||||
{{ 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='다운로드 속도를 제한합니다.') }}
|
||||
|
||||
{{ macros.m_hr() }}
|
||||
|
||||
<!-- Downloader Setting -->
|
||||
{{ macros.setting_top_big('다운로더 설정') }}
|
||||
{{ macros.setting_bottom() }}
|
||||
|
||||
{{ macros.setting_input_text('aria2c_path', 'aria2c 경로', value=arg['aria2c_path'], desc='aria2c 실행 파일 경로 (고속 다운로드용)') }}
|
||||
{{ macros.setting_input_text('aria2c_connections', 'aria2c 연결 수', value=arg['aria2c_connections'], desc='aria2c 동시 연결 수 (기본 16)') }}
|
||||
{{ macros.setting_input_text('ffmpeg_path', 'ffmpeg 경로', value=arg['ffmpeg_path'], desc='ffmpeg 실행 파일 경로 (HLS 스트림용)') }}
|
||||
{{ macros.setting_input_text('yt_dlp_path', 'yt-dlp 경로', value=arg['yt_dlp_path'], desc='비워두면 Python 모듈 사용') }}
|
||||
|
||||
{{ macros.m_hr() }}
|
||||
|
||||
<!-- Retry Setting -->
|
||||
{{ macros.setting_top_big('재시도 설정') }}
|
||||
{{ macros.setting_bottom() }}
|
||||
|
||||
{{ macros.setting_checkbox('auto_retry', '자동 재시도', value=arg['auto_retry'], desc='다운로드 실패 시 자동으로 재시도') }}
|
||||
{{ macros.setting_input_text('max_retry', '최대 재시도 횟수', value=arg['max_retry'], desc='최대 재시도 횟수') }}
|
||||
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block tail_js %}
|
||||
<script type="text/javascript">
|
||||
var package_name = "{{arg['package_name'] }}";
|
||||
var sub = "{{arg['module_name'] }}"; // sub usually is module name like 'queue'
|
||||
|
||||
// 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(){
|
||||
// Nothing special needed
|
||||
});
|
||||
|
||||
$("body").on('click', '#globalSettingSaveBtn', function(e){
|
||||
e.preventDefault();
|
||||
var formData = get_formdata('#setting');
|
||||
$.ajax({
|
||||
url: '/' + package_name + '/ajax/' + sub + '/setting_save',
|
||||
type: "POST",
|
||||
cache: false,
|
||||
data: formData,
|
||||
dataType: "json",
|
||||
success: function(ret) {
|
||||
if (ret.ret == 'success') {
|
||||
$.notify('설정을 저장했습니다.', {type:'success'});
|
||||
} else {
|
||||
$.notify('저장 실패: ' + ret.msg, {type:'danger'});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user