All checks were successful
Build and Push Docker Image / build (push) Successful in 28s
Server:
- Add getEmbyUsers(): fetches all Emby users, builds Map of
lowercase/sanitized name -> display name, cached 60s
- Add buildTagBadges(allTags, embyUserMap): classifies each tag
as { label, matchedUser: displayName|null } against the full
Emby user database
- Attach tagBadges[] to every download object when showAll=true
(all 10 construction sites across SABnzbd queue/history and
qBittorrent queue/history blocks)
- matchedUserTag still set to the tag matching the *current* user
for the non-showAll badge
Frontend:
- showAll mode: renders tagBadges[] — unmatched tags (no Emby user)
amber leftmost, matched tags show Emby display name in accent
colour rightmost
- Normal mode: renders matchedUserTag badge only (current user's tag)
735 lines
31 KiB
JavaScript
735 lines
31 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
|
|
const axios = require('axios');
|
|
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
|
const cache = require('../utils/cache');
|
|
const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
|
const { 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 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;
|
|
}
|
|
|
|
// Return all resolved tag labels for a series/movie.
|
|
// For Radarr: tags is array of IDs, tagMap is id -> label mapping.
|
|
// For Sonarr: tags are objects with a label property.
|
|
function extractAllTags(tags, tagMap) {
|
|
if (!tags || tags.length === 0) return [];
|
|
if (tagMap) {
|
|
return tags.map(id => tagMap.get(id)).filter(Boolean);
|
|
}
|
|
return tags.map(t => t && t.label).filter(Boolean);
|
|
}
|
|
|
|
// Return the tag label that matches the current username, or null.
|
|
function extractUserTag(tags, tagMap, username) {
|
|
const allLabels = extractAllTags(tags, tagMap);
|
|
if (!allLabels.length) return null;
|
|
if (username) {
|
|
const match = allLabels.find(label => tagMatchesUser(label, username));
|
|
if (match) return match;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
|
|
function sanitizeTagLabel(input) {
|
|
if (!input) return '';
|
|
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
// Check if a tag matches the username: exact match first, then sanitized match
|
|
function tagMatchesUser(tag, username) {
|
|
if (!tag || !username) return false;
|
|
const tagLower = tag.toLowerCase();
|
|
// Exact match (handles users whose tags weren't mangled)
|
|
if (tagLower === username) return true;
|
|
// Sanitized match (handles Ombi-mangled tags for email-style usernames)
|
|
if (tagLower === sanitizeTagLabel(username)) return true;
|
|
return false;
|
|
}
|
|
|
|
// Extract import issues from a Sonarr/Radarr queue record
|
|
function getImportIssues(queueRecord) {
|
|
if (!queueRecord) return null;
|
|
const state = queueRecord.trackedDownloadState;
|
|
const status = queueRecord.trackedDownloadStatus;
|
|
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
|
|
const messages = [];
|
|
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
|
|
for (const sm of queueRecord.statusMessages) {
|
|
if (sm.messages && sm.messages.length > 0) {
|
|
messages.push(...sm.messages);
|
|
} else if (sm.title) {
|
|
messages.push(sm.title);
|
|
}
|
|
}
|
|
}
|
|
if (queueRecord.errorMessage) {
|
|
messages.push(queueRecord.errorMessage);
|
|
}
|
|
if (messages.length === 0) return null;
|
|
return messages;
|
|
}
|
|
|
|
// Helper to build Sonarr web UI link for a series
|
|
function getSonarrLink(series) {
|
|
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
|
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
|
}
|
|
|
|
// Helper to build Radarr web UI link for a movie
|
|
function getRadarrLink(movie) {
|
|
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
|
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
|
}
|
|
|
|
// Fetch all Emby users and return a Map<lowerName -> displayName> (and sanitized variants).
|
|
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
|
|
async function getEmbyUsers() {
|
|
const cached = cache.get('emby:users');
|
|
if (cached) return cached;
|
|
try {
|
|
const response = await axios.get(`${EMBY_URL}/Users`, {
|
|
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
|
});
|
|
// Build map: both raw lowercase and sanitized form -> display name
|
|
const map = new Map();
|
|
for (const u of response.data) {
|
|
const name = u.Name || '';
|
|
map.set(name.toLowerCase(), name);
|
|
map.set(sanitizeTagLabel(name), name);
|
|
}
|
|
cache.set('emby:users', map, 60000);
|
|
return map;
|
|
} catch (err) {
|
|
console.error('[Dashboard] Failed to fetch Emby users:', err.message);
|
|
return new Map();
|
|
}
|
|
}
|
|
|
|
// Classify each tag label: matched to a known Emby user, or unmatched.
|
|
// Returns array of { label, matchedUser: string|null }
|
|
function buildTagBadges(allTags, embyUserMap) {
|
|
return allTags.map(label => {
|
|
const lower = label.toLowerCase();
|
|
const sanitized = sanitizeTagLabel(label);
|
|
const displayName = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
|
return { label, matchedUser: displayName };
|
|
});
|
|
}
|
|
|
|
// Track active dashboard clients: Map<username, { refreshRateMs, lastSeen }>
|
|
const activeClients = new Map();
|
|
const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests
|
|
|
|
function getActiveClients() {
|
|
const now = Date.now();
|
|
// Prune stale clients
|
|
for (const [key, client] of activeClients.entries()) {
|
|
if (now - client.lastSeen > CLIENT_STALE_MS) activeClients.delete(key);
|
|
}
|
|
return Array.from(activeClients.values());
|
|
}
|
|
|
|
// Get user downloads for authenticated user
|
|
router.get('/user-downloads', async (req, res) => {
|
|
try {
|
|
// Get authenticated user from cookie
|
|
const userCookie = req.cookies.emby_user;
|
|
if (!userCookie) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const user = JSON.parse(userCookie);
|
|
const username = user.name.toLowerCase();
|
|
const usernameSanitized = sanitizeTagLabel(user.name);
|
|
const isAdmin = !!user.isAdmin;
|
|
const showAll = isAdmin && req.query.showAll === 'true';
|
|
console.log(`[Dashboard] Serving downloads for user: ${user.name} (${username}), isAdmin: ${isAdmin}, showAll: ${showAll}`);
|
|
|
|
// Track this client's refresh rate
|
|
const clientRefreshRate = parseInt(req.query.refreshRate, 10);
|
|
if (clientRefreshRate > 0) {
|
|
activeClients.set(username, { user: user.name, refreshRateMs: clientRefreshRate, lastSeen: Date.now() });
|
|
} else {
|
|
// Client has refresh off or didn't send — still mark as seen but with no rate
|
|
activeClients.set(username, { user: user.name, refreshRateMs: 0, lastSeen: Date.now() });
|
|
}
|
|
|
|
// When polling is disabled, fetch on-demand if cache has expired
|
|
// The fetched data is cached (30s TTL) so subsequent requests from any user reuse it
|
|
if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) {
|
|
console.log(`[Dashboard] Cache expired and polling disabled, fetching on-demand...`);
|
|
await pollAllServices();
|
|
}
|
|
|
|
// Read all data from cache
|
|
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
|
|
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
|
|
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
|
|
const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] };
|
|
const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] };
|
|
const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] };
|
|
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
|
|
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
|
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
|
|
|
|
// Wrap in the structure the rest of the code expects
|
|
const sabnzbdQueue = { data: { queue: sabQueueData } };
|
|
const sabnzbdHistory = { data: { history: sabHistoryData } };
|
|
const sonarrQueue = { data: sonarrQueueData };
|
|
const sonarrHistory = { data: sonarrHistoryData };
|
|
const radarrQueue = { data: radarrQueueData };
|
|
const radarrHistory = { data: radarrHistoryData };
|
|
const radarrTags = { data: radarrTagsData };
|
|
|
|
// Build series/movie maps from embedded objects in queue records
|
|
// (history is fetched without includeSeries/includeMovie for speed;
|
|
// history matches fall back to the queue-built map via seriesId/movieId)
|
|
const seriesMap = new Map();
|
|
for (const r of sonarrQueue.data.records) {
|
|
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
|
}
|
|
for (const r of sonarrHistory.data.records) {
|
|
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
|
}
|
|
const moviesMap = new Map();
|
|
for (const r of radarrQueue.data.records) {
|
|
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
|
}
|
|
for (const r of radarrHistory.data.records) {
|
|
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
|
}
|
|
|
|
// 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]));
|
|
|
|
// When showing all downloads, fetch full Emby user list to classify tags
|
|
const embyUserMap = showAll ? await getEmbyUsers() : new Map();
|
|
|
|
console.log(`[Dashboard] Cache data - Series: ${seriesMap.size}, Movies: ${moviesMap.size}, qBit: ${qbittorrentTorrents.length}`);
|
|
|
|
// 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 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 allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
const dlObj = {
|
|
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,
|
|
allTags,
|
|
matchedUserTag: matchedUserTag || null,
|
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
|
};
|
|
const issues = getImportIssues(sonarrMatch);
|
|
if (issues) dlObj.importIssues = issues;
|
|
if (isAdmin) {
|
|
dlObj.downloadPath = slot.storage || null;
|
|
dlObj.targetPath = series.path || null;
|
|
dlObj.arrLink = getSonarrLink(series);
|
|
}
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
const dlObj = {
|
|
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,
|
|
allTags,
|
|
matchedUserTag: matchedUserTag || null,
|
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
|
};
|
|
const issues = getImportIssues(radarrMatch);
|
|
if (issues) dlObj.importIssues = issues;
|
|
if (isAdmin) {
|
|
dlObj.downloadPath = slot.storage || null;
|
|
dlObj.targetPath = movie.path || null;
|
|
dlObj.arrLink = getRadarrLink(movie);
|
|
}
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`[Dashboard] Error processing slot:`, err.message);
|
|
console.error(`[Dashboard] Slot data:`, JSON.stringify(slot));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process SABnzbd history
|
|
if (sabnzbdHistory.data.history && sabnzbdHistory.data.history.slots) {
|
|
for (const slot of sabnzbdHistory.data.history.slots) {
|
|
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 allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
const dlObj = {
|
|
type: 'series',
|
|
title: nzbName,
|
|
coverArt: getCoverArt(series),
|
|
status: slot.status,
|
|
size: slot.size,
|
|
completedAt: slot.completed_time,
|
|
seriesName: series.title,
|
|
episodeInfo: sonarrMatch,
|
|
allTags,
|
|
matchedUserTag: matchedUserTag || null,
|
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
|
};
|
|
if (isAdmin) {
|
|
dlObj.downloadPath = slot.storage || null;
|
|
dlObj.targetPath = series.path || null;
|
|
dlObj.arrLink = getSonarrLink(series);
|
|
}
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
const dlObj = {
|
|
type: 'movie',
|
|
title: nzbName,
|
|
coverArt: getCoverArt(movie),
|
|
status: slot.status,
|
|
size: slot.size,
|
|
completedAt: slot.completed_time,
|
|
movieName: movie.title,
|
|
movieInfo: radarrMatch,
|
|
allTags,
|
|
matchedUserTag: matchedUserTag || null,
|
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
|
};
|
|
if (isAdmin) {
|
|
dlObj.downloadPath = slot.storage || null;
|
|
dlObj.targetPath = movie.path || null;
|
|
dlObj.arrLink = getRadarrLink(movie);
|
|
}
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
} 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 (from embedded objects in queue/history)
|
|
const userMovies = Array.from(moviesMap.values()).filter(m => {
|
|
return !!extractUserTag(m.tags, radarrTagMap, username);
|
|
});
|
|
const userSeries = Array.from(seriesMap.values()).filter(s => {
|
|
return !!extractUserTag(s.tags, sonarrTagMap, 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 allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
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.allTags = allTags;
|
|
download.matchedUserTag = matchedUserTag || null;
|
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
|
const sonarrIssues = getImportIssues(sonarrMatch);
|
|
if (sonarrIssues) download.importIssues = sonarrIssues;
|
|
if (isAdmin) {
|
|
download.downloadPath = download.savePath || null;
|
|
download.targetPath = series.path || null;
|
|
download.arrLink = getSonarrLink(series);
|
|
}
|
|
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 allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
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.allTags = allTags;
|
|
download.matchedUserTag = matchedUserTag || null;
|
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
|
const radarrIssues = getImportIssues(radarrMatch);
|
|
if (radarrIssues) download.importIssues = radarrIssues;
|
|
if (isAdmin) {
|
|
download.downloadPath = download.savePath || null;
|
|
download.targetPath = movie.path || null;
|
|
download.arrLink = getRadarrLink(movie);
|
|
}
|
|
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 allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
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.allTags = allTags;
|
|
download.matchedUserTag = matchedUserTag || null;
|
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
|
if (isAdmin) {
|
|
download.downloadPath = download.savePath || null;
|
|
download.targetPath = series.path || null;
|
|
download.arrLink = getSonarrLink(series);
|
|
}
|
|
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 allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
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.allTags = allTags;
|
|
download.matchedUserTag = matchedUserTag || null;
|
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
|
if (isAdmin) {
|
|
download.downloadPath = download.savePath || null;
|
|
download.targetPath = movie.path || null;
|
|
download.arrLink = getRadarrLink(movie);
|
|
}
|
|
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: user.name,
|
|
isAdmin: isAdmin,
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// 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, 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 => {
|
|
userDownloads[user.Name.toLowerCase()] = {
|
|
username: user.Name,
|
|
seriesCount: 0,
|
|
movieCount: 0
|
|
};
|
|
});
|
|
|
|
// Process series tags
|
|
allSeries.forEach(series => {
|
|
const tags = extractAllTags(series.tags, sonarrTagMap);
|
|
tags.forEach(userTag => {
|
|
const uname = userTag.toLowerCase();
|
|
if (userDownloads[uname]) userDownloads[uname].seriesCount++;
|
|
});
|
|
});
|
|
|
|
// Process movie tags
|
|
allMovies.forEach(movie => {
|
|
const tags = extractAllTags(movie.tags, radarrTagMap);
|
|
tags.forEach(userTag => {
|
|
const uname = userTag.toLowerCase();
|
|
if (userDownloads[uname]) userDownloads[uname].movieCount++;
|
|
});
|
|
});
|
|
|
|
res.json(Object.values(userDownloads));
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch user summary', details: error.message });
|
|
}
|
|
});
|
|
|
|
// Admin-only status page with cache stats
|
|
router.get('/status', (req, res) => {
|
|
try {
|
|
const userCookie = req.cookies.emby_user;
|
|
if (!userCookie) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
const user = JSON.parse(userCookie);
|
|
if (!user.isAdmin) {
|
|
return res.status(403).json({ error: 'Admin access required' });
|
|
}
|
|
|
|
const cacheStats = cache.getStats();
|
|
const uptime = process.uptime();
|
|
|
|
res.json({
|
|
server: {
|
|
uptimeSeconds: Math.floor(uptime),
|
|
nodeVersion: process.version,
|
|
memoryUsageMB: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10,
|
|
heapUsedMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024 * 10) / 10,
|
|
heapTotalMB: Math.round(process.memoryUsage().heapTotal / 1024 / 1024 * 10) / 10
|
|
},
|
|
polling: {
|
|
enabled: POLLING_ENABLED,
|
|
intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0,
|
|
lastPoll: getLastPollTimings()
|
|
},
|
|
cache: cacheStats,
|
|
clients: getActiveClients()
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to get status', details: err.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|