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:
2026-01-08 19:33:18 +09:00
parent 5afb082692
commit c7a3d6fd03
13 changed files with 923 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,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
View File

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

View File

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

199
chrome_extension/popup.js Normal file
View File

@@ -0,0 +1,199 @@
// GDM YouTube Downloader - Popup Script
const DEFAULT_SERVER = 'http://localhost:9099';
let currentUrl = '';
let selectedFormat = 'bestvideo+bestaudio/best';
// DOM Elements
const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error');
const notYoutubeEl = document.getElementById('not-youtube');
const mainEl = document.getElementById('main');
const thumbnailEl = document.getElementById('thumbnail');
const titleEl = document.getElementById('video-title');
const durationEl = document.getElementById('video-duration');
const qualityOptionsEl = document.getElementById('quality-options');
const serverUrlEl = document.getElementById('server-url');
const downloadBtn = document.getElementById('download-btn');
const statusEl = document.getElementById('status');
const retryBtn = document.getElementById('retry-btn');
const errorMessageEl = document.getElementById('error-message');
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
// Load saved server URL
const stored = await chrome.storage.local.get(['serverUrl']);
serverUrlEl.value = stored.serverUrl || DEFAULT_SERVER;
// Get current tab URL
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentUrl = tab.url;
// Check if YouTube
if (!isYouTubeUrl(currentUrl)) {
showSection('not-youtube');
return;
}
// Fetch video info
fetchVideoInfo();
});
// Event Listeners
downloadBtn.addEventListener('click', startDownload);
retryBtn.addEventListener('click', fetchVideoInfo);
serverUrlEl.addEventListener('change', saveServerUrl);
function isYouTubeUrl(url) {
return url && (url.includes('youtube.com/watch') || url.includes('youtu.be/'));
}
function showSection(section) {
loadingEl.classList.add('hidden');
errorEl.classList.add('hidden');
notYoutubeEl.classList.add('hidden');
mainEl.classList.add('hidden');
switch (section) {
case 'loading': loadingEl.classList.remove('hidden'); break;
case 'error': errorEl.classList.remove('hidden'); break;
case 'not-youtube': notYoutubeEl.classList.remove('hidden'); break;
case 'main': mainEl.classList.remove('hidden'); break;
}
}
function showStatus(message, type = 'info') {
statusEl.textContent = message;
statusEl.className = `status ${type}`;
statusEl.classList.remove('hidden');
if (type === 'success') {
setTimeout(() => statusEl.classList.add('hidden'), 3000);
}
}
function hideStatus() {
statusEl.classList.add('hidden');
}
function formatDuration(seconds) {
if (!seconds) return '';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
async function fetchVideoInfo() {
showSection('loading');
hideStatus();
const serverUrl = serverUrlEl.value.replace(/\/$/, '');
try {
const response = await fetch(
`${serverUrl}/gommi_downloader_manager/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 });
}

View File

@@ -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

View File

@@ -171,6 +171,119 @@ class ModuleQueue(PluginModuleBase):
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)}')
self.P.logger.error(traceback.format_exc()) self.P.logger.error(traceback.format_exc())