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용 범용 다운로드 매니저 플러그인입니다. FlaskFarm용 범용 다운로드 매니저 플러그인입니다.
여러 다운로더 플러그인(YouTube, Anime 등)의 다운로드 요청을 통합 관리하고 큐(Queue)를 제공합니다. 여러 다운로더 플러그인(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) ## v0.2.12 변경사항 (2026-01-07)
- **안정성 개선**: `ffmpeg_hls` 다운로더에서 URL이 비어있을 경우 로그 기록 시 발생하는 `TypeError` 수정. - **안정성 개선**: `ffmpeg_hls` 다운로더에서 URL이 비어있을 경우 로그 기록 시 발생하는 `TypeError` 수정.

View File

@@ -80,6 +80,15 @@ class FfmpegHlsDownloader(BaseDownloader):
except Exception as ce: except Exception as ce:
logger.error(f"Failed to read cookies_file: {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 # 입력 URL
cmd.extend(['-i', url]) cmd.extend(['-i', url])

View File

@@ -62,6 +62,11 @@ class YtdlpAria2Downloader(BaseDownloader):
aria2c_path = options.get('aria2c_path', 'aria2c') aria2c_path = options.get('aria2c_path', 'aria2c')
connections = options.get('connections', 4) 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') max_rate = P.ModelSetting.get('max_download_rate')
if max_rate == '0': if max_rate == '0':

View File

@@ -1,6 +1,6 @@
title: "GDM" title: "GDM"
package_name: gommi_downloader_manager package_name: gommi_downloader_manager
version: '0.2.13' version: '0.2.15'
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

@@ -141,6 +141,39 @@ class ModuleQueue(PluginModuleBase):
P.logger.error(f'DB Clear Error: {e}') P.logger.error(f'DB Clear Error: {e}')
ret['msg'] = '목록을 초기화했습니다.' 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: except Exception as e:
self.P.logger.error(f'Exception:{str(e)}') self.P.logger.error(f'Exception:{str(e)}')
@@ -385,9 +418,7 @@ class DownloadTask:
self.error_message = '' self.error_message = ''
self.filepath = os.path.join(save_path, filename) if filename else '' self.filepath = os.path.join(save_path, filename) if filename else ''
# 메타데이터 # 메타데이터 (이미 __init__ 상단에서 인자로 받은 title, thumbnail을 self.title, self.thumbnail에 할당함)
self.title = ''
self.thumbnail = ''
self.duration = 0 self.duration = 0
self.filesize = 0 self.filesize = 0
@@ -670,3 +701,7 @@ class DownloadTask:
'created_time': self.created_time, 'created_time': self.created_time,
'file_size': self.filesize, '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; margin-right: 0.5rem;
} }
/* Card List Layout */ /* Card List Layout - Single Column for clear order */
.download-grid { .download-grid {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); flex-direction: column;
gap: 1.25rem; gap: 0.75rem;
} }
@media (max-width: 576px) { @media (max-width: 576px) {
@@ -211,11 +211,11 @@
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 16px; border-radius: 12px;
padding: 1.25rem; padding: 0.75rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.5rem;
transition: all 0.3s ease; transition: all 0.3s ease;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -281,7 +281,7 @@
/* Progress Section */ /* Progress Section */
.dl-progress-container { .dl-progress-container {
margin-top: 0.5rem; margin-top: 0.25rem;
} }
.dl-progress-header { .dl-progress-header {
@@ -317,7 +317,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 0.5rem; margin-top: 0.25rem;
} }
.dl-status-label { .dl-status-label {
@@ -467,12 +467,265 @@
.dl-card.expanded .dl-expand-hint { .dl-card.expanded .dl-expand-hint {
display: none; 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> </style>
<div id="gommi_download_manager_queue_list" class="mt-4"> <div id="gommi_download_manager_queue_list" class="mt-4">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">GDM Queue</h1> <h1 class="page-title">GDM Queue</h1>
<div class="header-actions"> <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()"> <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>
@@ -528,6 +781,12 @@
expandedIds.push(card.id); 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) { if (!items || items.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="empty-state"> <div class="empty-state">
@@ -548,6 +807,19 @@
const card = document.getElementById(cardId); const card = document.getElementById(cardId);
if (card) card.classList.add('expanded'); 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) { function createDownloadCard(item) {
@@ -558,12 +830,12 @@
const thumbnail = item.thumbnail || ''; const thumbnail = item.thumbnail || '';
let statusClass = `status-${status}`; let statusClass = `status-${status}`;
let metaHtml = '';
if (item.meta) { // Build meta info from meta object
if (item.meta.series) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.series}</span>`; const series = item.meta?.series || '';
if (item.meta.season) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.season}기</span>`; const season = item.meta?.season ? `S${String(item.meta.season).padStart(2, '0')}` : '';
if (item.meta.episode) metaHtml += `<span class="badge badge-outline mr-1">${item.meta.episode}화</span>`; const episode = item.meta?.episode ? `E${String(item.meta.episode).padStart(2, '0')}` : '';
} const episodeTag = (season || episode) ? `${season}${episode}` : '';
// Format times // Format times
const startTime = item.start_time || item.created_time || '-'; const startTime = item.start_time || item.created_time || '-';
@@ -572,65 +844,70 @@
const fileSize = item.file_size ? formatFileSize(item.file_size) : '-'; const fileSize = item.file_size ? formatFileSize(item.file_size) : '-';
const downloadUrl = item.url || '-'; 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 ` return `
<div class="dl-card" id="card_${item.id}" onclick="toggleCardDetail(this, event)"> <div class="dl-card" id="card_${item.id}" onclick="toggleCardDetail(this, event)">
<div class="dl-meta"> <div class="dl-card-header">
<span class="dl-id-badge">#${item.id.toString().split('_').pop()}</span> <div class="dl-header-left">
<span class="dl-source-pill">${source.toUpperCase()}</span> <input type="checkbox" class="dl-select-checkbox" data-id="${item.id}" onclick="event.stopPropagation(); updateSelectedCount();">
</div> <span class="dl-index-badge">${item.id.toString().split('_').pop()}</span>
<div style="display: flex; gap: 1rem; align-items: flex-start;"> <span class="dl-source-tag" style="${sourceStyle}">${source.toUpperCase()}</span>
${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'">` : ''} ${episodeTag ? `<span class="dl-episode-tag">${episodeTag}</span>` : ''}
<div class="dl-info" style="flex: 1; min-width: 0;"> </div>
<div class="dl-title" title="${displayTitle}">${displayTitle}</div> <div class="dl-status-pill ${statusClass}">
<div class="mt-2" style="font-size: 0.7rem; display: flex; flex-wrap: wrap; gap: 4px;"> <span class="status-dot"></span>
${metaHtml} ${status.charAt(0).toUpperCase() + status.slice(1)}
</div>
</div> </div>
</div> </div>
<div class="dl-progress-container">
<div class="dl-progress-header"> <div class="dl-card-body">
<span class="dl-percent">${percent}%</span> ${thumbnail ? `<img src="${thumbnail}" class="dl-thumb" onerror="this.style.display='none'">` : ''}
<span class="dl-speed">${item.speed || ''}</span> <div class="dl-content">
</div> ${series ? `<div class="dl-series">${series}</div>` : ''}
<div class="dl-progress-bar-bg"> <div class="dl-filename" title="${displayTitle}">${displayTitle}</div>
<div class="dl-progress-bar-fill" style="width: ${percent}%;"></div>
</div> </div>
</div> </div>
<div class="dl-footer">
<div class="dl-status-label ${statusClass}"> <div class="dl-progress-section">
<div class="status-dot"></div> <div class="dl-progress-info">
<span>${status.charAt(0).toUpperCase() + status.slice(1)}</span> <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>
<div class="dl-actions"> <div class="dl-actions">
${status === 'downloading' || status === 'pending' || status === 'paused' ? <button class="dl-btn cancel" title="취소" onclick="event.stopPropagation(); cancelDownload('${item.id}')" ${status === 'downloading' || status === 'pending' || status === 'paused' || status === 'extracting' ? '' : 'disabled'}>
`<button class="btn-action-small cancel" title="Cancel Download" onclick="event.stopPropagation(); cancelDownload('${item.id}')"> <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}')">
} <i class="fa fa-trash-o"></i>
</button>
</div> </div>
</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-panel">
<div class="dl-detail-row"> <div class="dl-detail-row"><span class="dl-detail-label">시작</span><span class="dl-detail-value">${startTime}</span></div>
<span class="dl-detail-label">시작 시간</span> <div class="dl-detail-row"><span class="dl-detail-label">종료</span><span class="dl-detail-value">${endTime}</span></div>
<span class="dl-detail-value">${startTime}</span> <div class="dl-detail-row"><span class="dl-detail-label">크기</span><span class="dl-detail-value">${fileSize}</span></div>
</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">종료 시간</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>
</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 // Toggle card detail panel
function toggleCardDetail(card, event) { function toggleCardDetail(card, event) {
// Don't toggle if clicking on action buttons // Don't toggle if clicking on action buttons