Files
wehub-resource-sync d18ada4ee7
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:24:41 +08:00

208 lines
5.8 KiB
TypeScript

interface 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
}
interface VideoInfo {
title?: string
thumbnail?: string
duration?: number
formats?: VideoFormat[]
}
interface VideoInfoCacheEntry {
url: string
status: 'pending' | 'ready' | 'error'
fetchedAt: number
info?: VideoInfo
error?: string
}
const PORT_RANGE_START = 27_100
const PORT_RANGE_END = 27_120
const STATUS_TIMEOUT_MS = 800
const INFO_TIMEOUT_MS = 60_000
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)
})
})