feat(extension): integrate local video info API (#67)
* feat(extension): integrate local video info API * chore(repo): clean up popup styles * feat(extension): revamp popup UI * chore(repo): revert docs and drop content script
2
.gitignore
vendored
@@ -2,6 +2,8 @@ node_modules
|
||||
/dist
|
||||
out
|
||||
.conductor/
|
||||
.wxt
|
||||
.output
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
|
||||
26
extension/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.output
|
||||
stats.html
|
||||
stats-*.json
|
||||
.wxt
|
||||
web-ext.config.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
extension/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# WXT + React
|
||||
|
||||
This template should help get you started developing with React in WXT.
|
||||
163
extension/assets/content.css
Normal file
@@ -0,0 +1,163 @@
|
||||
.vidbee-download-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
transition: all 0.2s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-container.vidbee-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
.vidbee-download-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.6;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.vidbee-download-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.vidbee-download-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.vidbee-tooltip {
|
||||
position: absolute;
|
||||
right: calc(100% + 8px);
|
||||
top: 50%;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
z-index: 10000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vidbee-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
.vidbee-close-button {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: rgba(255, 77, 77, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-container:hover .vidbee-close-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vidbee-close-button:hover {
|
||||
background: rgba(255, 77, 77, 1);
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.vidbee-close-button:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.vidbee-close-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.vidbee-close-button .vidbee-tooltip {
|
||||
right: calc(100% + 6px);
|
||||
top: 50%;
|
||||
left: auto;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
}
|
||||
|
||||
.vidbee-close-button .vidbee-tooltip::after {
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
right: auto;
|
||||
transform: translateY(-50%);
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
203
extension/entrypoints/background.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
type VideoFormat = {
|
||||
format_id?: string
|
||||
ext?: string
|
||||
format_note?: string
|
||||
resolution?: string
|
||||
width?: number
|
||||
height?: number
|
||||
fps?: number
|
||||
vcodec?: string
|
||||
acodec?: string
|
||||
filesize?: number
|
||||
filesize_approx?: number
|
||||
tbr?: number
|
||||
}
|
||||
|
||||
type VideoInfo = {
|
||||
title?: string
|
||||
thumbnail?: string
|
||||
duration?: number
|
||||
formats?: VideoFormat[]
|
||||
}
|
||||
|
||||
type VideoInfoCacheEntry = {
|
||||
url: string
|
||||
status: 'pending' | 'ready' | 'error'
|
||||
fetchedAt: number
|
||||
info?: VideoInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PORT_RANGE_START = 27100
|
||||
const PORT_RANGE_END = 27120
|
||||
const STATUS_TIMEOUT_MS = 800
|
||||
const INFO_TIMEOUT_MS = 60000
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
const pendingRequests = new Map<string, Promise<void>>()
|
||||
const defaultIconPaths = {
|
||||
16: 'icon/16.png',
|
||||
32: 'icon/32.png',
|
||||
48: 'icon/48.png',
|
||||
128: 'icon/128.png'
|
||||
}
|
||||
const loadingIconPaths = {
|
||||
16: 'icon/icon-loading-16.png',
|
||||
32: 'icon/icon-loading-32.png',
|
||||
48: 'icon/icon-loading-48.png',
|
||||
128: 'icon/icon-loading-128.png'
|
||||
}
|
||||
const successIconPaths = {
|
||||
16: 'icon/icon-success-16.png',
|
||||
32: 'icon/icon-success-32.png',
|
||||
48: 'icon/icon-success-48.png',
|
||||
128: 'icon/icon-success-128.png'
|
||||
}
|
||||
|
||||
const setActionIcon = (status: 'default' | 'loading' | 'success', tabId?: number): void => {
|
||||
const paths =
|
||||
status === 'loading'
|
||||
? loadingIconPaths
|
||||
: status === 'success'
|
||||
? successIconPaths
|
||||
: defaultIconPaths
|
||||
const options = tabId ? { path: paths, tabId } : { path: paths }
|
||||
void browser.action.setIcon(options)
|
||||
}
|
||||
|
||||
const fetchJson = async <T>(url: string, timeoutMs: number): Promise<T> => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
const data = (await response.json().catch(() => null)) as (T & { error?: string }) | null
|
||||
if (!response.ok) {
|
||||
const message = data && typeof data === 'object' && 'error' in data ? data.error : null
|
||||
const details = data && typeof data === 'object' && 'details' in data ? data.details : null
|
||||
const combined = [message, details].filter(Boolean).join('\n\n')
|
||||
throw new Error(combined || `Request failed: ${response.status}`)
|
||||
}
|
||||
return data as T
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error('Request timed out.')
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('signal is aborted')) {
|
||||
throw new Error('Request timed out.')
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('Failed to fetch')) {
|
||||
throw new Error('VidBee app not responding on this port.')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
const findAvailablePort = async (): Promise<number | null> => {
|
||||
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
try {
|
||||
await fetchJson<{ ok: boolean }>(`${baseUrl}/status`, STATUS_TIMEOUT_MS)
|
||||
return port
|
||||
} catch {
|
||||
// Keep scanning.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const requestVideoInfo = async (targetUrl: string): Promise<VideoInfo> => {
|
||||
const port = await findAvailablePort()
|
||||
if (!port) {
|
||||
throw new Error('VidBee app not found on localhost.')
|
||||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
const tokenResponse = await fetchJson<{ token?: string }>(`${baseUrl}/token`, STATUS_TIMEOUT_MS)
|
||||
if (!tokenResponse.token) {
|
||||
throw new Error('Failed to acquire token from VidBee.')
|
||||
}
|
||||
|
||||
return fetchJson<VideoInfo>(
|
||||
`${baseUrl}/video-info?url=${encodeURIComponent(targetUrl)}&token=${encodeURIComponent(
|
||||
tokenResponse.token
|
||||
)}`,
|
||||
INFO_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
|
||||
const getCacheMap = async (): Promise<Record<string, VideoInfoCacheEntry>> => {
|
||||
const data = await browser.storage.local.get('videoInfoCacheByUrl')
|
||||
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
|
||||
if (!map) return {}
|
||||
return map
|
||||
}
|
||||
|
||||
const pruneCache = (map: Record<string, VideoInfoCacheEntry>): void => {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of Object.entries(map)) {
|
||||
if (now - entry.fetchedAt > CACHE_TTL_MS) {
|
||||
delete map[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadCache = async (url: string): Promise<VideoInfoCacheEntry | null> => {
|
||||
const map = await getCacheMap()
|
||||
pruneCache(map)
|
||||
const cache = map[url]
|
||||
if (!cache) return null
|
||||
return cache
|
||||
}
|
||||
|
||||
const saveCacheEntry = async (cache: VideoInfoCacheEntry): Promise<void> => {
|
||||
const map = await getCacheMap()
|
||||
pruneCache(map)
|
||||
map[cache.url] = cache
|
||||
await browser.storage.local.set({ videoInfoCacheByUrl: map })
|
||||
}
|
||||
|
||||
const fetchAndCache = async (url: string, tabId?: number): Promise<void> => {
|
||||
if (pendingRequests.has(url)) {
|
||||
return pendingRequests.get(url) as Promise<void>
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const existing = await loadCache(url)
|
||||
if (existing?.status === 'ready') {
|
||||
setActionIcon('success', tabId)
|
||||
return
|
||||
}
|
||||
|
||||
setActionIcon('loading', tabId)
|
||||
await saveCacheEntry({ url, status: 'pending', fetchedAt: Date.now() })
|
||||
|
||||
try {
|
||||
const info = await requestVideoInfo(url)
|
||||
await saveCacheEntry({ url, status: 'ready', fetchedAt: Date.now(), info })
|
||||
setActionIcon('success', tabId)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch video info.'
|
||||
await saveCacheEntry({ url, status: 'error', fetchedAt: Date.now(), error: message })
|
||||
setActionIcon('default', tabId)
|
||||
}
|
||||
})()
|
||||
|
||||
pendingRequests.set(url, task)
|
||||
try {
|
||||
await task
|
||||
} finally {
|
||||
pendingRequests.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
export default defineBackground(() => {
|
||||
browser.runtime.onMessage.addListener((message: { type?: string; url?: string }, sender) => {
|
||||
if (message.type !== 'video-info:fetch' || !message.url) {
|
||||
return
|
||||
}
|
||||
void fetchAndCache(message.url, sender.tab?.id)
|
||||
})
|
||||
})
|
||||
291
extension/entrypoints/popup/App.css
Normal file
@@ -0,0 +1,291 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--fg: #111111;
|
||||
--fg-secondary: #757575;
|
||||
--border: #f0f0f0;
|
||||
--accent: #000000;
|
||||
--error: #e00000;
|
||||
--success: #00c853;
|
||||
--warning: #ffd600;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 360px;
|
||||
min-height: 200px;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--border);
|
||||
}
|
||||
.status-dot.loading { background-color: var(--warning); box-shadow: 0 0 4px var(--warning); }
|
||||
.status-dot.ok { background-color: var(--success); }
|
||||
.status-dot.error { background-color: var(--error); }
|
||||
|
||||
.video-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 90px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.video-details h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
font-size: 12px;
|
||||
color: var(--fg-secondary);
|
||||
margin: 0 0 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 90px;
|
||||
height: 50px;
|
||||
background: var(--border);
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.formats-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.format-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Sticky headers for long lists */
|
||||
.sticky-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
padding: 4px 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fg-secondary);
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.format-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.format-table th {
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
color: var(--fg-secondary);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.format-table td {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.format-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.col-id { width: 50px; color: var(--fg-secondary); }
|
||||
.col-ext { width: 50px; }
|
||||
.col-size { width: 60px; text-align: right; }
|
||||
.format-table th.col-size { text-align: right; }
|
||||
|
||||
.empty-state {
|
||||
font-size: 12px;
|
||||
color: var(--fg-secondary);
|
||||
padding: 12px 0;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
background: rgba(224, 0, 0, 0.05);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.primary-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.troubleshoot-box {
|
||||
background: var(--border);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.troubleshoot-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.troubleshoot-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2.5px solid var(--border);
|
||||
border-top-color: var(--fg);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
415
extension/entrypoints/popup/App.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import './App.css'
|
||||
|
||||
type VideoFormat = {
|
||||
format_id?: string
|
||||
ext?: string
|
||||
format_note?: string
|
||||
resolution?: string
|
||||
width?: number
|
||||
height?: number
|
||||
fps?: number
|
||||
vcodec?: string
|
||||
acodec?: string
|
||||
filesize?: number
|
||||
filesize_approx?: number
|
||||
tbr?: number
|
||||
}
|
||||
|
||||
type VideoInfo = {
|
||||
title?: string
|
||||
thumbnail?: string
|
||||
duration?: number
|
||||
formats?: VideoFormat[]
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
const isValidHttpUrl = (value?: string): boolean => {
|
||||
if (!value) return false
|
||||
return value.startsWith('http://') || value.startsWith('https://')
|
||||
}
|
||||
|
||||
const formatDuration = (value?: number): string => {
|
||||
if (!value || value <= 0) return 'Unknown'
|
||||
const totalSeconds = Math.round(value)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const paddedSeconds = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${minutes}:${paddedSeconds}`
|
||||
}
|
||||
|
||||
const formatBytes = (value?: number): string => {
|
||||
if (!value || value <= 0) return '-'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = value
|
||||
let unitIndex = 0
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${size.toFixed(size >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
const isVideoFormat = (format: VideoFormat): boolean => {
|
||||
if (format.vcodec && format.vcodec !== 'none') {
|
||||
return true
|
||||
}
|
||||
return Boolean(format.resolution || format.width || format.height)
|
||||
}
|
||||
|
||||
const isAudioFormat = (format: VideoFormat): boolean => {
|
||||
return Boolean(format.acodec && format.acodec !== 'none' && !isVideoFormat(format))
|
||||
}
|
||||
|
||||
type VideoInfoCacheEntry = {
|
||||
url: string
|
||||
status: 'pending' | 'ready' | 'error'
|
||||
fetchedAt: number
|
||||
info?: VideoInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
type VideoGroup = {
|
||||
label: string
|
||||
height: number
|
||||
formats: VideoFormat[]
|
||||
}
|
||||
|
||||
const loadCachedInfo = async (url: string): Promise<VideoInfoCacheEntry | null> => {
|
||||
const data = await browser.storage.local.get('videoInfoCacheByUrl')
|
||||
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
|
||||
if (!map) return null
|
||||
const cached = map[url]
|
||||
if (!cached) return null
|
||||
if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) return null
|
||||
return cached
|
||||
}
|
||||
|
||||
const sanitizeError = (error: string): string => {
|
||||
const message = error.toLowerCase()
|
||||
if (
|
||||
message.includes('localhost') ||
|
||||
message.includes('fetch') ||
|
||||
message.includes('network') ||
|
||||
message.includes('connect') ||
|
||||
message.includes('failed to request')
|
||||
) {
|
||||
return 'Client connection failed'
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [info, setInfo] = useState<VideoInfo | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [currentUrl, setCurrentUrl] = useState<string>('')
|
||||
const [retryTrigger, setRetryTrigger] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const targetState = { url: '' }
|
||||
|
||||
const handleStorageChange = (
|
||||
changes: Record<string, browser.storage.StorageChange>,
|
||||
areaName: string
|
||||
) => {
|
||||
if (!active || areaName !== 'local') return
|
||||
const change = changes.videoInfoCacheByUrl
|
||||
if (!change?.newValue) return
|
||||
|
||||
const map = change.newValue as Record<string, VideoInfoCacheEntry>
|
||||
const next = map[targetState.url]
|
||||
if (!next) return
|
||||
|
||||
if (next.status === 'ready' && next.info) {
|
||||
setInfo(next.info)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
} else if (next.status === 'error' && next.error) {
|
||||
setError(sanitizeError(next.error))
|
||||
setInfo(null)
|
||||
setLoading(false)
|
||||
} else if (next.status === 'pending') {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
|
||||
browser.storage.onChanged.addListener(handleStorageChange)
|
||||
|
||||
const loadInfo = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setInfo(null)
|
||||
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||
if (!isValidHttpUrl(tab?.url)) {
|
||||
setError('Please open a valid video page first.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrl = tab.url as string
|
||||
targetState.url = targetUrl
|
||||
setCurrentUrl(targetUrl)
|
||||
|
||||
const cached = await loadCachedInfo(targetUrl)
|
||||
const shouldBypassCache = retryTrigger > 0
|
||||
if (cached && !shouldBypassCache) {
|
||||
if (cached.status === 'ready' && cached.info) {
|
||||
setInfo(cached.info)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (cached.status === 'error' && cached.error) {
|
||||
setError(sanitizeError(cached.error))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'video-info:fetch',
|
||||
url: targetUrl
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to request video info.'
|
||||
setError(sanitizeError(message))
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const latest = await loadCachedInfo(targetUrl)
|
||||
if (latest && latest.status === 'ready' && latest.info) {
|
||||
setInfo(latest.info)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
} else if (latest && latest.status === 'error' && latest.error) {
|
||||
setError(sanitizeError(latest.error))
|
||||
setInfo(null)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadInfo()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
browser.storage.onChanged.removeListener(handleStorageChange)
|
||||
}
|
||||
}, [retryTrigger])
|
||||
|
||||
const formats = useMemo(() => info?.formats ?? [], [info])
|
||||
const groupedFormats = useMemo(() => {
|
||||
const video: VideoFormat[] = []
|
||||
const audio: VideoFormat[] = []
|
||||
const other: VideoFormat[] = []
|
||||
|
||||
for (const format of formats) {
|
||||
if (isVideoFormat(format)) {
|
||||
video.push(format)
|
||||
} else if (isAudioFormat(format)) {
|
||||
audio.push(format)
|
||||
} else {
|
||||
other.push(format)
|
||||
}
|
||||
}
|
||||
|
||||
return { video, audio, other }
|
||||
}, [formats])
|
||||
|
||||
const groupedVideoFormats = useMemo(() => {
|
||||
const raw = groupedFormats.video
|
||||
if (!raw.length) return []
|
||||
|
||||
const groups: Record<number, VideoFormat[]> = {}
|
||||
const noHeight: VideoFormat[] = []
|
||||
|
||||
for (const f of raw) {
|
||||
const h = f.height || f.resolution?.match(/x(\d+)/)?.[1]
|
||||
const heightVal = h ? Number(h) : 0
|
||||
|
||||
if (heightVal > 0) {
|
||||
if (!groups[heightVal]) groups[heightVal] = []
|
||||
groups[heightVal].push(f)
|
||||
} else {
|
||||
noHeight.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
const sortedLabels = Object.keys(groups)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a)
|
||||
|
||||
const result: VideoGroup[] = sortedLabels.map((h) => ({
|
||||
label: `${h}p`,
|
||||
height: h,
|
||||
formats: groups[h].sort((a, b) => {
|
||||
const sa = a.filesize || a.filesize_approx || 0
|
||||
const sb = b.filesize || b.filesize_approx || 0
|
||||
return sb - sa
|
||||
})
|
||||
}))
|
||||
|
||||
if (noHeight.length > 0) {
|
||||
result.push({
|
||||
label: 'Other',
|
||||
height: 0,
|
||||
formats: noHeight
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [groupedFormats.video])
|
||||
|
||||
const handleOpenClient = () => {
|
||||
if (!currentUrl) return
|
||||
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
|
||||
window.location.href = deepLink
|
||||
}
|
||||
|
||||
const renderStatus = () => {
|
||||
// if (loading) return null
|
||||
if (error)
|
||||
return (
|
||||
<span className="status-indicator">
|
||||
<div className="status-dot error" /> Error
|
||||
</span>
|
||||
)
|
||||
if (info)
|
||||
return (
|
||||
<span className="status-indicator">
|
||||
<div className="status-dot ok" /> Ready
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<span className="status-indicator">
|
||||
<div className="status-dot" /> Idle
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header>
|
||||
<h1>VidBee</h1>
|
||||
{renderStatus()}
|
||||
</header>
|
||||
{loading && (
|
||||
<div className="loading-container">
|
||||
<div className="spinner" />
|
||||
<div className="loading-text">Analyzing video...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="error-container">
|
||||
<div className="error-banner">{error}</div>
|
||||
<div className="troubleshoot-box">
|
||||
<p className="troubleshoot-title">Having trouble?</p>
|
||||
<div className="troubleshoot-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={() => {
|
||||
window.location.href = 'vidbee://'
|
||||
setTimeout(() => setRetryTrigger((c) => c + 1), 100)
|
||||
}}
|
||||
>
|
||||
Open Client
|
||||
</button>
|
||||
<span className="divider">•</span>
|
||||
<a
|
||||
href="https://vidbee.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link-button"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && info && (
|
||||
<>
|
||||
<section className="video-info">
|
||||
<div className="video-details">
|
||||
<h2>{info.title || 'Untitled video'}</h2>
|
||||
<div className="meta-row">
|
||||
<span>{formatDuration(info.duration)}</span>
|
||||
<span>•</span>
|
||||
<span>{formats.length} formats</span>
|
||||
</div>
|
||||
</div>
|
||||
{info.thumbnail && <img className="thumbnail" src={info.thumbnail} alt="" />}
|
||||
</section>
|
||||
|
||||
<button type="button" className="primary-button" onClick={handleOpenClient}>
|
||||
Download with VidBee
|
||||
</button>
|
||||
|
||||
<section className="formats-section">
|
||||
{groupedVideoFormats.map((group) => (
|
||||
<div className="format-group" key={group.label}>
|
||||
<div className="group-title sticky-title">{group.label}</div>
|
||||
<table className="format-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-id">ID</th>
|
||||
<th className="col-ext">Ext</th>
|
||||
<th className="col-size">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.formats.map((f) => (
|
||||
<tr key={`vg-${group.label}-${f.format_id ?? f.ext ?? 'video'}`}>
|
||||
<td className="col-id">{f.format_id || '-'}</td>
|
||||
<td className="col-ext">{f.ext || '-'}</td>
|
||||
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{groupedVideoFormats.length === 0 && groupedFormats.audio.length === 0 && (
|
||||
<div className="empty-state">No compatible formats.</div>
|
||||
)}
|
||||
|
||||
{groupedFormats.audio.length > 0 && (
|
||||
<div className="format-group">
|
||||
<div className="group-title">Audio Only</div>
|
||||
<table className="format-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-id">ID</th>
|
||||
<th className="col-ext">Ext</th>
|
||||
<th className="col-size">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupedFormats.audio.map((f) => (
|
||||
<tr key={`a-${f.format_id ?? f.ext ?? 'audio'}`}>
|
||||
<td className="col-id">{f.format_id || '-'}</td>
|
||||
<td className="col-ext">{f.ext || '-'}</td>
|
||||
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
13
extension/entrypoints/popup/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Default Popup Title</title>
|
||||
<meta name="manifest.type" content="browser_action" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
extension/entrypoints/popup/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (root) {
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
}
|
||||
28
extension/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "wxt-react-starter",
|
||||
"description": "manifest.json description",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"compile": "tsc --noEmit",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@wxt-dev/module-react": "^1.1.5",
|
||||
"typescript": "^5.9.3",
|
||||
"wxt": "^0.20.6"
|
||||
}
|
||||
}
|
||||
3541
extension/pnpm-lock.yaml
generated
Normal file
8
extension/public/_locales/en/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"downloadWithVidBee": {
|
||||
"message": "Download with VidBee"
|
||||
},
|
||||
"hideButton": {
|
||||
"message": "Hide"
|
||||
}
|
||||
}
|
||||
BIN
extension/public/icon/128.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
extension/public/icon/16.png
Normal file
|
After Width: | Height: | Size: 924 B |
BIN
extension/public/icon/32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
extension/public/icon/48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
extension/public/icon/icon-loading-128.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
extension/public/icon/icon-loading-16.png
Normal file
|
After Width: | Height: | Size: 744 B |
BIN
extension/public/icon/icon-loading-32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
extension/public/icon/icon-loading-48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
extension/public/icon/icon-success-128.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
extension/public/icon/icon-success-16.png
Normal file
|
After Width: | Height: | Size: 751 B |
BIN
extension/public/icon/icon-success-32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
extension/public/icon/icon-success-48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
7
extension/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./.wxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
11
extension/wxt.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'wxt'
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
export default defineConfig({
|
||||
modules: ['@wxt-dev/module-react'],
|
||||
manifest: {
|
||||
default_locale: 'en',
|
||||
host_permissions: ['http://127.0.0.1/*'],
|
||||
permissions: ['activeTab', 'storage']
|
||||
}
|
||||
})
|
||||
@@ -20,6 +20,7 @@ import { ffmpegManager } from './lib/ffmpeg-manager'
|
||||
import { subscriptionManager } from './lib/subscription-manager'
|
||||
import { subscriptionScheduler } from './lib/subscription-scheduler'
|
||||
import { ytdlpManager } from './lib/ytdlp-manager'
|
||||
import { startExtensionApiServer, stopExtensionApiServer } from './local-api'
|
||||
import { settingsManager } from './settings'
|
||||
import { createTray, destroyTray } from './tray'
|
||||
import { applyAutoLaunchSetting } from './utils/auto-launch'
|
||||
@@ -462,6 +463,8 @@ app.whenReady().then(async () => {
|
||||
log.error('Failed to initialize yt-dlp:', error)
|
||||
}
|
||||
|
||||
await startExtensionApiServer()
|
||||
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
||||
|
||||
@@ -509,6 +512,7 @@ app.on('window-all-closed', () => {
|
||||
// Cleanup tray on quit
|
||||
app.on('will-quit', () => {
|
||||
destroyTray()
|
||||
void stopExtensionApiServer()
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
|
||||
191
src/main/local-api.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import crypto from 'node:crypto'
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
|
||||
import log from 'electron-log/main'
|
||||
|
||||
import { downloadEngine } from './lib/download-engine'
|
||||
|
||||
const PORT_RANGE_START = 27100
|
||||
const PORT_RANGE_END = 27120
|
||||
const TOKEN_TTL_MS = 60_000
|
||||
|
||||
type TokenRecord = {
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
let server: http.Server | null = null
|
||||
let serverPort: number | null = null
|
||||
const tokens = new Map<string, TokenRecord>()
|
||||
|
||||
const isLoopbackAddress = (address?: string | null): boolean => {
|
||||
if (!address) return false
|
||||
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
||||
}
|
||||
|
||||
const writeJson = (res: http.ServerResponse, status: number, body: unknown): void => {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
})
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
const writeEmpty = (res: http.ServerResponse, status: number): void => {
|
||||
res.writeHead(status, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
})
|
||||
res.end()
|
||||
}
|
||||
|
||||
const issueToken = (): string => {
|
||||
const token = crypto.randomBytes(16).toString('hex')
|
||||
tokens.set(token, { expiresAt: Date.now() + TOKEN_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
const consumeToken = (token?: string | null): boolean => {
|
||||
if (!token) return false
|
||||
const record = tokens.get(token)
|
||||
if (!record) return false
|
||||
if (Date.now() > record.expiresAt) {
|
||||
tokens.delete(token)
|
||||
return false
|
||||
}
|
||||
tokens.delete(token)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleRequest = async (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (!isLoopbackAddress(req.socket.remoteAddress)) {
|
||||
writeJson(res, 403, { error: 'Forbidden' })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
writeEmpty(res, 204)
|
||||
return
|
||||
}
|
||||
|
||||
if (!req.url) {
|
||||
writeJson(res, 400, { error: 'Missing URL' })
|
||||
return
|
||||
}
|
||||
|
||||
const requestUrl = new URL(req.url, 'http://127.0.0.1')
|
||||
const pathname = requestUrl.pathname
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
writeJson(res, 405, { error: 'Method not allowed' })
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/token') {
|
||||
const token = issueToken()
|
||||
writeJson(res, 200, { token, expiresInMs: TOKEN_TTL_MS })
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/video-info') {
|
||||
const token = requestUrl.searchParams.get('token')
|
||||
if (!consumeToken(token)) {
|
||||
writeJson(res, 401, { error: 'Invalid token' })
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrl = requestUrl.searchParams.get('url')
|
||||
if (!targetUrl || !targetUrl.trim()) {
|
||||
writeJson(res, 400, { error: 'Missing url' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await downloadEngine.getVideoInfo(targetUrl.trim())
|
||||
writeJson(res, 200, {
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
duration: info.duration,
|
||||
formats: info.formats ?? []
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch video info'
|
||||
const details =
|
||||
error instanceof Error
|
||||
? error.stack
|
||||
: typeof error === 'object' && error && 'stderr' in error
|
||||
? String((error as { stderr?: unknown }).stderr ?? '')
|
||||
: undefined
|
||||
writeJson(res, 500, { error: message, details })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/status') {
|
||||
writeJson(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
writeJson(res, 404, { error: 'Not found' })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unhandled request error'
|
||||
writeJson(res, 500, { error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const startServerOnPort = (port: number): Promise<http.Server> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
void handleRequest(req, res)
|
||||
})
|
||||
|
||||
httpServer.once('error', (error) => {
|
||||
httpServer.close()
|
||||
reject(error)
|
||||
})
|
||||
|
||||
httpServer.listen(port, '127.0.0.1', () => resolve(httpServer))
|
||||
})
|
||||
|
||||
export async function startExtensionApiServer(): Promise<number | null> {
|
||||
if (server && serverPort) {
|
||||
return serverPort
|
||||
}
|
||||
|
||||
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
|
||||
try {
|
||||
server = await startServerOnPort(port)
|
||||
const address = server.address() as AddressInfo | null
|
||||
serverPort = address?.port ?? port
|
||||
log.info(`Extension API listening on 127.0.0.1:${serverPort}`)
|
||||
return serverPort
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err.code !== 'EADDRINUSE') {
|
||||
log.warn('Extension API failed to start on port:', port, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.error(`Extension API failed to bind any port in range ${PORT_RANGE_START}-${PORT_RANGE_END}`)
|
||||
return null
|
||||
}
|
||||
|
||||
export async function stopExtensionApiServer(): Promise<void> {
|
||||
if (!server) return
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server?.close(() => resolve())
|
||||
})
|
||||
|
||||
server = null
|
||||
serverPort = null
|
||||
tokens.clear()
|
||||
}
|
||||