diff --git a/chrome_extension/README.md b/chrome_extension/README.md new file mode 100644 index 0000000..822622c --- /dev/null +++ b/chrome_extension/README.md @@ -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 설치됨 diff --git a/chrome_extension/background.js b/chrome_extension/background.js new file mode 100644 index 0000000..827cad2 --- /dev/null +++ b/chrome_extension/background.js @@ -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 }; + } +} diff --git a/chrome_extension/content.css b/chrome_extension/content.css new file mode 100644 index 0000000..e2e0482 --- /dev/null +++ b/chrome_extension/content.css @@ -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; +} diff --git a/chrome_extension/content.js b/chrome_extension/content.js new file mode 100644 index 0000000..20110c7 --- /dev/null +++ b/chrome_extension/content.js @@ -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 = ` + ⬇️ + GDM + `; + btn.title = 'GDM으로 다운로드'; + + btn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + btn.disabled = true; + btn.innerHTML = '전송중'; + + try { + const response = await chrome.runtime.sendMessage({ + action: 'download', + url: window.location.href + }); + + if (response && response.ret === 'success') { + btn.innerHTML = '완료'; + setTimeout(() => { + btn.innerHTML = '⬇️GDM'; + btn.disabled = false; + }, 2000); + } else { + throw new Error(response?.msg || 'Unknown error'); + } + } catch (error) { + btn.innerHTML = '실패'; + console.error('GDM Error:', error); + setTimeout(() => { + btn.innerHTML = '⬇️GDM'; + btn.disabled = false; + }, 2000); + } + }); + + container.appendChild(btn); + } +})(); diff --git a/chrome_extension/icons/icon128.png b/chrome_extension/icons/icon128.png new file mode 100644 index 0000000..fff8b8f Binary files /dev/null and b/chrome_extension/icons/icon128.png differ diff --git a/chrome_extension/icons/icon16.png b/chrome_extension/icons/icon16.png new file mode 100644 index 0000000..5f7d0d0 Binary files /dev/null and b/chrome_extension/icons/icon16.png differ diff --git a/chrome_extension/icons/icon48.png b/chrome_extension/icons/icon48.png new file mode 100644 index 0000000..6938720 Binary files /dev/null and b/chrome_extension/icons/icon48.png differ diff --git a/chrome_extension/manifest.json b/chrome_extension/manifest.json new file mode 100644 index 0000000..04f0ec7 --- /dev/null +++ b/chrome_extension/manifest.json @@ -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"] + } + ] +} diff --git a/chrome_extension/popup.css b/chrome_extension/popup.css new file mode 100644 index 0000000..8895c69 --- /dev/null +++ b/chrome_extension/popup.css @@ -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); +} diff --git a/chrome_extension/popup.html b/chrome_extension/popup.html new file mode 100644 index 0000000..90f9f7b --- /dev/null +++ b/chrome_extension/popup.html @@ -0,0 +1,76 @@ + + + + + GDM YouTube Downloader + + + +
+
+

🎬 GDM Downloader

+
+ +
+ +
+
+

영상 정보 불러오는 중...

+
+ + + + + + + + + +
+ + +
+ + + + diff --git a/chrome_extension/popup.js b/chrome_extension/popup.js new file mode 100644 index 0000000..630cb09 --- /dev/null +++ b/chrome_extension/popup.js @@ -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 = ` +
${format.label}
+ ${format.note ? `
${format.note}
` : ''} + `; + 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 = ' 전송 중...'; + 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 = '⬇️ 다운로드 시작'; + } +} + +async function saveServerUrl() { + await chrome.storage.local.set({ serverUrl: serverUrlEl.value }); +} diff --git a/info.yaml b/info.yaml index 5bef71f..3f2a172 100644 --- a/info.yaml +++ b/info.yaml @@ -1,6 +1,6 @@ title: "GDM" package_name: gommi_downloader_manager -version: '0.2.18' +version: '0.2.19' description: FlaskFarm 범용 다운로더 큐 - YouTube, 애니24, 링크애니, Anilife 지원 developer: projectdx home: https://gitea.yommi.duckdns.org/projectdx/gommi_downloader_manager diff --git a/mod_queue.py b/mod_queue.py index 0b18cad..2e75d07 100644 --- a/mod_queue.py +++ b/mod_queue.py @@ -170,6 +170,119 @@ class ModuleQueue(PluginModuleBase): self.P.logger.error(f'DB Delete Error: {e}') 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: self.P.logger.error(f'Exception:{str(e)}')