feat: initialize gomdown-helper with yt-dlp transfer flow
This commit is contained in:
459
src/background/index.ts
Normal file
459
src/background/index.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import { isLikelyDownloadResponse, normalizeUrl } from '../lib/downloadIntent'
|
||||
import { nativeAddUri, nativeFocus } from '../lib/nativeHost'
|
||||
import { getSettings } from '../lib/settings'
|
||||
import { upsertHistory } from '../lib/history'
|
||||
import { makeMediaCandidate, mediaFingerprint, shouldCaptureMediaResponse } from '../lib/mediaCapture'
|
||||
import { clearMediaCandidates, listMediaCandidates, upsertMediaCandidate } from '../lib/mediaStore'
|
||||
|
||||
const REQUEST_TTL_MS = 8000
|
||||
const TRANSFER_DEDUPE_TTL_MS = 7000
|
||||
const contextMenuId = 'gomdown-helper-download-context-menu-option'
|
||||
|
||||
const pendingRequests = new Map<string, any>()
|
||||
const capturedUrls = new Map<string, number>()
|
||||
const capturedFingerprints = new Map<string, number>()
|
||||
const recentTransferFingerprints = new Map<string, number>()
|
||||
const handledRequestIds = new Map<string, number>()
|
||||
const capturedTabIds = new Map<number, number>()
|
||||
const recentMediaFingerprints = new Map<string, number>()
|
||||
let webRequestHooked = false
|
||||
let downloadHooked = false
|
||||
let contextMenuHooked = false
|
||||
let contextMenuUpdateInFlight: Promise<void> | null = null
|
||||
|
||||
function urlFingerprint(raw: string): string {
|
||||
try {
|
||||
const u = new URL(raw)
|
||||
const path = (u.pathname || '/').replace(/\/+$/, '') || '/'
|
||||
return `${u.protocol}//${u.host}${path}`.toLowerCase()
|
||||
} catch {
|
||||
return String(raw || '').toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
function pruneMap<K>(map: Map<K, number>): void {
|
||||
const now = Date.now()
|
||||
for (const [key, expiresAt] of map.entries()) {
|
||||
if (expiresAt <= now) map.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function rememberCapturedUrl(url: string): void {
|
||||
const expiresAt = Date.now() + REQUEST_TTL_MS
|
||||
capturedUrls.set(normalizeUrl(url), expiresAt)
|
||||
capturedFingerprints.set(urlFingerprint(url), expiresAt)
|
||||
}
|
||||
|
||||
function rememberCapturedTab(tabId?: number): void {
|
||||
if (!Number.isInteger(tabId) || (tabId ?? -1) < 0) return
|
||||
capturedTabIds.set(tabId as number, Date.now() + REQUEST_TTL_MS)
|
||||
}
|
||||
|
||||
function wasCapturedUrl(url: string): boolean {
|
||||
pruneMap(capturedUrls)
|
||||
pruneMap(capturedFingerprints)
|
||||
const normalized = normalizeUrl(url)
|
||||
return capturedUrls.has(normalized) || capturedFingerprints.has(urlFingerprint(url))
|
||||
}
|
||||
|
||||
function wasCapturedTab(tabId?: number): boolean {
|
||||
pruneMap(capturedTabIds)
|
||||
if (!Number.isInteger(tabId) || (tabId ?? -1) < 0) return false
|
||||
return capturedTabIds.has(tabId as number)
|
||||
}
|
||||
|
||||
function shouldSuppressDuplicateTransfer(url: string): boolean {
|
||||
pruneMap(recentTransferFingerprints)
|
||||
return recentTransferFingerprints.has(urlFingerprint(url))
|
||||
}
|
||||
|
||||
function rememberRecentTransfer(url: string): void {
|
||||
recentTransferFingerprints.set(urlFingerprint(url), Date.now() + TRANSFER_DEDUPE_TTL_MS)
|
||||
}
|
||||
|
||||
function wasRequestHandled(requestId?: string): boolean {
|
||||
pruneMap(handledRequestIds)
|
||||
return !!requestId && handledRequestIds.has(requestId)
|
||||
}
|
||||
|
||||
function markRequestHandled(requestId?: string): void {
|
||||
if (!requestId) return
|
||||
handledRequestIds.set(requestId, Date.now() + REQUEST_TTL_MS)
|
||||
}
|
||||
|
||||
function shouldSuppressDuplicateMedia(url: string): boolean {
|
||||
pruneMap(recentMediaFingerprints)
|
||||
const fp = mediaFingerprint(url)
|
||||
return recentMediaFingerprints.has(fp)
|
||||
}
|
||||
|
||||
function rememberMediaFingerprint(url: string): void {
|
||||
recentMediaFingerprints.set(mediaFingerprint(url), Date.now() + REQUEST_TTL_MS)
|
||||
}
|
||||
|
||||
async function requestGdownFocus(): Promise<void> {
|
||||
try {
|
||||
await nativeFocus()
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
async function notify(message: string): Promise<void> {
|
||||
await browser.notifications
|
||||
.create(`gomdown-notice-${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: '/images/icon-large.png',
|
||||
title: 'Gomdown Helper',
|
||||
message,
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
async function transferUrlToGdown(
|
||||
url: string,
|
||||
referer = '',
|
||||
extractor?: 'yt-dlp' | 'aria2',
|
||||
format?: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
if (shouldSuppressDuplicateTransfer(url)) {
|
||||
return { ok: false, error: 'duplicate transfer suppressed' }
|
||||
}
|
||||
|
||||
const settings = await getSettings()
|
||||
if (!settings.extensionStatus) return { ok: false, error: 'extension disabled' }
|
||||
if (!settings.motrixAPIkey) return { ok: false, error: 'motrixAPIkey is not set' }
|
||||
|
||||
try {
|
||||
const nativeResult = await nativeAddUri({
|
||||
url,
|
||||
rpcPort: settings.motrixPort,
|
||||
rpcSecret: settings.motrixAPIkey,
|
||||
referer,
|
||||
split: 64,
|
||||
extractor: extractor === 'yt-dlp' ? 'yt-dlp' : undefined,
|
||||
format: extractor === 'yt-dlp' ? format || 'bestvideo*+bestaudio/best' : undefined,
|
||||
})
|
||||
|
||||
if (!nativeResult?.ok) {
|
||||
return { ok: false, error: nativeResult?.error || 'native host addUri failed' }
|
||||
}
|
||||
|
||||
if (settings.activateAppOnDownload) {
|
||||
await requestGdownFocus()
|
||||
}
|
||||
rememberRecentTransfer(url)
|
||||
const gid = String(nativeResult?.gid || nativeResult?.requestId || `pending-${Date.now()}`)
|
||||
const pathname = (() => {
|
||||
try {
|
||||
return new URL(url).pathname
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})()
|
||||
const guessedName = pathname.split('/').filter(Boolean).pop() || url
|
||||
await upsertHistory({
|
||||
gid,
|
||||
downloader: 'native',
|
||||
startTime: new Date().toISOString(),
|
||||
icon: '/images/32.png',
|
||||
name: decodeURIComponent(guessedName),
|
||||
path: null,
|
||||
status: nativeResult?.pending ? 'queued' : 'downloading',
|
||||
size: 0,
|
||||
downloaded: 0,
|
||||
})
|
||||
|
||||
if (settings.enableNotifications) {
|
||||
await browser.notifications.create(`gomdown-transfer-${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: '/images/icon-large.png',
|
||||
title: 'Gomdown Helper',
|
||||
message: 'Download sent to gdown',
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldCaptureRequest(details: any): Promise<boolean> {
|
||||
if (details.type !== 'main_frame') return false
|
||||
if ((details.method || '').toUpperCase() !== 'GET') return false
|
||||
if (typeof details.statusCode === 'number' && (details.statusCode < 200 || details.statusCode > 299)) return false
|
||||
|
||||
const settings = await getSettings()
|
||||
if (!settings.extensionStatus || !settings.motrixAPIkey) return false
|
||||
const contentLengthRaw = String(
|
||||
Array.isArray(details?.responseHeaders)
|
||||
? details.responseHeaders.find((h: any) => String(h?.name || '').toLowerCase() === 'content-length')?.value || ''
|
||||
: ''
|
||||
)
|
||||
const contentLength = Number(contentLengthRaw || 0)
|
||||
if (settings.minFileSize > 0 && contentLength > 0 && contentLength < settings.minFileSize * 1024 * 1024) {
|
||||
return false
|
||||
}
|
||||
return isLikelyDownloadResponse(details)
|
||||
}
|
||||
|
||||
async function interceptByWebRequest(details: any): Promise<void> {
|
||||
if (wasRequestHandled(details.requestId)) return
|
||||
|
||||
const accepted = await shouldCaptureRequest(details)
|
||||
if (!accepted) return
|
||||
|
||||
const req = pendingRequests.get(details.requestId)
|
||||
const referer = String(req?.documentUrl || req?.originUrl || req?.initiator || req?.url || '')
|
||||
const sent = await transferUrlToGdown(details.url, referer)
|
||||
if (!sent.ok) return
|
||||
|
||||
markRequestHandled(details.requestId)
|
||||
rememberCapturedUrl(details.url)
|
||||
rememberCapturedTab(details.tabId)
|
||||
}
|
||||
|
||||
async function interceptMediaByWebRequest(details: any): Promise<void> {
|
||||
if (!shouldCaptureMediaResponse(details)) return
|
||||
const settings = await getSettings()
|
||||
if (!settings.extensionStatus) return
|
||||
if (shouldSuppressDuplicateMedia(details.url)) return
|
||||
|
||||
const req = pendingRequests.get(details.requestId)
|
||||
const referer = String(req?.documentUrl || req?.originUrl || req?.initiator || req?.url || '')
|
||||
const candidate = makeMediaCandidate(details, referer)
|
||||
await upsertMediaCandidate(candidate, mediaFingerprint(candidate.url))
|
||||
rememberMediaFingerprint(candidate.url)
|
||||
}
|
||||
|
||||
async function applyShelfVisibility(): Promise<void> {
|
||||
const downloadsApi = browser.downloads as any
|
||||
if (!downloadsApi.setShelfEnabled) return
|
||||
const settings = await getSettings()
|
||||
if (!settings.extensionStatus) return
|
||||
const enabled = settings.useNativeHost ? false : !settings.hideChromeBar
|
||||
await downloadsApi.setShelfEnabled(enabled)
|
||||
}
|
||||
|
||||
function setupDownloadCancelHook(): void {
|
||||
if (downloadHooked) return
|
||||
downloadHooked = true
|
||||
browser.downloads.onCreated.addListener(async (downloadItem) => {
|
||||
await applyShelfVisibility()
|
||||
const download = downloadItem as any
|
||||
const target = download.finalUrl || download.url || ''
|
||||
if (!wasCapturedUrl(target) && !wasCapturedTab(download.tabId)) return
|
||||
|
||||
await browser.downloads.cancel(downloadItem.id).catch(() => null)
|
||||
await browser.downloads.erase({ id: downloadItem.id }).catch(() => null)
|
||||
await browser.downloads.removeFile(downloadItem.id).catch(() => null)
|
||||
})
|
||||
}
|
||||
|
||||
function setupWebRequestInterceptor(): void {
|
||||
if (webRequestHooked) return
|
||||
webRequestHooked = true
|
||||
browser.webRequest.onSendHeaders.addListener(
|
||||
(details) => {
|
||||
pendingRequests.set(details.requestId, details as any)
|
||||
},
|
||||
{ urls: ['<all_urls>'] },
|
||||
['requestHeaders', 'extraHeaders']
|
||||
)
|
||||
|
||||
browser.webRequest.onErrorOccurred.addListener(
|
||||
(details) => {
|
||||
pendingRequests.delete(details.requestId)
|
||||
handledRequestIds.delete(String(details.requestId))
|
||||
},
|
||||
{ urls: ['<all_urls>'] }
|
||||
)
|
||||
|
||||
browser.webRequest.onCompleted.addListener(
|
||||
(details) => {
|
||||
pendingRequests.delete(details.requestId)
|
||||
handledRequestIds.delete(String(details.requestId))
|
||||
},
|
||||
{ urls: ['<all_urls>'] }
|
||||
)
|
||||
|
||||
browser.webRequest.onHeadersReceived.addListener(
|
||||
(details) => {
|
||||
void interceptByWebRequest(details)
|
||||
void interceptMediaByWebRequest(details)
|
||||
},
|
||||
{ urls: ['<all_urls>'] },
|
||||
['responseHeaders']
|
||||
)
|
||||
}
|
||||
|
||||
async function handleContextMenuClick(data: any, tab?: any): Promise<void> {
|
||||
console.log('[gomdown-helper] context menu clicked', {
|
||||
menuItemId: data?.menuItemId,
|
||||
linkUrl: data?.linkUrl,
|
||||
srcUrl: data?.srcUrl,
|
||||
frameUrl: data?.frameUrl,
|
||||
pageUrl: data?.pageUrl,
|
||||
tabUrl: tab?.url,
|
||||
})
|
||||
const clickedId = data?.menuItemId
|
||||
if (clickedId != null && String(clickedId) !== contextMenuId) return
|
||||
|
||||
const linkUrl = String(data?.linkUrl || data?.srcUrl || '').trim()
|
||||
const pageUrl = String(data?.frameUrl || data?.pageUrl || tab?.url || '').trim()
|
||||
const targetUrl = linkUrl || pageUrl
|
||||
|
||||
const url = String(targetUrl || '').trim()
|
||||
if (!url || /^(about:|chrome:|chrome-extension:|edge:|brave:)/i.test(url)) {
|
||||
await notify('다운로드 가능한 URL을 찾지 못했습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
const useYtDlp = !linkUrl && !!pageUrl
|
||||
const result = await transferUrlToGdown(
|
||||
url,
|
||||
String(data?.pageUrl || tab?.url || ''),
|
||||
useYtDlp ? 'yt-dlp' : 'aria2'
|
||||
)
|
||||
if (!result.ok) {
|
||||
await notify(`전송 실패: ${result.error || 'unknown error'}`)
|
||||
return
|
||||
}
|
||||
await notify(useYtDlp ? '페이지 URL을 yt-dlp로 gdown에 전송했습니다.' : 'gdown으로 전송했습니다.')
|
||||
}
|
||||
|
||||
function createContextMenuSafe(): void {
|
||||
if (typeof chrome === 'undefined' || !chrome.contextMenus?.create) {
|
||||
browser.contextMenus.create({
|
||||
id: contextMenuId,
|
||||
title: 'Download with Gomdown',
|
||||
visible: true,
|
||||
contexts: ['all'],
|
||||
})
|
||||
return
|
||||
}
|
||||
chrome.contextMenus.create(
|
||||
{
|
||||
id: contextMenuId,
|
||||
title: 'Download with Gomdown',
|
||||
contexts: ['all'],
|
||||
},
|
||||
() => {
|
||||
void chrome.runtime.lastError
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function setupContextMenuClickListener(): void {
|
||||
if (contextMenuHooked) return
|
||||
if (typeof chrome !== 'undefined' && chrome.contextMenus?.onClicked) {
|
||||
chrome.contextMenus.onClicked.addListener((info, tab) => {
|
||||
void handleContextMenuClick(info, tab)
|
||||
})
|
||||
} else {
|
||||
browser.contextMenus.onClicked.addListener((info: any, tab: any) => {
|
||||
void handleContextMenuClick(info, tab)
|
||||
})
|
||||
}
|
||||
contextMenuHooked = true
|
||||
}
|
||||
|
||||
async function createMenuItemInternal(): Promise<void> {
|
||||
const settings = await getSettings()
|
||||
if (!settings.extensionStatus || !settings.showContextOption) {
|
||||
await browser.contextMenus.removeAll().catch(() => null)
|
||||
return
|
||||
}
|
||||
|
||||
await browser.contextMenus.removeAll().catch(() => null)
|
||||
createContextMenuSafe()
|
||||
}
|
||||
|
||||
function createMenuItem(): Promise<void> {
|
||||
if (contextMenuUpdateInFlight) return contextMenuUpdateInFlight
|
||||
contextMenuUpdateInFlight = createMenuItemInternal().finally(() => {
|
||||
contextMenuUpdateInFlight = null
|
||||
})
|
||||
return contextMenuUpdateInFlight
|
||||
}
|
||||
|
||||
browser.runtime.onMessage.addListener((message: any, sender: any) => {
|
||||
if (message?.type === 'capture-link-download') {
|
||||
const url = String(message?.url || '').trim()
|
||||
if (!url) return Promise.resolve({ ok: false, error: 'url is empty' })
|
||||
|
||||
const senderTabId = Number(sender?.tab?.id)
|
||||
return transferUrlToGdown(url, String(message?.referer || '')).then((result) => {
|
||||
if (result.ok) {
|
||||
rememberCapturedUrl(url)
|
||||
rememberCapturedTab(senderTabId)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
if (message?.type === 'media:list') {
|
||||
return listMediaCandidates().then((items) => ({ ok: true, items }))
|
||||
}
|
||||
|
||||
if (message?.type === 'media:clear') {
|
||||
return clearMediaCandidates().then(() => ({ ok: true }))
|
||||
}
|
||||
|
||||
if (message?.type === 'media:enqueue') {
|
||||
const url = String(message?.url || '').trim()
|
||||
if (!url) return Promise.resolve({ ok: false, error: 'url is empty' })
|
||||
return transferUrlToGdown(url, String(message?.referer || '')).then((result) => result)
|
||||
}
|
||||
|
||||
if (message?.type === 'page:enqueue-ytdlp') {
|
||||
return browser.tabs
|
||||
.query({ active: true, currentWindow: true })
|
||||
.then(async (tabs) => {
|
||||
const tab = tabs[0]
|
||||
const url = String(tab?.url || '').trim()
|
||||
if (!url) return { ok: false, error: 'active tab url is empty' }
|
||||
return transferUrlToGdown(url, url, 'yt-dlp')
|
||||
})
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
console.log('[gomdown-helper] onInstalled')
|
||||
setupWebRequestInterceptor()
|
||||
setupDownloadCancelHook()
|
||||
setupContextMenuClickListener()
|
||||
void createMenuItem()
|
||||
void applyShelfVisibility()
|
||||
})
|
||||
|
||||
browser.runtime.onStartup.addListener(() => {
|
||||
console.log('[gomdown-helper] onStartup')
|
||||
setupWebRequestInterceptor()
|
||||
setupDownloadCancelHook()
|
||||
setupContextMenuClickListener()
|
||||
void createMenuItem()
|
||||
void applyShelfVisibility()
|
||||
})
|
||||
|
||||
browser.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName !== 'sync') return
|
||||
if (changes.hideChromeBar || changes.useNativeHost || changes.extensionStatus) {
|
||||
void applyShelfVisibility()
|
||||
void createMenuItem()
|
||||
}
|
||||
if (changes.showContextOption) {
|
||||
void createMenuItem()
|
||||
}
|
||||
})
|
||||
|
||||
setupWebRequestInterceptor()
|
||||
setupDownloadCancelHook()
|
||||
setupContextMenuClickListener()
|
||||
void createMenuItem()
|
||||
void applyShelfVisibility()
|
||||
console.log('[gomdown-helper] service worker initialized')
|
||||
Reference in New Issue
Block a user