Compare commits

...

28 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
d8353da16f Release v0.2.0: Fix package name, improve DB stability, support realtime youtube title update 2026-01-06 22:24:50 +09:00
ff8f35071b Fix: Explicit import of models in setup.py to force table creation 2026-01-06 21:19:23 +09:00
22 changed files with 2632 additions and 189 deletions

104
README.md
View File

@@ -1,71 +1,57 @@
# gommi_download_manager (GDM)
# Gommi Downloader Manager (GDM)
FlaskFarm 범용 다운로더 큐 플러그인 (v0.2.0)
FlaskFarm 범용 다운로드 매니저 플러그인입니다.
여러 다운로더 플러그인(YouTube, Anime 등)의 다운로드 요청을 통합 관리하고 큐(Queue)를 제공합니다.
## 🆕 0.2.0 업데이트 (2026-01-06)
## v0.2.30 변경사항 (2026-01-12)
- **자막 자동 다운로드 및 변환**: `ytdlp_aria2` 다운로더에 VTT 자막 다운로드 및 SRT 자동 변환 로직 내장.
- **경로 정규화 강화**: `output_template` 생성 시 중복 구분자(`//`, `\.\`)를 제거하여 경로 오염 방지.
- **다운로드 완료 지점 최적화**: 비디오 다운로드 성공 직후 자막 처리가 이어지도록 흐름 개선.
### 새 기능
- **플러그인 콜백 시스템**: 다운로드 완료 시 호출 플러그인에 상태 알림
- **외부 플러그인 통합 강화**: `caller_plugin`, `callback_id` 파라미터로 호출자 추적
- **HLS ffmpeg 헤더 수정**: None 값 필터링으로 에러 방지
## v0.2.29 변경사항 (2026-01-11)
- **Anilife 고유 ID 지원**: 에피소드 고유 코드가 없는 경우 제목 기반 매칭 로직 보강.
### 버그 수정
- PluginManager API 호환성 수정 (`F.plugin_instance_list``F.PluginManager.all_package_list`)
- 완료된 다운로드 진행률 100% 표시 수정
- 큐 목록 URL 표시 제거 (깔끔한 UI)
## 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`) 지원 및 호환성 옵션 최적화
- **썸네일 버그 수정**: 외부 플러그인 위임 시 썸네일 누락 현상 수정
### UI 개선
- 다크 메탈릭 디자인 유지
- 완료 상태 표시 개선
## 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` 누락 및 메타데이터 초기화 버그 수정 (이전 버전 패치 포함).
- **YouTube/일반 사이트**: yt-dlp + aria2c 지원 (고속 분할 다운로드)
- **스트리밍 사이트**: 애니24, 링크애니, Anilife (ffmpeg HLS / Camoufox) 지원
- **중앙 집중식 관리**: 여러 플러그인의 다운로드 요청을 한곳에서 통합 관리
- **전역 속도 제한 (Smart Limiter)**: 모든 다운로드에 공통 적용되는 속도 제한 기능
## v0.2.12 변경사항 (2026-01-07)
- **안정성 개선**: `ffmpeg_hls` 다운로더에서 URL이 비어있을 경우 로그 기록 시 발생하는 `TypeError` 수정.
## 외부 플러그인에서 사용하기
## v0.2.8 변경사항 (2026-01-07)
- **콜백 시스템 개선**: `module_list`가 리스트 형태인 플러그인(애니 다운로더 등)과의 콜백 연동 호환성 해결 (`AttributeError` 수정).
- **메타데이터 강화**: 다운로드 시작/종료 시간 및 최종 파일 크기 추적 기능 추가.
- **UI 상세 정보 보강**: GDM 큐 목록에서 시작 시간, 종료 시간, 파일 크기를 상세 패널에 표시.
- **DB 정밀 동기화**: 다운로드 완료 시 실제 파일 크기를 DB에 영구 저장.
```python
from gommi_download_manager.mod_queue import ModuleQueue
## v0.2.7 변경사항
- **패키지명 수정**: `gommi_download_manager` -> `gommi_downloader_manager`로 폴더명과 일치시켜 Bind Key 오류 해결.
- **안정성 개선**: DB 테이블 생성 로직 강화 (`setup.py` 명시적 모델 import).
- **YouTube 제목 지원**: `yt-dlp` 다운로드 시작 시 영상의 진짜 제목과 썸네일을 실시간으로 DB에 업데이트합니다.
- **UI 개선**: 큐 리스트 템플릿 오류 수정.
# 다운로드 추가 (콜백 지원)
task = ModuleQueue.add_download(
url='https://www.youtube.com/watch?v=...',
save_path='/path/to/save',
filename='video.mp4',
source_type='auto',
caller_plugin='my_plugin_name', # 콜백 호출 시 식별자
callback_id='unique_item_id', # 콜백 데이터에 포함
)
```
## 설치 및 업데이트
1. `git pull`
2. FlaskFarm 재시작 (DB 마이그레이션 적용을 위해 필수)
## 콜백 수신하기
호출 플러그인에서 `plugin_callback` 메서드를 정의하면 다운로드 완료 시 자동 호출됩니다:
```python
class MyModule:
def plugin_callback(self, data):
# data = {'callback_id': ..., 'status': 'completed', 'filepath': ..., 'error': ...}
if data['status'] == 'completed':
print(f"다운로드 완료: {data['filepath']}")
```
## 설정 가이드
웹 인터페이스 (`/gommi_download_manager/queue/setting`)에서 다음을 설정할 수 있습니다:
- **속도 제한**: 네트워크 상황에 맞춰 최대 다운로드 속도 조절
- **동시 다운로드 수**: 한 번에 몇 개를 받을지 설정
- **기본 저장 경로**: 경로 미지정 요청에 대한 백업 경로
## 성능 비교
| 다운로더 | 방식 | 특징 |
|---------|------|------|
| **yt-dlp (Native)** | 안정적 | 속도 제한 기능 완벽 지원 |
| **aria2c** | 고속 (분할) | 대용량 파일에 최적화 (현재 실험적 지원) |
| **ffmpeg** | 스트림 | HLS/M3U8 영상 저장에 사용 |
## 지원 플러그인
- youtube-dl
- anime_downloader (Ohli24, Linkkf 등)

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]:
"""소스 타입에 맞는 다운로더 인스턴스 반환"""
if source_type in ('youtube', 'general'):
if source_type in ('youtube', 'general', 'linkkf'):
from .ytdlp_aria2 import YtdlpAria2Downloader
return YtdlpAria2Downloader()
elif source_type in ('ani24', 'linkkf', 'hls'):
elif source_type in ('ohli24', 'ani24', 'hls'):
from .ffmpeg_hls import FfmpegHlsDownloader
return FfmpegHlsDownloader()

View File

@@ -42,10 +42,13 @@ class FfmpegHlsDownloader(BaseDownloader):
if not filename:
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_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']
@@ -80,6 +83,15 @@ class FfmpegHlsDownloader(BaseDownloader):
except Exception as 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
cmd.extend(['-i', url])
@@ -89,7 +101,9 @@ class FfmpegHlsDownloader(BaseDownloader):
# 출력 파일
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 = self._get_duration(url, options.get('ffprobe_path', 'ffprobe'), headers)
@@ -162,12 +176,22 @@ class FfmpegHlsDownloader(BaseDownloader):
"""다운로드 취소"""
super().cancel()
if self._process:
try:
# [FIX] 파이프 명시적으로 닫기
if self._process.stdout: self._process.stdout.close()
if self._process.stderr: self._process.stderr.close()
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:
"""ffprobe로 영상 길이 획득"""
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']
if headers:

View File

@@ -5,6 +5,8 @@ HTTP 직접 다운로더
"""
import os
import traceback
import re
import time
from typing import Dict, Any, Optional, Callable
from .base import BaseDownloader
@@ -20,6 +22,21 @@ except:
class HttpDirectDownloader(BaseDownloader):
"""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(
self,
url: str,
@@ -38,7 +55,8 @@ class HttpDirectDownloader(BaseDownloader):
if not filename:
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', {})
@@ -52,6 +70,9 @@ class HttpDirectDownloader(BaseDownloader):
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
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:
for chunk in response.iter_content(chunk_size=chunk_size):
@@ -62,6 +83,13 @@ class HttpDirectDownloader(BaseDownloader):
f.write(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:
progress = int(downloaded / total_size * 100)
speed = '' # TODO: 속도 계산

View File

@@ -27,26 +27,41 @@ class YtdlpAria2Downloader(BaseDownloader):
super().__init__()
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(
self,
url: str,
save_path: str,
filename: Optional[str] = None,
progress_callback: Optional[Callable] = None,
info_callback: Optional[Callable] = None,
**options
) -> Dict[str, Any]:
"""yt-dlp + aria2c로 다운로드"""
try:
os.makedirs(save_path, exist_ok=True)
# 출력 템플릿
if filename:
output_template = os.path.join(save_path, filename)
else:
output_template = os.path.join(save_path, '%(title)s.%(ext)s')
# 출력 템플릿 (outtmpl 옵션 우선 처리)
raw_outtmpl = options.get('outtmpl') or filename or '%(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 명령어 구성
# 기본 명령어 구성 (항상 verbose 로그 남기도록 수정)
cmd = [
'yt-dlp',
'--newline', # 진행률 파싱용
@@ -54,28 +69,41 @@ class YtdlpAria2Downloader(BaseDownloader):
'-o', output_template,
]
# aria2c 사용 (설치되어 있으면)
aria2c_path = options.get('aria2c_path', 'aria2c')
# TODO: 나중에 설정에서 쓰레드 수 지정 (기본값 4로 변경)
connections = options.get('connections', 4)
# 제목/썸네일 업데이트용 출력 추가 (GDM_FIX)
cmd.extend(['--print', 'before_dl:GDM_FIX:title:%(title)s'])
cmd.extend(['--print', 'before_dl:GDM_FIX:thumb:%(thumbnail)s'])
# 속도 제한 설정
max_rate = P.ModelSetting.get('max_download_rate')
if max_rate == '0':
max_rate_arg = ''
log_rate_msg = '무제한'
else:
max_rate_arg = f'--max-download-limit={max_rate}'
log_rate_msg = max_rate
cmd.extend(['--limit-rate', max_rate]) # Native downloader limit
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 사용 (일시 중지: 진행률 파싱 문제 해결 전까지 Native 사용)
if False and self._check_aria2c(aria2c_path):
cmd.extend([
'--downloader', 'aria2c',
'--downloader-args', f'aria2c:-x {connections} -s {connections} -k 1M {max_rate_arg}',
])
logger.debug(f'aria2c 사용: {connections}개 연결 (속도제한 {log_rate_msg})')
# aria2c 사용 (설치되어 있으면)
aria2c_path = options.get('aria2c_path', 'aria2c')
connections = options.get('connections', 4)
if self._check_aria2c(aria2c_path):
cmd.extend(['--external-downloader', aria2c_path])
# aria2c 설정: -x=연결수, -s=분할수, -j=병렬, -k=조각크기, --console-log-level=notice로 진행률 출력
aria2_args = f'aria2c:-x{connections} -s{connections} -j{connections} -k1M --summary-interval=1 --console-log-level=notice'
if rate_limited:
aria2_args = f'{aria2_args} --max-download-limit={max_rate}'
cmd.extend(['--external-downloader-args', aria2_args])
logger.info(f'[GDM] Using aria2c for multi-threaded download (connections: {connections})')
# 진행률 템플릿 추가 (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:
logger.info(f'[GDM] download speed limit enabled: {max_rate}/s')
# 포맷 선택
format_spec = options.get('format')
@@ -99,17 +127,20 @@ class YtdlpAria2Downloader(BaseDownloader):
if options.get('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_path = options.get('ffmpeg_path') or P.ModelSetting.get('ffmpeg_path')
# 경로가 비어있거나 'ffmpeg' 같은 단순 이름인 경우 자동 감지 시도
if not ffmpeg_path or ffmpeg_path == 'ffmpeg':
import shutil
detected_path = shutil.which('ffmpeg')
if detected_path:
ffmpeg_path = detected_path
else:
# Mac Homebrew 등 일반적인 경로 추가 탐색
common_paths = [
'/opt/homebrew/bin/ffmpeg',
'/usr/local/bin/ffmpeg',
@@ -121,7 +152,6 @@ class YtdlpAria2Downloader(BaseDownloader):
break
if ffmpeg_path:
# 파일 경로인 경우 폴더 경로로 변환하거나 그대로 사용 (yt-dlp는 둘 다 지원)
cmd.extend(['--ffmpeg-location', ffmpeg_path])
logger.debug(f'[GDM] 감지된 FFmpeg 경로: {ffmpeg_path}')
@@ -130,7 +160,6 @@ class YtdlpAria2Downloader(BaseDownloader):
if isinstance(extra_args, list):
cmd.extend(extra_args)
# 후처리 옵션 간편 지원 (예: {'extract_audio': True, 'audio_format': 'mp3'})
if options.get('extract_audio'):
cmd.append('--extract-audio')
if options.get('audio_format'):
@@ -169,10 +198,41 @@ class YtdlpAria2Downloader(BaseDownloader):
if not line:
continue
# 메타데이터 파싱 (GDM_FIX)
if 'GDM_FIX:' in line:
try:
if 'GDM_FIX:title:' in line:
title = line.split('GDM_FIX:title:', 1)[1].strip()
if info_callback:
info_callback({'title': title})
elif 'GDM_FIX:thumb:' in line:
thumb = line.split('GDM_FIX:thumb:', 1)[1].strip()
if info_callback:
info_callback({'thumbnail': thumb})
except:
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)
progress_match = re.search(r'\[download\]\s+(\d+\.?\d*)%', line)
# 로그 출력 여부 결정 (진행률은 5% 단위로만)
should_log = True
if progress_match:
pct = float(progress_match.group(1))
@@ -184,20 +244,15 @@ class YtdlpAria2Downloader(BaseDownloader):
if should_log:
logger.info(f'[GDM][yt-dlp] {line}')
# 진행률 파싱 (aria2c)
if not progress_match:
# aria2c match
aria2_match = re.search(r'\(\s*([\d.]+)%\)', line)
if aria2_match and (('DL:' in line) or ('CN:' in line)):
try:
progress = int(float(aria2_match.group(1)))
speed_match = re.search(r'DL:(\S+)', line)
speed = speed_match.group(1) if speed_match else ''
eta_match = re.search(r'ETA:(\S+)', line)
eta = eta_match.group(1) if eta_match else ''
if progress_callback:
progress_callback(progress, speed, eta)
continue
@@ -206,27 +261,20 @@ class YtdlpAria2Downloader(BaseDownloader):
if progress_match and progress_callback:
progress = int(float(progress_match.group(1)))
# 속도 파싱
speed = ''
speed_match = re.search(r'at\s+([\d.]+\s*[KMG]?i?B/s)', line)
if speed_match:
speed = speed_match.group(1)
# ETA 파싱
eta = ''
eta_match = re.search(r'ETA\s+([\d:]+)', line)
if eta_match:
eta = eta_match.group(1)
progress_callback(progress, speed, eta)
# 최종 파일 경로 추출 (Merger, VideoConvertor, Destination 모두 대응)
if any(x in line for x in ['[Merger]', '[VideoConvertor]', 'Destination:']):
path_match = re.search(r'(?:Destination:|into|to)\s+["\']?(.+?)(?:["\']|$)', line)
if path_match:
potential_path = path_match.group(1).strip('"\'')
# 확장자가 있는 경우만 파일 경로로 간주
if '.' in os.path.basename(potential_path):
final_filepath = potential_path
@@ -235,6 +283,15 @@ class YtdlpAria2Downloader(BaseDownloader):
if self._process.returncode == 0:
if progress_callback:
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}
else:
return {'success': False, 'error': f'Exit code: {self._process.returncode}'}
@@ -273,7 +330,16 @@ class YtdlpAria2Downloader(BaseDownloader):
"""다운로드 취소"""
super().cancel()
if self._process:
try:
# [FIX] 파이프 명시적으로 닫기
if self._process.stdout: self._process.stdout.close()
if self._process.stderr: self._process.stderr.close()
self._process.terminate()
# 짧은 대기 후 여전히 살아있으면 kill
try: self._process.wait(timeout=1)
except: self._process.kill()
except: pass
def _check_aria2c(self, aria2c_path: str) -> bool:
"""aria2c 설치 확인"""
@@ -286,3 +352,57 @@ class YtdlpAria2Downloader(BaseDownloader):
return result.returncode == 0
except:
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
version: '0.1.15'
version: '0.2.37'
description: FlaskFarm 범용 다운로더 큐 - YouTube, 애니24, 링크애니, Anilife 지원
developer: projectdx
home: https://gitea.yommi.duckdns.org/projectdx/gommi_downloader_manager

View File

@@ -5,6 +5,7 @@ import os
import time
import threading
import traceback
import re
from datetime import datetime
from typing import Optional, Dict, Any, List, Callable
from enum import Enum
@@ -18,6 +19,7 @@ from framework import F, socketio
class DownloadStatus(str, Enum):
PENDING = "pending"
EXTRACTING = "extracting" # 메타데이터 추출 중
WAITING = "waiting" # 동시 다운로드 슬롯 대기 중
DOWNLOADING = "downloading"
PAUSED = "paused"
COMPLETED = "completed"
@@ -46,11 +48,43 @@ class ModuleQueue(PluginModuleBase):
# 진행 중인 다운로드 인스턴스들
_downloads: Dict[str, 'DownloadTask'] = {}
_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:
from .setup import default_route_socketio_module
super(ModuleQueue, self).__init__(P, name='queue', first_menu='list')
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:
@@ -142,6 +176,208 @@ class ModuleQueue(PluginModuleBase):
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:
self.P.logger.error(f'Exception:{str(e)}')
self.P.logger.error(traceback.format_exc())
@@ -252,14 +488,14 @@ class ModuleQueue(PluginModuleBase):
if caller_plugin:
cp_lower = caller_plugin.lower()
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 'youtube' in cp_lower: return 'youtube'
# 2. 메타데이터 기반 판단
if meta and meta.get('source'):
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 == 'linkkf': return 'linkkf'
@@ -267,7 +503,7 @@ class ModuleQueue(PluginModuleBase):
if 'youtube.com' in url_lower or 'youtu.be' in url_lower:
return 'youtube'
elif 'ani24' in url_lower or 'ohli24' in url_lower:
return 'ani24'
return 'ohli24'
elif 'linkkf' in url_lower:
return 'linkkf'
elif 'anilife' in url_lower:
@@ -280,6 +516,7 @@ class ModuleQueue(PluginModuleBase):
def plugin_load(self) -> None:
"""플러그인 로드 시 초기화"""
self.P.logger.info('gommi_downloader 플러그인 로드')
self._ensure_concurrency_limit()
try:
# DB에서 진행 중인 작업 로드
with F.app.app_context():
@@ -334,6 +571,96 @@ class ModuleQueue(PluginModuleBase):
for task in self._downloads.values():
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:
"""개별 다운로드 태스크"""
@@ -385,9 +712,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
@@ -396,16 +721,47 @@ class DownloadTask:
self._downloader = None
self._cancelled = False
self.db_id: Optional[int] = None
self.start_time: Optional[str] = None
self.end_time: Optional[str] = None
self.created_time: str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def start(self):
"""다운로드 시작 (비동기)"""
self._thread = threading.Thread(target=self._run, daemon=True)
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):
"""다운로드 실행"""
slot_acquired = False
try:
self.status = DownloadStatus.EXTRACTING
if not self.start_time:
self.start_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
self._emit_status()
# 다운로더 선택 및 실행
@@ -415,16 +771,67 @@ class DownloadTask:
if not self._downloader:
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._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(
url=self.url,
save_path=self.save_path,
filename=self.filename,
progress_callback=self._progress_callback,
**self.options
info_callback=self._info_update_callback,
**runtime_options
)
if self._cancelled:
@@ -433,13 +840,17 @@ class DownloadTask:
self.status = DownloadStatus.COMPLETED
self.filepath = result.get('filepath', '')
self.progress = 100
self.end_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if self.filepath and os.path.exists(self.filepath):
self.filesize = os.path.getsize(self.filepath)
# DB 업데이트
self._update_db_status()
# 실시간 콜백 처리
if self._on_complete:
self._on_complete(self.filepath)
try: self._on_complete(self.filepath)
except: pass
# 플러그인 간 영구적 콜백 처리
if self.caller_plugin and self.callback_id:
@@ -464,6 +875,11 @@ class DownloadTask:
self._cleanup_if_empty()
finally:
if slot_acquired and ModuleQueue._concurrency_sem is not None:
try:
ModuleQueue._concurrency_sem.release()
except Exception:
pass
self._emit_status()
def _progress_callback(self, progress: int, speed: str = '', eta: str = ''):
@@ -488,6 +904,37 @@ class DownloadTask:
except:
pass
def _info_update_callback(self, info_dict):
"""다운로더로부터 메타데이터 업데이트 수신"""
try:
if 'title' in info_dict and info_dict['title']:
self.title = info_dict['title']
if 'thumbnail' in info_dict and info_dict['thumbnail']:
self.thumbnail = info_dict['thumbnail']
# DB 업데이트
self._update_db_info()
# 상태 전송
self._emit_status()
except:
pass
def _update_db_info(self):
"""DB의 제목/썸네일 정보 동기화"""
try:
if self.db_id:
from .model import ModelDownloadItem
with F.app.app_context():
item = F.db.session.query(ModelDownloadItem).filter_by(id=self.db_id).first()
if item:
item.title = self.title
item.thumbnail = self.thumbnail
F.db.session.commit()
except:
pass
def cancel(self):
"""다운로드 취소"""
self._cancelled = True
@@ -534,6 +981,7 @@ class DownloadTask:
item.status = self.status
if self.status == DownloadStatus.COMPLETED:
item.completed_time = datetime.now()
item.filesize = self.filesize
if self.error_message:
item.error_message = self.error_message
F.db.session.add(item)
@@ -573,7 +1021,27 @@ class DownloadTask:
if target_P:
# 모듈에서 콜백 메서드 찾기
callback_invoked = False
for module_name, module_instance in getattr(target_P, 'module_list', {}).items():
module_list = getattr(target_P, 'module_list', [])
if isinstance(module_list, dict):
modules = module_list.items()
elif isinstance(module_list, list):
modules = [(getattr(m, 'name', str(i)), m) for i, m in enumerate(module_list)]
else:
modules = []
# 모듈명 추출 (예: 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'):
callback_data = {
'callback_id': self.callback_id,
@@ -584,7 +1052,9 @@ class DownloadTask:
}
module_instance.plugin_callback(callback_data)
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
if not callback_invoked:
@@ -615,4 +1085,12 @@ class DownloadTask:
'caller_plugin': self.caller_plugin,
'callback_id': self.callback_id,
'db_id': self.db_id,
'start_time': self.start_time,
'end_time': self.end_time,
'created_time': self.created_time,
'file_size': self.filesize,
}
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
# 패키지 이름 동적 처리 (폴더명 기준)
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):
"""다운로드 아이템 DB 모델"""
@@ -57,6 +57,10 @@ class ModelDownloadItem(ModelBase):
ret['meta'] = {}
else:
ret['meta'] = {}
if self.created_time:
ret['created_time'] = self.created_time.strftime('%Y-%m-%d %H:%M:%S')
# JS UI expects file_size (with underscore)
ret['file_size'] = self.filesize or 0
return ret
@classmethod

104
setup.py
View File

@@ -15,7 +15,7 @@ gommi_download_manager - FlaskFarm 범용 다운로더 큐 플러그인
import traceback
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 = {
'filepath': __file__,
@@ -52,9 +52,111 @@ from plugin import *
P = create_plugin_instance(setting)
try:
import flask
from flask import Blueprint
from .model import ModelSetting, ModelDownloadItem
except ImportError:
pass
try:
from .mod_queue import ModuleQueue
P.set_module_list([ModuleQueue])
except Exception as e:
P.logger.error(f'Exception:{str(e)}')
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;
}
/* 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 {
font-family: var(--font-sans);
color: var(--text-main);
@@ -196,6 +254,9 @@
<div class="page-header">
<h1 class="page-title">GDM Settings</h1>
<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">
<i class="fa fa-save"></i> Save Changes
</button>
@@ -234,6 +295,10 @@
<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="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>
</select>
</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>
{% endblock %}