feat(torrent): Add LuCI dashboard and fix WebTorrent ESM issue
- Add luci-app-torrent: unified dashboard for qBittorrent + WebTorrent - RPCD handler with status/list/start/stop/add methods - Dark-themed UI with real-time torrent queue display - Start/Stop controls and magnet link add functionality - 5-second auto-refresh polling - Fix webtorrent v2.x ESM incompatibility - Pin to v1.9.7 (last CommonJS version) - Use npm install with --save-exact to prevent semver drift - HAProxy exposure configured: - qBittorrent: torrent.gk2.secubox.in (192.168.255.42:8090) - WebTorrent: stream.gk2.secubox.in (192.168.255.43:8095) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0ec28266c5
commit
10b3d3a43c
@ -5124,3 +5124,23 @@ git checkout HEAD -- index.html
|
||||
- `secubox-app-sabnzbd/`: Makefile, UCI config, init.d, sabnzbdctl
|
||||
- `secubox-app-nzbhydra/`: Makefile, UCI config, init.d, nzbhydractl
|
||||
- `luci-app-newsbin/`: overview.js, RPCD handler, ACL, menu
|
||||
|
||||
109. **qBittorrent & WebTorrent - Torrent Services (2026-03-15)**
|
||||
- Both use Debian LXC containers (no Docker/Podman)
|
||||
- **qBittorrent** (`secubox-app-qbittorrent`):
|
||||
- Container IP: 192.168.255.42:8090
|
||||
- CLI: `qbittorrentctl install|start|stop|status|add|list|shell|configure-haproxy`
|
||||
- Default login: admin / adminadmin
|
||||
- Installs qbittorrent-nox via apt inside container
|
||||
- Torrent add via magnet links or URLs
|
||||
- **WebTorrent** (`secubox-app-webtorrent`):
|
||||
- Container IP: 192.168.255.43:8095
|
||||
- CLI: `webtorrentctl install|start|stop|status|add|list|shell|configure-haproxy`
|
||||
- Node.js streaming server with browser-based WebRTC support
|
||||
- Fixed webtorrent v2.x ESM incompatibility: pinned to v1.9.7 (last CommonJS version)
|
||||
- npm exact version install prevents semver resolution to breaking v2.x
|
||||
- In-browser streaming via `/stream/:infoHash/:path` endpoint
|
||||
- Dark-themed web UI with real-time torrent status
|
||||
- **Files**:
|
||||
- `secubox-app-qbittorrent/`: Makefile, UCI config, init.d, qbittorrentctl
|
||||
- `secubox-app-webtorrent/`: Makefile, UCI config, init.d, webtorrentctl
|
||||
|
||||
25
package/secubox/luci-app-torrent/Makefile
Normal file
25
package/secubox/luci-app-torrent/Makefile
Normal file
@ -0,0 +1,25 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
LUCI_TITLE:=LuCI Torrent Dashboard
|
||||
LUCI_DESCRIPTION:=Unified dashboard for qBittorrent and WebTorrent
|
||||
LUCI_DEPENDS:=+luci-base +secubox-app-qbittorrent +secubox-app-webtorrent
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
PKG_NAME:=luci-app-torrent
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=1
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
define Package/luci-app-torrent/install
|
||||
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
|
||||
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-torrent.json $(1)/usr/share/luci/menu.d/
|
||||
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
|
||||
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-torrent.json $(1)/usr/share/rpcd/acl.d/
|
||||
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
|
||||
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.torrent $(1)/usr/libexec/rpcd/
|
||||
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/torrent
|
||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/torrent/overview.js $(1)/www/luci-static/resources/view/torrent/
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,luci-app-torrent))
|
||||
@ -0,0 +1,311 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require poll';
|
||||
|
||||
var callStatus = rpc.declare({
|
||||
object: 'luci.torrent',
|
||||
method: 'status',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callQbtList = rpc.declare({
|
||||
object: 'luci.torrent',
|
||||
method: 'qbt_list',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callWtList = rpc.declare({
|
||||
object: 'luci.torrent',
|
||||
method: 'wt_list',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callQbtStart = rpc.declare({
|
||||
object: 'luci.torrent',
|
||||
method: 'qbt_start'
|
||||
});
|
||||
|
||||
var callQbtStop = rpc.declare({
|
||||
object: 'luci.torrent',
|
||||
method: 'qbt_stop'
|
||||
});
|
||||
|
||||
var callWtStart = rpc.declare({
|
||||
object: 'luci.torrent',
|
||||
method: 'wt_start'
|
||||
});
|
||||
|
||||
var callWtStop = rpc.declare({
|
||||
object: 'luci.torrent',
|
||||
method: 'wt_stop'
|
||||
});
|
||||
|
||||
var callQbtAdd = rpc.declare({
|
||||
object: 'luci.torrent',
|
||||
method: 'qbt_add',
|
||||
params: ['url']
|
||||
});
|
||||
|
||||
var callWtAdd = rpc.declare({
|
||||
object: 'luci.torrent',
|
||||
method: 'wt_add',
|
||||
params: ['magnet']
|
||||
});
|
||||
|
||||
function formatSpeed(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B/s';
|
||||
var k = 1024;
|
||||
var sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
var i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
var k = 1024;
|
||||
var sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
callStatus(),
|
||||
callQbtList(),
|
||||
callWtList()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var status = data[0] || {};
|
||||
var qbt = status.qbittorrent || {};
|
||||
var wt = status.webtorrent || {};
|
||||
var qbtList = (data[1] || {}).torrents || [];
|
||||
var wtList = (data[2] || {}).torrents || [];
|
||||
|
||||
var view = E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', { 'style': 'color: #00d4ff; margin-bottom: 20px;' }, [
|
||||
E('span', { 'style': 'margin-right: 10px;' }, '\u{1F9F2}'),
|
||||
'Torrent Services'
|
||||
]),
|
||||
|
||||
// Status Cards
|
||||
E('div', { 'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 30px;' }, [
|
||||
// qBittorrent Card
|
||||
E('div', { 'style': 'background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); border-radius: 12px; padding: 20px; border: 1px solid #2a2a4e;' }, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;' }, [
|
||||
E('h3', { 'style': 'color: #00d4ff; margin: 0; font-size: 18px;' }, '\u{1F4E5} qBittorrent'),
|
||||
E('span', {
|
||||
'style': 'padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; ' +
|
||||
(qbt.running ? 'background: rgba(0,200,83,0.2); color: #00c853;' : 'background: rgba(244,67,54,0.2); color: #f44336;')
|
||||
}, qbt.running ? 'RUNNING' : 'STOPPED')
|
||||
]),
|
||||
E('div', { 'style': 'display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 15px;' }, [
|
||||
E('div', { 'style': 'background: rgba(0,0,0,0.2); padding: 10px; border-radius: 8px;' }, [
|
||||
E('div', { 'style': 'color: #888; font-size: 11px;' }, 'DOWNLOAD'),
|
||||
E('div', { 'style': 'color: #00c853; font-size: 16px; font-weight: 600;' }, formatSpeed(qbt.dl_speed))
|
||||
]),
|
||||
E('div', { 'style': 'background: rgba(0,0,0,0.2); padding: 10px; border-radius: 8px;' }, [
|
||||
E('div', { 'style': 'color: #888; font-size: 11px;' }, 'UPLOAD'),
|
||||
E('div', { 'style': 'color: #2196f3; font-size: 16px; font-weight: 600;' }, formatSpeed(qbt.up_speed))
|
||||
])
|
||||
]),
|
||||
E('div', { 'style': 'color: #888; font-size: 13px; margin-bottom: 15px;' }, [
|
||||
E('span', {}, 'Torrents: '),
|
||||
E('strong', { 'style': 'color: #e0e0e0;' }, String(qbt.torrents || 0)),
|
||||
qbt.version ? E('span', { 'style': 'margin-left: 15px;' }, ['Version: ', E('strong', { 'style': 'color: #e0e0e0;' }, qbt.version)]) : ''
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; gap: 10px;' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button',
|
||||
'style': 'flex: 1; padding: 8px; border-radius: 6px; ' +
|
||||
(qbt.running ? 'background: #f44336; border-color: #f44336;' : 'background: #00c853; border-color: #00c853;'),
|
||||
'click': ui.createHandlerFn(this, function() {
|
||||
return (qbt.running ? callQbtStop() : callQbtStart()).then(function() {
|
||||
window.location.reload();
|
||||
});
|
||||
})
|
||||
}, qbt.running ? 'Stop' : 'Start'),
|
||||
E('a', {
|
||||
'href': qbt.url || 'http://192.168.255.42:8090/',
|
||||
'target': '_blank',
|
||||
'class': 'btn cbi-button',
|
||||
'style': 'flex: 1; padding: 8px; border-radius: 6px; text-align: center; text-decoration: none; background: #2196f3; border-color: #2196f3; color: white;'
|
||||
}, 'Open UI')
|
||||
])
|
||||
]),
|
||||
|
||||
// WebTorrent Card
|
||||
E('div', { 'style': 'background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); border-radius: 12px; padding: 20px; border: 1px solid #2a2a4e;' }, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;' }, [
|
||||
E('h3', { 'style': 'color: #7c3aed; margin: 0; font-size: 18px;' }, '\u{1F4FA} WebTorrent'),
|
||||
E('span', {
|
||||
'style': 'padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; ' +
|
||||
(wt.running ? 'background: rgba(0,200,83,0.2); color: #00c853;' : 'background: rgba(244,67,54,0.2); color: #f44336;')
|
||||
}, wt.running ? 'RUNNING' : 'STOPPED')
|
||||
]),
|
||||
E('div', { 'style': 'background: rgba(0,0,0,0.2); padding: 15px; border-radius: 8px; margin-bottom: 15px;' }, [
|
||||
E('div', { 'style': 'color: #888; font-size: 11px;' }, 'ACTIVE STREAMS'),
|
||||
E('div', { 'style': 'color: #7c3aed; font-size: 24px; font-weight: 600;' }, String(wt.torrents || 0))
|
||||
]),
|
||||
E('div', { 'style': 'color: #888; font-size: 13px; margin-bottom: 15px;' }, [
|
||||
'Browser-based torrent streaming with WebRTC'
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; gap: 10px;' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button',
|
||||
'style': 'flex: 1; padding: 8px; border-radius: 6px; ' +
|
||||
(wt.running ? 'background: #f44336; border-color: #f44336;' : 'background: #00c853; border-color: #00c853;'),
|
||||
'click': ui.createHandlerFn(this, function() {
|
||||
return (wt.running ? callWtStop() : callWtStart()).then(function() {
|
||||
window.location.reload();
|
||||
});
|
||||
})
|
||||
}, wt.running ? 'Stop' : 'Start'),
|
||||
E('a', {
|
||||
'href': wt.url || 'http://192.168.255.43:8095/',
|
||||
'target': '_blank',
|
||||
'class': 'btn cbi-button',
|
||||
'style': 'flex: 1; padding: 8px; border-radius: 6px; text-align: center; text-decoration: none; background: #7c3aed; border-color: #7c3aed; color: white;'
|
||||
}, 'Open UI')
|
||||
])
|
||||
])
|
||||
]),
|
||||
|
||||
// Add Torrent Section
|
||||
E('div', { 'style': 'background: #1a1a2e; border-radius: 12px; padding: 20px; border: 1px solid #2a2a4e; margin-bottom: 30px;' }, [
|
||||
E('h3', { 'style': 'color: #e0e0e0; margin: 0 0 15px 0; font-size: 16px;' }, '\u2795 Add Torrent'),
|
||||
E('div', { 'style': 'display: flex; gap: 10px;' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'torrent-url',
|
||||
'placeholder': 'Paste magnet link or torrent URL...',
|
||||
'style': 'flex: 1; padding: 12px; background: #0a0a12; border: 1px solid #3a3a4e; border-radius: 8px; color: #e0e0e0; font-size: 14px;'
|
||||
}),
|
||||
E('button', {
|
||||
'class': 'btn cbi-button',
|
||||
'style': 'padding: 12px 24px; background: linear-gradient(135deg, #00d4ff, #7c3aed); border: none; border-radius: 8px; color: white; font-weight: 600;',
|
||||
'click': ui.createHandlerFn(this, function() {
|
||||
var url = document.getElementById('torrent-url').value.trim();
|
||||
if (!url) {
|
||||
ui.addNotification(null, E('p', 'Please enter a magnet link or URL'), 'warning');
|
||||
return;
|
||||
}
|
||||
// Add to both services
|
||||
var promises = [];
|
||||
if (qbt.running) promises.push(callQbtAdd(url));
|
||||
if (wt.running && url.startsWith('magnet:')) promises.push(callWtAdd(url));
|
||||
if (promises.length === 0) {
|
||||
ui.addNotification(null, E('p', 'No torrent service is running'), 'warning');
|
||||
return;
|
||||
}
|
||||
return Promise.all(promises).then(function() {
|
||||
ui.addNotification(null, E('p', 'Torrent added successfully'), 'info');
|
||||
document.getElementById('torrent-url').value = '';
|
||||
window.location.reload();
|
||||
});
|
||||
})
|
||||
}, 'Add')
|
||||
])
|
||||
]),
|
||||
|
||||
// Torrent Lists
|
||||
E('div', { 'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 20px;' }, [
|
||||
// qBittorrent List
|
||||
E('div', { 'style': 'background: #1a1a2e; border-radius: 12px; padding: 20px; border: 1px solid #2a2a4e;' }, [
|
||||
E('h3', { 'style': 'color: #00d4ff; margin: 0 0 15px 0; font-size: 16px;' }, '\u{1F4E5} qBittorrent Queue'),
|
||||
E('div', { 'id': 'qbt-list' }, this.renderQbtList(qbtList))
|
||||
]),
|
||||
|
||||
// WebTorrent List
|
||||
E('div', { 'style': 'background: #1a1a2e; border-radius: 12px; padding: 20px; border: 1px solid #2a2a4e;' }, [
|
||||
E('h3', { 'style': 'color: #7c3aed; margin: 0 0 15px 0; font-size: 16px;' }, '\u{1F4FA} WebTorrent Streams'),
|
||||
E('div', { 'id': 'wt-list' }, this.renderWtList(wtList))
|
||||
])
|
||||
])
|
||||
]);
|
||||
|
||||
poll.add(L.bind(this.pollData, this), 5);
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
renderQbtList: function(torrents) {
|
||||
if (!torrents || torrents.length === 0) {
|
||||
return E('div', { 'style': 'color: #666; text-align: center; padding: 30px;' }, 'No active torrents');
|
||||
}
|
||||
|
||||
return E('div', {}, torrents.slice(0, 10).map(function(t) {
|
||||
var progress = (t.progress || 0) * 100;
|
||||
var state = t.state || 'unknown';
|
||||
var stateColor = state.includes('download') ? '#00c853' : (state.includes('upload') ? '#2196f3' : '#888');
|
||||
|
||||
return E('div', { 'style': 'background: rgba(0,0,0,0.2); border-radius: 8px; padding: 12px; margin-bottom: 10px;' }, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; margin-bottom: 8px;' }, [
|
||||
E('span', { 'style': 'color: #e0e0e0; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 70%;' }, t.name || 'Unknown'),
|
||||
E('span', { 'style': 'color: ' + stateColor + '; font-size: 11px; text-transform: uppercase;' }, state)
|
||||
]),
|
||||
E('div', { 'style': 'background: #2a2a3e; border-radius: 4px; height: 6px; overflow: hidden;' }, [
|
||||
E('div', { 'style': 'background: linear-gradient(90deg, #00d4ff, #7c3aed); height: 100%; width: ' + progress + '%; transition: width 0.3s;' })
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; margin-top: 6px; color: #888; font-size: 11px;' }, [
|
||||
E('span', {}, progress.toFixed(1) + '%'),
|
||||
E('span', {}, formatSize(t.size || t.total_size || 0)),
|
||||
E('span', {}, '\u2193 ' + formatSpeed(t.dlspeed || 0) + ' \u2191 ' + formatSpeed(t.upspeed || 0))
|
||||
])
|
||||
]);
|
||||
}));
|
||||
},
|
||||
|
||||
renderWtList: function(torrents) {
|
||||
if (!torrents || torrents.length === 0) {
|
||||
return E('div', { 'style': 'color: #666; text-align: center; padding: 30px;' }, 'No active streams');
|
||||
}
|
||||
|
||||
return E('div', {}, torrents.slice(0, 10).map(function(t) {
|
||||
var progress = (t.progress || 0) * 100;
|
||||
|
||||
return E('div', { 'style': 'background: rgba(0,0,0,0.2); border-radius: 8px; padding: 12px; margin-bottom: 10px;' }, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; margin-bottom: 8px;' }, [
|
||||
E('span', { 'style': 'color: #e0e0e0; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 70%;' }, t.name || 'Unknown'),
|
||||
E('span', { 'style': 'color: #7c3aed; font-size: 11px;' }, (t.numPeers || 0) + ' peers')
|
||||
]),
|
||||
E('div', { 'style': 'background: #2a2a3e; border-radius: 4px; height: 6px; overflow: hidden;' }, [
|
||||
E('div', { 'style': 'background: linear-gradient(90deg, #7c3aed, #00d4ff); height: 100%; width: ' + progress + '%; transition: width 0.3s;' })
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; margin-top: 6px; color: #888; font-size: 11px;' }, [
|
||||
E('span', {}, progress.toFixed(1) + '%'),
|
||||
E('span', {}, formatSize(t.length || 0)),
|
||||
E('span', {}, '\u2193 ' + formatSpeed(t.downloadSpeed || 0))
|
||||
])
|
||||
]);
|
||||
}));
|
||||
},
|
||||
|
||||
pollData: function() {
|
||||
var self = this;
|
||||
return Promise.all([callQbtList(), callWtList()]).then(function(data) {
|
||||
var qbtList = (data[0] || {}).torrents || [];
|
||||
var wtList = (data[1] || {}).torrents || [];
|
||||
|
||||
var qbtEl = document.getElementById('qbt-list');
|
||||
var wtEl = document.getElementById('wt-list');
|
||||
|
||||
if (qbtEl) {
|
||||
qbtEl.innerHTML = '';
|
||||
qbtEl.appendChild(self.renderQbtList(qbtList));
|
||||
}
|
||||
if (wtEl) {
|
||||
wtEl.innerHTML = '';
|
||||
wtEl.appendChild(self.renderWtList(wtList));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
# RPCD handler for Torrent dashboard (qBittorrent + WebTorrent)
|
||||
|
||||
. /usr/share/libubox/jshn.sh
|
||||
|
||||
QBT_IP="192.168.255.42"
|
||||
QBT_PORT="8090"
|
||||
WT_IP="192.168.255.43"
|
||||
WT_PORT="8095"
|
||||
|
||||
list_methods() {
|
||||
cat << 'EOFM'
|
||||
{"status":{},"qbt_list":{},"wt_list":{},"qbt_start":{},"qbt_stop":{},"wt_start":{},"wt_stop":{},"qbt_add":{"url":"str"},"wt_add":{"magnet":"str"}}
|
||||
EOFM
|
||||
}
|
||||
|
||||
# Check if container is running
|
||||
is_running() {
|
||||
/usr/bin/lxc-info -n "$1" 2>/dev/null | grep -q "RUNNING"
|
||||
}
|
||||
|
||||
# Get qBittorrent status
|
||||
get_qbt_status() {
|
||||
local running=0
|
||||
local version=""
|
||||
local dl_speed=0
|
||||
local up_speed=0
|
||||
local torrents=0
|
||||
|
||||
if is_running "qbittorrent"; then
|
||||
running=1
|
||||
version=$(/usr/bin/curl -s "http://${QBT_IP}:${QBT_PORT}/api/v2/app/version" 2>/dev/null)
|
||||
local transfer=$(/usr/bin/curl -s "http://${QBT_IP}:${QBT_PORT}/api/v2/transfer/info" 2>/dev/null)
|
||||
if [ -n "$transfer" ]; then
|
||||
dl_speed=$(echo "$transfer" | jsonfilter -e '@.dl_info_speed' 2>/dev/null)
|
||||
up_speed=$(echo "$transfer" | jsonfilter -e '@.up_info_speed' 2>/dev/null)
|
||||
fi
|
||||
local list=$(/usr/bin/curl -s "http://${QBT_IP}:${QBT_PORT}/api/v2/torrents/info" 2>/dev/null)
|
||||
if [ -n "$list" ] && [ "$list" != "[]" ]; then
|
||||
torrents=$(echo "$list" | jsonfilter -e '@[*]' 2>/dev/null | wc -l)
|
||||
fi
|
||||
fi
|
||||
|
||||
json_init
|
||||
json_add_boolean "running" "$running"
|
||||
json_add_string "version" "${version:-unknown}"
|
||||
json_add_int "dl_speed" "${dl_speed:-0}"
|
||||
json_add_int "up_speed" "${up_speed:-0}"
|
||||
json_add_int "torrents" "${torrents:-0}"
|
||||
json_add_string "url" "https://torrent.gk2.secubox.in/"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Get WebTorrent status
|
||||
get_wt_status() {
|
||||
local running=0
|
||||
local torrents=0
|
||||
|
||||
if is_running "webtorrent"; then
|
||||
running=1
|
||||
local list=$(/usr/bin/curl -s "http://${WT_IP}:${WT_PORT}/api/torrents" 2>/dev/null)
|
||||
if [ -n "$list" ] && [ "$list" != "[]" ]; then
|
||||
torrents=$(echo "$list" | jsonfilter -e '@[*]' 2>/dev/null | wc -l)
|
||||
fi
|
||||
fi
|
||||
|
||||
json_init
|
||||
json_add_boolean "running" "$running"
|
||||
json_add_int "torrents" "${torrents:-0}"
|
||||
json_add_string "url" "https://stream.gk2.secubox.in/"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Combined status
|
||||
do_status() {
|
||||
local qbt_running=0
|
||||
local wt_running=0
|
||||
local qbt_version=""
|
||||
local qbt_dl=0
|
||||
local qbt_up=0
|
||||
local qbt_count=0
|
||||
local wt_count=0
|
||||
|
||||
if is_running "qbittorrent"; then
|
||||
qbt_running=1
|
||||
qbt_version=$(/usr/bin/curl -s "http://${QBT_IP}:${QBT_PORT}/api/v2/app/version" 2>/dev/null)
|
||||
local transfer=$(/usr/bin/curl -s "http://${QBT_IP}:${QBT_PORT}/api/v2/transfer/info" 2>/dev/null)
|
||||
if [ -n "$transfer" ]; then
|
||||
qbt_dl=$(echo "$transfer" | jsonfilter -e '@.dl_info_speed' 2>/dev/null)
|
||||
qbt_up=$(echo "$transfer" | jsonfilter -e '@.up_info_speed' 2>/dev/null)
|
||||
fi
|
||||
local qlist=$(/usr/bin/curl -s "http://${QBT_IP}:${QBT_PORT}/api/v2/torrents/info" 2>/dev/null)
|
||||
if [ -n "$qlist" ] && [ "$qlist" != "[]" ]; then
|
||||
qbt_count=$(echo "$qlist" | jsonfilter -e '@[*]' 2>/dev/null | wc -l)
|
||||
fi
|
||||
fi
|
||||
|
||||
if is_running "webtorrent"; then
|
||||
wt_running=1
|
||||
local wtlist=$(/usr/bin/curl -s "http://${WT_IP}:${WT_PORT}/api/torrents" 2>/dev/null)
|
||||
if [ -n "$wtlist" ] && [ "$wtlist" != "[]" ]; then
|
||||
wt_count=$(echo "$wtlist" | jsonfilter -e '@[*]' 2>/dev/null | wc -l)
|
||||
fi
|
||||
fi
|
||||
|
||||
json_init
|
||||
json_add_object "qbittorrent"
|
||||
json_add_boolean "running" "$qbt_running"
|
||||
json_add_string "version" "${qbt_version:-unknown}"
|
||||
json_add_int "dl_speed" "${qbt_dl:-0}"
|
||||
json_add_int "up_speed" "${qbt_up:-0}"
|
||||
json_add_int "torrents" "${qbt_count:-0}"
|
||||
json_add_string "url" "https://torrent.gk2.secubox.in/"
|
||||
json_add_string "ip" "${QBT_IP}:${QBT_PORT}"
|
||||
json_close_object
|
||||
|
||||
json_add_object "webtorrent"
|
||||
json_add_boolean "running" "$wt_running"
|
||||
json_add_int "torrents" "${wt_count:-0}"
|
||||
json_add_string "url" "https://stream.gk2.secubox.in/"
|
||||
json_add_string "ip" "${WT_IP}:${WT_PORT}"
|
||||
json_close_object
|
||||
|
||||
json_dump
|
||||
}
|
||||
|
||||
# List qBittorrent torrents
|
||||
do_qbt_list() {
|
||||
local list=$(/usr/bin/curl -s "http://${QBT_IP}:${QBT_PORT}/api/v2/torrents/info" 2>/dev/null)
|
||||
if [ -z "$list" ] || [ "$list" = "Forbidden" ]; then
|
||||
echo '{"torrents":[]}'
|
||||
return
|
||||
fi
|
||||
echo "{\"torrents\":$list}"
|
||||
}
|
||||
|
||||
# List WebTorrent torrents
|
||||
do_wt_list() {
|
||||
local list=$(/usr/bin/curl -s "http://${WT_IP}:${WT_PORT}/api/torrents" 2>/dev/null)
|
||||
if [ -z "$list" ]; then
|
||||
echo '{"torrents":[]}'
|
||||
return
|
||||
fi
|
||||
echo "{\"torrents\":$list}"
|
||||
}
|
||||
|
||||
# Start qBittorrent
|
||||
do_qbt_start() {
|
||||
/usr/sbin/qbittorrentctl start >/dev/null 2>&1
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Stop qBittorrent
|
||||
do_qbt_stop() {
|
||||
/usr/sbin/qbittorrentctl stop >/dev/null 2>&1
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Start WebTorrent
|
||||
do_wt_start() {
|
||||
/usr/sbin/webtorrentctl start >/dev/null 2>&1
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Stop WebTorrent
|
||||
do_wt_stop() {
|
||||
/usr/sbin/webtorrentctl stop >/dev/null 2>&1
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Add torrent to qBittorrent
|
||||
do_qbt_add() {
|
||||
read -r input
|
||||
local url=$(echo "$input" | jsonfilter -e '@.url' 2>/dev/null)
|
||||
|
||||
if [ -z "$url" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "URL required"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
|
||||
local result=$(/usr/bin/curl -s -X POST "http://${QBT_IP}:${QBT_PORT}/api/v2/torrents/add" -d "urls=$url" 2>/dev/null)
|
||||
|
||||
json_init
|
||||
if [ "$result" = "Ok." ]; then
|
||||
json_add_boolean "success" 1
|
||||
else
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "$result"
|
||||
fi
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Add torrent to WebTorrent
|
||||
do_wt_add() {
|
||||
read -r input
|
||||
local magnet=$(echo "$input" | jsonfilter -e '@.magnet' 2>/dev/null)
|
||||
|
||||
if [ -z "$magnet" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Magnet link required"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
|
||||
local result=$(/usr/bin/curl -s -X POST "http://${WT_IP}:${WT_PORT}/api/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"magnet\":\"$magnet\"}" 2>/dev/null)
|
||||
|
||||
json_init
|
||||
if echo "$result" | grep -q "success"; then
|
||||
json_add_boolean "success" 1
|
||||
else
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Failed to add torrent"
|
||||
fi
|
||||
json_dump
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
list) list_methods ;;
|
||||
call)
|
||||
case "$2" in
|
||||
status) do_status ;;
|
||||
qbt_list) do_qbt_list ;;
|
||||
wt_list) do_wt_list ;;
|
||||
qbt_start) do_qbt_start ;;
|
||||
qbt_stop) do_qbt_stop ;;
|
||||
wt_start) do_wt_start ;;
|
||||
wt_stop) do_wt_stop ;;
|
||||
qbt_add) do_qbt_add ;;
|
||||
wt_add) do_wt_add ;;
|
||||
*) echo '{"error":"Unknown method"}' ;;
|
||||
esac
|
||||
;;
|
||||
*) echo '{"error":"Unknown command"}' ;;
|
||||
esac
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"admin/services/torrent": {
|
||||
"title": "Torrent",
|
||||
"order": 85,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "torrent/overview"
|
||||
},
|
||||
"depends": {
|
||||
"acl": ["luci-app-torrent"],
|
||||
"uci": { "qbittorrent": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"luci-app-torrent": {
|
||||
"description": "Grant access to Torrent dashboard",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"luci.torrent": ["status", "qbt_list", "wt_list"]
|
||||
},
|
||||
"uci": ["qbittorrent", "webtorrent"]
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"luci.torrent": ["qbt_start", "qbt_stop", "qbt_add", "wt_start", "wt_stop", "wt_add"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -87,34 +87,23 @@ if ! command -v node >/dev/null 2>&1; then
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
fi
|
||||
|
||||
# Install webtorrent-hybrid and instant.io server
|
||||
if [ ! -d "/opt/webtorrent/node_modules" ]; then
|
||||
echo "Installing WebTorrent packages..."
|
||||
# Install webtorrent packages - use EXACT version 1.9.7 (last CommonJS version)
|
||||
# v2.x is ESM-only and breaks require()
|
||||
if [ ! -d "/opt/webtorrent/node_modules/webtorrent" ]; then
|
||||
echo "Installing WebTorrent packages (v1.9.7 for CommonJS)..."
|
||||
mkdir -p /opt/webtorrent
|
||||
cd /opt/webtorrent
|
||||
|
||||
# Create package.json
|
||||
cat > package.json << 'PKGEOF'
|
||||
{
|
||||
"name": "webtorrent-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"webtorrent": "^2.0.0",
|
||||
"express": "^4.18.0",
|
||||
"cors": "^2.8.5"
|
||||
}
|
||||
}
|
||||
PKGEOF
|
||||
npm install webtorrent@1.9.7 express@4.18.0 cors@2.8.5 --save-exact --production || {
|
||||
echo "npm install failed"; exit 1;
|
||||
}
|
||||
fi
|
||||
|
||||
npm install --production || { echo "npm install failed"; exit 1; }
|
||||
|
||||
# Create server script
|
||||
cat > server.js << 'SRVEOF'
|
||||
# Create/update server script
|
||||
cat > /opt/webtorrent/server.js << 'SRVEOF'
|
||||
const WebTorrent = require('webtorrent');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const app = express();
|
||||
const client = new WebTorrent();
|
||||
@ -125,114 +114,17 @@ app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use('/downloads', express.static(DOWNLOAD_PATH));
|
||||
|
||||
// Serve web UI
|
||||
app.get('/', (req, res) => {
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>WebTorrent Streaming</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui; background: #0a0a12; color: #e0e0e0; min-height: 100vh; }
|
||||
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
|
||||
h1 { color: #00d4ff; margin-bottom: 20px; }
|
||||
.add-form { display: flex; gap: 10px; margin-bottom: 30px; }
|
||||
input[type="text"] { flex: 1; padding: 12px; background: #1a1a24; border: 1px solid #3a3a4e; border-radius: 8px; color: #e0e0e0; font-size: 16px; }
|
||||
button { padding: 12px 24px; background: linear-gradient(135deg, #00d4ff, #7c3aed); border: none; border-radius: 8px; color: #fff; font-weight: 600; cursor: pointer; }
|
||||
button:hover { opacity: 0.9; }
|
||||
.torrent { background: #1a1a24; border: 1px solid #2a2a3e; border-radius: 12px; padding: 20px; margin-bottom: 15px; }
|
||||
.torrent:hover { border-color: #00d4ff; }
|
||||
.torrent-name { font-weight: 600; color: #00d4ff; margin-bottom: 10px; word-break: break-all; }
|
||||
.torrent-info { color: #888; font-size: 14px; }
|
||||
.progress-bar { height: 8px; background: #2a2a3e; border-radius: 4px; margin: 10px 0; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, #00d4ff, #7c3aed); transition: width 0.3s; }
|
||||
.files { margin-top: 15px; }
|
||||
.file { padding: 8px 12px; background: #12121a; border-radius: 6px; margin: 5px 0; display: flex; justify-content: space-between; align-items: center; }
|
||||
.file a { color: #00d4ff; text-decoration: none; }
|
||||
.file a:hover { text-decoration: underline; }
|
||||
.empty { text-align: center; color: #666; padding: 60px; }
|
||||
video { width: 100%; border-radius: 8px; margin-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🌊 WebTorrent Streaming</h1>
|
||||
<div class="add-form">
|
||||
<input type="text" id="magnet" placeholder="Paste magnet link or info hash...">
|
||||
<button onclick="addTorrent()">Add</button>
|
||||
</div>
|
||||
<div id="torrents"></div>
|
||||
</div>
|
||||
<script>
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
async function addTorrent() {
|
||||
const magnet = document.getElementById('magnet').value.trim();
|
||||
if (!magnet) return;
|
||||
const res = await fetch('/api/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ magnet })
|
||||
});
|
||||
document.getElementById('magnet').value = '';
|
||||
loadTorrents();
|
||||
}
|
||||
|
||||
async function loadTorrents() {
|
||||
const res = await fetch('/api/torrents');
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('torrents');
|
||||
if (data.length === 0) {
|
||||
container.innerHTML = '<div class="empty">No torrents. Add a magnet link above.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = data.map(t => \`
|
||||
<div class="torrent">
|
||||
<div class="torrent-name">\${t.name}</div>
|
||||
<div class="torrent-info">
|
||||
\${formatBytes(t.downloaded)} / \${formatBytes(t.length)}
|
||||
(\${(t.progress * 100).toFixed(1)}%)
|
||||
↓ \${formatBytes(t.downloadSpeed)}/s
|
||||
↑ \${formatBytes(t.uploadSpeed)}/s
|
||||
Peers: \${t.numPeers}
|
||||
</div>
|
||||
<div class="progress-bar"><div class="progress-fill" style="width: \${t.progress * 100}%"></div></div>
|
||||
<div class="files">
|
||||
\${t.files.map(f => \`
|
||||
<div class="file">
|
||||
<span>\${f.name}</span>
|
||||
<a href="/stream/\${t.infoHash}/\${encodeURIComponent(f.path)}" target="_blank">Stream</a>
|
||||
</div>
|
||||
\`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
\`).join('');
|
||||
}
|
||||
|
||||
setInterval(loadTorrents, 2000);
|
||||
loadTorrents();
|
||||
</script>
|
||||
</body>
|
||||
</html>`);
|
||||
res.send(getHTML());
|
||||
});
|
||||
|
||||
// API: List torrents
|
||||
app.get('/api/torrents', (req, res) => {
|
||||
const torrents = client.torrents.map(t => ({
|
||||
infoHash: t.infoHash,
|
||||
name: t.name,
|
||||
length: t.length,
|
||||
downloaded: t.downloaded,
|
||||
uploaded: t.uploaded,
|
||||
downloadSpeed: t.downloadSpeed,
|
||||
uploadSpeed: t.uploadSpeed,
|
||||
progress: t.progress,
|
||||
numPeers: t.numPeers,
|
||||
files: t.files.map(f => ({ name: f.name, path: f.path, length: f.length }))
|
||||
@ -240,54 +132,45 @@ app.get('/api/torrents', (req, res) => {
|
||||
res.json(torrents);
|
||||
});
|
||||
|
||||
// API: Add torrent
|
||||
app.post('/api/add', (req, res) => {
|
||||
const { magnet } = req.body;
|
||||
if (!magnet) return res.status(400).json({ error: 'Magnet required' });
|
||||
|
||||
client.add(magnet, { path: DOWNLOAD_PATH }, torrent => {
|
||||
console.log('Added:', torrent.name);
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Stream file
|
||||
app.get('/stream/:infoHash/*', (req, res) => {
|
||||
const torrent = client.get(req.params.infoHash);
|
||||
if (!torrent) return res.status(404).send('Torrent not found');
|
||||
|
||||
const filePath = req.params[0];
|
||||
const file = torrent.files.find(f => f.path === filePath);
|
||||
if (!file) return res.status(404).send('File not found');
|
||||
|
||||
const range = req.headers.range;
|
||||
if (range) {
|
||||
const positions = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(positions[0], 10);
|
||||
const end = positions[1] ? parseInt(positions[1], 10) : file.length - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${file.length}`,
|
||||
'Content-Range': 'bytes ' + start + '-' + end + '/' + file.length,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
'Content-Length': (end - start) + 1,
|
||||
'Content-Type': 'video/mp4'
|
||||
});
|
||||
file.createReadStream({ start, end }).pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': file.length,
|
||||
'Content-Type': 'video/mp4'
|
||||
});
|
||||
res.writeHead(200, { 'Content-Length': file.length, 'Content-Type': 'video/mp4' });
|
||||
file.createReadStream().pipe(res);
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`WebTorrent server running on http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
function getHTML() {
|
||||
return '<!DOCTYPE html><html><head><title>WebTorrent</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>*{box-sizing:border-box;margin:0;padding:0}body{font-family:system-ui;background:#0a0a12;color:#e0e0e0;min-height:100vh}.container{max-width:900px;margin:0 auto;padding:20px}h1{color:#00d4ff;margin-bottom:20px}.add-form{display:flex;gap:10px;margin-bottom:30px}input[type=text]{flex:1;padding:12px;background:#1a1a24;border:1px solid #3a3a4e;border-radius:8px;color:#e0e0e0;font-size:16px}button{padding:12px 24px;background:linear-gradient(135deg,#00d4ff,#7c3aed);border:none;border-radius:8px;color:#fff;font-weight:600;cursor:pointer}.torrent{background:#1a1a24;border:1px solid #2a2a3e;border-radius:12px;padding:20px;margin-bottom:15px}.torrent-name{font-weight:600;color:#00d4ff;margin-bottom:10px}.torrent-info{color:#888;font-size:14px}.progress-bar{height:8px;background:#2a2a3e;border-radius:4px;margin:10px 0}.progress-fill{height:100%;background:linear-gradient(90deg,#00d4ff,#7c3aed)}.files{margin-top:15px}.file{padding:8px 12px;background:#12121a;border-radius:6px;margin:5px 0;display:flex;justify-content:space-between}.file a{color:#00d4ff;text-decoration:none}.empty{text-align:center;color:#666;padding:60px}</style></head><body><div class="container"><h1>WebTorrent Streaming</h1><div class="add-form"><input type="text" id="magnet" placeholder="Paste magnet link..."><button onclick="addTorrent()">Add</button></div><div id="torrents"></div></div><script>function fmt(b){if(b===0)return"0 B";const k=1024,s=["B","KB","MB","GB"],i=Math.floor(Math.log(b)/Math.log(k));return(b/Math.pow(k,i)).toFixed(1)+" "+s[i]}async function addTorrent(){const m=document.getElementById("magnet").value.trim();if(!m)return;await fetch("/api/add",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({magnet:m})});document.getElementById("magnet").value="";load()}async function load(){const r=await fetch("/api/torrents");const d=await r.json();const c=document.getElementById("torrents");if(d.length===0){c.innerHTML="<div class=empty>No torrents</div>";return}c.innerHTML=d.map(t=>"<div class=torrent><div class=torrent-name>"+t.name+"</div><div class=torrent-info>"+fmt(t.downloaded)+"/"+fmt(t.length)+" ("+(t.progress*100).toFixed(1)+"%) "+fmt(t.downloadSpeed)+"/s Peers:"+t.numPeers+"</div><div class=progress-bar><div class=progress-fill style=width:"+(t.progress*100)+"%></div></div><div class=files>"+t.files.map(f=>"<div class=file><span>"+f.name+"</span><a href=/stream/"+t.infoHash+"/"+encodeURIComponent(f.path)+" target=_blank>Stream</a></div>").join("")+"</div></div>").join("")}setInterval(load,2000);load()</script></body></html>';
|
||||
}
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => console.log('WebTorrent on port ' + PORT));
|
||||
SRVEOF
|
||||
fi
|
||||
|
||||
echo "=== Starting WebTorrent server ==="
|
||||
mkdir -p /config /downloads
|
||||
|
||||
Loading…
Reference in New Issue
Block a user