Torrent handler core

This commit is contained in:
masterdraco 2025-02-27 19:18:54 +01:00
parent 130e9275b8
commit 18d726d609
6 changed files with 1544 additions and 0 deletions

75
torrent-handler/README.md Normal file
View File

@ -0,0 +1,75 @@
Core System Components
Manifest File
Contains all core system metadata, configuration schema, navigation structure, features, and hooks
CoreID: 1000
Name: Torrent Handler (Transmission)
Version: 1.0.0
Author: MasterDraco
Core Implementation
Complete JavaScript implementation with Transmission RPC API integration
Methods for connecting to Transmission, managing torrents, and handling hooks
Support for all requested features: start/stop/delete torrents, add new torrents, set seed ratio and time
UI Components
Torrent Dashboard: Shows all active torrents with stats and control buttons
Add Torrent Form: Upload torrent files with configurable settings
Torrent Settings: Configure connection and default torrent settings
Routes and Registration
Route handling for all pages
Navigation registration
CSS style registration
Core system registration with the framework
CSS Styles
Comprehensive styling for all components
Responsive design for different screen sizes
Status indicators, progress bars, and other UI elements
Features Implemented
✅ Connection to Transmission RPC API
✅ Start/stop/delete torrents
✅ Add new torrents by uploading torrent files
✅ Configure seed ratio for torrents
✅ Set time ratio (minutes) for seeding
✅ Auto-start toggle for new torrents
✅ Progress monitoring and status display
✅ Hook system for plugin extensions
How to Use the Core System
The system will connect to Transmission at the configured URL
The dashboard shows all torrents with their current status and progress
Use the Add Torrent button to upload new .torrent files
Configure default settings through the Settings page
For each torrent, you can:
Start or stop torrents
Remove torrents (keeping files)
Delete torrents (removing files)
Extension Points (Hooks)
I've included several hooks that plugins can use:
torrent_added: Triggers when a new torrent is added
torrent_completed: Triggers when a torrent finishes downloading
torrent_removed: Triggers when a torrent is removed
before_torrent_action: Allows intercepting actions before they happen
This system is ready to integrate with any framework that follows the specified pattern. Let me know if you need any clarification or have any questions about implementing specific aspects of the core system!

View File

@ -0,0 +1,351 @@
// core/torrent-handler/index.js
import manifest from './manifest';
export default class TorrentHandlerCore {
constructor(framework) {
this.id = manifest.coreID;
this.name = manifest.name;
this.version = manifest.version;
this.framework = framework;
this.config = {};
this.sessionId = null;
// Define hooks for plugin extension
this.hooks = {};
manifest.hooks.forEach(hook => {
this.hooks[hook.id] = [];
});
// Active torrents cache
this.torrents = [];
}
// Initialize the core system
async initialize(config) {
this.config = { ...this.getDefaultConfig(), ...config };
// Initialize connection to Transmission
try {
await this.connectToTransmission();
console.log(`[${this.name}] Initialized and connected to Transmission successfully`);
// Start periodic refresh of torrent list
this.startTorrentRefresh();
return true;
} catch (error) {
console.error(`[${this.name}] Initialization failed:`, error);
return false;
}
}
// Get default configuration from manifest
getDefaultConfig() {
const defaults = {};
manifest.configuration.forEach(configItem => {
defaults[configItem.field] = configItem.default;
});
return defaults;
}
// Connect to Transmission RPC API
async connectToTransmission() {
try {
const response = await this.transmissionRequest('session-stats', {});
console.log(`[${this.name}] Connected to Transmission ${response.version}`);
return true;
} catch (error) {
console.error(`[${this.name}] Connection error:`, error);
throw new Error('Failed to connect to Transmission RPC');
}
}
// Make request to Transmission RPC
async transmissionRequest(method, arguments) {
const url = this.config.transmission_url;
const headers = {
'Content-Type': 'application/json',
'X-Transmission-Session-Id': this.sessionId || ''
};
// Add authentication if provided
let auth = null;
if (this.config.transmission_username && this.config.transmission_password) {
auth = btoa(`${this.config.transmission_username}:${this.config.transmission_password}`);
headers['Authorization'] = `Basic ${auth}`;
}
const requestBody = {
method: method,
arguments: arguments
};
try {
const response = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(requestBody)
});
// Handle session ID requirement
if (response.status === 409) {
this.sessionId = response.headers.get('X-Transmission-Session-Id');
return this.transmissionRequest(method, arguments);
}
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const data = await response.json();
if (data.result !== 'success') {
throw new Error(`Transmission Error: ${data.result}`);
}
return data.arguments;
} catch (error) {
console.error(`[${this.name}] RPC request failed:`, error);
throw error;
}
}
// Start periodic refresh of torrent list
startTorrentRefresh() {
// Refresh every 3 seconds
this.refreshInterval = setInterval(async () => {
try {
await this.refreshTorrents();
} catch (error) {
console.error(`[${this.name}] Refresh error:`, error);
}
}, 3000);
}
// Refresh list of torrents
async refreshTorrents() {
const fields = [
'id', 'name', 'status', 'percentDone', 'totalSize',
'uploadRatio', 'rateDownload', 'rateUpload',
'eta', 'errorString', 'downloadDir', 'addedDate',
'seedRatioLimit', 'seedRatioMode', 'seedIdleLimit',
'seedIdleMode', 'files', 'priorities'
];
const response = await this.transmissionRequest('torrent-get', {
fields: fields
});
const newTorrents = response.torrents;
// Check for completed torrents
newTorrents.forEach(torrent => {
const existingTorrent = this.torrents.find(t => t.id === torrent.id);
// If torrent just completed, trigger hook
if (existingTorrent && existingTorrent.percentDone < 1 && torrent.percentDone === 1) {
this.executeHook('torrent_completed', {
torrentId: torrent.id,
name: torrent.name,
downloadDirectory: torrent.downloadDir,
files: torrent.files
});
}
});
// Update torrents cache
this.torrents = newTorrents;
return this.torrents;
}
// Get all torrents
async getTorrents() {
try {
return await this.refreshTorrents();
} catch (error) {
console.error(`[${this.name}] Error getting torrents:`, error);
throw error;
}
}
// Add a new torrent
async addTorrent(torrentFile, options = {}) {
// Convert file to base64
const reader = new FileReader();
return new Promise((resolve, reject) => {
reader.onload = async () => {
try {
const base64Data = reader.result.split(',')[1];
const args = {
metainfo: base64Data,
'download-dir': options.downloadDirectory || this.config.default_download_directory,
paused: !(options.autoStart !== undefined ? options.autoStart : this.config.auto_start_torrents)
};
// Set seed ratio if provided
if (options.seedRatio !== undefined) {
args.seedRatioLimit = options.seedRatio;
args.seedRatioMode = 1; // Use torrent-specific seed ratio limit
}
// Set seed time if provided
if (options.seedTime !== undefined) {
args.seedIdleLimit = options.seedTime;
args.seedIdleMode = 1; // Use torrent-specific seed idle limit
}
const response = await this.transmissionRequest('torrent-add', args);
const torrentAdded = response['torrent-added'] || {};
this.executeHook('torrent_added', {
torrentId: torrentAdded.id,
name: torrentAdded.name,
size: torrentAdded.totalSize,
addedTimestamp: Math.floor(Date.now() / 1000)
});
resolve(torrentAdded);
} catch (error) {
reject(error);
}
};
reader.onerror = () => {
reject(new Error('Failed to read torrent file'));
};
reader.readAsDataURL(torrentFile);
});
}
// Start a torrent
async startTorrent(torrentId) {
// Execute before action hook
const hookData = {
torrentId: torrentId,
name: this.getTorrentName(torrentId),
action: 'start',
cancel: false
};
await this.executeHook('before_torrent_action', hookData);
if (hookData.cancel) {
console.log(`[${this.name}] Torrent start cancelled by hook`);
return false;
}
await this.transmissionRequest('torrent-start', {
ids: [torrentId]
});
return true;
}
// Stop a torrent
async stopTorrent(torrentId) {
// Execute before action hook
const hookData = {
torrentId: torrentId,
name: this.getTorrentName(torrentId),
action: 'stop',
cancel: false
};
await this.executeHook('before_torrent_action', hookData);
if (hookData.cancel) {
console.log(`[${this.name}] Torrent stop cancelled by hook`);
return false;
}
await this.transmissionRequest('torrent-stop', {
ids: [torrentId]
});
return true;
}
// Remove a torrent
async removeTorrent(torrentId, deleteData = false) {
// Execute before action hook
const hookData = {
torrentId: torrentId,
name: this.getTorrentName(torrentId),
action: 'remove',
cancel: false
};
await this.executeHook('before_torrent_action', hookData);
if (hookData.cancel) {
console.log(`[${this.name}] Torrent removal cancelled by hook`);
return false;
}
await this.transmissionRequest('torrent-remove', {
ids: [torrentId],
'delete-local-data': deleteData
});
this.executeHook('torrent_removed', {
torrentId: torrentId,
name: this.getTorrentName(torrentId),
reason: deleteData ? 'removed_with_data' : 'removed'
});
return true;
}
// Set torrent properties
async setTorrentProperties(torrentId, properties) {
const args = {
ids: [torrentId],
...properties
};
await this.transmissionRequest('torrent-set', args);
return true;
}
// Helper to get torrent name by id
getTorrentName(torrentId) {
const torrent = this.torrents.find(t => t.id === torrentId);
return torrent ? torrent.name : `Unknown (${torrentId})`;
}
// Register a hook handler (used by plugins)
registerHook(hookName, callback) {
if (this.hooks[hookName]) {
this.hooks[hookName].push(callback);
return true;
}
return false;
}
// Execute hook handlers
async executeHook(hookName, data) {
if (!this.hooks[hookName]) return data;
let result = { ...data };
for (const handler of this.hooks[hookName]) {
try {
result = await handler(result) || result;
} catch (error) {
console.error(`[${this.name}] Hook execution error (${hookName}):`, error);
}
}
return result;
}
// Cleanup on shutdown
async shutdown() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
console.log(`[${this.name}] Core system shutting down`);
return true;
}
}

View File

@ -0,0 +1,167 @@
// core/torrent-handler/manifest.js
export default {
coreID: "1000",
name: "Torrent Handler (Transmission)",
description: "System for handling torrents",
version: "1.0.0",
author: "MasterDraco",
// Configuration schema
configuration: [
{
field: "transmission_url",
type: "string",
label: "Transmission URL",
description: "URL to the Transmission RPC interface (e.g. http://localhost:9091/transmission/rpc)",
required: true,
default: "http://localhost:9091/transmission/rpc"
},
{
field: "transmission_username",
type: "string",
label: "Username",
description: "Username for Transmission authentication",
required: false,
default: ""
},
{
field: "transmission_password",
type: "string",
label: "Password",
description: "Password for Transmission authentication",
required: false,
default: ""
},
{
field: "default_seed_ratio",
type: "number",
label: "Default Seed Ratio",
description: "Default ratio to seed torrents (e.g. 2.0 means seed until shared 2x the download size)",
required: false,
default: 1.0
},
{
field: "default_seed_time",
type: "number",
label: "Default Seed Time (minutes)",
description: "Default time to seed torrents in minutes",
required: false,
default: 60
},
{
field: "auto_start_torrents",
type: "boolean",
label: "Auto-start Torrents",
description: "Automatically start torrents when added",
required: false,
default: true
}
],
// Navigation structure
navigation: [
{
id: "torrent_dashboard",
name: "Torrent Dashboard",
icon: "download",
route: "/torrents"
},
{
id: "torrent_add",
name: "Add Torrent",
icon: "plus-circle",
route: "/torrents/add"
},
{
id: "torrent_settings",
name: "Torrent Settings",
icon: "settings",
route: "/torrents/settings"
}
],
// Main features
features: [
{
feature: "transmission_connection",
description: "Connect to Transmission RPC API",
dataModel: {
url: "string",
username: "string",
password: "string",
sessionId: "string"
}
},
{
feature: "torrent_management",
description: "Start, stop, and delete torrents",
dataModel: {
torrentId: "number",
action: "string"
}
},
{
feature: "add_torrent",
description: "Add new torrents by uploading torrent file",
dataModel: {
torrentFile: "file",
seedRatio: "number",
seedTime: "number",
autoStart: "boolean",
downloadDirectory: "string"
}
},
{
feature: "torrent_settings",
description: "Configure default torrent settings",
dataModel: {
defaultSeedRatio: "number",
defaultSeedTime: "number",
defaultDownloadDirectory: "string"
}
}
],
// Extension points (hooks)
hooks: [
{
id: "torrent_added",
purpose: "Called when a new torrent is added to the system",
dataFormat: {
torrentId: "number",
name: "string",
size: "number",
addedTimestamp: "number"
}
},
{
id: "torrent_completed",
purpose: "Called when a torrent completes downloading",
dataFormat: {
torrentId: "number",
name: "string",
downloadDirectory: "string",
files: "array"
}
},
{
id: "torrent_removed",
purpose: "Called when a torrent is removed from the system",
dataFormat: {
torrentId: "number",
name: "string",
reason: "string"
}
},
{
id: "before_torrent_action",
purpose: "Called before performing an action on a torrent (start, stop, delete)",
dataFormat: {
torrentId: "number",
name: "string",
action: "string",
cancel: "boolean"
}
}
]
};

View File

@ -0,0 +1,278 @@
/* core/torrent-handler/styles.css */
/* Dashboard layout */
.torrent-dashboard {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.dashboard-actions {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.empty-state {
text-align: center;
padding: 40px;
background-color: #f9f9f9;
border-radius: 8px;
}
.loading {
text-align: center;
padding: 20px;
font-style: italic;
}
/* Torrent table styles */
.torrents-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.torrents-table th,
.torrents-table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.torrents-table th {
background-color: #f2f2f2;
font-weight: bold;
text-transform: uppercase;
font-size: 0.8em;
letter-spacing: 0.5px;
}
.torrents-table tr:hover {
background-color: #f5f5f5;
}
.torrents-table .actions {
display: flex;
gap: 5px;
}
/* Progress bar */
.progress-bar {
width: 100%;
background-color: #f1f1f1;
border-radius: 4px;
position: relative;
height: 20px;
overflow: hidden;
}
.progress-fill {
background-color: #4CAF50;
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-bar span {
position: absolute;
text-align: center;
line-height: 20px;
width: 100%;
color: #000;
font-size: 12px;
font-weight: bold;
text-shadow: 0 0 2px rgba(255,255,255,0.7);
}
/* Status indicators */
.status-indicator {
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8em;
font-weight: bold;
display: inline-block;
text-align: center;
}
.status-downloading {
background-color: #e3f2fd;
color: #0d47a1;
}
.status-seeding {
background-color: #e8f5e9;
color: #2e7d32;
}
.status-stopped {
background-color: #f5f5f5;
color: #616161;
}
.status-checking {
background-color: #fff3e0;
color: #e65100;
}
.status-queued {
background-color: #f3e5f5;
color: #6a1b9a;
}
/* Message boxes */
.error-message,
.success-message {
padding: 15px;
margin: 20px 0;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.error-message {
background-color: #fdecea;
color: #c62828;
border-left: 4px solid #c62828;
}
.success-message {
background-color: #e8f5e9;
color: #2e7d32;
border-left: 4px solid #2e7d32;
}
.error-message button,
.success-message button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 1.5em;
line-height: 1;
opacity: 0.7;
}
.error-message button:hover,
.success-message button:hover {
opacity: 1;
}
/* Forms */
.add-torrent-form,
.torrent-settings {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
}
.form-group label[for="auto-start"],
.form-group label[for="auto_start_torrents"] {
display: flex;
align-items: center;
font-weight: normal;
}
.form-group label[for="auto-start"] input,
.form-group label[for="auto_start_torrents"] input {
margin-right: 10px;
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group input[type="number"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
.form-group input[type="file"] {
padding: 10px 0;
}
.form-group .help-text {
font-size: 0.8em;
color: #666;
margin-top: 5px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 30px;
justify-content: flex-end;
}
button {
padding: 10px 20px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s;
}
button:hover {
background-color: #45a049;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
button[type="button"] {
background-color: #f1f1f1;
color: #333;
}
button[type="button"]:hover {
background-color: #e0e0e0;
}
fieldset {
margin-bottom: 30px;
border: 1px solid #ddd;
padding: 20px;
border-radius: 4px;
}
legend {
font-weight: bold;
padding: 0 10px;
font-size: 1.1em;
}
/* Responsive design */
@media (max-width: 768px) {
.torrents-table {
display: block;
overflow-x: auto;
}
.form-actions {
flex-direction: column;
}
.form-actions button {
width: 100%;
}
}

View File

@ -0,0 +1,184 @@
// core/torrent-handler/routes.js
import React from 'react';
import { TorrentDashboard, AddTorrentForm, TorrentSettings } from './components';
export default function registerRoutes(framework, core) {
// Register routes based on navigation items in manifest
framework.registerRoute('/torrents', () => <TorrentDashboard core={core} />);
framework.registerRoute('/torrents/add', ({navigate}) => <AddTorrentForm core={core} navigate={navigate} />);
framework.registerRoute('/torrents/settings', ({navigate}) => <TorrentSettings core={core} navigate={navigate} />);
}
// core/torrent-handler/register.js
import TorrentHandlerCore from './index';
import registerRoutes from './routes';
import manifest from './manifest';
// Register the core system with the framework
export default function register(framework) {
// Create instance of the core system
const core = new TorrentHandlerCore(framework);
// Register core with the framework
framework.registerCore(core.id, core);
// Register navigation items
manifest.navigation.forEach(item => {
framework.registerNavigationItem(item.id, {
name: item.name,
icon: item.icon,
route: item.route,
coreId: core.id
});
});
// Register routes
registerRoutes(framework, core);
// Register CSS/Styles
framework.registerStyles(`
/* Torrent Dashboard Styles */
.torrent-dashboard .progress-bar {
width: 100%;
background-color: #f1f1f1;
border-radius: 4px;
position: relative;
height: 20px;
}
.torrent-dashboard .progress-fill {
background-color: #4CAF50;
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.torrent-dashboard .progress-bar span {
position: absolute;
text-align: center;
line-height: 20px;
width: 100%;
color: #000;
font-size: 12px;
}
.torrent-dashboard .torrents-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.torrent-dashboard .torrents-table th,
.torrent-dashboard .torrents-table td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.torrent-dashboard .torrents-table th {
background-color: #f2f2f2;
font-weight: bold;
}
.torrent-dashboard .torrents-table .actions {
display: flex;
gap: 5px;
}
.error-message, .success-message {
padding: 10px;
margin: 10px 0;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
}
.success-message {
background-color: #d4edda;
color: #155724;
}
/* Form Styles */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group input[type="number"] {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.form-group .help-text {
font-size: 12px;
color: #666;
margin-top: 3px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
button {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #45a049;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
button[type="button"] {
background-color: #f1f1f1;
color: #333;
}
button[type="button"]:hover {
background-color: #e0e0e0;
}
fieldset {
margin-bottom: 20px;
border: 1px solid #ddd;
padding: 15px;
border-radius: 4px;
}
legend {
font-weight: bold;
padding: 0 10px;
}
`);
// Return success
return {
success: true,
message: `${manifest.name} core system registered successfully`
};
}

View File

@ -0,0 +1,489 @@
// core/torrent-handler/components/TorrentDashboard.js
import React, { useState, useEffect } from 'react';
export const TorrentDashboard = ({ core }) => {
const [torrents, setTorrents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const loadTorrents = async () => {
setLoading(true);
try {
const torrentList = await core.getTorrents();
setTorrents(torrentList);
setError(null);
} catch (err) {
setError('Failed to load torrents: ' + err.message);
} finally {
setLoading(false);
}
};
// Load torrents on component mount
useEffect(() => {
loadTorrents();
// Set up refresh interval
const interval = setInterval(loadTorrents, 3000);
// Clean up interval on unmount
return () => clearInterval(interval);
}, []);
const handleStart = async (torrentId) => {
try {
await core.startTorrent(torrentId);
loadTorrents();
} catch (err) {
setError('Failed to start torrent: ' + err.message);
}
};
const handleStop = async (torrentId) => {
try {
await core.stopTorrent(torrentId);
loadTorrents();
} catch (err) {
setError('Failed to stop torrent: ' + err.message);
}
};
const handleRemove = async (torrentId, deleteData) => {
if (confirm(`Are you sure you want to remove this torrent${deleteData ? ' and its data' : ''}?`)) {
try {
await core.removeTorrent(torrentId, deleteData);
loadTorrents();
} catch (err) {
setError('Failed to remove torrent: ' + err.message);
}
}
};
// Helper function to format size
const formatSize = (bytes) => {
if (bytes === 0) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
};
// Helper function to format status
const getStatusText = (status) => {
const statusMap = {
0: 'Stopped',
1: 'Queued to check',
2: 'Checking',
3: 'Queued to download',
4: 'Downloading',
5: 'Queued to seed',
6: 'Seeding'
};
return statusMap[status] || 'Unknown';
};
return (
<div className="torrent-dashboard">
<h1>Torrent Dashboard</h1>
{error && (
<div className="form-actions">
<button type="button" onClick={() => navigate('/torrents')}>Cancel</button>
<button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Settings'}
</button>
</div>
</form>
</div>
);
};
// core/torrent-handler/components/index.js
// Export all components for easier imports
export { TorrentDashboard } from './TorrentDashboard';
export { AddTorrentForm } from './AddTorrentForm';
export { TorrentSettings } from './TorrentSettings';
error-message">
{error}
<button onClick={() => setError(null)}>Dismiss</button>
</div>
)}
<div className="dashboard-actions">
<button onClick={loadTorrents} disabled={loading}>
{loading ? 'Refreshing...' : 'Refresh'}
</button>
<a href="#/torrents/add" className="button">Add Torrent</a>
</div>
{loading && torrents.length === 0 ? (
<div className="loading">Loading torrents...</div>
) : torrents.length === 0 ? (
<div className="empty-state">
<p>No torrents found.</p>
<a href="#/torrents/add" className="button">Add a torrent</a>
</div>
) : (
<table className="torrents-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Progress</th>
<th>Size</th>
<th>Down</th>
<th>Up</th>
<th>Ratio</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{torrents.map(torrent => (
<tr key={torrent.id}>
<td>{torrent.name}</td>
<td>{getStatusText(torrent.status)}</td>
<td>
<div className="form-group">
<label htmlFor="default_seed_ratio">Default Seed Ratio:</label>
<input
type="number"
id="default_seed_ratio"
name="default_seed_ratio"
value={settings.default_seed_ratio}
onChange={handleChange}
min="0"
step="0.1"
/>
<p className="help-text">Set to 0 for unlimited</p>
</div>
<div className="form-group">
<label htmlFor="default_seed_time">Default Seed Time (minutes):</label>
<input
type="number"
id="default_seed_time"
name="default_seed_time"
value={settings.default_seed_time}
onChange={handleChange}
min="0"
/>
<p className="help-text">Set to 0 for unlimited</p>
</div>
<div className="form-group">
<label htmlFor="auto_start_torrents">
<input
type="checkbox"
id="auto_start_torrents"
name="auto_start_torrents"
checked={settings.auto_start_torrents}
onChange={handleChange}
/>
Start torrents automatically when added
</label>
</div>
</fieldset>
<div className="form-group">
<label htmlFor="transmission_username">Username:</label>
<input
type="text"
id="transmission_username"
name="transmission_username"
value={settings.transmission_username}
onChange={handleChange}
/>
<p className="help-text">Leave empty if authentication is not required</p>
</div>
<div className="form-group">
<label htmlFor="transmission_password">Password:</label>
<input
type="password"
id="transmission_password"
name="transmission_password"
value={settings.transmission_password}
onChange={handleChange}
/>
</div>
<div className="form-actions">
<button type="button" onClick={handleTestConnection} disabled={loading}>
{loading ? 'Testing...' : 'Test Connection'}
</button>
</div>
</fieldset>
<fieldset>
<legend>Default Torrent Settings</legend>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${Math.round(torrent.percentDone * 100)}%` }}
></div>
<span>{Math.round(torrent.percentDone * 100)}%</span>
</div>
</td>
<td>{formatSize(torrent.totalSize)}</td>
<td>{formatSize(torrent.rateDownload)}/s</td>
<td>{formatSize(torrent.rateUpload)}/s</td>
<td>{torrent.uploadRatio.toFixed(2)}</td>
<td className="actions">
{[0, 3, 5].includes(torrent.status) ? (
<button onClick={() => handleStart(torrent.id)}>Start</button>
) : (
<button onClick={() => handleStop(torrent.id)}>Stop</button>
)}
<button onClick={() => handleRemove(torrent.id, false)}>Remove</button>
<button onClick={() => handleRemove(torrent.id, true)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
// core/torrent-handler/components/AddTorrentForm.js
import React, { useState } from 'react';
export const AddTorrentForm = ({ core, navigate }) => {
const [torrentFile, setTorrentFile] = useState(null);
const [seedRatio, setSeedRatio] = useState(core.config.default_seed_ratio);
const [seedTime, setSeedTime] = useState(core.config.default_seed_time);
const [autoStart, setAutoStart] = useState(core.config.auto_start_torrents);
const [downloadDir, setDownloadDir] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file && (file.name.endsWith('.torrent') || file.type === 'application/x-bittorrent')) {
setTorrentFile(file);
setError(null);
} else {
setTorrentFile(null);
setError('Please select a valid .torrent file');
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!torrentFile) {
setError('Please select a torrent file');
return;
}
setLoading(true);
setError(null);
try {
await core.addTorrent(torrentFile, {
seedRatio: parseFloat(seedRatio),
seedTime: parseInt(seedTime, 10),
autoStart: autoStart,
downloadDirectory: downloadDir || undefined
});
// Redirect to dashboard
navigate('/torrents');
} catch (err) {
setError('Failed to add torrent: ' + err.message);
setLoading(false);
}
};
return (
<div className="add-torrent-form">
<h1>Add New Torrent</h1>
{error && (
<div className="error-message">
{error}
<button onClick={() => setError(null)}>Dismiss</button>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="torrent-file">Torrent File:</label>
<input
type="file"
id="torrent-file"
accept=".torrent,application/x-bittorrent"
onChange={handleFileChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="seed-ratio">Seed Ratio:</label>
<input
type="number"
id="seed-ratio"
value={seedRatio}
onChange={e => setSeedRatio(e.target.value)}
min="0"
step="0.1"
/>
<p className="help-text">Set to 0 for unlimited</p>
</div>
<div className="form-group">
<label htmlFor="seed-time">Seed Time (minutes):</label>
<input
type="number"
id="seed-time"
value={seedTime}
onChange={e => setSeedTime(e.target.value)}
min="0"
/>
<p className="help-text">Set to 0 for unlimited</p>
</div>
<div className="form-group">
<label htmlFor="auto-start">
<input
type="checkbox"
id="auto-start"
checked={autoStart}
onChange={e => setAutoStart(e.target.checked)}
/>
Start torrent automatically
</label>
</div>
<div className="form-group">
<label htmlFor="download-dir">Download Directory (optional):</label>
<input
type="text"
id="download-dir"
value={downloadDir}
onChange={e => setDownloadDir(e.target.value)}
placeholder="Leave empty for default"
/>
</div>
<div className="form-actions">
<button type="button" onClick={() => navigate('/torrents')}>Cancel</button>
<button type="submit" disabled={loading || !torrentFile}>
{loading ? 'Adding...' : 'Add Torrent'}
</button>
</div>
</form>
</div>
);
};
// core/torrent-handler/components/TorrentSettings.js
import React, { useState } from 'react';
export const TorrentSettings = ({ core, navigate }) => {
const [settings, setSettings] = useState({
default_seed_ratio: core.config.default_seed_ratio,
default_seed_time: core.config.default_seed_time,
auto_start_torrents: core.config.auto_start_torrents,
transmission_url: core.config.transmission_url,
transmission_username: core.config.transmission_username,
transmission_password: core.config.transmission_password
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setSettings({
...settings,
[name]: type === 'checkbox' ? checked : value
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
try {
// Update core configuration
core.config = { ...core.config, ...settings };
// Save configuration to framework persistence
await core.framework.saveConfiguration(core.id, core.config);
setSuccess(true);
} catch (err) {
setError('Failed to save settings: ' + err.message);
} finally {
setLoading(false);
}
};
const handleTestConnection = async () => {
setLoading(true);
setError(null);
setSuccess(false);
try {
// Temporarily update connection settings
const originalConfig = { ...core.config };
core.config = {
...core.config,
transmission_url: settings.transmission_url,
transmission_username: settings.transmission_username,
transmission_password: settings.transmission_password
};
// Test connection
await core.connectToTransmission();
setSuccess('Connection successful!');
} catch (err) {
setError('Connection test failed: ' + err.message);
} finally {
setLoading(false);
}
};
return (
<div className="torrent-settings">
<h1>Torrent Settings</h1>
{error && (
<div className="error-message">
{error}
<button onClick={() => setError(null)}>Dismiss</button>
</div>
)}
{success && (
<div className="success-message">
{typeof success === 'string' ? success : 'Settings saved successfully!'}
<button onClick={() => setSuccess(false)}>Dismiss</button>
</div>
)}
<form onSubmit={handleSubmit}>
<fieldset>
<legend>Connection Settings</legend>
<div className="form-group">
<label htmlFor="transmission_url">Transmission URL:</label>
<input
type="text"
id="transmission_url"
name="transmission_url"
value={settings.transmission_url}
onChange={handleChange}
required
/>
<p className="help-text">URL to Transmission RPC interface (e.g. http://localhost:9091/transmission/rpc)</p>
</div>
<div className="