diff --git a/lib/config-store.js b/lib/config-store.js index 96ee419..c990f92 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -26,6 +26,9 @@ const DEFAULTS = { globalSettings: { alwaysOnTop: false, shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart + logFilePath: '', + resumeQueueOnLaunch: true, + pendingQueue: null, scramble: { active: false, prefix: '', diff --git a/lib/hosters.js b/lib/hosters.js index c1d52c6..0421acb 100644 --- a/lib/hosters.js +++ b/lib/hosters.js @@ -41,7 +41,7 @@ const HOSTER_CONFIGS = { }, 'voe.sx': { apiBase: 'https://voe.sx', - serverEndpoints: ['/api/upload/server'], + serverEndpoints: ['/api/upload/server', '/api/v1/upload/server'], buildUploadUrl: (url, key) => appendKeyParam(url, key), formFields: () => ({}), parseResult: parseVoeResult @@ -176,9 +176,15 @@ function parseDoodstreamResult(payload) { // VOE: { file: { file_code } } function parseVoeResult(payload) { - const file_code = (payload.file && typeof payload.file === 'object') - ? payload.file.file_code - : null; + const source = payload && typeof payload === 'object' && payload.result && typeof payload.result === 'object' + ? payload.result + : payload; + const file = source && typeof source.file === 'object' ? source.file : null; + const file_code = file?.file_code + || file?.filecode + || source?.file_code + || source?.filecode + || null; return { download_url: file_code ? `https://voe.sx/${file_code}` : null, @@ -353,26 +359,64 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress, throttle, signal); - const { body, statusCode } = await request(targetUrl, { + const { body, statusCode, headers } = await request(targetUrl, { method: 'POST', body: iterable, signal, headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': String(totalSize) + 'Content-Length': String(totalSize), + 'Accept': 'application/json, text/plain;q=0.9, */*;q=0.8', + 'User-Agent': 'multi-hoster-uploader/1.1' }, headersTimeout: UPLOAD_TIMEOUT, bodyTimeout: UPLOAD_TIMEOUT }); - const payload = await body.json(); + const rawBody = await body.text(); + let payload = null; + try { + payload = rawBody ? JSON.parse(rawBody) : {}; + } catch { + const snippet = rawBody ? rawBody.slice(0, 240).replace(/\s+/g, ' ').trim() : ''; + throw new Error( + `Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}` + ); + } + + if (statusCode < 200 || statusCode >= 300) { + throw new Error( + payload.msg + || payload.message + || `Upload fehlgeschlagen (HTTP ${statusCode}${headers?.['content-type'] ? `, ${headers['content-type']}` : ''})` + ); + } if (payload.status && [401, 403, 429, 500].includes(payload.status)) { throw new Error(payload.msg || payload.message || JSON.stringify(payload)); } - // Step 3: Parse result - return config.parseResult(payload); + const result = config.parseResult(payload); + if (result?.file_code || result?.download_url || result?.embed_url) { + return result; + } + + if (payload.success === false) { + throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`); + } + + throw new Error( + payload.msg + || payload.message + || `Upload zu ${hosterName} lieferte keine verwendbaren Dateidaten zurueck.` + ); } -module.exports = { uploadFile, HOSTER_CONFIGS }; +module.exports = { + uploadFile, + HOSTER_CONFIGS, + __test: { + extractUploadServerUrl, + parseVoeResult + } +}; diff --git a/main.js b/main.js index 7cd8ff0..d429b25 100644 --- a/main.js +++ b/main.js @@ -58,24 +58,64 @@ function normalizeApiError(payload, fallback) { return fallback; } -function getLogFilePath() { - // Next to the EXE when packaged, next to project root when dev +function getDefaultLogFilePath() { const baseDir = app.isPackaged ? path.dirname(process.execPath) : path.join(__dirname); return path.join(baseDir, 'fileuploader.log'); } +function getLogFilePath() { + const config = configStore.load(); + const customPath = config && config.globalSettings + ? String(config.globalSettings.logFilePath || '').trim() + : ''; + return customPath || getDefaultLogFilePath(); +} + function appendUploadLog(hoster, link, fileName) { try { + const logPath = getLogFilePath(); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; const line = `${dateStr}|${hoster}|${link}||${fileName}|\n`; - fs.appendFileSync(getLogFilePath(), line, 'utf-8'); + fs.appendFileSync(logPath, line, 'utf-8'); } catch {} } +function buildUploadTasks(config, files, hosters) { + const tasks = []; + + for (const file of files) { + for (const hoster of hosters) { + const hosterConfig = config.hosters[hoster]; + if (!hosterConfig) { + debugLog(` skip ${hoster}: no config`); + continue; + } + + if (hoster === 'vidmoly.me') { + if (!hosterConfig.username || !hosterConfig.password) { + debugLog(` skip ${hoster}: missing username/password`); + continue; + } + tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); + } else { + if (!hosterConfig.apiKey) { + debugLog(` skip ${hoster}: missing apiKey`); + continue; + } + tasks.push({ file, hoster, apiKey: hosterConfig.apiKey }); + debugLog(` task: ${hoster} key=${hosterConfig.apiKey.slice(0, 6)}...`); + } + } + } + + return tasks; +} + async function checkDoodstreamHealth(hosterConfig) { const apiKey = hosterConfig && hosterConfig.apiKey ? String(hosterConfig.apiKey).trim() @@ -216,7 +256,7 @@ function createWindow() { height: 750, minWidth: 800, minHeight: 550, - backgroundColor: '#0f0f1a', + backgroundColor: '#16181c', autoHideMenuBar: true, webPreferences: { contextIsolation: true, @@ -322,34 +362,7 @@ ipcMain.handle('start-upload', (_event, payload) => { debugLog(`start-upload: files=${JSON.stringify(files)}, hosters=${JSON.stringify(hosters)}`); - // Build tasks with credentials - const tasks = []; - for (const file of files) { - for (const hoster of hosters) { - const hosterConfig = config.hosters[hoster]; - if (!hosterConfig) { - debugLog(` skip ${hoster}: no config`); - continue; - } - - if (hoster === 'vidmoly.me') { - // Vidmoly uses username/password login - if (!hosterConfig.username || !hosterConfig.password) { - debugLog(` skip ${hoster}: missing username/password`); - continue; - } - tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); - } else { - // Other hosters use API key - if (!hosterConfig.apiKey) { - debugLog(` skip ${hoster}: missing apiKey`); - continue; - } - tasks.push({ file, hoster, apiKey: hosterConfig.apiKey }); - debugLog(` task: ${hoster} key=${hosterConfig.apiKey.slice(0, 6)}...`); - } - } - } + const tasks = buildUploadTasks(config, files, hosters); debugLog(` tasks built: ${tasks.length}`); diff --git a/renderer/app.js b/renderer/app.js index 6d7fd41..3fb523e 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -2,11 +2,13 @@ const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']; // --- State --- let selectedFiles = []; // { path, name, size } +let selectedUploadHosters = []; let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; let hosterSettings = {}; let uploading = false; let healthCheckRunning = false; let autoHealthCheckEnabled = true; +let queuePersistTimer = null; const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload'; // Queue state @@ -23,11 +25,21 @@ async function init() { config = await window.api.getConfig(); hosterSettings = config.hosterSettings || {}; autoHealthCheckEnabled = loadAutoCheckPreference(); - renderHosterChips(); + syncSelectedUploadHosters(); + restoreQueueStateFromConfig(); + renderHosterSummary(); + renderHosterModal(); renderSettings(); setupListeners(); setupDragDrop(); loadHistory(); + updateUploadView(); + + if (shouldAutoResumeQueue()) { + setTimeout(() => { + if (!uploading) startUpload(); + }, 350); + } // Version display try { @@ -75,37 +87,215 @@ document.querySelectorAll('.tab').forEach(tab => { }); }); -// --- Hoster chips --- +// --- Hoster selection --- function hosterHasCredentials(name, hoster) { if (name === 'vidmoly.me') return !!(hoster.username && hoster.password); return !!hoster.apiKey; } -function renderHosterChips() { - const container = document.getElementById('hosterSelect'); - container.innerHTML = ''; - for (const name of HOSTERS) { - const hoster = config.hosters[name] || {}; - const hasCreds = hosterHasCredentials(name, hoster); - const chip = document.createElement('label'); - chip.className = 'hoster-chip' + (hoster.enabled && hasCreds ? ' selected' : '') + (!hasCreds ? ' no-key' : ''); - chip.innerHTML = ` - - - ${name} - `; - chip.querySelector('input').addEventListener('change', (e) => { - chip.classList.toggle('selected', e.target.checked); - if (!uploading && selectedFiles.length > 0) buildQueuePreview(); - updateStartButton(); +function getAvailableHosters() { + return HOSTERS + .map(name => { + const hoster = config.hosters[name] || {}; + return { name, hoster, hasCreds: hosterHasCredentials(name, hoster) }; + }) + .filter(item => item.hasCreds); +} + +function syncSelectedUploadHosters() { + const available = new Set(getAvailableHosters().map(item => item.name)); + selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name)); + if (selectedUploadHosters.length === 0) { + selectedUploadHosters = HOSTERS.filter(name => { + const hoster = config.hosters[name] || {}; + return !!hoster.enabled && hosterHasCredentials(name, hoster); }); - container.appendChild(chip); } } function getSelectedHosters() { - return Array.from(document.querySelectorAll('#hosterSelect input:checked')) - .map(cb => cb.dataset.hoster); + return selectedUploadHosters.slice(); +} + +function renderHosterSummary() { + const summary = document.getElementById('hosterSummary'); + if (!summary) return; + const hosters = getSelectedHosters(); + if (hosters.length === 0) { + summary.textContent = 'Keine Upload-Ziele ausgewaehlt'; + } else if (hosters.length === 1) { + summary.textContent = `Aktives Ziel: ${hosters[0]}`; + } else { + summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.join(', ')}`; + } +} + +function renderHosterModal() { + const list = document.getElementById('hosterModalList'); + const hint = document.getElementById('hosterModalHint'); + if (!list || !hint) return; + + const available = getAvailableHosters(); + if (available.length === 0) { + list.innerHTML = ''; + hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Einstellungen API-Key oder Login eintragen.'; + return; + } + + list.innerHTML = available.map(item => { + const checked = selectedUploadHosters.includes(item.name); + const subtitle = item.name === 'vidmoly.me' ? 'Login hinterlegt' : 'API-Key hinterlegt'; + return ` + + `; + }).join(''); + + hint.textContent = 'Die Auswahl wird fuer neue Queue-Eintraege verwendet.'; + + list.querySelectorAll('input[data-hoster-modal]').forEach(input => { + input.addEventListener('change', () => { + input.closest('.hoster-option')?.classList.toggle('selected', input.checked); + }); + }); +} + +function openHosterModal() { + syncSelectedUploadHosters(); + renderHosterModal(); + document.getElementById('hosterModal').style.display = 'flex'; +} + +function closeHosterModal() { + const modal = document.getElementById('hosterModal'); + if (modal) modal.style.display = 'none'; +} + +function applyHosterSelection() { + selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked')) + .map(input => input.dataset.hosterModal); + renderHosterSummary(); + if (!uploading && selectedFiles.length > 0) buildQueuePreview(); + updateStartButton(); + persistQueueStateSoon(); + closeHosterModal(); +} + +function normalizeRestoredJobStatus(status) { + if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview') return status; + return 'queued'; +} + +function restoreQueueStateFromConfig() { + const pending = config?.globalSettings?.pendingQueue; + if (!pending || typeof pending !== 'object') return; + + selectedUploadHosters = Array.isArray(pending.selectedUploadHosters) + ? pending.selectedUploadHosters.filter(Boolean) + : selectedUploadHosters; + + selectedFiles = Array.isArray(pending.selectedFiles) + ? pending.selectedFiles + .filter(file => file && file.path) + .map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 })) + : []; + + queueJobs = Array.isArray(pending.queueJobs) + ? pending.queueJobs + .filter(job => job && job.fileName && job.hoster) + .map(job => ({ + id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + uploadId: null, + file: job.file || '', + fileName: job.fileName, + hoster: job.hoster, + status: normalizeRestoredJobStatus(job.status), + bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0, + bytesTotal: job.bytesTotal || 0, + speedKbs: 0, + elapsed: 0, + remaining: 0, + error: job.error || null, + result: job.result || null, + attempt: 0, + maxAttempts: job.maxAttempts || 0, + link: '', + progress: job.status === 'done' ? 1 : 0 + })) + : []; +} + +function buildPersistedQueueState() { + const unfinishedJobs = queueJobs.filter(job => job.status !== 'done' && job.status !== 'skipped'); + const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file])); + + for (const job of unfinishedJobs) { + if (job.file && !selectedFileMap.has(job.file)) { + selectedFileMap.set(job.file, { + path: job.file, + name: job.fileName, + size: job.bytesTotal || 0 + }); + } + } + + if (selectedFileMap.size === 0 && queueJobs.every(job => job.status === 'done' || job.status === 'skipped')) { + return null; + } + + return { + selectedUploadHosters: getSelectedHosters(), + selectedFiles: Array.from(selectedFileMap.values()), + queueJobs: queueJobs.map(job => ({ + id: job.id, + file: job.file, + fileName: job.fileName, + hoster: job.hoster, + status: job.status, + bytesTotal: job.bytesTotal || 0, + error: job.error || null, + result: job.result || null, + maxAttempts: job.maxAttempts || 0 + })) + }; +} + +async function persistQueueStateNow() { + const globalSettings = { + ...(config.globalSettings || {}), + pendingQueue: buildPersistedQueueState() + }; + config.globalSettings = globalSettings; + await window.api.saveGlobalSettings(globalSettings); +} + +function persistQueueStateSoon() { + clearTimeout(queuePersistTimer); + queuePersistTimer = setTimeout(() => { + persistQueueStateNow().catch(() => {}); + }, 250); +} + +function clearPersistedQueueStateSoon() { + clearTimeout(queuePersistTimer); + queuePersistTimer = setTimeout(() => { + const globalSettings = { + ...(config.globalSettings || {}), + pendingQueue: null + }; + config.globalSettings = globalSettings; + window.api.saveGlobalSettings(globalSettings).catch(() => {}); + }, 0); +} + +function shouldAutoResumeQueue() { + if (!config?.globalSettings?.resumeQueueOnLaunch) return false; + return queueJobs.some(job => !['done', 'skipped'].includes(job.status)); } // --- File selection --- @@ -132,39 +322,47 @@ function setupDragDrop() { } function addDroppedFiles(fileList) { + let added = 0; const files = Array.from(fileList); for (const file of files) { if (!selectedFiles.find(f => f.path === file.path)) { selectedFiles.push({ path: file.path, name: file.name, size: file.size }); + added++; } } updateUploadView(); + persistQueueStateSoon(); + if (added > 0) openHosterModal(); } async function pickFiles() { const paths = await window.api.selectFiles(); if (!paths) return; + let added = 0; for (const p of paths) { if (!selectedFiles.find(f => f.path === p)) { const name = p.split('\\').pop().split('/').pop(); selectedFiles.push({ path: p, name, size: null }); // size resolved by upload-manager + added++; } } updateUploadView(); + persistQueueStateSoon(); + if (added > 0) openHosterModal(); } function updateUploadView() { const dropZone = document.getElementById('dropZone'); - const queueContainer = document.getElementById('queueContainer'); + const queueShell = document.getElementById('queueShell'); const queueActions = document.getElementById('queueActions'); if (selectedFiles.length === 0 && queueJobs.length === 0) { dropZone.style.display = 'flex'; - queueContainer.style.display = 'none'; + queueShell.style.display = 'none'; queueActions.style.display = 'none'; } else { dropZone.style.display = 'none'; - queueContainer.style.display = 'block'; + queueShell.style.display = 'flex'; queueActions.style.display = 'flex'; if (!uploading && selectedFiles.length > 0) { buildQueuePreview(); @@ -183,6 +381,12 @@ function updateStartButton() { // Build preview jobs from selected files x selected hosters (before upload starts) function buildQueuePreview() { const hosters = getSelectedHosters(); + if (hosters.length === 0) { + queueJobs = queueJobs.filter(j => j.status !== 'preview'); + renderQueueTable(); + persistQueueStateSoon(); + return; + } // Remove old preview jobs (status 'preview') queueJobs = queueJobs.filter(j => j.status !== 'preview'); @@ -202,6 +406,7 @@ function buildQueuePreview() { } } renderQueueTable(); + persistQueueStateSoon(); } // --- Queue Table Rendering (debounced) --- @@ -371,7 +576,10 @@ document.addEventListener('click', (e) => { if (!e.target.closest('.context-menu')) hideContextMenu(); }); document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') hideContextMenu(); + if (e.key === 'Escape') { + hideContextMenu(); + closeHosterModal(); + } }); document.getElementById('contextMenu').addEventListener('click', (e) => { @@ -394,10 +602,17 @@ async function handleContextAction(action) { selectedJobIds.clear(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } + persistQueueStateSoon(); } else if (action === 'copy-all-links') { copyAllLinks(); } else if (action === 'delete-all') { - if (!uploading) { queueJobs = []; selectedFiles = []; selectedJobIds.clear(); updateUploadView(); } + if (!uploading) { + queueJobs = []; + selectedFiles = []; + selectedJobIds.clear(); + updateUploadView(); + clearPersistedQueueStateSoon(); + } } else if (action === 'always-on-top') { alwaysOnTopState = !alwaysOnTopState; await window.api.setAlwaysOnTop(alwaysOnTopState); @@ -471,6 +686,7 @@ async function startUpload() { console.log('[startUpload] sending payload:', uploadPayload); const result = await window.api.startUpload(uploadPayload); console.log('[startUpload] response:', result); + persistQueueStateSoon(); if (result && result.error) { alert(result.error); @@ -486,6 +702,7 @@ async function cancelUpload() { document.getElementById('startUploadBtn').style.display = 'inline-block'; document.getElementById('cancelUploadBtn').style.display = 'none'; updateStartButton(); + persistQueueStateSoon(); } // --- Progress handling --- @@ -529,6 +746,7 @@ function handleProgress(data) { job.progress = data.progress || 0; scheduleQueueRender(); + persistQueueStateSoon(); } function handleBatchDone(summary) { @@ -539,6 +757,8 @@ function handleBatchDone(summary) { document.getElementById('cancelUploadBtn').style.display = 'none'; updateStartButton(); renderQueueTable(); + loadHistory(); + clearPersistedQueueStateSoon(); // Final stats update document.getElementById('sbState').textContent = 'Fertig'; @@ -574,6 +794,7 @@ function retrySelectedJobs() { selectedJobIds.clear(); renderQueueTable(); updateStartButton(); + persistQueueStateSoon(); } // --- Health Check --- @@ -623,6 +844,29 @@ function renderSettings() { const container = document.getElementById('settingsHosters'); container.innerHTML = ''; + const globalSettings = config.globalSettings || {}; + const generalPanel = document.createElement('div'); + generalPanel.className = 'hoster-settings-panel'; + generalPanel.innerHTML = ` +
Noch keine Uploads.
'; + renderRecentUploadsPanel(); return; } @@ -781,6 +1043,41 @@ async function loadHistory() { } renderHistoryTable(container); + renderRecentUploadsPanel(); +} + +function renderRecentUploadsPanel() { + const tbody = document.getElementById('recentFilesBody'); + if (!tbody) return; + if (!historyRowsData.length) { + tbody.innerHTML = '