From d94156943b7109d6f9232b09e172a84966a4f0da Mon Sep 17 00:00:00 2001 From: Administrator Date: Wed, 11 Mar 2026 01:23:27 +0100 Subject: [PATCH] feat: doodstream login support, auto-remove from queue, byse URL fix - Add doodstream.com web login (email+password) as alternative to API key - Fix doodstream login: use X-Requested-With header for JSON response - Add "Aus der Queue entfernen bei Abschluss" setting - Fix byse.sx download URLs to use /d/ prefix - Make config writes async to prevent race conditions Co-Authored-By: Claude Opus 4.6 --- lib/config-store.js | 25 +++- lib/doodstream-upload.js | 276 +++++++++++++++++++++++++++++++++++++++ lib/hosters.js | 2 +- lib/upload-manager.js | 5 + main.js | 42 ++++-- package.json | 2 +- renderer/app.js | 29 +++- 7 files changed, 357 insertions(+), 24 deletions(-) create mode 100644 lib/doodstream-upload.js diff --git a/lib/config-store.js b/lib/config-store.js index 14e3a01..2ef0557 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -12,7 +12,7 @@ const HOSTER_SETTINGS_DEFAULTS = { const DEFAULTS = { hosters: { - 'doodstream.com': { enabled: true, apiKey: '' }, + 'doodstream.com': { enabled: true, apiKey: '', username: '', password: '' }, 'voe.sx': { enabled: true, apiKey: '' }, 'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' }, 'byse.sx': { enabled: true, apiKey: '' } @@ -28,6 +28,7 @@ const DEFAULTS = { shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart logFilePath: '', resumeQueueOnLaunch: true, + removeFromQueueOnDone: false, pendingQueue: null, scramble: { active: false, @@ -84,9 +85,12 @@ class ConfigStore { if (config.hosters) current.hosters = config.hosters; if (config.hosterSettings) current.hosterSettings = config.hosterSettings; if (config.globalSettings) current.globalSettings = config.globalSettings; - // Async write to avoid blocking main process const data = JSON.stringify(current, null, 2); - fs.writeFile(this.filePath, data, 'utf-8', () => {}); + return new Promise((resolve, reject) => { + fs.writeFile(this.filePath, data, 'utf-8', (err) => { + if (err) reject(err); else resolve(); + }); + }); } loadHistory() { @@ -97,17 +101,26 @@ class ConfigStore { appendHistory(entry) { const config = this.load(); config.history.push(entry); - // Cap at MAX_HISTORY if (config.history.length > MAX_HISTORY) { config.history = config.history.slice(-MAX_HISTORY); } - fs.writeFileSync(this.filePath, JSON.stringify(config, null, 2), 'utf-8'); + const data = JSON.stringify(config, null, 2); + return new Promise((resolve, reject) => { + fs.writeFile(this.filePath, data, 'utf-8', (err) => { + if (err) reject(err); else resolve(); + }); + }); } clearHistory() { const config = this.load(); config.history = []; - fs.writeFileSync(this.filePath, JSON.stringify(config, null, 2), 'utf-8'); + const data = JSON.stringify(config, null, 2); + return new Promise((resolve, reject) => { + fs.writeFile(this.filePath, data, 'utf-8', (err) => { + if (err) reject(err); else resolve(); + }); + }); } } diff --git a/lib/doodstream-upload.js b/lib/doodstream-upload.js new file mode 100644 index 0000000..c2d3d9c --- /dev/null +++ b/lib/doodstream-upload.js @@ -0,0 +1,276 @@ +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 + +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: '' + }); + + const res = await this._fetch(BASE_URL + '/', { + method: 'POST', + body: loginData.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': BASE_URL + '/', + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + const body = await res.text(); + let json; + try { json = JSON.parse(body); } catch { json = null; } + + if (!json || json.status !== 'success') { + 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(); + + // Look for sess_id in the page (Vue component prop or hidden field) + const sessMatch = html.match(/sess_id['":\s]+['"]([a-zA-Z0-9]+)['"]/); + if (sessMatch) { + this.sessId = sessMatch[1]; + return; + } + + // Alternative: look in script or data attributes + 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')}`; + const fileStream = fs.createReadStream(filePath); + + // Build form parts + const fields = { + sess_id: this.sessId, + utype: 'reg' + }; + + 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` + ); + } + 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 + fileStream.on('data', (chunk) => { + if (signal && signal.aborted) { + fileStream.destroy(); + bodyStream.destroy(); + return; + } + 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', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': String(totalSize), + 'User-Agent': USER_AGENT, + 'Cookie': this._cookieHeader() + }, + body: bodyStream, + signal, + bodyTimeout: UPLOAD_TIMEOUT, + headersTimeout: 60000 + }); + + const resText = await uploadRes.body.text(); + let payload; + try { payload = JSON.parse(resText); } catch {} + + if (!payload) { + // Try to extract from HTML response + const match = resText.match(/filecode['":\s]+['"]([a-zA-Z0-9]+)['"]/i); + if (match) { + return { + download_url: `https://doodstream.com/d/${match[1]}`, + embed_url: `https://doodstream.com/e/${match[1]}`, + file_code: match[1] + }; + } + throw new Error('Doodstream Upload: Keine gueltige Antwort erhalten'); + } + + // Parse result + 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 || 'Unbekannter Fehler'}`); + } + + 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 + }; + } +} + +module.exports = DoodstreamUploader; diff --git a/lib/hosters.js b/lib/hosters.js index 0421acb..1475818 100644 --- a/lib/hosters.js +++ b/lib/hosters.js @@ -212,7 +212,7 @@ function parseByseResult(payload) { } return { - download_url: file_code ? `https://byse.sx/${file_code}` : null, + download_url: file_code ? `https://byse.sx/d/${file_code}` : null, embed_url: file_code ? `https://byse.sx/e/${file_code}` : null, file_code }; diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 8be88fb..e8c4275 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -5,6 +5,7 @@ const crypto = require('crypto'); const { uploadFile } = require('./hosters'); const VidmolyUploader = require('./vidmoly-upload'); const VoeUploader = require('./voe-upload'); +const DoodstreamUploader = require('./doodstream-upload'); const Semaphore = require('./semaphore'); const Throttle = require('./throttle'); @@ -254,6 +255,10 @@ class UploadManager extends EventEmitter { const voe = new VoeUploader(); await voe.login(task.username, task.password); result = await voe.upload(task.file, progressCb, jobSignal, throttle); + } else if (task.hoster === 'doodstream.com' && task.username) { + const dood = new DoodstreamUploader(); + await dood.login(task.username, task.password); + result = await dood.upload(task.file, progressCb, jobSignal, throttle); } else { result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, jobSignal, throttle); } diff --git a/main.js b/main.js index 5d6b7fa..0436738 100644 --- a/main.js +++ b/main.js @@ -6,6 +6,7 @@ const UploadManager = require('./lib/upload-manager'); const { HOSTER_CONFIGS } = require('./lib/hosters'); const VidmolyUploader = require('./lib/vidmoly-upload'); const VoeUploader = require('./lib/voe-upload'); +const DoodstreamUploader = require('./lib/doodstream-upload'); const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater'); let mainWindow; @@ -107,6 +108,10 @@ function buildUploadTasks(config, files, hosters) { // VOE login-based upload (preferred over API) tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); debugLog(` task: ${hoster} login=${hosterConfig.username.slice(0, 6)}...`); + } else if (hoster === 'doodstream.com' && hosterConfig.username && hosterConfig.password) { + // Doodstream login-based upload (preferred over API) + tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); + debugLog(` task: ${hoster} login=${hosterConfig.username.slice(0, 6)}...`); } else { if (!hosterConfig.apiKey) { debugLog(` skip ${hoster}: missing apiKey`); @@ -122,12 +127,27 @@ function buildUploadTasks(config, files, hosters) { } async function checkDoodstreamHealth(hosterConfig) { + const username = hosterConfig && hosterConfig.username + ? String(hosterConfig.username).trim() + : ''; + const password = hosterConfig && hosterConfig.password + ? String(hosterConfig.password).trim() + : ''; + + // Login-based check (preferred) + if (username && password) { + const uploader = new DoodstreamUploader(); + await uploader.login(username, password); + return { status: 'ok', message: 'Login ok, Upload-Seite bereit' }; + } + + // Fall back to API key check const apiKey = hosterConfig && hosterConfig.apiKey ? String(hosterConfig.apiKey).trim() : ''; if (!apiKey) { - return { status: 'error', message: 'API Key fehlt' }; + return { status: 'error', message: 'Login oder API Key fehlt' }; } const apiBase = HOSTER_CONFIGS['doodstream.com'].apiBase; @@ -398,8 +418,8 @@ ipcMain.handle('get-config', () => { return configStore.load(); }); -ipcMain.handle('save-config', (_event, config) => { - configStore.save(config); +ipcMain.handle('save-config', async (_event, config) => { + await configStore.save(config); return true; }); @@ -487,9 +507,9 @@ ipcMain.handle('start-upload', (_event, payload) => { } }); - uploadManager.on('batch-done', (summary) => { + uploadManager.on('batch-done', async (summary) => { debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); - configStore.appendHistory(summary); + await configStore.appendHistory(summary); // Write successful uploads to fileuploader.log for (const file of summary.files || []) { for (const result of file.results || []) { @@ -590,8 +610,8 @@ ipcMain.handle('get-hoster-settings', () => { return config.hosterSettings || {}; }); -ipcMain.handle('save-hoster-settings', (_event, hosterSettings) => { - configStore.save({ hosterSettings }); +ipcMain.handle('save-hoster-settings', async (_event, hosterSettings) => { + await configStore.save({ hosterSettings }); return true; }); @@ -601,17 +621,17 @@ ipcMain.handle('get-global-settings', () => { return config.globalSettings || {}; }); -ipcMain.handle('save-global-settings', (_event, globalSettings) => { - configStore.save({ globalSettings }); +ipcMain.handle('save-global-settings', async (_event, globalSettings) => { + await configStore.save({ globalSettings }); return true; }); // --- Always on top --- -ipcMain.handle('set-always-on-top', (_event, value) => { +ipcMain.handle('set-always-on-top', async (_event, value) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.setAlwaysOnTop(!!value); } - configStore.save({ globalSettings: { ...configStore.load().globalSettings, alwaysOnTop: !!value } }); + await configStore.save({ globalSettings: { ...configStore.load().globalSettings, alwaysOnTop: !!value } }); return true; }); diff --git a/package.json b/package.json index de46cbe..9a3dede 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "1.4.0", + "version": "1.5.0", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index 2300323..9dc3908 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -99,7 +99,7 @@ document.querySelectorAll('.tab').forEach(tab => { // --- Hoster selection --- function hosterHasCredentials(name, hoster) { if (name === 'vidmoly.me') return !!(hoster.username && hoster.password); - if (name === 'voe.sx') return !!(hoster.username && hoster.password) || !!hoster.apiKey; + if (name === 'voe.sx' || name === 'doodstream.com') return !!(hoster.username && hoster.password) || !!hoster.apiKey; return !!hoster.apiKey; } @@ -871,6 +871,19 @@ function handleBatchDone(summary) { renderRecentUploadsPanel(); loadHistory(); + + // Auto-remove completed jobs from queue if enabled + const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone; + if (removeOnDone) { + const doneJobs = queueJobs.filter(j => j.status === 'done'); + for (const job of doneJobs) { + removeJobFromIndex(job); + selectedJobIds.delete(job.id); + } + queueJobs = queueJobs.filter(j => j.status !== 'done'); + renderQueueTable(); + } + clearPersistedQueueStateSoon(); // Final stats update @@ -976,6 +989,10 @@ function renderSettings() { +
+ + +
`; container.appendChild(generalPanel); @@ -1054,7 +1071,8 @@ async function saveSettings() { const globalSettings = { ...(config.globalSettings || {}), logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(), - resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked + resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked, + removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked }; for (const name of HOSTERS) { @@ -1091,6 +1109,7 @@ function getHostersWithoutCreds() { function getCredentialLabel(name, hoster) { if (name === 'vidmoly.me') return hoster.username || 'Login'; if (name === 'voe.sx') return hoster.username && hoster.password ? (hoster.username || 'Login') : 'API-Key'; + if (name === 'doodstream.com') return hoster.username && hoster.password ? (hoster.username || 'Login') : 'API-Key'; return 'API-Key'; } @@ -1176,7 +1195,7 @@ function getCredsFieldsHtml(name, hoster) { `; } - if (name === 'voe.sx') { + if (name === 'voe.sx' || name === 'doodstream.com') { return `
@@ -1274,7 +1293,7 @@ async function deleteAccount(hosterName) { // Reset credentials to defaults if (hosterName === 'vidmoly.me') { hosters[hosterName] = { enabled: false, authType: 'login', username: '', password: '' }; - } else if (hosterName === 'voe.sx') { + } else if (hosterName === 'voe.sx' || hosterName === 'doodstream.com') { hosters[hosterName] = { enabled: false, username: '', password: '', apiKey: '' }; } else { hosters[hosterName] = { enabled: false, apiKey: '' }; @@ -1296,7 +1315,7 @@ function readAccountCredsFromModal(hosterName) { const password = (document.getElementById('accField_password')?.value || '').trim(); return { enabled: !!(username && password), authType: 'login', username, password }; } - if (hosterName === 'voe.sx') { + if (hosterName === 'voe.sx' || hosterName === 'doodstream.com') { const username = (document.getElementById('accField_username')?.value || '').trim(); const password = (document.getElementById('accField_password')?.value || '').trim(); const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim();