Compare commits

..

3 Commits

8 changed files with 394 additions and 39 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,7 +42,8 @@ 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')
@@ -173,7 +174,16 @@ class FfmpegHlsDownloader(BaseDownloader):
"""다운로드 취소""" """다운로드 취소"""
super().cancel() super().cancel()
if self._process: if self._process:
try:
# [FIX] 파이프 명시적으로 닫기
if self._process.stdout: self._process.stdout.close()
if self._process.stderr: self._process.stderr.close()
self._process.terminate() 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

@@ -38,7 +38,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', {})

View File

@@ -40,11 +40,13 @@ 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 = [
@@ -58,6 +60,10 @@ 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 = P.ModelSetting.get('max_download_rate')
rate_limited = bool(max_rate and max_rate != '0')
# 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 +71,18 @@ 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 = ''
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
# 포맷 선택 # 포맷 선택
format_spec = options.get('format') format_spec = options.get('format')
@@ -147,14 +150,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 +262,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 +309,16 @@ class YtdlpAria2Downloader(BaseDownloader):
"""다운로드 취소""" """다운로드 취소"""
super().cancel() super().cancel()
if self._process: if self._process:
try:
# [FIX] 파이프 명시적으로 닫기
if self._process.stdout: self._process.stdout.close()
if self._process.stderr: self._process.stderr.close()
self._process.terminate() 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 +331,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.33'
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

@@ -297,13 +297,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()
@@ -890,7 +890,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,7 +913,9 @@ 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}")
# 대상 모듈을 명확히 찾았으면 종료
if target_module_name:
break break
if not callback_invoked: if not callback_invoked:

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;
@@ -740,6 +814,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">
@@ -767,6 +900,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

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>