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 });
|
||||
}
|
||||
Reference in New Issue
Block a user