diff --git a/public/s/css/upload.css b/public/s/css/upload.css index 21a3175..9bb9588 100644 --- a/public/s/css/upload.css +++ b/public/s/css/upload.css @@ -1480,3 +1480,229 @@ to { opacity: 1; transform: scale(1) translateY(0); } } + +/* ============================================= + URL Upload Tracker — single persistent panel + ============================================= */ + +#url-upload-tracker { + position: fixed; + bottom: 20px; + left: 20px; + z-index: 9998; + width: 320px; + background: var(--nav-bg, #1c1c1c); + border: 1px solid rgba(var(--accent-rgb, 120,120,120), 0.15); + overflow: hidden; + font-size: 0.8rem; + opacity: 0; + transform: translateY(10px); + transition: opacity 0.28s ease, transform 0.28s ease; + pointer-events: none; +} + +#url-upload-tracker.uut-visible { + opacity: 1; + transform: translateY(0); + pointer-events: all; +} + +/* ── Header ──────────────────────────────────────── */ +.uut-header { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 10px 8px 12px; + background: rgba(var(--accent-rgb, 120,120,120), 0.07); + border-bottom: 1px solid rgba(var(--accent-rgb, 120,120,120), 0.1); + user-select: none; +} + +.uut-header > .fa-solid { + color: var(--accent); + font-size: 11px; + opacity: 0.75; + flex-shrink: 0; +} + +.uut-title { + flex: 1; + font-size: 0.74rem; + font-weight: 600; + color: var(--white, #fff); + letter-spacing: 0.01em; +} + +.uut-badge { + background: var(--accent); + color: #fff; + font-size: 0.62rem; + font-weight: 700; + min-width: 16px; + height: 16px; + border-radius: 8px; + padding: 0 4px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.uut-actions { display: flex; gap: 2px; } + +.uut-btn { + background: none; + border: none; + cursor: pointer; + color: var(--white, #fff); + opacity: 0.3; + padding: 3px 6px; + border-radius: 4px; + font-size: 12px; + line-height: 1; + transition: opacity 0.15s, background 0.15s; +} +.uut-btn:hover { opacity: 0.8; background: rgba(var(--accent-rgb,120,120,120),0.12); } + +/* ── Body (scrollable) ───────────────────────────── */ +.uut-body { + max-height: 340px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: rgba(var(--accent-rgb,120,120,120), 0.25) transparent; +} + +/* ── Active job rows ─────────────────────────────── */ +.uut-job { + padding: 8px 12px 6px; + border-bottom: 1px solid rgba(var(--accent-rgb,120,120,120), 0.06); + transition: opacity 0.3s; +} + +.uut-job--fading { opacity: 0; } + +.uut-job-row { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.uut-spinner { + width: 11px; + height: 11px; + flex-shrink: 0; + margin-top: 3px; + border: 1.5px solid rgba(var(--accent-rgb,120,120,120), 0.18); + border-top-color: var(--accent); + border-radius: 50%; + animation: uutSpin 0.75s linear infinite; +} +@keyframes uutSpin { to { transform: rotate(360deg); } } + +.uut-job-info { flex: 1; min-width: 0; } + +.uut-url { + display: block; + font-size: 0.72rem; + color: var(--white, #fff); + opacity: 0.65; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.uut-meta { + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; +} + +.uut-stage { + font-size: 0.71rem; + color: var(--accent); + white-space: nowrap; +} + +.uut-speed { + font-size: 0.67rem; + color: var(--white, #fff); + opacity: 0.38; + margin-left: auto; + white-space: nowrap; +} + +/* Progress bar */ +.uut-bar { + height: 2px; + background: rgba(var(--accent-rgb,120,120,120), 0.1); + border-radius: 2px; + overflow: hidden; + margin-top: 6px; +} + +.uut-bar-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 0.65s ease; + background-image: linear-gradient(90deg, var(--accent) 0%, rgba(255,255,255,0.4) 50%, var(--accent) 100%); + background-size: 200% 100%; + animation: uutShimmer 1.6s linear infinite; +} +@keyframes uutShimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } } + +/* ── Divider between active + history ────────────── */ +.uut-divider { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px; + opacity: 0.22; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--white, #fff); +} +.uut-divider::before, +.uut-divider::after { content: ''; flex: 1; height: 1px; background: currentColor; } + +/* ── History rows ────────────────────────────────── */ +.uut-hist-row { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 12px; + border-bottom: 1px solid rgba(var(--accent-rgb,120,120,120), 0.05); + transition: background 0.15s; +} +.uut-hist-row:last-child { border-bottom: none; } +.uut-hist-row:hover { background: rgba(var(--accent-rgb,120,120,120), 0.05); } + +.uut-ok { color: #51cf66; font-size: 10px; flex-shrink: 0; } +.uut-err { color: #ff5050; font-size: 10px; flex-shrink: 0; } + +.uut-hist-right { margin-left: auto; flex-shrink: 0; } + +.uut-link { + font-size: 0.69rem; + color: var(--accent); + text-decoration: none; + opacity: 0.8; + white-space: nowrap; + transition: opacity 0.15s; +} +.uut-link:hover { opacity: 1; text-decoration: underline; } + +.uut-errtxt { + font-size: 0.67rem; + color: #ff5050; + max-width: 90px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + opacity: 0.8; +} + diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 1967c80..6d9e21d 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -7200,6 +7200,12 @@ class NotificationSystem { // Render the active tab this._renderActiveTab(); + // Notify URL upload tracker widget about completed/failed background uploads + const bgUploads = notifications.filter(n => n.type === 'upload_success' || n.type === 'upload_error'); + if (bgUploads.length > 0) { + document.dispatchEvent(new CustomEvent('f0ck:upload_bg_update', { detail: bgUploads })); + } + // Live update for History Page const historyContainer = document.querySelector('.notifications-list-full'); if (historyContainer) { diff --git a/public/s/js/upload.js b/public/s/js/upload.js index ff3f529..cfb0175 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -7,6 +7,224 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => { .replace(/'/g, "'"); }); +// ============================================================ +// URL Upload Tracker — single persistent panel with queue + history +// ============================================================ +(function () { + const POLL_INTERVAL = 2000; + const MAX_HISTORY = 10; + const STORAGE_KEY = 'f0ck_url_upload_history'; + + // active jobs: jobId → { url, percent, stage, speed, eta, resolved, pollTimer } + const _active = new Map(); + + // history: [{ id, url, type, itemId, error, ts }] — newest first + let _history = (() => { + try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; } + })(); + + let _collapsed = false; + + // ── Panel DOM ───────────────────────────────────────────────────────────── + const _$ = id => document.getElementById(id); + + const _ensurePanel = () => { + if (_$('url-upload-tracker')) return; + const el = document.createElement('div'); + el.id = 'url-upload-tracker'; + el.innerHTML = ` +
+ + URL Uploads + +
+ + +
+
+
+
+
Recent
+
+
+ `; + document.body.appendChild(el); + _$('uut-btn-toggle').addEventListener('click', () => { + _collapsed = !_collapsed; + _$('uut-body').style.display = _collapsed ? 'none' : ''; + _$('uut-btn-toggle').textContent = _collapsed ? '+' : '−'; + }); + _$('uut-btn-clear').addEventListener('click', () => { + _history = []; + try { localStorage.removeItem(STORAGE_KEY); } catch {} + _renderHistory(); + _sync(); + }); + }; + + // ── Visibility + badge ──────────────────────────────────────────────────── + const _sync = () => { + _ensurePanel(); + const panel = _$('url-upload-tracker'); + const hasContent = _active.size > 0 || _history.length > 0; + panel.classList.toggle('uut-visible', hasContent); + + const badge = _$('uut-badge'); + if (badge) { badge.textContent = _active.size || ''; badge.style.display = _active.size ? '' : 'none'; } + + const div = _$('uut-divider'); + if (div) div.style.display = (_active.size > 0 && _history.length > 0) ? '' : 'none'; + + const clearBtn = _$('uut-btn-clear'); + if (clearBtn) clearBtn.style.display = _history.length > 0 ? '' : 'none'; + }; + + // ── Active job row ──────────────────────────────────────────────────────── + const _renderJob = (jobId) => { + _ensurePanel(); + const job = _active.get(jobId); + const list = _$('uut-active-list'); + if (!list || !job) return; + + let row = list.querySelector(`[data-jid="${CSS.escape(jobId)}"]`); + if (!row) { + row = document.createElement('div'); + row.className = 'uut-job'; + row.dataset.jid = jobId; + list.appendChild(row); + } + + const pct = Math.min(Math.max(job.percent || 0, 0), 100); + const labels = { queued:'Queued', downloading:'Downloading', analyzing:'Analyzing', saving:'Saving', processing:'Processing', extracting:'Extracting' }; + const label = labels[job.stage] || job.stage || 'Processing'; + const status = (job.stage === 'downloading' && pct > 0) ? `${label} ${pct.toFixed(1)}%` : label; + const speed = (job.speed && job.eta) ? `${job.speed} · ETA ${job.eta}` : (job.speed || ''); + + row.innerHTML = ` +
+
+
+ ${window.escapeHtmlUpload(job.url)} +
+ ${status} + ${speed ? `${speed}` : ''} +
+
+
+
+ `; + }; + + // ── History ─────────────────────────────────────────────────────────────── + const _saveHistory = () => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(_history)); } catch {} }; + + const _pushHistory = (entry) => { + _history.unshift(entry); + if (_history.length > MAX_HISTORY) _history.length = MAX_HISTORY; + _saveHistory(); + _renderHistory(); + }; + + const _renderHistory = () => { + _ensurePanel(); + const list = _$('uut-history-list'); + if (!list) return; + if (!_history.length) { list.innerHTML = ''; return; } + list.innerHTML = _history.map(h => { + const ok = h.type === 'upload_success'; + const icon = ok ? '' : ''; + const right = ok && h.itemId + ? `View →` + : (h.error ? `${window.escapeHtmlUpload(h.error)}` : ''); + return `
${icon}${window.escapeHtmlUpload(h.url)}
${right}
`; + }).join(''); + }; + + // ── Polling ─────────────────────────────────────────────────────────────── + const _stopPoll = (jobId) => { + const j = _active.get(jobId); + if (j?.pollTimer) { clearTimeout(j.pollTimer); j.pollTimer = null; } + }; + + const _startPolling = (jobId) => { + const job = _active.get(jobId); + if (!job || !jobId) return; + const poll = async () => { + if (!_active.has(jobId) || job.resolved) return; + try { + const resp = await fetch(`/api/v2/upload-url/progress/${jobId}?t=${Date.now()}`, { cache: 'no-store' }); + if (!resp.ok) { job.pollTimer = setTimeout(poll, POLL_INTERVAL); return; } + const state = await resp.json(); + if (!state.success) { job.pollTimer = setTimeout(poll, POLL_INTERVAL); return; } + if (state.percent !== undefined) job.percent = state.percent; + if (state.stage) job.stage = state.stage; + if (state.speed !== undefined) job.speed = state.speed; + if (state.eta !== undefined) job.eta = state.eta; + _renderJob(jobId); + if (state.done) { + job.pollTimer = null; + setTimeout(() => { if (!job.resolved) _resolveJob(jobId, { type: state.error ? 'upload_error' : 'upload_success', item_id: null, data: state.error ? { msg: state.error } : null }); }, 2000); + return; + } + } catch {} + job.pollTimer = setTimeout(poll, POLL_INTERVAL); + }; + job.pollTimer = setTimeout(poll, POLL_INTERVAL); + }; + + // ── Resolve ─────────────────────────────────────────────────────────────── + const _resolveJob = (jobId, notif) => { + const job = _active.get(jobId); + if (!job || job.resolved) return; + job.resolved = true; + _stopPoll(jobId); + + _pushHistory({ id: jobId, url: job.url, type: notif.type, itemId: notif.item_id || null, error: notif.data?.msg || (notif.type === 'upload_error' ? 'Upload failed' : null), ts: Date.now() }); + + // Fade out the active row then remove + const row = _$('uut-active-list')?.querySelector(`[data-jid="${CSS.escape(jobId)}"]`); + if (row) { row.classList.add('uut-job--fading'); setTimeout(() => row.remove(), 300); } + setTimeout(() => { _active.delete(jobId); _sync(); }, 350); + }; + + // ── Public API ──────────────────────────────────────────────────────────── + const addJob = (url, jobId) => { + if (!jobId) jobId = `local_${Date.now()}`; + _ensurePanel(); + _active.set(jobId, { url, percent: 0, stage: 'queued', speed: null, eta: null, resolved: false, pollTimer: null }); + _renderJob(jobId); + _sync(); + _startPolling(jobId); + return jobId; + }; + + const resolveByJobId = (jobId, notif) => { if (_active.has(jobId)) { _resolveJob(jobId, notif); return true; } return false; }; + const resolveByUrl = (notif, url) => { for (const [id, j] of _active) { if (!j.resolved && j.url === url) { _resolveJob(id, notif); return true; } } return false; }; + const resolveOldest = (notif) => { for (const [id, j] of _active) { if (!j.resolved) { _resolveJob(id, notif); return true; } } return false; }; + + // ── Notifications ───────────────────────────────────────────────────────── + document.addEventListener('f0ck:upload_bg_update', (e) => { + (e.detail || []).forEach(notif => { + let matched = false; + const jobId = notif.data?.jobId; + if (jobId) matched = resolveByJobId(jobId, notif); + if (!matched) { const url = notif.data?.url; if (url) matched = resolveByUrl(notif, url); } + }); + }); + + // ── Init: render persisted history on load ──────────────────────────────── + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { if (_history.length) { _ensurePanel(); _renderHistory(); _sync(); } }); + } else { + if (_history.length) { _ensurePanel(); _renderHistory(); _sync(); } + } + + window.urlUploadTracker = { addJob, resolveByJobId, resolveByUrl, resolveOldest }; +})(); + + + + // Throttled queue to capture the first frame of video files asynchronously without blocking the browser class VideoThumbnailQueue { constructor(concurrency = 3) { @@ -2132,6 +2350,10 @@ window.initUploadForm = (selector) => { if (data.success) { successCount++; lastData = data; + // If this is a background URL upload, register it with the tracker widget + if (data.pending && window.urlUploadTracker) { + window.urlUploadTracker.addJob(url, data.jobId || null); + } if (data.itemid) { try { const ts = Date.now(); @@ -2160,9 +2382,9 @@ window.initUploadForm = (selector) => { if (dragModal) dragModal.classList.remove('show'); if (window.resetGlobalScrollState) window.resetGlobalScrollState(); if (window.hideAllModals) window.hideAllModals(); - + form._f0ckUploader.reset(); - + if (isShitpost) { if (lastData?.manual_approval && typeof window.flashMessage === 'function') { window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning'); @@ -2172,6 +2394,9 @@ window.initUploadForm = (selector) => { if (typeof window.flashMessage === 'function') { window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning'); } + } else if (lastData?.pending) { + // Background URL upload — tracker widget is already showing; just navigate home + /* no additional message needed */ } else if (!dragModal && statusDiv) { statusDiv.innerHTML = '✓ ' + (lastData?.msg || 'Upload successful'); statusDiv.className = 'upload-status success'; @@ -2181,7 +2406,7 @@ window.initUploadForm = (selector) => { setTimeout(() => { if (typeof window.loadPageAjax === 'function') window.loadPageAjax('/'); else window.location.href = '/'; - }, dragModal ? 0 : 1000); + }, dragModal ? 0 : (lastData?.pending ? 0 : 1000)); } else { restoreBtn(); } @@ -2286,9 +2511,9 @@ window.initUploadForm = (selector) => { successCount++; lastData = res; if (res.pending) { - // Background URL download — show i18n toast - if (typeof window.flashMessage === 'function') { - window.flashMessage(window.f0ckI18n?.upload_url_queued_background || res.msg, 4000, 'info'); + // Background URL download — show tracker widget entry + if (window.urlUploadTracker) { + window.urlUploadTracker.addJob(item.url, res.jobId || null); } } if (res.itemid) { diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index b89393a..7676fb5 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -1,4 +1,5 @@ import { promises as fs } from "fs"; +import { spawn as _spawnRaw } from 'child_process'; import db from '../../sql.mjs'; import lib from '../../lib.mjs'; import cfg from '../../config.mjs'; @@ -6,6 +7,90 @@ import { applyWordFilter } from '../../wordfilter.mjs'; import queue from '../../queue.mjs'; import path from "path"; +// ────────────────────────────────────────────────────────────────────── +// In-memory job progress map (keyed by jobId string) +// Entries: { stage, percent, speed, eta, done, error } +// Auto-cleaned 10 min after completion. +// ────────────────────────────────────────────────────────────────────── +const progressMap = new Map(); + +const setProgress = (jobId, patch) => { + const existing = progressMap.get(jobId) || { stage: 'queued', percent: 0, speed: null, eta: null, done: false, error: null }; + progressMap.set(jobId, { ...existing, ...patch }); +}; + +const cleanupJob = (jobId) => { + setTimeout(() => progressMap.delete(jobId), 10 * 60 * 1000); +}; + +/** + * Like queue.spawn() but streams stderr lines to a callback for live progress, + * while still resolving with { stdout, stderr } when the process exits. + * Splits on both \n and \r so yt-dlp's carriage-return progress works. + */ +const spawnWithProgress = (cmd, args, onLine) => { + return new Promise((resolve, reject) => { + const child = _spawnRaw(cmd, args); + const stdoutChunks = []; + const stderrChunks = []; + let stderrBuf = ''; + + if (child.stdout) child.stdout.on('data', d => stdoutChunks.push(d)); + if (child.stderr) { + child.stderr.on('data', chunk => { + stderrChunks.push(chunk); + stderrBuf += chunk.toString(); + // Split on \r or \n (yt-dlp uses \r to overwrite progress in-place) + const parts = stderrBuf.split(/[\r\n]/); + // Keep the last (potentially incomplete) segment in the buffer + stderrBuf = parts.pop() ?? ''; + for (const line of parts) { + // Strip ANSI escape codes + const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim(); + if (clean && onLine) onLine(clean); + } + }); + } + + child.on('close', code => { + // Process any remaining buffered content + if (stderrBuf.trim() && onLine) { + const clean = stderrBuf.replace(/\x1b\[[0-9;]*m/g, '').trim(); + if (clean) onLine(clean); + } + const stdout = Buffer.concat(stdoutChunks).toString(); + const stderr = Buffer.concat(stderrChunks).toString(); + if (code !== 0) { + const err = new Error(`Command '${cmd} ${args.join(' ')}' failed with code ${code}`); + err.stderr = stderr; + err.stdout = stdout; + return reject(err); + } + resolve({ stdout, stderr }); + }); + child.on('error', err => { + err.stderr = Buffer.concat(stderrChunks).toString(); + err.stdout = Buffer.concat(stdoutChunks).toString(); + reject(err); + }); + }); +}; + +/** Parse a yt-dlp stderr line and return a progress patch object or null. */ +const parseYtdlpLine = (line) => { + // [download] 47.3% of ~ 58.23MiB at 3.22MiB/s ETA 00:13 + const dlMatch = line.match(/\[download\]\s+([\d.]+)%.*?at\s+([\d.]+\s*\S+\/s)(?:.*?ETA\s+(\S+))?/); + if (dlMatch) { + return { stage: 'downloading', percent: parseFloat(dlMatch[1]), speed: dlMatch[2] || null, eta: dlMatch[3] || null }; + } + // [download] Destination: ... + if (line.includes('[download] Destination:')) return { stage: 'downloading' }; + if (line.includes('[Merger]') || line.includes('[ffmpeg]')) return { stage: 'processing', percent: 99 }; + if (line.includes('[ExtractAudio]')) return { stage: 'extracting', percent: 99 }; + return null; +}; + + // Native multipart form data parser const parseMultipart = (buffer, boundary) => { const parts = {}; @@ -80,6 +165,15 @@ const collectBody = (req) => { export default router => { router.group(/^\/api\/v2/, group => { + // ── GET /api/v2/upload-url/progress/:jobId ────────────────────────────── + group.get(/\/upload-url\/progress\/(?[a-zA-Z0-9_-]+)$/, lib.loggedin, (req, res) => { + const jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop(); + const state = progressMap.get(jobId); + res.setHeader?.('Cache-Control', 'no-store'); + if (!state) return res.json({ success: false, msg: 'Job not found' }, 404); + return res.json({ success: true, ...state }); + }); + const saveComment = async (itemid, userid, content) => { if (!content || !content.trim()) return; try { @@ -345,9 +439,14 @@ export default router => { }; // Return immediately to avoid proxy timeouts + // Generate a client-side trackable job ID + const jobId = await queue.genuuid(); + setProgress(jobId, { stage: 'queued', percent: 0, speed: null, eta: null, done: false, error: null }); + res.json({ success: true, pending: true, + jobId, msg: 'URL processing started in background. You will receive a notification when it is finished.' }); @@ -392,7 +491,7 @@ export default router => { try { const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : []; - const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web']; + const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web', '--newline', '--no-colors']; let maxfilesize = cfg.main.maxfilesize; if (session.admin) maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier); @@ -403,9 +502,10 @@ export default router => { let source; console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`); - + setProgress(jobId, { stage: 'downloading', percent: 0 }); + try { - source = (await queue.spawn('yt-dlp', [ + source = (await spawnWithProgress('yt-dlp', [ ...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', url, @@ -414,22 +514,30 @@ export default router => { '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4' - ])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop(); + ], (line) => { + const patch = parseYtdlpLine(line); + if (patch) setProgress(jobId, patch); + })).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop(); } catch (err) { console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`); if (isInstagram) throw new Error(sanitizeError(err)); - + + setProgress(jobId, { stage: 'downloading', percent: 0, speed: null, eta: null }); try { - source = (await queue.spawn('yt-dlp', [ + source = (await spawnWithProgress('yt-dlp', [ ...proxyArgs, ...ytdlpArgs, url, '--max-filesize', `${maxfilesize / 1024}k`, '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath' - ])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop(); + ], (line) => { + const patch = parseYtdlpLine(line); + if (patch) setProgress(jobId, patch); + })).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop(); } catch (err2) { console.warn(`[UPLOAD-URL-ASYNC] Stage 2 failed: ${err2.message}`); console.log(`[UPLOAD-URL-ASYNC] Starting Stage 3 (curl) fallback for ${url}`); + setProgress(jobId, { stage: 'downloading', percent: 0, speed: null, eta: null }); const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.tmp`); let referer = url; try { @@ -474,6 +582,8 @@ export default router => { if (!source || source.match(/larger than/)) throw new Error('File too large or download failed'); + setProgress(jobId, { stage: 'analyzing', percent: 100, speed: null, eta: null }); + const { stat } = await import('fs/promises'); const size = (await stat(source)).size; if (size > maxfilesize) { @@ -515,7 +625,7 @@ export default router => { const repostSum = await queue.checkrepostsum(checksum); if (repostSum) { await fs.unlink(source).catch(() => {}); - await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${repostSum}, ${db.json({ url, msg: 'Duplicate detected (Checksum)' })})`; + await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${repostSum}, ${db.json({ jobId, url, msg: 'Duplicate detected (Checksum)' })})`; return; } } @@ -527,7 +637,7 @@ export default router => { const phashMatch = await queue.checkrepostphash(phash); if (phashMatch) { await fs.unlink(source).catch(() => {}); - await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${phashMatch}, ${db.json({ url, msg: 'Visual duplicate detected (PHash)' })})`; + await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${phashMatch}, ${db.json({ jobId, url, msg: 'Visual duplicate detected (PHash)' })})`; return; } } @@ -548,6 +658,7 @@ export default router => { } catch (e) { } } } + setProgress(jobId, { stage: 'saving', percent: 100 }); if (!linkedToExistingUrl) await fs.copyFile(source, path.join(destDir, filename)); await fs.unlink(source).catch(() => { }); @@ -640,12 +751,16 @@ export default router => { } // Completion notification - await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`; + setProgress(jobId, { stage: 'done', percent: 100, done: true, error: null }); + cleanupJob(jobId); + await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_success', 0, ${itemid}, ${db.json({ jobId })})`; } catch (err) { console.error('[UPLOAD-URL-ASYNC] Final Error:', err); // Error notification - await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ url, msg: sanitizeError(err) })})`; + setProgress(jobId, { stage: 'error', done: true, error: sanitizeError(err) }); + cleanupJob(jobId); + await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ jobId, url, msg: sanitizeError(err) })})`; } })(); }