diff --git a/lib/doodstream-upload.js b/lib/doodstream-upload.js index a111ac1..525fa5d 100644 --- a/lib/doodstream-upload.js +++ b/lib/doodstream-upload.js @@ -7,6 +7,13 @@ const BASE_URL = 'https://doodstream.com'; const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; const UPLOAD_TIMEOUT = 1800000; // 30 min +function _debugLog(msg) { + try { + const ts = new Date().toISOString(); + fs.appendFileSync(path.join(process.cwd(), 'doodstream-debug.log'), `[${ts}] ${msg}\n`); + } catch {} +} + class DoodstreamUploader { constructor() { this.cookies = new Map(); @@ -193,73 +200,34 @@ class DoodstreamUploader { // Build multipart form const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`; - const fileStream = fs.createReadStream(filePath); // Build form parts - const fields = { - sess_id: this.sessId, - utype: 'reg' - }; + let preamble = ''; + preamble += `--${boundary}\r\nContent-Disposition: form-data; name="sess_id"\r\n\r\n${this.sessId}\r\n`; + preamble += `--${boundary}\r\nContent-Disposition: form-data; name="utype"\r\n\r\nreg\r\n`; + preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`; - const preamble = []; - for (const [key, val] of Object.entries(fields)) { - preamble.push( - `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${val}\r\n` - ); + const epilogue = `\r\n--${boundary}--\r\n`; + const preambleBuf = Buffer.from(preamble, 'utf-8'); + const epilogueBuf = Buffer.from(epilogue, 'utf-8'); + const totalSize = preambleBuf.length + fileSize + epilogueBuf.length; + + const CHUNK_SIZE = 256 * 1024; + let bytesRead = 0; + + const self = this; + async function* generate() { + yield preambleBuf; + const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE }); + for await (const chunk of fileStream) { + if (signal && signal.aborted) throw new Error('Aborted'); + if (throttle) await throttle.consume(chunk.length, signal); + bytesRead += chunk.length; + yield chunk; + if (progressCb) progressCb(bytesRead, fileSize); + } + yield epilogueBuf; } - preamble.push( - `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n` - ); - - const preambleBuffer = Buffer.from(preamble.join('')); - const epilogue = Buffer.from(`\r\n--${boundary}--\r\n`); - const totalSize = preambleBuffer.length + fileSize + epilogue.length; - - // Assemble body - const { Readable } = require('stream'); - let bytesSent = 0; - - const bodyStream = new Readable({ - read() {} - }); - - // Push preamble - bodyStream.push(preambleBuffer); - bytesSent += preambleBuffer.length; - - // Pipe file with throttle support - fileStream.on('data', (chunk) => { - if (signal && signal.aborted) { - fileStream.destroy(); - bodyStream.destroy(); - return; - } - if (throttle) { - fileStream.pause(); - throttle.consume(chunk.length, signal).then(() => { - bodyStream.push(chunk); - bytesSent += chunk.length; - if (progressCb) progressCb(Math.max(0, bytesSent - preambleBuffer.length), fileSize); - fileStream.resume(); - }).catch(() => { - fileStream.destroy(); - bodyStream.destroy(); - }); - } else { - bodyStream.push(chunk); - bytesSent += chunk.length; - if (progressCb) progressCb(Math.max(0, bytesSent - preambleBuffer.length), fileSize); - } - }); - - fileStream.on('end', () => { - bodyStream.push(epilogue); - bodyStream.push(null); - }); - - fileStream.on('error', (err) => { - bodyStream.destroy(err); - }); const uploadRes = await request(uploadUrl, { method: 'POST', @@ -269,83 +237,190 @@ class DoodstreamUploader { 'User-Agent': USER_AGENT, 'Cookie': this._cookieHeader() }, - body: bodyStream, + body: generate(), signal, bodyTimeout: UPLOAD_TIMEOUT, headersTimeout: 60000 }); const statusCode = uploadRes.statusCode; + _debugLog(`Upload response status: ${statusCode}`); + + // Handle redirects from upload server (undici doesn't follow them) + if ([301, 302, 303, 307, 308].includes(statusCode)) { + const location = uploadRes.headers['location']; + try { await uploadRes.body.text(); } catch {} + _debugLog(`Upload redirect to: ${location}`); + if (location) { + return this._handleUploadResult(location); + } + } + const resText = await uploadRes.body.text(); - let payload; - try { payload = JSON.parse(resText); } catch {} + _debugLog(`Upload response body (first 500): ${resText.slice(0, 500)}`); if (statusCode >= 400) { + let payload; + try { payload = JSON.parse(resText); } catch {} const msg = payload && payload.msg ? payload.msg : resText.slice(0, 200); throw new Error(`Doodstream Upload HTTP ${statusCode}: ${msg}`); } - if (!payload) { - // Try to extract filecode directly from HTML - const codeMatch = resText.match(/filecode['":\s]+['"]([a-zA-Z0-9]+)['"]/i); - if (codeMatch) { - return { - download_url: `https://doodstream.com/d/${codeMatch[1]}`, - embed_url: `https://doodstream.com/e/${codeMatch[1]}`, - file_code: codeMatch[1] - }; - } + return this._parseUploadResponse(resText); + } - // Follow HTML form redirect (two-step upload) - const formAction = resText.match(/]*action=['"]([^'"]+)['"]/i); - if (formAction) { - const hiddenFields = {}; - const inputRegex = /]*type=['"]hidden['"][^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi; - let m; - while ((m = inputRegex.exec(resText)) !== null) { - hiddenFields[m[1]] = m[2]; - } - // Also try reversed attribute order (value before name) - const inputRegex2 = /]*value=['"]([^'"]*)['""][^>]*name=['"]([^'"]+)['"]/gi; - while ((m = inputRegex2.exec(resText)) !== null) { - if (!hiddenFields[m[2]]) hiddenFields[m[2]] = m[1]; - } + /** + * Follow a redirect URL from upload server and extract filecode + */ + async _handleUploadResult(url) { + _debugLog(`Following upload result URL: ${url}`); + const res = await this._fetch(url); + const html = await res.text(); + _debugLog(`Result page (first 500): ${html.slice(0, 500)}`); + return this._parseUploadResponse(html); + } - const formData = new URLSearchParams(hiddenFields); - const followRes = await this._fetch(formAction[1], { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: formData.toString() - }); - const followText = await followRes.text(); + /** + * Extract hidden form fields from HTML (handles various attribute orders) + */ + _extractHiddenFields(html) { + const fields = {}; + // Textarea fields: + const ta = /]*name=['"]([^'"]+)['"][^>]*>([\s\S]*?)<\/textarea>/gi; + let m; + while ((m = ta.exec(html)) !== null) fields[m[1]] = m[2].trim(); + // Input hidden fields + const p1 = /]*type=['"]hidden['"][^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi; + while ((m = p1.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; } + const p2 = /]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi; + while ((m = p2.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; } + const p3 = /]*value=['"]([^'"]*)['"]\s[^>]*name=['"]([^'"]+)['"]/gi; + while ((m = p3.exec(html)) !== null) { if (!fields[m[2]]) fields[m[2]] = m[1]; } + return fields; + } - // Try JSON from follow response - let followPayload; - try { followPayload = JSON.parse(followText); } catch {} - if (followPayload) { - payload = followPayload; - } else { - // Try filecode from follow response HTML - const followCode = followText.match(/filecode['":\s]+['"]([a-zA-Z0-9]+)['"]/i); - if (followCode) { - return { - download_url: `https://doodstream.com/d/${followCode[1]}`, - embed_url: `https://doodstream.com/e/${followCode[1]}`, - file_code: followCode[1] - }; - } - throw new Error(`Doodstream Upload: Redirect-Antwort ungueltig (${followText.slice(0, 150)})`); - } - } else { - throw new Error(`Doodstream Upload: Keine gueltige Antwort (HTTP ${statusCode}, Body: ${resText.slice(0, 150)})`); - } + /** + * Parse filecode from upload server response (JSON or HTML) + */ + async _parseUploadResponse(resText) { + // 1. Try JSON + let payload; + try { payload = JSON.parse(resText); } catch {} + + if (payload) { + return this._extractFromJson(payload); } + // 2. Try filecode directly in HTML + const code = this._findFilecodeInHtml(resText); + if (code) { + _debugLog(`Found filecode in HTML: ${code}`); + return this._buildResult(code); + } + + // 3. Parse HTML form (XFileSharing two-step upload) + const hiddenFields = this._extractHiddenFields(resText); + _debugLog(`Hidden fields: ${JSON.stringify(hiddenFields)}`); + + // Check if filecode is already in hidden fields + const fnCode = hiddenFields.fn || hiddenFields.filecode || hiddenFields.file_code; + if (fnCode && fnCode.length >= 8) { + _debugLog(`Filecode from hidden field 'fn': ${fnCode}`); + // We still need to submit the form so doodstream registers the file + // But the filecode is the 'fn' value + } + + // XFileSharing standard: form with op=upload_result, fn, st + // Always submit to doodstream.com, not to CDN + if (hiddenFields.fn || hiddenFields.op === 'upload_result') { + // Ensure op=upload_result is set + if (!hiddenFields.op) hiddenFields.op = 'upload_result'; + + _debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`); + const formData = new URLSearchParams(hiddenFields); + const followRes = await this._fetch(BASE_URL + '/', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': BASE_URL + '/' + }, + body: formData.toString() + }); + const followText = await followRes.text(); + _debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`); + + // Try to find filecode in result page + const resultCode = this._findFilecodeInHtml(followText); + if (resultCode) { + return this._buildResult(resultCode); + } + + // If we had fn from hidden fields, use that as filecode + if (fnCode && fnCode.length >= 8) { + return this._buildResult(fnCode); + } + + // Try download URL pattern in result page + const dlMatch = followText.match(/https?:\/\/[a-z0-9.]+\/d\/([a-zA-Z0-9]+)/i); + if (dlMatch) { + return this._buildResult(dlMatch[1]); + } + + throw new Error(`Doodstream Upload: upload_result Seite hat keinen filecode (${followText.slice(0, 150)})`); + } + + // 4. Fallback: follow form action as-is (for non-XFS forms) + const formAction = resText.match(/]*action=['"]([^'"]+)['"]/i); + if (formAction) { + _debugLog(`Fallback: following form action ${formAction[1]}`); + const formData = new URLSearchParams(hiddenFields); + const followRes = await this._fetch(formAction[1], { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': BASE_URL + '/' + }, + body: formData.toString() + }); + const followText = await followRes.text(); + _debugLog(`Fallback response (first 500): ${followText.slice(0, 500)}`); + + const fallbackCode = this._findFilecodeInHtml(followText); + if (fallbackCode) return this._buildResult(fallbackCode); + + // Check if fn was in original hidden fields + if (fnCode && fnCode.length >= 8) return this._buildResult(fnCode); + + throw new Error(`Doodstream Upload: Redirect-Antwort ungueltig (${followText.slice(0, 150)})`); + } + + throw new Error(`Doodstream Upload: Keine gueltige Antwort (Body: ${resText.slice(0, 150)})`); + } + + /** + * Search for filecode patterns in HTML + */ + _findFilecodeInHtml(html) { + // filecode: "xxx" or filecode = "xxx" + const m1 = html.match(/filecode['":\s]+['"]([a-zA-Z0-9]{8,})['"]/i); + if (m1) return m1[1]; + // file_code: "xxx" + const m2 = html.match(/file_code['":\s]+['"]([a-zA-Z0-9]{8,})['"]/i); + if (m2) return m2[1]; + // Download URL pattern: /d/FILECODE + const m3 = html.match(/\/d\/([a-zA-Z0-9]{8,})/); + if (m3) return m3[1]; + return null; + } + + /** + * Extract result from JSON payload + */ + _extractFromJson(payload) { if (payload.status && Number(payload.status) !== 200 && payload.msg) { throw new Error(`Doodstream Upload: ${payload.msg}`); } - // Parse result let item = null; const result = payload.result; if (Array.isArray(result) && result.length > 0) { @@ -359,13 +434,20 @@ class DoodstreamUploader { } const fileCode = item.filecode || item.file_code || ''; - return { download_url: item.download_url || item.protected_dl || (fileCode ? `https://doodstream.com/d/${fileCode}` : null), embed_url: item.protected_embed || (fileCode ? `https://doodstream.com/e/${fileCode}` : null), file_code: fileCode }; } + + _buildResult(fileCode) { + return { + download_url: `https://doodstream.com/d/${fileCode}`, + embed_url: `https://doodstream.com/e/${fileCode}`, + file_code: fileCode + }; + } } module.exports = DoodstreamUploader; diff --git a/main.js b/main.js index ef42c01..2462d33 100644 --- a/main.js +++ b/main.js @@ -425,6 +425,7 @@ function createWindow() { } }); + mainWindow.webContents.setBackgroundThrottling(false); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); } diff --git a/renderer/app.js b/renderer/app.js index 6aa481f..3e88b76 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -476,8 +476,10 @@ function updateQueueActionButtons() { const hasSelection = selectedJobIds.size > 0; const hasUploadSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['done', 'error', 'aborted', 'skipped'].includes(job.status)); const hasAbortSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued', 'getting-server', 'uploading', 'retrying'].includes(job.status)); + const hasStartableSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued'].includes(job.status)); const hasMovableSelection = hasSelection && !uploading; + const startSelectedBtn = document.getElementById('startSelectedBtn'); const reuploadBtn = document.getElementById('reuploadSelectedBtn'); const abortSelectedBtn = document.getElementById('abortSelectedBtn'); const finishStopBtn = document.getElementById('finishStopBtn'); @@ -487,6 +489,7 @@ function updateQueueActionButtons() { const moveDownBtn = document.getElementById('moveDownBtn'); const moveBottomBtn = document.getElementById('moveBottomBtn'); + if (startSelectedBtn) startSelectedBtn.disabled = uploading || !hasStartableSelection || getSelectedHosters().length === 0; if (reuploadBtn) reuploadBtn.disabled = !hasUploadSelection; if (abortSelectedBtn) abortSelectedBtn.disabled = !hasAbortSelection; if (finishStopBtn) finishStopBtn.disabled = !uploading; @@ -921,6 +924,64 @@ async function startUpload() { } } +async function startSelectedUpload() { + if (healthCheckRunning || uploading) return; + + const hosters = getSelectedHosters(); + if (hosters.length === 0) { alert('Bitte mindestens einen Hoster auswählen.'); return; } + + const jobsToStart = queueJobs.filter((job) => selectedJobIds.has(job.id) && (job.status === 'preview' || job.status === 'queued')); + if (jobsToStart.length === 0) return; + + // Auto health check + if (autoHealthCheckEnabled) { + const checkHosters = hosters.filter(name => name === 'doodstream.com' || name === 'vidmoly.me' || name === 'voe.sx' || name === 'byse.sx'); + if (checkHosters.length > 0) { + healthCheckRunning = true; + try { + const rows = await executeHealthCheck(checkHosters, 'auto'); + const errors = rows.filter(r => r.status === 'error'); + if (errors.length > 0) { + alert(`Auto-Check fehlgeschlagen:\n${errors.map(r => `${r.hoster}: ${r.message}`).join('\n')}\n\nUpload wurde nicht gestartet.`); + return; + } + } catch (err) { + alert(`Auto-Check fehlgeschlagen: ${err.message}\nUpload wurde nicht gestartet.`); + return; + } finally { + healthCheckRunning = false; + } + } + } + + uploading = true; + jobsToStart.forEach(j => { + if (j.status === 'preview') j.status = 'queued'; + }); + updateQueueActionButtons(); + renderQueueTable(); + updateStatusBar(); + + const uploadPayload = { + hosters, + jobs: jobsToStart.map((job) => ({ + id: job.id, + file: job.file, + fileName: job.fileName, + hoster: job.hoster + })) + }; + const result = await window.api.startUpload(uploadPayload); + persistQueueStateSoon(); + + if (result && result.error) { + alert(result.error); + uploading = false; + updateQueueActionButtons(); + updateStatusBar(); + } +} + async function cancelUpload() { await window.api.cancelUpload(); uploading = false; @@ -1373,6 +1434,10 @@ function renderSettings() {
+
+ + +
@@ -1501,6 +1566,16 @@ async function saveSettings(options = {}) { globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024)) }; + // Always on top setting + const aotCheckbox = document.getElementById('alwaysOnTopInput'); + if (aotCheckbox) { + const newAot = !!aotCheckbox.checked; + if (newAot !== alwaysOnTopState) { + alwaysOnTopState = newAot; + await window.api.setAlwaysOnTop(alwaysOnTopState); + } + } + for (const name of HOSTERS) { const hs = { ...(hosterSettings[name] || {}) }; document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => { @@ -1845,7 +1920,7 @@ async function loadHistory() { const dt = formatDateTime(batch.timestamp || new Date()); for (const file of (batch.files || [])) { for (const result of (file.results || [])) { - if (result.status === 'aborted') continue; + if (result.status === 'aborted' || result.status === 'error') continue; const isError = result.status === 'error'; historyRowsData.push({ date: dt.text, dateTs: dt.ts, @@ -1965,6 +2040,7 @@ function setupListeners() { document.getElementById('addFilesBtn').addEventListener('click', pickFiles); document.getElementById('chooseHostersBtn').addEventListener('click', openHosterModal); document.getElementById('startUploadBtn').addEventListener('click', startUpload); + document.getElementById('startSelectedBtn').addEventListener('click', startSelectedUpload); document.getElementById('reuploadSelectedBtn').addEventListener('click', retrySelectedJobs); document.getElementById('abortSelectedBtn').addEventListener('click', abortSelectedJobs); document.getElementById('finishStopBtn').addEventListener('click', finishUploadsInProgress); diff --git a/renderer/index.html b/renderer/index.html index 1c0531c..730d0a7 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -40,9 +40,12 @@