Compare commits

...

5 Commits

8 changed files with 620 additions and 41 deletions

View File

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

View File

@@ -42,10 +42,13 @@ class FfmpegHlsDownloader(BaseDownloader):
if not filename: if not filename:
filename = f"download_{int(__import__('time').time())}.mp4" 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 명령어 구성
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']
@@ -173,7 +176,16 @@ class FfmpegHlsDownloader(BaseDownloader):
"""다운로드 취소""" """다운로드 취소"""
super().cancel() super().cancel()
if self._process: 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: def _get_duration(self, url: str, ffprobe_path: str, headers: Dict) -> float:
"""ffprobe로 영상 길이 획득""" """ffprobe로 영상 길이 획득"""

View File

@@ -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,
@@ -38,7 +55,8 @@ class HttpDirectDownloader(BaseDownloader):
if not filename: if not filename:
filename = url.split('/')[-1].split('?')[0] or f"download_{int(__import__('time').time())}" 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', {}) headers = options.get('headers', {})
@@ -52,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):
@@ -61,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)

View File

@@ -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,
@@ -40,12 +53,14 @@ class YtdlpAria2Downloader(BaseDownloader):
try: try:
os.makedirs(save_path, exist_ok=True) os.makedirs(save_path, exist_ok=True)
# 출력 템플릿 # 출력 템플릿 (outtmpl 옵션 우선 처리)
if filename: raw_outtmpl = options.get('outtmpl') or filename or '%(title)s.%(ext)s'
output_template = os.path.join(save_path, filename)
else:
output_template = os.path.join(save_path, '%(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 명령어 구성 # yt-dlp 명령어 구성
cmd = [ cmd = [
'yt-dlp', '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:title:%(title)s'])
cmd.extend(['--print', 'before_dl:GDM_FIX:thumb:%(thumbnail)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 사용 (설치되어 있으면)
aria2c_path = options.get('aria2c_path', 'aria2c') aria2c_path = options.get('aria2c_path', 'aria2c')
connections = options.get('connections', 4) connections = options.get('connections', 4)
@@ -65,21 +88,22 @@ class YtdlpAria2Downloader(BaseDownloader):
if self._check_aria2c(aria2c_path): if self._check_aria2c(aria2c_path):
cmd.extend(['--external-downloader', aria2c_path]) cmd.extend(['--external-downloader', aria2c_path])
# aria2c 설정: -x=연결수, -s=분할수, -j=병렬, -k=조각크기, --console-log-level=notice로 진행률 출력 # 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})') logger.info(f'[GDM] Using aria2c for multi-threaded download (connections: {connections})')
# 진행률 템플릿 추가 (yt-dlp native downloader) # 진행률 템플릿 추가 (yt-dlp native downloader)
cmd.extend(['--progress-template', 'download:GDM_PROGRESS:%(progress._percent_str)s:%(progress._speed_str)s:%(progress._eta_str)s']) cmd.extend(['--progress-template', 'download:GDM_PROGRESS:%(progress._percent_str)s:%(progress._speed_str)s:%(progress._eta_str)s'])
# 속도 제한 설정 # yt-dlp native downloader 제한 (external-downloader 미사용/보조 경로)
max_rate = P.ModelSetting.get('max_download_rate') if rate_limited:
if max_rate == '0': cmd.extend(['--limit-rate', max_rate])
max_rate_arg = '' if options.get('is_global_rate_split'):
log_rate_msg = '무제한' logger.info(f'[GDM] global split limit enabled: {max_rate}/s per task')
else: else:
max_rate_arg = f'--max-download-limit={max_rate}' logger.info(f'[GDM] download speed limit enabled: {max_rate}/s')
log_rate_msg = max_rate
cmd.extend(['--limit-rate', max_rate]) # Native downloader limit
# 포맷 선택 # 포맷 선택
format_spec = options.get('format') format_spec = options.get('format')
@@ -147,14 +171,6 @@ class YtdlpAria2Downloader(BaseDownloader):
if options.get('add_metadata'): if options.get('add_metadata'):
cmd.append('--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 추가 # URL 추가
cmd.append(url) cmd.append(url)
@@ -267,6 +283,15 @@ class YtdlpAria2Downloader(BaseDownloader):
if self._process.returncode == 0: if self._process.returncode == 0:
if progress_callback: if progress_callback:
progress_callback(100, '', '') 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} return {'success': True, 'filepath': final_filepath}
else: else:
return {'success': False, 'error': f'Exit code: {self._process.returncode}'} return {'success': False, 'error': f'Exit code: {self._process.returncode}'}
@@ -305,7 +330,16 @@ class YtdlpAria2Downloader(BaseDownloader):
"""다운로드 취소""" """다운로드 취소"""
super().cancel() super().cancel()
if self._process: 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: def _check_aria2c(self, aria2c_path: str) -> bool:
"""aria2c 설치 확인""" """aria2c 설치 확인"""
@@ -318,3 +352,57 @@ class YtdlpAria2Downloader(BaseDownloader):
return result.returncode == 0 return result.returncode == 0
except: except:
return False 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" title: "GDM"
package_name: gommi_downloader_manager package_name: gommi_downloader_manager
version: '0.2.28' 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

View File

@@ -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 =====
@@ -297,13 +354,13 @@ class ModuleQueue(PluginModuleBase):
# 1. Git Pull # 1. Git Pull
cmd = ['git', '-C', plugin_path, 'pull'] cmd = ['git', '-C', plugin_path, 'pull']
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
stdout, stderr = process.communicate()
if process.returncode != 0: if result.returncode != 0:
raise Exception(f"Git pull 실패: {stderr}") 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) # 2. 모듈 리로드 (Hot-Reload)
self.reload_plugin() self.reload_plugin()
@@ -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 = ''):
@@ -890,7 +1029,19 @@ class DownloadTask:
else: else:
modules = [] 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: 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'): if hasattr(module_instance, 'plugin_callback'):
callback_data = { callback_data = {
'callback_id': self.callback_id, 'callback_id': self.callback_id,
@@ -901,8 +1052,10 @@ class DownloadTask:
} }
module_instance.plugin_callback(callback_data) module_instance.plugin_callback(callback_data)
callback_invoked = True callback_invoked = True
P.logger.info(f"Callback invoked on module {module_name}") P.logger.info(f"Callback invoked on module {instance_name}")
break # 대상 모듈을 명확히 찾았으면 종료
if target_module_name:
break
if not callback_invoked: if not callback_invoked:
P.logger.debug(f"No plugin_callback method found in {self.caller_plugin}") P.logger.debug(f"No plugin_callback method found in {self.caller_plugin}")

View File

@@ -46,7 +46,63 @@
background-color: #3e5770 !important; 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 { #gommi_download_manager_queue_list {
font-family: var(--font-sans); 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 { .page-title {
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
@@ -249,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 {
@@ -366,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); }
@@ -584,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; }
@@ -740,6 +823,65 @@
opacity: 0.25; opacity: 0.25;
cursor: not-allowed; 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> </style>
<div id="gommi_download_manager_queue_list" class="mt-4"> <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;"> <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>
@@ -767,6 +912,70 @@
</div> </div>
</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> <script>
// PACKAGE_NAME and MODULE_NAME are already defined globally by framework // 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 : '-'} <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}')">
@@ -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) { function deleteDownload(id) {
$.ajax({ $.ajax({
url: '/{{ arg["package_name"] }}/ajax/{{ arg["module_name"] }}/delete', 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; --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 { #gommi_download_manager_queue_setting {
font-family: var(--font-sans); font-family: var(--font-sans);
color: var(--text-main); 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="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="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="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> <option value="10M" {% if arg['max_download_rate'] == '10M' %}selected{% endif %}>10 MB/s</option>
</select> </select>
</div> </div>