Compare commits

...

5 Commits

8 changed files with 620 additions and 41 deletions

View File

@@ -3,9 +3,13 @@
FlaskFarm용 범용 다운로드 매니저 플러그인입니다.
여러 다운로더 플러그인(YouTube, Anime 등)의 다운로드 요청을 통합 관리하고 큐(Queue)를 제공합니다.
## v0.2.27 변경사항 (2026-01-09)
- **자가 업데이트 기능 추가**: 설정 페이지에서 "Update" 버튼 클릭으로 Git Pull 및 플러그인 핫 리로드 지원
- **버전 체크 API**: GitHub에서 최신 버전 정보를 가져와 업데이트 알림 표시 (1시간 캐싱)
## v0.2.30 변경사항 (2026-01-12)
- **자막 자동 다운로드 및 변환**: `ytdlp_aria2` 다운로더에 VTT 자막 다운로드 및 SRT 자동 변환 로직 내장.
- **경로 정규화 강화**: `output_template` 생성 시 중복 구분자(`//`, `\.\`)를 제거하여 경로 오염 방지.
- **다운로드 완료 지점 최적화**: 비디오 다운로드 성공 직후 자막 처리가 이어지도록 흐름 개선.
## v0.2.29 변경사항 (2026-01-11)
- **Anilife 고유 ID 지원**: 에피소드 고유 코드가 없는 경우 제목 기반 매칭 로직 보강.
## v0.2.24 변경사항 (2026-01-08)
- **Chrome 확장프로그램 추가**: YouTube에서 GDM으로 바로 다운로드 전송

View File

@@ -42,10 +42,13 @@ class FfmpegHlsDownloader(BaseDownloader):
if not filename:
filename = f"download_{int(__import__('time').time())}.mp4"
filepath = os.path.join(save_path, filename)
filepath = os.path.abspath(os.path.join(save_path, filename))
filepath = os.path.normpath(filepath)
# 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']
@@ -173,7 +176,16 @@ class FfmpegHlsDownloader(BaseDownloader):
"""다운로드 취소"""
super().cancel()
if self._process:
self._process.terminate()
try:
# [FIX] 파이프 명시적으로 닫기
if self._process.stdout: self._process.stdout.close()
if self._process.stderr: self._process.stderr.close()
self._process.terminate()
# 짧은 대기 후 여전히 살아있으면 kill
try: self._process.wait(timeout=1)
except: self._process.kill()
except: pass
def _get_duration(self, url: str, ffprobe_path: str, headers: Dict) -> float:
"""ffprobe로 영상 길이 획득"""

View File

@@ -5,6 +5,8 @@ HTTP 직접 다운로더
"""
import os
import traceback
import re
import time
from typing import Dict, Any, Optional, Callable
from .base import BaseDownloader
@@ -19,6 +21,21 @@ except:
class HttpDirectDownloader(BaseDownloader):
"""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(
self,
@@ -38,7 +55,8 @@ class HttpDirectDownloader(BaseDownloader):
if not filename:
filename = url.split('/')[-1].split('?')[0] or f"download_{int(__import__('time').time())}"
filepath = os.path.join(save_path, filename)
filepath = os.path.abspath(os.path.join(save_path, filename))
filepath = os.path.normpath(filepath)
# 헤더 설정
headers = options.get('headers', {})
@@ -52,6 +70,9 @@ class HttpDirectDownloader(BaseDownloader):
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
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:
for chunk in response.iter_content(chunk_size=chunk_size):
@@ -61,6 +82,13 @@ class HttpDirectDownloader(BaseDownloader):
if chunk:
f.write(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:
progress = int(downloaded / total_size * 100)

View File

@@ -26,6 +26,19 @@ class YtdlpAria2Downloader(BaseDownloader):
def __init__(self):
super().__init__()
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(
self,
@@ -40,12 +53,14 @@ class YtdlpAria2Downloader(BaseDownloader):
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')
# 출력 템플릿 (outtmpl 옵션 우선 처리)
raw_outtmpl = options.get('outtmpl') or filename or '%(title)s.%(ext)s'
# 경로와 템플릿 결합 후 정규화
output_template = os.path.abspath(os.path.join(save_path, raw_outtmpl))
# 윈도우/리눅스 구분 없이 중복 슬래시 제거 및 절대 경로 확보
output_template = os.path.normpath(output_template)
# yt-dlp 명령어 구성
cmd = [
'yt-dlp',
@@ -58,6 +73,14 @@ class YtdlpAria2Downloader(BaseDownloader):
cmd.extend(['--print', 'before_dl:GDM_FIX:title:%(title)s'])
cmd.extend(['--print', 'before_dl:GDM_FIX:thumb:%(thumbnail)s'])
# 속도 제한 설정
max_rate = self._normalize_rate(
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_path = options.get('aria2c_path', 'aria2c')
connections = options.get('connections', 4)
@@ -65,21 +88,22 @@ class YtdlpAria2Downloader(BaseDownloader):
if self._check_aria2c(aria2c_path):
cmd.extend(['--external-downloader', aria2c_path])
# aria2c 설정: -x=연결수, -s=분할수, -j=병렬, -k=조각크기, --console-log-level=notice로 진행률 출력
cmd.extend(['--external-downloader-args', f'aria2c:-x{connections} -s{connections} -j{connections} -k1M --summary-interval=1 --console-log-level=notice'])
aria2_args = f'aria2c:-x{connections} -s{connections} -j{connections} -k1M --summary-interval=1 --console-log-level=notice'
if rate_limited:
aria2_args = f'{aria2_args} --max-download-limit={max_rate}'
cmd.extend(['--external-downloader-args', aria2_args])
logger.info(f'[GDM] Using aria2c for multi-threaded download (connections: {connections})')
# 진행률 템플릿 추가 (yt-dlp native downloader)
cmd.extend(['--progress-template', 'download:GDM_PROGRESS:%(progress._percent_str)s:%(progress._speed_str)s:%(progress._eta_str)s'])
# 속도 제한 설정
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
# yt-dlp native downloader 제한 (external-downloader 미사용/보조 경로)
if rate_limited:
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')
@@ -147,14 +171,6 @@ class YtdlpAria2Downloader(BaseDownloader):
if options.get('add_metadata'):
cmd.append('--add-metadata')
if options.get('outtmpl'):
# outtmpl 옵션이 별도로 전달된 경우 덮어쓰기 (output_template는 -o가 이미 차지함)
# 하지만 yt-dlp -o 옵션이 곧 outtmpl임.
# 파일명 템플릿 문제 해결을 위해 filename 인자 대신 outtmpl 옵션을 우선시
# 위에서 -o output_template를 이미 넣었으므로, 여기서 다시 넣으면 중복될 수 있음.
# 따라서 로직 수정: filename 없이 outtmpl만 온 경우
pass
# URL 추가
cmd.append(url)
@@ -267,6 +283,15 @@ class YtdlpAria2Downloader(BaseDownloader):
if self._process.returncode == 0:
if progress_callback:
progress_callback(100, '', '')
# 자막 다운로드 처리
vtt_url = options.get('subtitles')
if vtt_url and final_filepath:
try:
self._download_subtitle(vtt_url, final_filepath, headers=options.get('headers'))
except Exception as e:
logger.error(f'[GDM] Subtitle download error: {e}')
return {'success': True, 'filepath': final_filepath}
else:
return {'success': False, 'error': f'Exit code: {self._process.returncode}'}
@@ -305,7 +330,16 @@ class YtdlpAria2Downloader(BaseDownloader):
"""다운로드 취소"""
super().cancel()
if self._process:
self._process.terminate()
try:
# [FIX] 파이프 명시적으로 닫기
if self._process.stdout: self._process.stdout.close()
if self._process.stderr: self._process.stderr.close()
self._process.terminate()
# 짧은 대기 후 여전히 살아있으면 kill
try: self._process.wait(timeout=1)
except: self._process.kill()
except: pass
def _check_aria2c(self, aria2c_path: str) -> bool:
"""aria2c 설치 확인"""
@@ -318,3 +352,57 @@ class YtdlpAria2Downloader(BaseDownloader):
return result.returncode == 0
except:
return False
def _download_subtitle(self, vtt_url: str, output_path: str, headers: Optional[dict] = None):
"""자막 다운로드 및 SRT 변환"""
try:
import requests
# 자막 파일 경로 생성 (비디오 파일명.srt)
video_basename = os.path.splitext(output_path)[0]
srt_path = video_basename + ".srt"
logger.info(f"[GDM] Downloading subtitle from: {vtt_url}")
response = requests.get(vtt_url, headers=headers, timeout=30)
if response.status_code == 200:
vtt_content = response.text
srt_content = self._vtt_to_srt(vtt_content)
with open(srt_path, "w", encoding="utf-8") as f:
f.write(srt_content)
logger.info(f"[GDM] Subtitle saved to: {srt_path}")
return True
except Exception as e:
logger.error(f"[GDM] Failed to download subtitle: {e}")
return False
def _vtt_to_srt(self, vtt_content: str) -> str:
"""VTT 형식을 SRT 형식으로 간단히 변환"""
if not vtt_content.startswith("WEBVTT"):
return vtt_content
lines = vtt_content.split("\n")
srt_lines = []
cue_index = 1
i = 0
while i < len(lines):
line = lines[i].strip()
if line.startswith("WEBVTT") or line.startswith("NOTE") or line.startswith("STYLE"):
i += 1
continue
if not line:
i += 1
continue
if "-->" in line:
# VTT 타임코드를 SRT 형식으로 변환 (. -> ,)
srt_timecode = line.replace(".", ",")
srt_lines.append(str(cue_index))
srt_lines.append(srt_timecode)
cue_index += 1
i += 1
while i < len(lines) and lines[i].strip():
srt_lines.append(lines[i].rstrip())
i += 1
srt_lines.append("")
else:
i += 1
return "\n".join(srt_lines)

View File

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

View File

@@ -5,6 +5,7 @@ import os
import time
import threading
import traceback
import re
from datetime import datetime
from typing import Optional, Dict, Any, List, Callable
from enum import Enum
@@ -18,6 +19,7 @@ from framework import F, socketio
class DownloadStatus(str, Enum):
PENDING = "pending"
EXTRACTING = "extracting" # 메타데이터 추출 중
WAITING = "waiting" # 동시 다운로드 슬롯 대기 중
DOWNLOADING = "downloading"
PAUSED = "paused"
COMPLETED = "completed"
@@ -46,6 +48,8 @@ class ModuleQueue(PluginModuleBase):
# 진행 중인 다운로드 인스턴스들
_downloads: Dict[str, 'DownloadTask'] = {}
_queue_lock = threading.Lock()
_concurrency_sem: Optional[threading.Semaphore] = None
_concurrency_limit: int = 0
# 업데이트 체크 캐싱
_last_update_check = 0
@@ -55,6 +59,32 @@ class ModuleQueue(PluginModuleBase):
from .setup import default_route_socketio_module
super(ModuleQueue, self).__init__(P, name='queue', first_menu='list')
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:
@@ -174,6 +204,33 @@ class ModuleQueue(PluginModuleBase):
self.P.logger.error(f'DB Delete Error: {e}')
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 =====
@@ -297,13 +354,13 @@ class ModuleQueue(PluginModuleBase):
# 1. Git Pull
cmd = ['git', '-C', plugin_path, 'pull']
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
stdout, stderr = process.communicate()
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if process.returncode != 0:
raise Exception(f"Git pull 실패: {stderr}")
if result.returncode != 0:
raise Exception(f"Git pull 실패: {result.stderr}")
self.P.logger.info(f"Git pull 결과: {stdout}")
self.P.logger.info(f"Git pull 결과: {result.stdout}")
stdout = result.stdout
# 2. 모듈 리로드 (Hot-Reload)
self.reload_plugin()
@@ -459,6 +516,7 @@ class ModuleQueue(PluginModuleBase):
def plugin_load(self) -> None:
"""플러그인 로드 시 초기화"""
self.P.logger.info('gommi_downloader 플러그인 로드')
self._ensure_concurrency_limit()
try:
# DB에서 진행 중인 작업 로드
with F.app.app_context():
@@ -671,9 +729,35 @@ class DownloadTask:
"""다운로드 시작 (비동기)"""
self._thread = threading.Thread(target=self._run, daemon=True)
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):
"""다운로드 실행"""
slot_acquired = False
try:
self.status = DownloadStatus.EXTRACTING
if not self.start_time:
@@ -686,9 +770,59 @@ class DownloadTask:
if not self._downloader:
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._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(
@@ -697,7 +831,7 @@ class DownloadTask:
filename=self.filename,
progress_callback=self._progress_callback,
info_callback=self._info_update_callback,
**self.options
**runtime_options
)
if self._cancelled:
@@ -741,6 +875,11 @@ class DownloadTask:
self._cleanup_if_empty()
finally:
if slot_acquired and ModuleQueue._concurrency_sem is not None:
try:
ModuleQueue._concurrency_sem.release()
except Exception:
pass
self._emit_status()
def _progress_callback(self, progress: int, speed: str = '', eta: str = ''):
@@ -890,7 +1029,19 @@ class DownloadTask:
else:
modules = []
# 모듈명 추출 (예: anime_downloader_linkkf -> linkkf)
target_module_name = None
if len(parts) > 1:
target_module_name = parts[-1]
for module_name, module_instance in modules:
# 모듈 인스턴스의 name 또는 변수명 확인
instance_name = getattr(module_instance, 'name', module_name)
# 대상 모듈명이 지정되어 있으면 일치하는 경우에만 호출
if target_module_name and instance_name != target_module_name:
continue
if hasattr(module_instance, 'plugin_callback'):
callback_data = {
'callback_id': self.callback_id,
@@ -901,8 +1052,10 @@ class DownloadTask:
}
module_instance.plugin_callback(callback_data)
callback_invoked = True
P.logger.info(f"Callback invoked on module {module_name}")
break
P.logger.info(f"Callback invoked on module {instance_name}")
# 대상 모듈을 명확히 찾았으면 종료
if target_module_name:
break
if not callback_invoked:
P.logger.debug(f"No plugin_callback method found in {self.caller_plugin}")

View File

@@ -46,7 +46,63 @@
background-color: #3e5770 !important;
}
#loading { display: none !important; }
/* Plugin-owned loading override (independent from Flaskfarm default loader assets) */
#loading,
#modal_loading {
display: none;
position: fixed;
inset: 0;
z-index: 3000010 !important;
background: rgba(10, 22, 36, 0.46);
backdrop-filter: blur(2px);
}
#loading img,
#modal_loading img {
display: none !important;
}
#loading::before,
#modal_loading::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 58px;
height: 58px;
margin-left: -29px;
margin-top: -29px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.24);
border-top-color: var(--accent-primary);
border-right-color: var(--accent-secondary);
animation: gdm-loader-spin 0.9s linear infinite;
}
#loading::after,
#modal_loading::after {
content: "LOADING";
position: absolute;
left: 50%;
top: calc(50% + 44px);
transform: translateX(-50%);
color: var(--text-main);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
}
#loading[style*="display: block"],
#loading[style*="display: inline-block"],
#modal_loading[style*="display: block"],
#modal_loading[style*="display: inline-block"] {
display: block !important;
}
@keyframes gdm-loader-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
#gommi_download_manager_queue_list {
font-family: var(--font-sans);
@@ -143,6 +199,24 @@
}
}
/* Mobile 5px Padding - Maximum Screen Usage */
@media (max-width: 768px) {
.container, .container-fluid, #main_container, #gommi_download_manager_queue_list {
padding-left: 5px !important;
padding-right: 5px !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.row {
margin-left: 0 !important;
margin-right: 0 !important;
}
[class*="col-"] {
padding-left: 4px !important;
padding-right: 4px !important;
}
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
@@ -249,6 +323,10 @@
background: linear-gradient(135deg, rgba(168, 85, 247, 0.1), rgba(30, 41, 59, 0.95));
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 */
.dl-meta {
@@ -366,6 +444,9 @@
.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-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 .status-dot { background-color: var(--success); }
@@ -584,6 +665,8 @@
.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 .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 .status-dot { background: var(--danger); }
.dl-status-pill.status-cancelled { background: rgba(107, 114, 128, 0.2); color: #9ca3af; }
@@ -740,6 +823,65 @@
opacity: 0.25;
cursor: not-allowed;
}
/* ===== CUSTOM CROSSHAIR CURSOR ===== */
.custom-cursor-outer {
position: fixed;
width: 36px;
height: 36px;
background: rgba(168, 85, 247, 0.25);
border: none;
pointer-events: none;
z-index: 99999;
transform: translate(-50%, -50%);
transition: width 0.15s ease, height 0.15s ease, background 0.15s ease, transform 0.15s ease, filter 0.15s ease;
/* 5-point star clip-path */
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
/* Strong outline via multiple drop-shadows */
filter:
drop-shadow(0 0 0 rgba(168, 85, 247, 1))
drop-shadow(1px 0 0 rgba(168, 85, 247, 1))
drop-shadow(-1px 0 0 rgba(168, 85, 247, 1))
drop-shadow(0 1px 0 rgba(168, 85, 247, 1))
drop-shadow(0 -1px 0 rgba(168, 85, 247, 1));
}
.custom-cursor-dot {
position: fixed;
width: 6px;
height: 6px;
background: rgba(168, 85, 247, 1);
border-radius: 50%;
pointer-events: none;
z-index: 99999;
transform: translate(-50%, -50%);
transition: transform 0.08s ease;
}
/* Cursor hover state on interactive elements */
.custom-cursor-outer.hovering {
width: 38px;
height: 38px;
background: rgba(192, 132, 252, 0.5);
transform: translate(-50%, -50%) rotate(36deg);
}
.custom-cursor-dot.hovering {
transform: translate(-50%, -50%) scale(1.3);
}
/* Hide default cursor when custom cursor is active */
#gommi_download_manager_queue_list,
#gommi_download_manager_queue_list * {
cursor: none !important;
}
/* Exception: keep pointer on buttons for accessibility hint */
#gommi_download_manager_queue_list button:hover,
#gommi_download_manager_queue_list .dl-btn:hover,
#gommi_download_manager_queue_list .btn-premium:hover {
cursor: none !important;
}
</style>
<div id="gommi_download_manager_queue_list" class="mt-4">
@@ -749,6 +891,9 @@
<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>)
</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()">
<i class="fa fa-trash"></i> Reset All
</button>
@@ -767,6 +912,70 @@
</div>
</div>
<!-- Custom Cursor Elements -->
<div class="custom-cursor-outer" id="cursor-ring"></div>
<div class="custom-cursor-dot" id="cursor-dot"></div>
<script>
// ===== CUSTOM CURSOR WITH INERTIA EFFECT (Optimized) =====
(function() {
const ring = document.getElementById('cursor-ring');
const dot = document.getElementById('cursor-dot');
if (!ring || !dot) return;
let mouseX = 0, mouseY = 0;
let ringX = 0, ringY = 0;
let dotX = 0, dotY = 0;
// Interactive elements selector
const interactiveSelector = 'a, button, .btn, .dl-btn, .btn-premium, .dl-card, input, select';
// Track mouse position
document.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
});
// Lerp animation with smooth follow
function animate() {
// Ring follows with inertia
ringX += (mouseX - ringX) * 0.12;
ringY += (mouseY - ringY) * 0.12;
ring.style.left = ringX + 'px';
ring.style.top = ringY + 'px';
// Dot follows quickly
dotX += (mouseX - dotX) * 0.25;
dotY += (mouseY - dotY) * 0.25;
dot.style.left = dotX + 'px';
dot.style.top = dotY + 'px';
requestAnimationFrame(animate);
}
animate();
// Event delegation for hover effects (document-wide)
document.addEventListener('mouseover', (e) => {
if (e.target.matches(interactiveSelector) || e.target.closest(interactiveSelector)) {
ring.classList.add('hovering');
dot.classList.add('hovering');
}
});
document.addEventListener('mouseout', (e) => {
if (e.target.matches(interactiveSelector) || e.target.closest(interactiveSelector)) {
ring.classList.remove('hovering');
dot.classList.remove('hovering');
}
});
// Show cursor on page
ring.style.opacity = '1';
dot.style.opacity = '1';
})();
</script>
<script>
// PACKAGE_NAME and MODULE_NAME are already defined globally by framework
@@ -971,7 +1180,7 @@
<i class="fa fa-clock-o"></i> ${startTime !== '-' ? startTime.split(' ')[1] || startTime : '-'}
</div>
<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>
</button>
<button class="dl-btn delete" title="삭제" onclick="event.stopPropagation(); deleteDownload('${item.id}')">
@@ -1048,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) {
$.ajax({
url: '/{{ arg["package_name"] }}/ajax/{{ arg["module_name"] }}/delete',

View File

@@ -23,6 +23,64 @@
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
/* Plugin-owned loading override (independent from Flaskfarm default loader assets) */
#loading,
#modal_loading {
display: none;
position: fixed;
inset: 0;
z-index: 3000010 !important;
background: rgba(7, 16, 35, 0.46);
backdrop-filter: blur(2px);
}
#loading img,
#modal_loading img {
display: none !important;
}
#loading::before,
#modal_loading::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 58px;
height: 58px;
margin-left: -29px;
margin-top: -29px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.24);
border-top-color: var(--accent-primary);
border-right-color: var(--accent-secondary);
animation: gdm-loader-spin 0.9s linear infinite;
}
#loading::after,
#modal_loading::after {
content: "LOADING";
position: absolute;
left: 50%;
top: calc(50% + 44px);
transform: translateX(-50%);
color: var(--text-main);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
}
#loading[style*="display: block"],
#loading[style*="display: inline-block"],
#modal_loading[style*="display: block"],
#modal_loading[style*="display: inline-block"] {
display: block !important;
}
@keyframes gdm-loader-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
#gommi_download_manager_queue_setting {
font-family: var(--font-sans);
color: var(--text-main);
@@ -237,6 +295,10 @@
<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="6M" {% if arg['max_download_rate'] == '6M' %}selected{% endif %}>6 MB/s</option>
<option value="7M" {% if arg['max_download_rate'] == '7M' %}selected{% endif %}>7 MB/s</option>
<option value="8M" {% if arg['max_download_rate'] == '8M' %}selected{% endif %}>8 MB/s</option>
<option value="9M" {% if arg['max_download_rate'] == '9M' %}selected{% endif %}>9 MB/s</option>
<option value="10M" {% if arg['max_download_rate'] == '10M' %}selected{% endif %}>10 MB/s</option>
</select>
</div>