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

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