2025-03-04 22:28:11 +00:00

1650 lines
47 KiB
JavaScript

/**
* Transmission RSS Manager - Main Application Script
* @description Core functionality for the web interface
*/
// Application state
const appState = {
darkMode: localStorage.getItem('darkMode') === 'true',
currentTab: 'home',
notifications: [],
config: null,
feeds: [],
items: [],
torrents: [],
library: {},
isLoading: false,
authToken: localStorage.getItem('authToken') || null,
user: null
};
// Initialize app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
initializeApp();
});
/**
* Initialize the application
*/
function initializeApp() {
// Apply theme based on saved preference
applyTheme();
// Add event listeners
addEventListeners();
// Check authentication status
checkAuthStatus();
// Load initial data
loadInitialData();
// Initialize notifications system
initNotifications();
}
/**
* Apply theme (light/dark) to the document
*/
function applyTheme() {
if (appState.darkMode) {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('theme-toggle-icon').className = 'fas fa-sun';
} else {
document.documentElement.setAttribute('data-theme', 'light');
document.getElementById('theme-toggle-icon').className = 'fas fa-moon';
}
}
/**
* Set up all event listeners
*/
function addEventListeners() {
// Theme toggle
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
// Tab navigation
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const tabId = e.currentTarget.getAttribute('data-tab');
switchTab(tabId);
});
});
// Action buttons
document.getElementById('btn-update-feeds').addEventListener('click', updateFeeds);
document.getElementById('btn-add-feed').addEventListener('click', showAddFeedModal);
document.getElementById('add-torrent-form').addEventListener('submit', handleAddTorrent);
// Form submissions
document.getElementById('settings-form').addEventListener('submit', saveSettings);
document.getElementById('login-form')?.addEventListener('submit', handleLogin);
// Processor controls
document.getElementById('btn-start-processor').addEventListener('click', startProcessor);
document.getElementById('btn-stop-processor').addEventListener('click', stopProcessor);
// Filter handlers
document.querySelectorAll('input[name="item-filter"]').forEach(input => {
input.addEventListener('change', loadRssItems);
});
document.querySelectorAll('input[name="library-filter"]').forEach(input => {
input.addEventListener('change', filterLibrary);
});
// Search handlers
document.getElementById('library-search')?.addEventListener('input', debounce(searchLibrary, 300));
}
/**
* Check authentication status
*/
function checkAuthStatus() {
if (!appState.authToken) {
// If login form exists, show it; otherwise we're on a public page
const loginForm = document.getElementById('login-form');
if (loginForm) {
document.getElementById('app-container').classList.add('d-none');
document.getElementById('login-container').classList.remove('d-none');
}
return;
}
// Validate token
fetch('/api/auth/validate', {
headers: {
'Authorization': `Bearer ${appState.authToken}`
}
})
.then(response => {
if (!response.ok) throw new Error('Invalid token');
return response.json();
})
.then(data => {
appState.user = data.user;
updateUserInfo();
// Show app content
if (document.getElementById('login-container')) {
document.getElementById('login-container').classList.add('d-none');
document.getElementById('app-container').classList.remove('d-none');
}
})
.catch(error => {
console.error('Auth validation error:', error);
localStorage.removeItem('authToken');
appState.authToken = null;
appState.user = null;
// Show login if present
if (document.getElementById('login-form')) {
document.getElementById('app-container').classList.add('d-none');
document.getElementById('login-container').classList.remove('d-none');
}
});
}
/**
* Handle user login
* @param {Event} e - Submit event
*/
function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
setLoading(true);
fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
})
.then(response => {
if (!response.ok) throw new Error('Invalid credentials');
return response.json();
})
.then(data => {
localStorage.setItem('authToken', data.token);
appState.authToken = data.token;
appState.user = data.user;
document.getElementById('login-container').classList.add('d-none');
document.getElementById('app-container').classList.remove('d-none');
// Load data
loadInitialData();
showNotification('Welcome back!', 'success');
})
.catch(error => {
showNotification('Login failed: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Update user info in the UI
*/
function updateUserInfo() {
if (!appState.user) return;
const userInfoEl = document.getElementById('user-info');
if (userInfoEl) {
userInfoEl.textContent = appState.user.username;
}
}
/**
* Toggle between light and dark theme
*/
function toggleTheme() {
appState.darkMode = !appState.darkMode;
localStorage.setItem('darkMode', appState.darkMode);
applyTheme();
}
/**
* Switch to a different tab
* @param {string} tabId - ID of the tab to switch to
*/
function switchTab(tabId) {
// Update active tab
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.getAttribute('data-tab') === tabId);
});
// Show the selected tab content
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.toggle('active', tab.id === tabId);
});
// Update state
appState.currentTab = tabId;
// Load tab-specific data
if (tabId === 'torrents-tab') {
loadTorrents();
} else if (tabId === 'rss-tab') {
loadRssFeeds();
loadRssItems();
} else if (tabId === 'media-tab') {
loadLibrary();
} else if (tabId === 'settings-tab') {
loadSettings();
}
}
/**
* Load initial data for the application
*/
function loadInitialData() {
setLoading(true);
// Load system status first
loadSystemStatus()
.then(() => {
// Then load data for the current tab
switch (appState.currentTab) {
case 'home':
loadTorrents();
break;
case 'torrents-tab':
loadTorrents();
break;
case 'rss-tab':
loadRssFeeds();
loadRssItems();
break;
case 'media-tab':
loadLibrary();
break;
case 'settings-tab':
loadSettings();
break;
}
})
.finally(() => {
setLoading(false);
});
}
/**
* Load system status information
* @returns {Promise} - Resolves when data is loaded
*/
function loadSystemStatus() {
return fetch('/api/status', {
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
updateStatusDisplay(data);
return data;
})
.catch(error => {
showNotification('Error loading status: ' + error.message, 'danger');
});
}
/**
* Update the status display with current system status
* @param {Object} data - Status data from API
*/
function updateStatusDisplay(data) {
const statusContainer = document.getElementById('status-container');
if (!statusContainer) return;
const statusHtml = `
<div class="stats-container">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-server"></i>
</div>
<div class="stat-info">
<p>System Status</p>
<h3>${data.status}</h3>
<small>Version ${data.version}</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="color: ${data.transmissionConnected ? 'var(--secondary-color)' : 'var(--danger-color)'}; background-color: ${data.transmissionConnected ? 'rgba(46, 204, 113, 0.1)' : 'rgba(231, 76, 60, 0.1)'};">
<i class="fas fa-exchange-alt"></i>
</div>
<div class="stat-info">
<p>Transmission</p>
<h3>${data.transmissionConnected ? 'Connected' : 'Disconnected'}</h3>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="color: ${data.postProcessorActive ? 'var(--secondary-color)' : 'var(--warning-color)'}; background-color: ${data.postProcessorActive ? 'rgba(46, 204, 113, 0.1)' : 'rgba(243, 156, 18, 0.1)'};">
<i class="fas fa-cogs"></i>
</div>
<div class="stat-info">
<p>Post-Processor</p>
<h3>${data.postProcessorActive ? 'Running' : 'Stopped'}</h3>
<small>${data.config.autoProcessing ? 'Auto-processing enabled' : 'Auto-processing disabled'}</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="color: ${data.rssFeedManagerActive ? 'var(--secondary-color)' : 'var(--warning-color)'}; background-color: ${data.rssFeedManagerActive ? 'rgba(46, 204, 113, 0.1)' : 'rgba(243, 156, 18, 0.1)'};">
<i class="fas fa-rss"></i>
</div>
<div class="stat-info">
<p>RSS Manager</p>
<h3>${data.rssFeedManagerActive ? 'Running' : 'Stopped'}</h3>
<small>${data.config.rssEnabled ? `${appState.feeds?.length || 0} feeds active` : 'No feeds'}</small>
</div>
</div>
</div>
`;
statusContainer.innerHTML = statusHtml;
}
/**
* Load torrents data from the server
*/
function loadTorrents() {
setLoading(true);
fetch('/api/transmission/torrents', {
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
appState.torrents = data.data || [];
updateTorrentsDisplay();
})
.catch(error => {
showNotification('Error loading torrents: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Update the torrents display with current data
*/
function updateTorrentsDisplay() {
const torrentsContainer = document.getElementById('torrents-container');
if (!torrentsContainer) return;
if (!appState.torrents || appState.torrents.length === 0) {
torrentsContainer.innerHTML = '<p>No active torrents.</p>';
return;
}
let html = `
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Progress</th>
<th>Size</th>
<th>Ratio</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
appState.torrents.forEach(torrent => {
const status = getTorrentStatus(torrent);
const progressPercent = Math.round(torrent.percentDone * 100);
const size = formatBytes(torrent.totalSize);
html += `
<tr>
<td>${torrent.name}</td>
<td><span class="badge ${getBadgeClassForStatus(torrent.status)}">${status}</span></td>
<td>
<div class="progress">
<div class="progress-bar ${getProgressBarClassForStatus(torrent.status)}" style="width: ${progressPercent}%"></div>
</div>
<small>${progressPercent}%</small>
</td>
<td>${size}</td>
<td>${torrent.uploadRatio.toFixed(2)}</td>
<td>
${torrent.status === 0 ?
`<button class="btn btn-success btn-sm" onclick="startTorrent(${torrent.id})"><i class="fas fa-play"></i> Start</button>` :
`<button class="btn btn-warning btn-sm" onclick="stopTorrent(${torrent.id})"><i class="fas fa-pause"></i> Stop</button>`
}
<button class="btn btn-danger btn-sm" onclick="removeTorrent(${torrent.id})"><i class="fas fa-trash"></i> Remove</button>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
torrentsContainer.innerHTML = html;
}
/**
* Get the appropriate CSS class for a torrent status badge
* @param {number} status - Torrent status code
* @returns {string} - CSS class name
*/
function getBadgeClassForStatus(status) {
switch (status) {
case 0: return 'badge-danger'; // Stopped
case 1: case 2: case 3: return 'badge-warning'; // Checking/Waiting
case 4: return 'badge-primary'; // Downloading
case 5: case 6: return 'badge-success'; // Seeding
default: return 'badge-secondary';
}
}
/**
* Get the appropriate CSS class for a torrent progress bar
* @param {number} status - Torrent status code
* @returns {string} - CSS class name
*/
function getProgressBarClassForStatus(status) {
switch (status) {
case 0: return 'bg-danger'; // Stopped
case 4: return 'bg-primary'; // Downloading
case 5: case 6: return 'bg-success'; // Seeding
default: return '';
}
}
/**
* Start a torrent
* @param {number} id - Torrent ID
*/
function startTorrent(id) {
setLoading(true);
fetch('/api/transmission/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify({ ids: id })
})
.then(handleResponse)
.then(data => {
showNotification('Torrent started successfully', 'success');
loadTorrents();
})
.catch(error => {
showNotification('Error starting torrent: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Stop a torrent
* @param {number} id - Torrent ID
*/
function stopTorrent(id) {
setLoading(true);
fetch('/api/transmission/stop', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify({ ids: id })
})
.then(handleResponse)
.then(data => {
showNotification('Torrent stopped successfully', 'success');
loadTorrents();
})
.catch(error => {
showNotification('Error stopping torrent: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Remove a torrent
* @param {number} id - Torrent ID
*/
function removeTorrent(id) {
if (!confirm('Are you sure you want to remove this torrent? This will also delete the local data.')) {
return;
}
setLoading(true);
fetch('/api/transmission/remove', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify({ ids: id, deleteLocalData: true })
})
.then(handleResponse)
.then(data => {
showNotification('Torrent removed successfully', 'success');
loadTorrents();
})
.catch(error => {
showNotification('Error removing torrent: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Handle adding a torrent from URL
* @param {Event} e - Form submit event
*/
function handleAddTorrent(e) {
e.preventDefault();
const url = document.getElementById('torrent-url').value.trim();
if (!url) {
showNotification('Please enter a torrent URL', 'warning');
return;
}
setLoading(true);
fetch('/api/transmission/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify({ url })
})
.then(handleResponse)
.then(data => {
showNotification('Torrent added successfully', 'success');
document.getElementById('torrent-url').value = '';
loadTorrents();
})
.catch(error => {
showNotification('Error adding torrent: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Load RSS feeds from the server
*/
function loadRssFeeds() {
setLoading(true);
fetch('/api/rss/feeds', {
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
appState.feeds = data.data || [];
updateRssFeedsDisplay();
})
.catch(error => {
showNotification('Error loading RSS feeds: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Update the RSS feeds display with current data
*/
function updateRssFeedsDisplay() {
const feedsContainer = document.getElementById('feeds-container');
if (!feedsContainer) return;
if (!appState.feeds || appState.feeds.length === 0) {
feedsContainer.innerHTML = '<p>No RSS feeds configured. Click "Add New Feed" to add one.</p>';
return;
}
let html = `
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th>Auto-Download</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
appState.feeds.forEach(feed => {
html += `
<tr>
<td>${feed.name}</td>
<td><a href="${feed.url}" target="_blank" rel="noopener noreferrer">${feed.url}</a></td>
<td>${feed.autoDownload ? '<span class="badge badge-success">Yes</span>' : '<span class="badge badge-secondary">No</span>'}</td>
<td>
<button class="btn btn-primary btn-sm" onclick="editFeed('${feed.id}')"><i class="fas fa-edit"></i> Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteFeed('${feed.id}')"><i class="fas fa-trash"></i> Delete</button>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
feedsContainer.innerHTML = html;
}
/**
* Load RSS items from the server
*/
function loadRssItems() {
setLoading(true);
const itemFilter = document.querySelector('input[name="item-filter"]:checked').value;
fetch(`/api/rss/items?filter=${itemFilter}`, {
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
appState.items = data.data || [];
updateRssItemsDisplay();
})
.catch(error => {
showNotification('Error loading RSS items: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Update the RSS items display with current data
*/
function updateRssItemsDisplay() {
const itemsContainer = document.getElementById('items-container');
if (!itemsContainer) return;
if (!appState.items || appState.items.length === 0) {
itemsContainer.innerHTML = '<p>No RSS items found.</p>';
return;
}
let html = `
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Feed</th>
<th>Size</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
appState.items.forEach(item => {
const feed = appState.feeds.find(f => f.id === item.feedId) || { name: 'Unknown' };
const size = item.size ? formatBytes(item.size) : 'Unknown';
const date = new Date(item.pubDate).toLocaleDateString();
html += `
<tr>
<td>${item.title}</td>
<td>${feed.name}</td>
<td>${size}</td>
<td>${date}</td>
<td>
<button class="btn ${item.downloaded ? 'btn-secondary' : 'btn-success'} btn-sm"
onclick="downloadRssItem('${item.id}')"
${item.downloaded ? 'disabled' : ''}>
<i class="fas fa-${item.downloaded ? 'check' : 'download'}"></i>
${item.downloaded ? 'Downloaded' : 'Download'}
</button>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
itemsContainer.innerHTML = html;
}
/**
* Download an RSS item as a torrent
* @param {string} itemId - RSS item ID
*/
function downloadRssItem(itemId) {
setLoading(true);
fetch('/api/rss/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify({ itemId })
})
.then(handleResponse)
.then(data => {
showNotification('Item added to Transmission successfully', 'success');
loadRssItems();
loadTorrents();
})
.catch(error => {
showNotification('Error downloading item: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Show the add feed modal
*/
function showAddFeedModal() {
const modalHtml = `
<div class="modal-backdrop" id="add-feed-modal">
<div class="modal">
<div class="modal-header">
<h2>Add RSS Feed</h2>
<button class="modal-close" onclick="closeModal('add-feed-modal')">&times;</button>
</div>
<div class="modal-body">
<form id="add-feed-form">
<div class="form-group">
<label class="form-label" for="feed-name">Feed Name:</label>
<input type="text" id="feed-name" class="form-control" placeholder="My Feed" required>
</div>
<div class="form-group">
<label class="form-label" for="feed-url">Feed URL:</label>
<input type="url" id="feed-url" class="form-control" placeholder="https://example.com/rss.xml" required>
</div>
<div class="form-check">
<input type="checkbox" id="feed-auto-download" class="form-check-input">
<label class="form-check-label" for="feed-auto-download">Auto-Download</label>
</div>
<div id="filter-container" class="mt-3 d-none">
<h4>Auto-Download Filters</h4>
<div class="form-group">
<label class="form-label" for="filter-title">Title Contains:</label>
<input type="text" id="filter-title" class="form-control" placeholder="e.g., 1080p, HEVC">
</div>
<div class="form-group">
<label class="form-label" for="filter-category">Category:</label>
<input type="text" id="filter-category" class="form-control" placeholder="e.g., Movies, TV">
</div>
<div class="form-group">
<label class="form-label" for="filter-min-size">Minimum Size (MB):</label>
<input type="number" id="filter-min-size" class="form-control" min="0">
</div>
<div class="form-group">
<label class="form-label" for="filter-max-size">Maximum Size (MB):</label>
<input type="number" id="filter-max-size" class="form-control" min="0">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('add-feed-modal')">Cancel</button>
<button class="btn btn-primary" onclick="addFeed()">Add Feed</button>
</div>
</div>
</div>
`;
// Add modal to the document
const modalContainer = document.createElement('div');
modalContainer.innerHTML = modalHtml;
document.body.appendChild(modalContainer.firstChild);
// Show the modal
setTimeout(() => {
document.getElementById('add-feed-modal').classList.add('show');
}, 10);
// Add event listeners
document.getElementById('feed-auto-download').addEventListener('change', function() {
document.getElementById('filter-container').classList.toggle('d-none', !this.checked);
});
}
/**
* Close a modal
* @param {string} modalId - ID of the modal to close
*/
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (!modal) return;
modal.classList.remove('show');
// Remove modal after animation
setTimeout(() => {
modal.remove();
}, 300);
}
/**
* Add a new RSS feed
*/
function addFeed() {
const name = document.getElementById('feed-name').value.trim();
const url = document.getElementById('feed-url').value.trim();
const autoDownload = document.getElementById('feed-auto-download').checked;
if (!name || !url) {
showNotification('Name and URL are required!', 'warning');
return;
}
// Create feed object
const feed = {
name,
url,
autoDownload
};
// Add filters if auto-download is enabled
if (autoDownload) {
const title = document.getElementById('filter-title').value.trim();
const category = document.getElementById('filter-category').value.trim();
const minSize = document.getElementById('filter-min-size').value ?
parseInt(document.getElementById('filter-min-size').value, 10) * 1024 * 1024 : null;
const maxSize = document.getElementById('filter-max-size').value ?
parseInt(document.getElementById('filter-max-size').value, 10) * 1024 * 1024 : null;
feed.filters = [{
title,
category,
minSize,
maxSize
}];
}
setLoading(true);
fetch('/api/rss/feeds', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify(feed)
})
.then(handleResponse)
.then(data => {
showNotification('Feed added successfully', 'success');
closeModal('add-feed-modal');
loadRssFeeds();
})
.catch(error => {
showNotification('Error adding feed: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Edit an existing RSS feed
* @param {string} feedId - ID of the feed to edit
*/
function editFeed(feedId) {
const feed = appState.feeds.find(f => f.id === feedId);
if (!feed) {
showNotification('Feed not found', 'danger');
return;
}
// Create modal with feed data
const modalHtml = `
<div class="modal-backdrop" id="edit-feed-modal">
<div class="modal">
<div class="modal-header">
<h2>Edit RSS Feed</h2>
<button class="modal-close" onclick="closeModal('edit-feed-modal')">&times;</button>
</div>
<div class="modal-body">
<form id="edit-feed-form">
<input type="hidden" id="edit-feed-id" value="${feed.id}">
<div class="form-group">
<label class="form-label" for="edit-feed-name">Feed Name:</label>
<input type="text" id="edit-feed-name" class="form-control" value="${feed.name}" required>
</div>
<div class="form-group">
<label class="form-label" for="edit-feed-url">Feed URL:</label>
<input type="url" id="edit-feed-url" class="form-control" value="${feed.url}" required>
</div>
<div class="form-check">
<input type="checkbox" id="edit-feed-auto-download" class="form-check-input" ${feed.autoDownload ? 'checked' : ''}>
<label class="form-check-label" for="edit-feed-auto-download">Auto-Download</label>
</div>
<div id="edit-filter-container" class="mt-3 ${feed.autoDownload ? '' : 'd-none'}">
<h4>Auto-Download Filters</h4>
<div class="form-group">
<label class="form-label" for="edit-filter-title">Title Contains:</label>
<input type="text" id="edit-filter-title" class="form-control" value="${feed.filters && feed.filters[0] ? feed.filters[0].title || '' : ''}">
</div>
<div class="form-group">
<label class="form-label" for="edit-filter-category">Category:</label>
<input type="text" id="edit-filter-category" class="form-control" value="${feed.filters && feed.filters[0] ? feed.filters[0].category || '' : ''}">
</div>
<div class="form-group">
<label class="form-label" for="edit-filter-min-size">Minimum Size (MB):</label>
<input type="number" id="edit-filter-min-size" class="form-control" min="0" value="${feed.filters && feed.filters[0] && feed.filters[0].minSize ? Math.floor(feed.filters[0].minSize / (1024 * 1024)) : ''}">
</div>
<div class="form-group">
<label class="form-label" for="edit-filter-max-size">Maximum Size (MB):</label>
<input type="number" id="edit-filter-max-size" class="form-control" min="0" value="${feed.filters && feed.filters[0] && feed.filters[0].maxSize ? Math.floor(feed.filters[0].maxSize / (1024 * 1024)) : ''}">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('edit-feed-modal')">Cancel</button>
<button class="btn btn-primary" onclick="updateFeed()">Update Feed</button>
</div>
</div>
</div>
`;
// Add modal to the document
const modalContainer = document.createElement('div');
modalContainer.innerHTML = modalHtml;
document.body.appendChild(modalContainer.firstChild);
// Show the modal
setTimeout(() => {
document.getElementById('edit-feed-modal').classList.add('show');
}, 10);
// Add event listeners
document.getElementById('edit-feed-auto-download').addEventListener('change', function() {
document.getElementById('edit-filter-container').classList.toggle('d-none', !this.checked);
});
}
/**
* Update an RSS feed
*/
function updateFeed() {
const feedId = document.getElementById('edit-feed-id').value;
const name = document.getElementById('edit-feed-name').value.trim();
const url = document.getElementById('edit-feed-url').value.trim();
const autoDownload = document.getElementById('edit-feed-auto-download').checked;
if (!name || !url) {
showNotification('Name and URL are required!', 'warning');
return;
}
// Create feed object
const feed = {
name,
url,
autoDownload
};
// Add filters if auto-download is enabled
if (autoDownload) {
const title = document.getElementById('edit-filter-title').value.trim();
const category = document.getElementById('edit-filter-category').value.trim();
const minSize = document.getElementById('edit-filter-min-size').value ?
parseInt(document.getElementById('edit-filter-min-size').value, 10) * 1024 * 1024 : null;
const maxSize = document.getElementById('edit-filter-max-size').value ?
parseInt(document.getElementById('edit-filter-max-size').value, 10) * 1024 * 1024 : null;
feed.filters = [{
title,
category,
minSize,
maxSize
}];
}
setLoading(true);
fetch(`/api/rss/feeds/${feedId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify(feed)
})
.then(handleResponse)
.then(data => {
showNotification('Feed updated successfully', 'success');
closeModal('edit-feed-modal');
loadRssFeeds();
})
.catch(error => {
showNotification('Error updating feed: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Delete an RSS feed
* @param {string} feedId - ID of the feed to delete
*/
function deleteFeed(feedId) {
if (!confirm('Are you sure you want to delete this feed?')) {
return;
}
setLoading(true);
fetch(`/api/rss/feeds/${feedId}`, {
method: 'DELETE',
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
showNotification('Feed deleted successfully', 'success');
loadRssFeeds();
})
.catch(error => {
showNotification('Error deleting feed: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Update all RSS feeds
*/
function updateFeeds() {
setLoading(true);
fetch('/api/rss/update', {
method: 'POST',
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
showNotification('RSS feeds updated successfully', 'success');
loadRssItems();
})
.catch(error => {
showNotification('Error updating RSS feeds: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Load media library data
*/
function loadLibrary() {
setLoading(true);
fetch('/api/media/library', {
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
appState.library = data.data || {};
updateLibraryDisplay();
})
.catch(error => {
showNotification('Error loading library: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Update the library display with current data
*/
function updateLibraryDisplay() {
const libraryContainer = document.getElementById('library-container');
if (!libraryContainer) return;
const selectedCategory = document.querySelector('input[name="library-filter"]:checked').value;
const categories = selectedCategory === 'all' ? Object.keys(appState.library) : [selectedCategory];
if (!appState.library || Object.keys(appState.library).every(key => !appState.library[key] || appState.library[key].length === 0)) {
libraryContainer.innerHTML = '<p>No media files in library.</p>';
return;
}
let html = '';
categories.forEach(category => {
const items = appState.library[category];
if (!items || items.length === 0) {
return;
}
const displayName = getCategoryTitle(category);
html += `
<div class="card mb-3 library-section" data-category="${category}">
<div class="card-header">
<h3>${displayName} <span class="badge badge-primary">${items.length}</span></h3>
</div>
<div class="card-body">
<div class="row">
`;
items.forEach(item => {
html += `
<div class="col-md-4 col-sm-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">${item.name}</h5>
<p class="text-muted mb-2"><small>Added: ${new Date(item.added).toLocaleDateString()}</small></p>
</div>
<div class="card-footer">
<button class="btn btn-sm btn-primary" onclick="openMediaFile('${item.path}')">Open</button>
</div>
</div>
</div>
`;
});
html += `
</div>
</div>
</div>
`;
});
libraryContainer.innerHTML = html || '<p>No media files match the selected category.</p>';
}
/**
* Filter the library display based on selected category
*/
function filterLibrary() {
updateLibraryDisplay();
}
/**
* Search the library
* @param {Event} e - Input event
*/
function searchLibrary(e) {
const query = e.target.value.toLowerCase();
if (!query) {
// If search is cleared, show everything
updateLibraryDisplay();
return;
}
setLoading(true);
fetch(`/api/media/library?query=${encodeURIComponent(query)}`, {
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
// Temporarily update the library for display
const originalLibrary = appState.library;
appState.library = data.data || {};
updateLibraryDisplay();
// Restore original library data
appState.library = originalLibrary;
})
.catch(error => {
showNotification('Error searching library: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Open a media file
* @param {string} path - Path to the media file
*/
function openMediaFile(path) {
// In a real application, this would open the file in a media player
// For now, we'll just open a notification
showNotification(`Opening file: ${path}`, 'info');
// In a real app, you might do:
// window.open(`/media/stream?path=${encodeURIComponent(path)}`, '_blank');
}
/**
* Load settings data from the server
*/
function loadSettings() {
setLoading(true);
fetch('/api/config', {
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
appState.config = data;
updateSettingsForm();
})
.catch(error => {
showNotification('Error loading settings: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Update the settings form with current configuration
*/
function updateSettingsForm() {
const config = appState.config;
if (!config) return;
// Transmission settings
document.getElementById('transmission-host').value = config.transmissionConfig?.host || '';
document.getElementById('transmission-port').value = config.transmissionConfig?.port || '';
document.getElementById('transmission-user').value = config.transmissionConfig?.username || '';
// Don't set password field for security
// Processing settings
document.getElementById('seeding-ratio').value = config.seedingRequirements?.minRatio || '';
document.getElementById('seeding-time').value = config.seedingRequirements?.minTimeMinutes || '';
document.getElementById('check-interval').value = config.seedingRequirements?.checkIntervalSeconds || '';
// Path settings
document.getElementById('movies-path').value = config.destinationPaths?.movies || '';
document.getElementById('tvshows-path').value = config.destinationPaths?.tvShows || '';
document.getElementById('music-path').value = config.destinationPaths?.music || '';
document.getElementById('books-path').value = config.destinationPaths?.books || '';
document.getElementById('magazines-path').value = config.destinationPaths?.magazines || '';
document.getElementById('software-path').value = config.destinationPaths?.software || '';
// Processing options
document.getElementById('extract-archives').checked = config.processingOptions?.extractArchives || false;
document.getElementById('delete-archives').checked = config.processingOptions?.deleteArchives || false;
document.getElementById('create-category-folders').checked = config.processingOptions?.createCategoryFolders || false;
document.getElementById('rename-files').checked = config.processingOptions?.renameFiles || false;
document.getElementById('ignore-sample').checked = config.processingOptions?.ignoreSample || false;
document.getElementById('ignore-extras').checked = config.processingOptions?.ignoreExtras || false;
// RSS settings
document.getElementById('rss-interval').value = config.rssUpdateIntervalMinutes || '';
// Security settings if they exist
if (document.getElementById('https-enabled')) {
document.getElementById('https-enabled').checked = config.securitySettings?.httpsEnabled || false;
document.getElementById('auth-enabled').checked = config.securitySettings?.authEnabled || false;
}
}
/**
* Save application settings
* @param {Event} e - Form submit event
*/
function saveSettings(e) {
e.preventDefault();
const settingsData = {
transmissionConfig: {
host: document.getElementById('transmission-host').value.trim(),
port: parseInt(document.getElementById('transmission-port').value.trim(), 10),
username: document.getElementById('transmission-user').value.trim()
},
seedingRequirements: {
minRatio: parseFloat(document.getElementById('seeding-ratio').value.trim()),
minTimeMinutes: parseInt(document.getElementById('seeding-time').value.trim(), 10),
checkIntervalSeconds: parseInt(document.getElementById('check-interval').value.trim(), 10)
},
destinationPaths: {
movies: document.getElementById('movies-path').value.trim(),
tvShows: document.getElementById('tvshows-path').value.trim(),
music: document.getElementById('music-path').value.trim(),
books: document.getElementById('books-path').value.trim(),
magazines: document.getElementById('magazines-path').value.trim(),
software: document.getElementById('software-path').value.trim()
},
processingOptions: {
extractArchives: document.getElementById('extract-archives').checked,
deleteArchives: document.getElementById('delete-archives').checked,
createCategoryFolders: document.getElementById('create-category-folders').checked,
renameFiles: document.getElementById('rename-files').checked,
ignoreSample: document.getElementById('ignore-sample').checked,
ignoreExtras: document.getElementById('ignore-extras').checked
},
rssUpdateIntervalMinutes: parseInt(document.getElementById('rss-interval').value.trim(), 10)
};
// Add password only if provided
const password = document.getElementById('transmission-pass').value.trim();
if (password) {
settingsData.transmissionConfig.password = password;
}
// Add security settings if they exist
if (document.getElementById('https-enabled')) {
settingsData.securitySettings = {
httpsEnabled: document.getElementById('https-enabled').checked,
authEnabled: document.getElementById('auth-enabled').checked
};
}
setLoading(true);
fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify(settingsData)
})
.then(handleResponse)
.then(data => {
showNotification('Settings saved successfully', 'success');
loadSettings(); // Reload settings to get the updated values
loadSystemStatus(); // Reload status to reflect any changes
})
.catch(error => {
showNotification('Error saving settings: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Test the connection to the Transmission server
*/
function testTransmissionConnection() {
const host = document.getElementById('transmission-host').value.trim();
const port = document.getElementById('transmission-port').value.trim();
const username = document.getElementById('transmission-user').value.trim();
const password = document.getElementById('transmission-pass').value.trim();
setLoading(true);
fetch('/api/transmission/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify({ host, port, username, password })
})
.then(handleResponse)
.then(data => {
if (data.success) {
showNotification(`${data.message} (Version: ${data.data.version})`, 'success');
} else {
showNotification(data.message, 'danger');
}
})
.catch(error => {
showNotification('Error testing connection: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Start the post-processor
*/
function startProcessor() {
setLoading(true);
fetch('/api/post-processor/start', {
method: 'POST',
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
showNotification('Post-processor started successfully', 'success');
loadSystemStatus();
})
.catch(error => {
showNotification('Error starting post-processor: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Stop the post-processor
*/
function stopProcessor() {
setLoading(true);
fetch('/api/post-processor/stop', {
method: 'POST',
headers: authHeaders()
})
.then(handleResponse)
.then(data => {
showNotification('Post-processor stopped successfully', 'success');
loadSystemStatus();
})
.catch(error => {
showNotification('Error stopping post-processor: ' + error.message, 'danger');
})
.finally(() => {
setLoading(false);
});
}
/**
* Initialize the notifications system
*/
function initNotifications() {
// Create notifications container if it doesn't exist
if (!document.getElementById('notifications-container')) {
const container = document.createElement('div');
container.id = 'notifications-container';
container.style.position = 'fixed';
container.style.top = '20px';
container.style.right = '20px';
container.style.zIndex = '1060';
document.body.appendChild(container);
}
}
/**
* Show a notification message
* @param {string} message - Message to display
* @param {string} type - Type of notification (success, danger, warning, info)
*/
function showNotification(message, type = 'info') {
const container = document.getElementById('notifications-container');
const notification = document.createElement('div');
notification.className = `alert alert-${type}`;
notification.innerHTML = message;
notification.style.opacity = '0';
notification.style.transform = 'translateY(-20px)';
notification.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
container.appendChild(notification);
// Fade in
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
}, 10);
// Auto-remove after a delay
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(-20px)';
setTimeout(() => {
notification.remove();
}, 300);
}, 5000);
}
/**
* Get the title display name for a category
* @param {string} category - Category key
* @returns {string} - Formatted category title
*/
function getCategoryTitle(category) {
switch(category) {
case 'movies': return 'Movies';
case 'tvShows': return 'TV Shows';
case 'music': return 'Music';
case 'books': return 'Books';
case 'magazines': return 'Magazines';
case 'software': return 'Software';
default: return category.charAt(0).toUpperCase() + category.slice(1);
}
}
/**
* Get the status string for a torrent
* @param {Object} torrent - Torrent object
* @returns {string} - Status string
*/
function getTorrentStatus(torrent) {
const statusMap = {
0: 'Stopped',
1: 'Check Waiting',
2: 'Checking',
3: 'Download Waiting',
4: 'Downloading',
5: 'Seed Waiting',
6: 'Seeding'
};
return statusMap[torrent.status] || 'Unknown';
}
/**
* Format bytes to human-readable size
* @param {number} bytes - Size in bytes
* @param {number} decimals - Number of decimal places
* @returns {string} - Formatted size string
*/
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* Set the loading state of the application
* @param {boolean} isLoading - Whether the app is loading
*/
function setLoading(isLoading) {
appState.isLoading = isLoading;
// Update loading spinner if it exists
const spinner = document.getElementById('loading-spinner');
if (spinner) {
spinner.style.display = isLoading ? 'block' : 'none';
}
}
/**
* Create auth headers including token if available
* @returns {Object} - Headers object
*/
function authHeaders() {
return appState.authToken ? {
'Authorization': `Bearer ${appState.authToken}`
} : {};
}
/**
* Handle API response with error checking
* @param {Response} response - Fetch API response
* @returns {Promise} - Resolves to response data
*/
function handleResponse(response) {
if (!response.ok) {
// Try to get error message from response
return response.json()
.then(data => {
throw new Error(data.message || `HTTP error ${response.status}`);
})
.catch(e => {
// If JSON parsing fails, throw generic error
if (e instanceof SyntaxError) {
throw new Error(`HTTP error ${response.status}`);
}
throw e;
});
}
return response.json();
}
/**
* Create a debounced version of a function
* @param {Function} func - Function to debounce
* @param {number} wait - Milliseconds to wait
* @returns {Function} - Debounced function
*/
function debounce(func, wait) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}