feat: fix download-to-user matching, add cover art to downloads
- Fix seriesMap key (use Sonarr internal id, not tvdbId) - Fix Sonarr tag resolution (use tag map like Radarr) - Use sourceTitle for history record matching - Fall back to embedded movie/series objects when API timeouts - Add includeMovie/includeSeries params to queue/history API calls - Add coverArt field to all download responses (TMDB poster URLs) - Add cover art display to frontend download cards - Fix user-summary route to use instance config and tag maps
This commit is contained in:
@@ -1,29 +1,83 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config();
|
||||
|
||||
// Setup logging with levels
|
||||
// Levels: debug (0), info (1), warn (2), error (3), silent (4)
|
||||
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
||||
const currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1;
|
||||
|
||||
const logFile = fs.createWriteStream(path.join(__dirname, '../server.log'), { flags: 'a' });
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
const originalConsoleDebug = console.debug;
|
||||
|
||||
function shouldLog(level) {
|
||||
return level >= currentLevel;
|
||||
}
|
||||
|
||||
console.debug = function(...args) {
|
||||
if (!shouldLog(LOG_LEVELS.debug)) return;
|
||||
const message = args.join(' ');
|
||||
originalConsoleDebug.apply(console, args);
|
||||
logFile.write(`[${new Date().toISOString()}] DEBUG: ${message}\n`);
|
||||
};
|
||||
|
||||
console.log = function(...args) {
|
||||
if (!shouldLog(LOG_LEVELS.info)) return;
|
||||
const message = args.join(' ');
|
||||
originalConsoleLog.apply(console, args);
|
||||
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
|
||||
};
|
||||
|
||||
console.warn = function(...args) {
|
||||
if (!shouldLog(LOG_LEVELS.warn)) return;
|
||||
const message = args.join(' ');
|
||||
originalConsoleWarn.apply(console, args);
|
||||
logFile.write(`[${new Date().toISOString()}] WARN: ${message}\n`);
|
||||
};
|
||||
|
||||
console.error = function(...args) {
|
||||
if (!shouldLog(LOG_LEVELS.error)) return;
|
||||
const message = args.join(' ');
|
||||
originalConsoleError.apply(console, args);
|
||||
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
||||
};
|
||||
|
||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||
const sonarrRoutes = require('./routes/sonarr');
|
||||
const radarrRoutes = require('./routes/radarr');
|
||||
const embyRoutes = require('./routes/emby');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const authRoutes = require('./routes/auth');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
||||
app.use('/api/sonarr', sonarrRoutes);
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.json({ message: 'Media Download Dashboard API' });
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
console.log(`=================================`);
|
||||
console.log(` sofarr - Your Downloads Dashboard`);
|
||||
console.log(` Server running on port ${PORT}`);
|
||||
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`);
|
||||
console.log(`=================================`);
|
||||
});
|
||||
|
||||
94
server/routes/auth.js
Normal file
94
server/routes/auth.js
Normal file
@@ -0,0 +1,94 @@
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const router = express.Router();
|
||||
|
||||
const EMBY_URL = process.env.EMBY_URL;
|
||||
const EMBY_API_KEY = process.env.EMBY_API_KEY;
|
||||
|
||||
// Authenticate user with Emby
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
console.log(`[Auth] Attempting login for user: ${username}`);
|
||||
|
||||
// Authenticate with Emby
|
||||
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
|
||||
Username: username,
|
||||
Pw: password
|
||||
}, {
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="MediaDashboard", Device="Browser", DeviceId="dashboard-${Date.now()}", Version="1.0.0"`
|
||||
}
|
||||
});
|
||||
|
||||
const authData = authResponse.data;
|
||||
console.log(`[Auth] Emby auth response:`, JSON.stringify(authData));
|
||||
|
||||
// Get user info using the access token
|
||||
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
|
||||
headers: {
|
||||
'X-MediaBrowser-Token': authData.AccessToken
|
||||
}
|
||||
});
|
||||
|
||||
const user = userResponse.data;
|
||||
console.log(`[Auth] User info:`, JSON.stringify(user));
|
||||
console.log(`[Auth] Login successful for user: ${user.Name}`);
|
||||
|
||||
// Set authentication cookie
|
||||
res.cookie('emby_user', JSON.stringify({
|
||||
id: user.Id,
|
||||
name: user.Name,
|
||||
token: authData.AccessToken
|
||||
}), {
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.Id,
|
||||
name: user.Name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Auth] Login failed:`, error.message);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid username or password'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get current authenticated user
|
||||
router.get('/me', (req, res) => {
|
||||
try {
|
||||
const userCookie = req.cookies.emby_user;
|
||||
|
||||
if (!userCookie) {
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
const user = JSON.parse(userCookie);
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Auth] Error getting current user:`, error.message);
|
||||
res.json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req, res) => {
|
||||
res.clearCookie('emby_user');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,131 +2,355 @@ const express = require('express');
|
||||
const axios = require('axios');
|
||||
const router = express.Router();
|
||||
|
||||
const SABNZBD_URL = process.env.SABNZBD_URL;
|
||||
const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY;
|
||||
const SONARR_URL = process.env.SONARR_URL;
|
||||
const SONARR_API_KEY = process.env.SONARR_API_KEY;
|
||||
const RADARR_URL = process.env.RADARR_URL;
|
||||
const RADARR_API_KEY = process.env.RADARR_API_KEY;
|
||||
const { getTorrents, mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||
const {
|
||||
getSABnzbdInstances,
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
} = require('../utils/config');
|
||||
|
||||
const EMBY_URL = process.env.EMBY_URL;
|
||||
const EMBY_API_KEY = process.env.EMBY_API_KEY;
|
||||
|
||||
// Helper function to extract user tag from series/movie
|
||||
function extractUserTag(tags) {
|
||||
if (!tags) return null;
|
||||
const userTag = tags.find(tag => tag.label && tag.label.startsWith('user:'));
|
||||
return userTag ? userTag.label.replace('user:', '') : null;
|
||||
// Helper function to extract poster/cover art URL from a movie or series object
|
||||
function getCoverArt(item) {
|
||||
if (!item || !item.images) return null;
|
||||
const poster = item.images.find(img => img.coverType === 'poster');
|
||||
if (poster) return poster.remoteUrl || poster.url || null;
|
||||
// Fallback to fanart if no poster
|
||||
const fanart = item.images.find(img => img.coverType === 'fanart');
|
||||
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||
}
|
||||
|
||||
// Get user downloads for current session
|
||||
router.get('/user-downloads/:sessionId', async (req, res) => {
|
||||
// Helper function to extract user tag from series/movie
|
||||
// For Radarr: tags is array of IDs, tagMap is id -> label mapping
|
||||
// For Sonarr: tags is array of objects with label property
|
||||
function extractUserTag(tags, tagMap) {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
|
||||
// If tagMap provided (Radarr), look up label by ID
|
||||
if (tagMap) {
|
||||
for (const tagId of tags) {
|
||||
const label = tagMap.get(tagId);
|
||||
if (label) return label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sonarr style - tags are objects with label
|
||||
const userTag = tags.find(tag => tag && tag.label);
|
||||
return userTag ? userTag.label : null;
|
||||
}
|
||||
|
||||
// Get user downloads for authenticated user
|
||||
router.get('/user-downloads', async (req, res) => {
|
||||
try {
|
||||
// Get current user from Emby session
|
||||
const sessionResponse = await axios.get(`${EMBY_URL}/Sessions`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
});
|
||||
|
||||
const session = sessionResponse.data.find(s => s.Id === req.params.sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
// Get authenticated user from cookie
|
||||
const userCookie = req.cookies.emby_user;
|
||||
if (!userCookie) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
});
|
||||
const currentUser = userResponse.data;
|
||||
const username = currentUser.Name.toLowerCase();
|
||||
const user = JSON.parse(userCookie);
|
||||
const username = user.name.toLowerCase();
|
||||
console.log(`[Dashboard] Fetching downloads for authenticated user: ${user.name} (${username})`);
|
||||
|
||||
// Get SABnzbd queue and history
|
||||
const [sabnzbdQueue, sabnzbdHistory, sonarrQueue, sonarrHistory, radarrQueue, radarrHistory, sonarrSeries, radarrMovies] = await Promise.all([
|
||||
axios.get(`${SABNZBD_URL}/api`, {
|
||||
params: { mode: 'queue', apikey: SABNZBD_API_KEY, output: 'json' }
|
||||
}),
|
||||
axios.get(`${SABNZBD_URL}/api`, {
|
||||
params: { mode: 'history', apikey: SABNZBD_API_KEY, output: 'json', limit: 100 }
|
||||
}),
|
||||
axios.get(`${SONARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||
}).catch(() => ({ data: { records: [] } })),
|
||||
axios.get(`${SONARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY },
|
||||
params: { pageSize: 100 }
|
||||
}).catch(() => ({ data: { records: [] } })),
|
||||
axios.get(`${RADARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||
}).catch(() => ({ data: { records: [] } })),
|
||||
axios.get(`${RADARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY },
|
||||
params: { pageSize: 100 }
|
||||
}).catch(() => ({ data: { records: [] } })),
|
||||
axios.get(`${SONARR_URL}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||
}).catch(() => ({ data: [] })),
|
||||
axios.get(`${RADARR_URL}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||
}).catch(() => ({ data: [] }))
|
||||
// Get all service instances
|
||||
const sabInstances = getSABnzbdInstances();
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
console.log(`[Dashboard] Fetching data from all services...`);
|
||||
console.log(`[Dashboard] SABnzbd instances: ${sabInstances.length}`);
|
||||
console.log(`[Dashboard] Sonarr instances: ${sonarrInstances.length}`);
|
||||
console.log(`[Dashboard] Radarr instances: ${radarrInstances.length}`);
|
||||
|
||||
// Fetch from all SABnzbd instances
|
||||
const sabQueuePromises = sabInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api`, {
|
||||
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] SABnzbd ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { queue: { slots: [] } } };
|
||||
})
|
||||
);
|
||||
|
||||
const sabHistoryPromises = sabInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api`, {
|
||||
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 100 }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] SABnzbd ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { history: { slots: [] } } };
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch from all Sonarr instances
|
||||
const sonarrTagsPromises = sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Sonarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
);
|
||||
|
||||
const sonarrQueuePromises = sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeSeries: true, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Sonarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
);
|
||||
|
||||
const sonarrHistoryPromises = sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 100, includeSeries: true, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Sonarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
);
|
||||
|
||||
const sonarrSeriesPromises = sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Sonarr ${inst.id} series error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch from all Radarr instances
|
||||
const radarrQueuePromises = radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeMovie: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Radarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
);
|
||||
|
||||
const radarrHistoryPromises = radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 100, includeMovie: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Radarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
);
|
||||
|
||||
const radarrMoviesPromises = radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Radarr ${inst.id} movies error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
);
|
||||
|
||||
const radarrTagsPromises = radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Radarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
);
|
||||
|
||||
// Execute all requests
|
||||
const [
|
||||
sabQueues, sabHistories, sonarrTagsResults, sonarrQueues, sonarrHistories, sonarrSeriesResults,
|
||||
radarrQueues, radarrHistories, radarrMoviesResults, radarrTagsResults,
|
||||
qbittorrentTorrents
|
||||
] = await Promise.all([
|
||||
Promise.all(sabQueuePromises),
|
||||
Promise.all(sabHistoryPromises),
|
||||
Promise.all(sonarrTagsPromises),
|
||||
Promise.all(sonarrQueuePromises),
|
||||
Promise.all(sonarrHistoryPromises),
|
||||
Promise.all(sonarrSeriesPromises),
|
||||
Promise.all(radarrQueuePromises),
|
||||
Promise.all(radarrHistoryPromises),
|
||||
Promise.all(radarrMoviesPromises),
|
||||
Promise.all(radarrTagsPromises),
|
||||
getTorrents().catch(err => {
|
||||
console.error(`[Dashboard] qBittorrent error:`, err.message);
|
||||
return [];
|
||||
})
|
||||
]);
|
||||
|
||||
// Aggregate data from all instances
|
||||
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
|
||||
const sabnzbdQueue = {
|
||||
data: {
|
||||
queue: {
|
||||
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
|
||||
status: firstSabQueue && firstSabQueue.status,
|
||||
speed: firstSabQueue && firstSabQueue.speed,
|
||||
kbpersec: firstSabQueue && firstSabQueue.kbpersec
|
||||
}
|
||||
}
|
||||
};
|
||||
const sabnzbdHistory = {
|
||||
data: {
|
||||
history: {
|
||||
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
|
||||
}
|
||||
}
|
||||
};
|
||||
const sonarrQueue = {
|
||||
data: {
|
||||
records: sonarrQueues.flatMap(q => q.data.records || [])
|
||||
}
|
||||
};
|
||||
const sonarrHistory = {
|
||||
data: {
|
||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||
}
|
||||
};
|
||||
const sonarrSeries = {
|
||||
data: sonarrSeriesResults.flatMap(s => s.data || [])
|
||||
};
|
||||
const radarrQueue = {
|
||||
data: {
|
||||
records: radarrQueues.flatMap(q => q.data.records || [])
|
||||
}
|
||||
};
|
||||
const radarrHistory = {
|
||||
data: {
|
||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||
}
|
||||
};
|
||||
const radarrMovies = {
|
||||
data: radarrMoviesResults.flatMap(m => m.data || [])
|
||||
};
|
||||
const radarrTags = {
|
||||
data: radarrTagsResults.flatMap(t => t.data || [])
|
||||
};
|
||||
|
||||
console.log(`[Dashboard] Data fetched successfully`);
|
||||
console.log(`[Dashboard] Sonarr series: ${sonarrSeries.data.length}`);
|
||||
console.log(`[Dashboard] Radarr movies: ${radarrMovies.data.length}`);
|
||||
console.log(`[Dashboard] Radarr queue records: ${radarrQueue.data.records.length}`);
|
||||
console.log(`[Dashboard] Radarr history records: ${radarrHistory.data.records.length}`);
|
||||
console.log(`[Dashboard] SABnzbd queue slots: ${sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.slots.length : 0}`);
|
||||
console.log(`[Dashboard] SABnzbd history slots: ${sabnzbdHistory.data.history ? sabnzbdHistory.data.history.slots.length : 0}`);
|
||||
console.log(`[Dashboard] qBittorrent torrents: ${qbittorrentTorrents.length}`);
|
||||
console.log(`[Dashboard] Radarr queue:`, JSON.stringify(radarrQueue.data.records));
|
||||
console.log(`[Dashboard] Radarr history:`, JSON.stringify(radarrHistory.data.records));
|
||||
|
||||
// Create maps for quick lookup
|
||||
const seriesMap = new Map(sonarrSeries.data.map(s => [s.tvdbId || s.id, s]));
|
||||
const moviesMap = new Map(radarrMovies.data.map(m => [m.tmdbId || m.id, m]));
|
||||
const seriesMap = new Map(sonarrSeries.data.map(s => [s.id, s]));
|
||||
const moviesMap = new Map(radarrMovies.data.map(m => [m.id, m]));
|
||||
|
||||
// Create tag maps (id -> label)
|
||||
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
|
||||
console.log(`[Dashboard] Radarr tags:`, JSON.stringify(radarrTags.data));
|
||||
console.log(`[Dashboard] Movies map keys:`, Array.from(moviesMap.keys()).slice(0, 10));
|
||||
console.log(`[Dashboard] Looking for movieId: 2962`);
|
||||
console.log(`[Dashboard] Movie 2962:`, JSON.stringify(moviesMap.get(2962)));
|
||||
console.log(`[Dashboard] Sample movie structure:`, JSON.stringify(radarrMovies.data[0]));
|
||||
|
||||
// Match SABnzbd downloads to Sonarr/Radarr activity
|
||||
const userDownloads = [];
|
||||
|
||||
// Process SABnzbd queue
|
||||
const queueStatus = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.status : null;
|
||||
const queueSpeed = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.speed : null;
|
||||
const queueKbpersec = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.kbpersec : null;
|
||||
console.log(`[Dashboard] Queue status: ${queueStatus}, speed: ${queueSpeed}, kbpersec: ${queueKbpersec}`);
|
||||
|
||||
// Helper to determine status and speed
|
||||
function getSlotStatusAndSpeed(slot) {
|
||||
// If whole queue is paused, everything is paused with 0 speed
|
||||
if (queueStatus === 'Paused') {
|
||||
return { status: 'Paused', speed: '0' };
|
||||
}
|
||||
// Use slot's actual status and queue speed
|
||||
return {
|
||||
status: slot.status || 'Unknown',
|
||||
speed: queueSpeed || queueKbpersec || '0'
|
||||
};
|
||||
}
|
||||
|
||||
if (sabnzbdQueue.data.queue && sabnzbdQueue.data.queue.slots) {
|
||||
for (const slot of sabnznzbdQueue.data.queue.slots) {
|
||||
const nzbName = slot.nzbname.toLowerCase();
|
||||
|
||||
// Try to match with Sonarr
|
||||
const sonarrMatch = sonarrQueue.data.records.find(r =>
|
||||
r.title.toLowerCase().includes(nzbName) || nzbName.includes(r.title.toLowerCase())
|
||||
);
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId);
|
||||
if (series) {
|
||||
const userTag = extractUserTag(series.tags);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
userDownloads.push({
|
||||
type: 'series',
|
||||
title: slot.nzbname,
|
||||
status: 'downloading',
|
||||
progress: slot.percentage,
|
||||
size: slot.size,
|
||||
speed: slot.speed,
|
||||
eta: slot.eta,
|
||||
seriesName: series.title,
|
||||
episodeInfo: sonarrMatch
|
||||
});
|
||||
for (const slot of sabnzbdQueue.data.queue.slots) {
|
||||
try {
|
||||
const nzbName = slot.filename || slot.nzbname;
|
||||
if (!nzbName) {
|
||||
console.log(`[Dashboard] Skipping slot with no filename/nzbname`);
|
||||
continue;
|
||||
}
|
||||
const slotState = getSlotStatusAndSpeed(slot);
|
||||
console.log(`[Dashboard] Slot ${nzbName}: status=${slotState.status}, speed=${slotState.speed}`);
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
// Try to match with Sonarr
|
||||
const sonarrMatch = sonarrQueue.data.records.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
userDownloads.push({
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(series),
|
||||
status: slotState.status,
|
||||
progress: slot.percentage,
|
||||
mb: slot.mb,
|
||||
mbmissing: slot.mbmissing,
|
||||
size: slot.size,
|
||||
speed: slotState.speed,
|
||||
eta: slot.timeleft,
|
||||
seriesName: series.title,
|
||||
episodeInfo: sonarrMatch
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match with Radarr
|
||||
const radarrMatch = radarrQueue.data.records.find(r =>
|
||||
r.title.toLowerCase().includes(nzbName) || nzbName.includes(r.title.toLowerCase())
|
||||
);
|
||||
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId);
|
||||
if (movie) {
|
||||
const userTag = extractUserTag(movie.tags);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
userDownloads.push({
|
||||
type: 'movie',
|
||||
title: slot.nzbname,
|
||||
status: 'downloading',
|
||||
progress: slot.percentage,
|
||||
size: slot.size,
|
||||
speed: slot.speed,
|
||||
eta: slot.eta,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch
|
||||
});
|
||||
// Try to match with Radarr
|
||||
const radarrMatch = radarrQueue.data.records.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
userDownloads.push({
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(movie),
|
||||
status: slotState.status,
|
||||
progress: slot.percentage,
|
||||
mb: slot.mb,
|
||||
mbmissing: slot.mbmissing,
|
||||
size: slot.size,
|
||||
speed: slotState.speed,
|
||||
eta: slot.timeleft,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Dashboard] Error processing slot:`, err.message);
|
||||
console.error(`[Dashboard] Slot data:`, JSON.stringify(slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,61 +358,214 @@ router.get('/user-downloads/:sessionId', async (req, res) => {
|
||||
// Process SABnzbd history
|
||||
if (sabnzbdHistory.data.history && sabnzbdHistory.data.history.slots) {
|
||||
for (const slot of sabnzbdHistory.data.history.slots) {
|
||||
const nzbName = slot.nzbname.toLowerCase();
|
||||
|
||||
// Try to match with Sonarr history
|
||||
const sonarrMatch = sonarrHistory.data.records.find(r =>
|
||||
r.title.toLowerCase().includes(nzbName) || nzbName.includes(r.title.toLowerCase())
|
||||
);
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId);
|
||||
if (series) {
|
||||
const userTag = extractUserTag(series.tags);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
userDownloads.push({
|
||||
type: 'series',
|
||||
title: slot.nzbname,
|
||||
status: slot.status,
|
||||
size: slot.size,
|
||||
completedAt: slot.completed_time,
|
||||
seriesName: series.title,
|
||||
episodeInfo: sonarrMatch
|
||||
});
|
||||
try {
|
||||
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
|
||||
if (!nzbName) {
|
||||
console.log(`[Dashboard] Skipping history slot with no name/nzb_name/nzbname`);
|
||||
continue;
|
||||
}
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
// Try to match with Sonarr history
|
||||
const sonarrMatch = sonarrHistory.data.records.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
userDownloads.push({
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(series),
|
||||
status: slot.status,
|
||||
size: slot.size,
|
||||
completedAt: slot.completed_time,
|
||||
seriesName: series.title,
|
||||
episodeInfo: sonarrMatch
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match with Radarr history
|
||||
const radarrMatch = radarrHistory.data.records.find(r =>
|
||||
r.title.toLowerCase().includes(nzbName) || nzbName.includes(r.title.toLowerCase())
|
||||
);
|
||||
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId);
|
||||
if (movie) {
|
||||
const userTag = extractUserTag(movie.tags);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
userDownloads.push({
|
||||
type: 'movie',
|
||||
title: slot.nzbname,
|
||||
status: slot.status,
|
||||
size: slot.size,
|
||||
completedAt: slot.completed_time,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch
|
||||
});
|
||||
// Try to match with Radarr history
|
||||
const radarrMatch = radarrHistory.data.records.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
userDownloads.push({
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(movie),
|
||||
status: slot.status,
|
||||
size: slot.size,
|
||||
completedAt: slot.completed_time,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Dashboard] Error processing history slot:`, err.message);
|
||||
console.error(`[Dashboard] History slot data:`, JSON.stringify(slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: show what queue records look like and which movies/series are tagged for this user
|
||||
console.log(`[Dashboard] Sonarr queue titles:`, sonarrQueue.data.records.map(r => ({ title: r.title, seriesId: r.seriesId })));
|
||||
console.log(`[Dashboard] Radarr queue titles:`, radarrQueue.data.records.map(r => ({ title: r.title, movieId: r.movieId })));
|
||||
console.log(`[Dashboard] Sonarr history titles:`, sonarrHistory.data.records.slice(0, 10).map(r => ({ title: r.title, seriesId: r.seriesId })));
|
||||
console.log(`[Dashboard] Radarr history titles:`, radarrHistory.data.records.slice(0, 10).map(r => ({ title: r.title, movieId: r.movieId })));
|
||||
console.log(`[Dashboard] Sonarr tag map:`, Array.from(sonarrTagMap.entries()));
|
||||
console.log(`[Dashboard] Radarr tag map:`, Array.from(radarrTagMap.entries()));
|
||||
|
||||
// Show movies/series tagged for this user
|
||||
const userMovies = radarrMovies.data.filter(m => {
|
||||
const tag = extractUserTag(m.tags, radarrTagMap);
|
||||
return tag && tag.toLowerCase() === username;
|
||||
});
|
||||
const userSeries = sonarrSeries.data.filter(s => {
|
||||
const tag = extractUserTag(s.tags, sonarrTagMap);
|
||||
return tag && tag.toLowerCase() === username;
|
||||
});
|
||||
console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.title));
|
||||
console.log(`[Dashboard] Series tagged for ${username}:`, userSeries.map(s => s.title));
|
||||
|
||||
// Process qBittorrent torrents - match to user-tagged Sonarr/Radarr activity
|
||||
console.log(`[Dashboard] Processing ${qbittorrentTorrents.length} qBittorrent torrents for user ${username}`);
|
||||
for (const torrent of qbittorrentTorrents) {
|
||||
try {
|
||||
const torrentName = torrent.name || '';
|
||||
const torrentNameLower = torrentName.toLowerCase();
|
||||
if (!torrentName) continue;
|
||||
|
||||
console.log(`[Dashboard] Checking torrent "${torrentName}"`);
|
||||
|
||||
// Try to match with Sonarr queue (user-tagged series)
|
||||
const sonarrMatch = sonarrQueue.data.records.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'series';
|
||||
download.coverArt = getCoverArt(series);
|
||||
download.seriesName = series.title;
|
||||
download.episodeInfo = sonarrMatch;
|
||||
download.userTag = userTag;
|
||||
userDownloads.push(download);
|
||||
continue; // Skip to next torrent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match with Radarr queue (user-tagged movies)
|
||||
const radarrMatch = radarrQueue.data.records.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'movie';
|
||||
download.coverArt = getCoverArt(movie);
|
||||
download.movieName = movie.title;
|
||||
download.movieInfo = radarrMatch;
|
||||
download.userTag = userTag;
|
||||
userDownloads.push(download);
|
||||
continue; // Skip to next torrent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match with Sonarr history
|
||||
const sonarrHistoryMatch = sonarrHistory.data.records.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
|
||||
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
||||
if (series) {
|
||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'series';
|
||||
download.coverArt = getCoverArt(series);
|
||||
download.seriesName = series.title;
|
||||
download.episodeInfo = sonarrHistoryMatch;
|
||||
download.userTag = userTag;
|
||||
userDownloads.push(download);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match with Radarr history
|
||||
const radarrHistoryMatch = radarrHistory.data.records.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
|
||||
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
||||
if (movie) {
|
||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
||||
if (userTag && userTag.toLowerCase() === username) {
|
||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'movie';
|
||||
download.coverArt = getCoverArt(movie);
|
||||
download.movieName = movie.title;
|
||||
download.movieInfo = radarrHistoryMatch;
|
||||
download.userTag = userTag;
|
||||
userDownloads.push(download);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Dashboard] Error processing torrent:`, err.message);
|
||||
console.error(`[Dashboard] Torrent data:`, JSON.stringify(torrent));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Dashboard] Found ${userDownloads.length} downloads for user ${username}`);
|
||||
console.log(`[Dashboard] Sending ${userDownloads.length} downloads`);
|
||||
if (userDownloads.length > 0) {
|
||||
console.log(`[Dashboard] First download:`, JSON.stringify(userDownloads[0]));
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: currentUser.Name,
|
||||
user: user.name,
|
||||
downloads: userDownloads
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Dashboard] Error fetching user downloads:`, error.message);
|
||||
console.error(`[Dashboard] Full error:`, error);
|
||||
res.status(500).json({ error: 'Failed to fetch user downloads', details: error.message });
|
||||
}
|
||||
});
|
||||
@@ -196,21 +573,43 @@ router.get('/user-downloads/:sessionId', async (req, res) => {
|
||||
// Get all users with their download counts
|
||||
router.get('/user-summary', async (req, res) => {
|
||||
try {
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
// Get all Emby users
|
||||
const usersResponse = await axios.get(`${EMBY_URL}/Users`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
});
|
||||
|
||||
// Get all series and movies with tags
|
||||
const [sonarrSeries, radarrMovies] = await Promise.all([
|
||||
axios.get(`${SONARR_URL}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||
}).catch(() => ({ data: [] })),
|
||||
axios.get(`${RADARR_URL}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||
}).catch(() => ({ data: [] }))
|
||||
// Get all series, movies, and tags from all instances
|
||||
const [sonarrSeriesResults, sonarrTagsResults, radarrMoviesResults, radarrTagsResults] = await Promise.all([
|
||||
Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(r => r.data).catch(() => [])
|
||||
)),
|
||||
Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(r => r.data).catch(() => [])
|
||||
)),
|
||||
Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(r => r.data).catch(() => [])
|
||||
)),
|
||||
Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(r => r.data).catch(() => [])
|
||||
))
|
||||
]);
|
||||
|
||||
const allSeries = sonarrSeriesResults.flat();
|
||||
const sonarrTagMap = new Map(sonarrTagsResults.flat().map(t => [t.id, t.label]));
|
||||
const allMovies = radarrMoviesResults.flat();
|
||||
const radarrTagMap = new Map(radarrTagsResults.flat().map(t => [t.id, t.label]));
|
||||
|
||||
// Count downloads per user
|
||||
const userDownloads = {};
|
||||
usersResponse.data.forEach(user => {
|
||||
@@ -222,8 +621,8 @@ router.get('/user-summary', async (req, res) => {
|
||||
});
|
||||
|
||||
// Process series tags
|
||||
sonarrSeries.data.forEach(series => {
|
||||
const userTag = extractUserTag(series.tags);
|
||||
allSeries.forEach(series => {
|
||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
||||
if (userTag) {
|
||||
const username = userTag.toLowerCase();
|
||||
if (userDownloads[username]) {
|
||||
@@ -233,8 +632,8 @@ router.get('/user-summary', async (req, res) => {
|
||||
});
|
||||
|
||||
// Process movie tags
|
||||
radarrMovies.data.forEach(movie => {
|
||||
const userTag = extractUserTag(movie.tags);
|
||||
allMovies.forEach(movie => {
|
||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
||||
if (userTag) {
|
||||
const username = userTag.toLowerCase();
|
||||
if (userDownloads[username]) {
|
||||
|
||||
78
server/utils/config.js
Normal file
78
server/utils/config.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPassword) {
|
||||
// Try to parse JSON array format first
|
||||
if (envVar) {
|
||||
try {
|
||||
// Handle multi-line JSON by removing newlines and extra spaces
|
||||
const cleaned = envVar.replace(/\s+/g, ' ').trim();
|
||||
const instances = JSON.parse(cleaned);
|
||||
if (Array.isArray(instances) && instances.length > 0) {
|
||||
logToFile(`[Config] Parsed ${instances.length} instances from JSON array`);
|
||||
return instances.map((inst, idx) => ({
|
||||
...inst,
|
||||
id: inst.name || `instance-${idx + 1}`
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
logToFile(`[Config] Failed to parse JSON array: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to legacy single-instance format
|
||||
if (legacyUrl && legacyKey) {
|
||||
logToFile(`[Config] Using legacy single-instance format`);
|
||||
return [{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
url: legacyUrl,
|
||||
apiKey: legacyKey,
|
||||
username: legacyUsername,
|
||||
password: legacyPassword
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getSABnzbdInstances() {
|
||||
return parseInstances(
|
||||
process.env.SABNZBD_INSTANCES,
|
||||
process.env.SABNZBD_URL,
|
||||
process.env.SABNZBD_API_KEY
|
||||
);
|
||||
}
|
||||
|
||||
function getSonarrInstances() {
|
||||
return parseInstances(
|
||||
process.env.SONARR_INSTANCES,
|
||||
process.env.SONARR_URL,
|
||||
process.env.SONARR_API_KEY
|
||||
);
|
||||
}
|
||||
|
||||
function getRadarrInstances() {
|
||||
return parseInstances(
|
||||
process.env.RADARR_INSTANCES,
|
||||
process.env.RADARR_URL,
|
||||
process.env.RADARR_API_KEY
|
||||
);
|
||||
}
|
||||
|
||||
function getQbittorrentInstances() {
|
||||
return parseInstances(
|
||||
process.env.QBITTORRENT_INSTANCES,
|
||||
process.env.QBITTORRENT_URL,
|
||||
null, // no apiKey for qBittorrent
|
||||
process.env.QBITTORRENT_USERNAME,
|
||||
process.env.QBITTORRENT_PASSWORD
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSABnzbdInstances,
|
||||
getSonarrInstances,
|
||||
getRadarrInstances,
|
||||
getQbittorrentInstances,
|
||||
parseInstances
|
||||
};
|
||||
12
server/utils/logger.js
Normal file
12
server/utils/logger.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const logFile = fs.createWriteStream(path.join(__dirname, '../../server.log'), { flags: 'a' });
|
||||
|
||||
function logToFile(message) {
|
||||
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
logToFile
|
||||
};
|
||||
212
server/utils/qbittorrent.js
Normal file
212
server/utils/qbittorrent.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const axios = require('axios');
|
||||
const { logToFile } = require('./logger');
|
||||
const { getQbittorrentInstances } = require('./config');
|
||||
|
||||
class QBittorrentClient {
|
||||
constructor(instance) {
|
||||
this.id = instance.id;
|
||||
this.name = instance.name;
|
||||
this.url = instance.url;
|
||||
this.username = instance.username;
|
||||
this.password = instance.password;
|
||||
this.authCookie = null;
|
||||
}
|
||||
|
||||
async login() {
|
||||
try {
|
||||
logToFile(`[qBittorrent:${this.name}] Attempting login...`);
|
||||
const response = await axios.post(`${this.url}/api/v2/auth/login`,
|
||||
`username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 400
|
||||
}
|
||||
);
|
||||
|
||||
if (response.headers['set-cookie']) {
|
||||
this.authCookie = response.headers['set-cookie'][0];
|
||||
logToFile(`[qBittorrent:${this.name}] Login successful`);
|
||||
return true;
|
||||
}
|
||||
|
||||
logToFile(`[qBittorrent:${this.name}] Login failed - no cookie`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
logToFile(`[qBittorrent:${this.name}] Login error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async makeRequest(endpoint, config = {}) {
|
||||
const url = `${this.url}${endpoint}`;
|
||||
|
||||
if (!this.authCookie) {
|
||||
const loggedIn = await this.login();
|
||||
if (!loggedIn) {
|
||||
throw new Error(`Failed to authenticate with ${this.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
...config,
|
||||
headers: {
|
||||
...config.headers,
|
||||
'Cookie': this.authCookie
|
||||
}
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
// If unauthorized, try re-authenticating once
|
||||
if (error.response && error.response.status === 403) {
|
||||
logToFile(`[qBittorrent:${this.name}] Auth expired, re-authenticating...`);
|
||||
this.authCookie = null;
|
||||
const loggedIn = await this.login();
|
||||
if (loggedIn) {
|
||||
return axios.get(url, {
|
||||
...config,
|
||||
headers: {
|
||||
...config.headers,
|
||||
'Cookie': this.authCookie
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTorrents() {
|
||||
try {
|
||||
const response = await this.makeRequest('/api/v2/torrents/info');
|
||||
logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents`);
|
||||
// Add instance info to each torrent
|
||||
return response.data.map(torrent => ({
|
||||
...torrent,
|
||||
instanceId: this.id,
|
||||
instanceName: this.name
|
||||
}));
|
||||
} catch (error) {
|
||||
logToFile(`[qBittorrent:${this.name}] Error fetching torrents: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getClients() {
|
||||
const instances = getQbittorrentInstances();
|
||||
if (instances.length === 0) {
|
||||
logToFile('[qBittorrent] No instances configured');
|
||||
return [];
|
||||
}
|
||||
logToFile(`[qBittorrent] Created ${instances.length} client(s)`);
|
||||
return instances.map(inst => new QBittorrentClient(inst));
|
||||
}
|
||||
|
||||
async function getAllTorrents() {
|
||||
const clients = getClients();
|
||||
if (clients.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
clients.map(client => client.getTorrents().catch(err => {
|
||||
logToFile(`[qBittorrent] Error from ${client.name}: ${err.message}`);
|
||||
return [];
|
||||
}))
|
||||
);
|
||||
|
||||
const allTorrents = results.flat();
|
||||
logToFile(`[qBittorrent] Total torrents from all instances: ${allTorrents.length}`);
|
||||
return allTorrents;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (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];
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSecond) {
|
||||
return formatBytes(bytesPerSecond) + '/s';
|
||||
}
|
||||
|
||||
function formatEta(seconds) {
|
||||
if (seconds < 0 || seconds === 8640000) return '∞'; // qBittorrent uses 8640000 for unknown
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function mapTorrentToDownload(torrent) {
|
||||
const totalSize = torrent.size;
|
||||
const downloadedSize = torrent.completed;
|
||||
const progress = torrent.progress * 100;
|
||||
|
||||
// Map qBittorrent states to our status
|
||||
const stateMap = {
|
||||
'downloading': 'Downloading',
|
||||
'stalledDL': 'Downloading',
|
||||
'metaDL': 'Downloading',
|
||||
'forcedDL': 'Downloading',
|
||||
'allocating': 'Downloading',
|
||||
'uploading': 'Seeding',
|
||||
'stalledUP': 'Seeding',
|
||||
'forcedUP': 'Seeding',
|
||||
'queuedUP': 'Queued',
|
||||
'queuedDL': 'Queued',
|
||||
'checkingUP': 'Checking',
|
||||
'checkingDL': 'Checking',
|
||||
'checkingResumeData': 'Checking',
|
||||
'moving': 'Moving',
|
||||
'pausedUP': 'Paused',
|
||||
'pausedDL': 'Paused',
|
||||
'stoppedUP': 'Stopped',
|
||||
'stoppedDL': 'Stopped',
|
||||
'error': 'Error',
|
||||
'missingFiles': 'Error',
|
||||
'unknown': 'Unknown'
|
||||
};
|
||||
|
||||
const status = stateMap[torrent.state] || torrent.state;
|
||||
|
||||
return {
|
||||
type: 'torrent',
|
||||
title: torrent.name,
|
||||
instanceName: torrent.instanceName,
|
||||
status: status,
|
||||
progress: progress.toFixed(1),
|
||||
size: formatBytes(totalSize),
|
||||
rawSize: totalSize,
|
||||
rawDownloaded: downloadedSize,
|
||||
speed: formatSpeed(torrent.dlspeed),
|
||||
rawSpeed: torrent.dlspeed,
|
||||
eta: formatEta(torrent.eta),
|
||||
rawEta: torrent.eta,
|
||||
seeds: torrent.num_seeds,
|
||||
peers: torrent.num_leechs,
|
||||
availability: (torrent.availability * 100).toFixed(1),
|
||||
hash: torrent.hash,
|
||||
category: torrent.category,
|
||||
tags: torrent.tags,
|
||||
qbittorrent: true
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTorrents: getAllTorrents,
|
||||
getClients,
|
||||
mapTorrentToDownload,
|
||||
formatBytes,
|
||||
formatSpeed,
|
||||
formatEta
|
||||
};
|
||||
Reference in New Issue
Block a user