From cb607760ce537d0f61e54ed20b9ec67511f938e7 Mon Sep 17 00:00:00 2001 From: JSC Date: Sat, 20 Sep 2025 20:54:12 +0200 Subject: [PATCH] Add SDB Audio Extractor extension with options and popup UI - Implemented manifest.json for extension configuration - Created options.html and options.js for user settings management - Developed popup.html and popup.js for audio extraction functionality - Added icons for the extension (icon16.png, icon48.png) - Integrated API token and base URL configuration with validation - Implemented audio extraction from YouTube videos with error handling --- README.md | 166 ++++++++++++++++++++++++++++++++++++++++++ background.js | 150 ++++++++++++++++++++++++++++++++++++++ content.js | 58 +++++++++++++++ icon.svg | 25 +++++++ icon128.png | Bin 0 -> 18422 bytes icon16.png | Bin 0 -> 1225 bytes icon48.png | Bin 0 -> 1675 bytes manifest.json | 37 ++++++++++ options.html | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++ options.js | 124 ++++++++++++++++++++++++++++++++ popup.html | 168 +++++++++++++++++++++++++++++++++++++++++++ popup.js | 147 ++++++++++++++++++++++++++++++++++++++ 12 files changed, 1069 insertions(+) create mode 100644 README.md create mode 100644 background.js create mode 100644 content.js create mode 100644 icon.svg create mode 100644 icon128.png create mode 100644 icon16.png create mode 100644 icon48.png create mode 100644 manifest.json create mode 100644 options.html create mode 100644 options.js create mode 100644 popup.html create mode 100644 popup.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..41382fb --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# SDB Audio Extractor Chrome Extension + +A Chrome extension that allows you to extract audio from YouTube videos directly to your SDB soundboard application. + +## Features + +- 🎵 Extract audio from YouTube videos with one click +- 🔐 Secure API token authentication +- ⚡ Immediate response - extraction happens in the background +- 📝 Right-click context menu support +- ⚙️ Easy configuration through options page +- 🔄 Real-time status updates + +## Installation + +### Method 1: Developer Mode (Recommended) + +1. Download or clone this extension to your computer +2. Open Chrome and navigate to `chrome://extensions/` +3. Enable "Developer mode" in the top right corner +4. Click "Load unpacked" and select the `chrome-extension` folder +5. The extension should now appear in your extensions list + +### Method 2: Pack and Install + +1. In Chrome, go to `chrome://extensions/` +2. Enable "Developer mode" +3. Click "Pack extension" +4. Select the `chrome-extension` folder as the root directory +5. Click "Pack Extension" to create a `.crx` file +6. Install the `.crx` file by dragging it to the extensions page + +## Configuration + +Before using the extension, you need to configure your API credentials: + +1. Click the extension icon in Chrome's toolbar +2. Click "Configure API Token" or right-click the extension and select "Options" +3. Follow these steps to get your API token: + - Open your SDB soundboard application (usually at http://192-168-100-199.sslip.io) + - Log into your account + - Navigate to your user settings or profile page + - Generate a new API token + - Copy the token +4. Paste the token in the "API Token" field +5. Verify the "API Base URL" (default: http://192-168-100-199.sslip.io) +6. Click "Test Connection" to verify everything works +7. Click "Save Settings" + +## Usage + +### Method 1: Extension Popup + +1. Navigate to any YouTube video page +2. Click the SDB Audio Extractor extension icon in your toolbar +3. The popup will show the current video title and URL +4. Click "Extract Audio" to start the extraction +5. The extension will send the request to your SDB backend +6. Check your soundboard application for the extracted audio + +### Method 2: Right-Click Context Menu + +1. Navigate to any YouTube video page +2. Right-click anywhere on the page +3. Select "Extract Audio with SDB" from the context menu +4. You'll receive a notification when the extraction starts + +## Requirements + +- Chrome browser (version 88+) +- SDB soundboard backend running and accessible +- Valid API token from your SDB user account +- Internet connection for YouTube access + +## File Structure + +``` +chrome-extension/ +├── manifest.json # Extension configuration +├── popup.html # Main popup interface +├── popup.js # Popup functionality +├── options.html # Configuration page +├── options.js # Options page functionality +├── content.js # YouTube page integration +├── background.js # Background service worker +├── icon16.png # Extension icon (16x16) +├── icon48.png # Extension icon (48x48) +├── icon128.png # Extension icon (128x128) +└── README.md # This file +``` + +## Permissions + +The extension requires the following permissions: + +- `activeTab`: To access the current YouTube tab +- `storage`: To save your API configuration +- `https://www.youtube.com/*`: To work on YouTube pages +- `http://localhost:8000/*`: To communicate with your SDB backend + +## Troubleshooting + +### "API token not configured" Error + +- Make sure you've configured your API token in the extension options +- Verify the token is correctly copied from your SDB account + +### "Cannot connect to API server" Error + +- Check that your SDB backend is running (usually on port 8000) +- Verify the API Base URL in the extension options +- Make sure there are no firewall issues blocking localhost connections + +### "Authentication failed" Error + +- Your API token might be expired or invalid +- Generate a new API token in your SDB account +- Update the token in the extension options + +### Extension not working on YouTube + +- Make sure you're on a YouTube video page (youtube.com/watch?v=...) +- Try refreshing the page and clicking the extension again +- Check the browser console for any error messages + +### Right-click menu not appearing + +- The context menu only appears on YouTube video pages +- Make sure the extension is enabled and properly loaded +- Try reloading the extension in chrome://extensions/ + +## Development + +To modify or extend this extension: + +1. Make your changes to the source files +2. Go to `chrome://extensions/` +3. Click the refresh button on the SDB Audio Extractor extension +4. Test your changes + +### Adding New Features + +- Modify `manifest.json` for new permissions or files +- Update `popup.html` and `popup.js` for UI changes +- Modify `content.js` for YouTube page interaction +- Update `background.js` for background functionality + +## Security Notes + +- Your API token is stored locally in Chrome's sync storage +- The extension only communicates with YouTube and your configured SDB backend +- No data is sent to third-party services +- Always use HTTPS in production environments + +## Support + +If you encounter issues: + +1. Check the troubleshooting section above +2. Verify your SDB backend is running and accessible +3. Check the browser console for error messages +4. Ensure your API token is valid and has proper permissions + +## License + +This extension is part of the SDB soundboard project. \ No newline at end of file diff --git a/background.js b/background.js new file mode 100644 index 0000000..1de692c --- /dev/null +++ b/background.js @@ -0,0 +1,150 @@ +// Background service worker +chrome.runtime.onInstalled.addListener(() => { + console.log('SDB Audio Extractor extension installed'); + + // Create context menu + chrome.contextMenus.create({ + id: 'extractAudio', + title: 'Extract Audio with SDB', + contexts: ['page'], + documentUrlPatterns: ['https://www.youtube.com/watch*'] + }); +}); + +// Function to clean YouTube URL +function getCleanVideoUrl(url) { + const match = url.match(/[?&]v=([^&]+)/); + if (match) { + return `https://www.youtube.com/watch?v=${match[1]}`; + } + return url; // Return original if we can't extract video ID +} + +// Handle context menu clicks +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + if (info.menuItemId === 'extractAudio') { + // Get API configuration + const result = await chrome.storage.sync.get(['apiToken', 'apiBaseUrl']); + const apiToken = result.apiToken; + const apiBaseUrl = result.apiBaseUrl || 'http://192-168-100-199.sslip.io'; + + if (!apiToken) { + // Open options page if no token configured + chrome.runtime.openOptionsPage(); + return; + } + + // Get video information from content script for notification + let videoTitle = null; + try { + const videoInfo = await chrome.tabs.sendMessage(tab.id, { action: 'getVideoInfo' }); + if (videoInfo && videoInfo.title) { + videoTitle = videoInfo.title; + } + } catch (error) { + console.log('Could not get video info from content script:', error); + } + + // Send extraction request + try { + // Use clean URL for the API request + const cleanUrl = getCleanVideoUrl(tab.url); + + const response = await fetch(`${apiBaseUrl}/api/v1/extractions/?url=${encodeURIComponent(cleanUrl)}`, { + method: 'POST', + headers: { + 'API-TOKEN': apiToken + } + }); + + if (response.ok) { + // Show success notification + const notificationMessage = videoTitle + ? `Audio extraction started for "${videoTitle}"!` + : 'Audio extraction started successfully!'; + + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icon48.png', + title: 'SDB Audio Extractor', + message: notificationMessage + }); + } else { + throw new Error(`HTTP ${response.status}`); + } + } catch (error) { + console.error('Extraction error:', error); + let errorMessage = 'Unknown error occurred'; + + if (error.message) { + errorMessage = error.message; + } else if (error.name === 'TypeError' && error.message.includes('fetch')) { + errorMessage = 'Cannot connect to API server'; + } else if (typeof error === 'string') { + errorMessage = error; + } + + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icon48.png', + title: 'SDB Audio Extractor', + message: `Failed to extract audio: ${errorMessage}` + }); + } + } +}); + +// Handle messages from content scripts or popup +chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + console.log('Background received message:', request); + + if (request.action === 'extractAudio') { + // Handle extraction request from content script if needed + handleExtraction(request.url).then(sendResponse); + return true; // Keep message channel open for async response + } +}); + +async function handleExtraction(url) { + try { + const result = await chrome.storage.sync.get(['apiToken', 'apiBaseUrl']); + const apiToken = result.apiToken; + const apiBaseUrl = result.apiBaseUrl || 'http://192-168-100-199.sslip.io'; + + if (!apiToken) { + throw new Error('API token not configured'); + } + + // Use clean URL for the API request + const cleanUrl = getCleanVideoUrl(url); + + const response = await fetch(`${apiBaseUrl}/api/v1/extractions/?url=${encodeURIComponent(cleanUrl)}`, { + method: 'POST', + headers: { + 'API-TOKEN': apiToken + } + }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}`; + try { + const errorData = await response.json(); + if (errorData.detail) { + errorMessage = errorData.detail; + } else if (errorData.message) { + errorMessage = errorData.message; + } else if (typeof errorData === 'string') { + errorMessage = errorData; + } + } catch (e) { + errorMessage = response.statusText || errorMessage; + } + throw new Error(errorMessage); + } + + return await response.json(); + } catch (error) { + console.error('Extraction error:', error); + throw error; + } +} \ No newline at end of file diff --git a/content.js b/content.js new file mode 100644 index 0000000..5c3813f --- /dev/null +++ b/content.js @@ -0,0 +1,58 @@ +// Content script for YouTube pages +(function() { + 'use strict'; + + // Function to get video information + function getVideoInfo() { + const titleElement = document.querySelector('h1.ytd-watch-metadata yt-formatted-string') || + document.querySelector('h1.ytd-video-primary-info-renderer .title') || + document.querySelector('#title h1') || + document.querySelector('h1'); + + const title = titleElement ? titleElement.textContent.trim() : document.title; + const currentUrl = window.location.href; + const cleanUrl = getCleanVideoUrl(currentUrl); + + return { + title: title, + url: cleanUrl, + videoId: getVideoId(currentUrl) + }; + } + + // Extract video ID from URL + function getVideoId(url) { + const match = url.match(/[?&]v=([^&]+)/); + return match ? match[1] : null; + } + + // Clean YouTube URL to only include the video ID + function getCleanVideoUrl(url) { + const videoId = getVideoId(url); + if (videoId) { + return `https://www.youtube.com/watch?v=${videoId}`; + } + return url; // Return original if we can't extract video ID + } + + // Listen for messages from popup + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'getVideoInfo') { + const videoInfo = getVideoInfo(); + sendResponse(videoInfo); + } + return true; // Keep message channel open for async response + }); + + // Optional: Monitor for navigation changes on YouTube (SPA) + let lastUrl = location.href; + new MutationObserver(() => { + const url = location.href; + if (url !== lastUrl) { + lastUrl = url; + // URL changed, video info might have changed + console.log('YouTube navigation detected'); + } + }).observe(document, { subtree: true, childList: true }); + +})(); \ No newline at end of file diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..c182680 --- /dev/null +++ b/icon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/icon128.png b/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..4150b87512c049263994f8ffe60fcf02c291718a GIT binary patch literal 18422 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 literal 0 HcmV?d00001 diff --git a/icon16.png b/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..a173a09d329114d441de7005ac0e21bc2ec9a812 GIT binary patch literal 1225 zcmeAS@N?(olHy`uVBq!ia0vp^0w65F1SAhIZYc#)3dtTpz6=aiY77hwEes65fIsh^I$%pondJbFXtpjp@eb;3l!%1#uk<6+&H@b5}ZbYgjZ0d1&t{ z5OgeAs4(N&q6O08Yx9`abv0=>>xoERuu{z0EO2Dik**sO$vu{_9!^)@7l=(iT>E|A z=X+20s!veqjDGm%#UG9v$!|9vU^C~~F*i?rLeTsmew*pG(-~j+l&Ue9-w|S((vW?9 zt9Mjgblv2o%}bk~JpOk4?UTii-H$KH@i24gDov7|IeWrvhqCnY^vTxSWN-Vf&1UPH z-#34WV`{*}hwBf=$IpAaPvZ&q&n4$p7*8_Wd{!s3AgV0NO==69Zf9VR*D2G*Gh3MZ zZlB%s^=4I(ahdVAS&d7N1v$6<=loy1RZ+=aX?~6Q@4RV$ioQJUcq98#R&i>;y~1T~ zu8W(#oUkpL@W1ZQZ^vo1y1%tTXZ_ImD7o|A=6lZTbl2-nmNu0(U7{8`xoWrL9FeqHL??~;&%2s}n){#jsj?L0@7~Us-@l#x zdxPv7hLpLR{1$x3d>HH?yS(r7=MBp?EL;6`UFr2Ijs+XD)--&Y{3$p=C)Y_@p=%L` zMhKVa0nv2~PRk~6R@|?+&yX3N8QpMfh2gKCsdf_jT=61p44KK9$qt_)Kh3OPxM=+% z{tar8lN;W@dY`3mO!_hFiUmv#d_@h8_ZRRRgwJ06_fGg5?QhyHFBgAtXk33le8;l8 z%kn1Imo3W(HmTfKwa@F_+xKrLyR~0*JhlAk!^wudV5rR)Jg8wt)Z752+htnzdwohGw(d zLt`rw11lo~Z36=<0|UWL2Bs()a`RI%(<*UmNO<4T3Dlqgx1l66H?_DVF}DD>9`j!- z!+?6~KzfSH(yfy7b8}PkN*ENJ5|gvji}FkJQWTOii;{Cv6}S|9ON%p;6LS>u@=HKm zKM<9XnwSD(7nh{w!q{m!iRnPLOMZD?PCigcVo_0kIZ(h6Oc!S)rj@{XU^C=zJ?{s) Ogu&C*&t;ucLK6VH+Xf&2 literal 0 HcmV?d00001 diff --git a/icon48.png b/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..33823bd321d9ea1fc9a322b33c2f735ff5fe1006 GIT binary patch literal 1675 zcmZ{ke>Br;9LK+tAL06KDxr}y73Md?MW*?2nH6g%Dcg);KbXZyS-W!Aglgj!`7ydB zDU#9;q0$=V$11VgqH;n-*`!6G-EW;!=iEPf&Uv2q`}29;ujhIGdh(+p(Rw=OIsgFZ zg@&NEf{OSy4b{Mvr)Vw)1x5Fy2idL*I zxS>f5N265VhcRlW!|1< z;ul(X-@V0!;OGs*YTfWv8Zf`VtJJz3#*YJG9Rh=wuUmvtAa7$Q6p+M9VgVII1>wZo zlmg2wbS;)CDwUNQX>1nTZG`NG)$6#kc0>BQBXBnKmY{}O0SzZ=WA`@tqN!f1BVCCI zizwO!4=!9Nz^p1dD1IQ=6QCOXEGY~aHoUxhWEL^(RbFaq^V$&|x5B$W~n6x2@ zLaZHz3ziD9L_V9hzxeuL`qMOBJcwoGpj+3EkVYmF@o)e9SS_)xMV0VkrjeQ!%XrUB zo|*7gOK84HPr0y0(LNx0^~Iv{w`QJQeb$xV~%d@t&g-xuVE#~3HE=0Dk|B;n4Q(0a&WBi&q zKc&#|sAH<%p+$Ohkf0)ZnIV5ZVsXnHOd2PR6J^&|O%7dEN|jRaIMJ0~7BZ`Snrdo3 zb8~*lNp+8(vfm~B5szaT%lYs^6<%tsUBXVPovL; zz&B6b4-CRi-FVX6dYGn73+%;=tv8&p>_zUi1#`%_NQ!jKp^H#gfap6u*UOoLT0i!viesMVq=M^=I znpNA;kCfFos1wxb5?#gKG$X|M0X8<*TJp*}BZeRYcml4J_MEY!JX;fmVwy1x9_qtn z4Oid}S0`|0%Tk~>Vp6o=qRiGOo$7{I+7*w(aN#jb6URnag7L^fY~g~D`>7!kWB+_% zq1c*jbZaqdpo*bL6jgtzCtSs;&ZqAi_Xw~%o)0-~aK->KR#NeJ@!wN3yNm2qrU7a8 z*VPTZjJ?!e`pA7SEg^Fb1&{{+CEc@h3F+&zGX~)Qw~iHzXC)9=WF(161_f|IxVSqY zyd9jqqn%xmZV03cVj}{9L?Dcs5$^v3XpDs9#J#@;;^n-25P<)@ft5@nXRrvgv|lrx zU(Vq`hW(An$V@_#7&IE0&H`YY38d5{CSwmh0Y*w@lBi^u4lH6%Mly*&h0z(TZ)M~+ zp^ym)KYRv@O#9&zse~lZqZnCqDg%rVm`p|%*a-Zt85BYy>u2@-j5V^?N5Pi>p}~=; J#?A4${{iFY;u!z{ literal 0 HcmV?d00001 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..57dbde2 --- /dev/null +++ b/manifest.json @@ -0,0 +1,37 @@ +{ + "manifest_version": 3, + "name": "SDB Audio Extractor", + "version": "1.0", + "description": "Extract audio from YouTube videos to your SDB soundboard", + "permissions": [ + "activeTab", + "storage", + "contextMenus", + "notifications" + ], + "host_permissions": [ + "https://www.youtube.com/*", + "http://localhost:8000/*", + "http://192-168-100-199.sslip.io/*" + ], + "action": { + "default_popup": "popup.html", + "default_title": "Extract Audio" + }, + "content_scripts": [ + { + "matches": ["https://www.youtube.com/*"], + "js": ["content.js"], + "run_at": "document_end" + } + ], + "background": { + "service_worker": "background.js" + }, + "options_page": "options.html", + "icons": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } +} \ No newline at end of file diff --git a/options.html b/options.html new file mode 100644 index 0000000..3c8788a --- /dev/null +++ b/options.html @@ -0,0 +1,194 @@ + + + + + SDB Audio Extractor - Options + + + +
+ +
Configuration
+
+ +
+

Setup Instructions

+
    +
  1. Log into your SDB soundboard application
  2. +
  3. Go to your account settings
  4. +
  5. Generate an API token
  6. +
  7. Copy the token and paste it below
  8. +
  9. Make sure your API base URL is correct (usually http://192-168-100-199.sslip.io)
  10. +
  11. Click "Test Connection" to verify everything works
  12. +
+
+ +
+
+ + +
This token is used to authenticate with your SDB backend API
+
+ +
+ + +
The base URL of your SDB backend API (default: http://192-168-100-199.sslip.io)
+
+ + + +
+ + + + + + \ No newline at end of file diff --git a/options.js b/options.js new file mode 100644 index 0000000..2dff7a2 --- /dev/null +++ b/options.js @@ -0,0 +1,124 @@ +document.addEventListener('DOMContentLoaded', async () => { + const form = document.getElementById('options-form'); + const apiTokenInput = document.getElementById('api-token'); + const apiBaseUrlInput = document.getElementById('api-base-url'); + const saveBtn = document.getElementById('save-btn'); + const testBtn = document.getElementById('test-btn'); + const status = document.getElementById('status'); + + // Load saved settings + const result = await chrome.storage.sync.get(['apiToken', 'apiBaseUrl']); + if (result.apiToken) { + apiTokenInput.value = result.apiToken; + } + if (result.apiBaseUrl) { + apiBaseUrlInput.value = result.apiBaseUrl; + } else { + apiBaseUrlInput.value = 'http://192-168-100-199.sslip.io'; + } + + // Save settings + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const apiToken = apiTokenInput.value.trim(); + const apiBaseUrl = apiBaseUrlInput.value.trim() || 'http://192-168-100-199.sslip.io'; + + if (!apiToken) { + showStatus('Please enter an API token', 'error'); + return; + } + + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + await chrome.storage.sync.set({ + apiToken: apiToken, + apiBaseUrl: apiBaseUrl + }); + + showStatus('Settings saved successfully!', 'success'); + + // Auto-test connection after saving + setTimeout(testConnection, 1000); + + } catch (error) { + console.error('Error saving settings:', error); + showStatus('Failed to save settings', 'error'); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save Settings'; + } + }); + + // Test connection + testBtn.addEventListener('click', testConnection); + + async function testConnection() { + const apiToken = apiTokenInput.value.trim(); + const apiBaseUrl = apiBaseUrlInput.value.trim() || 'http://192-168-100-199.sslip.io'; + + if (!apiToken) { + showStatus('Please enter an API token first', 'error'); + return; + } + + testBtn.disabled = true; + testBtn.textContent = 'Testing...'; + showStatus('Testing connection...', 'loading'); + + try { + // Test with health endpoint first + const healthResponse = await fetch(`${apiBaseUrl}/api/v1/health`, { + method: 'GET' + }); + + if (!healthResponse.ok) { + throw new Error(`Health check failed: HTTP ${healthResponse.status}`); + } + + // Test authentication with a simple API call + const authResponse = await fetch(`${apiBaseUrl}/api/v1/extractions/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiToken}` + } + }); + + if (authResponse.status === 401) { + throw new Error('Invalid API token - authentication failed'); + } else if (authResponse.status === 403) { + throw new Error('API token does not have required permissions'); + } else if (!authResponse.ok) { + throw new Error(`API test failed: HTTP ${authResponse.status}`); + } + + showStatus('✅ Connection successful! Your settings are working correctly.', 'success'); + + } catch (error) { + console.error('Connection test error:', error); + + if (error.message.includes('fetch')) { + showStatus('❌ Cannot connect to API server. Check if the backend is running and the URL is correct.', 'error'); + } else { + showStatus(`❌ Connection failed: ${error.message}`, 'error'); + } + } finally { + testBtn.disabled = false; + testBtn.textContent = 'Test Connection'; + } + } + + function showStatus(message, type) { + status.textContent = message; + status.className = `status ${type}`; + status.classList.remove('hidden'); + + if (type === 'success') { + setTimeout(() => { + status.classList.add('hidden'); + }, 5000); + } + } +}); \ No newline at end of file diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..722af2c --- /dev/null +++ b/popup.html @@ -0,0 +1,168 @@ + + + + + + + +
+ +
Extract audio from YouTube videos
+
+ + + + + + + + + +
+ +
+ + + + \ No newline at end of file diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..3e622f8 --- /dev/null +++ b/popup.js @@ -0,0 +1,147 @@ +document.addEventListener('DOMContentLoaded', async () => { + const extractBtn = document.getElementById('extract-btn'); + const configBtn = document.getElementById('config-btn'); + const status = document.getElementById('status'); + const warning = document.getElementById('warning'); + const notYoutube = document.getElementById('not-youtube'); + const videoSection = document.getElementById('video-section'); + const videoTitle = document.getElementById('video-title'); + const videoUrl = document.getElementById('video-url'); + + // Check if we're on YouTube + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + if (!tab.url || !tab.url.includes('youtube.com/watch')) { + notYoutube.classList.remove('hidden'); + return; + } + + // Check API token configuration + const result = await chrome.storage.sync.get(['apiToken', 'apiBaseUrl']); + const apiToken = result.apiToken; + const apiBaseUrl = result.apiBaseUrl || 'http://192-168-100-199.sslip.io'; + + if (!apiToken) { + warning.classList.remove('hidden'); + extractBtn.disabled = true; + } else { + videoSection.classList.remove('hidden'); + } + + // Get video information from content script + let currentVideoInfo = null; + try { + const response = await chrome.tabs.sendMessage(tab.id, { action: 'getVideoInfo' }); + if (response && response.title && response.url) { + currentVideoInfo = response; + videoTitle.textContent = response.title; + videoUrl.textContent = response.url; + } else { + videoTitle.textContent = 'Video information not available'; + videoUrl.textContent = tab.url; + } + } catch (error) { + console.error('Error getting video info:', error); + videoTitle.textContent = 'Video information not available'; + videoUrl.textContent = tab.url; + } + + // Function to clean YouTube URL + function getCleanVideoUrl(url) { + const match = url.match(/[?&]v=([^&]+)/); + if (match) { + return `https://www.youtube.com/watch?v=${match[1]}`; + } + return url; // Return original if we can't extract video ID + } + + // Extract button click handler + extractBtn.addEventListener('click', async () => { + if (!apiToken) { + showStatus('Please configure your API token first', 'error'); + return; + } + + extractBtn.disabled = true; + extractBtn.textContent = 'Extracting...'; + showStatus('Starting audio extraction...', 'loading'); + + try { + // Use clean URL for the API request + const cleanUrl = getCleanVideoUrl(tab.url); + + console.log('Making extraction request:', { + apiBaseUrl, + cleanUrl, + title: currentVideoInfo?.title, + hasToken: !!apiToken + }); + + const response = await fetch(`${apiBaseUrl}/api/v1/extractions/?url=${encodeURIComponent(cleanUrl)}`, { + method: 'POST', + headers: { + 'API-TOKEN': apiToken + } + }); + + console.log('Response status:', response.status, response.statusText); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}`; + try { + const errorData = await response.json(); + if (errorData.detail) { + errorMessage = errorData.detail; + } else if (errorData.message) { + errorMessage = errorData.message; + } else if (typeof errorData === 'string') { + errorMessage = errorData; + } + } catch (e) { + // If we can't parse JSON, use the status text + errorMessage = response.statusText || errorMessage; + } + throw new Error(errorMessage); + } + + await response.json(); + const successMessage = currentVideoInfo?.title + ? `Audio extraction started for "${currentVideoInfo.title}"! Check your soundboard for the result.` + : 'Audio extraction started successfully! Check your soundboard for the result.'; + showStatus(successMessage, 'success'); + + // Close popup after successful extraction + setTimeout(() => { + window.close(); + }, 2000); + + } catch (error) { + console.error('Extraction error:', error); + let errorMessage = 'Unknown error occurred'; + + if (error.message) { + errorMessage = error.message; + } else if (error.name === 'TypeError' && error.message.includes('fetch')) { + errorMessage = 'Cannot connect to API server. Check your API base URL and network connection.'; + } else if (typeof error === 'string') { + errorMessage = error; + } + + showStatus(`Failed to extract audio: ${errorMessage}`, 'error'); + } finally { + extractBtn.disabled = false; + extractBtn.textContent = 'Extract Audio'; + } + }); + + // Config button click handler + configBtn.addEventListener('click', () => { + chrome.runtime.openOptionsPage(); + }); + + function showStatus(message, type) { + status.textContent = message; + status.className = `status ${type}`; + status.classList.remove('hidden'); + } +}); \ No newline at end of file