v0.2.14: FFmpeg HLS resilience and aria2c multi-threading fixes

This commit is contained in:
2026-01-07 22:35:58 +09:00
parent 340ed8833e
commit d6819447d7
6 changed files with 461 additions and 67 deletions

View File

@@ -3,6 +3,11 @@
FlaskFarm용 범용 다운로드 매니저 플러그인입니다.
여러 다운로더 플러그인(YouTube, Anime 등)의 다운로드 요청을 통합 관리하고 큐(Queue)를 제공합니다.
## v0.2.14 변경사항 (2026-01-07)
- **FFmpeg HLS 안정화**: Ohli24 분산 호스트 환경 대응을 위해 `-http_persistent 0` 및 재연결 옵션(`-reconnect`) 추가.
- **aria2c 멀티쓰레드 활성화**: `yt-dlp`에서 `aria2c`를 외부 다운로더로 정상 호출하도록 수정하여 고속 분할 다운로드 지원.
- **GDM 위임 로직 버그 수정**: `DownloadTask` 객체의 `as_dict` 누락 및 메타데이터 초기화 버그 수정 (이전 버전 패치 포함).
## v0.2.12 변경사항 (2026-01-07)
- **안정성 개선**: `ffmpeg_hls` 다운로더에서 URL이 비어있을 경우 로그 기록 시 발생하는 `TypeError` 수정.

View File

@@ -80,6 +80,15 @@ class FfmpegHlsDownloader(BaseDownloader):
except Exception as ce:
logger.error(f"Failed to read cookies_file: {ce}")
# 입력 전 설정 (Reconnection & HTTP persistence fix)
cmd.extend([
'-reconnect', '1',
'-reconnect_at_eof', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
'-http_persistent', '0'
])
# 입력 URL
cmd.extend(['-i', url])

View File

@@ -62,6 +62,11 @@ class YtdlpAria2Downloader(BaseDownloader):
aria2c_path = options.get('aria2c_path', 'aria2c')
connections = options.get('connections', 4)
if self._check_aria2c(aria2c_path):
cmd.extend(['--external-downloader', aria2c_path])
cmd.extend(['--external-downloader-args', f'aria2c:-x{connections} -s{connections} -j{connections} -k1M'])
logger.info(f'[GDM] Using aria2c for multi-threaded download (connections: {connections})')
# 속도 제한 설정
max_rate = P.ModelSetting.get('max_download_rate')
if max_rate == '0':

View File

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

View File

@@ -142,6 +142,39 @@ class ModuleQueue(PluginModuleBase):
ret['msg'] = '목록을 초기화했습니다.'
elif command == 'delete':
# 특정 항목 완전 삭제 (메모리 + DB)
download_id = req.form.get('id', '')
# 메모리에서 삭제
if download_id in self._downloads:
self._downloads[download_id].cancel()
del self._downloads[download_id]
# DB에서 삭제 (db_XXX 형태인 경우)
if download_id.startswith('db_'):
db_id = int(download_id.replace('db_', ''))
try:
from .model import ModelDownloadItem
with F.app.app_context():
F.db.session.query(ModelDownloadItem).filter_by(id=db_id).delete()
F.db.session.commit()
except Exception as e:
self.P.logger.error(f'DB Delete Error: {e}')
else:
# 메모리 기반 ID에서 db_id 추출 시도
try:
task = self._downloads.get(download_id)
if task and hasattr(task, 'db_id') and task.db_id:
from .model import ModelDownloadItem
with F.app.app_context():
F.db.session.query(ModelDownloadItem).filter_by(id=task.db_id).delete()
F.db.session.commit()
except Exception as e:
self.P.logger.error(f'DB Delete Error: {e}')
ret['msg'] = '항목이 삭제되었습니다.'
except Exception as e:
self.P.logger.error(f'Exception:{str(e)}')
self.P.logger.error(traceback.format_exc())
@@ -385,9 +418,7 @@ class DownloadTask:
self.error_message = ''
self.filepath = os.path.join(save_path, filename) if filename else ''
# 메타데이터
self.title = ''
self.thumbnail = ''
# 메타데이터 (이미 __init__ 상단에서 인자로 받은 title, thumbnail을 self.title, self.thumbnail에 할당함)
self.duration = 0
self.filesize = 0
@@ -670,3 +701,7 @@ class DownloadTask:
'created_time': self.created_time,
'file_size': self.filesize,
}
def as_dict(self) -> Dict[str, Any]:
"""데이터 직렬화 (get_status 별칭)"""
return self.get_status()

View File

@@ -192,11 +192,11 @@
margin-right: 0.5rem;
}
/* Card List Layout */
/* Card List Layout - Single Column for clear order */
.download-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@media (max-width: 576px) {
@@ -211,11 +211,11 @@
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.25rem;
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.5rem;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
@@ -281,7 +281,7 @@
/* Progress Section */
.dl-progress-container {
margin-top: 0.5rem;
margin-top: 0.25rem;
}
.dl-progress-header {
@@ -317,7 +317,7 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
margin-top: 0.25rem;
}
.dl-status-label {
@@ -467,12 +467,265 @@
.dl-card.expanded .dl-expand-hint {
display: none;
}
/* Selection Checkbox */
.dl-select-checkbox {
width: 16px;
height: 16px;
margin-right: 6px;
cursor: pointer;
accent-color: var(--accent-primary);
flex-shrink: 0;
}
.dl-card.selected {
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px var(--glow);
}
/* ===== NEW FANTASTIC CARD DESIGN ===== */
/* Card Header */
.dl-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.dl-header-left {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.dl-source-tag {
padding: 3px 8px;
border-radius: 6px;
font-size: 10px;
font-weight: 800;
color: white;
text-transform: uppercase;
letter-spacing: 0.5px;
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
}
.dl-index-badge {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
color: var(--text-main);
font-family: 'SF Mono', 'Menlo', monospace;
min-width: 32px;
text-align: center;
}
.dl-episode-tag {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
color: var(--text-main);
font-family: 'SF Mono', 'Menlo', monospace;
}
.dl-status-pill {
display: flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 20px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.dl-status-pill .status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.dl-status-pill.status-downloading { background: rgba(78, 159, 217, 0.2); color: var(--accent-primary); }
.dl-status-pill.status-downloading .status-dot { background: var(--accent-primary); box-shadow: 0 0 6px var(--accent-primary); animation: pulse 1.5s infinite; }
.dl-status-pill.status-completed { background: rgba(78, 191, 115, 0.2); color: var(--success); }
.dl-status-pill.status-completed .status-dot { background: var(--success); }
.dl-status-pill.status-pending { background: rgba(171, 182, 194, 0.15); color: var(--text-muted); }
.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-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; }
.dl-status-pill.status-cancelled .status-dot { background: #9ca3af; }
/* Card Body */
.dl-card-body {
display: flex;
gap: 10px;
align-items: flex-start;
}
.dl-thumb {
width: 64px;
height: 36px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--border);
flex-shrink: 0;
}
.dl-content {
flex: 1;
min-width: 0;
}
.dl-series {
font-size: 13px;
font-weight: 700;
color: var(--accent-secondary);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dl-filename {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'SF Mono', 'Menlo', monospace;
opacity: 0.8;
}
/* Progress Section */
.dl-progress-section {
margin-top: 8px;
}
.dl-progress-info {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.dl-percent-big {
font-size: 18px;
font-weight: 700;
color: var(--text-main);
}
.dl-percent-big small {
font-size: 11px;
font-weight: 600;
opacity: 0.6;
}
.dl-speed-text {
font-size: 11px;
color: var(--accent-primary);
font-weight: 600;
}
.dl-progress-track {
height: 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 10px;
overflow: hidden;
}
.dl-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
border-radius: 10px;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0 8px var(--glow);
}
/* Card Footer */
.dl-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.dl-time-info {
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 4px;
}
.dl-time-info i {
font-size: 10px;
opacity: 0.6;
}
.dl-actions {
display: flex;
gap: 6px;
}
.dl-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.dl-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
color: var(--text-main);
transform: scale(1.05);
}
.dl-btn.cancel:hover:not(:disabled) {
background: rgba(240, 173, 78, 0.2);
color: var(--warning);
border-color: var(--warning);
}
.dl-btn.delete:hover {
background: rgba(217, 83, 79, 0.2);
color: var(--danger);
border-color: var(--danger);
}
.dl-btn:disabled {
opacity: 0.25;
cursor: not-allowed;
}
</style>
<div id="gommi_download_manager_queue_list" class="mt-4">
<div class="page-header">
<h1 class="page-title">GDM Queue</h1>
<div class="header-actions">
<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 danger" onclick="resetList()">
<i class="fa fa-trash"></i> Reset All
</button>
@@ -528,6 +781,12 @@
expandedIds.push(card.id);
});
// Save checked checkbox IDs before re-rendering
const checkedIds = [];
container.querySelectorAll('.dl-select-checkbox:checked').forEach(function(cb) {
checkedIds.push(cb.dataset.id);
});
if (!items || items.length === 0) {
container.innerHTML = `
<div class="empty-state">
@@ -548,6 +807,19 @@
const card = document.getElementById(cardId);
if (card) card.classList.add('expanded');
});
// Restore checkbox state
checkedIds.forEach(function(id) {
const cb = container.querySelector('.dl-select-checkbox[data-id="' + id + '"]');
if (cb) {
cb.checked = true;
const card = cb.closest('.dl-card');
if (card) card.classList.add('selected');
}
});
// Update selected count
updateSelectedCount();
}
function createDownloadCard(item) {
@@ -558,12 +830,12 @@
const thumbnail = item.thumbnail || '';
let statusClass = `status-${status}`;
let metaHtml = '';
if (item.meta) {
if (item.meta.series) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.series}</span>`;
if (item.meta.season) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.season}기</span>`;
if (item.meta.episode) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.episode}화</span>`;
}
// Build meta info from meta object
const series = item.meta?.series || '';
const season = item.meta?.season ? `S${String(item.meta.season).padStart(2, '0')}` : '';
const episode = item.meta?.episode ? `E${String(item.meta.episode).padStart(2, '0')}` : '';
const episodeTag = (season || episode) ? `${season}${episode}` : '';
// Format times
const startTime = item.start_time || item.created_time || '-';
@@ -572,65 +844,70 @@
const fileSize = item.file_size ? formatFileSize(item.file_size) : '-';
const downloadUrl = item.url || '-';
// Source badge color based on type
const sourceColors = {
'ani24': 'background: linear-gradient(135deg, #f59e0b, #d97706);',
'ohli24': 'background: linear-gradient(135deg, #f59e0b, #d97706);',
'anilife': 'background: linear-gradient(135deg, #ec4899, #be185d);',
'youtube': 'background: linear-gradient(135deg, #ef4444, #b91c1c);',
'hls': 'background: linear-gradient(135deg, #10b981, #059669);',
'auto': 'background: linear-gradient(135deg, #6366f1, #4f46e5);',
};
const sourceStyle = sourceColors[source.toLowerCase()] || sourceColors['auto'];
return `
<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>
<div class="dl-card-header">
<div class="dl-header-left">
<input type="checkbox" class="dl-select-checkbox" data-id="${item.id}" onclick="event.stopPropagation(); updateSelectedCount();">
<span class="dl-index-badge">${item.id.toString().split('_').pop()}</span>
<span class="dl-source-tag" style="${sourceStyle}">${source.toUpperCase()}</span>
${episodeTag ? `<span class="dl-episode-tag">${episodeTag}</span>` : ''}
</div>
<div style="display: flex; gap: 1rem; align-items: flex-start;">
${thumbnail ? `<img src="${thumbnail}" style="width: 80px; height: 45px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border);" onerror="this.style.display='none'">` : ''}
<div class="dl-info" style="flex: 1; min-width: 0;">
<div class="dl-title" title="${displayTitle}">${displayTitle}</div>
<div class="mt-2" style="font-size: 0.7rem; display: flex; flex-wrap: wrap; gap: 4px;">
${metaHtml}
<div class="dl-status-pill ${statusClass}">
<span class="status-dot"></span>
${status.charAt(0).toUpperCase() + status.slice(1)}
</div>
</div>
</div>
<div class="dl-progress-container">
<div class="dl-progress-header">
<span class="dl-percent">${percent}%</span>
<span class="dl-speed">${item.speed || ''}</span>
</div>
<div class="dl-progress-bar-bg">
<div class="dl-progress-bar-fill" style="width: ${percent}%;"></div>
<div class="dl-card-body">
${thumbnail ? `<img src="${thumbnail}" class="dl-thumb" onerror="this.style.display='none'">` : ''}
<div class="dl-content">
${series ? `<div class="dl-series">${series}</div>` : ''}
<div class="dl-filename" title="${displayTitle}">${displayTitle}</div>
</div>
</div>
<div class="dl-footer">
<div class="dl-status-label ${statusClass}">
<div class="status-dot"></div>
<span>${status.charAt(0).toUpperCase() + status.slice(1)}</span>
<div class="dl-progress-section">
<div class="dl-progress-info">
<span class="dl-percent-big">${percent}<small>%</small></span>
<span class="dl-speed-text">${item.speed || ''}</span>
</div>
<div class="dl-progress-track">
<div class="dl-progress-fill" style="width: ${percent}%;"></div>
</div>
</div>
<div class="dl-card-footer">
<div class="dl-time-info">
<i class="fa fa-clock-o"></i> ${startTime !== '-' ? startTime.split(' ')[1] || startTime : '-'}
</div>
<div class="dl-actions">
${status === 'downloading' || status === 'pending' || status === 'paused' ?
`<button class="btn-action-small cancel" title="Cancel Download" onclick="event.stopPropagation(); cancelDownload('${item.id}')">
<button class="dl-btn cancel" title="취소" onclick="event.stopPropagation(); cancelDownload('${item.id}')" ${status === 'downloading' || status === 'pending' || status === 'paused' || status === 'extracting' ? '' : 'disabled'}>
<i class="fa fa-stop"></i>
</button>` : ''
}
</button>
<button class="dl-btn delete" title="삭제" onclick="event.stopPropagation(); deleteDownload('${item.id}')">
<i class="fa fa-trash-o"></i>
</button>
</div>
</div>
<div class="dl-expand-hint"><i class="fa fa-chevron-down"></i> 클릭하여 상세 정보 보기</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 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>
</div>
`;
@@ -693,6 +970,69 @@
});
}
function deleteDownload(id) {
$.ajax({
url: '/{{ arg["package_name"] }}/ajax/{{ arg["module_name"] }}/delete',
type: 'POST',
data: { id: id },
dataType: 'json',
success: function(ret) {
$.notify('<strong>Item Deleted</strong>', {type: 'success'});
refreshList(false);
}
});
}
function updateSelectedCount() {
const checked = document.querySelectorAll('.dl-select-checkbox:checked');
const btn = document.getElementById('delete_selected_btn');
const countSpan = document.getElementById('selected_count');
if (checked.length > 0) {
btn.style.display = 'inline-flex';
countSpan.textContent = checked.length;
} else {
btn.style.display = 'none';
}
// Toggle selected class on cards
document.querySelectorAll('.dl-card').forEach(function(card) {
const checkbox = card.querySelector('.dl-select-checkbox');
if (checkbox && checkbox.checked) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
});
}
function deleteSelected() {
const checked = document.querySelectorAll('.dl-select-checkbox:checked');
if (checked.length === 0) return;
if (!confirm(checked.length + '개 항목을 삭제하시겠습니까?')) return;
const count = checked.length;
let pending = count;
// Clear checkboxes immediately so they don't get restored
checked.forEach(function(cb) { cb.checked = false; });
document.getElementById('delete_selected_btn').style.display = 'none';
checked.forEach(function(cb) {
$.ajax({
url: '/{{ arg["package_name"] }}/ajax/{{ arg["module_name"] }}/delete',
type: 'POST',
data: { id: cb.dataset.id },
dataType: 'json',
complete: function() {
pending--;
if (pending === 0) {
$.notify('<strong>' + count + '개 항목 삭제됨</strong>', {type: 'success'});
refreshList(false); // Non-silent refresh to ensure complete UI update
}
}
});
});
}
// Toggle card detail panel
function toggleCardDetail(card, event) {
// Don't toggle if clicking on action buttons