Compare commits
26 Commits
d8353da16f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c7564c0033 | |||
| 7a087ce9c5 | |||
| 1cdf68cc59 | |||
| 9d5d1514c4 | |||
| 9c4f36de6b | |||
| c38a7ae39b | |||
| 7beb536ca0 | |||
| c0a0f1ff2d | |||
| d98c13a4e9 | |||
| cae3c9b269 | |||
| 4baf23d8ad | |||
| 2fa4f474c3 | |||
| 1b59ca4279 | |||
| c7a3d6fd03 | |||
| 5afb082692 | |||
| ace56dfd73 | |||
| d6819447d7 | |||
| 340ed8833e | |||
| 2caed63d85 | |||
| d0dfef1445 | |||
| 68d12372ad | |||
| 890ed46e1c | |||
| bbdafb4ce0 | |||
| 77b37e8675 | |||
| f4b99f7d67 | |||
| faaaecc236 |
41
README.md
41
README.md
@@ -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에 업데이트합니다.
|
||||||
|
|||||||
40
chrome_extension/README.md
Normal file
40
chrome_extension/README.md
Normal 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 설치됨
|
||||||
85
chrome_extension/background.js
Normal file
85
chrome_extension/background.js
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
38
chrome_extension/content.css
Normal file
38
chrome_extension/content.css
Normal 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;
|
||||||
|
}
|
||||||
69
chrome_extension/content.js
Normal file
69
chrome_extension/content.js
Normal 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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
BIN
chrome_extension/icons/icon128.png
Normal file
BIN
chrome_extension/icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
BIN
chrome_extension/icons/icon16.png
Normal file
BIN
chrome_extension/icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1023 B |
BIN
chrome_extension/icons/icon48.png
Normal file
BIN
chrome_extension/icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
41
chrome_extension/manifest.json
Normal file
41
chrome_extension/manifest.json
Normal 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
263
chrome_extension/popup.css
Normal 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);
|
||||||
|
}
|
||||||
76
chrome_extension/popup.html
Normal file
76
chrome_extension/popup.html
Normal 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
199
chrome_extension/popup.js
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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: 속도 계산
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
466
mod_queue.py
466
mod_queue.py
@@ -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()
|
||||||
|
|||||||
6
model.py
6
model.py
@@ -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
|
||||||
|
|||||||
97
setup.py
97
setup.py
@@ -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
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user