- Add system status card to dashboard with uptime and version display - Implement version checking against git repository - Add one-click update functionality - Update footer and version references to 2.0.0 - Add server endpoints for status, update checking, and update application 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1655 lines
47 KiB
JavaScript
1655 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();
|
|
|
|
// Initialize system status (new in v2.0.0)
|
|
if (typeof initSystemStatus === 'function') {
|
|
initSystemStatus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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')">×</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')">×</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);
|
|
};
|
|
} |