Compare commits

..

26 Commits

Author SHA1 Message Date
c7564c0033 feat: add waiting status and clear-completed queue action 2026-03-03 21:37:00 +09:00
7a087ce9c5 fix: enforce max_concurrent and global speed-limit distribution 2026-03-03 18:36:56 +09:00
1cdf68cc59 chore: bump 0.2.32 and apply plugin loading override 2026-03-03 18:25:05 +09:00
9d5d1514c4 Bump version to v0.2.30: Subtitle auto-download & path normalization 2026-01-12 20:57:12 +09:00
9c4f36de6b Fix GDM callback mapping: correctly target module within plugin package 2026-01-11 18:47:41 +09:00
c38a7ae39b v0.2.27: Add self-update feature with hot reload 2026-01-09 22:18:36 +09:00
7beb536ca0 docs: Update README with v0.2.25 changelog 2026-01-08 21:11:08 +09:00
c0a0f1ff2d docs: Update README with v0.2.24 changelog 2026-01-08 21:01:03 +09:00
d98c13a4e9 fix: Change source type from ani24 to ohli24 - Update detection logic in mod_queue.py - Update source badge color mapping - Update downloader selection 2026-01-08 20:14:21 +09:00
cae3c9b269 perf: Optimize list rendering with partial DOM updates - Preserve existing card DOM elements (no image flicker) - Update only changed fields (progress, status, speed) - Add/remove cards only when necessary - Reorder cards to match server order 2026-01-08 20:07:19 +09:00
4baf23d8ad feat: Add card background color distinction by status - Completed: green gradient - Error: red gradient - Cancelled: gray with reduced opacity - Downloading: blue tint - Extracting: purple tint 2026-01-08 20:02:11 +09:00
2fa4f474c3 fix: Improve progress display for aria2c downloads - Add --progress-template with GDM_PROGRESS format - Add aria2c --summary-interval for status updates - Parse GDM_PROGRESS pattern for progress callback 2026-01-08 20:00:34 +09:00
1b59ca4279 fix: Add public API for Chrome extension (no login required) - New endpoints: /public/youtube/formats, /public/youtube/add - Update extension to use public API - Add contextMenus/notifications permissions 2026-01-08 19:47:41 +09:00
c7a3d6fd03 feat: Add YouTube Chrome extension and GDM API - Chrome extension with popup UI, quality selection, server config - YouTube API endpoints: youtube_add, youtube_formats - Background worker with context menu integration - Content script with inline download button 2026-01-08 19:33:18 +09:00
5afb082692 v0.2.17: Add yt-dlp HTTP headers support (--add-header)
- Pass headers dict to yt-dlp as --add-header arguments
- Move 'linkkf' from ffmpeg_hls to ytdlp_aria2 downloader mapping
- Fix Linkkf CDN redirect issue (Referer header required)
2026-01-08 01:30:29 +09:00
ace56dfd73 v0.2.15: Fix queue item deletion bug 2026-01-07 23:34:55 +09:00
d6819447d7 v0.2.14: FFmpeg HLS resilience and aria2c multi-threading fixes 2026-01-07 22:35:58 +09:00
340ed8833e v0.2.12: Stabilization fix for ffmpeg_hls logging 2026-01-07 15:47:03 +09:00
2caed63d85 v0.2.10: Fix logging TypeError in ffmpeg_hls 2026-01-07 15:27:09 +09:00
d0dfef1445 v0.2.8: Fix callback AttributeError and enhance metadata display 2026-01-07 15:09:12 +09:00
68d12372ad Fix: Preserve expanded state during refresh, format created_time 2026-01-07 00:43:58 +09:00
890ed46e1c Fix: Force body background to Superhero theme (fixes white background on other themes) 2026-01-06 23:49:31 +09:00
bbdafb4ce0 Add click-to-expand detail panel on queue cards (start/end time, file size, path, URL) 2026-01-06 23:47:15 +09:00
77b37e8675 Force Superhero theme regardless of global FlaskFarm theme 2026-01-06 23:40:57 +09:00
f4b99f7d67 Fix: Change 'name' to 'title' in info.yaml for correct plugin list display 2026-01-06 22:47:29 +09:00
faaaecc236 Fix: Use abspath for robust package_name resolution (Docker/Linux fix) 2026-01-06 22:39:10 +09:00
22 changed files with 2567 additions and 110 deletions

View File

@@ -3,7 +3,46 @@
FlaskFarm용 범용 다운로드 매니저 플러그인입니다. FlaskFarm용 범용 다운로드 매니저 플러그인입니다.
여러 다운로더 플러그인(YouTube, Anime 등)의 다운로드 요청을 통합 관리하고 큐(Queue)를 제공합니다. 여러 다운로더 플러그인(YouTube, Anime 등)의 다운로드 요청을 통합 관리하고 큐(Queue)를 제공합니다.
## v0.2.0 변경사항 ## v0.2.30 변경사항 (2026-01-12)
- **자막 자동 다운로드 및 변환**: `ytdlp_aria2` 다운로더에 VTT 자막 다운로드 및 SRT 자동 변환 로직 내장.
- **경로 정규화 강화**: `output_template` 생성 시 중복 구분자(`//`, `\.\`)를 제거하여 경로 오염 방지.
- **다운로드 완료 지점 최적화**: 비디오 다운로드 성공 직후 자막 처리가 이어지도록 흐름 개선.
## v0.2.29 변경사항 (2026-01-11)
- **Anilife 고유 ID 지원**: 에피소드 고유 코드가 없는 경우 제목 기반 매칭 로직 보강.
## v0.2.24 변경사항 (2026-01-08)
- **Chrome 확장프로그램 추가**: YouTube에서 GDM으로 바로 다운로드 전송
- **Public API 추가**: `/public/youtube/formats`, `/public/youtube/add` (로그인 불필요)
- **진행률 표시 개선**: aria2c 다운로드 시 진행률 파싱 수정
- **카드 상태별 색상**: 완료(초록), 에러(빨강), 다운로드 중(파랑) 배경 구분
- **부분 DOM 업데이트**: 이미지 깜빡임 방지, 성능 최적화
- **소스 타입 수정**: ani24 → ohli24로 통일
- **FFmpeg HLS 안정성**: 비표준 m3u8 확장자(`.txt`) 지원 및 호환성 옵션 최적화
- **썸네일 버그 수정**: 외부 플러그인 위임 시 썸네일 누락 현상 수정
## v0.2.17 변경사항 (2026-01-08)
- **yt-dlp HTTP 헤더 지원**: `options.headers` 딕셔너리에서 `--add-header` 인자를 생성하여 Referer/User-Agent 등 커스텀 헤더를 yt-dlp에 전달합니다.
- **Linkkf CDN 리다이렉트 해결**: Referer 헤더 없이 m3u8 URL 접근 시 Google Cloud로 리다이렉트되던 문제 수정.
## v0.2.15 변경사항 (2026-01-08)
- **삭제 로직 버그 수정**: 메모리에 실시간으로 로드된 다운로드 항목을 삭제할 때, 메모리에서 먼저 제거되어 DB 데이터가 남던 순서 오류를 수정했습니다.
## 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` 수정.
## v0.2.8 변경사항 (2026-01-07)
- **콜백 시스템 개선**: `module_list`가 리스트 형태인 플러그인(애니 다운로더 등)과의 콜백 연동 호환성 해결 (`AttributeError` 수정).
- **메타데이터 강화**: 다운로드 시작/종료 시간 및 최종 파일 크기 추적 기능 추가.
- **UI 상세 정보 보강**: GDM 큐 목록에서 시작 시간, 종료 시간, 파일 크기를 상세 패널에 표시.
- **DB 정밀 동기화**: 다운로드 완료 시 실제 파일 크기를 DB에 영구 저장.
## v0.2.7 변경사항
- **패키지명 수정**: `gommi_download_manager` -> `gommi_downloader_manager`로 폴더명과 일치시켜 Bind Key 오류 해결. - **패키지명 수정**: `gommi_download_manager` -> `gommi_downloader_manager`로 폴더명과 일치시켜 Bind Key 오류 해결.
- **안정성 개선**: DB 테이블 생성 로직 강화 (`setup.py` 명시적 모델 import). - **안정성 개선**: DB 테이블 생성 로직 강화 (`setup.py` 명시적 모델 import).
- **YouTube 제목 지원**: `yt-dlp` 다운로드 시작 시 영상의 진짜 제목과 썸네일을 실시간으로 DB에 업데이트합니다. - **YouTube 제목 지원**: `yt-dlp` 다운로드 시작 시 영상의 진짜 제목과 썸네일을 실시간으로 DB에 업데이트합니다.

View File

@@ -0,0 +1,40 @@
# GDM YouTube Downloader Chrome Extension
YouTube 영상을 FlaskFarm GDM(gommi_downloader_manager)으로 전송하여 다운로드하는 Chrome 확장프로그램입니다.
## 설치 방법
1. Chrome에서 `chrome://extensions/` 접속
2. 우측 상단 **개발자 모드** 활성화
3. **압축해제된 확장 프로그램을 로드합니다** 클릭
4.`chrome_extension` 폴더 선택
## 사용 방법
### 팝업 UI
1. YouTube 영상 페이지에서 확장 아이콘 클릭
2. **GDM 서버** 주소 입력 (예: `http://192.168.1.100:9099`)
3. 원하는 **품질** 선택
4. **다운로드 시작** 클릭
### 페이지 버튼 (선택)
- YouTube 동영상 페이지에서 자동으로 **GDM** 버튼이 추가됩니다
- 버튼 클릭 시 최고 품질로 바로 다운로드 전송
### 우클릭 메뉴
- YouTube 페이지에서 우클릭 → **GDM으로 다운로드**
## API 엔드포인트
확장에서 사용하는 GDM API:
| 엔드포인트 | 용도 |
|-----------|------|
| `GET /gommi_downloader_manager/ajax/queue/youtube_formats?url=...` | 품질 목록 조회 |
| `POST /gommi_downloader_manager/ajax/queue/youtube_add` | 다운로드 추가 |
## 요구사항
- Chrome 88+ (Manifest V3)
- FlaskFarm + gommi_downloader_manager 플러그인
- yt-dlp 설치됨

View File

@@ -0,0 +1,85 @@
// GDM YouTube Downloader - Background Service Worker
// Handles extension lifecycle and context menu integration
// Context menu setup
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'gdm-download',
title: 'GDM으로 다운로드',
contexts: ['page', 'link'],
documentUrlPatterns: ['https://www.youtube.com/*', 'https://youtu.be/*']
});
});
// Context menu click handler
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === 'gdm-download') {
const url = info.linkUrl || tab.url;
// Open popup or send directly
const stored = await chrome.storage.local.get(['serverUrl']);
const serverUrl = (stored.serverUrl || 'http://localhost:9099').replace(/\/$/, '');
try {
const response = await fetch(
`${serverUrl}/gommi_downloader_manager/ajax/queue/youtube_add`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: url,
format: 'bestvideo+bestaudio/best'
})
}
);
const data = await response.json();
if (data.ret === 'success') {
// Show notification
chrome.notifications.create({
type: 'basic',
iconUrl: 'icons/icon128.png',
title: 'GDM 다운로드',
message: '다운로드가 추가되었습니다!'
});
}
} catch (error) {
console.error('GDM download error:', error);
chrome.notifications.create({
type: 'basic',
iconUrl: 'icons/icon128.png',
title: 'GDM 오류',
message: '서버 연결 실패: ' + error.message
});
}
}
});
// Message handler for content script
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'download') {
handleDownload(request.url, request.format).then(sendResponse);
return true; // Async response
}
});
async function handleDownload(url, format = 'bestvideo+bestaudio/best') {
const stored = await chrome.storage.local.get(['serverUrl']);
const serverUrl = (stored.serverUrl || 'http://localhost:9099').replace(/\/$/, '');
try {
const response = await fetch(
`${serverUrl}/gommi_downloader_manager/ajax/queue/youtube_add`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, format })
}
);
return await response.json();
} catch (error) {
return { ret: 'error', msg: error.message };
}
}

View File

@@ -0,0 +1,38 @@
/* GDM YouTube Downloader - Content Script Styles */
.gdm-yt-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
margin-left: 8px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
border: none;
border-radius: 18px;
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-family: 'Roboto', Arial, sans-serif;
}
.gdm-yt-btn:hover {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.gdm-yt-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.gdm-yt-btn .gdm-icon {
font-size: 14px;
}
.gdm-yt-btn .gdm-text {
font-weight: 500;
}

View File

@@ -0,0 +1,69 @@
// GDM YouTube Downloader - Content Script
// Optional: Inject download button on YouTube page
(function() {
'use strict';
// Check if we're on a YouTube video page
if (!window.location.href.includes('youtube.com/watch')) {
return;
}
// Wait for YouTube player to load
const observer = new MutationObserver((mutations, obs) => {
const actionBar = document.querySelector('#top-level-buttons-computed');
if (actionBar && !document.getElementById('gdm-download-btn')) {
injectButton(actionBar);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
function injectButton(container) {
const btn = document.createElement('button');
btn.id = 'gdm-download-btn';
btn.className = 'gdm-yt-btn';
btn.innerHTML = `
<span class="gdm-icon">⬇️</span>
<span class="gdm-text">GDM</span>
`;
btn.title = 'GDM으로 다운로드';
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
btn.disabled = true;
btn.innerHTML = '<span class="gdm-icon">⏳</span><span class="gdm-text">전송중</span>';
try {
const response = await chrome.runtime.sendMessage({
action: 'download',
url: window.location.href
});
if (response && response.ret === 'success') {
btn.innerHTML = '<span class="gdm-icon">✅</span><span class="gdm-text">완료</span>';
setTimeout(() => {
btn.innerHTML = '<span class="gdm-icon">⬇️</span><span class="gdm-text">GDM</span>';
btn.disabled = false;
}, 2000);
} else {
throw new Error(response?.msg || 'Unknown error');
}
} catch (error) {
btn.innerHTML = '<span class="gdm-icon">❌</span><span class="gdm-text">실패</span>';
console.error('GDM Error:', error);
setTimeout(() => {
btn.innerHTML = '<span class="gdm-icon">⬇️</span><span class="gdm-text">GDM</span>';
btn.disabled = false;
}, 2000);
}
});
container.appendChild(btn);
}
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,41 @@
{
"manifest_version": 3,
"name": "GDM YouTube Downloader",
"version": "1.0.0",
"description": "YouTube 영상을 GDM(gommi_downloader_manager)으로 전송하여 다운로드",
"permissions": [
"activeTab",
"storage",
"contextMenus",
"notifications"
],
"host_permissions": [
"https://www.youtube.com/*",
"https://youtu.be/*",
"http://*/*",
"https://*/*"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://www.youtube.com/*"],
"js": ["content.js"],
"css": ["content.css"]
}
]
}

263
chrome_extension/popup.css Normal file
View File

@@ -0,0 +1,263 @@
/* GDM YouTube Downloader - Popup Styles */
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--success: #10b981;
--error: #ef4444;
--bg-dark: #0f172a;
--bg-card: #1e293b;
--text: #e2e8f0;
--text-muted: #94a3b8;
--border: rgba(255, 255, 255, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 360px;
min-height: 400px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-dark);
color: var(--text);
}
.container {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
header {
display: flex;
align-items: center;
gap: 8px;
}
header h1 {
font-size: 18px;
font-weight: 600;
}
.section {
display: flex;
flex-direction: column;
gap: 12px;
}
.hidden {
display: none !important;
}
/* Loading */
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Video Info */
.video-info {
display: flex;
gap: 12px;
padding: 12px;
background: var(--bg-card);
border-radius: 12px;
border: 1px solid var(--border);
}
.video-info img {
width: 120px;
height: 68px;
object-fit: cover;
border-radius: 8px;
}
.video-meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.video-meta h3 {
font-size: 14px;
font-weight: 500;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.duration {
font-size: 12px;
color: var(--text-muted);
}
/* Quality Section */
.quality-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.quality-section label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.quality-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.quality-option {
padding: 10px 12px;
background: var(--bg-card);
border: 2px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.quality-option:hover {
border-color: var(--primary);
background: rgba(59, 130, 246, 0.1);
}
.quality-option.selected {
border-color: var(--primary);
background: rgba(59, 130, 246, 0.2);
}
.quality-option .label {
font-size: 13px;
font-weight: 600;
}
.quality-option .note {
font-size: 11px;
color: var(--text-muted);
}
/* Server Section */
.server-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.server-section label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.server-section input {
padding: 10px 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
}
.server-section input:focus {
outline: none;
border-color: var(--primary);
}
.server-section small {
font-size: 11px;
color: var(--text-muted);
}
/* Buttons */
.btn {
padding: 12px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
color: white;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
}
/* Status */
.status {
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
text-align: center;
}
.status.success {
background: rgba(16, 185, 129, 0.2);
color: var(--success);
}
.status.error {
background: rgba(239, 68, 68, 0.2);
color: var(--error);
}
.status.info {
background: rgba(59, 130, 246, 0.2);
color: var(--primary);
}
/* Error */
.error-text {
color: var(--error);
text-align: center;
}
footer {
text-align: center;
color: var(--text-muted);
padding-top: 8px;
border-top: 1px solid var(--border);
}

View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>GDM YouTube Downloader</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<header>
<h1>🎬 GDM Downloader</h1>
</header>
<div id="content">
<!-- 로딩 -->
<div id="loading" class="section">
<div class="spinner"></div>
<p>영상 정보 불러오는 중...</p>
</div>
<!-- 에러 -->
<div id="error" class="section hidden">
<p class="error-text" id="error-message">오류 발생</p>
<button id="retry-btn" class="btn btn-secondary">다시 시도</button>
</div>
<!-- 유튜브가 아닌 경우 -->
<div id="not-youtube" class="section hidden">
<p>⚠️ YouTube 페이지에서만 사용 가능합니다.</p>
</div>
<!-- 메인 UI -->
<div id="main" class="section hidden">
<!-- 영상 정보 -->
<div class="video-info">
<img id="thumbnail" src="" alt="thumbnail">
<div class="video-meta">
<h3 id="video-title">제목</h3>
<span id="video-duration" class="duration"></span>
</div>
</div>
<!-- 품질 선택 -->
<div class="quality-section">
<label>품질 선택</label>
<div id="quality-options" class="quality-grid">
<!-- JS로 동적 생성 -->
</div>
</div>
<!-- 서버 설정 -->
<div class="server-section">
<label>GDM 서버</label>
<input type="text" id="server-url" placeholder="http://localhost:9099">
<small>FlaskFarm 서버 주소</small>
</div>
<!-- 다운로드 버튼 -->
<button id="download-btn" class="btn btn-primary">
<span class="btn-icon">⬇️</span>
다운로드 시작
</button>
<!-- 상태 메시지 -->
<div id="status" class="status hidden"></div>
</div>
</div>
<footer>
<small>GDM v1.0</small>
</footer>
</div>
<script src="popup.js"></script>
</body>
</html>

199
chrome_extension/popup.js Normal file
View File

@@ -0,0 +1,199 @@
// GDM YouTube Downloader - Popup Script
const DEFAULT_SERVER = 'http://localhost:9099';
let currentUrl = '';
let selectedFormat = 'bestvideo+bestaudio/best';
// DOM Elements
const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error');
const notYoutubeEl = document.getElementById('not-youtube');
const mainEl = document.getElementById('main');
const thumbnailEl = document.getElementById('thumbnail');
const titleEl = document.getElementById('video-title');
const durationEl = document.getElementById('video-duration');
const qualityOptionsEl = document.getElementById('quality-options');
const serverUrlEl = document.getElementById('server-url');
const downloadBtn = document.getElementById('download-btn');
const statusEl = document.getElementById('status');
const retryBtn = document.getElementById('retry-btn');
const errorMessageEl = document.getElementById('error-message');
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
// Load saved server URL
const stored = await chrome.storage.local.get(['serverUrl']);
serverUrlEl.value = stored.serverUrl || DEFAULT_SERVER;
// Get current tab URL
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentUrl = tab.url;
// Check if YouTube
if (!isYouTubeUrl(currentUrl)) {
showSection('not-youtube');
return;
}
// Fetch video info
fetchVideoInfo();
});
// Event Listeners
downloadBtn.addEventListener('click', startDownload);
retryBtn.addEventListener('click', fetchVideoInfo);
serverUrlEl.addEventListener('change', saveServerUrl);
function isYouTubeUrl(url) {
return url && (url.includes('youtube.com/watch') || url.includes('youtu.be/'));
}
function showSection(section) {
loadingEl.classList.add('hidden');
errorEl.classList.add('hidden');
notYoutubeEl.classList.add('hidden');
mainEl.classList.add('hidden');
switch (section) {
case 'loading': loadingEl.classList.remove('hidden'); break;
case 'error': errorEl.classList.remove('hidden'); break;
case 'not-youtube': notYoutubeEl.classList.remove('hidden'); break;
case 'main': mainEl.classList.remove('hidden'); break;
}
}
function showStatus(message, type = 'info') {
statusEl.textContent = message;
statusEl.className = `status ${type}`;
statusEl.classList.remove('hidden');
if (type === 'success') {
setTimeout(() => statusEl.classList.add('hidden'), 3000);
}
}
function hideStatus() {
statusEl.classList.add('hidden');
}
function formatDuration(seconds) {
if (!seconds) return '';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
async function fetchVideoInfo() {
showSection('loading');
hideStatus();
const serverUrl = serverUrlEl.value.replace(/\/$/, '');
try {
const response = await fetch(
`${serverUrl}/gommi_downloader_manager/public/youtube/formats?url=${encodeURIComponent(currentUrl)}`,
{ method: 'GET' }
);
const data = await response.json();
if (data.ret !== 'success') {
throw new Error(data.msg || '영상 정보를 가져올 수 없습니다.');
}
// Display video info
titleEl.textContent = data.title || '제목 없음';
thumbnailEl.src = data.thumbnail || '';
durationEl.textContent = formatDuration(data.duration);
// Render quality options
renderQualityOptions(data.formats || []);
showSection('main');
} catch (error) {
console.error('Fetch error:', error);
errorMessageEl.textContent = error.message || '서버 연결 실패';
showSection('error');
}
}
function renderQualityOptions(formats) {
qualityOptionsEl.innerHTML = '';
if (formats.length === 0) {
// Default options
formats = [
{ id: 'bestvideo+bestaudio/best', label: '최고 품질', note: '' },
{ id: 'bestvideo[height<=1080]+bestaudio/best', label: '1080p', note: '권장' },
{ id: 'bestvideo[height<=720]+bestaudio/best', label: '720p', note: '' },
{ id: 'bestaudio/best', label: '오디오만', note: '' }
];
}
formats.forEach((format, index) => {
const option = document.createElement('div');
option.className = 'quality-option' + (index === 0 ? ' selected' : '');
option.dataset.format = format.id;
option.innerHTML = `
<div class="label">${format.label}</div>
${format.note ? `<div class="note">${format.note}</div>` : ''}
`;
option.addEventListener('click', () => selectQuality(option, format.id));
qualityOptionsEl.appendChild(option);
});
// Select first by default
if (formats.length > 0) {
selectedFormat = formats[0].id;
}
}
function selectQuality(element, formatId) {
document.querySelectorAll('.quality-option').forEach(el => el.classList.remove('selected'));
element.classList.add('selected');
selectedFormat = formatId;
}
async function startDownload() {
downloadBtn.disabled = true;
downloadBtn.innerHTML = '<span class="btn-icon">⏳</span> 전송 중...';
hideStatus();
const serverUrl = serverUrlEl.value.replace(/\/$/, '');
try {
const response = await fetch(
`${serverUrl}/gommi_downloader_manager/public/youtube/add`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: currentUrl,
format: selectedFormat
})
}
);
const data = await response.json();
if (data.ret === 'success') {
showStatus('✅ 다운로드가 추가되었습니다!', 'success');
} else {
throw new Error(data.msg || '다운로드 추가 실패');
}
} catch (error) {
console.error('Download error:', error);
showStatus('❌ ' + (error.message || '전송 실패'), 'error');
} finally {
downloadBtn.disabled = false;
downloadBtn.innerHTML = '<span class="btn-icon">⬇️</span> 다운로드 시작';
}
}
async function saveServerUrl() {
await chrome.storage.local.set({ serverUrl: serverUrlEl.value });
}

View File

@@ -8,11 +8,11 @@ from .base import BaseDownloader
def get_downloader(source_type: str) -> Optional[BaseDownloader]: def get_downloader(source_type: str) -> Optional[BaseDownloader]:
"""소스 타입에 맞는 다운로더 인스턴스 반환""" """소스 타입에 맞는 다운로더 인스턴스 반환"""
if source_type in ('youtube', 'general'): if source_type in ('youtube', 'general', 'linkkf'):
from .ytdlp_aria2 import YtdlpAria2Downloader from .ytdlp_aria2 import YtdlpAria2Downloader
return YtdlpAria2Downloader() return YtdlpAria2Downloader()
elif source_type in ('ani24', 'linkkf', 'hls'): elif source_type in ('ohli24', 'ani24', 'hls'):
from .ffmpeg_hls import FfmpegHlsDownloader from .ffmpeg_hls import FfmpegHlsDownloader
return FfmpegHlsDownloader() return FfmpegHlsDownloader()

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']
@@ -80,6 +83,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 & Allowed extensions for non-standard m3u8 like .txt)
cmd.extend([
'-allowed_extensions', 'ALL',
'-reconnect', '1',
'-reconnect_at_eof', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5'
])
# 입력 URL # 입력 URL
cmd.extend(['-i', url]) cmd.extend(['-i', url])
@@ -89,7 +101,9 @@ class FfmpegHlsDownloader(BaseDownloader):
# 출력 파일 # 출력 파일
cmd.append(filepath) cmd.append(filepath)
logger.debug(f'ffmpeg 명령어: {" ".join(cmd[:15])}...') # 92라인 수정: cmd 리스트 내의 None 요소를 빈 문자열로 변환하거나 걸러내기
safe_cmd = [str(x) if x is not None else "" for x in cmd]
logger.debug(f'ffmpeg 명령어: {" ".join(safe_cmd[:15])}...')
# 먼저 duration 얻기 위해 ffprobe 실행 # 먼저 duration 얻기 위해 ffprobe 실행
duration = self._get_duration(url, options.get('ffprobe_path', 'ffprobe'), headers) duration = self._get_duration(url, options.get('ffprobe_path', 'ffprobe'), headers)
@@ -162,12 +176,22 @@ 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로 영상 길이 획득"""
try: try:
cmd = [ffprobe_path, '-v', 'error', '-show_entries', 'format=duration', cmd = [ffprobe_path, '-v', 'error', '-allowed_extensions', 'ALL',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1'] '-of', 'default=noprint_wrappers=1:nokey=1']
if headers: if headers:

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
@@ -20,6 +22,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,
url: str, url: str,
@@ -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):
@@ -62,6 +83,13 @@ class HttpDirectDownloader(BaseDownloader):
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)
speed = '' # TODO: 속도 계산 speed = '' # TODO: 속도 계산

View File

@@ -27,6 +27,19 @@ class YtdlpAria2Downloader(BaseDownloader):
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,
url: str, url: str,
@@ -40,11 +53,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,19 +73,37 @@ 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)
# 속도 제한 설정 if self._check_aria2c(aria2c_path):
max_rate = P.ModelSetting.get('max_download_rate') cmd.extend(['--external-downloader', aria2c_path])
if max_rate == '0': # aria2c 설정: -x=연결수, -s=분할수, -j=병렬, -k=조각크기, --console-log-level=notice로 진행률 출력
max_rate_arg = '' aria2_args = f'aria2c:-x{connections} -s{connections} -j{connections} -k1M --summary-interval=1 --console-log-level=notice'
log_rate_msg = '무제한' 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})')
# 진행률 템플릿 추가 (yt-dlp native downloader)
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 미사용/보조 경로)
if rate_limited:
cmd.extend(['--limit-rate', max_rate])
if options.get('is_global_rate_split'):
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')
@@ -94,6 +127,11 @@ class YtdlpAria2Downloader(BaseDownloader):
if options.get('proxy'): if options.get('proxy'):
cmd.extend(['--proxy', options['proxy']]) cmd.extend(['--proxy', options['proxy']])
# HTTP 헤더 추가 (Referer 등 - Linkkf 등 리다이렉트 방지용)
if options.get('headers'):
for key, value in options['headers'].items():
cmd.extend(['--add-header', f'{key}:{value}'])
# FFmpeg 경로 자동 감지 및 설정 # FFmpeg 경로 자동 감지 및 설정
ffmpeg_path = options.get('ffmpeg_path') or P.ModelSetting.get('ffmpeg_path') ffmpeg_path = options.get('ffmpeg_path') or P.ModelSetting.get('ffmpeg_path')
@@ -133,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)
@@ -182,6 +212,24 @@ class YtdlpAria2Downloader(BaseDownloader):
except: except:
pass pass
# 진행률 파싱 - GDM_PROGRESS 템플릿 (우선)
# 형식: GDM_PROGRESS:XX.X%:SPEED:ETA
if 'GDM_PROGRESS:' in line:
try:
parts = line.split('GDM_PROGRESS:', 1)[1].split(':')
if len(parts) >= 1:
pct_str = parts[0].strip().replace('%', '').strip()
progress = int(float(pct_str)) if pct_str and pct_str != 'N/A' else 0
speed = parts[1].strip() if len(parts) > 1 else ''
eta = parts[2].strip() if len(parts) > 2 else ''
if speed == 'N/A': speed = ''
if eta == 'N/A': eta = ''
if progress_callback and progress > 0:
progress_callback(progress, speed, eta)
continue
except:
pass
# 진행률 파싱 (yt-dlp default) # 진행률 파싱 (yt-dlp default)
progress_match = re.search(r'\[download\]\s+(\d+\.?\d*)%', line) progress_match = re.search(r'\[download\]\s+(\d+\.?\d*)%', line)
@@ -235,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}'}
@@ -273,7 +330,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 설치 확인"""
@@ -286,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 @@
name: GDM title: "GDM"
package_name: gommi_downloader_manager package_name: gommi_downloader_manager
version: '0.2.1' 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,11 +48,43 @@ 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
_latest_version = None
def __init__(self, P: Any) -> None: def __init__(self, P: Any) -> None:
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:
@@ -142,6 +176,208 @@ class ModuleQueue(PluginModuleBase):
ret['msg'] = '목록을 초기화했습니다.' ret['msg'] = '목록을 초기화했습니다.'
elif command == 'delete':
# 특정 항목 완전 삭제 (메모리 + DB)
download_id = req.form.get('id', '')
db_id_to_delete = None
# 1. DB ID 추출 및 메모리 정리
if download_id in self._downloads:
task = self._downloads[download_id]
if hasattr(task, 'db_id'):
db_id_to_delete = task.db_id
task.cancel()
del self._downloads[download_id]
# 2. DB에서 삭제 처리
if download_id.startswith('db_'):
db_id_to_delete = int(download_id.replace('db_', ''))
if db_id_to_delete:
try:
from .model import ModelDownloadItem
with F.app.app_context():
F.db.session.query(ModelDownloadItem).filter_by(id=db_id_to_delete).delete()
F.db.session.commit()
self.P.logger.info(f"Deleted DB item: {db_id_to_delete}")
except Exception as e:
self.P.logger.error(f'DB Delete Error: {e}')
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 =====
elif command == 'youtube_add':
# Chrome 확장에서 YouTube 다운로드 요청
import json
from .setup import P, ToolUtil
# JSON 또는 Form 데이터 처리
if req.is_json:
data = req.get_json()
else:
data = req.form.to_dict()
url = data.get('url', '')
if not url:
ret['ret'] = 'error'
ret['msg'] = 'URL이 필요합니다.'
return jsonify(ret)
# YouTube URL 검증
if 'youtube.com' not in url and 'youtu.be' not in url:
ret['ret'] = 'error'
ret['msg'] = '유효한 YouTube URL이 아닙니다.'
return jsonify(ret)
format_id = data.get('format', 'bestvideo+bestaudio/best')
save_path = data.get('path') or ToolUtil.make_path(self.P.ModelSetting.get('save_path'))
# 다운로드 추가
item = self.add_download(
url=url,
save_path=save_path,
source_type='youtube',
caller_plugin='chrome_extension',
format=format_id
)
if item:
ret['id'] = item.id
ret['msg'] = '다운로드가 추가되었습니다.'
else:
ret['ret'] = 'error'
ret['msg'] = '다운로드 추가 실패'
elif command == 'youtube_formats':
# YouTube 영상 품질 목록 조회
url = req.args.get('url') or req.form.get('url', '')
if not url:
ret['ret'] = 'error'
ret['msg'] = 'URL이 필요합니다.'
return jsonify(ret)
try:
import yt_dlp
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': False,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
ret['title'] = info.get('title', '')
ret['thumbnail'] = info.get('thumbnail', '')
ret['duration'] = info.get('duration', 0)
# 품질 목록 생성
formats = []
# 미리 정의된 품질 옵션들
formats.append({'id': 'bestvideo+bestaudio/best', 'label': '최고 품질', 'note': '자동 선택'})
# 실제 포맷에서 해상도 추출
available_heights = set()
for f in info.get('formats', []):
height = f.get('height')
if height and f.get('vcodec') != 'none':
available_heights.add(height)
# 해상도별 옵션 추가
for height in sorted(available_heights, reverse=True):
if height >= 2160:
formats.append({'id': f'bestvideo[height<=2160]+bestaudio/best', 'label': '4K (2160p)', 'note': '고용량'})
elif height >= 1440:
formats.append({'id': f'bestvideo[height<=1440]+bestaudio/best', 'label': '2K (1440p)', 'note': ''})
elif height >= 1080:
formats.append({'id': f'bestvideo[height<=1080]+bestaudio/best', 'label': 'FHD (1080p)', 'note': '권장'})
elif height >= 720:
formats.append({'id': f'bestvideo[height<=720]+bestaudio/best', 'label': 'HD (720p)', 'note': ''})
elif height >= 480:
formats.append({'id': f'bestvideo[height<=480]+bestaudio/best', 'label': 'SD (480p)', 'note': '저용량'})
# 오디오 전용 옵션
formats.append({'id': 'bestaudio/best', 'label': '오디오만', 'note': 'MP3 변환'})
# 중복 제거
seen = set()
unique_formats = []
for f in formats:
if f['id'] not in seen:
seen.add(f['id'])
unique_formats.append(f)
ret['formats'] = unique_formats
except Exception as e:
self.P.logger.error(f'YouTube format extraction error: {e}')
ret['ret'] = 'error'
ret['msg'] = str(e)
elif command == 'self_update':
# 자가 업데이트 (Git Pull) 및 모듈 리로드
try:
import subprocess
plugin_path = os.path.dirname(__file__)
self.P.logger.info(f"GDM 자가 업데이트 시작: {plugin_path}")
# 1. Git Pull
cmd = ['git', '-C', plugin_path, 'pull']
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
raise Exception(f"Git pull 실패: {result.stderr}")
self.P.logger.info(f"Git pull 결과: {result.stdout}")
stdout = result.stdout
# 2. 모듈 리로드 (Hot-Reload)
self.reload_plugin()
ret['msg'] = f"업데이트 및 리로드 완료!<br><pre>{stdout}</pre>"
ret['data'] = stdout
except Exception as e:
self.P.logger.error(f"GDM 자가 업데이트 중 오류: {str(e)}")
self.P.logger.error(traceback.format_exc())
ret['ret'] = 'danger'
ret['msg'] = f"업데이트 실패: {str(e)}"
elif command == 'check_update':
# 업데이트 확인
force = req.form.get('force') == 'true'
ret['data'] = self.get_update_info(force=force)
except Exception as e: except Exception as e:
self.P.logger.error(f'Exception:{str(e)}') self.P.logger.error(f'Exception:{str(e)}')
self.P.logger.error(traceback.format_exc()) self.P.logger.error(traceback.format_exc())
@@ -252,14 +488,14 @@ class ModuleQueue(PluginModuleBase):
if caller_plugin: if caller_plugin:
cp_lower = caller_plugin.lower() cp_lower = caller_plugin.lower()
if 'anilife' in cp_lower: return 'anilife' if 'anilife' in cp_lower: return 'anilife'
if 'ohli24' in cp_lower or 'ani24' in cp_lower: return 'ani24' if 'ohli24' in cp_lower or 'ani24' in cp_lower: return 'ohli24'
if 'linkkf' in cp_lower: return 'linkkf' if 'linkkf' in cp_lower: return 'linkkf'
if 'youtube' in cp_lower: return 'youtube' if 'youtube' in cp_lower: return 'youtube'
# 2. 메타데이터 기반 판단 # 2. 메타데이터 기반 판단
if meta and meta.get('source'): if meta and meta.get('source'):
ms_lower = meta.get('source').lower() ms_lower = meta.get('source').lower()
if ms_lower in ['ani24', 'ohli24']: return 'ani24' if ms_lower in ['ani24', 'ohli24']: return 'ohli24'
if ms_lower == 'anilife': return 'anilife' if ms_lower == 'anilife': return 'anilife'
if ms_lower == 'linkkf': return 'linkkf' if ms_lower == 'linkkf': return 'linkkf'
@@ -267,7 +503,7 @@ class ModuleQueue(PluginModuleBase):
if 'youtube.com' in url_lower or 'youtu.be' in url_lower: if 'youtube.com' in url_lower or 'youtu.be' in url_lower:
return 'youtube' return 'youtube'
elif 'ani24' in url_lower or 'ohli24' in url_lower: elif 'ani24' in url_lower or 'ohli24' in url_lower:
return 'ani24' return 'ohli24'
elif 'linkkf' in url_lower: elif 'linkkf' in url_lower:
return 'linkkf' return 'linkkf'
elif 'anilife' in url_lower: elif 'anilife' in url_lower:
@@ -280,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():
@@ -334,6 +571,96 @@ class ModuleQueue(PluginModuleBase):
for task in self._downloads.values(): for task in self._downloads.values():
task.cancel() task.cancel()
def get_update_info(self, force=False):
"""GitHub에서 최신 버전 정보 가져오기 (캐싱 활용)"""
import requests
now = time.time()
# 실제 로컬 파일에서 현재 버전 읽기
current_version = self.P.plugin_info.get('version', '0.0.0')
try:
info_path = os.path.join(os.path.dirname(__file__), 'info.yaml')
if os.path.exists(info_path):
import yaml
with open(info_path, 'r', encoding='utf-8') as f:
local_info = yaml.safe_load(f)
current_version = str(local_info.get('version', current_version))
except: pass
# 1시간마다 체크 (force=True면 즉시)
if not force and ModuleQueue._latest_version and (now - ModuleQueue._last_update_check < 3600):
return {
'current': current_version,
'latest': ModuleQueue._latest_version,
'has_update': self._is_newer(ModuleQueue._latest_version, current_version)
}
try:
url = "https://raw.githubusercontent.com/projectdx75/gommi_downloader_manager/master/info.yaml"
res = requests.get(url, timeout=5)
if res.status_code == 200:
import yaml
data = yaml.safe_load(res.text)
ModuleQueue._latest_version = str(data.get('version', ''))
ModuleQueue._last_update_check = now
return {
'current': current_version,
'latest': ModuleQueue._latest_version,
'has_update': self._is_newer(ModuleQueue._latest_version, current_version)
}
except Exception as e:
self.P.logger.error(f"Update check failed: {e}")
return {
'current': current_version,
'latest': ModuleQueue._latest_version or current_version,
'has_update': False
}
def _is_newer(self, latest, current):
"""버전 비교 (0.7.8 vs 0.7.7)"""
if not latest or not current: return False
try:
l_parts = [int(p) for p in latest.split('.')]
c_parts = [int(p) for p in current.split('.')]
return l_parts > c_parts
except:
return latest != current
def reload_plugin(self):
"""플러그인 모듈 핫 리로드"""
import sys
import importlib
try:
package_name = self.P.package_name
self.P.logger.info(f"플러그인 리로드 시작: {package_name}")
# 1. 관련 모듈 찾기 및 리로드
modules_to_reload = []
for module_name in list(sys.modules.keys()):
if module_name.startswith(package_name):
modules_to_reload.append(module_name)
# 의존성 역순으로 정렬 (깊은 모듈 먼저)
modules_to_reload.sort(key=lambda x: x.count('.'), reverse=True)
for module_name in modules_to_reload:
try:
module = sys.modules[module_name]
importlib.reload(module)
self.P.logger.debug(f"Reloaded: {module_name}")
except Exception as e:
self.P.logger.warning(f"Failed to reload {module_name}: {e}")
self.P.logger.info(f"플러그인 모듈 [{package_name}] 리로드 완료")
return True
except Exception as e:
self.P.logger.error(f"모듈 리로드 중 실패: {str(e)}")
self.P.logger.error(traceback.format_exc())
return False
class DownloadTask: class DownloadTask:
"""개별 다운로드 태스크""" """개별 다운로드 태스크"""
@@ -385,9 +712,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
@@ -396,16 +721,47 @@ class DownloadTask:
self._downloader = None self._downloader = None
self._cancelled = False self._cancelled = False
self.db_id: Optional[int] = None 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): def start(self):
"""다운로드 시작 (비동기)""" """다운로드 시작 (비동기)"""
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:
self.start_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
self._emit_status() self._emit_status()
# 다운로더 선택 및 실행 # 다운로더 선택 및 실행
@@ -415,9 +771,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(
url=self.url, url=self.url,
@@ -425,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:
@@ -434,13 +840,17 @@ class DownloadTask:
self.status = DownloadStatus.COMPLETED self.status = DownloadStatus.COMPLETED
self.filepath = result.get('filepath', '') self.filepath = result.get('filepath', '')
self.progress = 100 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 업데이트 # DB 업데이트
self._update_db_status() self._update_db_status()
# 실시간 콜백 처리 # 실시간 콜백 처리
if self._on_complete: 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: if self.caller_plugin and self.callback_id:
@@ -465,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 = ''):
@@ -566,6 +981,7 @@ class DownloadTask:
item.status = self.status item.status = self.status
if self.status == DownloadStatus.COMPLETED: if self.status == DownloadStatus.COMPLETED:
item.completed_time = datetime.now() item.completed_time = datetime.now()
item.filesize = self.filesize
if self.error_message: if self.error_message:
item.error_message = self.error_message item.error_message = self.error_message
F.db.session.add(item) F.db.session.add(item)
@@ -605,7 +1021,27 @@ class DownloadTask:
if target_P: if target_P:
# 모듈에서 콜백 메서드 찾기 # 모듈에서 콜백 메서드 찾기
callback_invoked = False 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 = []
# 모듈명 추출 (예: anime_downloader_linkkf -> linkkf)
target_module_name = None
if len(parts) > 1:
target_module_name = parts[-1]
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,
@@ -616,7 +1052,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:
@@ -647,4 +1085,12 @@ class DownloadTask:
'caller_plugin': self.caller_plugin, 'caller_plugin': self.caller_plugin,
'callback_id': self.callback_id, 'callback_id': self.callback_id,
'db_id': self.db_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,
} }
def as_dict(self) -> Dict[str, Any]:
"""데이터 직렬화 (get_status 별칭)"""
return self.get_status()

View File

@@ -7,7 +7,7 @@ import os
from datetime import datetime from datetime import datetime
# 패키지 이름 동적 처리 (폴더명 기준) # 패키지 이름 동적 처리 (폴더명 기준)
package_name = os.path.split(os.path.dirname(__file__))[-1] package_name = os.path.basename(os.path.dirname(os.path.abspath(__file__)))
class ModelDownloadItem(ModelBase): class ModelDownloadItem(ModelBase):
"""다운로드 아이템 DB 모델""" """다운로드 아이템 DB 모델"""
@@ -57,6 +57,10 @@ class ModelDownloadItem(ModelBase):
ret['meta'] = {} ret['meta'] = {}
else: else:
ret['meta'] = {} 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 return ret
@classmethod @classmethod

View File

@@ -15,7 +15,7 @@ gommi_download_manager - FlaskFarm 범용 다운로더 큐 플러그인
import traceback import traceback
import os import os
package_name = os.path.split(os.path.dirname(__file__))[-1] package_name = os.path.basename(os.path.dirname(os.path.abspath(__file__)))
setting = { setting = {
'filepath': __file__, 'filepath': __file__,
@@ -65,3 +65,98 @@ try:
except Exception as e: except Exception as e:
P.logger.error(f'Exception:{str(e)}') P.logger.error(f'Exception:{str(e)}')
P.logger.error(traceback.format_exc()) P.logger.error(traceback.format_exc())
# ===== Public API for Chrome Extension (No Login Required) =====
try:
from flask import Blueprint, request, jsonify
public_api = Blueprint(f'{package_name}_public_api', package_name, url_prefix=f'/{package_name}/public')
@public_api.route('/youtube/formats', methods=['GET', 'POST'])
def youtube_formats():
"""YouTube 품질 목록 조회 (인증 불필요)"""
url = request.args.get('url') or request.form.get('url', '')
if not url:
return jsonify({'ret': 'error', 'msg': 'URL이 필요합니다.'})
try:
import yt_dlp
ydl_opts = {'quiet': True, 'no_warnings': True}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
formats = [{'id': 'bestvideo+bestaudio/best', 'label': '최고 품질', 'note': ''}]
heights = set()
for f in info.get('formats', []):
h = f.get('height')
if h and f.get('vcodec') != 'none':
heights.add(h)
for h in sorted(heights, reverse=True):
if h >= 2160: formats.append({'id': 'bestvideo[height<=2160]+bestaudio/best', 'label': '4K', 'note': ''})
elif h >= 1080: formats.append({'id': 'bestvideo[height<=1080]+bestaudio/best', 'label': '1080p', 'note': '권장'})
elif h >= 720: formats.append({'id': 'bestvideo[height<=720]+bestaudio/best', 'label': '720p', 'note': ''})
formats.append({'id': 'bestaudio/best', 'label': '오디오만', 'note': ''})
# 중복 제거
seen, unique = set(), []
for f in formats:
if f['id'] not in seen:
seen.add(f['id'])
unique.append(f)
return jsonify({
'ret': 'success',
'title': info.get('title', ''),
'thumbnail': info.get('thumbnail', ''),
'duration': info.get('duration', 0),
'formats': unique
})
except Exception as e:
return jsonify({'ret': 'error', 'msg': str(e)})
@public_api.route('/youtube/add', methods=['POST'])
def youtube_add():
"""YouTube 다운로드 추가 (인증 불필요)"""
try:
if request.is_json:
data = request.get_json()
else:
data = request.form.to_dict()
url = data.get('url', '')
if not url or ('youtube.com' not in url and 'youtu.be' not in url):
return jsonify({'ret': 'error', 'msg': '유효한 YouTube URL이 필요합니다.'})
format_id = data.get('format', 'bestvideo+bestaudio/best')
from framework import F
from tool import ToolUtil
save_path = ToolUtil.make_path(P.ModelSetting.get('save_path'))
item = ModuleQueue.add_download(
url=url,
save_path=save_path,
source_type='youtube',
caller_plugin='chrome_extension',
format=format_id
)
if item:
return jsonify({'ret': 'success', 'id': item.id, 'msg': '다운로드가 추가되었습니다.'})
else:
return jsonify({'ret': 'error', 'msg': '다운로드 추가 실패'})
except Exception as e:
P.logger.error(f'Public API youtube_add error: {e}')
return jsonify({'ret': 'error', 'msg': str(e)})
# Blueprint 등록
from framework import F
F.app.register_blueprint(public_api)
P.logger.info(f'Public API registered: /{package_name}/public/')
except Exception as e:
P.logger.warning(f'Public API registration failed: {e}')

File diff suppressed because it is too large Load Diff

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);
@@ -196,6 +254,9 @@
<div class="page-header"> <div class="page-header">
<h1 class="page-title">GDM Settings</h1> <h1 class="page-title">GDM Settings</h1>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-premium" id="btn-self-update" style="background: var(--accent-secondary); border-color: var(--accent-secondary);">
<i class="fa fa-refresh"></i> Update
</button>
<button type="button" class="btn-premium" id="globalSettingSaveBtn"> <button type="button" class="btn-premium" id="globalSettingSaveBtn">
<i class="fa fa-save"></i> Save Changes <i class="fa fa-save"></i> Save Changes
</button> </button>
@@ -234,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>
@@ -317,5 +382,35 @@
} }
}); });
}); });
// Self Update
$("body").on('click', '#btn-self-update', function(e){
e.preventDefault();
if (!confirm('최신 코드를 다운로드하고 플러그인을 리로드하시겠습니까?')) return;
var btn = $(this);
var originalText = btn.html();
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> 업데이트 중...');
$.ajax({
url: `/${package_name}/ajax/${sub}/self_update`,
type: "POST",
dataType: "json",
success: function(ret) {
if (ret.ret == 'success') {
$.notify('<strong>업데이트 완료!</strong> 페이지를 새로고침합니다.', {type:'success'});
setTimeout(function() { location.reload(); }, 1500);
} else {
$.notify('<strong>업데이트 실패: ' + ret.msg + '</strong>', {type:'danger'});
}
},
error: function() {
$.notify('<strong>업데이트 중 오류 발생</strong>', {type:'danger'});
},
complete: function() {
btn.prop('disabled', false).html(originalText);
}
});
});
</script> </script>
{% endblock %} {% endblock %}