commit e36986b64d31b226b2ba8724391bb1987910bdf3 Author: Fehér Roland Date: Wed Oct 8 12:28:02 2025 +0200 Initial commit diff --git a/index.html b/index.html new file mode 100644 index 0000000..c526782 --- /dev/null +++ b/index.html @@ -0,0 +1,30 @@ + + + + + + Transmission Commander + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/js/helpers.js b/js/helpers.js new file mode 100644 index 0000000..e377870 --- /dev/null +++ b/js/helpers.js @@ -0,0 +1,7 @@ +"use strict"; + +function nukeChildren(node) { + while (node.childNodes.length > 0) { + node.removeChild(node.childNodes[0]); + } +} diff --git a/js/trserver.js b/js/trserver.js new file mode 100644 index 0000000..9c60d2c --- /dev/null +++ b/js/trserver.js @@ -0,0 +1,211 @@ +"use strict"; + +class trserver { + #rpcurl; + #authHeader; + #sessionHeader; + #requests = []; + #torrents = {}; + #isOnline = false; + + #logger = function(msg) {}; + #torrentCallback = function(hash, torrentInfo) {}; + + constructor (initdata, logger, tcb) { + this.#rpcurl = initdata.rpcurl; + if (logger != null) { + this.#logger = logger; + } + if (tcb != null) { + this.#torrentCallback = tcb; + } + this.#authHeader = initdata.auth; + this.#sessionHeader = null; + } + + #rpccall_prepare(method, args) { + let request = { + 'payload': { + 'method': method, + 'arguments': args + } + }; + let tag = this.#requests.push(request) - 1; + return tag; + } + + #rpccall(tag) { + let request = this.#requests[tag]; + //this.#logger(`[${tag}] >> ${request.payload.method}`); + let rpc = new window.XMLHttpRequest(); + rpc.tag = tag; + rpc.open('POST', this.#rpcurl); + if (this.#authHeader != null) { + rpc.setRequestHeader('authorization', this.#authHeader); + } + if (this.#sessionHeader != null) { + rpc.setRequestHeader('X-Transmission-Session-Id', this.#sessionHeader); + } + rpc.setRequestHeader('content-type', 'application/json'); + rpc.addEventListener('readystatechange', this.#handler.bind(this)); + rpc.addEventListener('timeout', this.#handler.bind(this)); + rpc.addEventListener('error', this.#handler.bind(this)); + rpc.timeout = 10000; + try { + rpc.send(JSON.stringify(request.payload)); + } catch (error) { + this.#logger(error); + } + } + + rpccall_debug(method, args) { + var tag = this.#rpccall_prepare(method, args); + let request = this.#requests[tag]; + request.success = console.info; + this.#rpccall(tag); + } + + #handler(e) { + switch (e.type) { + case 'readystatechange': + if (e.target.readyState == 4) { + let tag = e.target.tag; + let request = this.#requests[tag]; + switch (e.target.status) { + case 200: + if (!this.#isOnline) { + this.#logger('Server API connection estabilished'); + this.#isOnline = true; + this.#forceUpdateTorrents(); + } + //request.response = JSON.parse(e.target.responseText); + if (request.success != null) { + request.success(JSON.parse(e.target.responseText).arguments); + } + else { + //this.#logger(e.target.responseText); + } + break; + case 409: + this.#sessionHeader = e.target.getResponseHeader('X-Transmission-Session-Id'); + this.#rpccall(tag); + break; + default: + request.response = {'code': e.target.status, 'content': e.target.responseText}; + //this.#logger(JSON.stringify(request.response)); + break; + } + } + break; + case 'timeout': + if (this.#isOnline) { + this.#logger('Server API connection timed out'); + this.#isOnline = false; + this.#forceUpdateTorrents(); + } + break; + case 'error': + if (this.#isOnline) { + this.#logger('Server API refuses connection'); + this.#isOnline = false; + this.#forceUpdateTorrents(); + } + this.#logger(JSON.stringify(e)); + break; + } + } + + refreshSession (){ + //let tag = this.#rpccall_prepare('session-get', null); + //this.#rpccall(tag); + } + + #parseTorrents(response) { + // mark everything as deleted but clean + for (const [key, value] of Object.entries(this.#torrents)) { + value.wasDeleted = value.deleted; + value.deleted = true; + value.dirty = false; + } + // Update torrent data + for (const torrent of response.torrents) { + let hash = torrent.hashString; + if (!(hash in this.#torrents)) { + this.#torrents[hash] = {'dirty': true}; + } + let knownTorrent = this.#torrents[hash]; + for (const [key, value] of Object.entries(torrent)) { + let parsedValue = undefined; + switch (key) { + case 'hashString': + break; + case 'percentDone': + parsedValue = Math.floor(value * 100); + break; + default: + parsedValue = value; + break; + } + + if (parsedValue != undefined && knownTorrent[key] !== parsedValue) { + knownTorrent[key] = parsedValue; + knownTorrent.dirty = true; + } + } + knownTorrent.deleted = false; + } + //check for changes + for (const [key, value] of Object.entries(this.#torrents)) { + if (value.deleted != value.wasDeleted || value.dirty) { + // call UI update + this.#torrentCallback(key, value); + } + } + } + + #forceUpdateTorrents() { + for (const [key, value] of Object.entries(this.#torrents)) { + // call UI update + this.#torrentCallback(key, value); + } + } + + refreshTorrentList() { + let tag = this.#rpccall_prepare('torrent-get', {'fields': ['id', 'hashString', 'name', 'status', 'percentDone', 'comment', 'magnetLink']}); + this.#requests[tag].success = this.#parseTorrents.bind(this); + this.#rpccall(tag); + } + + torrent_add_url(url, dontstart, path) { + let args = { + 'filename': url, + 'paused': dontstart + }; + if (path != null) { + args['download-dir'] = path; + } + let tag = this.#rpccall_prepare('torrent-add', args); + this.#rpccall(tag); + } + + torrent_start(hash) { + let tag = this.#rpccall_prepare('torrent-start', {'ids':[hash]}); + this.#rpccall(tag); + } + + torrent_pause(hash) { + let tag = this.#rpccall_prepare('torrent-stop', {'ids':[hash]}); + this.#rpccall(tag); + } + + getInstanceData() { + return { + 'rpcurl': this.#rpcurl, + 'auth': this.#authHeader + }; + } + + isOnline() { + return this.#isOnline; + } +}; diff --git a/js/trweb.js b/js/trweb.js new file mode 100644 index 0000000..4fa0fdc --- /dev/null +++ b/js/trweb.js @@ -0,0 +1,387 @@ +"use strict"; + +class trweb { + #servers = {}; + #knownTorrents = {}; + + #needsSort = false; + + #loadcb = function(key) {return localStorage.getItem(`trweb.${key}`)}; + #savecb = function(key, value) {localStorage.setItem(`trweb.${key}`, value)}; + #logger = function(msg) {}; + + #timer; + + + #container; + #guiHeader + #torrentListView; + #guiFooter + + constructor (container, loadcb, savecb, logger) { + this.#container = container; + if (loadcb != null) { + this.#loadcb = loadcb; + this.#savecb = savecb; + } + if (logger != null) { + this.#logger = logger; + } + + if (document.readyState === 'complete' || document.readyState === 'interactive') { + this.#logger('Document already loaded, jumpstarting'); + this.#handler({'type': 'DOMContentLoaded'}); + } + else { + this.#logger('Waiting for document to load'); + document.addEventListener('DOMContentLoaded', this.#handler.bind(this)); + } + } + + #registerServer(name, initdata) { + let torrentCallback = function(hash, torrentInfo) { + let mergedTorrentInfo = this.#knownTorrents[hash]; + if (mergedTorrentInfo == null) { + mergedTorrentInfo = this.#createTorrentEntry(hash); + } + mergedTorrentInfo.servers[name] = torrentInfo; + mergedTorrentInfo.name = torrentInfo.name; + + this.#updateTorrentDisplay(mergedTorrentInfo); + }; + let newserver = new trserver(initdata, function(msg) {this.#logger(`[Server: ${name}]: ${msg}`);}.bind(this), torrentCallback.bind(this)); + this.#servers[name] = newserver; + newserver.refreshTorrentList(); + } + + #loadServers() { + let serverjson = this.#loadcb('servers'); + this.#servers = {}; + if (serverjson == null) { + this.#logger('No saved server data found'); + } + else { + let serverdata = JSON.parse(serverjson); + for (const [key, value] of Object.entries(serverdata)) { + this.#registerServer(key, value); + } + this.#logger(`Loaded ${Object.keys(this.#servers).length} servers`); + } + } + + #saveServers() { + let serverdata = {}; + for (const [key, value] of Object.entries(this.#servers)) { + serverdata[key] = value.getInstanceData(); + } + this.#logger(`Saving ${Object.keys(serverdata).length} servers`); + this.#savecb('servers', JSON.stringify(serverdata)); + } + + #createTorrentEntry(hash) { + let entry = { + 'element': document.createElement('div'), + 'element_name': document.createElement('div'), + 'element_server': {}, + 'hash': hash, + 'name': '## NAME NOT SET ##', + 'servers': {} + }; + + this.#knownTorrents[hash] = entry; + + // set up DOM structure + entry.element.torrent = entry; + entry.element.classList.add('trweb_torrentlistentry', 'trweb_hide_buttons', 'row'); + entry.element.setAttributeNS('trweb', 'torrentlistfield', 'entry'); + entry.element.addEventListener('click', this.#handler.bind(this)); + entry.element_name.classList.add('trweb_torrentlistname', 'col'); + entry.element_name.setAttributeNS('trweb', 'fieldid', 'displayname'); + entry.element.appendChild(entry.element_name); + for (const srv of Object.keys(this.#servers)) { + this.#createServerEntryForTorrent(hash, srv); + } + + this.#torrentListView.appendChild(entry.element); + + this.#updateTorrentDisplay(entry); + + return entry; + } + + #createServerEntryForTorrent(hash, srv) { + this.#needsSort = true; + let entry = this.#knownTorrents[hash]; + + let element_server = document.createElement('div'); + element_server.classList.add('trweb_torrentlistserver', 'col-2', 'container'); + element_server.setAttributeNS('trweb', 'torrentlistfield', 'server'); + element_server.setAttributeNS('trweb', 'server', srv); + entry.element_server[srv] = element_server; + entry.element.appendChild(entry.element_server[srv]); + + // statusbar + let element_statusbar = document.createElement('div'); + element_statusbar.classList.add('trweb_torrentliststatusbar'); + element_server.element_statusbar = element_statusbar; + element_server.appendChild(element_statusbar); + + // row container + let element_serverrow = document.createElement('div'); + element_serverrow.classList.add('trweb_torrentlistserverrow', 'row'); + element_server.element_row = element_serverrow; + element_server.appendChild(element_serverrow); + + // Current status and percentage + let element_status = document.createElement('div'); + element_status.classList.add('trweb_torrentliststatus', 'col-12'); + element_status.setAttributeNS('trweb', 'server', srv); + element_status.setAttributeNS('trweb', 'torrentlistfield', 'status'); + element_server.element_status = element_status; + element_serverrow.appendChild(element_status); + + let element_start = document.createElement('div'); + element_start.classList.add('trweb_torrentlistcontrol', 'col-4'); + element_start.setAttributeNS('trweb', 'server', srv); + element_start.setAttributeNS('trweb', 'torrent', hash); + element_start.setAttributeNS('trweb', 'torrentlistfield', 'start'); + element_start.appendChild(document.createTextNode("Start")); + element_start.addEventListener("click", this.#handler.bind(this)); + element_server.element_start = element_start; + element_serverrow.appendChild(element_start); + + let element_pause = document.createElement('div'); + element_pause.classList.add('trweb_torrentlistcontrol', 'col-4'); + element_pause.setAttributeNS('trweb', 'server', srv); + element_pause.setAttributeNS('trweb', 'torrent', hash); + element_pause.setAttributeNS('trweb', 'torrentlistfield', 'pause'); + element_pause.appendChild(document.createTextNode("Pause")); + element_pause.addEventListener("click", this.#handler.bind(this)); + element_server.element_pause = element_pause; + element_serverrow.appendChild(element_pause); + + // Download by magnet + let element_magnet = document.createElement('div'); + element_magnet.classList.add('trweb_torrentlistmagnet', 'col'); + element_magnet.setAttributeNS('trweb', 'server', srv); + element_magnet.setAttributeNS('trweb', 'torrent', hash); + element_magnet.setAttributeNS('trweb', 'torrentlistfield', 'magnet'); + element_magnet.appendChild(document.createTextNode(`[${srv}] Magnet`)); + element_magnet.addEventListener("click", this.#handler.bind(this)); + element_server.element_magnet = element_magnet; + element_serverrow.appendChild(element_magnet); + + // Download by torrent file + let element_download = document.createElement('div'); + element_download.classList.add('trweb_torrentlistdownload', 'col'); + element_download.setAttributeNS('trweb', 'server', srv); + element_download.setAttributeNS('trweb', 'torrent', hash); + element_download.setAttributeNS('trweb', 'torrentlistfield', 'download'); + element_download.appendChild(document.createTextNode(`[${srv}] Get torrent`)); + element_download.addEventListener("click", this.#handler.bind(this)); + element_server.element_download = element_download; + element_serverrow.appendChild(element_download); + } + + #updateTorrentDisplay(torrent) { + let txt_name = torrent.element_name; + nukeChildren(txt_name); + txt_name.appendChild(document.createTextNode(torrent.name)); + + for (const srv of Object.keys(this.#servers)) { + let server = torrent.element_server[srv]; + let status = server.element_status; + + server.classList.remove( + 'trweb_status_asdf', + 'trweb_status_offline', + 'trweb_status_nonexistent', + 'trweb_status_paused', + 'trweb_status_verifqueued', + 'trweb_status_verifying', + 'trweb_status_downloading', + 'trweb_status_seeding' + ); + + nukeChildren(status); + + let statustext = 'Nothing to see here'; + let statusclass = 'trweb_status_asdf' + let barwidth = 50; + if (!this.#servers[srv].isOnline()) { + statustext = "Server offline"; + statusclass = 'trweb_status_offline'; + barwidth = 0; + } + else if (torrent.servers[srv] == undefined || torrent.servers[srv].deleted) { + statustext = "Not available"; + statusclass = 'trweb_status_nonexistent'; + barwidth = 0; + } + else { + statustext = `${torrent.servers[srv].status}`; + barwidth = torrent.servers[srv].percentDone; + switch (torrent.servers[srv].status) { + case 0: + statustext = 'Paused'; + statusclass = 'trweb_status_paused'; + break; + case 1: + statustext = 'Queued for verification'; + statusclass = 'trweb_status_verifqueued'; + break; + case 2: + statustext = 'Verifying'; + statusclass = 'trweb_status_verifying'; + break; + case 3: + statustext = 'Queued'; + break; + case 4: + statustext = 'Downloading'; + statusclass = 'trweb_status_downloading'; + break; + case 6: + statustext = 'Seeding'; + statusclass = 'trweb_status_seeding'; + break; + } + + statustext = `[${srv}]: ${statustext} - ${torrent.servers[srv].percentDone}%`; + } + server.element_statusbar.style.width = `${barwidth}%`; + server.classList.add(statusclass); + status.appendChild(document.createTextNode(statustext)); + } + } + + #handler(e) { + switch (e.type) { + case 'click': + if (e.currentTarget.hasAttributeNS('trweb', 'torrentlistfield')) { + let hash; + switch (e.currentTarget.getAttributeNS('trweb', 'torrentlistfield')) { + case 'entry': + e.currentTarget.classList.toggle('trweb_hide_buttons'); + e.stopPropagation(); + break; + case 'start': + hash = e.currentTarget.getAttributeNS('trweb', 'torrent'); + this.#servers[e.currentTarget.getAttributeNS('trweb', 'server')].torrent_start(hash); + e.stopPropagation(); + break; + case 'pause': + hash = e.currentTarget.getAttributeNS('trweb', 'torrent'); + this.#servers[e.currentTarget.getAttributeNS('trweb', 'server')].torrent_pause(hash); + e.stopPropagation(); + break; + case 'magnet': + let magneturl; + hash = e.currentTarget.getAttributeNS('trweb', 'torrent'); + for (const [srv, data] of Object.entries(this.#knownTorrents[hash].servers)) { + if (data.magnetLink != null) { + magneturl = data.magnetLink; + break; + } + } + if (magneturl != null) { + this.#servers[e.currentTarget.getAttributeNS('trweb', 'server')].torrent_add_url(magneturl, false, null); + } + else { + this.#logger(`Couldn't find magnet link! ${JSON.stringify(this.#knownTorrents[hash].servers)}`); + } + e.stopPropagation(); + break; + case 'download': + e.stopPropagation(); + window.alert('not implemented'); + break; + } + } + break; + case 'DOMContentLoaded': + this.#logger('Starting TRWEB instance'); + this.#container = document.getElementById(this.#container); + this.#container.classList.add('trweb_container'); + this.#guiHeader = document.createElement('div'); + this.#guiHeader.classList.add('trweb_header'); + this.#container.appendChild(this.#guiHeader); + this.#torrentListView = document.createElement('div'); + this.#torrentListView.classList.add('trweb_torrentlistview', 'container-fluid'); + this.#container.appendChild(this.#torrentListView); + this.#guiFooter = document.createElement('div'); + this.#guiFooter.classList.add('trweb_footer'); + this.#container.appendChild(this.#guiFooter); + this.#loadServers(); + this.#saveServers(); + this.setTimer(); + break; + } + } + + refresh() { + for (const [key, value] of Object.entries(this.#servers)) { + //value.refreshSession(); + value.refreshTorrentList(); + } + } + + sort() { + let list = Object.values(this.#knownTorrents); + list.sort((a, b) => a.name.localeCompare(b.name)); + + for (const node of list) { + this.#torrentListView.appendChild(node.element); + } + return list; + } + + #timercb() { + if (this.#needsSort) { + this.#needsSort = false; + this.sort(); + } + this.refresh(); + } + + setTimer() { + this.#timer = window.setInterval(this.#timercb.bind(this), 2000); + } + + haltTimer() { + window.clearInterval(this.#timer); + } + + save() { + this.#saveServers(); + } + + rpc(server, method, args) { + this.#servers[server].rpccall_debug(method, args); + } + + addServer(name, url, user, pass) { + this.#registerServer(name, {'rpcurl': url, 'auth': `Basic ${btoa(`${user}:${pass}`)}`}); + + for (const hash of Object.keys(this.#knownTorrents)) { + this.#createServerEntryForTorrent(hash, name); + } + + this.#saveServers(); + } + + removeServer(name) { + delete this.#servers[name]; + + for (const [hash, entry] of Object.entries(this.#knownTorrents)) { + entry.element.removeChild(entry.element_server[name]); + delete entry.servers[name]; + delete entry.element_server[name] + } + + this.#saveServers(); + } +} + +var trinstance = new trweb('trcontainer', null, null, function(msg) {console.info(`TRWEB: ${msg}`)}); +//var trinstance = new trweb('trcontainer', null, null, null); diff --git a/style/trweb.css b/style/trweb.css new file mode 100644 index 0000000..3f2f180 --- /dev/null +++ b/style/trweb.css @@ -0,0 +1,95 @@ +.trweb_torrentlistview { + background-color: cadetblue; +} +.trweb_torrentlistentry:nth-child(3n-1) { + background-color: darkcyan; +} +.trweb_torrentlistentry:nth-child(3n) { + background-color: cornflowerblue; +} + +.trweb_torrentlistentry:hover { + background-color: aqua; +} + +.trweb_torrentlistserver { + position: relative; +} + +.trweb_torrentlistserver.trweb_status_offline { + display: none; +} + +.trweb_torrentlistserver > div > * { + margin: 2px; +} + +.trweb_torrentliststatusbar { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 50%; + /*background-color: darkslateblue;*/ + background-clip: content-box; + padding: 2px; + border-radius: 6px; +} + +.trweb_status_paused .trweb_torrentliststatusbar { + background-color: gray; +} + +.trweb_status_verifqueued .trweb_torrentliststatusbar { + background-color: chocolate; +} + +.trweb_status_verifying .trweb_torrentliststatusbar { + background-color: gold; +} + +.trweb_status_downloading .trweb_torrentliststatusbar { + background-color: aquamarine; +} + +.trweb_status_seeding .trweb_torrentliststatusbar { + background-color: chartreuse; +} + +.trweb_torrentlistserverrow { + position: relative; + z-index: 1; +} + +.trweb_torrentliststatus { + /*background-color: darkslateblue; + background-size: 50% auto;*/ +} + +.trweb_torrentlistserver.trweb_status_nonexistent .trweb_torrentliststatus, +.trweb_torrentlistserver.trweb_status_nonexistent .trweb_torrentliststatusbar, +.trweb_torrentlistserver.trweb_status_nonexistent .trweb_torrentlistcontrol, +.trweb_torrentlistentry.trweb_hide_buttons .trweb_torrentlistcontrol { + display: none; +} + +.trweb_torrentlistcontrol, +.trweb_torrentlistmagnet, +.trweb_torrentlistdownload { + background-color: white; + font-weight: bold; + border: 1p solid black; + border-radius: 4px; +} + +.trweb_torrentlistmagnet, +.trweb_torrentlistdownload { + display: none; +} + +/* only display if magnet is available */ +.trweb_torrentlistserver.trweb_status_nonexistent .trweb_torrentlistmagnet, +.trweb_torrentlistserver.trweb_status_nonexistent .trweb_hastorrent .trweb_torrentlistdownload { + display: block; +} +