Compare commits

..

5 Commits

5 changed files with 199 additions and 22 deletions

View File

@@ -3,7 +3,13 @@
FlaskFarm용 범용 다운로드 매니저 플러그인입니다.
여러 다운로더 플러그인(YouTube, Anime 등)의 다운로드 요청을 통합 관리하고 큐(Queue)를 제공합니다.
## v0.2.0 변경사항
## v0.2.8 변경사항 (2026-01-07)
- **콜백 시스템 개선**: `module_list`가 리스트 형태인 플러그인(애니 다운로더 등)과의 콜백 연동 호환성 해결 (`AttributeError` 수정).
- **메타데이터 강화**: 다운로드 시작/종료 시간 및 최종 파일 크기 추적 기능 추가.
- **UI 상세 정보 보강**: GDM 큐 목록에서 시작 시간, 종료 시간, 파일 크기를 상세 패널에 표시.
- **DB 정밀 동기화**: 다운로드 완료 시 실제 파일 크기를 DB에 영구 저장.
## v0.2.7 변경사항
- **패키지명 수정**: `gommi_download_manager` -> `gommi_downloader_manager`로 폴더명과 일치시켜 Bind Key 오류 해결.
- **안정성 개선**: DB 테이블 생성 로직 강화 (`setup.py` 명시적 모델 import).
- **YouTube 제목 지원**: `yt-dlp` 다운로드 시작 시 영상의 진짜 제목과 썸네일을 실시간으로 DB에 업데이트합니다.

View File

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

View File

@@ -396,6 +396,9 @@ class DownloadTask:
self._downloader = None
self._cancelled = False
self.db_id: Optional[int] = None
self.start_time: Optional[str] = None
self.end_time: Optional[str] = None
self.created_time: str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def start(self):
"""다운로드 시작 (비동기)"""
@@ -406,6 +409,8 @@ class DownloadTask:
"""다운로드 실행"""
try:
self.status = DownloadStatus.EXTRACTING
if not self.start_time:
self.start_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
self._emit_status()
# 다운로더 선택 및 실행
@@ -434,13 +439,17 @@ class DownloadTask:
self.status = DownloadStatus.COMPLETED
self.filepath = result.get('filepath', '')
self.progress = 100
self.end_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if self.filepath and os.path.exists(self.filepath):
self.filesize = os.path.getsize(self.filepath)
# DB 업데이트
self._update_db_status()
# 실시간 콜백 처리
if self._on_complete:
self._on_complete(self.filepath)
try: self._on_complete(self.filepath)
except: pass
# 플러그인 간 영구적 콜백 처리
if self.caller_plugin and self.callback_id:
@@ -566,6 +575,7 @@ class DownloadTask:
item.status = self.status
if self.status == DownloadStatus.COMPLETED:
item.completed_time = datetime.now()
item.filesize = self.filesize
if self.error_message:
item.error_message = self.error_message
F.db.session.add(item)
@@ -605,7 +615,15 @@ class DownloadTask:
if target_P:
# 모듈에서 콜백 메서드 찾기
callback_invoked = False
for module_name, module_instance in getattr(target_P, 'module_list', {}).items():
module_list = getattr(target_P, 'module_list', [])
if isinstance(module_list, dict):
modules = module_list.items()
elif isinstance(module_list, list):
modules = [(getattr(m, 'name', str(i)), m) for i, m in enumerate(module_list)]
else:
modules = []
for module_name, module_instance in modules:
if hasattr(module_instance, 'plugin_callback'):
callback_data = {
'callback_id': self.callback_id,
@@ -647,4 +665,8 @@ class DownloadTask:
'caller_plugin': self.caller_plugin,
'callback_id': self.callback_id,
'db_id': self.db_id,
'start_time': self.start_time,
'end_time': self.end_time,
'created_time': self.created_time,
'file_size': self.filesize,
}

View File

@@ -57,6 +57,10 @@ class ModelDownloadItem(ModelBase):
ret['meta'] = {}
else:
ret['meta'] = {}
if self.created_time:
ret['created_time'] = self.created_time.strftime('%Y-%m-%d %H:%M:%S')
# JS UI expects file_size (with underscore)
ret['file_size'] = self.filesize or 0
return ret
@classmethod

View File

@@ -8,21 +8,42 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Premium Modern Design System */
:root {
--bg-body: #0f172a;
--surface: rgba(30, 41, 59, 0.7);
--surface-opaque: #1e293b;
--border: rgba(255, 255, 255, 0.1);
--text-main: #f8fafc;
--text-muted: #94a3b8;
--accent-primary: #38bdf8; /* Sky Blue */
--accent-secondary: #818cf8; /* Indigo */
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--glow: rgba(56, 189, 248, 0.3);
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
/* ========================================
GDM FORCED SUPERHERO THEME
(Overrides global FlaskFarm theme)
======================================== */
/* Superhero Color Palette - HARDCODED */
:root, #gommi_download_manager_queue_list, #gommi_download_manager_queue_list * {
--bg-body: #2b3e50 !important;
--surface: rgba(62, 88, 112, 0.9) !important;
--surface-opaque: #3e5870 !important;
--border: rgba(255, 255, 255, 0.15) !important;
--text-main: #ebebeb !important;
--text-muted: #abb6c2 !important;
--accent-primary: #4e9fd9 !important;
--accent-secondary: #5bc0de !important;
--success: #4ebf73 !important;
--warning: #f0ad4e !important;
--danger: #d9534f !important;
--glow: rgba(78, 159, 217, 0.4) !important;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important;
}
/* FORCE body background (overrides global theme) */
body {
background-color: #2b3e50 !important;
color: #ebebeb !important;
}
/* Also override main containers that might have theme colors */
.main, #main, .container, .container-fluid, #main_container {
background-color: #2b3e50 !important;
}
/* Navigation bar override */
.navbar, .navbar-dark, .bg-dark, .bg-primary {
background-color: #3e5770 !important;
}
#loading { display: none !important; }
@@ -30,7 +51,7 @@
#gommi_download_manager_queue_list {
font-family: var(--font-sans);
color: var(--text-main);
background-color: transparent;
background-color: #2b3e50 !important;
padding-bottom: 2rem;
}
@@ -128,6 +149,7 @@
letter-spacing: -0.025em;
background: linear-gradient(135deg, #fff 0%, #cbd5e1 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@@ -242,6 +264,7 @@
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
@@ -379,6 +402,71 @@
margin-bottom: 1rem;
opacity: 0.3;
}
/* Expandable Detail Panel */
.dl-card {
cursor: pointer;
}
.dl-detail-panel {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out, padding 0.3s ease-out, margin 0.3s ease-out;
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
margin-top: 0;
padding: 0 1rem;
}
.dl-card.expanded .dl-detail-panel {
max-height: 300px;
padding: 1rem;
margin-top: 1rem;
}
.dl-detail-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0.4rem 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
font-size: 0.75rem;
}
.dl-detail-row:last-child {
border-bottom: none;
}
.dl-detail-label {
color: var(--text-muted);
flex-shrink: 0;
margin-right: 1rem;
}
.dl-detail-value {
color: var(--text-main);
word-break: break-all;
text-align: right;
max-width: 70%;
}
.dl-detail-value.url {
font-family: monospace;
font-size: 0.65rem;
opacity: 0.8;
}
.dl-expand-hint {
font-size: 0.65rem;
color: var(--text-muted);
text-align: center;
margin-top: 0.5rem;
opacity: 0.6;
}
.dl-card.expanded .dl-expand-hint {
display: none;
}
</style>
<div id="gommi_download_manager_queue_list" class="mt-4">
@@ -434,6 +522,12 @@
const container = document.getElementById('download_list');
if (!container) return;
// Save expanded card IDs before re-rendering
const expandedIds = [];
container.querySelectorAll('.dl-card.expanded').forEach(function(card) {
expandedIds.push(card.id);
});
if (!items || items.length === 0) {
container.innerHTML = `
<div class="empty-state">
@@ -448,6 +542,12 @@
html += createDownloadCard(item);
});
container.innerHTML = html;
// Restore expanded state
expandedIds.forEach(function(cardId) {
const card = document.getElementById(cardId);
if (card) card.classList.add('expanded');
});
}
function createDownloadCard(item) {
@@ -465,8 +565,15 @@
if (item.meta.episode) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.episode}화</span>`;
}
// Format times
const startTime = item.start_time || item.created_time || '-';
const endTime = item.end_time || item.completed_time || '-';
const filePath = item.save_path || item.filepath || '-';
const fileSize = item.file_size ? formatFileSize(item.file_size) : '-';
const downloadUrl = item.url || '-';
return `
<div class="dl-card" id="card_${item.id}">
<div class="dl-card" id="card_${item.id}" onclick="toggleCardDetail(this, event)">
<div class="dl-meta">
<span class="dl-id-badge">#${item.id.toString().split('_').pop()}</span>
<span class="dl-source-pill">${source.toUpperCase()}</span>
@@ -496,12 +603,35 @@
</div>
<div class="dl-actions">
${status === 'downloading' || status === 'pending' || status === 'paused' ?
`<button class="btn-action-small cancel" title="Cancel Download" onclick="cancelDownload('${item.id}')">
`<button class="btn-action-small cancel" title="Cancel Download" onclick="event.stopPropagation(); cancelDownload('${item.id}')">
<i class="fa fa-stop"></i>
</button>` : ''
}
</div>
</div>
<div class="dl-expand-hint"><i class="fa fa-chevron-down"></i> 클릭하여 상세 정보 보기</div>
<div class="dl-detail-panel">
<div class="dl-detail-row">
<span class="dl-detail-label">시작 시간</span>
<span class="dl-detail-value">${startTime}</span>
</div>
<div class="dl-detail-row">
<span class="dl-detail-label">종료 시간</span>
<span class="dl-detail-value">${endTime}</span>
</div>
<div class="dl-detail-row">
<span class="dl-detail-label">파일 크기</span>
<span class="dl-detail-value">${fileSize}</span>
</div>
<div class="dl-detail-row">
<span class="dl-detail-label">저장 경로</span>
<span class="dl-detail-value">${filePath}</span>
</div>
<div class="dl-detail-row">
<span class="dl-detail-label">URL</span>
<span class="dl-detail-value url">${downloadUrl.length > 80 ? downloadUrl.substring(0, 80) + '...' : downloadUrl}</span>
</div>
</div>
</div>
`;
}
@@ -563,6 +693,21 @@
});
}
// Toggle card detail panel
function toggleCardDetail(card, event) {
// Don't toggle if clicking on action buttons
if (event.target.closest('.dl-actions')) return;
card.classList.toggle('expanded');
}
// Format file size to human readable
function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '-';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
}
// Socket Init
try {
if (typeof io !== 'undefined') {