Compare commits
2 Commits
1cdf68cc59
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c7564c0033 | |||
| 7a087ce9c5 |
@@ -47,6 +47,8 @@ class FfmpegHlsDownloader(BaseDownloader):
|
|||||||
|
|
||||||
# ffmpeg 명령어 구성
|
# ffmpeg 명령어 구성
|
||||||
ffmpeg_path = options.get('ffmpeg_path', 'ffmpeg')
|
ffmpeg_path = options.get('ffmpeg_path', 'ffmpeg')
|
||||||
|
if options.get('effective_max_download_rate') or options.get('max_download_rate'):
|
||||||
|
logger.warning('[GDM] ffmpeg_hls downloader does not support strict bandwidth cap; total limit may be approximate for HLS tasks.')
|
||||||
|
|
||||||
cmd = [ffmpeg_path, '-y']
|
cmd = [ffmpeg_path, '-y']
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ HTTP 직접 다운로더
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import traceback
|
import traceback
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from typing import Dict, Any, Optional, Callable
|
from typing import Dict, Any, Optional, Callable
|
||||||
|
|
||||||
from .base import BaseDownloader
|
from .base import BaseDownloader
|
||||||
@@ -19,6 +21,21 @@ except:
|
|||||||
|
|
||||||
class HttpDirectDownloader(BaseDownloader):
|
class HttpDirectDownloader(BaseDownloader):
|
||||||
"""HTTP 직접 다운로더"""
|
"""HTTP 직접 다운로더"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rate_to_bps(rate_value: Any) -> float:
|
||||||
|
if rate_value is None:
|
||||||
|
return 0.0
|
||||||
|
value = str(rate_value).strip().upper()
|
||||||
|
if not value or value in ('0', 'UNLIMITED'):
|
||||||
|
return 0.0
|
||||||
|
m = re.match(r'^(\d+(?:\.\d+)?)\s*([KMG])(?:I?B)?$', value)
|
||||||
|
if not m:
|
||||||
|
return 0.0
|
||||||
|
num = float(m.group(1))
|
||||||
|
unit = m.group(2)
|
||||||
|
mul = {'K': 1024, 'M': 1024 ** 2, 'G': 1024 ** 3}[unit]
|
||||||
|
return num * mul
|
||||||
|
|
||||||
def download(
|
def download(
|
||||||
self,
|
self,
|
||||||
@@ -53,6 +70,9 @@ class HttpDirectDownloader(BaseDownloader):
|
|||||||
total_size = int(response.headers.get('content-length', 0))
|
total_size = int(response.headers.get('content-length', 0))
|
||||||
downloaded = 0
|
downloaded = 0
|
||||||
chunk_size = 1024 * 1024 # 1MB 청크
|
chunk_size = 1024 * 1024 # 1MB 청크
|
||||||
|
max_rate = options.get('effective_max_download_rate') or options.get('max_download_rate')
|
||||||
|
rate_bps = self._rate_to_bps(max_rate)
|
||||||
|
start_time = time.monotonic()
|
||||||
|
|
||||||
with open(filepath, 'wb') as f:
|
with open(filepath, 'wb') as f:
|
||||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||||
@@ -62,6 +82,13 @@ class HttpDirectDownloader(BaseDownloader):
|
|||||||
if chunk:
|
if chunk:
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
downloaded += len(chunk)
|
downloaded += len(chunk)
|
||||||
|
|
||||||
|
# 평균 다운로드 속도를 제한(총량 제한 분배값 포함)
|
||||||
|
if rate_bps > 0:
|
||||||
|
elapsed = max(0.001, time.monotonic() - start_time)
|
||||||
|
expected_elapsed = downloaded / rate_bps
|
||||||
|
if expected_elapsed > elapsed:
|
||||||
|
time.sleep(expected_elapsed - elapsed)
|
||||||
|
|
||||||
if total_size > 0 and progress_callback:
|
if total_size > 0 and progress_callback:
|
||||||
progress = int(downloaded / total_size * 100)
|
progress = int(downloaded / total_size * 100)
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ class YtdlpAria2Downloader(BaseDownloader):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._process: Optional[subprocess.Popen] = None
|
self._process: Optional[subprocess.Popen] = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_rate(raw_rate: Any) -> str:
|
||||||
|
"""속도 제한 문자열 정규화 (예: 6MB -> 6M, 0/None -> '')"""
|
||||||
|
if raw_rate is None:
|
||||||
|
return ''
|
||||||
|
value = str(raw_rate).strip().upper()
|
||||||
|
if not value or value in ('0', '0B', 'UNLIMITED'):
|
||||||
|
return ''
|
||||||
|
m = re.match(r'^(\d+(?:\.\d+)?)\s*([KMG])(?:I?B)?$', value)
|
||||||
|
if m:
|
||||||
|
return f'{m.group(1)}{m.group(2)}'
|
||||||
|
return value
|
||||||
|
|
||||||
def download(
|
def download(
|
||||||
self,
|
self,
|
||||||
@@ -61,8 +74,12 @@ class YtdlpAria2Downloader(BaseDownloader):
|
|||||||
cmd.extend(['--print', 'before_dl:GDM_FIX:thumb:%(thumbnail)s'])
|
cmd.extend(['--print', 'before_dl:GDM_FIX:thumb:%(thumbnail)s'])
|
||||||
|
|
||||||
# 속도 제한 설정
|
# 속도 제한 설정
|
||||||
max_rate = P.ModelSetting.get('max_download_rate')
|
max_rate = self._normalize_rate(
|
||||||
rate_limited = bool(max_rate and max_rate != '0')
|
options.get('effective_max_download_rate')
|
||||||
|
or options.get('max_download_rate')
|
||||||
|
or P.ModelSetting.get('max_download_rate')
|
||||||
|
)
|
||||||
|
rate_limited = bool(max_rate)
|
||||||
|
|
||||||
# aria2c 사용 (설치되어 있으면)
|
# aria2c 사용 (설치되어 있으면)
|
||||||
aria2c_path = options.get('aria2c_path', 'aria2c')
|
aria2c_path = options.get('aria2c_path', 'aria2c')
|
||||||
@@ -83,6 +100,10 @@ class YtdlpAria2Downloader(BaseDownloader):
|
|||||||
# yt-dlp native downloader 제한 (external-downloader 미사용/보조 경로)
|
# yt-dlp native downloader 제한 (external-downloader 미사용/보조 경로)
|
||||||
if rate_limited:
|
if rate_limited:
|
||||||
cmd.extend(['--limit-rate', max_rate])
|
cmd.extend(['--limit-rate', max_rate])
|
||||||
|
if options.get('is_global_rate_split'):
|
||||||
|
logger.info(f'[GDM] global split limit enabled: {max_rate}/s per task')
|
||||||
|
else:
|
||||||
|
logger.info(f'[GDM] download speed limit enabled: {max_rate}/s')
|
||||||
|
|
||||||
# 포맷 선택
|
# 포맷 선택
|
||||||
format_spec = options.get('format')
|
format_spec = options.get('format')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
title: "GDM"
|
title: "GDM"
|
||||||
package_name: gommi_downloader_manager
|
package_name: gommi_downloader_manager
|
||||||
version: '0.2.33'
|
version: '0.2.37'
|
||||||
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
|
||||||
|
|||||||
141
mod_queue.py
141
mod_queue.py
@@ -5,6 +5,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Dict, Any, List, Callable
|
from typing import Optional, Dict, Any, List, Callable
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -18,6 +19,7 @@ from framework import F, socketio
|
|||||||
class DownloadStatus(str, Enum):
|
class DownloadStatus(str, Enum):
|
||||||
PENDING = "pending"
|
PENDING = "pending"
|
||||||
EXTRACTING = "extracting" # 메타데이터 추출 중
|
EXTRACTING = "extracting" # 메타데이터 추출 중
|
||||||
|
WAITING = "waiting" # 동시 다운로드 슬롯 대기 중
|
||||||
DOWNLOADING = "downloading"
|
DOWNLOADING = "downloading"
|
||||||
PAUSED = "paused"
|
PAUSED = "paused"
|
||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
@@ -46,6 +48,8 @@ class ModuleQueue(PluginModuleBase):
|
|||||||
# 진행 중인 다운로드 인스턴스들
|
# 진행 중인 다운로드 인스턴스들
|
||||||
_downloads: Dict[str, 'DownloadTask'] = {}
|
_downloads: Dict[str, 'DownloadTask'] = {}
|
||||||
_queue_lock = threading.Lock()
|
_queue_lock = threading.Lock()
|
||||||
|
_concurrency_sem: Optional[threading.Semaphore] = None
|
||||||
|
_concurrency_limit: int = 0
|
||||||
|
|
||||||
# 업데이트 체크 캐싱
|
# 업데이트 체크 캐싱
|
||||||
_last_update_check = 0
|
_last_update_check = 0
|
||||||
@@ -55,6 +59,32 @@ class ModuleQueue(PluginModuleBase):
|
|||||||
from .setup import default_route_socketio_module
|
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')
|
||||||
|
self._ensure_concurrency_limit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _ensure_concurrency_limit(cls):
|
||||||
|
"""max_concurrent 설정 기반 동시 실행 슬롯 보장"""
|
||||||
|
try:
|
||||||
|
from .setup import P
|
||||||
|
configured = int(P.ModelSetting.get('max_concurrent') or 3)
|
||||||
|
except Exception:
|
||||||
|
configured = 3
|
||||||
|
configured = max(1, configured)
|
||||||
|
|
||||||
|
if cls._concurrency_sem is None:
|
||||||
|
cls._concurrency_sem = threading.Semaphore(configured)
|
||||||
|
cls._concurrency_limit = configured
|
||||||
|
return
|
||||||
|
|
||||||
|
if cls._concurrency_limit != configured:
|
||||||
|
# 실행 중 태스크가 없을 때만 세마포어 재생성
|
||||||
|
active = any(
|
||||||
|
t.status == DownloadStatus.DOWNLOADING and not t._cancelled
|
||||||
|
for t in cls._downloads.values()
|
||||||
|
)
|
||||||
|
if not active:
|
||||||
|
cls._concurrency_sem = threading.Semaphore(configured)
|
||||||
|
cls._concurrency_limit = configured
|
||||||
|
|
||||||
|
|
||||||
def process_menu(self, page_name: str, req: Any) -> Any:
|
def process_menu(self, page_name: str, req: Any) -> Any:
|
||||||
@@ -174,6 +204,33 @@ class ModuleQueue(PluginModuleBase):
|
|||||||
self.P.logger.error(f'DB Delete Error: {e}')
|
self.P.logger.error(f'DB Delete Error: {e}')
|
||||||
|
|
||||||
ret['msg'] = '항목이 삭제되었습니다.'
|
ret['msg'] = '항목이 삭제되었습니다.'
|
||||||
|
|
||||||
|
elif command == 'delete_completed':
|
||||||
|
# 완료된 항목 일괄 삭제 (메모리 + DB)
|
||||||
|
removed_memory = 0
|
||||||
|
with self._queue_lock:
|
||||||
|
remove_ids = [
|
||||||
|
task_id
|
||||||
|
for task_id, task in self._downloads.items()
|
||||||
|
if task.status == DownloadStatus.COMPLETED
|
||||||
|
]
|
||||||
|
for task_id in remove_ids:
|
||||||
|
del self._downloads[task_id]
|
||||||
|
removed_memory = len(remove_ids)
|
||||||
|
|
||||||
|
removed_db = 0
|
||||||
|
try:
|
||||||
|
from .model import ModelDownloadItem
|
||||||
|
with F.app.app_context():
|
||||||
|
removed_db = F.db.session.query(ModelDownloadItem).filter(
|
||||||
|
ModelDownloadItem.status == DownloadStatus.COMPLETED
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
F.db.session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
self.P.logger.error(f'DB Delete Completed Error: {e}')
|
||||||
|
|
||||||
|
ret['msg'] = f'완료 항목 삭제: 메모리 {removed_memory}개, DB {removed_db}개'
|
||||||
|
ret['data'] = {'memory': removed_memory, 'db': removed_db}
|
||||||
|
|
||||||
# ===== YouTube API for Chrome Extension =====
|
# ===== YouTube API for Chrome Extension =====
|
||||||
|
|
||||||
@@ -459,6 +516,7 @@ class ModuleQueue(PluginModuleBase):
|
|||||||
def plugin_load(self) -> None:
|
def plugin_load(self) -> None:
|
||||||
"""플러그인 로드 시 초기화"""
|
"""플러그인 로드 시 초기화"""
|
||||||
self.P.logger.info('gommi_downloader 플러그인 로드')
|
self.P.logger.info('gommi_downloader 플러그인 로드')
|
||||||
|
self._ensure_concurrency_limit()
|
||||||
try:
|
try:
|
||||||
# DB에서 진행 중인 작업 로드
|
# DB에서 진행 중인 작업 로드
|
||||||
with F.app.app_context():
|
with F.app.app_context():
|
||||||
@@ -671,9 +729,35 @@ class DownloadTask:
|
|||||||
"""다운로드 시작 (비동기)"""
|
"""다운로드 시작 (비동기)"""
|
||||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rate_to_bps(rate_value: Any) -> float:
|
||||||
|
"""'6M'/'900K' 형태를 bytes/sec로 변환"""
|
||||||
|
if rate_value is None:
|
||||||
|
return 0.0
|
||||||
|
value = str(rate_value).strip().upper()
|
||||||
|
if not value or value in ('0', 'UNLIMITED'):
|
||||||
|
return 0.0
|
||||||
|
m = re.match(r'^(\d+(?:\.\d+)?)\s*([KMG])(?:I?B)?$', value)
|
||||||
|
if not m:
|
||||||
|
return 0.0
|
||||||
|
num = float(m.group(1))
|
||||||
|
unit = m.group(2)
|
||||||
|
mul = {'K': 1024, 'M': 1024 ** 2, 'G': 1024 ** 3}[unit]
|
||||||
|
return num * mul
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bps_to_rate(bps: float) -> str:
|
||||||
|
"""bytes/sec를 yt-dlp/aria2 형식 문자열로 변환"""
|
||||||
|
if bps <= 0:
|
||||||
|
return '0'
|
||||||
|
if bps >= 1024 ** 2:
|
||||||
|
return f'{max(0.1, bps / (1024 ** 2)):.2f}M'
|
||||||
|
return f'{max(1.0, bps / 1024):.2f}K'
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
"""다운로드 실행"""
|
"""다운로드 실행"""
|
||||||
|
slot_acquired = False
|
||||||
try:
|
try:
|
||||||
self.status = DownloadStatus.EXTRACTING
|
self.status = DownloadStatus.EXTRACTING
|
||||||
if not self.start_time:
|
if not self.start_time:
|
||||||
@@ -686,9 +770,59 @@ class DownloadTask:
|
|||||||
|
|
||||||
if not self._downloader:
|
if not self._downloader:
|
||||||
raise Exception(f"지원하지 않는 소스 타입: {self.source_type}")
|
raise Exception(f"지원하지 않는 소스 타입: {self.source_type}")
|
||||||
|
|
||||||
|
# 동시 다운로드 제한 슬롯 획득
|
||||||
|
ModuleQueue._ensure_concurrency_limit()
|
||||||
|
sem = ModuleQueue._concurrency_sem
|
||||||
|
if sem is not None:
|
||||||
|
self.status = DownloadStatus.WAITING
|
||||||
|
self._emit_status()
|
||||||
|
while not self._cancelled:
|
||||||
|
if sem.acquire(timeout=0.5):
|
||||||
|
slot_acquired = True
|
||||||
|
break
|
||||||
|
if not slot_acquired:
|
||||||
|
self.status = DownloadStatus.CANCELLED
|
||||||
|
self._emit_status()
|
||||||
|
return
|
||||||
|
|
||||||
self.status = DownloadStatus.DOWNLOADING
|
self.status = DownloadStatus.DOWNLOADING
|
||||||
self._emit_status()
|
self._emit_status()
|
||||||
|
|
||||||
|
# 전역 설정값을 태스크 옵션에 주입 (개별 호출 옵션이 있으면 우선)
|
||||||
|
from .setup import P
|
||||||
|
runtime_options = dict(self.options or {})
|
||||||
|
if not runtime_options.get('aria2c_path'):
|
||||||
|
runtime_options['aria2c_path'] = P.ModelSetting.get('aria2c_path')
|
||||||
|
if not runtime_options.get('connections'):
|
||||||
|
try:
|
||||||
|
runtime_options['connections'] = int(P.ModelSetting.get('aria2c_connections') or 16)
|
||||||
|
except Exception:
|
||||||
|
runtime_options['connections'] = 16
|
||||||
|
if not runtime_options.get('ffmpeg_path'):
|
||||||
|
runtime_options['ffmpeg_path'] = P.ModelSetting.get('ffmpeg_path')
|
||||||
|
if not runtime_options.get('max_download_rate'):
|
||||||
|
runtime_options['max_download_rate'] = P.ModelSetting.get('max_download_rate')
|
||||||
|
|
||||||
|
# 전체 속도 제한을 활성 다운로드 수에 따라 분배 (합산 속도 상한)
|
||||||
|
raw_global_rate = runtime_options.get('max_download_rate')
|
||||||
|
global_bps = self._rate_to_bps(raw_global_rate)
|
||||||
|
if global_bps > 0:
|
||||||
|
with ModuleQueue._queue_lock:
|
||||||
|
active_count = sum(
|
||||||
|
1
|
||||||
|
for t in ModuleQueue._downloads.values()
|
||||||
|
if t.status == DownloadStatus.DOWNLOADING and not t._cancelled
|
||||||
|
)
|
||||||
|
active_count = max(1, active_count)
|
||||||
|
effective_bps = global_bps / active_count
|
||||||
|
runtime_options['effective_max_download_rate'] = self._bps_to_rate(effective_bps)
|
||||||
|
runtime_options['is_global_rate_split'] = active_count > 1
|
||||||
|
if active_count > 1:
|
||||||
|
P.logger.info(
|
||||||
|
f'[GDM] Global speed split: total={raw_global_rate}/s, '
|
||||||
|
f'active={active_count}, per-task={runtime_options["effective_max_download_rate"]}/s'
|
||||||
|
)
|
||||||
|
|
||||||
# 다운로드 실행
|
# 다운로드 실행
|
||||||
result = self._downloader.download(
|
result = self._downloader.download(
|
||||||
@@ -697,7 +831,7 @@ class DownloadTask:
|
|||||||
filename=self.filename,
|
filename=self.filename,
|
||||||
progress_callback=self._progress_callback,
|
progress_callback=self._progress_callback,
|
||||||
info_callback=self._info_update_callback,
|
info_callback=self._info_update_callback,
|
||||||
**self.options
|
**runtime_options
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
@@ -741,6 +875,11 @@ class DownloadTask:
|
|||||||
self._cleanup_if_empty()
|
self._cleanup_if_empty()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
if slot_acquired and ModuleQueue._concurrency_sem is not None:
|
||||||
|
try:
|
||||||
|
ModuleQueue._concurrency_sem.release()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._emit_status()
|
self._emit_status()
|
||||||
|
|
||||||
def _progress_callback(self, progress: int, speed: str = '', eta: str = ''):
|
def _progress_callback(self, progress: int, speed: str = '', eta: str = ''):
|
||||||
|
|||||||
@@ -323,6 +323,10 @@
|
|||||||
background: linear-gradient(135deg, rgba(168, 85, 247, 0.1), rgba(30, 41, 59, 0.95));
|
background: linear-gradient(135deg, rgba(168, 85, 247, 0.1), rgba(30, 41, 59, 0.95));
|
||||||
border-color: rgba(168, 85, 247, 0.25);
|
border-color: rgba(168, 85, 247, 0.25);
|
||||||
}
|
}
|
||||||
|
.dl-card.status-waiting {
|
||||||
|
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1), rgba(30, 41, 59, 0.95));
|
||||||
|
border-color: rgba(245, 158, 11, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
/* ID & Meta Row */
|
/* ID & Meta Row */
|
||||||
.dl-meta {
|
.dl-meta {
|
||||||
@@ -440,6 +444,9 @@
|
|||||||
|
|
||||||
.status-downloading { color: var(--accent-primary); }
|
.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-downloading .status-dot { background-color: var(--accent-primary); box-shadow: 0 0 8px var(--accent-primary); animation: pulse 1.5s infinite; }
|
||||||
|
|
||||||
|
.status-waiting { color: var(--warning); }
|
||||||
|
.status-waiting .status-dot { background-color: var(--warning); box-shadow: 0 0 8px var(--warning); animation: pulse 1.5s infinite; }
|
||||||
|
|
||||||
.status-completed { color: var(--success); }
|
.status-completed { color: var(--success); }
|
||||||
.status-completed .status-dot { background-color: var(--success); }
|
.status-completed .status-dot { background-color: var(--success); }
|
||||||
@@ -658,6 +665,8 @@
|
|||||||
.dl-status-pill.status-pending .status-dot { background: var(--text-muted); opacity: 0.5; }
|
.dl-status-pill.status-pending .status-dot { background: var(--text-muted); opacity: 0.5; }
|
||||||
.dl-status-pill.status-extracting { background: rgba(168, 85, 247, 0.2); color: #c084fc; }
|
.dl-status-pill.status-extracting { background: rgba(168, 85, 247, 0.2); color: #c084fc; }
|
||||||
.dl-status-pill.status-extracting .status-dot { background: #c084fc; animation: pulse 1s infinite; }
|
.dl-status-pill.status-extracting .status-dot { background: #c084fc; animation: pulse 1s infinite; }
|
||||||
|
.dl-status-pill.status-waiting { background: rgba(240, 173, 78, 0.2); color: var(--warning); }
|
||||||
|
.dl-status-pill.status-waiting .status-dot { background: var(--warning); animation: pulse 1s infinite; }
|
||||||
.dl-status-pill.status-error { background: rgba(217, 83, 79, 0.2); color: var(--danger); }
|
.dl-status-pill.status-error { background: rgba(217, 83, 79, 0.2); color: var(--danger); }
|
||||||
.dl-status-pill.status-error .status-dot { background: var(--danger); }
|
.dl-status-pill.status-error .status-dot { background: var(--danger); }
|
||||||
.dl-status-pill.status-cancelled { background: rgba(107, 114, 128, 0.2); color: #9ca3af; }
|
.dl-status-pill.status-cancelled { background: rgba(107, 114, 128, 0.2); color: #9ca3af; }
|
||||||
@@ -882,6 +891,9 @@
|
|||||||
<button type="button" class="btn-premium" onclick="deleteSelected()" id="delete_selected_btn" style="display: none;">
|
<button type="button" class="btn-premium" onclick="deleteSelected()" id="delete_selected_btn" style="display: none;">
|
||||||
<i class="fa fa-trash-o"></i> 선택삭제 (<span id="selected_count">0</span>)
|
<i class="fa fa-trash-o"></i> 선택삭제 (<span id="selected_count">0</span>)
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-premium" onclick="deleteCompleted()">
|
||||||
|
<i class="fa fa-check"></i> 완료 삭제
|
||||||
|
</button>
|
||||||
<button type="button" class="btn-premium danger" onclick="resetList()">
|
<button type="button" class="btn-premium danger" onclick="resetList()">
|
||||||
<i class="fa fa-trash"></i> Reset All
|
<i class="fa fa-trash"></i> Reset All
|
||||||
</button>
|
</button>
|
||||||
@@ -1168,7 +1180,7 @@
|
|||||||
<i class="fa fa-clock-o"></i> ${startTime !== '-' ? startTime.split(' ')[1] || startTime : '-'}
|
<i class="fa fa-clock-o"></i> ${startTime !== '-' ? startTime.split(' ')[1] || startTime : '-'}
|
||||||
</div>
|
</div>
|
||||||
<div class="dl-actions">
|
<div class="dl-actions">
|
||||||
<button class="dl-btn cancel" title="취소" onclick="event.stopPropagation(); cancelDownload('${item.id}')" ${status === 'downloading' || status === 'pending' || status === 'paused' || status === 'extracting' ? '' : 'disabled'}>
|
<button class="dl-btn cancel" title="취소" onclick="event.stopPropagation(); cancelDownload('${item.id}')" ${status === 'downloading' || status === 'pending' || status === 'paused' || status === 'extracting' || status === 'waiting' ? '' : 'disabled'}>
|
||||||
<i class="fa fa-stop"></i>
|
<i class="fa fa-stop"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="dl-btn delete" title="삭제" onclick="event.stopPropagation(); deleteDownload('${item.id}')">
|
<button class="dl-btn delete" title="삭제" onclick="event.stopPropagation(); deleteDownload('${item.id}')">
|
||||||
@@ -1245,6 +1257,29 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteCompleted() {
|
||||||
|
if (!confirm('완료된 항목만 큐에서 삭제하시겠습니까?')) return;
|
||||||
|
$.ajax({
|
||||||
|
url: '/{{ arg["package_name"] }}/ajax/{{ arg["module_name"] }}/delete_completed',
|
||||||
|
type: 'POST',
|
||||||
|
data: {},
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(ret) {
|
||||||
|
if (ret.ret === 'success') {
|
||||||
|
const mem = ret.data?.memory ?? 0;
|
||||||
|
const db = ret.data?.db ?? 0;
|
||||||
|
$.notify(`<strong>완료 항목 삭제 완료 (메모리 ${mem}, DB ${db})</strong>`, {type: 'success'});
|
||||||
|
} else {
|
||||||
|
$.notify('<strong>완료 항목 삭제 실패</strong>', {type: 'warning'});
|
||||||
|
}
|
||||||
|
refreshList(false);
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
$.notify('<strong>완료 항목 삭제 실패</strong>', {type: 'warning'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function deleteDownload(id) {
|
function deleteDownload(id) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/{{ arg["package_name"] }}/ajax/{{ arg["module_name"] }}/delete',
|
url: '/{{ arg["package_name"] }}/ajax/{{ arg["module_name"] }}/delete',
|
||||||
|
|||||||
Reference in New Issue
Block a user