Files
lidify/backend/src/routes/notifications.ts
T
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features:
- Multi-source download system (Soulseek/Lidarr with fallback)
- Configurable enrichment speed control (1-5x)
- Mobile touch drag support for seek sliders
- iOS PWA media controls (Control Center, Lock Screen)
- Artist name alias resolution via Last.fm
- Circuit breaker pattern for audio analysis

Critical Fixes:
- Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM)
- Discovery system race conditions and import failures
- Radio decade categorization using originalYear
- LastFM API response normalization
- Mood bucket infinite loop prevention

Security:
- Bull Board admin authentication
- Lidarr webhook signature verification
- JWT token expiration and refresh
- Encryption key validation on startup

Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
2026-01-06 20:07:33 -06:00

724 lines
27 KiB
TypeScript

import { Router, Request, Response } from "express";
import { logger } from "../utils/logger";
import { notificationService } from "../services/notificationService";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
const router = Router();
/**
* GET /notifications
* Get all uncleared notifications for the current user
*/
router.get(
"/",
requireAuth,
async (req: Request, res: Response) => {
try {
logger.debug(
`[Notifications] Fetching notifications for user ${
req.user!.id
}`
);
const notifications = await notificationService.getForUser(
req.user!.id
);
logger.debug(
`[Notifications] Found ${notifications.length} notifications`
);
res.json(notifications);
} catch (error: any) {
logger.error("Error fetching notifications:", error);
res.status(500).json({ error: "Failed to fetch notifications" });
}
}
);
/**
* GET /notifications/unread-count
* Get count of unread notifications
*/
router.get(
"/unread-count",
requireAuth,
async (req: Request, res: Response) => {
try {
const count = await notificationService.getUnreadCount(
req.user!.id
);
res.json({ count });
} catch (error: any) {
logger.error("Error fetching unread count:", error);
res.status(500).json({ error: "Failed to fetch unread count" });
}
}
);
/**
* POST /notifications/:id/read
* Mark a notification as read
*/
router.post(
"/:id/read",
requireAuth,
async (req: Request, res: Response) => {
try {
await notificationService.markAsRead(req.params.id, req.user!.id);
res.json({ success: true });
} catch (error: any) {
logger.error("Error marking notification as read:", error);
res.status(500).json({
error: "Failed to mark notification as read",
});
}
}
);
/**
* POST /notifications/read-all
* Mark all notifications as read
*/
router.post(
"/read-all",
requireAuth,
async (req: Request, res: Response) => {
try {
await notificationService.markAllAsRead(req.user!.id);
res.json({ success: true });
} catch (error: any) {
logger.error("Error marking all notifications as read:", error);
res.status(500).json({
error: "Failed to mark all notifications as read",
});
}
}
);
/**
* POST /notifications/:id/clear
* Clear (dismiss) a notification
*/
router.post(
"/:id/clear",
requireAuth,
async (req: Request, res: Response) => {
try {
await notificationService.clear(req.params.id, req.user!.id);
res.json({ success: true });
} catch (error: any) {
logger.error("Error clearing notification:", error);
res.status(500).json({ error: "Failed to clear notification" });
}
}
);
/**
* POST /notifications/clear-all
* Clear all notifications
*/
router.post(
"/clear-all",
requireAuth,
async (req: Request, res: Response) => {
try {
await notificationService.clearAll(req.user!.id);
res.json({ success: true });
} catch (error: any) {
logger.error("Error clearing all notifications:", error);
res.status(500).json({
error: "Failed to clear all notifications",
});
}
}
);
// ============================================
// Download History Endpoints
// ============================================
/**
* GET /notifications/downloads/history
* Get completed/failed downloads that haven't been cleared
* Deduplicated by album subject (shows only most recent entry per album)
*/
router.get(
"/downloads/history",
requireAuth,
async (req: Request, res: Response) => {
try {
const downloads = await prisma.downloadJob.findMany({
where: {
userId: req.user!.id,
status: { in: ["completed", "failed", "exhausted"] },
cleared: false,
},
orderBy: { updatedAt: "desc" },
take: 100, // Fetch more to account for duplicates
});
// Deduplicate by subject - keep only the most recent entry per album
const seen = new Set<string>();
const deduplicated = downloads.filter((download) => {
if (seen.has(download.subject)) {
return false; // Skip duplicate
}
seen.add(download.subject);
return true; // Keep first occurrence (most recent due to ordering)
});
// Return top 50 after deduplication
res.json(deduplicated.slice(0, 50));
} catch (error: any) {
logger.error("Error fetching download history:", error);
res.status(500).json({ error: "Failed to fetch download history" });
}
}
);
/**
* GET /notifications/downloads/active
* Get active downloads (pending/processing)
*/
router.get(
"/downloads/active",
requireAuth,
async (req: Request, res: Response) => {
try {
const downloads = await prisma.downloadJob.findMany({
where: {
userId: req.user!.id,
status: { in: ["pending", "processing"] },
},
orderBy: { createdAt: "desc" },
});
res.json(downloads);
} catch (error: any) {
logger.error("Error fetching active downloads:", error);
res.status(500).json({ error: "Failed to fetch active downloads" });
}
}
);
/**
* POST /notifications/downloads/:id/clear
* Clear a download from history
*/
router.post(
"/downloads/:id/clear",
requireAuth,
async (req: Request, res: Response) => {
try {
await prisma.downloadJob.updateMany({
where: {
id: req.params.id,
userId: req.user!.id,
},
data: { cleared: true },
});
res.json({ success: true });
} catch (error: any) {
logger.error("Error clearing download:", error);
res.status(500).json({ error: "Failed to clear download" });
}
}
);
/**
* POST /notifications/downloads/clear-all
* Clear all completed/failed downloads from history
*/
router.post(
"/downloads/clear-all",
requireAuth,
async (req: Request, res: Response) => {
try {
await prisma.downloadJob.updateMany({
where: {
userId: req.user!.id,
status: { in: ["completed", "failed", "exhausted"] },
cleared: false,
},
data: { cleared: true },
});
res.json({ success: true });
} catch (error: any) {
logger.error("Error clearing all downloads:", error);
res.status(500).json({ error: "Failed to clear all downloads" });
}
}
);
/**
* POST /notifications/downloads/:id/retry
* Retry a failed download
*/
router.post(
"/downloads/:id/retry",
requireAuth,
async (req: Request, res: Response) => {
try {
// Get the failed download
const failedJob = await prisma.downloadJob.findFirst({
where: {
id: req.params.id,
userId: req.user!.id,
status: { in: ["failed", "exhausted"] },
},
});
if (!failedJob) {
return res
.status(404)
.json({ error: "Download not found or not failed" });
}
// If this was a pending-track retry job, re-run the pending-track retry flow
const metadata = failedJob.metadata as Record<
string,
unknown
> | null;
if (metadata?.downloadType === "pending-track-retry") {
const playlistId = metadata.playlistId as string | undefined;
const pendingTrackId = metadata.pendingTrackId as
| string
| undefined;
if (!playlistId || !pendingTrackId) {
return res.status(400).json({
error: "Cannot retry: missing playlistId or pendingTrackId",
});
}
// Mark old job as cleared
await prisma.downloadJob.update({
where: { id: failedJob.id },
data: { cleared: true },
});
// Validate playlist ownership and pending track exists
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist || playlist.userId !== req.user!.id) {
return res
.status(404)
.json({ error: "Playlist not found" });
}
const pendingTrack =
await prisma.playlistPendingTrack.findUnique({
where: { id: pendingTrackId },
});
if (!pendingTrack) {
return res
.status(404)
.json({ error: "Pending track not found" });
}
const retryTargetId =
pendingTrack.albumMbid ||
pendingTrack.artistMbid ||
`pendingTrack:${pendingTrack.id}`;
const newJobRecord = await prisma.downloadJob.create({
data: {
userId: req.user!.id,
subject: `${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`,
type: "track",
targetMbid: retryTargetId,
artistMbid: pendingTrack.artistMbid,
status: "processing",
attempts: 1,
startedAt: new Date(),
metadata: {
downloadType: "pending-track-retry",
source: "soulseek",
playlistId,
pendingTrackId,
spotifyArtist: pendingTrack.spotifyArtist,
spotifyTitle: pendingTrack.spotifyTitle,
spotifyAlbum: pendingTrack.spotifyAlbum,
albumMbid: pendingTrack.albumMbid,
},
},
});
const { soulseekService } = await import(
"../services/soulseek"
);
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.musicPath) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "Music path not configured",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "Music path not configured",
});
}
if (
!settings?.soulseekUsername ||
!settings?.soulseekPassword
) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "Soulseek credentials not configured",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "Soulseek credentials not configured",
});
}
const albumName =
pendingTrack.spotifyAlbum !== "Unknown Album"
? pendingTrack.spotifyAlbum
: pendingTrack.spotifyArtist;
const searchResult = await soulseekService.searchTrack(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle
);
if (
!searchResult.found ||
searchResult.allMatches.length === 0
) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "No matching files found",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "No matching files found",
});
}
// Start download in background (don't await)
soulseekService
.downloadBestMatch(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle,
albumName,
searchResult.allMatches,
settings.musicPath
)
.then(async (result) => {
if (result.success) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "completed",
completedAt: new Date(),
metadata: {
...(newJobRecord.metadata as any),
filePath: result.filePath,
},
},
});
try {
const { scanQueue } = await import(
"../workers/queues"
);
await scanQueue.add(
"scan",
{
userId: req.user!.id,
source: "retry-pending-track",
albumMbid:
pendingTrack.albumMbid || undefined,
artistMbid:
pendingTrack.artistMbid ||
undefined,
},
{
priority: 1,
removeOnComplete: true,
}
);
} catch {
// Best-effort; job status already reflects download
}
} else {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: result.error || "Download failed",
completedAt: new Date(),
},
});
}
})
.catch(async (error) => {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: error?.message || "Download exception",
completedAt: new Date(),
},
});
});
return res.json({ success: true, newJobId: newJobRecord.id });
}
// If this was a spotify_import job, retry with Soulseek first
if (metadata?.downloadType === "spotify_import") {
const artistName = metadata.artistName as string;
const albumTitle = metadata.albumTitle as string;
if (!artistName || !albumTitle) {
return res.status(400).json({
error: "Cannot retry: missing artist/album info",
});
}
// Mark old job as cleared
await prisma.downloadJob.update({
where: { id: failedJob.id },
data: { cleared: true },
});
// Create a NEW download job record for the retry
const newJobRecord = await prisma.downloadJob.create({
data: {
userId: req.user!.id,
type: "album",
targetMbid:
failedJob.targetMbid || `retry_${Date.now()}`,
artistMbid: failedJob.artistMbid,
subject: `${artistName} - ${albumTitle}`,
status: "processing",
attempts: 1,
startedAt: new Date(),
metadata: {
...metadata,
retryAttempt: true,
},
},
});
// Try Soulseek first (async)
const { soulseekService } = await import(
"../services/soulseek"
);
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
const musicPath = settings?.musicPath;
if (!musicPath) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "Music path not configured",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "Music path not configured",
});
}
// Build track from album info (single track search using album as title)
const tracks = [
{
artist: artistName,
title: albumTitle,
album: albumTitle,
},
];
logger.debug(
`[Retry] Trying Soulseek for ${artistName} - ${albumTitle}`
);
// Run Soulseek search async
soulseekService
.searchAndDownloadBatch(tracks, musicPath, settings?.soulseekConcurrentDownloads || 4)
.then(async (result) => {
if (result.successful > 0) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "completed",
completedAt: new Date(),
error: null,
metadata: {
...metadata,
source: "soulseek",
tracksDownloaded: result.successful,
files: result.files,
},
},
});
logger.debug(
`[Retry] ✓ Soulseek downloaded ${result.successful} tracks for ${artistName} - ${albumTitle}`
);
// Trigger library scan
const { scanQueue } = await import(
"../workers/queues"
);
await scanQueue.add("scan", {
paths: [],
fullScan: false,
userId: req.user!.id,
source: "retry-spotify-import",
});
} else {
// Soulseek failed, try Lidarr if we have an MBID
logger.debug(
`[Retry] Soulseek failed, trying Lidarr for ${artistName} - ${albumTitle}`
);
if (
failedJob.targetMbid &&
!failedJob.targetMbid.startsWith("retry_")
) {
const { simpleDownloadManager } = await import(
"../services/simpleDownloadManager"
);
const lidarrResult =
await simpleDownloadManager.startDownload(
newJobRecord.id,
artistName,
albumTitle,
failedJob.targetMbid,
req.user!.id,
false
);
if (!lidarrResult.success) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error:
lidarrResult.error ||
"Both Soulseek and Lidarr failed",
completedAt: new Date(),
},
});
}
} else {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "No tracks found on Soulseek, no MBID for Lidarr fallback",
completedAt: new Date(),
},
});
}
}
})
.catch(async (error) => {
logger.error(`[Retry] Soulseek error:`, error);
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: error?.message || "Soulseek error",
completedAt: new Date(),
},
});
});
return res.json({ success: true, newJobId: newJobRecord.id });
}
// Validate that we have the required MBIDs
if (!failedJob.targetMbid) {
return res
.status(400)
.json({ error: "Cannot retry: missing album MBID" });
}
// Mark old job as cleared
await prisma.downloadJob.update({
where: { id: failedJob.id },
data: { cleared: true },
});
// Extract parameters from the failed job
// Subject is typically "Artist - Album" format
const subjectParts = failedJob.subject.split(" - ");
const artistName = subjectParts[0] || failedJob.subject;
const albumTitle =
(metadata?.albumTitle as string) ||
subjectParts[1] ||
failedJob.subject;
// Create a NEW download job record for the retry
const newJobRecord = await prisma.downloadJob.create({
data: {
userId: req.user!.id,
type: failedJob.type as "artist" | "album",
targetMbid: failedJob.targetMbid,
artistMbid: failedJob.artistMbid,
subject: failedJob.subject,
status: "pending",
metadata: (metadata || {}) as any,
},
});
// Import the download manager dynamically to avoid circular deps
const { simpleDownloadManager } = await import(
"../services/simpleDownloadManager"
);
// Start download with the correct positional arguments
// startDownload(jobId, artistName, albumTitle, albumMbid, userId, isDiscovery)
const result = await simpleDownloadManager.startDownload(
newJobRecord.id,
artistName,
albumTitle,
failedJob.targetMbid,
req.user!.id,
false // isDiscovery
);
res.json({
success: result.success,
newJobId: newJobRecord.id,
error: result.error,
});
} catch (error: any) {
logger.error("Error retrying download:", error);
res.status(500).json({ error: "Failed to retry download" });
}
}
);
export default router;