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
// Cap doodstream's per-hoster debug log alongside the main log files so
// dev-mode sessions don't accumulate gigabytes of upload trace.
const { maybeRotateLogFile } = require('./log-rotation');
const _DOODSTREAM_LOG_MAX_BYTES = 10 * 1024 * 1024;
const _DOODSTREAM_LOG_MAX_BACKUPS = 1;
// Resolve the log path at write-time. In a packaged build __dirname lives
// inside app.asar (read-only) — writing there fails silently and we lose every
// production trace. Prefer Electron's writable userData dir, fall back to the
// repo root only when running outside Electron (tests / plain node).
function _doodstreamLogPath() {
try {
const { app } = require('electron');
if (app && typeof app.getPath === 'function') {
return path.join(app.getPath('userData'), 'doodstream-debug.log');
}
} catch { /* not running under Electron */ }
return path.join(__dirname, '..', 'doodstream-debug.log');
}
let _debugVerbose = false;
function setDebugVerbose(v) { _debugVerbose = !!v; }
function _debugLog(msg, force = false) {
if (!_debugVerbose && !force) return;
try {
const logPath = _doodstreamLogPath();
maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS);
const ts = new Date().toISOString();
fs.appendFileSync(logPath, `[${ts}] ${msg}\n`);
} catch {}
}
class DoodstreamUploader {
constructor() {
this.cookies = new Map();
this.sessId = '';
this.apiKey = ''; // optionally derived from the logged-in session (deriveApiKey)
}
_cookieHeader() {
return Array.from(this.cookies.entries())
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
cloneSession() {
const uploader = new DoodstreamUploader();
uploader.cookies = new Map(this.cookies);
uploader.sessId = this.sessId;
return uploader;
}
_isAuthenticatedPage(html) {
return /href=["']\/settings["']/i.test(html)
&& /href=["']\/videos["']/i.test(html)
&& /href=["'][^"']*(?:op=logout|\/logout)["']/i.test(html)
&& !/]*\bname=["'](?:login|password|loginotp)["']/i.test(html);
}
_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) {
if (opts.allowedOrigin && new URL(url).origin !== opts.allowedOrigin) {
throw new Error('Doodstream Login: Unerwartetes Weiterleitungsziel');
}
const MAX_REDIRECTS = 10;
const headers = {
'User-Agent': USER_AGENT,
...(opts.headers || {})
};
if (this.cookies.size > 0) {
headers['Cookie'] = this._cookieHeader();
}
// The small discovery/result requests that bookend a multi-minute upload
// occasionally hit a transient blip ("fetch failed", ECONNRESET, a hung TLS
// handshake). A blip here shouldn't throw away the whole upload, so retry a
// few times with short backoff. Each attempt gets its own 20s timeout —
// Node's fetch has none by default, and a hung socket would otherwise stall
// the attempt for minutes. The big file upload (undici) is retried at the
// upload-manager level, not here.
let res;
for (let attempt = 1; ; attempt++) {
const timeoutSignal = AbortSignal.timeout(20000);
const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
try {
res = await fetch(url, { ...opts, headers, redirect: 'manual', signal });
break;
} catch (err) {
if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry
if (attempt >= 3) throw err;
_debugLog(`_fetch transient (${attempt}/3) ${new URL(url).origin}: retry`);
await new Promise(r => setTimeout(r, 400 * attempt));
}
}
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, otp) {
if (!otp || this.cookies.size === 0) {
const homeRes = await this._fetch(BASE_URL);
await homeRes.text();
}
// GET login via AJAX (XHR header required for JSON response)
const loginData = new URLSearchParams({
login: username,
password: password,
loginotp: otp || ''
});
// Use raw fetch with redirect: 'manual' to detect success redirects
const headers = {
'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}/?op=login_ajax&${loginData.toString()}`, {
method: 'GET',
headers,
redirect: 'manual',
signal: AbortSignal.timeout(20000)
});
this._parseCookiesFromHeaders(res.headers);
let loginRedirect = '';
// On successful login, server may redirect (3xx) to dashboard
if ([301, 302, 303, 307, 308].includes(res.status)) {
try { await res.text(); } catch {}
loginRedirect = res.headers.get('location') || '';
} else {
const body = await res.text();
let json;
try { json = JSON.parse(body); } catch { json = null; }
if (json && ['success', 'redirect'].includes(json.status)) {
if (json.status === 'redirect') loginRedirect = json.message || '';
} else if (json && (json.status === 'otp_sent' || (json.message && /otp|verification code/i.test(json.message)))) {
// OTP required — signal caller to collect OTP from user
const err = new Error(`Doodstream Login: ${json.message || 'OTP erforderlich'}`);
err.otpRequired = true;
throw err;
} else if (json && json.status === 'fail') {
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
} else if (res.status === 200 && this._isAuthenticatedPage(body)) {
this.sessId = this._findSessId(body);
return;
} else {
const msg = (json && json.message) || 'Login fehlgeschlagen';
throw new Error(`Doodstream Login: ${msg}`);
}
}
if (loginRedirect) {
const target = new URL(loginRedirect, BASE_URL);
if (target.origin !== BASE_URL || target.username || target.password) {
throw new Error('Doodstream Login: Unerwartetes Weiterleitungsziel');
}
const landing = await this._fetch(target.href, { allowedOrigin: BASE_URL });
const landingHtml = await landing.text();
this._diagnoseSessionPage('landing', landing, landingHtml);
const sessId = this._findSessId(landingHtml);
if (landing.status === 200 && (sessId || this._isAuthenticatedPage(landingHtml))) {
this.sessId = sessId;
return;
}
}
await this._extractSessId();
}
async _extractSessId() {
const res = await this._fetch(BASE_URL + '/?op=upload');
const html = await res.text();
this._diagnoseSessionPage('upload', res, html);
const sessId = this._findSessId(html);
if (res.status === 200 && sessId) {
this.sessId = sessId;
return;
}
this.sessId = '';
const guest = /utype\s*:\s*['"]anon['"]/.test(html);
_debugLog(`session-missing HTTP=${res.status} guest=${guest} cookies=${this.cookies.size}`, true);
throw new Error('Doodstream: sess_id nicht gefunden nach Login');
}
_findSessId(html) {
if (!html) return '';
const fields = this._extractHiddenFields(html);
if (fields.sess_id) return String(fields.sess_id).trim();
const decoded = String(html)
.replace(/"|"|"/gi, '"')
.replace(/'|'|'/gi, "'")
.replace(/&/gi, '&');
const match = decoded.match(/(?:["'])?\bsess_id\b(?:["'])?\s*[:=]\s*(["'])([^"']+)\1/i);
return match ? match[2].trim() : '';
}
_diagnoseSessionPage(stage, response, html) {
const routes = new Set();
for (const match of html.matchAll(/\bhref=["']([^"']+)["']/gi)) {
try {
const url = new URL(match[1].replace(/&/g, '&'), BASE_URL);
if (url.origin !== BASE_URL) continue;
const op = url.searchParams.get('op') || '';
if (/^[a-z_]+$/i.test(op)) routes.add(`op=${op}`);
if (/^\/[a-z_/-]*$/i.test(url.pathname)) routes.add(url.pathname);
} catch {}
}
const fields = [...html.matchAll(/<(?:input|textarea)\b[^>]*\bname=["']([a-z_]+)["']/gi)].map(match => match[1]);
const components = [...html.matchAll(/<([a-z]+-[a-z-]+)\b/g)].map(match => match[1]);
const scripts = [...html.matchAll(/