clipon / frontend /src /utils /api.js
yonagush
Fix YouTube DNS failure via Invidious proxy + retheme to chainstreet gold
a72d248
const API_BASE = '/api'
/**
* Process a video from URL or file
* @param {string|File} urlOrFile - YouTube/TikTok/Instagram URL or File object
* @param {object} options - { numClips, language, aspectRatio, rewriteHooks, captionStyle, brandKit }
* @returns {Promise<{jobId: string, status: string}>}
*/
export async function processVideo(urlOrFile, options = {}) {
const formData = new FormData()
if (typeof urlOrFile === 'string') {
formData.append('url', urlOrFile)
} else if (urlOrFile instanceof File) {
formData.append('file', urlOrFile)
} else {
throw new Error('urlOrFile must be a string (URL) or File object')
}
formData.append('num_clips', options.numClips || 5)
formData.append('language', options.language || 'en')
formData.append('aspect_ratio', options.aspectRatio || '9:16')
formData.append('rewrite_hooks', options.rewriteHooks ? 'true' : 'false')
// BUG FIX: backend reads 'caption_style' and 'brand_kit' (not _json suffix)
if (options.captionStyle) {
formData.append('caption_style', JSON.stringify(options.captionStyle))
}
if (options.brandKit) {
formData.append('brand_kit', JSON.stringify(options.brandKit))
}
// Brand logo file upload
if (options.brandKit?.logoFile instanceof File) {
formData.append('logo_file', options.brandKit.logoFile)
}
// YouTube cookies.txt for datacenter IP bypass
if (options.cookiesFile instanceof File) {
formData.append('cookies_file', options.cookiesFile)
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 300000) // 5 min timeout
try {
const response = await fetch(`${API_BASE}/process`, {
method: 'POST',
body: formData,
signal: controller.signal,
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.detail || `HTTP ${response.status}`)
}
const data = await response.json()
// BUG FIX: backend returns snake_case job_id, normalize to camelCase
return {
jobId: data.job_id || data.jobId,
status: data.status || 'processing',
progress: data.progress || 0,
}
} finally {
clearTimeout(timeout)
}
}
/**
* Get job status and results
* @param {string} jobId
* @returns {Promise<JobResult>}
*/
export async function getJob(jobId) {
// BUG FIX: backend uses singular /api/job/ not /api/jobs/
const response = await fetch(`${API_BASE}/job/${jobId}`)
if (!response.ok) {
throw new Error(`Failed to fetch job: ${response.status}`)
}
return await response.json()
}
/**
* List all jobs
* @returns {Promise<Array>}
*/
export async function getJobs() {
const response = await fetch(`${API_BASE}/jobs`)
if (!response.ok) {
throw new Error(`Failed to fetch jobs: ${response.status}`)
}
return await response.json()
}
/**
* Delete a job
* @param {string} jobId
* @returns {Promise<{success: boolean}>}
*/
export async function deleteJob(jobId) {
const response = await fetch(`${API_BASE}/job/${jobId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error(`Failed to delete job: ${response.status}`)
}
return await response.json()
}
/**
* Download a clip
* @param {string} jobId
* @param {string} clipId
* @returns {Promise<Blob>}
*/
export async function downloadClip(jobId, clipId) {
// BUG FIX: match actual backend route /api/clips/{job_id}/{clip_id}/download
const response = await fetch(`${API_BASE}/clips/${jobId}/${clipId}/download`, {
method: 'GET',
})
if (!response.ok) {
throw new Error(`Failed to download clip: ${response.status}`)
}
return await response.blob()
}
/**
* Generate article reels from a news article URL
* @param {string} articleUrl - URL of the article
* @param {object} options - { reelType, numPoints, ttsVoice, accentColor, groqApiKey }
* @returns {Promise<{jobId: string, status: string}>}
*/
export async function generateArticleReels(articleUrl, options = {}) {
const formData = new FormData()
formData.append('article_url', articleUrl)
formData.append('reel_type', options.reelType || 'Financial News Reel')
formData.append('num_points', options.numPoints || 7)
formData.append('tts_voice', options.ttsVoice || 'Guy - News Anchor (Male)')
formData.append('accent_hex', options.accentColor || '#E8A020')
formData.append('groq_api_key', options.groqApiKey || '')
const response = await fetch(`${API_BASE}/article-reels`, {
method: 'POST',
body: formData,
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.detail || `HTTP ${response.status}`)
}
const data = await response.json()
return {
jobId: data.job_id || data.jobId,
status: data.status || 'processing',
}
}
/**
* Create WebSocket connection for real-time job updates
* @param {string} jobId
* @param {function} onMessage - Called with JobResult when message arrives
* @param {function} onClose - Called when connection closes
* @returns {{close: function}}
*/
export function createWebSocket(jobId, onMessage, onClose) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}/api/ws/${jobId}`
let ws = null
let reconnectAttempts = 0
const maxReconnectAttempts = 5
const reconnectDelay = 1000
function connect() {
try {
ws = new WebSocket(wsUrl)
ws.onopen = () => {
reconnectAttempts = 0
console.log('[WebSocket] Connected to', jobId)
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (onMessage) onMessage(data)
} catch (err) {
console.error('[WebSocket] Failed to parse message:', err)
}
}
ws.onerror = (error) => {
console.error('[WebSocket] Error:', error)
}
ws.onclose = () => {
console.log('[WebSocket] Connection closed')
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++
setTimeout(connect, reconnectDelay)
} else {
if (onClose) onClose()
}
}
} catch (err) {
console.error('[WebSocket] Connection failed:', err)
if (onClose) onClose()
}
}
connect()
return {
close: () => {
if (ws) ws.close()
},
}
}