Implementing a streaming setup with Usenet

This commit is contained in:
Mukhtar Akere
2025-08-01 15:27:24 +01:00
parent afe577bf2f
commit f9861e3b54
65 changed files with 9437 additions and 924 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -400,7 +400,7 @@ class DecypharrUtils {
if (data.channel === 'beta') {
versionBadge.classList.add('badge-warning');
} else if (data.channel === 'nightly') {
} else if (data.channel === 'experimental') {
versionBadge.classList.add('badge-error');
}
}

View File

@@ -3,6 +3,7 @@ class ConfigManager {
constructor() {
this.debridCount = 0;
this.arrCount = 0;
this.usenetProviderCount = 0;
this.debridDirectoryCounts = {};
this.directoryFilterCounts = {};
@@ -11,8 +12,10 @@ class ConfigManager {
loadingOverlay: document.getElementById('loadingOverlay'),
debridConfigs: document.getElementById('debridConfigs'),
arrConfigs: document.getElementById('arrConfigs'),
usenetConfigs: document.getElementById('usenetConfigs'),
addDebridBtn: document.getElementById('addDebridBtn'),
addArrBtn: document.getElementById('addArrBtn')
addArrBtn: document.getElementById('addArrBtn'),
addUsenetBtn: document.getElementById('addUsenetBtn')
};
this.init();
@@ -40,6 +43,7 @@ class ConfigManager {
// Add buttons
this.refs.addDebridBtn.addEventListener('click', () => this.addDebridConfig());
this.refs.addArrBtn.addEventListener('click', () => this.addArrConfig());
this.refs.addUsenetBtn.addEventListener('click', () => this.addUsenetConfig());
// WebDAV toggle handlers
document.addEventListener('change', (e) => {
@@ -82,6 +86,12 @@ class ConfigManager {
config.arrs.forEach(arr => this.addArrConfig(arr));
}
// Load usenet config
this.populateUsenetSettings(config.usenet);
// Load SABnzbd config
this.populateSABnzbdSettings(config.sabnzbd);
// Load repair config
this.populateRepairSettings(config.repair);
}
@@ -139,6 +149,26 @@ class ConfigManager {
});
}
populateUsenetSettings(usenetConfig) {
if (!usenetConfig) return;
// Populate general Usenet settings
let fields = ["mount_folder", "chunks", "skip_pre_cache", "rc_url", "rc_user", "rc_pass"];
fields.forEach(field => {
const element = document.querySelector(`[name="usenet.${field}"]`);
if (element && usenetConfig[field] !== undefined) {
if (element.type === 'checkbox') {
element.checked = usenetConfig[field];
} else {
element.value = usenetConfig[field];
}
}
});
if (usenetConfig.providers && Array.isArray(usenetConfig.providers)) {
usenetConfig.providers.forEach(usenet => this.addUsenetConfig(usenet));
}
}
addDebridConfig(data = {}) {
const debridHtml = this.getDebridTemplate(this.debridCount, data);
this.refs.debridConfigs.insertAdjacentHTML('beforeend', debridHtml);
@@ -228,7 +258,7 @@ class ConfigManager {
<span class="label-text font-medium">API Key</span>
</label>
<div class="password-toggle-container">
<input type="password" class="input input-bordered input-has-toggle"
<input autocomplete="off" type="password" class="input input-bordered input-has-toggle"
name="debrid[${index}].api_key" id="debrid[${index}].api_key" required>
<button type="button" class="password-toggle-btn">
<i class="bi bi-eye" id="debrid[${index}].api_key_icon"></i>
@@ -448,7 +478,7 @@ class ConfigManager {
<span class="label-text font-medium">RC Password</span>
</label>
<div class="password-toggle-container">
<input type="password" class="input input-bordered webdav-field input-has-toggle"
<input autocomplete="off" type="password" class="input input-bordered webdav-field input-has-toggle"
name="debrid[${index}].rc_pass" id="debrid[${index}].rc_pass">
<button type="button" class="password-toggle-btn">
<i class="bi bi-eye" id="debrid[${index}].rc_pass_icon"></i>
@@ -745,9 +775,9 @@ class ConfigManager {
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-box max-w-2xl">
<form method="dialog">
<div method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
</form>
</div>
<h3 class="font-bold text-lg mb-4">Directory Filter Types</h3>
<div class="space-y-4">
<div>
@@ -779,7 +809,7 @@ class ConfigManager {
<li>Examples: 24h, 7d, 30d</li>
</ul>
</div>
<div class="alert alert-info">
<div class="alert alert-warning">
<i class="bi bi-info-circle"></i>
<span>Negative filters (Not...) will exclude matches instead of including them.</span>
</div>
@@ -868,7 +898,7 @@ class ConfigManager {
<span class="label-text font-medium">API Token</span>
</label>
<div class="password-toggle-container">
<input type="password" class="input input-bordered input-has-toggle ${isAutoDetected ? 'input-disabled' : ''}"
<input autocomplete="off" type="password" class="input input-bordered input-has-toggle ${isAutoDetected ? 'input-disabled' : ''}"
name="arr[${index}].token" id="arr[${index}].token"
${isAutoDetected ? 'readonly' : 'required'}>
<button type="button" class="password-toggle-btn ${isAutoDetected ? 'opacity-50 cursor-not-allowed' : ''}"
@@ -882,7 +912,7 @@ class ConfigManager {
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
<div class="form-control">
<label class="label" for="arr[${index}].selected_debrid">
<span class="label-text font-medium">Preferred Debrid Service</span>
<span class="label-text font-medium">Preferred Service</span>
</label>
<select class="select select-bordered" name="arr[${index}].selected_debrid" id="arr[${index}].selected_debrid">
<option value="" selected>Auto-select</option>
@@ -890,6 +920,7 @@ class ConfigManager {
<option value="alldebrid">AllDebrid</option>
<option value="debrid_link">Debrid Link</option>
<option value="torbox">Torbox</option>
<option value="usenet">Usenet</option>
</select>
<div class="label">
<span class="label-text-alt">Which debrid service this Arr should prefer</span>
@@ -990,6 +1021,23 @@ class ConfigManager {
}
});
// Validate Usenet servers
if (config.usenet) {
config.usenet.providers.forEach((usenet, index) => {
if (!usenet.host) {
errors.push(`Usenet server #${index + 1}: Host is required`);
}
if (usenet.port && (usenet.port < 1 || usenet.port > 65535)) {
errors.push(`Usenet server #${index + 1}: Port must be between 1 and 65535`);
}
if (usenet.connections && (usenet.connections < 1 )) {
errors.push(`Usenet server #${index + 1}: Connections must be more than 0`);
}
});
}
// Validate repair settings
if (config.repair.enabled) {
if (!config.repair.interval) {
@@ -1038,6 +1086,12 @@ class ConfigManager {
// Arr configurations
arrs: this.collectArrConfigs(),
// Usenet configurations
usenet: this.collectUsenetConfig(),
// SABnzbd configuration
sabnzbd: this.collectSABnzbdConfig(),
// Repair configuration
repair: this.collectRepairConfig()
};
@@ -1153,6 +1207,211 @@ class ConfigManager {
return arrs;
}
addUsenetConfig(data = {}) {
const usenetHtml = this.getUsenetTemplate(this.usenetProviderCount, data);
this.refs.usenetConfigs.insertAdjacentHTML('beforeend', usenetHtml);
// Populate data if provided
if (Object.keys(data).length > 0) {
this.populateUsenetData(this.usenetProviderCount, data);
}
this.usenetProviderCount++;
}
populateUsenetData(index, data) {
Object.entries(data).forEach(([key, value]) => {
const input = document.querySelector(`[name="usenet[${index}].${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
}
});
}
getUsenetTemplate(index, data = {}) {
return `
<div class="card bg-base-100 border border-base-300 shadow-sm usenet-config" data-index="${index}">
<div class="card-body">
<div class="flex justify-between items-start mb-4">
<h3 class="card-title text-lg">
<i class="bi bi-globe mr-2 text-info"></i>
Usenet Server #${index + 1}
</h3>
<button type="button" class="btn btn-error btn-sm" onclick="this.closest('.usenet-config').remove();">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="form-control">
<label class="label" for="usenet[${index}].name">
<span class="label-text font-medium">Name</span>
</label>
<input type="text" class="input input-bordered"
name="usenet[${index}].name" id="usenet[${index}].name"
placeholder="provider name, e.g easynews" required>
<div class="label">
<span class="label-text-alt">Usenet Name</span>
</div>
</div>
<div class="form-control">
<label class="label" for="usenet[${index}].host">
<span class="label-text font-medium">Host</span>
</label>
<input type="text" class="input input-bordered"
name="usenet[${index}].host" id="usenet[${index}].host"
placeholder="news.provider.com" required>
<div class="label">
<span class="label-text-alt">Usenet server hostname</span>
</div>
</div>
<div class="form-control">
<label class="label" for="usenet[${index}].port">
<span class="label-text font-medium">Port</span>
</label>
<input type="number" class="input input-bordered"
name="usenet[${index}].port" id="usenet[${index}].port"
placeholder="119" value="119" min="1" max="65535">
<div class="label">
<span class="label-text-alt">Server port (119 for standard, 563 for SSL)</span>
</div>
</div>
<div class="form-control">
<label class="label" for="usenet[${index}].connections">
<span class="label-text font-medium">Connections</span>
</label>
<input type="number" class="input input-bordered"
name="usenet[${index}].connections" id="usenet[${index}].connections"
placeholder="30" value="30" min="1" max="50">
<div class="label">
<span class="label-text-alt">Maximum simultaneous connections</span>
</div>
</div>
<div class="form-control">
<label class="label" for="usenet[${index}].username">
<span class="label-text font-medium">Username</span>
</label>
<input type="text" class="input input-bordered"
name="usenet[${index}].username" id="usenet[${index}].username">
<div class="label">
<span class="label-text-alt">Username for authentication</span>
</div>
</div>
<div class="form-control">
<label class="label" for="usenet[${index}].password">
<span class="label-text font-medium">Password</span>
</label>
<div class="password-toggle-container">
<input autocomplete="off" type="password" class="input input-bordered input-has-toggle"
name="usenet[${index}].password" id="usenet[${index}].password">
<button type="button" class="password-toggle-btn">
<i class="bi bi-eye" id="usenet[${index}].password_icon"></i>
</button>
</div>
<div class="label">
<span class="label-text-alt">Password for authentication</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox"
name="usenet[${index}].ssl" id="usenet[${index}].ssl">
<span class="label-text font-medium">Use SSL</span>
</label>
<div class="label">
<span class="label-text-alt">Use SSL encryption</span>
</div>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox"
name="usenet[${index}].use_tls" id="usenet[${index}].use_tls">
<span class="label-text font-medium">Use TLS</span>
</label>
<div class="label">
<span class="label-text-alt">Use TLS encryption</span>
</div>
</div>
</div>
</div>
</div>
`;
}
populateSABnzbdSettings(sabnzbdConfig) {
if (!sabnzbdConfig) return;
const fields = ['download_folder', 'refresh_interval'];
fields.forEach(field => {
const element = document.querySelector(`[name="sabnzbd.${field}"]`);
if (element && sabnzbdConfig[field] !== undefined) {
if (element.type === 'checkbox') {
element.checked = sabnzbdConfig[field];
} else {
element.value = sabnzbdConfig[field];
}
}
});
const categoriesEl = document.querySelector('[name="sabnzbd.categories"]');
if (categoriesEl && sabnzbdConfig.categories) {
categoriesEl.value = sabnzbdConfig.categories.join(', ');
}
}
collectUsenetConfig() {
const providers = [];
for (let i = 0; i < this.usenetProviderCount; i++) {
const hostEl = document.querySelector(`[name="usenet[${i}].host"]`);
if (!hostEl || !hostEl.closest('.usenet-config')) continue;
const usenet = {
host: hostEl.value,
port: parseInt(document.querySelector(`[name="usenet[${i}].port"]`).value) || 119,
username: document.querySelector(`[name="usenet[${i}].username"]`).value,
password: document.querySelector(`[name="usenet[${i}].password"]`).value,
connections: parseInt(document.querySelector(`[name="usenet[${i}].connections"]`).value) || 30,
name: document.querySelector(`[name="usenet[${i}].name"]`).value,
ssl: document.querySelector(`[name="usenet[${i}].ssl"]`).checked,
use_tls: document.querySelector(`[name="usenet[${i}].use_tls"]`).checked,
};
if (usenet.host) {
providers.push(usenet);
}
}
return {
"providers": providers,
"chunks": parseInt(document.querySelector('[name="usenet.chunks"]').value) || 15,
"mount_folder": document.querySelector('[name="usenet.mount_folder"]').value,
"skip_pre_cache": document.querySelector('[name="usenet.skip_pre_cache"]').checked,
"rc_url": document.querySelector('[name="usenet.rc_url"]').value,
"rc_user": document.querySelector('[name="usenet.rc_user"]').value,
"rc_pass": document.querySelector('[name="usenet.rc_pass"]').value,
};
}
collectSABnzbdConfig() {
return {
download_folder: document.querySelector('[name="sabnzbd.download_folder"]').value,
refresh_interval: parseInt(document.querySelector('[name="sabnzbd.refresh_interval"]').value) || 15,
categories: document.querySelector('[name="sabnzbd.categories"]').value
.split(',').map(ext => ext.trim()).filter(Boolean)
};
}
collectRepairConfig() {
return {
enabled: document.querySelector('[name="repair.enabled"]').checked,

View File

@@ -1,32 +1,51 @@
// Dashboard functionality for torrent management
class TorrentDashboard {
// Dashboard functionality for torrent and NZB management
class Dashboard {
constructor() {
this.state = {
mode: 'torrents', // 'torrents' or 'nzbs'
torrents: [],
selectedTorrents: new Set(),
nzbs: [],
selectedItems: new Set(),
categories: new Set(),
filteredTorrents: [],
filteredItems: [],
selectedCategory: '',
selectedState: '',
sortBy: 'added_on',
itemsPerPage: 20,
currentPage: 1,
selectedTorrentContextMenu: null
selectedItemContextMenu: null
};
this.refs = {
torrentsList: document.getElementById('torrentsList'),
// Mode switching
torrentsMode: document.getElementById('torrentsMode'),
nzbsMode: document.getElementById('nzbsMode'),
// Table elements
dataList: document.getElementById('dataList'),
torrentsHeaders: document.getElementById('torrentsHeaders'),
nzbsHeaders: document.getElementById('nzbsHeaders'),
// Controls
categoryFilter: document.getElementById('categoryFilter'),
stateFilter: document.getElementById('stateFilter'),
sortSelector: document.getElementById('sortSelector'),
selectAll: document.getElementById('selectAll'),
selectAllNzb: document.getElementById('selectAllNzb'),
batchDeleteBtn: document.getElementById('batchDeleteBtn'),
batchDeleteDebridBtn: document.getElementById('batchDeleteDebridBtn'),
refreshBtn: document.getElementById('refreshBtn'),
// Context menus
torrentContextMenu: document.getElementById('torrentContextMenu'),
nzbContextMenu: document.getElementById('nzbContextMenu'),
// Pagination and empty state
paginationControls: document.getElementById('paginationControls'),
paginationInfo: document.getElementById('paginationInfo'),
emptyState: document.getElementById('emptyState')
emptyState: document.getElementById('emptyState'),
emptyStateTitle: document.getElementById('emptyStateTitle'),
emptyStateMessage: document.getElementById('emptyStateMessage')
};
this.init();
@@ -34,20 +53,26 @@ class TorrentDashboard {
init() {
this.bindEvents();
this.loadTorrents();
this.loadModeFromURL();
this.loadData();
this.startAutoRefresh();
}
bindEvents() {
// Mode switching
this.refs.torrentsMode.addEventListener('click', () => this.switchMode('torrents'));
this.refs.nzbsMode.addEventListener('click', () => this.switchMode('nzbs'));
// Refresh button
this.refs.refreshBtn.addEventListener('click', () => this.loadTorrents());
this.refs.refreshBtn.addEventListener('click', () => this.loadData());
// Batch delete
this.refs.batchDeleteBtn.addEventListener('click', () => this.deleteSelectedTorrents());
this.refs.batchDeleteDebridBtn.addEventListener('click', () => this.deleteSelectedTorrents(true));
this.refs.batchDeleteBtn.addEventListener('click', () => this.deleteSelectedItems());
this.refs.batchDeleteDebridBtn.addEventListener('click', () => this.deleteSelectedItems(true));
// Select all checkbox
// Select all checkboxes
this.refs.selectAll.addEventListener('change', (e) => this.toggleSelectAll(e.target.checked));
this.refs.selectAllNzb.addEventListener('change', (e) => this.toggleSelectAll(e.target.checked));
// Filters
this.refs.categoryFilter.addEventListener('change', (e) => this.setFilter('category', e.target.value));
@@ -57,18 +82,333 @@ class TorrentDashboard {
// Context menu
this.bindContextMenu();
// Torrent selection
this.refs.torrentsList.addEventListener('change', (e) => {
if (e.target.classList.contains('torrent-select')) {
this.toggleTorrentSelection(e.target.dataset.hash, e.target.checked);
// Item selection
this.refs.dataList.addEventListener('change', (e) => {
if (e.target.classList.contains('item-select')) {
this.toggleItemSelection(e.target.dataset.id, e.target.checked);
}
});
}
switchMode(mode) {
if (this.state.mode === mode) return;
this.state.mode = mode;
this.state.selectedItems.clear();
// Update URL parameter
this.updateURL(mode);
// Update button states
if (mode === 'torrents') {
this.refs.torrentsMode.classList.remove('btn-outline');
this.refs.torrentsMode.classList.add('btn-primary');
this.refs.nzbsMode.classList.remove('btn-primary');
this.refs.nzbsMode.classList.add('btn-outline');
// Show torrent headers, hide NZB headers
this.refs.torrentsHeaders.classList.remove('hidden');
this.refs.nzbsHeaders.classList.add('hidden');
// Update empty state
this.refs.emptyStateTitle.textContent = 'No Torrents Found';
this.refs.emptyStateMessage.textContent = "You haven't added any torrents yet. Start by adding your first download!";
// Show debrid batch delete button
this.refs.batchDeleteDebridBtn.classList.remove('hidden');
} else {
this.refs.nzbsMode.classList.remove('btn-outline');
this.refs.nzbsMode.classList.add('btn-primary');
this.refs.torrentsMode.classList.remove('btn-primary');
this.refs.torrentsMode.classList.add('btn-outline');
// Show NZB headers, hide torrent headers
this.refs.nzbsHeaders.classList.remove('hidden');
this.refs.torrentsHeaders.classList.add('hidden');
// Update empty state
this.refs.emptyStateTitle.textContent = 'No NZBs Found';
this.refs.emptyStateMessage.textContent = "You haven't added any NZB downloads yet. Start by adding your first NZB!";
// Hide debrid batch delete button (not relevant for NZBs)
this.refs.batchDeleteDebridBtn.classList.add('hidden');
}
// Reset filters and reload data
this.state.selectedCategory = '';
this.state.selectedState = '';
this.state.currentPage = 1;
this.refs.categoryFilter.value = '';
this.refs.stateFilter.value = '';
this.loadData();
this.updateBatchActions();
}
updateBatchActions() {
const hasSelection = this.state.selectedItems.size > 0;
// Show/hide batch delete button
if (this.refs.batchDeleteBtn) {
this.refs.batchDeleteBtn.classList.toggle('hidden', !hasSelection);
}
// Show/hide debrid batch delete button (only for torrents)
if (this.refs.batchDeleteDebridBtn) {
const showDebridButton = hasSelection && this.state.mode === 'torrents';
this.refs.batchDeleteDebridBtn.classList.toggle('hidden', !showDebridButton);
}
// Update button text with count
if (hasSelection) {
const count = this.state.selectedItems.size;
const itemType = this.state.mode === 'torrents' ? 'Torrent' : 'NZB';
const itemTypePlural = this.state.mode === 'torrents' ? 'Torrents' : 'NZBs';
if (this.refs.batchDeleteBtn) {
const deleteText = count === 1 ? `Delete ${itemType}` : `Delete ${count} ${itemTypePlural}`;
const deleteSpan = this.refs.batchDeleteBtn.querySelector('span');
if (deleteSpan) {
deleteSpan.textContent = deleteText;
}
}
if (this.refs.batchDeleteDebridBtn && this.state.mode === 'torrents') {
const debridText = count === 1 ? 'Remove From Debrid' : `Remove ${count} From Debrid`;
const debridSpan = this.refs.batchDeleteDebridBtn.querySelector('span');
if (debridSpan) {
debridSpan.textContent = debridText;
}
}
} else {
// Reset button text when no selection
if (this.refs.batchDeleteBtn) {
const deleteSpan = this.refs.batchDeleteBtn.querySelector('span');
if (deleteSpan) {
deleteSpan.textContent = 'Delete Selected';
}
}
if (this.refs.batchDeleteDebridBtn) {
const debridSpan = this.refs.batchDeleteDebridBtn.querySelector('span');
if (debridSpan) {
debridSpan.textContent = 'Remove From Debrid';
}
}
}
}
loadData() {
if (this.state.mode === 'torrents') {
this.loadTorrents();
} else {
this.loadNZBs();
}
}
async loadNZBs() {
try {
const response = await window.decypharrUtils.fetcher('/api/nzbs');
if (!response.ok) {
throw new Error('Failed to fetch NZBs');
}
const data = await response.json();
this.state.nzbs = data.nzbs || [];
this.updateCategories();
this.applyFilters();
this.renderData();
} catch (error) {
console.error('Error loading NZBs:', error);
window.decypharrUtils.createToast('Error loading NZBs', 'error');
}
}
updateCategories() {
const items = this.state.mode === 'torrents' ? this.state.torrents : this.state.nzbs;
this.state.categories = new Set(items.map(item => item.category).filter(Boolean));
}
applyFilters() {
if (this.state.mode === 'torrents') {
this.filterTorrents();
} else {
this.filterNZBs();
}
}
filterNZBs() {
let filtered = [...this.state.nzbs];
if (this.state.selectedCategory) {
filtered = filtered.filter(n => n.category === this.state.selectedCategory);
}
if (this.state.selectedState) {
filtered = filtered.filter(n => n.status === this.state.selectedState);
}
// Apply sorting
filtered.sort((a, b) => {
switch (this.state.sortBy) {
case 'added_on':
return new Date(b.added_on) - new Date(a.added_on);
case 'added_on_asc':
return new Date(a.added_on) - new Date(b.added_on);
case 'name_asc':
return a.name.localeCompare(b.name);
case 'name_desc':
return b.name.localeCompare(a.name);
case 'size_desc':
return (b.total_size || 0) - (a.total_size || 0);
case 'size_asc':
return (a.total_size || 0) - (b.total_size || 0);
case 'progress_desc':
return (b.progress || 0) - (a.progress || 0);
case 'progress_asc':
return (a.progress || 0) - (b.progress || 0);
default:
return 0;
}
});
this.state.filteredItems = filtered;
}
renderData() {
if (this.state.mode === 'torrents') {
this.renderTorrents();
} else {
this.renderNZBs();
}
}
renderNZBs() {
const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage;
const endIndex = startIndex + this.state.itemsPerPage;
const pageItems = this.state.filteredItems.slice(startIndex, endIndex);
const tbody = this.refs.dataList;
tbody.innerHTML = '';
if (pageItems.length === 0) {
this.refs.emptyState.classList.remove('hidden');
} else {
this.refs.emptyState.classList.add('hidden');
pageItems.forEach(nzb => {
const row = document.createElement('tr');
row.className = 'hover cursor-pointer';
row.setAttribute('data-id', nzb.id);
row.setAttribute('data-name', nzb.name);
row.setAttribute('data-category', nzb.category || '');
const progressPercent = Math.round(nzb.progress || 0);
const sizeFormatted = this.formatBytes(nzb.total_size || 0);
const etaFormatted = this.formatETA(nzb.eta || 0);
const ageFormatted = this.formatAge(nzb.date_posted);
const statusBadge = this.getStatusBadge(nzb.status);
row.innerHTML = `
<td class="w-12">
<label class="cursor-pointer">
<input type="checkbox" class="checkbox checkbox-sm item-select" data-id="${nzb.id}">
</label>
</td>
<td class="font-medium max-w-xs">
<div class="truncate" title="${nzb.name}">${nzb.name}</div>
</td>
<td>${sizeFormatted}</td>
<td>
<div class="flex items-center gap-2">
<div class="w-16 bg-base-300 rounded-full h-2">
<div class="bg-primary h-2 rounded-full transition-all duration-300" style="width: ${progressPercent}%"></div>
</div>
<span class="text-sm font-medium">${progressPercent}%</span>
</div>
</td>
<td>${etaFormatted}</td>
<td>
<span class="badge badge-ghost badge-sm">${nzb.category || 'N/A'}</span>
</td>
<td>${statusBadge}</td>
<td>${ageFormatted}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs" onclick="window.dashboard.deleteNZB('${nzb.id}');" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
}
this.updatePagination();
this.updateSelectionUI();
}
getStatusBadge(status) {
const statusMap = {
'downloading': '<span class="badge badge-info badge-sm">Downloading</span>',
'completed': '<span class="badge badge-success badge-sm">Completed</span>',
'paused': '<span class="badge badge-warning badge-sm">Paused</span>',
'failed': '<span class="badge badge-error badge-sm">Failed</span>',
'queued': '<span class="badge badge-ghost badge-sm">Queued</span>',
'processing': '<span class="badge badge-info badge-sm">Processing</span>',
'verifying': '<span class="badge badge-info badge-sm">Verifying</span>',
'repairing': '<span class="badge badge-warning badge-sm">Repairing</span>',
'extracting': '<span class="badge badge-info badge-sm">Extracting</span>'
};
return statusMap[status] || '<span class="badge badge-ghost badge-sm">Unknown</span>';
}
formatETA(seconds) {
if (!seconds || seconds <= 0) return 'N/A';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}
formatAge(datePosted) {
if (!datePosted) return 'N/A';
const now = new Date();
const posted = new Date(datePosted);
const diffMs = now - posted;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'Today';
} else if (diffDays === 1) {
return '1 day';
} else {
return `${diffDays} days`;
}
}
formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
bindContextMenu() {
// Show context menu
this.refs.torrentsList.addEventListener('contextmenu', (e) => {
const row = e.target.closest('tr[data-hash]');
this.refs.dataList.addEventListener('contextmenu', (e) => {
const row = e.target.closest('tr[data-id]');
if (!row) return;
e.preventDefault();
@@ -77,12 +417,14 @@ class TorrentDashboard {
// Hide context menu
document.addEventListener('click', (e) => {
if (!this.refs.torrentContextMenu.contains(e.target)) {
const torrentMenu = this.refs.torrentContextMenu;
const nzbMenu = this.refs.nzbContextMenu;
if (!torrentMenu.contains(e.target) && !nzbMenu.contains(e.target)) {
this.hideContextMenu();
}
});
// Context menu actions
// Context menu actions for torrents
this.refs.torrentContextMenu.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (action) {
@@ -90,37 +432,72 @@ class TorrentDashboard {
this.hideContextMenu();
}
});
// Context menu actions for NZBs
this.refs.nzbContextMenu.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (action) {
this.handleContextAction(action);
this.hideContextMenu();
}
});
}
showContextMenu(event, row) {
this.state.selectedTorrentContextMenu = {
hash: row.dataset.hash,
name: row.dataset.name,
category: row.dataset.category || ''
};
this.refs.torrentContextMenu.querySelector('.torrent-name').textContent =
this.state.selectedTorrentContextMenu.name;
const { pageX, pageY } = event;
const { clientWidth, clientHeight } = document.documentElement;
const menu = this.refs.torrentContextMenu;
if (this.state.mode === 'torrents') {
this.state.selectedItemContextMenu = {
id: row.dataset.hash,
name: row.dataset.name,
category: row.dataset.category || '',
type: 'torrent'
};
// Position the menu
menu.style.left = `${Math.min(pageX, clientWidth - 200)}px`;
menu.style.top = `${Math.min(pageY, clientHeight - 150)}px`;
const menu = this.refs.torrentContextMenu;
menu.querySelector('.torrent-name').textContent = this.state.selectedItemContextMenu.name;
// Position the menu
menu.style.left = `${Math.min(pageX, clientWidth - 200)}px`;
menu.style.top = `${Math.min(pageY, clientHeight - 150)}px`;
menu.classList.remove('hidden');
} else {
this.state.selectedItemContextMenu = {
id: row.dataset.id,
name: row.dataset.name,
category: row.dataset.category || '',
type: 'nzb'
};
menu.classList.remove('hidden');
const menu = this.refs.nzbContextMenu;
menu.querySelector('.nzb-name').textContent = this.state.selectedItemContextMenu.name;
// Position the menu
menu.style.left = `${Math.min(pageX, clientWidth - 200)}px`;
menu.style.top = `${Math.min(pageY, clientHeight - 150)}px`;
menu.classList.remove('hidden');
}
}
hideContextMenu() {
this.refs.torrentContextMenu.classList.add('hidden');
this.state.selectedTorrentContextMenu = null;
this.refs.nzbContextMenu.classList.add('hidden');
this.state.selectedItemContextMenu = null;
}
async handleContextAction(action) {
const torrent = this.state.selectedTorrentContextMenu;
if (!torrent) return;
const item = this.state.selectedItemContextMenu;
if (!item) return;
if (item.type === 'torrent') {
await this.handleTorrentAction(action, item);
} else {
await this.handleNZBAction(action, item);
}
}
async handleTorrentAction(action, torrent) {
const actions = {
'copy-magnet': async () => {
@@ -149,6 +526,87 @@ class TorrentDashboard {
}
}
async handleNZBAction(action, nzb) {
const actions = {
'pause': async () => {
try {
const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzb.id}/pause`, {
method: 'POST'
});
if (response.ok) {
window.decypharrUtils.createToast('NZB paused successfully');
this.loadData();
} else {
throw new Error('Failed to pause NZB');
}
} catch (error) {
window.decypharrUtils.createToast('Failed to pause NZB', 'error');
}
},
'resume': async () => {
try {
const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzb.id}/resume`, {
method: 'POST'
});
if (response.ok) {
window.decypharrUtils.createToast('NZB resumed successfully');
this.loadData();
} else {
throw new Error('Failed to resume NZB');
}
} catch (error) {
window.decypharrUtils.createToast('Failed to resume NZB', 'error');
}
},
'retry': async () => {
try {
const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzb.id}/retry`, {
method: 'POST'
});
if (response.ok) {
window.decypharrUtils.createToast('NZB retry started successfully');
this.loadData();
} else {
throw new Error('Failed to retry NZB');
}
} catch (error) {
window.decypharrUtils.createToast('Failed to retry NZB', 'error');
}
},
'copy-name': async () => {
try {
await navigator.clipboard.writeText(nzb.name);
window.decypharrUtils.createToast('NZB name copied to clipboard');
} catch (error) {
window.decypharrUtils.createToast('Failed to copy NZB name', 'error');
}
},
'delete': async () => {
await this.deleteNZB(nzb.id);
}
};
if (actions[action]) {
await actions[action]();
}
}
async deleteNZB(nzbId) {
try {
const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzbId}`, {
method: 'DELETE'
});
if (response.ok) {
window.decypharrUtils.createToast('NZB deleted successfully');
this.loadData();
} else {
throw new Error('Failed to delete NZB');
}
} catch (error) {
window.decypharrUtils.createToast('Failed to delete NZB', 'error');
}
}
async loadTorrents() {
try {
// Show loading state
@@ -173,14 +631,14 @@ class TorrentDashboard {
}
updateUI() {
// Filter torrents
this.filterTorrents();
// Apply filters based on current mode
this.applyFilters();
// Update category dropdown
this.updateCategoryFilter();
// Render torrents table
this.renderTorrents();
// Render data table
this.renderData();
// Update pagination
this.updatePagination();
@@ -206,7 +664,7 @@ class TorrentDashboard {
// Sort torrents
filtered = this.sortTorrents(filtered);
this.state.filteredTorrents = filtered;
this.state.filteredItems = filtered;
}
sortTorrents(torrents) {
@@ -253,27 +711,27 @@ class TorrentDashboard {
renderTorrents() {
const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage;
const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredTorrents.length);
const pageItems = this.state.filteredTorrents.slice(startIndex, endIndex);
const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredItems.length);
const pageItems = this.state.filteredItems.slice(startIndex, endIndex);
this.refs.torrentsList.innerHTML = pageItems.map(torrent => this.torrentRowTemplate(torrent)).join('');
this.refs.dataList.innerHTML = pageItems.map(torrent => this.torrentRowTemplate(torrent)).join('');
}
torrentRowTemplate(torrent) {
const progressPercent = (torrent.progress * 100).toFixed(1);
const isSelected = this.state.selectedTorrents.has(torrent.hash);
const isSelected = this.state.selectedItems.has(torrent.hash);
let addedOn = new Date(torrent.added_on).toLocaleString();
return `
<tr data-hash="${torrent.hash}"
<tr data-id="${torrent.hash}"
data-name="${this.escapeHtml(torrent.name)}"
data-category="${torrent.category || ''}"
class="hover:bg-base-200 transition-colors">
<td>
<label class="cursor-pointer">
<input type="checkbox"
class="checkbox checkbox-sm torrent-select"
data-hash="${torrent.hash}"
class="checkbox checkbox-sm item-select"
data-id="${torrent.hash}"
${isSelected ? 'checked' : ''}>
</label>
</td>
@@ -358,13 +816,13 @@ class TorrentDashboard {
}
updatePagination() {
const totalPages = Math.ceil(this.state.filteredTorrents.length / this.state.itemsPerPage);
const totalPages = Math.ceil(this.state.filteredItems.length / this.state.itemsPerPage);
const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage;
const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredTorrents.length);
const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredItems.length);
// Update pagination info
this.refs.paginationInfo.textContent =
`Showing ${this.state.filteredTorrents.length > 0 ? startIndex + 1 : 0}-${endIndex} of ${this.state.filteredTorrents.length} torrents`;
`Showing ${this.state.filteredItems.length > 0 ? startIndex + 1 : 0}-${endIndex} of ${this.state.filteredItems.length} torrents`;
// Clear pagination controls
this.refs.paginationControls.innerHTML = '';
@@ -412,33 +870,42 @@ class TorrentDashboard {
updateSelectionUI() {
// Clean up selected torrents that no longer exist
const currentHashes = new Set(this.state.filteredTorrents.map(t => t.hash));
this.state.selectedTorrents.forEach(hash => {
const currentHashes = new Set(this.state.filteredItems.map(t => t.hash));
this.state.selectedItems.forEach(hash => {
if (!currentHashes.has(hash)) {
this.state.selectedTorrents.delete(hash);
this.state.selectedItems.delete(hash);
}
});
// Update batch delete button
this.refs.batchDeleteBtn.classList.toggle('hidden', this.state.selectedTorrents.size === 0);
this.refs.batchDeleteDebridBtn.classList.toggle('hidden', this.state.selectedTorrents.size === 0);
this.refs.batchDeleteBtn.classList.toggle('hidden', this.state.selectedItems.size === 0);
this.refs.batchDeleteDebridBtn.classList.toggle('hidden', this.state.selectedItems.size === 0);
// Update select all checkbox
const visibleTorrents = this.state.filteredTorrents.slice(
const visibleTorrents = this.state.filteredItems.slice(
(this.state.currentPage - 1) * this.state.itemsPerPage,
this.state.currentPage * this.state.itemsPerPage
);
this.refs.selectAll.checked = visibleTorrents.length > 0 &&
visibleTorrents.every(torrent => this.state.selectedTorrents.has(torrent.hash));
this.refs.selectAll.indeterminate = visibleTorrents.some(torrent => this.state.selectedTorrents.has(torrent.hash)) &&
!visibleTorrents.every(torrent => this.state.selectedTorrents.has(torrent.hash));
visibleTorrents.every(torrent => this.state.selectedItems.has(torrent.hash));
this.refs.selectAll.indeterminate = visibleTorrents.some(torrent => this.state.selectedItems.has(torrent.hash)) &&
!visibleTorrents.every(torrent => this.state.selectedItems.has(torrent.hash));
}
toggleEmptyState() {
const isEmpty = this.state.torrents.length === 0;
this.refs.emptyState.classList.toggle('hidden', !isEmpty);
document.querySelector('.card:has(#torrentsList)').classList.toggle('hidden', isEmpty);
const items = this.state.mode === 'torrents' ? this.state.torrents : this.state.nzbs;
const isEmpty = items.length === 0;
if (this.refs.emptyState) {
this.refs.emptyState.classList.toggle('hidden', !isEmpty);
}
// Find the main data table card and toggle its visibility
const dataTableCard = document.querySelector('.card:has(#dataList)');
if (dataTableCard) {
dataTableCard.classList.toggle('hidden', isEmpty);
}
}
// Event handlers
@@ -459,29 +926,30 @@ class TorrentDashboard {
}
toggleSelectAll(checked) {
const visibleTorrents = this.state.filteredTorrents.slice(
const visibleTorrents = this.state.filteredItems.slice(
(this.state.currentPage - 1) * this.state.itemsPerPage,
this.state.currentPage * this.state.itemsPerPage
);
visibleTorrents.forEach(torrent => {
if (checked) {
this.state.selectedTorrents.add(torrent.hash);
this.state.selectedItems.add(torrent.hash);
} else {
this.state.selectedTorrents.delete(torrent.hash);
this.state.selectedItems.delete(torrent.hash);
}
});
this.updateUI();
}
toggleTorrentSelection(hash, checked) {
toggleItemSelection(id, checked) {
if (checked) {
this.state.selectedTorrents.add(hash);
this.state.selectedItems.add(id);
} else {
this.state.selectedTorrents.delete(hash);
this.state.selectedItems.delete(id);
}
this.updateSelectionUI();
this.updateBatchActions();
}
async deleteTorrent(hash, category, removeFromDebrid = false) {
@@ -504,38 +972,55 @@ class TorrentDashboard {
}
}
async deleteSelectedTorrents(removeFromDebrid = false) {
const count = this.state.selectedTorrents.size;
async deleteSelectedItems(removeFromDebrid = false) {
const count = this.state.selectedItems.size;
if (count === 0) {
window.decypharrUtils.createToast('No torrents selected for deletion', 'warning');
const itemType = this.state.mode === 'torrents' ? 'torrents' : 'NZBs';
window.decypharrUtils.createToast(`No ${itemType} selected for deletion`, 'warning');
return;
}
if (!confirm(`Are you sure you want to delete ${count} torrent${count > 1 ? 's' : ''}${removeFromDebrid ? ' from debrid' : ''}?`)) {
const itemType = this.state.mode === 'torrents' ? 'torrent' : 'NZB';
const itemTypePlural = this.state.mode === 'torrents' ? 'torrents' : 'NZBs';
if (!confirm(`Are you sure you want to delete ${count} ${count > 1 ? itemTypePlural : itemType}${removeFromDebrid ? ' from debrid' : ''}?`)) {
return;
}
try {
const hashes = Array.from(this.state.selectedTorrents).join(',');
const response = await window.decypharrUtils.fetcher(
`/api/torrents/?hashes=${encodeURIComponent(hashes)}&removeFromDebrid=${removeFromDebrid}`,
{ method: 'DELETE' }
);
if (this.state.mode === 'torrents') {
const hashes = Array.from(this.state.selectedItems).join(',');
const response = await window.decypharrUtils.fetcher(
`/api/torrents/?hashes=${encodeURIComponent(hashes)}&removeFromDebrid=${removeFromDebrid}`,
{ method: 'DELETE' }
);
if (!response.ok) throw new Error(await response.text());
if (!response.ok) throw new Error(await response.text());
} else {
// Delete NZBs one by one
const promises = Array.from(this.state.selectedItems).map(id =>
window.decypharrUtils.fetcher(`/api/nzbs/${id}`, { method: 'DELETE' })
);
const responses = await Promise.all(promises);
for (const response of responses) {
if (!response.ok) throw new Error(await response.text());
}
}
window.decypharrUtils.createToast(`${count} torrent${count > 1 ? 's' : ''} deleted successfully`);
this.state.selectedTorrents.clear();
await this.loadTorrents();
window.decypharrUtils.createToast(`${count} ${count > 1 ? itemTypePlural : itemType} deleted successfully`);
this.state.selectedItems.clear();
await this.loadData();
} catch (error) {
console.error('Error deleting torrents:', error);
window.decypharrUtils.createToast(`Failed to delete some torrents: ${error.message}`, 'error');
console.error(`Error deleting ${itemTypePlural}:`, error);
window.decypharrUtils.createToast(`Failed to delete some ${itemTypePlural}: ${error.message}`, 'error');
}
}
startAutoRefresh() {
this.refreshInterval = setInterval(() => {
this.loadTorrents();
this.loadData();
}, 5000);
// Clean up on page unload
@@ -556,4 +1041,54 @@ class TorrentDashboard {
};
return text ? text.replace(/[&<>"']/g, (m) => map[m]) : '';
}
loadModeFromURL() {
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get('mode');
if (mode === 'nzbs' || mode === 'torrents') {
this.state.mode = mode;
} else {
this.state.mode = 'torrents'; // Default mode
}
// Set the initial UI state without triggering reload
this.setModeUI(this.state.mode);
}
setModeUI(mode) {
if (mode === 'torrents') {
this.refs.torrentsMode.classList.remove('btn-outline');
this.refs.torrentsMode.classList.add('btn-primary');
this.refs.nzbsMode.classList.remove('btn-primary');
this.refs.nzbsMode.classList.add('btn-outline');
this.refs.torrentsHeaders.classList.remove('hidden');
this.refs.nzbsHeaders.classList.add('hidden');
this.refs.emptyStateTitle.textContent = 'No Torrents Found';
this.refs.emptyStateMessage.textContent = "You haven't added any torrents yet. Start by adding your first download!";
this.refs.batchDeleteDebridBtn.classList.remove('hidden');
} else {
this.refs.nzbsMode.classList.remove('btn-outline');
this.refs.nzbsMode.classList.add('btn-primary');
this.refs.torrentsMode.classList.remove('btn-primary');
this.refs.torrentsMode.classList.add('btn-outline');
this.refs.nzbsHeaders.classList.remove('hidden');
this.refs.torrentsHeaders.classList.add('hidden');
this.refs.emptyStateTitle.textContent = 'No NZBs Found';
this.refs.emptyStateMessage.textContent = "You haven't added any NZB downloads yet. Start by adding your first NZB!";
this.refs.batchDeleteDebridBtn.classList.add('hidden');
}
}
updateURL(mode) {
const url = new URL(window.location);
url.searchParams.set('mode', mode);
window.history.replaceState({}, '', url);
}
}

View File

@@ -2,16 +2,29 @@
class DownloadManager {
constructor(downloadFolder) {
this.downloadFolder = downloadFolder;
this.currentMode = 'torrent'; // Default mode
this.refs = {
downloadForm: document.getElementById('downloadForm'),
// Mode controls
torrentMode: document.getElementById('torrentMode'),
nzbMode: document.getElementById('nzbMode'),
// Torrent inputs
magnetURI: document.getElementById('magnetURI'),
torrentFiles: document.getElementById('torrentFiles'),
torrentInputs: document.getElementById('torrentInputs'),
// NZB inputs
nzbURLs: document.getElementById('nzbURLs'),
nzbFiles: document.getElementById('nzbFiles'),
nzbInputs: document.getElementById('nzbInputs'),
// Common form elements
arr: document.getElementById('arr'),
downloadAction: document.getElementById('downloadAction'),
downloadUncached: document.getElementById('downloadUncached'),
downloadFolder: document.getElementById('downloadFolder'),
downloadFolderHint: document.getElementById('downloadFolderHint'),
debrid: document.getElementById('debrid'),
submitBtn: document.getElementById('submitDownload'),
submitButtonText: document.getElementById('submitButtonText'),
activeCount: document.getElementById('activeCount'),
completedCount: document.getElementById('completedCount'),
totalSize: document.getElementById('totalSize')
@@ -24,12 +37,17 @@ class DownloadManager {
this.loadSavedOptions();
this.bindEvents();
this.handleMagnetFromURL();
this.loadModeFromURL();
}
bindEvents() {
// Form submission
this.refs.downloadForm.addEventListener('submit', (e) => this.handleSubmit(e));
// Mode switching
this.refs.torrentMode.addEventListener('click', () => this.switchMode('torrent'));
this.refs.nzbMode.addEventListener('click', () => this.switchMode('nzb'));
// Save options on change
this.refs.arr.addEventListener('change', () => this.saveOptions());
this.refs.downloadAction.addEventListener('change', () => this.saveOptions());
@@ -38,6 +56,7 @@ class DownloadManager {
// File input enhancement
this.refs.torrentFiles.addEventListener('change', (e) => this.handleFileSelection(e));
this.refs.nzbFiles.addEventListener('change', (e) => this.handleFileSelection(e));
// Drag and drop
this.setupDragAndDrop();
@@ -48,13 +67,15 @@ class DownloadManager {
category: localStorage.getItem('downloadCategory') || '',
action: localStorage.getItem('downloadAction') || 'symlink',
uncached: localStorage.getItem('downloadUncached') === 'true',
folder: localStorage.getItem('downloadFolder') || this.downloadFolder
folder: localStorage.getItem('downloadFolder') || this.downloadFolder,
mode: localStorage.getItem('downloadMode') || 'torrent'
};
this.refs.arr.value = savedOptions.category;
this.refs.downloadAction.value = savedOptions.action;
this.refs.downloadUncached.checked = savedOptions.uncached;
this.refs.downloadFolder.value = savedOptions.folder;
this.currentMode = savedOptions.mode;
}
saveOptions() {
@@ -62,6 +83,7 @@ class DownloadManager {
localStorage.setItem('downloadAction', this.refs.downloadAction.value);
localStorage.setItem('downloadUncached', this.refs.downloadUncached.checked.toString());
localStorage.setItem('downloadFolder', this.refs.downloadFolder.value);
localStorage.setItem('downloadMode', this.currentMode);
}
handleMagnetFromURL() {
@@ -81,31 +103,57 @@ class DownloadManager {
e.preventDefault();
const formData = new FormData();
let urls = [];
let files = [];
let endpoint = '/api/add';
let itemType = 'torrent';
// Get URLs
const urls = this.refs.magnetURI.value
.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0);
if (this.currentMode === 'torrent') {
// Get torrent URLs
urls = this.refs.magnetURI.value
.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0);
if (urls.length > 0) {
formData.append('urls', urls.join('\n'));
}
if (urls.length > 0) {
formData.append('urls', urls.join('\n'));
}
// Get files
for (let i = 0; i < this.refs.torrentFiles.files.length; i++) {
formData.append('files', this.refs.torrentFiles.files[i]);
// Get torrent files
for (let i = 0; i < this.refs.torrentFiles.files.length; i++) {
formData.append('files', this.refs.torrentFiles.files[i]);
files.push(this.refs.torrentFiles.files[i]);
}
} else if (this.currentMode === 'nzb') {
// Get NZB URLs
urls = this.refs.nzbURLs.value
.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0);
if (urls.length > 0) {
formData.append('nzbUrls', urls.join('\n'));
}
// Get NZB files
for (let i = 0; i < this.refs.nzbFiles.files.length; i++) {
formData.append('nzbFiles', this.refs.nzbFiles.files[i]);
files.push(this.refs.nzbFiles.files[i]);
}
endpoint = '/api/nzbs/add';
itemType = 'NZB';
}
// Validation
const totalItems = urls.length + this.refs.torrentFiles.files.length;
const totalItems = urls.length + files.length;
if (totalItems === 0) {
window.decypharrUtils.createToast('Please provide at least one torrent', 'warning');
window.decypharrUtils.createToast(`Please provide at least one ${itemType}`, 'warning');
return;
}
if (totalItems > 100) {
window.decypharrUtils.createToast('Please submit up to 100 torrents at a time', 'warning');
window.decypharrUtils.createToast(`Please submit up to 100 ${itemType}s at a time`, 'warning');
return;
}
@@ -123,7 +171,7 @@ class DownloadManager {
// Set loading state
window.decypharrUtils.setButtonLoading(this.refs.submitBtn, true);
const response = await window.decypharrUtils.fetcher('/api/add', {
const response = await window.decypharrUtils.fetcher(endpoint, {
method: 'POST',
body: formData,
headers: {} // Remove Content-Type to let browser set it for FormData
@@ -137,19 +185,19 @@ class DownloadManager {
// Handle partial success
if (result.errors && result.errors.length > 0) {
console.log(result.errors);
let errorMessage = ` ${result.errors.join('\n')}`;
if (result.results.length > 0) {
window.decypharrUtils.createToast(
`Added ${result.results.length} torrents with ${result.errors.length} errors`,
`Added ${result.results.length} ${itemType}s with ${result.errors.length} errors \n${errorMessage}`,
'warning'
);
this.showErrorDetails(result.errors);
} else {
window.decypharrUtils.createToast('Failed to add torrents', 'error');
this.showErrorDetails(result.errors);
window.decypharrUtils.createToast(`Failed to add ${itemType}s \n${errorMessage}`, 'error');
}
} else {
window.decypharrUtils.createToast(
`Successfully added ${result.results.length} torrent${result.results.length > 1 ? 's' : ''}!`
`Successfully added ${result.results.length} ${itemType}${result.results.length > 1 ? 's' : ''}!`
);
this.clearForm();
}
@@ -162,22 +210,49 @@ class DownloadManager {
}
}
showErrorDetails(errors) {
// Create a modal or detailed view for errors
const errorList = errors.map(error => `${error}`).join('\n');
console.error('Download errors:', errorList);
switchMode(mode) {
this.currentMode = mode;
this.saveOptions();
this.updateURL(mode);
// You could also show this in a modal for better UX
setTimeout(() => {
if (confirm('Some torrents failed to add. Would you like to see the details?')) {
alert(errorList);
}
}, 1000);
// Update button states
if (mode === 'torrent') {
this.refs.torrentMode.classList.remove('btn-outline');
this.refs.torrentMode.classList.add('btn-primary');
this.refs.nzbMode.classList.remove('btn-primary');
this.refs.nzbMode.classList.add('btn-outline');
// Show/hide sections
this.refs.torrentInputs.classList.remove('hidden');
this.refs.nzbInputs.classList.add('hidden');
// Update UI text
this.refs.submitButtonText.textContent = 'Add to Download Queue';
this.refs.downloadFolderHint.textContent = 'Leave empty to use default qBittorrent folder';
} else {
this.refs.nzbMode.classList.remove('btn-outline');
this.refs.nzbMode.classList.add('btn-primary');
this.refs.torrentMode.classList.remove('btn-primary');
this.refs.torrentMode.classList.add('btn-outline');
// Show/hide sections
this.refs.nzbInputs.classList.remove('hidden');
this.refs.torrentInputs.classList.add('hidden');
// Update UI text
this.refs.submitButtonText.textContent = 'Add to NZB Queue';
this.refs.downloadFolderHint.textContent = 'Leave empty to use default SABnzbd folder';
}
}
clearForm() {
this.refs.magnetURI.value = '';
this.refs.torrentFiles.value = '';
if (this.currentMode === 'torrent') {
this.refs.magnetURI.value = '';
this.refs.torrentFiles.value = '';
} else {
this.refs.nzbURLs.value = '';
this.refs.nzbFiles.value = '';
}
}
handleFileSelection(e) {
@@ -226,20 +301,84 @@ class DownloadManager {
const dt = e.dataTransfer;
const files = dt.files;
// Filter for .torrent files
const torrentFiles = Array.from(files).filter(file =>
file.name.toLowerCase().endsWith('.torrent')
);
if (this.currentMode === 'torrent') {
// Filter for .torrent files
const torrentFiles = Array.from(files).filter(file =>
file.name.toLowerCase().endsWith('.torrent')
);
if (torrentFiles.length > 0) {
// Create a new FileList-like object
const dataTransfer = new DataTransfer();
torrentFiles.forEach(file => dataTransfer.items.add(file));
this.refs.torrentFiles.files = dataTransfer.files;
if (torrentFiles.length > 0) {
// Create a new FileList-like object
const dataTransfer = new DataTransfer();
torrentFiles.forEach(file => dataTransfer.items.add(file));
this.refs.torrentFiles.files = dataTransfer.files;
this.handleFileSelection({ target: { files: torrentFiles } });
this.handleFileSelection({ target: { files: torrentFiles } });
} else {
window.decypharrUtils.createToast('Please drop .torrent files only', 'warning');
}
} else {
window.decypharrUtils.createToast('Please drop .torrent files only', 'warning');
// Filter for .nzb files
const nzbFiles = Array.from(files).filter(file =>
file.name.toLowerCase().endsWith('.nzb')
);
if (nzbFiles.length > 0) {
// Create a new FileList-like object
const dataTransfer = new DataTransfer();
nzbFiles.forEach(file => dataTransfer.items.add(file));
this.refs.nzbFiles.files = dataTransfer.files;
this.handleFileSelection({ target: { files: nzbFiles } });
} else {
window.decypharrUtils.createToast('Please drop .nzb files only', 'warning');
}
}
}
loadModeFromURL() {
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get('mode');
if (mode === 'nzb' || mode === 'torrent') {
this.currentMode = mode;
} else {
this.currentMode = this.currentMode || 'torrent'; // Use saved preference or default
}
// Initialize the mode without updating URL again
this.setModeUI(this.currentMode);
}
setModeUI(mode) {
if (mode === 'torrent') {
this.refs.torrentMode.classList.remove('btn-outline');
this.refs.torrentMode.classList.add('btn-primary');
this.refs.nzbMode.classList.remove('btn-primary');
this.refs.nzbMode.classList.add('btn-outline');
this.refs.torrentInputs.classList.remove('hidden');
this.refs.nzbInputs.classList.add('hidden');
this.refs.submitButtonText.textContent = 'Add to Download Queue';
this.refs.downloadFolderHint.textContent = 'Leave empty to use default qBittorrent folder';
} else {
this.refs.nzbMode.classList.remove('btn-outline');
this.refs.nzbMode.classList.add('btn-primary');
this.refs.torrentMode.classList.remove('btn-primary');
this.refs.torrentMode.classList.add('btn-outline');
this.refs.nzbInputs.classList.remove('hidden');
this.refs.torrentInputs.classList.add('hidden');
this.refs.submitButtonText.textContent = 'Add to NZB Queue';
this.refs.downloadFolderHint.textContent = 'Leave empty to use default SABnzbd folder';
}
}
updateURL(mode) {
const url = new URL(window.location);
url.searchParams.set('mode', mode);
window.history.replaceState({}, '', url);
}
}

View File

@@ -3,8 +3,12 @@ package web
import (
"fmt"
"github.com/sirrobot01/decypharr/pkg/store"
"github.com/sirrobot01/decypharr/pkg/usenet"
"io"
"mime/multipart"
"net/http"
"strings"
"sync"
"time"
"encoding/json"
@@ -28,6 +32,7 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
return
}
_store := store.Get()
cfg := config.Get()
results := make([]*store.ImportRequest, 0)
errs := make([]string, 0)
@@ -37,8 +42,8 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
debridName := r.FormValue("debrid")
callbackUrl := r.FormValue("callbackUrl")
downloadFolder := r.FormValue("downloadFolder")
if downloadFolder == "" {
downloadFolder = config.Get().QBitTorrent.DownloadFolder
if downloadFolder == "" && cfg.QBitTorrent != nil {
downloadFolder = cfg.QBitTorrent.DownloadFolder
}
downloadUncached := r.FormValue("downloadUncached") == "true"
@@ -236,8 +241,6 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
currentConfig.RemoveStalledAfter = updatedConfig.RemoveStalledAfter
currentConfig.AllowedExt = updatedConfig.AllowedExt
currentConfig.DiscordWebhook = updatedConfig.DiscordWebhook
// Should this be added?
currentConfig.URLBase = updatedConfig.URLBase
currentConfig.BindAddress = updatedConfig.BindAddress
currentConfig.Port = updatedConfig.Port
@@ -251,9 +254,11 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
// Update Debrids
if len(updatedConfig.Debrids) > 0 {
currentConfig.Debrids = updatedConfig.Debrids
// Clear legacy single debrid if using array
}
currentConfig.Usenet = updatedConfig.Usenet
currentConfig.SABnzbd = updatedConfig.SABnzbd
// Update Arrs through the service
storage := store.Get()
arrStorage := storage.Arr()
@@ -359,3 +364,198 @@ func (wb *Web) handleStopRepairJob(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusOK)
}
// NZB API Handlers
func (wb *Web) handleGetNZBs(w http.ResponseWriter, r *http.Request) {
// Get query parameters for filtering
status := r.URL.Query().Get("status")
category := r.URL.Query().Get("category")
nzbs := wb.usenet.Store().GetQueue()
// Apply filters if provided
filteredNZBs := make([]*usenet.NZB, 0)
for _, nzb := range nzbs {
if status != "" && nzb.Status != status {
continue
}
if category != "" && nzb.Category != category {
continue
}
filteredNZBs = append(filteredNZBs, nzb)
}
response := map[string]interface{}{
"nzbs": filteredNZBs,
"count": len(filteredNZBs),
}
request.JSONResponse(w, response, http.StatusOK)
}
func (wb *Web) handleDeleteNZB(w http.ResponseWriter, r *http.Request) {
nzbID := chi.URLParam(r, "id")
if nzbID == "" {
http.Error(w, "No NZB ID provided", http.StatusBadRequest)
return
}
wb.usenet.Store().RemoveFromQueue(nzbID)
wb.logger.Info().Str("nzb_id", nzbID).Msg("NZB delete requested")
request.JSONResponse(w, map[string]string{"status": "success"}, http.StatusOK)
}
func (wb *Web) handleAddNZBContent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
cfg := config.Get()
_store := store.Get()
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
results := make([]interface{}, 0)
errs := make([]string, 0)
arrName := r.FormValue("arr")
action := r.FormValue("action")
downloadFolder := r.FormValue("downloadFolder")
if downloadFolder == "" {
downloadFolder = cfg.SABnzbd.DownloadFolder
}
_arr := _store.Arr().Get(arrName)
if _arr == nil {
// These are not found in the config. They are throwaway arrs.
_arr = arr.New(arrName, "", "", false, false, nil, "", "")
}
_nzbURLS := r.FormValue("nzbUrls")
urlList := make([]string, 0)
if _nzbURLS != "" {
for _, u := range strings.Split(_nzbURLS, "\n") {
if trimmed := strings.TrimSpace(u); trimmed != "" {
urlList = append(urlList, trimmed)
}
}
}
files := r.MultipartForm.File["nzbFiles"]
totalItems := len(files) + len(urlList)
if totalItems == 0 {
request.JSONResponse(w, map[string]any{
"results": nil,
"errors": "No NZB URLs or files provided",
}, http.StatusBadRequest)
return
}
var wg sync.WaitGroup
for _, url := range urlList {
wg.Add(1)
go func(url string) {
defer wg.Done()
select {
case <-ctx.Done():
return // Exit if context is done
default:
}
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
errs = append(errs, fmt.Sprintf("Invalid URL format: %s", url))
return
}
// Download the NZB file from the URL
filename, content, err := utils.DownloadFile(url)
if err != nil {
wb.logger.Error().Err(err).Str("url", url).Msg("Failed to download NZB from URL")
errs = append(errs, fmt.Sprintf("Failed to download NZB from URL %s: %v", url, err))
return // Continue processing other URLs
}
req := &usenet.ProcessRequest{
NZBContent: content,
Name: filename,
Arr: _arr,
Action: action,
DownloadDir: downloadFolder,
}
nzb, err := wb.usenet.ProcessNZB(ctx, req)
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to process NZB from URL %s: %v", url, err))
return
}
wb.logger.Info().Str("nzb_id", nzb.ID).Str("url", url).Msg("NZB added from URL")
result := map[string]interface{}{
"id": nzb.ID,
"name": "NZB from URL",
"url": url,
"category": arrName,
}
results = append(results, result)
}(url)
}
// Handle NZB files
for _, fileHeader := range files {
wg.Add(1)
go func(fileHeader *multipart.FileHeader) {
defer wg.Done()
select {
case <-ctx.Done():
return
default:
}
file, err := fileHeader.Open()
if err != nil {
errs = append(errs, fmt.Sprintf("failed to open NZB file %s: %v", fileHeader.Filename, err))
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
errs = append(errs, fmt.Sprintf("failed to read NZB file %s: %v", fileHeader.Filename, err))
return
}
req := &usenet.ProcessRequest{
NZBContent: content,
Name: fileHeader.Filename,
Arr: _arr,
Action: action,
DownloadDir: downloadFolder,
}
nzb, err := wb.usenet.ProcessNZB(ctx, req)
if err != nil {
errs = append(errs, fmt.Sprintf("failed to process NZB file %s: %v", fileHeader.Filename, err))
return
}
wb.logger.Info().Str("nzb_id", nzb.ID).Str("file", fileHeader.Filename).Msg("NZB added from file")
// Simulate successful addition
result := map[string]interface{}{
"id": nzb.ID,
"name": fileHeader.Filename,
"filename": fileHeader.Filename,
"category": arrName,
}
results = append(results, result)
}(fileHeader)
}
// Wait for all goroutines to finish
wg.Wait()
// Validation
if len(results) == 0 && len(errs) == 0 {
request.JSONResponse(w, map[string]any{
"results": nil,
"errors": "No NZB URLs or files processed successfully",
}, http.StatusBadRequest)
return
}
request.JSONResponse(w, struct {
Results []interface{} `json:"results"`
Errors []string `json:"errors,omitempty"`
}{
Results: results,
Errors: errs,
}, http.StatusOK)
}

View File

@@ -47,6 +47,9 @@ func (wb *Web) Routes() http.Handler {
r.Get("/torrents", wb.handleGetTorrents)
r.Delete("/torrents/{category}/{hash}", wb.handleDeleteTorrent)
r.Delete("/torrents/", wb.handleDeleteTorrents)
r.Get("/nzbs", wb.handleGetNZBs)
r.Post("/nzbs/add", wb.handleAddNZBContent)
r.Delete("/nzbs/{id}", wb.handleDeleteNZB)
r.Get("/config", wb.handleGetConfig)
r.Post("/config", wb.handleUpdateConfig)
})

View File

@@ -24,6 +24,14 @@
<i class="bi bi-collection text-lg"></i>
<span class="hidden sm:inline">*Arrs</span>
</button>
<button type="button" class="tab-button flex items-center gap-2 py-3 px-1 border-b-2 border-transparent text-base-content/70 hover:text-base-content hover:border-base-300 font-medium text-sm transition-colors" data-tab="usenet">
<i class="bi bi-globe text-lg"></i>
<span class="hidden sm:inline">Usenet</span>
</button>
<button type="button" class="tab-button flex items-center gap-2 py-3 px-1 border-b-2 border-transparent text-base-content/70 hover:text-base-content hover:border-base-300 font-medium text-sm transition-colors" data-tab="sabnzbd">
<i class="bi bi-download text-lg"></i>
<span class="hidden sm:inline">SABnzbd</span>
</button>
<button type="button" class="tab-button flex items-center gap-2 py-3 px-1 border-b-2 border-transparent text-base-content/70 hover:text-base-content hover:border-base-300 font-medium text-sm transition-colors" data-tab="repair">
<i class="bi bi-wrench text-lg"></i>
<span class="hidden sm:inline">Repair</span>
@@ -328,6 +336,146 @@
</div>
</div>
<!-- Usenet Tab Content -->
<div class="tab-content hidden" data-tab-content="usenet">
<div class="space-y-6">
<h2 class="text-2xl font-bold flex items-center mb-6">
<i class="bi bi-globe mr-3 text-info"></i>Usenet Settings
</h2>
<!-- Global Usenet Settings -->
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body">
<h3 class="card-title text-lg mb-4">
<i class="bi bi-folder mr-2 text-info"></i>
Main Settings
</h3>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="usenet.mount_folder">
<span class="label-text font-medium">Mount Folder</span>
</label>
<input type="text" class="input input-bordered"
name="usenet.mount_folder" id="usenet.mount_folder"
placeholder="/mnt/usenet">
<div class="label">
<span class="label-text-alt">Path where usenet downloads are mounted</span>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="usenet.chunks">
<span class="label-text font-medium">Download Chunks</span>
</label>
<input type="text" class="input input-bordered"
name="usenet.chunks" id="usenet.chunks"
placeholder="30">
<div class="label">
<span class="label-text-alt">Number of chunks to pre-cache(default 5)</span>
</div>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox" name="usenet.skip_pre_cache" id="usenet.skip_pre_cache">
<div>
<span class="label-text font-medium">Skip Pre-Cache</span>
<div class="label-text-alt">Disabling this speeds up import</div>
</div>
</label>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="form-control">
<label class="label" for="usenet.rc_url">
<span class="label-text font-medium">Rclone RC URL</span>
</label>
<input type="text" class="input input-bordered"
name="usenet.rc_url" id="usenet.rc_url"
placeholder="http://rclone-usenet:9990">
<div class="label">
<span class="label-text-alt">Rclone RC URL</span>
</div>
</div>
<div class="form-control">
<label class="label" for="usenet.rc_user">
<span class="label-text font-medium">Rclone RC Username</span>
</label>
<input type="text" class="input input-bordered"
name="usenet.rc_user" id="usenet.rc_user"
placeholder="rcuser">
<div class="label">
<span class="label-text-alt">Rclone RC Username</span>
</div>
</div>
<div class="form-control">
<label class="label" for="usenet.rc_pass">
<span class="label-text font-medium">Rclone RC Password</span>
</label>
<div class="password-toggle-container">
<input autocomplete="off" type="password" class="input input-bordered webdav-field input-has-toggle"
name="usenet.rc_pass" id="usenet.rc_pass">
<button type="button" class="password-toggle-btn">
<i class="bi bi-eye" id="usenet.rc_pass_icon"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Usenet Servers Section -->
<div class="flex justify-between items-center">
<h3 class="text-xl font-bold flex items-center">
<i class="bi bi-server mr-2 text-info"></i>Usenet Servers
</h3>
<button type="button" id="addUsenetBtn" class="btn btn-info">
<i class="bi bi-plus mr-2"></i>Add Usenet Server
</button>
</div>
<div id="usenetConfigs" class="space-y-4">
<!-- Dynamic usenet configurations will be added here -->
</div>
</div>
</div>
<!-- SABnzbd Tab Content -->
<div class="tab-content hidden" data-tab-content="sabnzbd">
<div class="space-y-6">
<h2 class="text-2xl font-bold flex items-center mb-6">
<i class="bi bi-download mr-3 text-accent"></i>SABnzbd Settings
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="sabnzbd.download_folder">
<span class="label-text font-medium">Download Folder</span>
</label>
<input type="text" class="input input-bordered" name="sabnzbd.download_folder" id="sabnzbd.download_folder" placeholder="/downloads/sabnzbd">
<div class="label">
<span class="label-text-alt">Folder where SABnzbd downloads files</span>
</div>
</div>
<div class="form-control">
<label class="label" for="sabnzbd.refresh_interval">
<span class="label-text font-medium">Refresh Interval (seconds)</span>
</label>
<input type="number" class="input input-bordered" name="sabnzbd.refresh_interval" id="sabnzbd.refresh_interval" min="1" max="3600">
</div>
<div class="form-control">
<label class="label" for="sabnzbd.categories">
<span class="label-text font-medium">Default Categories</span>
</label>
<input type="text" class="input input-bordered" name="sabnzbd.categories" id="sabnzbd.categories">
</div>
</div>
</div>
</div>
</div> <!-- End tab-content-container -->
</div>
</div>

View File

@@ -4,8 +4,20 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<form id="downloadForm" enctype="multipart/form-data" class="space-y-3">
<!-- Mode Selection -->
<div class="flex justify-center mb-4">
<div class="join">
<button type="button" class="btn btn-primary join-item" id="torrentMode" data-mode="torrent">
<i class="bi bi-magnet mr-2"></i>Torrents
</button>
<button type="button" class="btn btn-outline join-item" id="nzbMode" data-mode="nzb">
<i class="bi bi-file-zip mr-2"></i>NZBs
</button>
</div>
</div>
<!-- Torrent Input Section -->
<div class="space-y-2">
<div class="space-y-2" id="torrentInputs">
<div class="form-control">
<label class="label" for="magnetURI">
<span class="label-text font-semibold">
@@ -42,6 +54,44 @@
</div>
</div>
<!-- NZB Input Section -->
<div class="space-y-2 hidden" id="nzbInputs">
<div class="form-control">
<label class="label" for="nzbURLs">
<span class="label-text font-semibold">
<i class="bi bi-link-45deg mr-2 text-primary"></i>NZB URLs
</span>
<span class="label-text-alt">Paste NZB download URLs</span>
</label>
<textarea class="textarea textarea-bordered h-32 font-mono text-sm"
id="nzbURLs"
name="nzbUrls"
placeholder="Paste your NZB URLs here, one per line..."></textarea>
</div>
<div class="divider">OR</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">
<i class="bi bi-file-earmark-arrow-up mr-2 text-secondary"></i>Upload NZB Files
</span>
<span class="label-text-alt">Select .nzb files</span>
</label>
<input type="file"
class="file-input file-input-bordered w-full"
id="nzbFiles"
name="nzbs"
multiple
accept=".nzb">
<div class="label">
<span class="label-text-alt">
<i class="bi bi-info-circle mr-1"></i>You can select multiple files at once
</span>
</div>
</div>
</div>
<div class="divider"></div>
<!-- Configuration Section -->
@@ -75,7 +125,7 @@
name="downloadFolder"
placeholder="/downloads/torrents">
<div class="label">
<span class="label-text-alt">Leave empty to use default qBittorrent folder</span>
<span class="label-text-alt" id="downloadFolderHint">Leave empty to use default qBittorrent folder</span>
</div>
</div>
</div>
@@ -131,7 +181,7 @@
<!-- Submit Button -->
<div class="form-control">
<button type="submit" class="btn btn-primary btn-lg" id="submitDownload">
<i class="bi bi-cloud-upload mr-2"></i>Add to Download Queue
<i class="bi bi-cloud-upload mr-2"></i><span id="submitButtonText">Add to Download Queue</span>
</button>
</div>
</form>

View File

@@ -4,6 +4,18 @@
<!-- Controls Section -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<!-- Dashboard Mode Toggle -->
<div class="flex justify-center mb-4">
<div class="join">
<button class="btn btn-primary join-item" id="torrentsMode" data-mode="torrents">
<i class="bi bi-magnet mr-2"></i>Torrents
</button>
<button class="btn btn-outline join-item" id="nzbsMode" data-mode="nzbs">
<i class="bi bi-file-zip mr-2"></i>NZBs
</button>
</div>
</div>
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
<!-- Batch Actions -->
<div class="flex items-center gap-2">
@@ -47,12 +59,13 @@
</div>
</div>
<!-- Torrents Table -->
<!-- Data Table -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-0">
<div class="overflow-x-auto">
<table class="table table-hover">
<thead class="bg-base-200">
<!-- Torrents Headers -->
<thead class="bg-base-200" id="torrentsHeaders">
<tr>
<th class="w-12">
<label class="cursor-pointer">
@@ -86,7 +99,41 @@
<th class="font-semibold w-32">Actions</th>
</tr>
</thead>
<tbody id="torrentsList">
<!-- NZBs Headers -->
<thead class="bg-base-200 hidden" id="nzbsHeaders">
<tr>
<th class="w-12">
<label class="cursor-pointer">
<input type="checkbox" class="checkbox checkbox-sm" id="selectAllNzb">
</label>
</th>
<th class="font-semibold">
<i class="bi bi-file-zip mr-2"></i>Name
</th>
<th class="font-semibold">
<i class="bi bi-hdd mr-2"></i>Size
</th>
<th class="font-semibold">
<i class="bi bi-speedometer2 mr-2"></i>Progress
</th>
<th class="font-semibold">
<i class="bi bi-clock mr-2"></i>ETA
</th>
<th class="font-semibold">
<i class="bi bi-tag mr-2"></i>Category
</th>
<th class="font-semibold">
<i class="bi bi-activity mr-2"></i>Status
</th>
<th class="font-semibold">
<i class="bi bi-calendar mr-2"></i>Age
</th>
<th class="font-semibold w-32">Actions</th>
</tr>
</thead>
<tbody id="dataList">
<!-- Dynamic content will be loaded here -->
</tbody>
</table>
@@ -95,7 +142,7 @@
<!-- Pagination -->
<div class="flex flex-col sm:flex-row justify-between items-center p-6 border-t border-base-200 gap-4">
<div class="text-sm text-base-content/70">
<span id="paginationInfo">Loading torrents...</span>
<span id="paginationInfo">Loading data...</span>
</div>
<div class="join" id="paginationControls"></div>
</div>
@@ -108,8 +155,8 @@
<div class="text-6xl text-base-content/30 mb-4">
<i class="bi bi-inbox"></i>
</div>
<h3 class="text-2xl font-bold mb-2">No Torrents Found</h3>
<p class="text-base-content/70 mb-6">You haven't added any torrents yet. Start by adding your first download!</p>
<h3 class="text-2xl font-bold mb-2" id="emptyStateTitle">No Data Found</h3>
<p class="text-base-content/70 mb-6" id="emptyStateMessage">No downloads found.</p>
<a href="{{.URLBase}}download" class="btn btn-primary">
<i class="bi bi-plus-circle mr-2"></i>Add New Download
</a>
@@ -117,7 +164,7 @@
</div>
</div>
<!-- Context Menu -->
<!-- Torrent Context Menu -->
<ul class="menu bg-base-100 shadow-lg rounded-box context-menu hidden fixed z-50" id="torrentContextMenu">
<li class="menu-title">
<span class="torrent-name text-sm font-bold truncate max-w-48"></span>
@@ -135,9 +182,33 @@
</a></li>
</ul>
<!-- NZB Context Menu -->
<ul class="menu bg-base-100 shadow-lg rounded-box context-menu hidden fixed z-50" id="nzbContextMenu">
<li class="menu-title">
<span class="nzb-name text-sm font-bold truncate max-w-48"></span>
</li>
<hr/>
<li><a class="menu-item text-sm" data-action="pause">
<i class="bi bi-pause text-warning"></i>Pause Download
</a></li>
<li><a class="menu-item text-sm" data-action="resume">
<i class="bi bi-play text-success"></i>Resume Download
</a></li>
<li><a class="menu-item text-sm" data-action="retry">
<i class="bi bi-arrow-clockwise text-info"></i>Retry Download
</a></li>
<li><a class="menu-item text-sm" data-action="copy-name">
<i class="bi bi-clipboard text-info"></i>Copy Name
</a></li>
<hr/>
<li><a class="menu-item text-sm text-error" data-action="delete">
<i class="bi bi-trash"></i>Delete NZB
</a></li>
</ul>
<script>
document.addEventListener('DOMContentLoaded', () => {
window.dashboard = new TorrentDashboard();
window.dashboard = new Dashboard();
});
</script>
{{ end }}

View File

@@ -126,13 +126,17 @@ func (wb *Web) DownloadHandler(w http.ResponseWriter, r *http.Request) {
for _, d := range cfg.Debrids {
debrids = append(debrids, d.Name)
}
downloadFolder := ""
if cfg.QBitTorrent != nil {
downloadFolder = cfg.QBitTorrent.DownloadFolder
}
data := map[string]interface{}{
"URLBase": cfg.URLBase,
"Page": "download",
"Title": "Download",
"Debrids": debrids,
"HasMultiDebrid": len(debrids) > 1,
"DownloadFolder": cfg.QBitTorrent.DownloadFolder,
"DownloadFolder": downloadFolder,
}
_ = wb.templates.ExecuteTemplate(w, "layout", data)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/logger"
"github.com/sirrobot01/decypharr/pkg/store"
"github.com/sirrobot01/decypharr/pkg/usenet"
"html/template"
"os"
)
@@ -61,9 +62,10 @@ type Web struct {
cookie *sessions.CookieStore
templates *template.Template
torrents *store.TorrentStorage
usenet usenet.Usenet
}
func New() *Web {
func New(usenet usenet.Usenet) *Web {
templates := template.Must(template.ParseFS(
content,
"templates/layout.html",
@@ -86,5 +88,6 @@ func New() *Web {
templates: templates,
cookie: cookieStore,
torrents: store.Get().Torrents(),
usenet: usenet,
}
}