// ==UserScript== // @name Transmission Remote Script // @namespace https://git.ponywave.de/Akamaru/Userscripts // @version 1.0 // @description Sendet Magnet-Links und .torrent-Dateien an Transmission im Netzwerk mit Pfad-Auswahl und Verwaltung // @author Akamaru // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @updateURL https://git.ponywave.de/Akamaru/Userscripts/raw/branch/master/torrent-to-transmission.user.js // ==/UserScript== (function() { 'use strict'; // --- Hilfsfunktionen für Storage --- async function getConfig() { let config = await GM_getValue('transmissionConfig'); if (config) return JSON.parse(config); return await askForConfig(); } async function setConfig(config) { await GM_setValue('transmissionConfig', JSON.stringify(config)); } async function askForConfig() { let ipport = prompt('Transmission IP und Port (z.B. 192.168.1.100:9091):'); if (!ipport) return null; let user = prompt('Benutzername (leer lassen, falls nicht benötigt):'); let pass = prompt('Passwort (leer lassen, falls nicht benötigt):'); let url = 'http://' + ipport + '/transmission/rpc'; let config = { url, user, pass }; await setConfig(config); return config; } async function getPaths() { let paths = await GM_getValue('transmissionPaths'); if (paths) return JSON.parse(paths); return []; } async function setPaths(paths) { await GM_setValue('transmissionPaths', JSON.stringify(paths)); } // --- Hilfsfunktionen für modale Dialoge --- function showModalDialog({title, content, onOk, onCancel, okText = 'OK', cancelText = 'Abbrechen', showCancel = true}) { let overlay = document.createElement('div'); overlay.style.position = 'fixed'; overlay.style.top = 0; overlay.style.left = 0; overlay.style.width = '100vw'; overlay.style.height = '100vh'; overlay.style.background = 'rgba(0,0,0,0.5)'; overlay.style.zIndex = 99999; overlay.style.display = 'flex'; overlay.style.alignItems = 'center'; overlay.style.justifyContent = 'center'; let dialog = document.createElement('div'); dialog.style.background = '#222'; dialog.style.color = '#fff'; dialog.style.padding = '24px 32px'; dialog.style.borderRadius = '10px'; dialog.style.minWidth = '480px'; dialog.style.boxShadow = '0 4px 24px rgba(0,0,0,0.4)'; dialog.style.display = 'flex'; dialog.style.flexDirection = 'column'; dialog.style.gap = '16px'; if (title) { let h2 = document.createElement('h2'); h2.textContent = title; h2.style.margin = '0 0 8px 0'; dialog.appendChild(h2); } if (typeof content === 'string') { let p = document.createElement('div'); p.innerHTML = content; dialog.appendChild(p); } else if (content instanceof Node) { dialog.appendChild(content); } let btnRow = document.createElement('div'); btnRow.style.display = 'flex'; btnRow.style.justifyContent = 'flex-end'; btnRow.style.gap = '12px'; if (showCancel) { let cancelBtn = document.createElement('button'); cancelBtn.textContent = cancelText; cancelBtn.style.background = '#666'; cancelBtn.style.color = '#fff'; cancelBtn.style.border = 'none'; cancelBtn.style.padding = '8px 18px'; cancelBtn.style.borderRadius = '4px'; cancelBtn.style.cursor = 'pointer'; cancelBtn.onclick = function() { document.body.removeChild(overlay); if (onCancel) onCancel(); }; btnRow.appendChild(cancelBtn); } let okBtn = document.createElement('button'); okBtn.textContent = okText; okBtn.style.background = '#6c3'; okBtn.style.color = '#222'; okBtn.style.border = 'none'; okBtn.style.padding = '8px 18px'; okBtn.style.borderRadius = '4px'; okBtn.style.cursor = 'pointer'; okBtn.onclick = function() { document.body.removeChild(overlay); if (onOk) onOk(); }; btnRow.appendChild(okBtn); dialog.appendChild(btnRow); overlay.appendChild(dialog); document.body.appendChild(overlay); return {overlay, dialog}; } // --- Eigener Dialog für Pfadverwaltung --- async function editPathsMenu() { let paths = await getPaths(); let container = document.createElement('div'); container.style.display = 'flex'; container.style.flexDirection = 'column'; container.style.gap = '10px'; let list = document.createElement('ul'); list.style.listStyle = 'none'; list.style.padding = '0'; list.style.margin = '0'; function renderList() { list.innerHTML = ''; paths.forEach((p, i) => { let li = document.createElement('li'); li.style.display = 'flex'; li.style.alignItems = 'center'; li.style.gap = '8px'; let span = document.createElement('span'); span.textContent = p; li.appendChild(span); let delBtn = document.createElement('button'); delBtn.textContent = 'Löschen'; delBtn.style.background = '#a33'; delBtn.style.color = '#fff'; delBtn.style.border = 'none'; delBtn.style.padding = '2px 8px'; delBtn.style.borderRadius = '4px'; delBtn.style.cursor = 'pointer'; delBtn.onclick = async function() { paths.splice(i, 1); await setPaths(paths); renderList(); }; li.appendChild(delBtn); list.appendChild(li); }); } renderList(); container.appendChild(list); let addRow = document.createElement('div'); addRow.style.display = 'flex'; addRow.style.gap = '8px'; let input = document.createElement('input'); input.type = 'text'; input.placeholder = 'Neuen Pfad eingeben...'; input.style.flex = '1'; input.style.padding = '4px'; input.style.borderRadius = '4px'; input.style.border = '1px solid #888'; input.style.background = '#333'; input.style.color = '#fff'; addRow.appendChild(input); let addBtn = document.createElement('button'); addBtn.textContent = 'Hinzufügen'; addBtn.style.background = '#444'; addBtn.style.color = '#fff'; addBtn.style.border = 'none'; addBtn.style.padding = '4px 12px'; addBtn.style.borderRadius = '4px'; addBtn.style.cursor = 'pointer'; addBtn.onclick = async function() { let val = input.value.trim(); if (val) { paths.push(val); await setPaths(paths); input.value = ''; renderList(); } }; addRow.appendChild(addBtn); container.appendChild(addRow); showModalDialog({ title: 'Download-Pfade verwalten', content: container, okText: 'Schließen', showCancel: false }); } // --- Eigener Dialog für Transmission-Konfiguration --- async function askForConfig() { return new Promise(resolve => { let container = document.createElement('div'); container.style.display = 'flex'; container.style.flexDirection = 'column'; container.style.gap = '10px'; let ipInput = document.createElement('input'); ipInput.type = 'text'; ipInput.placeholder = 'IP und Port (z.B. 192.168.1.100:9091)'; ipInput.style.padding = '4px'; ipInput.style.borderRadius = '4px'; ipInput.style.border = '1px solid #888'; ipInput.style.background = '#333'; ipInput.style.color = '#fff'; container.appendChild(ipInput); let userInput = document.createElement('input'); userInput.type = 'text'; userInput.placeholder = 'Benutzername (optional)'; userInput.style.padding = '4px'; userInput.style.borderRadius = '4px'; userInput.style.border = '1px solid #888'; userInput.style.background = '#333'; userInput.style.color = '#fff'; container.appendChild(userInput); let passInput = document.createElement('input'); passInput.type = 'password'; passInput.placeholder = 'Passwort (optional)'; passInput.style.padding = '4px'; passInput.style.borderRadius = '4px'; passInput.style.border = '1px solid #888'; passInput.style.background = '#333'; passInput.style.color = '#fff'; container.appendChild(passInput); showModalDialog({ title: 'Transmission konfigurieren', content: container, okText: 'Speichern', cancelText: 'Abbrechen', showCancel: true, onOk: async () => { let ipport = ipInput.value.trim(); let user = userInput.value.trim(); let pass = passInput.value; if (!ipport) return; let url = 'http://' + ipport + '/transmission/rpc'; let config = { url, user, pass }; await setConfig(config); resolve(config); }, onCancel: () => resolve(null) }); }); } // --- Transmission Session-ID holen --- function getSessionId(config, callback) { let headers = {}; if (config.user) { headers['Authorization'] = 'Basic ' + btoa(config.user + ':' + config.pass); } GM_xmlhttpRequest({ method: 'POST', url: config.url, headers: headers, onload: function(response) { const match = response.responseHeaders.match(/x-transmission-session-id:(.+)/i); const sessionId = match ? match[1].trim() : null; callback(sessionId); }, onerror: function() { showToast('Fehler beim Abrufen der Session-ID!'); } }); } // --- Magnet/Torrent an Transmission senden --- function sendToTransmission(data, isMagnet, downloadDir, retry) { getConfig().then(config => { getSessionId(config, function(sessionId) { let headers = { 'Content-Type': 'application/json', 'X-Transmission-Session-Id': sessionId }; if (config.user) { headers['Authorization'] = 'Basic ' + btoa(config.user + ':' + config.pass); } let args = isMagnet ? { filename: data } : { metainfo: data }; if (downloadDir) args['download-dir'] = downloadDir; let body = { method: 'torrent-add', arguments: args }; GM_xmlhttpRequest({ method: 'POST', url: config.url, headers: headers, data: JSON.stringify(body), onload: function(response) { if (response.status === 409 && !retry) { const match = response.responseHeaders.match(/x-transmission-session-id:(.+)/i); const newSessionId = match ? match[1].trim() : null; if (newSessionId) { headers['X-Transmission-Session-Id'] = newSessionId; GM_xmlhttpRequest({ method: 'POST', url: config.url, headers: headers, data: JSON.stringify(body), onload: function(resp2) { if (resp2.status === 409) { showToast('Session-ID konnte nicht erneuert werden!\nResponse Headers:\n' + resp2.responseHeaders); } else { showToast('An Transmission gesendet!'); } }, onerror: function() { showToast('Fehler beim Senden an Transmission!'); } }); } else { showToast('Session-ID konnte nicht erneuert werden!\nResponse Headers:\n' + response.responseHeaders); } } else if (response.status === 409 && retry) { showToast('Session-ID konnte nicht erneuert werden!\nResponse Headers:\n' + response.responseHeaders); } else { showToast('An Transmission gesendet!'); } }, onerror: function() { showToast('Fehler beim Senden an Transmission!'); } }); }); }); } // --- .torrent-Datei herunterladen und in Base64 umwandeln --- function fetchAndEncodeTorrent(url, callback) { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'arraybuffer', onload: function(response) { const bytes = new Uint8Array(response.response); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } const base64 = btoa(binary); callback(base64); }, onerror: function() { showToast('Fehler beim Herunterladen der .torrent-Datei!'); } }); } // --- Dialog für Pfad-Auswahl und Senden --- async function showPathDialog(onSend, onCancel, fileList) { let paths = await getPaths(); // Dialog-Elemente erzeugen let overlay = document.createElement('div'); overlay.style.position = 'fixed'; overlay.style.top = 0; overlay.style.left = 0; overlay.style.width = '100vw'; overlay.style.height = '100vh'; overlay.style.background = 'rgba(0,0,0,0.5)'; overlay.style.zIndex = 99999; overlay.style.display = 'flex'; overlay.style.alignItems = 'center'; overlay.style.justifyContent = 'center'; let dialog = document.createElement('div'); dialog.style.background = '#222'; dialog.style.color = '#fff'; dialog.style.padding = '24px 32px'; dialog.style.borderRadius = '10px'; dialog.style.minWidth = '480px'; dialog.style.boxShadow = '0 4px 24px rgba(0,0,0,0.4)'; dialog.style.display = 'flex'; dialog.style.flexDirection = 'column'; dialog.style.gap = '16px'; let title = document.createElement('h2'); title.textContent = 'Download-Verzeichnis wählen'; title.style.margin = '0 0 8px 0'; dialog.appendChild(title); // Dateiliste für .torrent-Dateien mit Checkboxen let selectedFiles = []; let fileCheckboxes = []; if (fileList && fileList.length > 0) { selectedFiles = fileList.map(() => true); // standardmäßig alle ausgewählt let fileBox = document.createElement('div'); fileBox.style.maxHeight = '180px'; fileBox.style.overflowY = 'auto'; fileBox.style.background = '#181818'; fileBox.style.border = '1px solid #444'; fileBox.style.borderRadius = '6px'; fileBox.style.padding = '8px 12px'; fileBox.style.marginBottom = '8px'; fileBox.style.width = '100%'; let fileTitle = document.createElement('div'); fileTitle.textContent = 'Dateien im Torrent:'; fileTitle.style.fontWeight = 'bold'; fileTitle.style.marginBottom = '6px'; fileBox.appendChild(fileTitle); // Buttons für alle an/abwählen let btnRow = document.createElement('div'); btnRow.style.display = 'flex'; btnRow.style.gap = '8px'; btnRow.style.marginBottom = '8px'; let allOnBtn = document.createElement('button'); allOnBtn.textContent = 'Alle auswählen'; allOnBtn.style.background = '#444'; allOnBtn.style.color = '#fff'; allOnBtn.style.border = 'none'; allOnBtn.style.padding = '2px 10px'; allOnBtn.style.borderRadius = '4px'; allOnBtn.style.cursor = 'pointer'; allOnBtn.onclick = function() { fileCheckboxes.forEach((cb, i) => { cb.checked = true; selectedFiles[i] = true; }); }; btnRow.appendChild(allOnBtn); let allOffBtn = document.createElement('button'); allOffBtn.textContent = 'Alle abwählen'; allOffBtn.style.background = '#444'; allOffBtn.style.color = '#fff'; allOffBtn.style.border = 'none'; allOffBtn.style.padding = '2px 10px'; allOffBtn.style.borderRadius = '4px'; allOffBtn.style.cursor = 'pointer'; allOffBtn.onclick = function() { fileCheckboxes.forEach((cb, i) => { cb.checked = false; selectedFiles[i] = false; }); }; btnRow.appendChild(allOffBtn); fileBox.appendChild(btnRow); fileList.forEach((f, idx) => { let fileRow = document.createElement('div'); fileRow.style.display = 'flex'; fileRow.style.alignItems = 'center'; fileRow.style.gap = '8px'; fileRow.style.fontSize = '0.98em'; fileRow.style.wordBreak = 'break-all'; fileRow.style.whiteSpace = 'normal'; let cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true; cb.onchange = function() { selectedFiles[idx] = cb.checked; }; fileCheckboxes.push(cb); fileRow.appendChild(cb); let label = document.createElement('span'); label.textContent = f; fileRow.appendChild(label); fileBox.appendChild(fileRow); }); dialog.appendChild(fileBox); } let select = document.createElement('select'); select.style.fontSize = '1.1em'; select.style.padding = '6px'; select.style.marginBottom = '8px'; select.style.borderRadius = '4px'; select.style.border = '1px solid #888'; select.style.background = '#333'; select.style.color = '#fff'; select.style.width = '100%'; paths.forEach(p => { let opt = document.createElement('option'); opt.value = p; opt.textContent = p; select.appendChild(opt); }); dialog.appendChild(select); let btnRow = document.createElement('div'); btnRow.style.display = 'flex'; btnRow.style.justifyContent = 'flex-end'; btnRow.style.gap = '12px'; let cancelBtn = document.createElement('button'); cancelBtn.textContent = 'Abbrechen'; cancelBtn.style.background = '#666'; cancelBtn.style.color = '#fff'; cancelBtn.style.border = 'none'; cancelBtn.style.padding = '8px 18px'; cancelBtn.style.borderRadius = '4px'; cancelBtn.style.cursor = 'pointer'; cancelBtn.onclick = function() { document.body.removeChild(overlay); if (onCancel) onCancel(); }; btnRow.appendChild(cancelBtn); let sendBtn = document.createElement('button'); sendBtn.textContent = 'Senden'; sendBtn.style.background = '#6c3'; sendBtn.style.color = '#222'; sendBtn.style.border = 'none'; sendBtn.style.padding = '8px 18px'; sendBtn.style.borderRadius = '4px'; sendBtn.style.cursor = 'pointer'; sendBtn.onclick = function() { let dir = select.value; document.body.removeChild(overlay); if (onSend) { if (fileList && fileList.length > 0) { // Übergebe die Auswahl als Array von bools onSend(dir, selectedFiles); } else { onSend(dir); } } }; btnRow.appendChild(sendBtn); dialog.appendChild(btnRow); overlay.appendChild(dialog); document.body.appendChild(overlay); } // --- Hilfsfunktion: Nach dem Hinzufügen eines Torrents Dateien abwählen --- function setUnwantedFiles(torrentId, unwantedIndices, config) { if (!unwantedIndices || unwantedIndices.length === 0) return; let headers = { 'Content-Type': 'application/json', }; if (config.user) { headers['Authorization'] = 'Basic ' + btoa(config.user + ':' + config.pass); } getSessionId(config, function(sessionId) { headers['X-Transmission-Session-Id'] = sessionId; let body = { method: 'torrent-set', arguments: { ids: [torrentId], 'files-unwanted': unwantedIndices } }; GM_xmlhttpRequest({ method: 'POST', url: config.url, headers: headers, data: JSON.stringify(body), onload: function(response) { // Optional: Rückmeldung anzeigen if (response.status === 200) { showToast('Dateiauswahl an Transmission übermittelt!'); } else { showToast('Fehler beim Setzen der Dateiauswahl!'); } }, onerror: function() { showToast('Fehler beim Setzen der Dateiauswahl!'); } }); }); } // --- Hilfsfunktion: Torrent-Dateiliste aus bencode extrahieren --- function parseTorrentFileList(arrayBuffer) { // Minimaler Bencode-Parser für 'info.files' oder 'info.name' (single file) // Korrigiert: Dateinamen werden anhand der Längenangabe korrekt extrahiert try { let bytes = new Uint8Array(arrayBuffer); let str = ''; for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]); // Suche nach '4:info' und dann nach '5:files' oder '4:name' let infoIdx = str.indexOf('4:info'); if (infoIdx === -1) return []; let filesIdx = str.indexOf('5:files', infoIdx); if (filesIdx !== -1) { // Multi-File Torrent: Dateinamen extrahieren let fileList = []; // Suche alle "4:pathl...e"-Blöcke ab filesIdx let pathIdx = filesIdx; while ((pathIdx = str.indexOf('4:pathl', pathIdx)) !== -1) { pathIdx += 7; // nach '4:pathl' // Es können mehrere Strings (Verzeichnisse + Dateiname) folgen, jeweils als ":" let nameParts = []; while (str[pathIdx] >= '0' && str[pathIdx] <= '9') { // Lese Längenangabe let lenStr = ''; while (str[pathIdx] >= '0' && str[pathIdx] <= '9') { lenStr += str[pathIdx++]; } if (str[pathIdx] !== ':') break; pathIdx++; let len = parseInt(lenStr, 10); let name = str.substr(pathIdx, len); nameParts.push(name); pathIdx += len; } if (nameParts.length > 0) fileList.push(nameParts.join('/')); // Suche nächstes '4:pathl' ab aktueller Position } return fileList; } else { // Single-File Torrent: nur Name let nameIdx = str.indexOf('4:name', infoIdx); if (nameIdx !== -1) { let lenStr = ''; let idx = nameIdx + 6; while (str[idx] >= '0' && str[idx] <= '9') { lenStr += str[idx++]; } if (str[idx] === ':') { idx++; let len = parseInt(lenStr, 10); let name = str.substr(idx, len); return [name]; } } } } catch (e) {} return []; } // --- Klicks abfangen --- document.addEventListener('click', function(e) { let el = e.target.closest('a'); if (!el) return; // Magnet-Link if (el.href.startsWith('magnet:?')) { e.preventDefault(); showPathDialog(function(dir) { sendToTransmission(el.href, true, dir, false); }); } // .torrent-Datei if (el.href.match(/\.torrent($|\?)/i)) { e.preventDefault(); GM_xmlhttpRequest({ method: 'GET', url: el.href, responseType: 'arraybuffer', onload: function(response) { const arrayBuffer = response.response; const base64 = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer))); const fileList = parseTorrentFileList(arrayBuffer); showPathDialog(function(dir, selectedFiles) { // selectedFiles: Array von bools, true = gewünscht // Sende an Transmission, und setze unwanted falls nötig getConfig().then(config => { getSessionId(config, function(sessionId) { let headers = { 'Content-Type': 'application/json', 'X-Transmission-Session-Id': sessionId }; if (config.user) { headers['Authorization'] = 'Basic ' + btoa(config.user + ':' + config.pass); } let args = { metainfo: base64 }; if (dir) args['download-dir'] = dir; let body = { method: 'torrent-add', arguments: args }; GM_xmlhttpRequest({ method: 'POST', url: config.url, headers: headers, data: JSON.stringify(body), onload: function(response) { let respObj = {}; try { respObj = JSON.parse(response.responseText); } catch (e) {} if (response.status === 409) { const match = response.responseHeaders.match(/x-transmission-session-id:(.+)/i); const newSessionId = match ? match[1].trim() : null; if (newSessionId) { headers['X-Transmission-Session-Id'] = newSessionId; GM_xmlhttpRequest({ method: 'POST', url: config.url, headers: headers, data: JSON.stringify(body), onload: function(resp2) { let respObj2 = {}; try { respObj2 = JSON.parse(resp2.responseText); } catch (e) {} if (resp2.status === 409) { showToast('Session-ID konnte nicht erneuert werden!\nResponse Headers:\n' + resp2.responseHeaders); } else { showToast('An Transmission gesendet!'); // Nach dem Hinzufügen: unwanted setzen if (Array.isArray(selectedFiles) && fileList.length === selectedFiles.length) { let unwanted = []; for (let i = 0; i < selectedFiles.length; i++) { if (!selectedFiles[i]) unwanted.push(i); } if (respObj2.result === 'success' && respObj2.arguments && respObj2.arguments['torrent-added']) { let tid = respObj2.arguments['torrent-added'].id; setUnwantedFiles(tid, unwanted, config); } } } }, onerror: function() { showToast('Fehler beim Senden an Transmission!'); } }); } else { showToast('Session-ID konnte nicht erneuert werden!\nResponse Headers:\n' + response.responseHeaders); } } else { showToast('An Transmission gesendet!'); // Nach dem Hinzufügen: unwanted setzen if (Array.isArray(selectedFiles) && fileList.length === selectedFiles.length) { let unwanted = []; for (let i = 0; i < selectedFiles.length; i++) { if (!selectedFiles[i]) unwanted.push(i); } if (respObj.result === 'success' && respObj.arguments && respObj.arguments['torrent-added']) { let tid = respObj.arguments['torrent-added'].id; setUnwantedFiles(tid, unwanted, config); } } } }, onerror: function() { showToast('Fehler beim Senden an Transmission!'); } }); }); }); }, undefined, fileList); }, onerror: function() { showToast('Fehler beim Herunterladen der .torrent-Datei!'); } }); } }, true); // --- Menüeinträge --- if (typeof GM_registerMenuCommand !== 'undefined') { GM_registerMenuCommand('Transmission konfigurieren', askForConfig); GM_registerMenuCommand('Download-Pfade verwalten', editPathsMenu); } // --- Rückmeldungen als Toast --- function showToast(msg) { let toast = document.createElement('div'); toast.textContent = msg; toast.style.position = 'fixed'; toast.style.bottom = '32px'; toast.style.right = '32px'; toast.style.background = '#222'; toast.style.color = '#fff'; toast.style.padding = '12px 24px'; toast.style.borderRadius = '8px'; toast.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)'; toast.style.zIndex = 999999; document.body.appendChild(toast); setTimeout(() => { toast.style.transition = 'opacity 0.5s'; toast.style.opacity = 0; setTimeout(() => toast.remove(), 500); }, 2200); } })();