Implementing a streaming setup with Usenet
This commit is contained in:
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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user