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:
CyberMind-FR 2026-03-15 06:59:07 +01:00
parent 0ec28266c5
commit 10b3d3a43c
7 changed files with 653 additions and 136 deletions

View File

@ -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

View 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))

View File

@ -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
});

View File

@ -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

View File

@ -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 }
}
}
}

View File

@ -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"]
}
}
}
}

View File

@ -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