fix: doodstream upload, start selected, UI improvements

- Fix DoodStream upload: parse <textarea> fields (not just <input hidden>)
- Fix DoodStream upload: handle redirect responses from upload server
- Fix DoodStream upload: submit upload_result to doodstream.com (not CDN)
- Fix DoodStream speed display: switch to async generator streaming
- Add "Start Selected" toolbar button to upload only selected queue items
- Move "Always on Top" from context menu to Settings
- Remove "Shutdown after Finish" from context menu
- Hide error entries from upload history (only show successful uploads)
- Disable background throttling to prevent UI lag on focus switch
- Add debug logging for DoodStream upload troubleshooting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-03-11 03:38:44 +01:00
parent 8995feb541
commit e38c55988c
4 changed files with 283 additions and 132 deletions

View File

@ -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(/<form[^>]*action=['"]([^'"]+)['"]/i);
if (formAction) {
const hiddenFields = {};
const inputRegex = /<input[^>]*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 = /<input[^>]*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: <textarea name="op">upload_result</textarea>
const ta = /<textarea[^>]*name=['"]([^'"]+)['"][^>]*>([\s\S]*?)<\/textarea>/gi;
let m;
while ((m = ta.exec(html)) !== null) fields[m[1]] = m[2].trim();
// Input hidden fields
const p1 = /<input[^>]*type=['"]hidden['"][^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi;
while ((m = p1.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; }
const p2 = /<input[^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi;
while ((m = p2.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; }
const p3 = /<input[^>]*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(/<form[^>]*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;

View File

@ -425,6 +425,7 @@ function createWindow() {
}
});
mainWindow.webContents.setBackgroundThrottling(false);
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
}

View File

@ -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() {
</div>
<div class="settings-section-label">Verhalten</div>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row">
<label>Immer im Vordergrund</label>
<input type="checkbox" class="settings-autosave" id="alwaysOnTopInput" ${alwaysOnTopState ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Hoster-Limits hochskalieren</label>
<input type="checkbox" class="settings-autosave" id="scaleParallelUploadsInput" ${globalSettings.scaleParallelUploads ? 'checked' : ''}>
@ -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);

View File

@ -40,9 +40,12 @@
<div class="queue-shell" id="queueShell" style="display:none">
<div class="queue-command-bar" id="queueCommandBar">
<button class="toolbar-btn" id="startUploadBtn" title="Start Uploading" disabled>
<button class="toolbar-btn" id="startUploadBtn" title="Start all" disabled>
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M4 2l10 6-10 6z" fill="#4caf50"/></svg>
</button>
<button class="toolbar-btn" id="startSelectedBtn" title="Start selected" disabled>
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M6 3l8 5-8 5z" fill="#4caf50"/><rect x="1" y="3" width="3" height="10" rx="0.5" fill="#4caf50"/></svg>
</button>
<button class="toolbar-btn" id="reuploadSelectedBtn" title="Reupload selected file">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 1a7 7 0 0 0-5 2.1V1H2v4h4V4H3.7A5.5 5.5 0 1 1 2.5 8H1a7 7 0 1 0 7-7z" fill="#4caf50"/></svg>
</button>
@ -235,17 +238,6 @@
<div class="ctx-item" data-action="delete-selected">Entfernen</div>
<div class="ctx-separator"></div>
<div class="ctx-item" data-action="copy-all-links">Alle Links kopieren</div>
<div class="ctx-separator"></div>
<div class="ctx-item ctx-submenu" data-action="shutdown">
Shutdown nach Finish
<div class="ctx-submenu-items">
<div class="ctx-item" data-action="shutdown-nothing">Nichts</div>
<div class="ctx-item" data-action="shutdown-sleep">Ruhezustand</div>
<div class="ctx-item" data-action="shutdown-shutdown">Herunterfahren</div>
<div class="ctx-item" data-action="shutdown-restart">Neustart</div>
</div>
</div>
<div class="ctx-item" data-action="always-on-top">Immer im Vordergrund</div>
</div>
<div class="statusbar" id="statusbar">