Multi-Hoster-Upload/lib/doodstream-upload.js
Administrator e38c55988c 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>
2026-03-11 03:38:44 +01:00

454 lines
15 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
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();
this.sessId = '';
}
_cookieHeader() {
return Array.from(this.cookies.entries())
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
_parseCookiesFromHeaders(headers) {
let setCookies;
if (typeof headers.getSetCookie === 'function') {
setCookies = headers.getSetCookie();
} else if (headers['set-cookie']) {
setCookies = Array.isArray(headers['set-cookie']) ? headers['set-cookie'] : [headers['set-cookie']];
} else {
return;
}
for (const raw of setCookies) {
const pair = raw.split(';')[0];
const eq = pair.indexOf('=');
if (eq > 0) {
this.cookies.set(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
}
}
}
async _fetch(url, opts = {}, _redirectCount = 0) {
const MAX_REDIRECTS = 10;
const headers = {
'User-Agent': USER_AGENT,
...(opts.headers || {})
};
if (this.cookies.size > 0) {
headers['Cookie'] = this._cookieHeader();
}
const res = await fetch(url, {
...opts,
headers,
redirect: 'manual'
});
this._parseCookiesFromHeaders(res.headers);
if ([301, 302, 303, 307, 308].includes(res.status)) {
try { await res.text(); } catch {}
if (_redirectCount >= MAX_REDIRECTS) throw new Error('Zu viele Redirects');
const location = res.headers.get('location');
if (location) {
const nextUrl = new URL(location, url).href;
return this._fetch(nextUrl, { ...opts, method: 'GET', body: undefined }, _redirectCount + 1);
}
}
return res;
}
/**
* Login to DoodStream via web form
*/
async login(username, password) {
// GET homepage first to collect cookies
const homeRes = await this._fetch(BASE_URL);
await homeRes.text();
// POST login via AJAX (op in body, XHR header required for JSON response)
const loginData = new URLSearchParams({
op: 'login_ajax',
login: username,
password: password,
loginotp: ''
});
// Use raw fetch with redirect: 'manual' to detect success redirects
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': BASE_URL + '/',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': USER_AGENT
};
if (this.cookies.size > 0) {
headers['Cookie'] = this._cookieHeader();
}
const res = await fetch(BASE_URL + '/', {
method: 'POST',
body: loginData.toString(),
headers,
redirect: 'manual'
});
this._parseCookiesFromHeaders(res.headers);
// On successful login, server may redirect (3xx) to dashboard
if ([301, 302, 303, 307, 308].includes(res.status)) {
try { await res.text(); } catch {}
// Redirect means login succeeded
} else {
const body = await res.text();
let json;
try { json = JSON.parse(body); } catch { json = null; }
if (json && json.status === 'success') {
// Explicit success response
} else if (json && json.status === 'fail') {
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
} else if (body.includes('Dashboard')) {
// Got dashboard HTML directly — login worked
} else {
const msg = (json && json.message) || 'Login fehlgeschlagen';
throw new Error(`Doodstream Login: ${msg}`);
}
}
// Extract sess_id from the upload page
await this._extractSessId();
}
async _extractSessId() {
const res = await this._fetch(BASE_URL + '/?op=upload');
const html = await res.text();
// Hidden input: <input type="hidden" name="sess_id" value="xxx">
const hiddenMatch = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/);
if (hiddenMatch) {
this.sessId = hiddenMatch[1];
return;
}
// Vue component prop or JS: sess_id: "xxx" or sess_id="xxx"
const sessMatch = html.match(/sess_id['":\s]+['"]([a-zA-Z0-9]+)['"]/);
if (sessMatch) {
this.sessId = sessMatch[1];
return;
}
// Assignment: sess_id = 'xxx'
const altMatch = html.match(/sess_id\s*=\s*['"]([a-zA-Z0-9]+)['"]/);
if (altMatch) {
this.sessId = altMatch[1];
return;
}
throw new Error('Doodstream: sess_id nicht gefunden nach Login');
}
/**
* Get upload server URL from web interface
*/
async _getUploadServer() {
// Use the standard upload server endpoint
const res = await this._fetch(BASE_URL + '/?op=upload_server');
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch { json = null; }
if (json && json.result && /^https?:\/\//i.test(json.result)) {
return json.result;
}
// Fallback: try fetching from upload page HTML
const pageRes = await this._fetch(BASE_URL + '/?op=upload');
const html = await pageRes.text();
const srvMatch = html.match(/srv_url['":\s]+['"]?(https?:\/\/[^'">\s]+)['"]?/i);
if (srvMatch) return srvMatch[1];
// Last resort fallback
return 'https://tr1128ve.cloudatacdn.com/upload/01';
}
/**
* Upload file using web session
*/
async upload(filePath, progressCb, signal, throttle) {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
// Get upload server
const uploadUrl = await this._getUploadServer();
// Build multipart form
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`;
// Build form parts
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 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;
}
const uploadRes = await request(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize),
'User-Agent': USER_AGENT,
'Cookie': this._cookieHeader()
},
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();
_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}`);
}
return this._parseUploadResponse(resText);
}
/**
* 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);
}
/**
* 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;
}
/**
* 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}`);
}
let item = null;
const result = payload.result;
if (Array.isArray(result) && result.length > 0) {
item = result[0];
} else if (typeof result === 'object' && result) {
item = result;
}
if (!item) {
throw new Error(`Doodstream Upload fehlgeschlagen: ${payload.msg || JSON.stringify(payload).slice(0, 150)}`);
}
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;