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
This commit is contained in:
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 |
39
chrome_extension/manifest.json
Normal file
39
chrome_extension/manifest.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "GDM YouTube Downloader",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "YouTube 영상을 GDM(gommi_downloader_manager)으로 전송하여 다운로드",
|
||||||
|
"permissions": [
|
||||||
|
"activeTab",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"https://www.youtube.com/*",
|
||||||
|
"https://youtu.be/*",
|
||||||
|
"http://localhost:*/*",
|
||||||
|
"http://127.0.0.1:*/*"
|
||||||
|
],
|
||||||
|
"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/ajax/queue/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/ajax/queue/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 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
title: "GDM"
|
title: "GDM"
|
||||||
package_name: gommi_downloader_manager
|
package_name: gommi_downloader_manager
|
||||||
version: '0.2.18'
|
version: '0.2.19'
|
||||||
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
|
||||||
|
|||||||
113
mod_queue.py
113
mod_queue.py
@@ -170,6 +170,119 @@ class ModuleQueue(PluginModuleBase):
|
|||||||
self.P.logger.error(f'DB Delete Error: {e}')
|
self.P.logger.error(f'DB Delete Error: {e}')
|
||||||
|
|
||||||
ret['msg'] = '항목이 삭제되었습니다.'
|
ret['msg'] = '항목이 삭제되었습니다.'
|
||||||
|
|
||||||
|
# ===== 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)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.P.logger.error(f'Exception:{str(e)}')
|
self.P.logger.error(f'Exception:{str(e)}')
|
||||||
|
|||||||
Reference in New Issue
Block a user