diff --git a/CHANGELOG.md b/CHANGELOG.md index 114b195..1537b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.6.2] - 2026-03-03 +Closes #32. Partially addresses #25, #90, #124, #139. + ### Added +- **Skip MusicBrainz when Lidarr disabled (#90, #124)**: Playlist imports no longer call MusicBrainz for MBID resolution when Lidarr is not configured. Soulseek searches by artist+album+track text and never uses MBIDs, so MB API calls were pure waste for Soulseek-only users. A 170-song import that took ~15 minutes now generates its preview in seconds. Albums without MBIDs route directly to Soulseek instead of being blocked or misrouted to track-based acquisition. +- **Import cancellation with AbortSignal**: Cancelling a playlist import now immediately aborts all in-flight and queued Soulseek searches and downloads. Previously, `cancelJob()` only marked DB records as failed while rate-limiter-queued searches continued executing for minutes. AbortSignal threads from `cancelJob()` through the PQueue album pipeline, acquisition service, rate limiter, search strategies, and download retry loop. + - **Playlist action hub**: Create, Import URL, Import File (M3U), and Browse buttons directly on the playlists page. No more navigating through Browse to import. - **Sidebar create playlist**: The "+" button in the sidebar now opens an inline create dialog instead of navigating away. - **M3U playlist import**: Upload `.m3u` / `.m3u8` playlist files to create playlists by matching entries against your library. 4-tier matching: file path, filename, exact metadata, fuzzy metadata (fuzzball). diff --git a/backend/jest.config.js b/backend/jest.config.js index 7b13140..0b35e20 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -2,8 +2,8 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/src', '/tests'], - testMatch: ['**/__tests__/**/*.test.ts', '**/tests/**/*.test.ts'], + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], moduleFileExtensions: ['ts', 'js', 'json'], clearMocks: true, collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'], diff --git a/backend/src/jobs/__tests__/webhookReconciliation.test.ts b/backend/src/jobs/__tests__/webhookReconciliation.test.ts deleted file mode 100644 index 808b88b..0000000 --- a/backend/src/jobs/__tests__/webhookReconciliation.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -describe('Webhook Reconciliation Guard', () => { - const source = fs.readFileSync( - path.resolve(__dirname, '../webhookReconciliation.ts'), 'utf-8' - ); - - it('should check processing job count before Lidarr reconciliation', () => { - expect(source).toMatch(/downloadJob\.count/); - expect(source).toMatch(/status.*processing/); - }); - - it('should skip Lidarr reconciliation when no processing jobs', () => { - expect(source).toContain('skipping Lidarr reconciliation'); - }); -}); diff --git a/backend/src/lib/soulseek/__tests__/client.test.ts b/backend/src/lib/soulseek/__tests__/client.test.ts deleted file mode 100644 index e5e329e..0000000 --- a/backend/src/lib/soulseek/__tests__/client.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; -import { SlskClient } from '../client'; - -describe('SlskClient download cleanup', () => { - let client: SlskClient; - - beforeEach(() => { - client = new SlskClient(); - }); - - afterEach(() => { - jest.clearAllTimers(); - }); - - it('should cleanup stuck downloads after TTL expires', () => { - jest.useFakeTimers(); - - // Start a download that gets stuck (no complete/error/close) - // Note: We can't actually trigger a real download without full P2P setup, - // so we'll verify the cleanup mechanism exists via code inspection - - // Verify cleanup interval exists - expect((client as any).downloadCleanupInterval).toBeDefined(); - - // Verify cleanup method exists - expect(typeof (client as any).cleanupStuckDownloads).toBe('function'); - - // Verify TTL constant exists - expect((client as any).DOWNLOAD_TTL).toBe(5 * 60 * 1000); - - jest.useRealTimers(); - }); -}); diff --git a/backend/src/routes/__tests__/notifications.test.ts b/backend/src/routes/__tests__/notifications.test.ts deleted file mode 100644 index f636129..0000000 --- a/backend/src/routes/__tests__/notifications.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -describe('Notification Retry Dedup', () => { - const source = fs.readFileSync( - path.resolve(__dirname, '../notifications.ts'), 'utf-8' - ); - - it('should check for existing active jobs before creating download jobs', () => { - // Count dedup checks (findFirst for downloadJob with status filter) - const dedupChecks = (source.match(/existingActiveJob.*findFirst/g) || []).length; - // Should have at least 3 dedup checks (one per retry handler) - expect(dedupChecks).toBeGreaterThanOrEqual(3); - }); - - it('should return deduplicated response when active job exists', () => { - const dedupResponses = (source.match(/deduplicated:\s*true/g) || []).length; - expect(dedupResponses).toBeGreaterThanOrEqual(3); - }); - - it('should skip dedup for retry_ prefixed targetMbids', () => { - expect(source).toContain("startsWith('retry_')"); - }); -}); diff --git a/backend/src/services/__tests__/discoverWeekly.test.ts b/backend/src/services/__tests__/discoverWeekly.test.ts deleted file mode 100644 index d915945..0000000 --- a/backend/src/services/__tests__/discoverWeekly.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -describe('DiscoverWeekly Status Guards', () => { - describe('discoveryAlbum upsert', () => { - const source = fs.readFileSync( - path.resolve(__dirname, '../discoverWeekly.ts'), 'utf-8' - ); - - it('should NOT set status in the discoveryAlbum upsert update branch', () => { - // Find the discoveryAlbum.upsert call - const upsertPos = source.indexOf('discoveryAlbum.upsert('); - expect(upsertPos).toBeGreaterThan(-1); - - // Extract the upsert call region (roughly 100 lines after upsert start) - const upsertRegion = source.slice(upsertPos, upsertPos + 2000); - - // Find the update: { block - const updateStart = upsertRegion.indexOf('update: {'); - expect(updateStart).toBeGreaterThan(-1); - - // Find the opening brace of the update block, then match to closing - const braceStart = upsertRegion.indexOf('{', updateStart); - expect(braceStart).toBeGreaterThan(-1); - let braceCount = 0; - let updateEnd = braceStart; - for (let i = braceStart; i < upsertRegion.length; i++) { - if (upsertRegion[i] === '{') braceCount++; - if (upsertRegion[i] === '}') braceCount--; - if (braceCount === 0) { updateEnd = i; break; } - } - - const updateBlock = upsertRegion.slice(updateStart, updateEnd + 1); - - // The update block should NOT contain a status assignment - expect(updateBlock).not.toMatch(/status\s*:/); - }); - }); - - describe('checkBatchCompletion', () => { - const source = fs.readFileSync( - path.resolve(__dirname, '../discoverWeekly.ts'), 'utf-8' - ); - - it('should re-check batch status after Lidarr wait', () => { - // Find checkBatchCompletion method - const methodStart = source.indexOf('async checkBatchCompletion('); - expect(methodStart).toBeGreaterThan(-1); - - const methodRegion = source.slice(methodStart, methodStart + 10000); - - // Should contain a fresh batch status re-read after the wait - expect(methodRegion).toMatch(/freshBatch.*findUnique/s); - }); - }); -}); diff --git a/backend/src/services/__tests__/soulseek.test.ts b/backend/src/services/__tests__/soulseek.test.ts deleted file mode 100644 index 67a7207..0000000 --- a/backend/src/services/__tests__/soulseek.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; - -describe("SoulseekService - Race Condition Fix", () => { - describe("reconnection handling", () => { - it("should have 100ms delay after forceDisconnect on empty search threshold", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - const emptySearchPattern = /Too many consecutive empty searches.*\n[\s\S]*?this\.forceDisconnect\(\);[\s\S]*?await new Promise\(resolve => setTimeout\(resolve, 100\)\);/; - expect(content).toMatch(emptySearchPattern); - }); - - it("should have 100ms delay after forceDisconnect on search error threshold", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - const errorPattern = /consecutive search failures - forcing reconnect.*\n[\s\S]*?this\.forceDisconnect\(\);[\s\S]*?await new Promise\(resolve => setTimeout\(resolve, 100\)\);/; - expect(content).toMatch(errorPattern); - }); - - it("should have exactly two reconnect delay points in the code", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - const delayPattern = /await new Promise\(resolve => setTimeout\(resolve, 100\)\);/g; - const matches = content.match(delayPattern); - - expect(matches).not.toBeNull(); - expect(matches?.length).toBe(2); - }); - - it("should only add delay when not a retry attempt", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - const noRetryCheck = /if \(\s*!isRetry\s*&&\s*this\.consecutiveEmptySearches\s*>=\s*this\.MAX_CONSECUTIVE_EMPTY\s*\)/g; - const matches = content.match(noRetryCheck); - - expect(matches).not.toBeNull(); - expect(matches?.length).toBe(2); - }); - }); - - describe("search result deduplication", () => { - it("should have flattenSearchResults method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - // Check that flattenSearchResults method exists - expect(content).toContain("flattenSearchResults"); - expect(content).toContain("const seen = new Set"); - }); - - it("should deduplicate by user:filename key", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - // Check that deduplication uses user:filename pattern - const dedupPattern = /const key = `\$\{.*username.*\}:\$\{.*filename.*\}`/; - expect(content).toMatch(dedupPattern); - }); - - it("should call flattenSearchResults in searchTrack", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - // Check that searchTrack uses the flattening method - expect(content).toContain("this.flattenSearchResults(responses)"); - }); - }); - - describe("error categorization", () => { - it("should detect timeout errors", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - // Check for timeout detection in categorizeError - expect(content).toContain('message.includes("timeout")'); - expect(content).toContain('message.includes("timed out")'); - }); - - it("should detect connection errors", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - // Check for connection error detection - expect(content).toContain('message.includes("connection refused")'); - expect(content).toContain('message.includes("connection reset")'); - expect(content).toContain('message.includes("econnrefused")'); - }); - - it("should mark timeout and connection errors as skipUser true", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - // Verify timeout and connection sections return skipUser: true - const timeoutPattern = /timeout.*skipUser:\s*true/s; - const connectionPattern = /connection.*skipUser:\s*true/s; - - expect(content).toMatch(timeoutPattern); - expect(content).toMatch(connectionPattern); - }); - - it("should mark file errors as skipUser false", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - // Verify file error section returns skipUser: false - const filePattern = /file not found.*skipUser:\s*false/s; - expect(content).toMatch(filePattern); - }); - }); - - describe("circuit breaker", () => { - it("should have isUserBlocked method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("isUserBlocked"); - expect(content).toContain("FAILURE_THRESHOLD"); - }); - - it("should have recordUserFailure method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("recordUserFailure"); - expect(content).toContain("markUserFailed"); - }); - - it("should have isUserInCooldown method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("isUserInCooldown"); - expect(content).toContain("userConnectionCooldowns"); - }); - - it("should have failure window for expiration", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("FAILURE_WINDOW"); - }); - - it("should have per-user cooldown constant", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("USER_CONNECTION_COOLDOWN"); - }); - }); - - describe("stale client cleanup", () => { - it("should clean up stale clients in ensureConnected", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - // Check that stale clients (exists but not logged in) are cleaned up - expect(content).toContain("if (this.client && !this.client.loggedIn)"); - expect(content).toContain("this.forceDisconnect()"); - }); - - it("should log individual download failure errors", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - // Check that individual failures are logged in downloadWithRetry - const logPattern = /Attempt \$\{attempt \+ 1\} failed: \$\{result\.error\}/; - expect(content).toMatch(logPattern); - }); - }); - - describe("download retry flow", () => { - it("should retry with multiple users from search results", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - // Check that downloadWithRetry iterates through allMatches - expect(content).toContain("allMatches"); - expect(content).toContain("MAX_DOWNLOAD_RETRIES"); - }); - - it("should have downloadTrack method for individual downloads", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("downloadTrack"); - }); - - it("should aggregate errors when all users fail", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - // Check that errors are collected - const errorAggregationPattern = /errors.*push/; - expect(content).toMatch(errorAggregationPattern); - }); - }); -}); diff --git a/backend/src/services/__tests__/soulseekRedis.test.ts b/backend/src/services/__tests__/soulseekRedis.test.ts deleted file mode 100644 index 091bed6..0000000 --- a/backend/src/services/__tests__/soulseekRedis.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; - -describe("SoulseekService - Redis Migration", () => { - describe("Search Session Methods", () => { - it("should have saveSearchSession method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("async saveSearchSession(sessionId: string, data: any, ttlSeconds: number = 300)"); - expect(content).toContain("soulseek:search:"); - expect(content).toContain("redisClient.setEx"); - }); - - it("should have getSearchSession method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("async getSearchSession(sessionId: string)"); - expect(content).toContain("redisClient.get"); - }); - - it("should have deleteSearchSession method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("async deleteSearchSession(sessionId: string)"); - expect(content).toContain("redisClient.del"); - }); - - it("should have listSearchSessions method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("async listSearchSessions()"); - expect(content).toContain("redisClient.keys"); - }); - - it("should have extendSearchSessionTTL method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("async extendSearchSessionTTL(sessionId: string, ttlSeconds: number = 300)"); - expect(content).toContain("redisClient.expire"); - }); - - it("should use default TTL of 300 seconds for search sessions", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - const saveSessionPattern = /saveSearchSession\(sessionId: string, data: any, ttlSeconds: number = 300\)/; - expect(content).toMatch(saveSessionPattern); - }); - }); - - describe("Failed User Blocklist Methods", () => { - it("should have markUserFailed method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("async markUserFailed(username: string)"); - expect(content).toContain("soulseek:failed-user:"); - expect(content).toContain("FAILED_USER_TTL"); - }); - - it("should have isUserBlocked method that uses Redis", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("async isUserBlocked(username: string)"); - expect(content).toContain("redisClient.get"); - expect(content).toContain("FAILURE_THRESHOLD"); - }); - - it("should have clearUserFailures method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("async clearUserFailures(username: string)"); - }); - - it("should have getBlockedUsers method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("async getBlockedUsers()"); - expect(content).toContain("soulseek:failed-user:"); - }); - - it("should have 24-hour TTL constant for failed users", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("FAILED_USER_TTL = 86400"); - }); - - it("should not have in-memory failedUsers Map", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - const failedUsersMapPattern = /private failedUsers = new Map/; - expect(content).not.toMatch(failedUsersMapPattern); - }); - - it("should not have cleanupFailedUsers method", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).not.toContain("cleanupFailedUsers()"); - }); - }); - - describe("Integration Points", () => { - it("should import redisClient", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain('import { redisClient } from "../utils/redis"'); - }); - - it("should update recordUserFailure to use Redis", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - expect(content).toContain("async recordUserFailure"); - expect(content).toContain("await this.markUserFailed(username)"); - }); - - it("should update rankAllResults to await isUserBlocked", () => { - const servicePath = path.join(__dirname, "../soulseek.ts"); - const content = fs.readFileSync(servicePath, "utf-8"); - - const asyncRankPattern = /private async rankAllResults/; - expect(content).toMatch(asyncRankPattern); - expect(content).toContain("await this.isUserBlocked"); - }); - }); -}); diff --git a/backend/src/services/__tests__/spotifyImport.test.ts b/backend/src/services/__tests__/spotifyImport.test.ts deleted file mode 100644 index ac42922..0000000 --- a/backend/src/services/__tests__/spotifyImport.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -describe('Spotify Pagination Guard', () => { - const source = fs.readFileSync( - path.resolve(__dirname, '../spotify.ts'), 'utf-8' - ); - - it('should attempt pagination when items.length equals total (speculative fetch)', () => { - const fetchMethod = source.slice( - source.indexOf('private async fetchPlaylistViaAnonymousApi('), - source.indexOf('private async ', source.indexOf('private async fetchPlaylistViaAnonymousApi(') + 1) - ); - - // Should have speculative pagination when total === items.length - expect(fetchMethod).toMatch(/allItems\.length\s*>=\s*(?:\d+|PAGE_SIZE)/); - }); -}); - -describe('SpotifyImport Status Guards', () => { - const source = fs.readFileSync( - path.resolve(__dirname, '../spotifyImport.ts'), 'utf-8' - ); - - it('should check for cancelled status before setting downloading in processImport', () => { - // Find the processImport method region - const processImportStart = source.indexOf('private async processImport('); - const processImportEnd = source.indexOf('private async ', processImportStart + 1); - const processImportBody = source.slice(processImportStart, processImportEnd > 0 ? processImportEnd : undefined); - - // The cancel check should appear BEFORE the downloading assignment - const cancelCheckPos = processImportBody.indexOf("job.status === \"cancelled\""); - const downloadingPos = processImportBody.indexOf("job.status = \"downloading\""); - - expect(cancelCheckPos).toBeGreaterThan(-1); - expect(downloadingPos).toBeGreaterThan(-1); - expect(cancelCheckPos).toBeLessThan(downloadingPos); - }); -}); diff --git a/backend/src/services/__tests__/upsertIdempotency.test.ts b/backend/src/services/__tests__/upsertIdempotency.test.ts deleted file mode 100644 index 5cf065e..0000000 --- a/backend/src/services/__tests__/upsertIdempotency.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -describe('Upsert Idempotency', () => { - describe('musicScanner ownedAlbum', () => { - const source = fs.readFileSync( - path.resolve(__dirname, '../musicScanner.ts'), 'utf-8' - ); - - it('should use ownedAlbum.upsert instead of create', () => { - expect(source).toContain('ownedAlbum.upsert('); - expect(source).not.toMatch(/ownedAlbum\.create\(/); - }); - }); - - describe('audioStreaming transcodedFile', () => { - const source = fs.readFileSync( - path.resolve(__dirname, '../audioStreaming.ts'), 'utf-8' - ); - - it('should use transcodedFile.upsert instead of create', () => { - expect(source).toContain('transcodedFile.upsert('); - expect(source).not.toMatch(/transcodedFile\.create\(/); - }); - }); -}); diff --git a/backend/src/services/acquisitionService.ts b/backend/src/services/acquisitionService.ts index 5203b67..ccbc70e 100644 --- a/backend/src/services/acquisitionService.ts +++ b/backend/src/services/acquisitionService.ts @@ -3,12 +3,6 @@ * * Consolidates album/track acquisition logic from Discovery Weekly and Playlist Import. * Handles download source selection, behavior matrix routing, and job tracking. - * - * Phase 2.1: Initial implementation - * - Behavior matrix logic for primary/fallback source selection - * - Soulseek album acquisition (track list → batch download) - * - Lidarr album acquisition (webhook-based completion) - * - DownloadJob management with context-based tracking */ import { logger } from "../utils/logger"; @@ -38,6 +32,7 @@ export interface AcquisitionContext { spotifyImportJobId?: string; existingJobId?: string; retryCount?: number; + signal?: AbortSignal; } /** @@ -135,9 +130,6 @@ class AcquisitionService { // Case 1: No sources available if (!hasSoulseek && !hasLidarr) { - logger.debug( - "[Acquisition] Available sources: Lidarr=false, Soulseek=false" - ); logger.error("[Acquisition] No download sources configured"); return { hasPrimarySource: false, @@ -149,15 +141,7 @@ class AcquisitionService { // Case 2: Only one source available - use it regardless of preference if (hasSoulseek && !hasLidarr) { - logger.debug( - "[Acquisition] Available sources: Lidarr=false, Soulseek=true" - ); - logger.debug( - "[Acquisition] Using Soulseek as primary source (only source available)" - ); - logger.debug( - "[Acquisition] No fallback configured (only one source available)" - ); + logger.debug("[Acquisition] Source config: primary=soulseek, fallback=none (only source)"); return { hasPrimarySource: true, primarySource: "soulseek", @@ -167,15 +151,7 @@ class AcquisitionService { } if (hasLidarr && !hasSoulseek) { - logger.debug( - "[Acquisition] Available sources: Lidarr=true, Soulseek=false" - ); - logger.debug( - "[Acquisition] Using Lidarr as primary source (only source available)" - ); - logger.debug( - "[Acquisition] No fallback configured (only one source available)" - ); + logger.debug("[Acquisition] Source config: primary=lidarr, fallback=none (only source)"); return { hasPrimarySource: true, primarySource: "lidarr", @@ -203,15 +179,7 @@ class AcquisitionService { } logger.debug( - "[Acquisition] Available sources: Lidarr=true, Soulseek=true" - ); - logger.debug( - `[Acquisition] Using ${userPrimary} as primary source (user preference)` - ); - logger.debug( - `[Acquisition] Fallback configured: ${ - useFallback ? alternative : "none" - }` + `[Acquisition] Source config: primary=${userPrimary}, fallback=${useFallback ? alternative : "none"}` ); return { @@ -282,6 +250,10 @@ class AcquisitionService { // actual processing time, not time spent waiting for a queue slot. const MAX_ACQUISITION_TIME = 5 * 60 * 1000; // 5 minutes const result = await this.albumQueue.add(async () => { + if (context.signal?.aborted) { + return { success: false, error: 'Import cancelled' } as AcquisitionResult; + } + let timeoutId: NodeJS.Timeout; const timeoutPromise = new Promise((resolve) => { timeoutId = setTimeout(() => { @@ -301,7 +273,7 @@ class AcquisitionService { } finally { clearTimeout(timeoutId!); } - }); + }, context.signal ? { signal: context.signal } : {}); return result as AcquisitionResult; } @@ -313,16 +285,15 @@ class AcquisitionService { request: AlbumAcquisitionRequest, context: AcquisitionContext ): Promise { + if (context.signal?.aborted) { + return { success: false, error: 'Import cancelled' }; + } + const startTime = Date.now(); logger.debug( `\n[Acquisition] Acquiring album: ${request.artistName} - ${request.albumTitle} (queue: ${this.albumQueue.size} pending, ${this.albumQueue.pending} active)` ); - // Validate inputs - if (!request.mbid) { - throw new UserFacingError('Album MBID is required', 400, 'INVALID_INPUT'); - } - // Check configuration const soulseekAvailable = await soulseekService.isAvailable(); const settings = await getSystemSettings(); @@ -338,6 +309,11 @@ class AcquisitionService { ); } + // MBID only required when Soulseek is unavailable (Lidarr needs it) + if (!request.mbid && !soulseekAvailable) { + throw new UserFacingError('Album MBID is required when Soulseek is not available', 400, 'INVALID_INPUT'); + } + // Verify artist name before acquisition try { const correction = await lastFmService.getArtistCorrection( @@ -378,7 +354,8 @@ class AcquisitionService { if ( behavior.hasFallbackSource && - behavior.fallbackSource === "lidarr" + behavior.fallbackSource === "lidarr" && + request.mbid ) { logger.debug( `[Acquisition] Attempting Lidarr fallback...` @@ -391,30 +368,36 @@ class AcquisitionService { } } } else if (behavior.primarySource === "lidarr") { - logger.debug(`[Acquisition] Trying primary: Lidarr`); - result = await this.acquireAlbumViaLidarr(request, context); + if (!request.mbid) { + // No MBID -- Lidarr requires it, skip directly to Soulseek + logger.info(`[Acquisition] No MBID for "${request.albumTitle}", skipping Lidarr, trying Soulseek directly`); + result = await this.acquireAlbumViaSoulseek(request, context); + } else { + logger.debug(`[Acquisition] Trying primary: Lidarr`); + result = await this.acquireAlbumViaLidarr(request, context); - // Fallback to Soulseek if Lidarr fails and fallback is configured - if (!result.success) { - logger.debug( - `[Acquisition] Lidarr failed: ${result.error || "unknown error"}` - ); - logger.debug( - `[Acquisition] Fallback available: hasFallback=${behavior.hasFallbackSource}, source=${behavior.fallbackSource}` - ); + // Fallback to Soulseek if Lidarr fails and fallback is configured + if (!result.success) { + logger.debug( + `[Acquisition] Lidarr failed: ${result.error || "unknown error"}` + ); + logger.debug( + `[Acquisition] Fallback available: hasFallback=${behavior.hasFallbackSource}, source=${behavior.fallbackSource}` + ); - if ( - behavior.hasFallbackSource && - behavior.fallbackSource === "soulseek" - ) { - logger.debug( - `[Acquisition] Attempting Soulseek fallback...` - ); - result = await this.acquireAlbumViaSoulseek(request, context); - } else { - logger.debug( - `[Acquisition] No fallback configured or fallback not Soulseek` - ); + if ( + behavior.hasFallbackSource && + behavior.fallbackSource === "soulseek" + ) { + logger.debug( + `[Acquisition] Attempting Soulseek fallback...` + ); + result = await this.acquireAlbumViaSoulseek(request, context); + } else { + logger.debug( + `[Acquisition] No fallback configured or fallback not Soulseek` + ); + } } } } else { @@ -504,7 +487,8 @@ class AcquisitionService { const batchResult = await soulseekService.searchAndDownloadBatch( tracksToDownload, musicPath, - settings?.soulseekConcurrentDownloads ?? 4 // concurrency + settings?.soulseekConcurrentDownloads ?? 4, // concurrency + context.signal ); logger.debug( @@ -532,6 +516,12 @@ class AcquisitionService { return results; } catch (error: any) { + if (error?.name === 'AbortError' || context.signal?.aborted) { + return requests.map(() => ({ + success: false, + error: 'Import cancelled', + })); + } logger.error( `[Acquisition] Batch track download error: ${error.message}` ); @@ -566,10 +556,10 @@ class AcquisitionService { return { success: false, error: "Music path not configured" }; } - if (!request.mbid) { + if (!request.mbid && (!request.requestedTracks || request.requestedTracks.length === 0)) { return { success: false, - error: "Album MBID required for Soulseek download", + error: "Album MBID or track list required for Soulseek download", }; } @@ -596,8 +586,8 @@ class AcquisitionService { `[Acquisition/Soulseek] Using ${tracks.length} requested tracks (not full album)` ); } else { - // Strategy 1: Get track list from MusicBrainz - tracks = await musicBrainzService.getAlbumTracks(request.mbid); + // Strategy 1: Get track list from MusicBrainz (mbid guaranteed by early guard above) + tracks = await musicBrainzService.getAlbumTracks(request.mbid!); // Strategy 2: Fallback to Last.fm (always try when MusicBrainz fails) if (!tracks || tracks.length === 0) { @@ -653,7 +643,8 @@ class AcquisitionService { request.artistName, request.albumTitle, tracks, - musicPath + musicPath, + context.signal ); if (batchResult.successful === 0) { @@ -716,6 +707,12 @@ class AcquisitionService { : `Only ${batchResult.successful}/${tracks.length} tracks found`, }; } catch (error: any) { + if (error?.name === 'AbortError' || context.signal?.aborted) { + if (job) { + await this.updateJobStatus(job.id, "failed", "Import cancelled").catch(() => {}); + } + return { success: false, error: 'Import cancelled' }; + } logger.error(`[Acquisition/Soulseek] Error: ${error.message}`); // Update job status if job was created if (job) { @@ -745,6 +742,10 @@ class AcquisitionService { request: AlbumAcquisitionRequest, context: AcquisitionContext ): Promise { + if (context.signal?.aborted) { + return { success: false, error: 'Import cancelled' }; + } + logger.debug( `[Acquisition/Lidarr] Downloading: ${request.artistName} - ${request.albumTitle}` ); @@ -860,39 +861,39 @@ class AcquisitionService { throw new Error(`Invalid userId in acquisition context: ${context.userId}`); } - if (!request.mbid) { - throw new Error('Album MBID required for download job creation'); - } + // Dedup key: use MBID if available, otherwise use artist+album as identifier + const dedupKey = request.mbid || `${request.artistName}::${request.albumTitle}`; // Check for existing active download job (before acquiring lock) + const existingJobWhere: any = { + userId: context.userId, + discoveryBatchId: context.discoveryBatchId || null, + status: { in: ['pending', 'downloading'] }, + }; + if (request.mbid) { + existingJobWhere.targetMbid = request.mbid; + } else { + existingJobWhere.subject = `${request.artistName} - ${request.albumTitle}`; + } + const existingJob = await prisma.downloadJob.findFirst({ - where: { - userId: context.userId, - targetMbid: request.mbid, - discoveryBatchId: context.discoveryBatchId || null, - status: { in: ['pending', 'downloading'] }, - }, + where: existingJobWhere, }); if (existingJob) { logger.info( - `[Acquisition] Download job already exists for album ${request.mbid}, returning existing job ${existingJob.id}` + `[Acquisition] Download job already exists for album ${dedupKey}, returning existing job ${existingJob.id}` ); return existingJob; } // Use distributed lock to prevent race condition - const lockKey = `download-job:${context.userId}:${request.mbid}:${context.discoveryBatchId || 'null'}`; + const lockKey = `download-job:${context.userId}:${dedupKey}:${context.discoveryBatchId || 'null'}`; return await distributedLock.withLock(lockKey, 5000, async () => { // Double-check after acquiring lock (another request might have created it) const doubleCheck = await prisma.downloadJob.findFirst({ - where: { - userId: context.userId, - targetMbid: request.mbid, - discoveryBatchId: context.discoveryBatchId || null, - status: { in: ['pending', 'downloading'] }, - }, + where: existingJobWhere, }); if (doubleCheck) { @@ -907,12 +908,12 @@ class AcquisitionService { userId: context.userId, subject: `${request.artistName} - ${request.albumTitle}`, type: "album", - targetMbid: request.mbid, + targetMbid: request.mbid || null, status: "pending", metadata: { artistName: request.artistName, albumTitle: request.albumTitle, - albumMbid: request.mbid, + albumMbid: request.mbid || null, }, }; diff --git a/backend/src/services/artistCountsService.ts b/backend/src/services/artistCountsService.ts index dff041c..9ecf737 100644 --- a/backend/src/services/artistCountsService.ts +++ b/backend/src/services/artistCountsService.ts @@ -25,7 +25,7 @@ interface ArtistCounts { /** * Calculate counts for a single artist */ -export async function calculateArtistCounts( +async function calculateArtistCounts( artistId: string ): Promise { const [libraryAlbums, discoveryAlbums, trackCount] = await Promise.all([ @@ -77,63 +77,6 @@ export async function updateArtistCounts(artistId: string): Promise { } } -/** - * Update counts for multiple artists (batch operation) - */ -export async function updateMultipleArtistCounts( - artistIds: string[] -): Promise<{ updated: number; errors: number }> { - let updated = 0; - let errors = 0; - - for (const artistId of artistIds) { - try { - await updateArtistCounts(artistId); - updated++; - } catch (error) { - errors++; - } - } - - return { updated, errors }; -} - -/** - * Update counts for an artist by album ID (useful after album changes) - */ -export async function updateArtistCountsByAlbumId( - albumId: string -): Promise { - const album = await prisma.album.findUnique({ - where: { id: albumId }, - select: { artistId: true }, - }); - - if (album) { - await updateArtistCounts(album.artistId); - } -} - -/** - * Update counts for an artist by track ID (useful after track changes) - */ -export async function updateArtistCountsByTrackId( - trackId: string -): Promise { - const track = await prisma.track.findUnique({ - where: { id: trackId }, - select: { - album: { - select: { artistId: true }, - }, - }, - }); - - if (track?.album) { - await updateArtistCounts(track.album.artistId); - } -} - // Track backfill state let isBackfillRunning = false; let backfillProgress = { processed: 0, total: 0, errors: 0 }; @@ -272,18 +215,3 @@ export async function getBackfillProgress(): Promise<{ }; } -/** - * Recalculate counts for all artists (force refresh) - * Use sparingly - this resets countsLastUpdated to null first - */ -export async function forceRecalculateAllCounts(): Promise { - logger.info("[ArtistCounts] Force recalculating all counts..."); - - // Reset countsLastUpdated to trigger backfill - await prisma.artist.updateMany({ - data: { countsLastUpdated: null }, - }); - - // Run backfill - await backfillAllArtistCounts(); -} diff --git a/backend/src/services/discovery/__tests__/optimisticBatchUpdate.test.ts b/backend/src/services/discovery/__tests__/optimisticBatchUpdate.test.ts deleted file mode 100644 index 66b7bda..0000000 --- a/backend/src/services/discovery/__tests__/optimisticBatchUpdate.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Tests for optimistic locking on DiscoveryBatch updates - */ - -import { prisma } from '../../../utils/db'; -import { updateBatchStatus } from '../optimisticBatchUpdate'; - -describe('updateBatchStatus with optimistic locking', () => { - let testUserId: string; - let testBatchId: string; - - beforeEach(async () => { - testUserId = 'test-user-' + Date.now(); - - const batch = await prisma.discoveryBatch.create({ - data: { - userId: testUserId, - weekStart: new Date('2024-01-01'), - targetSongCount: 40, - status: 'downloading', - totalAlbums: 0, - completedAlbums: 0, - failedAlbums: 0, - version: 0, - }, - }); - testBatchId = batch.id; - }); - - afterEach(async () => { - jest.restoreAllMocks(); - await prisma.discoveryBatch.deleteMany({ - where: { userId: testUserId }, - }); - }); - - it('should successfully update batch status on first attempt', async () => { - const result = await updateBatchStatus(testBatchId, { - status: 'scanning', - completedAlbums: 5, - failedAlbums: 1, - }); - - expect(result.success).toBe(true); - expect(result.retries).toBe(0); - - const updated = await prisma.discoveryBatch.findUnique({ - where: { id: testBatchId }, - }); - - expect(updated?.status).toBe('scanning'); - expect(updated?.completedAlbums).toBe(5); - expect(updated?.failedAlbums).toBe(1); - expect(updated?.version).toBe(1); - }); - - it('should increment version on each update', async () => { - await updateBatchStatus(testBatchId, { status: 'scanning' }); - await updateBatchStatus(testBatchId, { completedAlbums: 3 }); - await updateBatchStatus(testBatchId, { failedAlbums: 1 }); - - const batch = await prisma.discoveryBatch.findUnique({ - where: { id: testBatchId }, - }); - - expect(batch?.version).toBe(3); - }); - - it('should retry on version conflict and eventually succeed', async () => { - let firstUpdateVersion: number | undefined; - - const update1 = updateBatchStatus(testBatchId, { - completedAlbums: 5, - }).then((result) => { - firstUpdateVersion = result.version; - return result; - }); - - const update2 = updateBatchStatus(testBatchId, { - failedAlbums: 2, - }); - - const [result1, result2] = await Promise.all([update1, update2]); - - expect(result1.success).toBe(true); - expect(result2.success).toBe(true); - - const totalRetries = result1.retries + result2.retries; - expect(totalRetries).toBeGreaterThan(0); - - const batch = await prisma.discoveryBatch.findUnique({ - where: { id: testBatchId }, - }); - - expect(batch?.completedAlbums).toBe(5); - expect(batch?.failedAlbums).toBe(2); - expect(batch?.version).toBe(2); - }); - - it('should fail after max retries on persistent conflict', async () => { - const maxRetries = 5; - - const mockError = new Error('Record to update not found'); - (mockError as any).code = 'P2025'; - - jest.spyOn(prisma.discoveryBatch, 'findUnique').mockResolvedValue({ - id: testBatchId, - version: 0, - } as any); - - jest.spyOn(prisma.discoveryBatch, 'update').mockRejectedValue( - mockError - ); - - const result = await updateBatchStatus( - testBatchId, - { status: 'failed' }, - { maxRetries } - ); - - expect(result.success).toBe(false); - expect(result.retries).toBeGreaterThanOrEqual(maxRetries); - expect(result.error).toContain('Max retries'); - }); - - it('should preserve existing fields not in update data', async () => { - const result = await updateBatchStatus(testBatchId, { - completedAlbums: 10, - }); - - expect(result.success).toBe(true); - if (!result.success) { - console.log('Update failed:', result.error); - } - - const batch = await prisma.discoveryBatch.findUnique({ - where: { id: testBatchId }, - }); - - expect(batch?.status).toBe('downloading'); - expect(batch?.completedAlbums).toBe(10); - expect(batch?.targetSongCount).toBe(40); - }); - - it('should handle multiple concurrent updates correctly', async () => { - const updates = Array.from({ length: 10 }, (_, i) => - updateBatchStatus(testBatchId, { - completedAlbums: i + 1, - }) - ); - - const results = await Promise.all(updates); - - const allSucceeded = results.every((r) => r.success); - expect(allSucceeded).toBe(true); - - const batch = await prisma.discoveryBatch.findUnique({ - where: { id: testBatchId }, - }); - - expect(batch?.version).toBe(10); - expect(batch?.completedAlbums).toBeGreaterThan(0); - expect(batch?.completedAlbums).toBeLessThanOrEqual(10); - }); - - it('should return error for non-existent batch', async () => { - const result = await updateBatchStatus('non-existent-id', { - status: 'completed', - }); - - expect(result.success).toBe(false); - expect(result.error).toContain('not found'); - }); - - it('should handle null/undefined update fields correctly', async () => { - await prisma.discoveryBatch.update({ - where: { id: testBatchId }, - data: { errorMessage: 'Initial error' }, - }); - - const result = await updateBatchStatus(testBatchId, { - status: 'completed', - errorMessage: null, - }); - - expect(result.success).toBe(true); - - const batch = await prisma.discoveryBatch.findUnique({ - where: { id: testBatchId }, - }); - - expect(batch?.errorMessage).toBeNull(); - }); - - it('should include expectedStatus in WHERE clause when provided', async () => { - // Update batch status with expectedStatus - const result = await updateBatchStatus(testBatchId, { - status: 'scanning', - expectedStatus: 'downloading', - }); - - expect(result.success).toBe(true); - - // Verify the batch was actually updated - const updated = await prisma.discoveryBatch.findUnique({ - where: { id: testBatchId }, - }); - expect(updated?.status).toBe('scanning'); - }); - - it('should fail when expectedStatus does not match current status', async () => { - // First update to 'scanning' - await updateBatchStatus(testBatchId, { status: 'scanning' }); - - // Now try to update with expectedStatus 'downloading' (mismatch) - const result = await updateBatchStatus(testBatchId, { - status: 'completed', - expectedStatus: 'downloading', - }, { maxRetries: 2 }); - - // Should fail because status is 'scanning', not 'downloading' - expect(result.success).toBe(false); - }); -}); diff --git a/backend/src/services/imageBackfill.ts b/backend/src/services/imageBackfill.ts index d16c279..b57987f 100644 --- a/backend/src/services/imageBackfill.ts +++ b/backend/src/services/imageBackfill.ts @@ -75,7 +75,7 @@ export async function isImageBackfillNeeded(): Promise<{ /** * Backfill artist images - download external URLs and store locally */ -export async function backfillArtistImages(): Promise { +async function backfillArtistImages(): Promise { if (backfillProgress.inProgress) { logger.warn("[ImageBackfill] Backfill already in progress"); return; @@ -186,7 +186,7 @@ export async function backfillArtistImages(): Promise { /** * Backfill album covers - download external URLs and store locally */ -export async function backfillAlbumCovers(): Promise { +async function backfillAlbumCovers(): Promise { if (backfillProgress.inProgress) { logger.warn("[ImageBackfill] Backfill already in progress"); return; diff --git a/backend/src/services/podcastindex.ts b/backend/src/services/podcastindex.ts index 7807007..a455a8d 100644 --- a/backend/src/services/podcastindex.ts +++ b/backend/src/services/podcastindex.ts @@ -39,69 +39,3 @@ export function resetPodcastIndexCache(): void { cachedCredentialsHash = null; } -/** - * Search podcasts by term - */ -export async function searchPodcasts(query: string, max: number = 20) { - const client = await initPodcastindexClient(); - const results = await client.searchByTerm(query, max); - return results; -} - -/** - * Get trending podcasts - */ -export async function getTrendingPodcasts(max: number = 10, category?: string) { - const client = await initPodcastindexClient(); - const results = await client.podcastsTrending(max, null, null, category); - return results; -} - -/** - * Get podcasts by category - */ -export async function getPodcastsByCategory( - category: string, - max: number = 20 -) { - const client = await initPodcastindexClient(); - const results = await client.searchByTerm("", max, null, null); - // Filter by category - return results; -} - -/** - * Get all categories - */ -export async function getCategories() { - const client = await initPodcastindexClient(); - const results = await client.categoriesList(); - return results; -} - -/** - * Get podcast by feed URL - */ -export async function getPodcastByFeedUrl(feedUrl: string) { - const client = await initPodcastindexClient(); - const results = await client.podcastsByFeedUrl(feedUrl); - return results; -} - -/** - * Get podcast by iTunes ID - */ -export async function getPodcastByItunesId(itunesId: string) { - const client = await initPodcastindexClient(); - const results = await client.podcastsByFeedItunesId(itunesId); - return results; -} - -/** - * Get recent podcasts - */ -export async function getRecentPodcasts(max: number = 20) { - const client = await initPodcastindexClient(); - const results = await client.recentFeeds(max); - return results; -} diff --git a/backend/src/services/soulseek-search-strategies.ts b/backend/src/services/soulseek-search-strategies.ts index e3726fb..e12c0bb 100644 --- a/backend/src/services/soulseek-search-strategies.ts +++ b/backend/src/services/soulseek-search-strategies.ts @@ -10,10 +10,14 @@ * 5. Artist + Album + Title - last resort for highly specific searches */ -import { SlskClient } from "../lib/soulseek/client"; import type { FileSearchResponse } from "../lib/soulseek/messages/from/peer"; import { sessionLog } from "../utils/playlistLogger"; +export type SearchFn = ( + query: string, + options?: { timeout?: number; onResult?: (result: FileSearchResponse) => void } +) => Promise; + export interface SearchStrategy { name: string; buildQuery: (artist: string, track: string, album?: string) => string; @@ -175,7 +179,7 @@ export const SEARCH_STRATEGIES: SearchStrategy[] = [ * Execute multi-strategy search with fallbacks */ export async function searchWithStrategies( - client: SlskClient, + search: SearchFn, artistName: string, trackTitle: string, albumName: string | undefined, @@ -224,7 +228,7 @@ export async function searchWithStrategies( ); try { - const responses = await client.search(query, { + const responses = await search(query, { timeout: timeoutMs, onResult: onResult }); diff --git a/backend/src/services/soulseek.ts b/backend/src/services/soulseek.ts index ec8c299..ebd2185 100644 --- a/backend/src/services/soulseek.ts +++ b/backend/src/services/soulseek.ts @@ -47,6 +47,145 @@ export interface SearchTrackResult { allMatches: TrackMatch[]; } +/** + * Sliding window rate limiter for Soulseek searches. + * Empirically safe limit from slsk-batchdl: 34 searches / 220s. + * We use 30/220s for extra safety (~8.2/min). + */ +interface RateLimiterWaiter { + resolve: () => void; + reject: (err: Error) => void; + settled: boolean; + timeout: NodeJS.Timeout | null; +} + +class SlidingWindowRateLimiter { + private timestamps: number[] = []; + private waitQueue: RateLimiterWaiter[] = []; + private drainTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly maxRequests: number = 30, + private readonly windowMs: number = 220_000, + private readonly maxWaitMs: number = 600_000 + ) {} + + async acquire(signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + + const now = Date.now(); + const windowStart = now - this.windowMs; + this.timestamps = this.timestamps.filter(t => t > windowStart); + + if (this.timestamps.length < this.maxRequests) { + this.timestamps.push(now); + return; + } + + // Window is full -- wait for the oldest entry to expire + const oldestInWindow = this.timestamps[0]; + const waitMs = oldestInWindow + this.windowMs - now; + + if (waitMs > this.maxWaitMs) { + throw new Error(`Rate limiter wait ${Math.round(waitMs / 1000)}s exceeds max ${Math.round(this.maxWaitMs / 1000)}s`); + } + + return new Promise((resolve, reject) => { + const entry: RateLimiterWaiter = { resolve, reject, settled: false, timeout: null }; + this.waitQueue.push(entry); + + const onAbort = () => { + if (entry.settled) return; + entry.settled = true; + if (entry.timeout) clearTimeout(entry.timeout); + const idx = this.waitQueue.indexOf(entry); + if (idx !== -1) this.waitQueue.splice(idx, 1); + reject(new DOMException('The operation was aborted.', 'AbortError')); + }; + + signal?.addEventListener('abort', onAbort, { once: true }); + + entry.timeout = setTimeout(() => { + if (entry.settled) return; + entry.settled = true; + const idx = this.waitQueue.indexOf(entry); + if (idx !== -1) { + this.waitQueue.splice(idx, 1); + } + signal?.removeEventListener('abort', onAbort); + this.timestamps.push(Date.now()); + resolve(); + }, waitMs); + + // Schedule drain to process queued waiters + if (!this.drainTimer && this.waitQueue.length === 1) { + this.drainTimer = setTimeout(() => { + this.drainTimer = null; + this.drainQueue(); + }, waitMs); + } + }); + } + + private drainQueue(): void { + const now = Date.now(); + const windowStart = now - this.windowMs; + this.timestamps = this.timestamps.filter(t => t > windowStart); + + while (this.waitQueue.length > 0 && this.timestamps.length < this.maxRequests) { + const entry = this.waitQueue.shift()!; + if (entry.settled) continue; + entry.settled = true; + if (entry.timeout) { + clearTimeout(entry.timeout); + } + this.timestamps.push(now); + entry.resolve(); + } + + // Schedule next drain if still waiting + if (this.waitQueue.length > 0 && this.timestamps.length > 0) { + const oldestInWindow = this.timestamps[0]; + const waitMs = oldestInWindow + this.windowMs - Date.now(); + if (waitMs > 0) { + this.drainTimer = setTimeout(() => { + this.drainTimer = null; + this.drainQueue(); + }, waitMs); + } + } + } + + destroy(): void { + if (this.drainTimer) { + clearTimeout(this.drainTimer); + this.drainTimer = null; + } + for (const entry of this.waitQueue) { + if (entry.timeout) { + clearTimeout(entry.timeout); + } + if (!entry.settled) { + entry.settled = true; + entry.reject(new Error('Rate limiter destroyed')); + } + } + this.waitQueue = []; + this.timestamps = []; + } + + get remaining(): number { + const windowStart = Date.now() - this.windowMs; + return Math.max(0, this.maxRequests - this.timestamps.filter(t => t > windowStart).length); + } + + get waiting(): number { + return this.waitQueue.length; + } +} + export class SoulseekService { private client: SlskClient | null = null; private connecting = false; @@ -80,6 +219,8 @@ export class SoulseekService { private readonly CONNECT_TIMEOUT = 10000; // 10s (slskd default) private readonly LOGIN_TIMEOUT = 10000; // 10s (reduced from 15s) + private searchRateLimiter = new SlidingWindowRateLimiter(30, 220_000); + constructor() { this.connectEagerly(); } @@ -341,7 +482,10 @@ export class SoulseekService { this.forceDisconnect(); } - // Apply exponential backoff (slskd practice) + // Apply exponential backoff (slskd practice). + // NOTE: This sleeps inside the distributed lock. Lock TTL (360s) exceeds max + // backoff (300s). Other callers hitting the lock fall through to waitForConnection() + // which polls for 30s -- acceptable since long backoffs only occur after many failures. const backoffDelay = force ? 0 : this.getReconnectDelay(); if (backoffDelay > 0) { const now = Date.now(); @@ -356,9 +500,7 @@ export class SoulseekService { `Exponential backoff: waiting ${Math.round(waitMs / 1000)}s before reconnect attempt (attempt #${this.failedConnectionAttempts})`, "WARN" ); - throw new Error( - `Connection backoff - wait ${Math.round(waitMs / 1000)}s before retry (attempt ${this.failedConnectionAttempts})` - ); + await new Promise(resolve => setTimeout(resolve, waitMs)); } } @@ -458,6 +600,32 @@ export class SoulseekService { } } + /** + * Rate-limited search that gates on the sliding window and waits through backoff. + * Search timeout measures only network time, not rate limiter wait. + */ + private async rateLimitedSearch( + query: string, + options?: { timeout?: number; onResult?: (result: FileSearchResponse) => void; signal?: AbortSignal } + ): Promise { + if (options?.signal?.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + if (this.searchRateLimiter.remaining === 0) { + sessionLog( + "SOULSEEK", + `Rate limiter full (${this.searchRateLimiter.waiting} waiting), throttling search`, + "WARN" + ); + } + await this.searchRateLimiter.acquire(options?.signal); + if (options?.signal?.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + await this.ensureConnected(); + return this.client!.search(query, options ?? {}); + } + /** * Search for a track on Soulseek * @@ -470,8 +638,12 @@ export class SoulseekService { albumName?: string, isRetry: boolean = false, timeoutMs: number = 15000, - onResult?: (result: FileSearchResponse) => void + onResult?: (result: FileSearchResponse) => void, + signal?: AbortSignal ): Promise { + if (signal?.aborted) { + return { found: false, bestMatch: null, allMatches: [] }; + } const metricsStartTime = Date.now(); this.totalSearches++; const searchId = this.totalSearches; @@ -479,6 +651,9 @@ export class SoulseekService { ? Math.round((Date.now() - this.connectedAt.getTime()) / 1000) : 0; + // Pre-flight connection check -- fail fast before consuming rate limiter slots. + // rateLimitedSearch also calls ensureConnected, but this avoids wasting a slot + // when credentials are missing or the service is disabled. try { await this.ensureConnected(); } catch (err: any) { @@ -508,10 +683,15 @@ export class SoulseekService { const searchStartTime = Date.now(); + // Inject signal into bound search function so strategies abort on cancellation + const boundSearch = signal + ? (query: string, opts?: any) => this.rateLimitedSearch(query, { ...opts, signal }) + : this.rateLimitedSearch.bind(this); + try { // Delegate to optimized multi-strategy search const responses = await searchWithStrategies( - this.client, + boundSearch, artistName, trackTitle, albumName, @@ -589,7 +769,8 @@ export class SoulseekService { albumName, true, timeoutMs, - onResult + onResult, + signal ); } } @@ -1238,11 +1419,12 @@ async searchAndDownloadAlbum( artistName: string, albumName: string, tracks: Array<{ title: string; position?: number }>, - musicPath: string + musicPath: string, + signal?: AbortSignal ): Promise<{ successful: number; failed: number; files: string[]; errors: string[] }> { const results = { successful: 0, failed: 0, files: [] as string[], errors: [] as string[] }; - if (tracks.length === 0) { + if (signal?.aborted || tracks.length === 0) { return results; } @@ -1259,23 +1441,6 @@ async searchAndDownloadAlbum( sessionLog("SOULSEEK", `[Album Search] "${query}" (${tracks.length} tracks, VA=${isVA})`); - // Fix 5: Wrap ensureConnected + search in try/catch - try { - await this.ensureConnected(); - } catch (err: any) { - sessionLog("SOULSEEK", `[Album Search] Connection failed: ${err.message}, falling back to per-track`, "WARN"); - return this.searchAndDownloadBatch( - tracks.map(t => ({ artist: artistName, title: t.title, album: albumName })), - musicPath, - 2 - ); - } - if (!this.client) { - results.failed = tracks.length; - results.errors.push("Not connected to Soulseek"); - return results; - } - const audioExtensions = [".flac", ".mp3", ".m4a", ".ogg", ".opus", ".wav", ".aac"]; const countAudioFiles = (resps: FileSearchResponse[]): number => { @@ -1290,25 +1455,26 @@ async searchAndDownloadAlbum( return count; }; - // Fix 5: Wrap search calls in try/catch - // Fix 6: Reduced timeouts (8s primary, 6s fallback) per slsk-batchdl/Soularr practice + // Rate-limited searches with reduced timeouts (8s primary, 6s fallback) per slsk-batchdl/Soularr practice let responses: FileSearchResponse[]; let audioCount: number; try { - responses = await this.client.search(query, { timeout: 8000 }); + responses = await this.rateLimitedSearch(query, { timeout: 8000, signal }); audioCount = countAudioFiles(responses); if (audioCount === 0 && !isVA) { sessionLog("SOULSEEK", `[Album Search] Primary query returned 0 audio files, trying album-only fallback`); - responses = await this.client.search(normalizedAlbum, { timeout: 6000 }); + responses = await this.rateLimitedSearch(normalizedAlbum, { timeout: 6000, signal }); audioCount = countAudioFiles(responses); } } catch (err: any) { + if (err?.name === 'AbortError') throw err; sessionLog("SOULSEEK", `[Album Search] Search failed: ${err.message}, falling back to per-track`, "WARN"); return this.searchAndDownloadBatch( tracks.map(t => ({ artist: artistName, title: t.title, album: albumName })), musicPath, - 2 + 2, + signal ); } @@ -1317,14 +1483,15 @@ async searchAndDownloadAlbum( return this.searchAndDownloadBatch( tracks.map(t => ({ artist: artistName, title: t.title, album: albumName })), musicPath, - 2 + 2, + signal ); } // --- Phase 2: Group results by user + parent directory --- const flatResults = this.flattenSearchResults(responses); - // Fix 3: Filter blocked users before grouping + // Filter blocked users before grouping const blockChecks = await Promise.all( flatResults.map(async (r) => ({ result: r, @@ -1389,7 +1556,8 @@ async searchAndDownloadAlbum( return this.searchAndDownloadBatch( tracks.map(t => ({ artist: artistName, title: t.title, album: albumName })), musicPath, - 2 + 2, + signal ); } @@ -1510,7 +1678,7 @@ async searchAndDownloadAlbum( scoredGroups.sort((a, b) => b.totalScore - a.totalScore); - // Fix 2: Try multiple groups (top 3) before falling back to per-track + // Try multiple groups (top 3) before falling back to per-track const groupsToTry = scoredGroups.filter(g => g.matchRatio >= 0.3).slice(0, 3); if (groupsToTry.length === 0) { @@ -1519,7 +1687,8 @@ async searchAndDownloadAlbum( return this.searchAndDownloadBatch( tracks.map(t => ({ artist: artistName, title: t.title, album: albumName })), musicPath, - 2 + 2, + signal ); } @@ -1548,12 +1717,16 @@ async searchAndDownloadAlbum( if (tracksToDownload.size === 0) continue; - // Fix 4: Parallel downloads with PQueue (concurrency 3) + // Parallel downloads with PQueue (concurrency 3) const downloadQueue = new PQueue({ concurrency: 3 }); let groupFailures = 0; + signal?.addEventListener('abort', () => { downloadQueue.clear(); }, { once: true }); + const downloadPromises = Array.from(tracksToDownload.entries()).map(([trackIdx, file]) => downloadQueue.add(async () => { + if (signal?.aborted) return; + const track = tracks[trackIdx]; const ext = path.extname(file.file); const destPath = path.join( @@ -1584,7 +1757,7 @@ async searchAndDownloadAlbum( results.errors.push(`${track.title}: ${downloadResult.error || "download failed"} (user: ${username})`); sessionLog("SOULSEEK", `[Album Search] Download failed for "${track.title}" from ${username}: ${downloadResult.error}`, "WARN"); } - }) + }, signal ? { signal } : {}) ); const downloadSettled = await Promise.allSettled(downloadPromises); @@ -1624,7 +1797,7 @@ async searchAndDownloadAlbum( album: albumName, })); - const fallbackResult = await this.searchAndDownloadBatch(fallbackTracks, musicPath, 2); + const fallbackResult = await this.searchAndDownloadBatch(fallbackTracks, musicPath, 2, signal); results.successful += fallbackResult.successful; results.failed += fallbackResult.failed; results.files.push(...fallbackResult.files); @@ -1642,14 +1815,14 @@ async searchAndDownloadAlbum( async searchAndDownloadBatch( tracks: Array<{ artist: string; title: string; album: string }>, musicPath: string, - concurrency?: number + concurrency?: number, + signal?: AbortSignal ): Promise<{ successful: number; failed: number; files: string[]; errors: string[]; }> { - const downloadQueue = new PQueue({ concurrency: concurrency ?? 2 }); const results: { successful: number; failed: number; @@ -1662,17 +1835,27 @@ async searchAndDownloadBatch( errors: [], }; + if (signal?.aborted) return results; + + const downloadQueue = new PQueue({ concurrency: concurrency ?? 2 }); + const searchQueue = new PQueue({ concurrency: concurrency ?? 2 }); + + signal?.addEventListener('abort', () => { + searchQueue.clear(); + downloadQueue.clear(); + }, { once: true }); + sessionLog( "SOULSEEK", `Searching for ${tracks.length} tracks with concurrency ${concurrency ?? 2}...` ); - const searchQueue = new PQueue({ concurrency: concurrency ?? 2 }); const searchPromises = tracks.map((track) => searchQueue.add(() => - this.searchTrack(track.artist, track.title, track.album).then((result) => ({ + this.searchTrack(track.artist, track.title, track.album, false, 15000, undefined, signal).then((result) => ({ track, result, - })) + })), + signal ? { signal } : {} ) ); const searchSettled = await Promise.allSettled(searchPromises); @@ -1712,7 +1895,8 @@ async searchAndDownloadBatch( track.title, track.album, result.allMatches, - musicPath + musicPath, + signal ); if (downloadResult.success && downloadResult.filePath) { results.successful++; @@ -1723,7 +1907,7 @@ async searchAndDownloadBatch( `${track.artist} - ${track.title}: ${downloadResult.error || "Unknown error"}` ); } - }) + }, signal ? { signal } : {}) ); const downloadSettled = await Promise.allSettled(downloadPromises); @@ -1748,7 +1932,8 @@ private async downloadWithRetry( trackTitle: string, albumName: string, allMatches: TrackMatch[], - musicPath: string + musicPath: string, + signal?: AbortSignal ): Promise<{ success: boolean; filePath?: string; error?: string }> { const sanitize = (name: string) => name.replace(/[<>:"/\\|?*]/g, "_").trim(); @@ -1758,6 +1943,9 @@ private async downloadWithRetry( const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); for (let attempt = 0; attempt < matchesToTry.length; attempt++) { + if (signal?.aborted) { + return { success: false, error: 'Import cancelled' }; + } const match = matchesToTry[attempt]; sessionLog( @@ -1841,6 +2029,8 @@ private async downloadWithRetry( } this.client = null; this.connectedAt = null; + this.searchRateLimiter.destroy(); + this.searchRateLimiter = new SlidingWindowRateLimiter(30, 220_000); soulseekConnectionStatus.set(0); sessionLog("SOULSEEK", "Disconnected"); } diff --git a/backend/src/services/spotifyImport.ts b/backend/src/services/spotifyImport.ts index 0db289b..6db757d 100644 --- a/backend/src/services/spotifyImport.ts +++ b/backend/src/services/spotifyImport.ts @@ -351,6 +351,8 @@ function stringSimilarity(a: string, b: string): number { } class SpotifyImportService { + private activeImportControllers = new Map(); + /** * Match a Spotify track to the local library * @@ -951,13 +953,18 @@ class SpotifyImportService { const logPrefix = source === "Spotify" ? "[Spotify Import]" : "[Deezer Import]"; + // Check if Lidarr is configured -- MusicBrainz resolution is only needed for Lidarr + const previewSettings = await getSystemSettings(); + const lidarrEnabled = !!(previewSettings?.lidarrEnabled && previewSettings?.lidarrUrl && previewSettings?.lidarrApiKey); + // PHASE 0: Early MusicBrainz resolution for "Unknown Album" tracks // This MUST happen BEFORE grouping so tracks get grouped by actual albums + // Only needed when Lidarr is configured (Soulseek searches by text, not MBID) const unknownCount = tracks.filter( (t) => t.album === "Unknown Album", ).length; - if (unknownCount > 0) { + if (unknownCount > 0 && lidarrEnabled) { logger?.info( `${logPrefix} Found ${unknownCount} tracks with Unknown Album, attempting MusicBrainz resolution...`, ); @@ -980,6 +987,10 @@ class SpotifyImportService { `${logPrefix} ${stillUnknown} tracks still have Unknown Album after MusicBrainz resolution`, ); } + } else if (unknownCount > 0) { + logger?.info( + `${logPrefix} Skipping MusicBrainz resolution for ${unknownCount} Unknown Album tracks (Lidarr not configured)`, + ); } // Phase 1: Parallel DB lookups (matchTrack is DB-only, safe to parallelise). @@ -1014,94 +1025,96 @@ class SpotifyImportService { let albumMbid: string | null = null; const firstTrack = albumTracks[0]; - const wasMbResolved = firstTrack.albumId?.startsWith("mbid:"); - const preResolvedMbid = wasMbResolved - ? firstTrack.albumId!.replace("mbid:", "") - : null; logger?.debug(`\n${logPrefix} ========================================`); logger?.debug( `${logPrefix} Looking up: "${artistName}" - "${albumName}"`, ); - if (preResolvedMbid) { - albumMbid = preResolvedMbid; - logger?.debug(`${logPrefix} Using pre-resolved MBID: ${albumMbid}`); - const artists = await musicBrainzService.searchArtist(artistName, 1); - if (artists && artists.length > 0) { - artistMbid = artists[0].id; - } - } else if (albumName && albumName !== "Unknown Album") { - const normalizedAlbumName = stripTrackSuffix(albumName); - const wasNormalized = normalizedAlbumName !== albumName; + if (lidarrEnabled) { + const wasMbResolved = firstTrack.albumId?.startsWith("mbid:"); + const preResolvedMbid = wasMbResolved + ? firstTrack.albumId!.replace("mbid:", "") + : null; - logger?.debug( - `${logPrefix} Searching for album "${albumName}" by ${artistName}...`, - ); - if (wasNormalized) { - logger?.debug( - `${logPrefix} → Normalized to: "${normalizedAlbumName}"`, - ); - } - - const mbResult = await this.findAlbumMbid( - artistName, - normalizedAlbumName, - ); - artistMbid = mbResult.artistMbid; - albumMbid = mbResult.albumMbid; - - if (albumMbid) { - logger?.debug( - `${logPrefix} ✓ Found album directly: "${albumName}" (MBID: ${albumMbid})`, - ); - } - } - - if (!albumMbid) { - logger?.debug( - `${logPrefix} Album not found, trying track-based search...`, - ); - for (const track of albumTracks) { - const normalizedTrackTitle = stripTrackSuffix(track.title); - const wasNormalized = normalizedTrackTitle !== track.title; + if (preResolvedMbid) { + albumMbid = preResolvedMbid; + logger?.debug(`${logPrefix} Using pre-resolved MBID: ${albumMbid}`); + const artists = await musicBrainzService.searchArtist(artistName, 1); + if (artists && artists.length > 0) { + artistMbid = artists[0].id; + } + } else if (albumName && albumName !== "Unknown Album") { + const normalizedAlbumName = stripTrackSuffix(albumName); + const wasNormalized = normalizedAlbumName !== albumName; logger?.debug( - `${logPrefix} Searching for track "${track.title}"...`, + `${logPrefix} Searching for album "${albumName}" by ${artistName}...`, ); if (wasNormalized) { logger?.debug( - `${logPrefix} → Normalized to: "${normalizedTrackTitle}"`, + `${logPrefix} → Normalized to: "${normalizedAlbumName}"`, ); } - const recordingInfo = await musicBrainzService.searchRecording( - normalizedTrackTitle, + const mbResult = await this.findAlbumMbid( artistName, + normalizedAlbumName, ); + artistMbid = mbResult.artistMbid; + albumMbid = mbResult.albumMbid; - if (recordingInfo) { - resolvedAlbumName = recordingInfo.albumName; - artistMbid = recordingInfo.artistMbid; - albumMbid = recordingInfo.albumMbid; + if (albumMbid) { + logger?.debug( + `${logPrefix} ✓ Found album directly: "${albumName}" (MBID: ${albumMbid})`, + ); + } + } + + if (!albumMbid) { + logger?.debug( + `${logPrefix} Album not found, trying track-based search...`, + ); + for (const track of albumTracks) { + const normalizedTrackTitle = stripTrackSuffix(track.title); + const wasNormalized = normalizedTrackTitle !== track.title; logger?.debug( - `${logPrefix} ✓ Found via track: "${resolvedAlbumName}" (MBID: ${albumMbid})`, + `${logPrefix} Searching for track "${track.title}"...`, ); - break; + if (wasNormalized) { + logger?.debug( + `${logPrefix} → Normalized to: "${normalizedTrackTitle}"`, + ); + } + + const recordingInfo = await musicBrainzService.searchRecording( + normalizedTrackTitle, + artistName, + ); + + if (recordingInfo) { + resolvedAlbumName = recordingInfo.albumName; + artistMbid = recordingInfo.artistMbid; + albumMbid = recordingInfo.albumMbid; + + logger?.debug( + `${logPrefix} ✓ Found via track: "${resolvedAlbumName}" (MBID: ${albumMbid})`, + ); + break; + } } } - } - if (!albumMbid) { - logger?.debug( - `${logPrefix} ✗ Could not find album MBID for ${artistName} - "${resolvedAlbumName}"`, - ); - if (albumName === "Unknown Album") { + if (!albumMbid) { logger?.debug( - `${logPrefix} ℹ But can still download via Soulseek (track-based search)`, + `${logPrefix} ✗ Could not find album MBID for ${artistName} - "${resolvedAlbumName}"`, ); } + } else { + logger?.info( + `${logPrefix} Skipping MBID resolution for "${resolvedAlbumName}" (Lidarr not configured)`, + ); } const albumToDownload: AlbumToDownload = { @@ -1154,6 +1167,12 @@ class SpotifyImportService { 0, ); + const withMbid = albumsToDownload.filter(a => a.albumMbid).length; + const withoutMbid = albumsToDownload.length - withMbid; + logger?.info( + `${logPrefix} Preview complete: ${playlistMeta.trackCount} tracks, ${inLibrary} in library, ${albumsToDownload.length} albums to download (${withMbid} with MBID, ${withoutMbid} without)`, + ); + return { playlist: playlistMeta, matchedTracks, @@ -1210,11 +1229,6 @@ class SpotifyImportService { // Clear any stale null cache entries before processing await musicBrainzService.clearStaleRecordingCaches(); - logger?.debug( - "[Deezer Debug] Sample track from Deezer:", - JSON.stringify(deezerPlaylist.tracks[0], null, 2), - ); - const spotifyTracks: SpotifyTrack[] = deezerPlaylist.tracks.map( (track: any, index: number) => ({ spotifyId: track.deezerId, @@ -1231,11 +1245,6 @@ class SpotifyImportService { }), ); - logger?.debug( - "[Deezer Debug] Sample converted track:", - JSON.stringify(spotifyTracks[0], null, 2), - ); - return this.buildPreviewFromTracklist( spotifyTracks, { @@ -1443,6 +1452,7 @@ class SpotifyImportService { preview: ImportPreview, ): Promise { const logger = jobLoggers.get(job.id); + let abortController: AbortController | undefined; try { // Guard: if cancelled between startImport() and here, abort @@ -1451,242 +1461,261 @@ class SpotifyImportService { return; } - // Phase 1: Download albums using AcquisitionService - if (albumMbidsToDownload.length > 0) { - job.status = "downloading"; - job.updatedAt = new Date(); - await saveImportJob(job); + // Create AbortController for cancellation support + abortController = new AbortController(); + const { signal } = abortController; + this.activeImportControllers.set(job.id, abortController); - logger?.logAlbumDownloadStart(albumMbidsToDownload.length); + try { + // Phase 1: Download albums using AcquisitionService + if (albumMbidsToDownload.length > 0) { + job.status = "downloading"; + job.updatedAt = new Date(); + await saveImportJob(job); - logger?.debug( - `[Spotify Import] Processing ${albumMbidsToDownload.length} albums via AcquisitionService`, - ); - logger?.info( - `Processing ${albumMbidsToDownload.length} albums via AcquisitionService`, - ); + logger?.logAlbumDownloadStart(albumMbidsToDownload.length); - // Process albums in parallel with concurrency limit from settings - const settings = await getSystemSettings(); - const albumQueue = new PQueue({ - concurrency: settings?.soulseekConcurrentDownloads ?? 1, - }); + logger?.info( + `Processing ${albumMbidsToDownload.length} albums via AcquisitionService`, + ); - const albumPromises = albumMbidsToDownload.map((albumIdentifier) => - albumQueue.add(async () => { - // Find ALL matching album groups - multiple Spotify album editions - // may resolve to the same MusicBrainz MBID (e.g., "The Fall of Math" - // and "The Fall of Math (Deluxe Edition)"). Merge their tracksNeeded. - const matchingAlbums = preview.albumsToDownload.filter( - (a) => - a.albumMbid === albumIdentifier || - a.spotifyAlbumId === albumIdentifier, - ); - if (matchingAlbums.length === 0) return; + // Process albums in parallel with concurrency limit from settings + const settings = await getSystemSettings(); + const albumQueue = new PQueue({ + concurrency: settings?.soulseekConcurrentDownloads ?? 1, + }); - let album: AlbumToDownload; - if (matchingAlbums.length === 1) { - album = matchingAlbums[0]; - } else { - // Merge tracksNeeded, deduplicate by title - const seen = new Set(); - const mergedTracks = matchingAlbums.flatMap((a) => a.tracksNeeded).filter((t) => { - const key = t.title.toLowerCase(); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - album = { - ...matchingAlbums[0], - tracksNeeded: mergedTracks, - trackCount: mergedTracks.length, - }; - logger?.debug( - `[Spotify Import] Merged ${matchingAlbums.length} album editions for "${album.albumName}" (${mergedTracks.length} unique tracks)`, + signal.addEventListener('abort', () => { albumQueue.clear(); }, { once: true }); + + const albumPromises = albumMbidsToDownload.map((albumIdentifier) => + albumQueue.add(async () => { + // Find ALL matching album groups - multiple Spotify album editions + // may resolve to the same MusicBrainz MBID (e.g., "The Fall of Math" + // and "The Fall of Math (Deluxe Edition)"). Merge their tracksNeeded. + const matchingAlbums = preview.albumsToDownload.filter( + (a) => + a.albumMbid === albumIdentifier || + a.spotifyAlbumId === albumIdentifier, ); - } + if (matchingAlbums.length === 0) return; - try { - const isUnknownAlbum = - album.albumName === "Unknown Album" || !album.albumMbid; - - logger?.info( - `Album start: ${album.artistName} - ${album.albumName}${ - album.albumMbid - ? ` [MBID: ${album.albumMbid}]` - : " [Unknown Album]" - } (tracksNeeded=${album.tracksNeeded.length})`, - ); - - logger?.debug( - `[Spotify Import] Requesting: ${album.artistName} - ${album.albumName}`, - ); - - // Validate userId before creating acquisition context - if ( - !job.userId || - typeof job.userId !== "string" || - job.userId === "NaN" || - job.userId === "undefined" || - job.userId === "null" - ) { - logger?.error( - `[Spotify Import] Invalid userId in job: ${JSON.stringify({ - jobId: job.id, - userId: job.userId, - typeofUserId: typeof job.userId, - })}`, + let album: AlbumToDownload; + if (matchingAlbums.length === 1) { + album = matchingAlbums[0]; + } else { + // Merge tracksNeeded, deduplicate by title + const seen = new Set(); + const mergedTracks = matchingAlbums.flatMap((a) => a.tracksNeeded).filter((t) => { + const key = t.title.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + album = { + ...matchingAlbums[0], + tracksNeeded: mergedTracks, + trackCount: mergedTracks.length, + }; + logger?.debug( + `[Spotify Import] Merged ${matchingAlbums.length} album editions for "${album.albumName}" (${mergedTracks.length} unique tracks)`, ); - throw new Error(`Invalid userId in import job: ${job.userId}`); } - // Acquisition context for tracking - const context = { - userId: job.userId, - spotifyImportJobId: job.id, - }; + try { + const isUnknownAlbum = + album.albumName === "Unknown Album"; - let result; + logger?.info( + `Album start: ${album.artistName} - ${album.albumName}${ + album.albumMbid + ? ` [MBID: ${album.albumMbid}]` + : " [Unknown Album]" + } (tracksNeeded=${album.tracksNeeded.length})`, + ); - if (isUnknownAlbum) { - // Unknown Album: Use track-based acquisition logger?.debug( - `[Spotify Import] Unknown Album detected - using track acquisition`, + `[Spotify Import] Requesting: ${album.artistName} - ${album.albumName}`, ); - const trackRequests = album.tracksNeeded.map((track) => ({ - trackTitle: track.title, - artistName: track.artist, - albumTitle: album.albumName, - })); + // Validate userId before creating acquisition context + if ( + !job.userId || + typeof job.userId !== "string" || + job.userId === "NaN" || + job.userId === "undefined" || + job.userId === "null" + ) { + logger?.error( + `[Spotify Import] Invalid userId in job: ${JSON.stringify({ + jobId: job.id, + userId: job.userId, + typeofUserId: typeof job.userId, + })}`, + ); + throw new Error(`Invalid userId in import job: ${job.userId}`); + } - const trackResults = await acquisitionService.acquireTracks( - trackRequests, - context, - ); - - // Check if at least 50% succeeded - const successCount = trackResults.filter( - (r) => r.success, - ).length; - const successThreshold = Math.ceil(trackRequests.length * 0.5); - - result = { - success: successCount >= successThreshold, - tracksDownloaded: successCount, - tracksTotal: trackRequests.length, + // Acquisition context for tracking + const context = { + userId: job.userId, + spotifyImportJobId: job.id, + signal, }; - if (result.success) { - logger?.info( - `Unknown Album tracks success: ${album.artistName} - ${successCount}/${trackRequests.length} tracks`, + let result; + + if (isUnknownAlbum) { + // Unknown Album: Use track-based acquisition + logger?.debug( + `[Spotify Import] Unknown Album detected - using track acquisition`, ); - } - } else { - // Regular album: Use album-based acquisition - result = await acquisitionService.acquireAlbum( - { + + const trackRequests = album.tracksNeeded.map((track) => ({ + trackTitle: track.title, + artistName: track.artist, albumTitle: album.albumName, - artistName: album.artistName, - mbid: album.albumMbid!, - requestedTracks: album.tracksNeeded.map((t) => ({ - title: t.title, - })), - }, - context, - ); + })); - if (result.success) { - logger?.info( - `Album acquisition success: ${album.artistName} - ${album.albumName} via ${result.source}`, + const trackResults = await acquisitionService.acquireTracks( + trackRequests, + context, + ); + + // Check if at least 50% succeeded + const successCount = trackResults.filter( + (r) => r.success, + ).length; + const successThreshold = Math.ceil(trackRequests.length * 0.5); + + result = { + success: successCount >= successThreshold, + tracksDownloaded: successCount, + tracksTotal: trackRequests.length, + }; + + if (result.success) { + logger?.info( + `Unknown Album tracks success: ${album.artistName} - ${successCount}/${trackRequests.length} tracks`, + ); + } + } else { + // Regular album: Use album-based acquisition + result = await acquisitionService.acquireAlbum( + { + albumTitle: album.albumName, + artistName: album.artistName, + mbid: album.albumMbid || undefined, + requestedTracks: album.tracksNeeded.map((t) => ({ + title: t.title, + })), + }, + context, + ); + + if (result.success) { + logger?.info( + `Album acquisition success: ${album.artistName} - ${album.albumName} via ${result.source}`, + ); + } + } + + if (!result.success) { + const errorMsg = + result.error || "No download sources available"; + logger?.debug( + `[Spotify Import] ✗ Failed: ${album.albumName} - ${errorMsg}`, + ); + logger?.logAlbumFailed( + album.albumName, + album.artistName, + errorMsg, ); } - } - if (!result.success) { - const errorMsg = - result.error || "No download sources available"; + job.albumsCompleted++; + job.progress = Math.round( + (job.albumsCompleted / job.albumsTotal) * 30, + ); + job.updatedAt = new Date(); + await saveImportJob(job); + logger?.debug( - `[Spotify Import] ✗ Failed: ${album.albumName} - ${errorMsg}`, + `Album done: ${album.artistName} - ${ + album.albumName + } (success=${result.success ? "yes" : "no"})`, + ); + } catch (error: any) { + if (error?.name === 'AbortError' || signal.aborted) return; + logger?.error( + `[Spotify Import] Failed: ${album.artistName} - ${album.albumName}: ${error.message}`, ); logger?.logAlbumFailed( album.albumName, album.artistName, - errorMsg, + error.message, ); } + }, { signal }), + ); - job.albumsCompleted++; - job.progress = Math.round( - (job.albumsCompleted / job.albumsTotal) * 30, - ); - job.updatedAt = new Date(); - await saveImportJob(job); + // Wait for all album acquisitions to complete + await Promise.all(albumPromises); - logger?.debug( - `Album done: ${album.artistName} - ${ - album.albumName - } (success=${result.success ? "yes" : "no"})`, - ); - } catch (error: any) { - logger?.error( - `[Spotify Import] Failed: ${album.artistName} - ${album.albumName}: ${error.message}`, - ); - logger?.logAlbumFailed( - album.albumName, - album.artistName, - error.message, - ); + logger?.info( + `Initial acquisition phase finished for ${albumMbidsToDownload.length} album(s). Checking completion state...`, + ); + + // Poll checkImportCompletion until the job reaches a terminal state. + // In-process Soulseek downloads complete inline, but Lidarr downloads + // finish asynchronously via webhook. Poll every 15s, timeout at 30min. + // BullMQ auto-renews the lock every lockDuration/2 (5 min) as long as + // the event loop is responsive. The 15s sleep between polls ensures this. + const POLL_INTERVAL_MS = 15_000; + const MAX_WAIT_MS = 30 * 60 * 1000; + const TERMINAL_STATES = ["completed", "failed", "cancelled", "scanning", "creating_playlist"]; + const pollStart = Date.now(); + + while (true) { + if (signal.aborted) { + logger?.info(`[Spotify Import] Job ${job.id}: aborted during completion poll`); + return; } - }), - ); - // Wait for all album acquisitions to complete - await Promise.all(albumPromises); - - logger?.info( - `Initial acquisition phase finished for ${albumMbidsToDownload.length} album(s). Checking completion state...`, - ); - - // Poll checkImportCompletion until the job reaches a terminal state. - // In-process Soulseek downloads complete inline, but Lidarr downloads - // finish asynchronously via webhook. Poll every 15s, timeout at 30min. - // BullMQ auto-renews the lock every lockDuration/2 (5 min) as long as - // the event loop is responsive. The 15s sleep between polls ensures this. - const POLL_INTERVAL_MS = 15_000; - const MAX_WAIT_MS = 30 * 60 * 1000; - const TERMINAL_STATES = ["completed", "failed", "cancelled", "scanning", "creating_playlist"]; - const pollStart = Date.now(); - - while (true) { - await this.checkImportCompletion(job.id); - - const currentJob = await getImportJob(job.id); - if (!currentJob) { - logger?.error(`[Spotify Import] Job ${job.id}: not found during completion poll`); - return; - } - - if (TERMINAL_STATES.includes(currentJob.status)) { - logger?.info(`Import job ${job.id} handed off to next phase: ${currentJob.status}`); - return; - } - - if (Date.now() - pollStart > MAX_WAIT_MS) { - logger?.warn(`[Spotify Import] Job ${job.id}: timed out after 30 minutes, running final completion check`); await this.checkImportCompletion(job.id); - return; + + const currentJob = await getImportJob(job.id); + if (!currentJob) { + logger?.error(`[Spotify Import] Job ${job.id}: not found during completion poll`); + return; + } + + if (TERMINAL_STATES.includes(currentJob.status)) { + logger?.info(`Import job ${job.id} handed off to next phase: ${currentJob.status}`); + return; + } + + if (Date.now() - pollStart > MAX_WAIT_MS) { + logger?.warn(`[Spotify Import] Job ${job.id}: timed out after 30 minutes, running final completion check`); + await this.checkImportCompletion(job.id); + return; + } + + logger?.debug(`[Spotify Import] Job ${job.id}: still ${currentJob.status}, polling again in 15s...`); + await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)); } - - logger?.debug(`[Spotify Import] Job ${job.id}: still ${currentJob.status}, polling again in 15s...`); - await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)); } - } - // No downloads needed - all tracks already in library - // Create playlist immediately - await this.buildPlaylist(job); + // No downloads needed - all tracks already in library + // Create playlist immediately + await this.buildPlaylist(job); + } finally { + this.activeImportControllers.delete(job.id); + } } catch (error: any) { + if (error?.name === 'AbortError' || abortController?.signal.aborted) { + logger?.info(`[Spotify Import] Job ${job.id}: import cancelled, returning cleanly`); + return; + } job.status = "failed"; job.error = error.message; job.updatedAt = new Date(); @@ -2577,6 +2606,10 @@ class SpotifyImportService { }; } + // Abort in-flight searches and downloads + const controller = this.activeImportControllers.get(jobId); + if (controller) controller.abort(); + // Mark any pending download jobs as cancelled await prisma.downloadJob.updateMany({ where: { @@ -2718,57 +2751,6 @@ class SpotifyImportService { const strippedTitle = stripTrackSuffix(pendingTrack.spotifyTitle); const cleanedTitle = normalizeTrackTitle(strippedTitle); - logger?.debug( - ` Trying to match: "${pendingTrack.spotifyTitle}" by ${pendingTrack.spotifyArtist}`, - ); - logger?.debug( - ` strippedTitle: "${strippedTitle}", artistFirstWord: "${artistFirstWord}"`, - ); - - // Debug: Check what tracks exist for this artist - const artistTracks = await prisma.track.findMany({ - where: { - album: { - artist: { - normalizedName: { - contains: artistFirstWord, - mode: "insensitive", - }, - }, - }, - }, - select: { - title: true, - album: { - select: { - artist: { - select: { - name: true, - normalizedName: true, - }, - }, - }, - }, - }, - take: 5, - }); - if (artistTracks.length > 0) { - logger?.debug( - ` DEBUG: Found ${artistTracks.length}+ tracks for artist containing "${artistFirstWord}"`, - ); - artistTracks - .slice(0, 3) - .forEach((t) => - logger?.debug( - ` - "${t.title}" (artist: ${t.album.artist.name}, normalized: ${t.album.artist.normalizedName})`, - ), - ); - } else { - logger?.debug( - ` DEBUG: NO tracks found for artist containing "${artistFirstWord}"`, - ); - } - // Try to find a matching track (using same strategies as buildPlaylist) // Strategy 1: Stripped title + fuzzy artist (contains first word) let localTrack = await prisma.track.findFirst({ diff --git a/backend/src/tests/artistNormalization.test.ts b/backend/src/tests/artistNormalization.test.ts deleted file mode 100644 index 89d6463..0000000 --- a/backend/src/tests/artistNormalization.test.ts +++ /dev/null @@ -1,525 +0,0 @@ -/** - * Artist Normalization Test Suite - * - * Tests the artist name normalization utilities to verify: - * 1. Hip-hop collaborations: "Ric Wilson x Chromeo x A-Trak" → "Ric Wilson" - * 2. Featured artists stripped: "Artist feat. Someone" → "Artist" - * 3. Band names preserved: "Of Mice & Men", "Between the Buried and Me" - * 4. Empty string validation: Never returns empty, returns "Unknown Artist" - * 5. Orchestra collaborations: "Philip Glass, Atlanta Symphony Orchestra" → "Philip Glass" - * 6. Folder fallback: "Paramore - After Laughter (2017) FLAC" → "Paramore" - * - * Run with: npx tsx src/tests/artistNormalization.test.ts - */ - -import { - extractPrimaryArtist, - parseArtistFromPath, - extractArtistFromRelativePath, - extractAlbumFromRelativePath, - normalizeArtistName, - canonicalizeVariousArtists, - areArtistNamesSimilar, - collapseForComparison, -} from "../utils/artistNormalization"; - -interface TestCase { - name: string; - input: string; - expected: string; - func: (input: string) => string | null; -} - -// Test cases for extractPrimaryArtist -const extractPrimaryArtistTests: TestCase[] = [ - // Hip-hop collaborations with " x " - { - name: "Hip-hop collaboration: Artist x Artist x Artist", - input: "Ric Wilson x Chromeo x A-Trak", - expected: "Ric Wilson", - func: extractPrimaryArtist, - }, - { - name: "Hip-hop collaboration: Artist x Artist", - input: "Artist A x Artist B", - expected: "Artist A", - func: extractPrimaryArtist, - }, - - // Featured artists - { - name: "Featured artist: feat.", - input: "Artist feat. Someone", - expected: "Artist", - func: extractPrimaryArtist, - }, - { - name: "Featured artist: feat (no dot)", - input: "Artist feat Someone", - expected: "Artist", - func: extractPrimaryArtist, - }, - { - name: "Featured artist: ft.", - input: "Artist ft. Someone", - expected: "Artist", - func: extractPrimaryArtist, - }, - { - name: "Featured artist: ft (no dot)", - input: "Artist ft Someone", - expected: "Artist", - func: extractPrimaryArtist, - }, - { - name: "Featured artist: featuring", - input: "Artist featuring Guest", - expected: "Artist", - func: extractPrimaryArtist, - }, - - // Band name preservation - { - name: "Band name: Of Mice & Men (preserved)", - input: "Of Mice & Men", - expected: "Of Mice & Men", - func: extractPrimaryArtist, - }, - { - name: "Band name: Between the Buried and Me (preserved)", - input: "Between the Buried and Me", - expected: "Between the Buried and Me", - func: extractPrimaryArtist, - }, - { - name: "Band name: Coheed and Cambria (preserved)", - input: "Coheed and Cambria", - expected: "Coheed and Cambria", - func: extractPrimaryArtist, - }, - { - name: "Band name: The Naked and Famous (preserved)", - input: "The Naked and Famous", - expected: "The Naked and Famous", - func: extractPrimaryArtist, - }, - { - name: "Band name: Earth, Wind & Fire (preserved)", - input: "Earth, Wind & Fire", - expected: "Earth, Wind & Fire", - func: extractPrimaryArtist, - }, - - // Collaborations that should split - { - name: "Collaboration: CHVRCHES & Robert Smith", - input: "CHVRCHES & Robert Smith", - expected: "CHVRCHES", - func: extractPrimaryArtist, - }, - - // Orchestra collaborations - { - name: "Orchestra collaboration: Philip Glass, Atlanta Symphony Orchestra", - input: "Philip Glass, Atlanta Symphony Orchestra", - expected: "Philip Glass", - func: extractPrimaryArtist, - }, - { - name: "Orchestra collaboration: Yo-Yo Ma, New York Philharmonic", - input: "Yo-Yo Ma, New York Philharmonic", - expected: "Yo-Yo Ma", - func: extractPrimaryArtist, - }, - - // Edge cases: Empty strings - { - name: "Empty string returns Unknown Artist", - input: "", - expected: "Unknown Artist", - func: extractPrimaryArtist, - }, - { - name: "Whitespace-only returns Unknown Artist", - input: " ", - expected: "Unknown Artist", - func: extractPrimaryArtist, - }, - - // No collaboration - returns as-is - { - name: "Single artist: Radiohead (preserved)", - input: "Radiohead", - expected: "Radiohead", - func: extractPrimaryArtist, - }, - { - name: "Single artist with article: The Beatles (preserved)", - input: "The Beatles", - expected: "The Beatles", - func: extractPrimaryArtist, - }, -]; - -// Test cases for parseArtistFromPath -const parseArtistFromPathTests: TestCase[] = [ - { - name: "Folder pattern: Artist - Album (Year) FLAC", - input: "Paramore - After Laughter (2017) FLAC", - expected: "Paramore", - func: parseArtistFromPath as (input: string) => string, - }, - { - name: "Folder pattern: Artist - Album", - input: "Radiohead - OK Computer", - expected: "Radiohead", - func: parseArtistFromPath as (input: string) => string, - }, - { - name: "Folder pattern: Artist - Album (Year)", - input: "The Beatles - Abbey Road (1969)", - expected: "The Beatles", - func: parseArtistFromPath as (input: string) => string, - }, - { - name: "Scene release format: Artist-Album.Name-FLAC-YEAR", - input: "Paramore-After.Laughter-FLAC-2017", - expected: "Paramore", - func: parseArtistFromPath as (input: string) => string, - }, -]; - -// Test cases for Various Artists canonicalization -const variousArtistsTests: TestCase[] = [ - { - name: "VA → Various Artists", - input: "VA", - expected: "Various Artists", - func: canonicalizeVariousArtists, - }, - { - name: "V.A. → Various Artists", - input: "V.A.", - expected: "Various Artists", - func: canonicalizeVariousArtists, - }, - { - name: "V/A → Various Artists", - input: "V/A", - expected: "Various Artists", - func: canonicalizeVariousArtists, - }, - { - name: "Various → Various Artists", - input: "Various", - expected: "Various Artists", - func: canonicalizeVariousArtists, - }, - { - name: "Various Artist → Various Artists", - input: "Various Artist", - expected: "Various Artists", - func: canonicalizeVariousArtists, - }, - { - name: " → Various Artists", - input: "", - expected: "Various Artists", - func: canonicalizeVariousArtists, - }, - { - name: "Normal artist preserved", - input: "Daft Punk", - expected: "Daft Punk", - func: canonicalizeVariousArtists, - }, -]; - -// Test cases for normalizeArtistName -const normalizeArtistNameTests: TestCase[] = [ - { - name: "Lowercase: RADIOHEAD → radiohead", - input: "RADIOHEAD", - expected: "radiohead", - func: normalizeArtistName, - }, - { - name: "Diacritics: Ólafur Arnalds → olafur arnalds", - input: "Ólafur Arnalds", - expected: "olafur arnalds", - func: normalizeArtistName, - }, - { - name: "Ampersand: Of Mice & Men → of mice and men", - input: "Of Mice & Men", - expected: "of mice and men", - func: normalizeArtistName, - }, - { - name: "Multiple spaces collapsed", - input: "The Beatles", - expected: "the beatles", - func: normalizeArtistName, - }, -]; - -function runTests(): void { - console.log("\n" + "=".repeat(70)); - console.log("ARTIST NORMALIZATION TEST SUITE"); - console.log("=".repeat(70)); - - let totalPassed = 0; - let totalFailed = 0; - - // Run extractPrimaryArtist tests - console.log("\n📌 extractPrimaryArtist() Tests"); - console.log("-".repeat(70)); - for (const test of extractPrimaryArtistTests) { - const result = test.func(test.input); - const passed = result === test.expected; - - if (passed) { - console.log(`✅ PASS: ${test.name}`); - totalPassed++; - } else { - console.log(`❌ FAIL: ${test.name}`); - console.log(` Input: "${test.input}"`); - console.log(` Expected: "${test.expected}"`); - console.log(` Got: "${result}"`); - totalFailed++; - } - } - - // Run parseArtistFromPath tests - console.log("\n📂 parseArtistFromPath() Tests"); - console.log("-".repeat(70)); - for (const test of parseArtistFromPathTests) { - const result = test.func(test.input); - const passed = result === test.expected; - - if (passed) { - console.log(`✅ PASS: ${test.name}`); - totalPassed++; - } else { - console.log(`❌ FAIL: ${test.name}`); - console.log(` Input: "${test.input}"`); - console.log(` Expected: "${test.expected}"`); - console.log(` Got: "${result}"`); - totalFailed++; - } - } - - // Run extractArtistFromRelativePath tests - console.log("\n📁 extractArtistFromRelativePath() Tests"); - console.log("-".repeat(70)); - - const relativePathTests = [ - // Standard Artist/Album/Track.ext — grandparent is artist - { - name: "Standard 3-level: Night Witch/Heir of Sympathy/Track.wav", - input: "Night Witch/Heir of Sympathy/Track.wav", - expected: "Night Witch", - }, - { - name: "Standard 3-level: Imminence/Heaven in Hiding (2021)/Track.mp3", - input: "Imminence/Heaven in Hiding (2021)/Track.mp3", - expected: "Imminence", - }, - { - name: "4-level Singles: Singles/Kai Engel/Idea/Track.mp3", - input: "Singles/Kai Engel/Idea/Track.mp3", - expected: "Kai Engel", - }, - // Dash pattern in album folder name — parseArtistFromPath handles this - { - name: "Dash in album folder: Artist/Artist - Album (2020)/Track.mp3", - input: "Artist/Artist - Album (2020)/Track.mp3", - expected: "Artist", - }, - // Single level — no grandparent available - { - name: "Flat file: Track.mp3 (no folders)", - input: "Track.mp3", - expected: null, - }, - // Windows path separators - { - name: "Windows path: Artist\\Album\\Track.mp3", - input: "Artist\\Album\\Track.mp3", - expected: "Artist", - }, - { - name: "Mixed separators: Artist/Album\\Track.mp3", - input: "Artist/Album\\Track.mp3", - expected: "Artist", - }, - ]; - - for (const test of relativePathTests) { - const result = extractArtistFromRelativePath(test.input); - const passed = result === test.expected; - - if (passed) { - console.log(`✅ PASS: ${test.name}`); - totalPassed++; - } else { - console.log(`❌ FAIL: ${test.name}`); - console.log(` Input: "${test.input}"`); - console.log(` Expected: ${JSON.stringify(test.expected)}`); - console.log(` Got: ${JSON.stringify(result)}`); - totalFailed++; - } - } - - // Run extractAlbumFromRelativePath tests - console.log("\n💿 extractAlbumFromRelativePath() Tests"); - console.log("-".repeat(70)); - - const albumPathTests = [ - { - name: "Standard: Artist/Album/Track.wav", - input: "Night Witch/Heir of Sympathy/Track.wav", - expected: "Heir of Sympathy", - }, - { - name: "With year: Artist/Album (2021)/Track.mp3", - input: "Imminence/Heaven in Hiding (2021)/Track.mp3", - expected: "Heaven in Hiding (2021)", - }, - { - name: "Flat file: Track.mp3", - input: "Track.mp3", - expected: null, - }, - { - name: "Windows path: Artist\\Album\\Track.mp3", - input: "Artist\\Album\\Track.mp3", - expected: "Album", - }, - ]; - - for (const test of albumPathTests) { - const result = extractAlbumFromRelativePath(test.input); - const passed = result === test.expected; - - if (passed) { - console.log(`✅ PASS: ${test.name}`); - totalPassed++; - } else { - console.log(`❌ FAIL: ${test.name}`); - console.log(` Expected: ${JSON.stringify(test.expected)}`); - console.log(` Got: ${JSON.stringify(result)}`); - totalFailed++; - } - } - - // Run Various Artists tests - console.log("\n🎭 canonicalizeVariousArtists() Tests"); - console.log("-".repeat(70)); - for (const test of variousArtistsTests) { - const result = test.func(test.input); - const passed = result === test.expected; - - if (passed) { - console.log(`✅ PASS: ${test.name}`); - totalPassed++; - } else { - console.log(`❌ FAIL: ${test.name}`); - console.log(` Input: "${test.input}"`); - console.log(` Expected: "${test.expected}"`); - console.log(` Got: "${result}"`); - totalFailed++; - } - } - - // Run normalizeArtistName tests - console.log("\n🔤 normalizeArtistName() Tests"); - console.log("-".repeat(70)); - for (const test of normalizeArtistNameTests) { - const result = test.func(test.input); - const passed = result === test.expected; - - if (passed) { - console.log(`✅ PASS: ${test.name}`); - totalPassed++; - } else { - console.log(`❌ FAIL: ${test.name}`); - console.log(` Input: "${test.input}"`); - console.log(` Expected: "${test.expected}"`); - console.log(` Got: "${result}"`); - totalFailed++; - } - } - - // Run areArtistNamesSimilar tests - console.log("\n🔍 areArtistNamesSimilar() Tests"); - console.log("-".repeat(70)); - - const similarityTests = [ - { name1: "Ólafur Arnalds", name2: "Olafur Arnalds", expected: true }, - { name1: "Of Mice & Men", name2: "Of Mice And Men", expected: true }, - { name1: "The Weeknd", name2: "The Weekend", expected: true }, - { name1: "Radiohead", name2: "Coldplay", expected: false }, - ]; - - for (const test of similarityTests) { - const result = areArtistNamesSimilar(test.name1, test.name2); - const passed = result === test.expected; - - if (passed) { - console.log(`✅ PASS: "${test.name1}" ≈ "${test.name2}" → ${result}`); - totalPassed++; - } else { - console.log(`❌ FAIL: "${test.name1}" ≈ "${test.name2}"`); - console.log(` Expected: ${test.expected}`); - console.log(` Got: ${result}`); - totalFailed++; - } - } - - // Run collapseForComparison tests - console.log("\n🔗 collapseForComparison() Tests"); - console.log("-".repeat(70)); - - const collapseTests = [ - { input: "dead mau5", expected: "deadmau5" }, - { input: "deadmau5", expected: "deadmau5" }, - { input: "jay z", expected: "jayz" }, - { input: "ac dc", expected: "acdc" }, - { input: "the beatles", expected: "thebeatles" }, - ]; - - for (const test of collapseTests) { - const result = collapseForComparison(test.input); - const passed = result === test.expected; - - if (passed) { - console.log(`✅ PASS: "${test.input}" → "${result}"`); - totalPassed++; - } else { - console.log(`❌ FAIL: "${test.input}"`); - console.log(` Expected: "${test.expected}"`); - console.log(` Got: "${result}"`); - totalFailed++; - } - } - - // Summary - console.log("\n" + "=".repeat(70)); - console.log("TEST RESULTS SUMMARY"); - console.log("=".repeat(70)); - console.log(`Total: ${totalPassed + totalFailed} tests`); - console.log(`Passed: ${totalPassed}`); - console.log(`Failed: ${totalFailed}`); - - if (totalFailed === 0) { - console.log("\n🎉 ALL TESTS PASSED! Artist normalization is working correctly."); - } else { - console.log("\n💥 SOME TESTS FAILED. Review the output above for details."); - } - - process.exit(totalFailed > 0 ? 1 : 0); -} - -// Run tests -runTests(); diff --git a/backend/src/tests/downloadDedup.test.ts b/backend/src/tests/downloadDedup.test.ts deleted file mode 100644 index c52ba46..0000000 --- a/backend/src/tests/downloadDedup.test.ts +++ /dev/null @@ -1,1370 +0,0 @@ -/** - * Download Job Deduplication Test - * - * Tests the entire download flow to verify: - * 1. Duplicate jobs are detected and linked (same artist+album, different MBID) - * 2. Only ONE notification per album - * 3. Completion merges duplicate jobs - * 4. Stale cleanup detects completed duplicates - * - * Run with: npx tsx src/tests/downloadDedup.test.ts - */ - -import { logger } from "../utils/logger"; -import { prisma } from "../utils/db"; -import { simpleDownloadManager } from "../services/simpleDownloadManager"; - -// Will be set dynamically to a real user from the database -let TEST_USER_ID = ""; -const TEST_ARTIST = "Test Artist Dedup " + Date.now(); -const TEST_ALBUM = "Test Album Dedup " + Date.now(); -const TEST_MBID_1 = "test-mbid-musicbrainz-" + Date.now(); -const TEST_MBID_2 = "test-mbid-lidarr-" + Date.now(); - -async function setup() { - // Get a real user from the database - const user = await prisma.user.findFirst(); - if (!user) { - throw new Error("No users in database! Please create a user first."); - } - TEST_USER_ID = user.id; - logger.debug(`[SETUP] Using user: ${user.username} (${user.id})`); -} - -async function cleanup() { - logger.debug("\n[CLEANUP] Removing test data..."); - // Delete all test jobs (including Unicode and special character tests) - await prisma.downloadJob.deleteMany({ - where: { - userId: TEST_USER_ID, - OR: [ - { subject: { contains: "Test Artist Dedup" } }, - { subject: { contains: "Röyksopp" } }, - { subject: { contains: "Test Album A" } }, - { subject: { contains: "Test Album B" } }, - ], - }, - }); - logger.debug("[CLEANUP] Done"); -} - -async function test1_DuplicateJobDetectionOnGrab(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 1: Duplicate Job Detection on Grab Event"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Job exists with MBID-1, Lidarr fires Grab with MBID-2 (same album)"); - logger.debug("Expected: Should link to existing job, NOT create duplicate"); - - // Create first job (simulating user request) - logger.debug("\n[STEP 1] Creating first job (user request)..."); - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - downloadType: "library", - }, - }, - }); - logger.debug(` Created job: ${job1.id}`); - logger.debug(` Subject: ${job1.subject}`); - logger.debug(` MBID: ${TEST_MBID_1}`); - logger.debug(` Status: processing`); - logger.debug(` Artist in metadata: ${TEST_ARTIST}`); - logger.debug(` Album in metadata: ${TEST_ALBUM}`); - - // Simulate Lidarr Grab event with DIFFERENT MBID - logger.debug("\n[STEP 2] Simulating Lidarr Grab webhook (different MBID)..."); - logger.debug(` Download ID: test-download-id-001`); - logger.debug(` MBID from Lidarr: ${TEST_MBID_2} (DIFFERENT!)`); - logger.debug(` Artist param: ${TEST_ARTIST}`); - logger.debug(` Album param: ${TEST_ALBUM}`); - - const grabResult = await simpleDownloadManager.onDownloadGrabbed( - "test-download-id-001", - TEST_MBID_2, // Different MBID! - TEST_ALBUM, - TEST_ARTIST, - 12345 // Lidarr album ID - ); - - logger.debug(`\n[STEP 3] Grab result:`); - logger.debug(` matched: ${grabResult.matched}`); - logger.debug(` jobId: ${grabResult.jobId}`); - logger.debug(` Expected jobId: ${job1.id}`); - logger.debug(` Linked to original job: ${grabResult.jobId === job1.id}`); - - // Verify: Should have linked to existing job, not created new one - const jobsAfterGrab = await prisma.downloadJob.findMany({ - where: { - OR: [ - { userId: TEST_USER_ID }, - { subject: { contains: "Test Artist Dedup" } }, - ] - }, - }); - - logger.debug(`\n[VERIFICATION]`); - logger.debug(` Total test jobs in DB: ${jobsAfterGrab.length}`); - logger.debug(` Expected: 1 (no duplicate created)`); - - for (const j of jobsAfterGrab) { - const meta = j.metadata as any; - logger.debug(` Job ${j.id}:`); - logger.debug(` Subject: ${j.subject}`); - logger.debug(` Status: ${j.status}`); - logger.debug(` lidarrRef: ${j.lidarrRef || 'null'}`); - logger.debug(` targetMbid: ${j.targetMbid}`); - logger.debug(` artistName in meta: ${meta?.artistName || 'null'}`); - logger.debug(` albumTitle in meta: ${meta?.albumTitle || 'null'}`); - } - - const testJobs = jobsAfterGrab.filter(j => j.subject?.includes("Test Artist Dedup")); - const passed = testJobs.length === 1 && grabResult.jobId === job1.id && grabResult.matched; - - if (passed) { - logger.debug("\n[PASS] TEST 1 PASSED: Duplicate detection working correctly"); - } else { - logger.debug("\n[FAIL] TEST 1 FAILED:"); - if (testJobs.length > 1) logger.debug(` - Created ${testJobs.length - 1} duplicate job(s)`); - if (grabResult.jobId !== job1.id) logger.debug(` - Linked to wrong job (${grabResult.jobId} vs ${job1.id})`); - if (!grabResult.matched) logger.debug(" - Failed to match any job"); - } - - return passed; -} - -async function test2_CompletionMergesDuplicates(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 2: Completion Merges Duplicate Jobs"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Two jobs exist for same album, one completes"); - logger.debug("Expected: Both should be marked as completed"); - - // Create TWO jobs for same album (simulating existing duplicates from old code) - logger.debug("\n[STEP 1] Creating two duplicate jobs (legacy scenario)..."); - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - lidarrRef: "download-001", - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - }, - }, - }); - const job2 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_2, - status: "processing", - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - }, - }, - }); - logger.debug(` Job 1: ${job1.id} (MBID: ${TEST_MBID_1}, lidarrRef: download-001)`); - logger.debug(` Job 2: ${job2.id} (MBID: ${TEST_MBID_2}, no lidarrRef)`); - - // Simulate completion for job1 - logger.debug("\n[STEP 2] Simulating completion webhook for job1..."); - logger.debug(` downloadId: download-001`); - logger.debug(` albumMbid: ${TEST_MBID_1}`); - logger.debug(` artistName: ${TEST_ARTIST}`); - logger.debug(` albumTitle: ${TEST_ALBUM}`); - - const completeResult = await simpleDownloadManager.onDownloadComplete( - "download-001", - TEST_MBID_1, - TEST_ARTIST, - TEST_ALBUM, - 12345 - ); - logger.debug(` Matched job: ${completeResult.jobId}`); - - // Check both jobs - const jobsAfter = await prisma.downloadJob.findMany({ - where: { - OR: [ - { userId: TEST_USER_ID }, - { subject: { contains: "Test Artist Dedup" } }, - ] - }, - orderBy: { createdAt: "asc" }, - }); - - logger.debug("\n[VERIFICATION]"); - let completedCount = 0; - const testJobs = jobsAfter.filter(j => j.subject?.includes("Test Artist Dedup")); - for (const j of testJobs) { - const meta = j.metadata as any; - logger.debug(` Job ${j.id}:`); - logger.debug(` Subject: ${j.subject}`); - logger.debug(` Status: ${j.status}`); - logger.debug(` artistName: ${meta?.artistName || 'null'}`); - logger.debug(` albumTitle: ${meta?.albumTitle || 'null'}`); - if (j.status === "completed") completedCount++; - } - logger.debug(`\n Test jobs found: ${testJobs.length}`); - logger.debug(` Completed jobs: ${completedCount}`); - logger.debug(` Expected: 2 (both merged as same album)`); - - const passed = completedCount === 2 && testJobs.length === 2; - - if (passed) { - logger.debug("\n[PASS] TEST 2 PASSED: Duplicates merged on completion"); - } else { - logger.debug("\n[FAIL] TEST 2 FAILED:"); - if (testJobs.length !== 2) logger.debug(` - Expected 2 test jobs, found ${testJobs.length}`); - if (completedCount !== 2) logger.debug(` - Expected 2 completed, found ${completedCount}`); - } - - return passed; -} - -async function test3_NotificationDedup(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 3: Notification Deduplication"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Multiple completions for same album"); - logger.debug("Expected: Only ONE notification should be sent (notificationSent flag)"); - - // Create first job - logger.debug("\n[STEP 1] Creating first job (notificationSent: false)..."); - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - lidarrRef: "download-002", - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - notificationSent: false, - }, - }, - }); - logger.debug(` Created job: ${job1.id}`); - - // First completion - should trigger notification - logger.debug("\n[STEP 2] First completion (should set notificationSent=true)..."); - await simpleDownloadManager.onDownloadComplete( - "download-002", - TEST_MBID_1, - TEST_ARTIST, - TEST_ALBUM, - 12346 - ); - - // Check if notificationSent flag was set - const job1After = await prisma.downloadJob.findUnique({ where: { id: job1.id } }); - const meta1 = job1After?.metadata as any; - logger.debug(` Job 1 notificationSent: ${meta1?.notificationSent}`); - - // Create second job and complete it - logger.debug("\n[STEP 3] Creating second job for same album..."); - const job2 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_2, - status: "processing", - lidarrRef: "download-003", - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - notificationSent: false, - }, - }, - }); - logger.debug(` Created job: ${job2.id}`); - - logger.debug("\n[STEP 4] Second completion (should NOT send duplicate notification)..."); - await simpleDownloadManager.onDownloadComplete( - "download-003", - TEST_MBID_2, - TEST_ARTIST, - TEST_ALBUM, - 12347 - ); - - // Check flags - const allJobs = await prisma.downloadJob.findMany({ - where: { userId: TEST_USER_ID }, - orderBy: { createdAt: "asc" }, - }); - - logger.debug("\n[VERIFICATION]"); - let notificationCount = 0; - for (const j of allJobs) { - const meta = j.metadata as any; - logger.debug(` Job ${j.id}:`); - logger.debug(` Status: ${j.status}`); - logger.debug(` notificationSent: ${meta?.notificationSent}`); - if (meta?.notificationSent === true) notificationCount++; - } - - logger.debug(`\n Jobs with notificationSent=true: ${notificationCount}`); - logger.debug(` Expected: 1 (only first job should have triggered notification)`); - - // At least one job should have notificationSent=true (first completion) - // The logic should prevent duplicate notifications - const passed = notificationCount >= 1; - - if (passed) { - logger.debug("\nTEST 3 PASSED: Notification dedup flag is working"); - } else { - logger.debug("\nTEST 3 FAILED: Notification flag not properly set"); - } - - return passed; -} - -async function test4_GrabMatchesByNameWhenMbidDiffers(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 4: Grab Matches by Artist+Album Name When MBID Differs"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Pending job exists, Lidarr Grab comes with completely different MBID"); - logger.debug("Expected: Should match by artist+album name and link to existing job"); - - // Create pending job (not yet processing) - logger.debug("\n[STEP 1] Creating PENDING job..."); - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "pending", // Note: pending, not processing - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - }, - }, - }); - logger.debug(` Created job: ${job1.id} (status: pending)`); - logger.debug(` Subject: ${job1.subject}`); - logger.debug(` Artist in metadata: ${TEST_ARTIST}`); - logger.debug(` Album in metadata: ${TEST_ALBUM}`); - - // Simulate grab with completely different MBID - logger.debug("\n[STEP 2] Simulating Grab with completely different MBID..."); - logger.debug(` Download ID: test-download-xyz`); - logger.debug(` MBID: completely-different-mbid-xyz`); - logger.debug(` Artist param: ${TEST_ARTIST}`); - logger.debug(` Album param: ${TEST_ALBUM}`); - - const grabResult = await simpleDownloadManager.onDownloadGrabbed( - "test-download-xyz", - "completely-different-mbid-xyz", - TEST_ALBUM, - TEST_ARTIST, - 99999 - ); - - logger.debug(`\n[STEP 3] Grab result:`); - logger.debug(` matched: ${grabResult.matched}`); - logger.debug(` jobId: ${grabResult.jobId}`); - logger.debug(` Expected jobId: ${job1.id}`); - - const jobsAfter = await prisma.downloadJob.findMany({ - where: { - OR: [ - { userId: TEST_USER_ID }, - { subject: { contains: "Test Artist Dedup" } }, - ] - }, - }); - - logger.debug("\n[VERIFICATION]"); - logger.debug(` Total test jobs: ${jobsAfter.length}`); - logger.debug(` Expected: 1 (matched pending job by name)`); - - for (const j of jobsAfter) { - const meta = j.metadata as any; - logger.debug(` Job ${j.id}:`); - logger.debug(` Subject: ${j.subject}`); - logger.debug(` Status: ${j.status}`); - logger.debug(` lidarrRef: ${j.lidarrRef || 'null'}`); - logger.debug(` artistName in meta: ${meta?.artistName || 'null'}`); - logger.debug(` albumTitle in meta: ${meta?.albumTitle || 'null'}`); - } - - const testJobs = jobsAfter.filter(j => j.subject?.includes("Test Artist Dedup")); - const passed = testJobs.length === 1 && grabResult.matched && grabResult.jobId === job1.id; - - if (passed) { - logger.debug("\n[PASS] TEST 4 PASSED: Name-based matching works for pending jobs"); - } else { - logger.debug("\n[FAIL] TEST 4 FAILED:"); - if (testJobs.length > 1) logger.debug(` - Created ${testJobs.length - 1} duplicate job(s)`); - if (grabResult.jobId !== job1.id) logger.debug(` - Linked to wrong job (${grabResult.jobId} vs ${job1.id})`); - if (!grabResult.matched) logger.debug(" - Failed to match any job"); - } - - return passed; -} - -async function test5_CompletionMatchesByNameWhenNoLidarrRef(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 5: Completion Matches by Name When No lidarrRef"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Job exists but never got lidarrRef, completion comes by name"); - logger.debug("Expected: Should match by artist+album name"); - - // Create job without lidarrRef - logger.debug("\n[STEP 1] Creating job WITHOUT lidarrRef..."); - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - // No lidarrRef! - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - }, - }, - }); - logger.debug(` Created job: ${job1.id} (no lidarrRef)`); - - // Simulate completion with different MBID but same artist+album - logger.debug("\n[STEP 2] Simulating completion (matching by name)..."); - const completeResult = await simpleDownloadManager.onDownloadComplete( - "unknown-download-id", - "unknown-mbid", - TEST_ARTIST, - TEST_ALBUM, - undefined - ); - - logger.debug(` Matched job: ${completeResult.jobId}`); - - const jobAfter = await prisma.downloadJob.findUnique({ where: { id: job1.id } }); - - logger.debug("\n[VERIFICATION]"); - logger.debug(` Job status: ${jobAfter?.status}`); - logger.debug(` Expected: completed`); - - const passed = jobAfter?.status === "completed" && completeResult.jobId === job1.id; - - if (passed) { - logger.debug("\nTEST 5 PASSED: Completion matched by name"); - } else { - logger.debug("\nTEST 5 FAILED: Did not match by name"); - } - - return passed; -} - -// Test 6: Case-insensitive matching -async function test6_CaseInsensitiveMatching(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 6: Case-Insensitive Matching"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Job exists with 'Artist - Album', grab comes with 'ARTIST - ALBUM'"); - logger.debug("Expected: Should match despite case difference"); - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - }, - }, - }); - logger.debug(` Created job with: "${TEST_ARTIST}" - "${TEST_ALBUM}"`); - - // Grab with UPPERCASE names - const grabResult = await simpleDownloadManager.onDownloadGrabbed( - "test-case-download", - "test-case-mbid", - TEST_ALBUM.toUpperCase(), - TEST_ARTIST.toUpperCase(), - 88888 - ); - - logger.debug(` Grabbed with: "${TEST_ARTIST.toUpperCase()}" - "${TEST_ALBUM.toUpperCase()}"`); - logger.debug(` Matched: ${grabResult.matched}`); - logger.debug(` Job ID: ${grabResult.jobId}`); - - const testJobs = await prisma.downloadJob.findMany({ - where: { subject: { contains: "Test Artist Dedup" } }, - }); - - const passed = testJobs.length === 1 && grabResult.matched && grabResult.jobId === job1.id; - - if (passed) { - logger.debug("\n[PASS] TEST 6 PASSED: Case-insensitive matching works"); - } else { - logger.debug("\n[FAIL] TEST 6 FAILED: Case-insensitive matching broken"); - } - - return passed; -} - -// Test 7: Same artist, different albums should NOT match -async function test7_SameArtistDifferentAlbum(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 7: Same Artist, Different Albums - Should NOT Match"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Job exists for 'Artist - Album A', grab comes for 'Artist - Album B'"); - logger.debug("Expected: Should NOT link to existing Album A job"); - - const ALBUM_A = "Test Album A " + Date.now(); - const ALBUM_B = "Test Album B " + Date.now(); - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${ALBUM_A}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - metadata: { - artistName: TEST_ARTIST, - albumTitle: ALBUM_A, - }, - }, - }); - logger.debug(` Created job for: "${TEST_ARTIST}" - "${ALBUM_A}"`); - - // Grab for DIFFERENT album by same artist - const grabResult = await simpleDownloadManager.onDownloadGrabbed( - "test-diff-album-download", - "test-diff-album-mbid", - ALBUM_B, - TEST_ARTIST, - 77777 - ); - - logger.debug(` Grabbed for: "${TEST_ARTIST}" - "${ALBUM_B}"`); - logger.debug(` Matched existing job: ${grabResult.matched}`); - logger.debug(` Linked to Album A job: ${grabResult.jobId === job1.id}`); - - // The IMPORTANT thing is that it did NOT match the Album A job - // (It may or may not create a new tracking job depending on user context) - const didNotMatchWrongAlbum = grabResult.jobId !== job1.id; - - const passed = didNotMatchWrongAlbum; - - if (passed) { - logger.debug("\n[PASS] TEST 7 PASSED: Different albums correctly NOT matched"); - } else { - logger.debug("\n[FAIL] TEST 7 FAILED: Incorrectly linked to wrong album"); - logger.debug(` Expected: NOT ${job1.id}`); - logger.debug(` Got: ${grabResult.jobId}`); - } - - return passed; -} - -// Test 8: Idempotency - completing same job twice -async function test8_IdempotentCompletion(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 8: Idempotent Completion - Completing Same Job Twice"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Same completion event fires twice"); - logger.debug("Expected: Should handle gracefully, no errors, job stays completed"); - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - lidarrRef: "idempotent-download", - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - }, - }, - }); - logger.debug(` Created job: ${job1.id}`); - - // First completion - logger.debug("\n First completion..."); - const result1 = await simpleDownloadManager.onDownloadComplete( - "idempotent-download", - TEST_MBID_1, - TEST_ARTIST, - TEST_ALBUM, - 66666 - ); - logger.debug(` Result 1 - jobId: ${result1.jobId}`); - - // Second completion (duplicate) - logger.debug(" Second completion (duplicate)..."); - const result2 = await simpleDownloadManager.onDownloadComplete( - "idempotent-download", - TEST_MBID_1, - TEST_ARTIST, - TEST_ALBUM, - 66666 - ); - logger.debug(` Result 2 - jobId: ${result2.jobId}`); - - const jobAfter = await prisma.downloadJob.findUnique({ where: { id: job1.id } }); - - // Should still be completed, no error - const passed = jobAfter?.status === "completed" && !jobAfter?.error; - - if (passed) { - logger.debug("\n[PASS] TEST 8 PASSED: Idempotent completion handled correctly"); - } else { - logger.debug("\n[FAIL] TEST 8 FAILED: Issue with repeated completion"); - logger.debug(` Status: ${jobAfter?.status}`); - logger.debug(` Error: ${jobAfter?.error}`); - } - - return passed; -} - -// Test 9: Discovery jobs should NOT send notifications -async function test9_DiscoveryJobsNoNotification(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 9: Discovery Jobs Should NOT Send Notifications"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Discovery job completes"); - logger.debug("Expected: notificationSent should remain false (skipped)"); - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - lidarrRef: "discovery-download", - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - downloadType: "discovery", // This is a discovery job! - notificationSent: false, - }, - }, - }); - logger.debug(` Created discovery job: ${job1.id}`); - - await simpleDownloadManager.onDownloadComplete( - "discovery-download", - TEST_MBID_1, - TEST_ARTIST, - TEST_ALBUM, - 55555 - ); - - const jobAfter = await prisma.downloadJob.findUnique({ where: { id: job1.id } }); - const meta = jobAfter?.metadata as any; - - // notificationSent should NOT be true for discovery jobs - const passed = jobAfter?.status === "completed" && meta?.notificationSent !== true; - - if (passed) { - logger.debug("\n[PASS] TEST 9 PASSED: Discovery job notification correctly skipped"); - } else { - logger.debug("\n[FAIL] TEST 9 FAILED: Discovery job incorrectly sent notification"); - logger.debug(` notificationSent: ${meta?.notificationSent}`); - } - - return passed; -} - -// Test 10: Retry updates lidarrRef -async function test10_RetryUpdatesLidarrRef(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 10: Retry Updates lidarrRef"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Job has lidarrRef 'download-1', new grab comes with 'download-2'"); - logger.debug("Expected: lidarrRef should update to new download ID"); - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - lidarrRef: "old-download-id", - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - }, - }, - }); - logger.debug(` Created job with lidarrRef: old-download-id`); - - // New grab (retry) with different download ID - const grabResult = await simpleDownloadManager.onDownloadGrabbed( - "new-download-id", - TEST_MBID_1, - TEST_ALBUM, - TEST_ARTIST, - 44444 - ); - - const jobAfter = await prisma.downloadJob.findUnique({ where: { id: job1.id } }); - - const passed = jobAfter?.lidarrRef === "new-download-id" && grabResult.jobId === job1.id; - - if (passed) { - logger.debug("\n[PASS] TEST 10 PASSED: lidarrRef updated on retry"); - } else { - logger.debug("\n[FAIL] TEST 10 FAILED: lidarrRef not updated"); - logger.debug(` Expected: new-download-id`); - logger.debug(` Got: ${jobAfter?.lidarrRef}`); - } - - return passed; -} - -// Test 11: Subject-only matching (no metadata) -async function test11_SubjectOnlyMatching(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 11: Subject-Only Matching (No Metadata)"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Job exists with subject but empty metadata"); - logger.debug("Expected: Should still match by subject"); - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - metadata: {}, // Empty metadata! - }, - }); - logger.debug(` Created job with empty metadata`); - logger.debug(` Subject: ${job1.subject}`); - - // Grab with artist/album names - const grabResult = await simpleDownloadManager.onDownloadGrabbed( - "subject-only-download", - "subject-only-mbid", - TEST_ALBUM, - TEST_ARTIST, - 33333 - ); - - const testJobs = await prisma.downloadJob.findMany({ - where: { subject: { contains: "Test Artist Dedup" } }, - }); - - const passed = testJobs.length === 1 && grabResult.matched && grabResult.jobId === job1.id; - - if (passed) { - logger.debug("\n[PASS] TEST 11 PASSED: Subject-only matching works"); - } else { - logger.debug("\n[FAIL] TEST 11 FAILED: Subject-only matching broken"); - logger.debug(` Jobs found: ${testJobs.length}`); - } - - return passed; -} - -// Test 12: Three duplicate jobs - all should be merged -async function test12_ThreeDuplicatesMerge(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 12: Three Duplicate Jobs All Merge on Completion"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Three jobs exist for same album, one completes"); - logger.debug("Expected: All three should be marked as completed"); - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - lidarrRef: "three-dup-download", - metadata: { artistName: TEST_ARTIST, albumTitle: TEST_ALBUM }, - }, - }); - const job2 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_2, - status: "processing", - metadata: { artistName: TEST_ARTIST, albumTitle: TEST_ALBUM }, - }, - }); - const job3 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: "third-mbid-" + Date.now(), - status: "processing", - metadata: { artistName: TEST_ARTIST, albumTitle: TEST_ALBUM }, - }, - }); - logger.debug(` Created 3 duplicate jobs: ${job1.id}, ${job2.id}, ${job3.id}`); - - // Complete one - await simpleDownloadManager.onDownloadComplete( - "three-dup-download", - TEST_MBID_1, - TEST_ARTIST, - TEST_ALBUM, - 22222 - ); - - const testJobs = await prisma.downloadJob.findMany({ - where: { subject: { contains: "Test Artist Dedup" } }, - }); - - const completedCount = testJobs.filter(j => j.status === "completed").length; - const passed = completedCount === 3; - - if (passed) { - logger.debug("\n[PASS] TEST 12 PASSED: All 3 duplicates merged"); - } else { - logger.debug("\n[FAIL] TEST 12 FAILED: Not all duplicates merged"); - logger.debug(` Completed: ${completedCount}/3`); - } - - return passed; -} - -// Test 13: Whitespace variations - should match after trimming -async function test13_WhitespaceVariations(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 13: Whitespace Variations"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Job has ' Artist - Album ', grab comes with 'Artist - Album'"); - logger.debug("Expected: Should match after trimming whitespace"); - - const PADDED_ARTIST = ` ${TEST_ARTIST} `; - const PADDED_ALBUM = ` ${TEST_ALBUM} `; - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${PADDED_ARTIST} - ${PADDED_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - metadata: { - artistName: PADDED_ARTIST, - albumTitle: PADDED_ALBUM, - }, - }, - }); - logger.debug(` Created job with padded names`); - logger.debug(` Artist: "${PADDED_ARTIST}"`); - logger.debug(` Album: "${PADDED_ALBUM}"`); - - // Grab with trimmed names - const grabResult = await simpleDownloadManager.onDownloadGrabbed( - "whitespace-download", - "whitespace-mbid", - TEST_ALBUM.trim(), - TEST_ARTIST.trim(), - 11111 - ); - - logger.debug(` Grabbed with trimmed names`); - logger.debug(` Matched: ${grabResult.matched}`); - - const testJobs = await prisma.downloadJob.findMany({ - where: { subject: { contains: "Test Artist Dedup" } }, - }); - - const passed = testJobs.length === 1 && grabResult.matched && grabResult.jobId === job1.id; - - if (passed) { - logger.debug("\n[PASS] TEST 13 PASSED: Whitespace handling works"); - } else { - logger.debug("\n[FAIL] TEST 13 FAILED: Whitespace not handled"); - logger.debug(` Jobs: ${testJobs.length}, Matched: ${grabResult.matched}`); - } - - return passed; -} - -// Test 14: Special characters and Unicode -async function test14_SpecialCharacters(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 14: Special Characters and Unicode"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Artist/album names with Unicode, accents, special chars"); - logger.debug("Expected: Should match exactly"); - - const UNICODE_ARTIST = "Röyksopp & björk 日本語"; - const UNICODE_ALBUM = "Mélodie d'amour (Remastered™) [Deluxe]"; - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${UNICODE_ARTIST} - ${UNICODE_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - metadata: { - artistName: UNICODE_ARTIST, - albumTitle: UNICODE_ALBUM, - }, - }, - }); - logger.debug(` Created job: ${job1.id}`); - logger.debug(` Artist: "${UNICODE_ARTIST}"`); - logger.debug(` Album: "${UNICODE_ALBUM}"`); - - // Grab with same Unicode names (use unique IDs to avoid matching old data) - const uniqueId = Date.now(); - const grabResult = await simpleDownloadManager.onDownloadGrabbed( - `unicode-download-${uniqueId}`, - `unicode-mbid-${uniqueId}`, - UNICODE_ALBUM, - UNICODE_ARTIST, - uniqueId % 100000 // Use a unique lidarrAlbumId - ); - - logger.debug(` Matched: ${grabResult.matched}`); - logger.debug(` Matched to original job: ${grabResult.jobId === job1.id}`); - - // Verify by fetching the job directly - const jobAfter = await prisma.downloadJob.findUnique({ where: { id: job1.id } }); - - // The key assertion: did it match the original job? - const passed = grabResult.matched && grabResult.jobId === job1.id; - - if (passed) { - logger.debug("\n[PASS] TEST 14 PASSED: Unicode/special chars handled"); - } else { - logger.debug("\n[FAIL] TEST 14 FAILED: Unicode/special chars not handled"); - logger.debug(` Expected jobId: ${job1.id}, Got: ${grabResult.jobId}`); - logger.debug(` lidarrRef: ${jobAfter?.lidarrRef}`); - } - - return passed; -} - -// Test 15: Concurrent race condition - multiple grabs at once -async function test15_ConcurrentGrabs(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 15: Concurrent Race Condition - Multiple Simultaneous Grabs"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Three grab events fire at nearly the same time for same album"); - logger.debug("Expected: At least one should link, NO duplicate jobs created"); - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - }, - }, - }); - logger.debug(` Created initial job: ${job1.id}`); - - // Fire 3 grabs concurrently (simulating race condition) - logger.debug(" Firing 3 concurrent grabs..."); - const [result1, result2, result3] = await Promise.all([ - simpleDownloadManager.onDownloadGrabbed( - "race-download-1", - "race-mbid-1", - TEST_ALBUM, - TEST_ARTIST, - 9001 - ), - simpleDownloadManager.onDownloadGrabbed( - "race-download-2", - "race-mbid-2", - TEST_ALBUM, - TEST_ARTIST, - 9002 - ), - simpleDownloadManager.onDownloadGrabbed( - "race-download-3", - "race-mbid-3", - TEST_ALBUM, - TEST_ARTIST, - 9003 - ), - ]); - - logger.debug(` Result 1: matched=${result1.matched}, jobId=${result1.jobId}`); - logger.debug(` Result 2: matched=${result2.matched}, jobId=${result2.jobId}`); - logger.debug(` Result 3: matched=${result3.matched}, jobId=${result3.jobId}`); - - const testJobs = await prisma.downloadJob.findMany({ - where: { subject: { contains: "Test Artist Dedup" } }, - }); - - // The KEY thing is: NO DUPLICATES created - // At least one grab should match the original job - // Others might not match (because job already has lidarrRef) - that's OK - const atLeastOneMatched = result1.matched || result2.matched || result3.matched; - const matchedToOriginal = result1.jobId === job1.id || result2.jobId === job1.id || result3.jobId === job1.id; - const noDuplicates = testJobs.length === 1; // Only our original job - - const passed = atLeastOneMatched && matchedToOriginal && noDuplicates; - - if (passed) { - logger.debug("\n[PASS] TEST 15 PASSED: No duplicates created under race condition"); - logger.debug(` Jobs in DB: ${testJobs.length} (expected 1)`); - } else { - logger.debug("\n[FAIL] TEST 15 FAILED: Race condition issue"); - logger.debug(` Jobs in DB: ${testJobs.length} (expected 1)`); - logger.debug(` At least one matched: ${atLeastOneMatched}`); - logger.debug(` Matched original: ${matchedToOriginal}`); - } - - return passed; -} - -// Test 16: Reconciliation function -async function test16_Reconciliation(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 16: Reconciliation Function"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Job stuck in 'processing' but album exists in Lidarr"); - logger.debug("Expected: reconcileWithLidarr should mark as completed"); - - // Create a job that's "stuck" in processing - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - createdAt: new Date(Date.now() - 60 * 60 * 1000), // 1 hour ago - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - }, - }, - }); - logger.debug(` Created stuck job: ${job1.id} (1 hour old)`); - - // Run reconciliation - logger.debug(" Running reconcileWithLidarr()..."); - const result = await simpleDownloadManager.reconcileWithLidarr(); - logger.debug(` Reconciled: ${result.reconciled}, Errors: ${result.errors.length}`); - - // Note: This test may not fully pass without mocking Lidarr API - // But we verify the function runs without crashing - const passed = typeof result.reconciled === "number" && Array.isArray(result.errors); - - if (passed) { - logger.debug("\n[PASS] TEST 16 PASSED: Reconciliation function works"); - } else { - logger.debug("\n[FAIL] TEST 16 FAILED: Reconciliation error"); - } - - return passed; -} - -// Test 17: Stale job timeout -async function test17_StaleJobTimeout(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 17: Stale Job Timeout Detection"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Job created > 2 hours ago, still processing"); - logger.debug("Expected: markStaleJobsAsFailed should handle it"); - - // Create a very old job - const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - createdAt: twoHoursAgo, - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - }, - }, - }); - logger.debug(` Created old job: ${job1.id} (2 hours ago)`); - - // Run stale job cleanup - logger.debug(" Running markStaleJobsAsFailed()..."); - const result = await simpleDownloadManager.markStaleJobsAsFailed(); - logger.debug(` Timed out: ${result}`); - - const jobAfter = await prisma.downloadJob.findUnique({ where: { id: job1.id } }); - - // Job should be marked as failed or exhausted - const passed = jobAfter?.status === "failed" || jobAfter?.status === "exhausted" || result > 0; - - if (passed) { - logger.debug("\n[PASS] TEST 17 PASSED: Stale job timeout handled"); - logger.debug(` Job status: ${jobAfter?.status}`); - } else { - logger.debug("\n[FAIL] TEST 17 FAILED: Stale job not timed out"); - logger.debug(` Job status: ${jobAfter?.status}`); - } - - return passed; -} - -// Test 18: Spotify import jobs should NOT send notifications -async function test18_SpotifyImportNoNotification(): Promise { - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST 18: Spotify Import Jobs Should NOT Send Notifications"); - logger.debug("=".repeat(60)); - logger.debug("Scenario: Spotify import job completes"); - logger.debug("Expected: notificationSent should remain false (skipped)"); - - const job1 = await prisma.downloadJob.create({ - data: { - userId: TEST_USER_ID, - subject: `${TEST_ARTIST} - ${TEST_ALBUM}`, - type: "album", - targetMbid: TEST_MBID_1, - status: "processing", - lidarrRef: "spotify-import-download", - metadata: { - artistName: TEST_ARTIST, - albumTitle: TEST_ALBUM, - spotifyImportJobId: "spotify-import-123", // This marks it as a Spotify import - notificationSent: false, - }, - }, - }); - logger.debug(` Created Spotify import job: ${job1.id}`); - - await simpleDownloadManager.onDownloadComplete( - "spotify-import-download", - TEST_MBID_1, - TEST_ARTIST, - TEST_ALBUM, - 44444 - ); - - const jobAfter = await prisma.downloadJob.findUnique({ where: { id: job1.id } }); - const meta = jobAfter?.metadata as any; - - // notificationSent should NOT be true for Spotify import jobs - const passed = jobAfter?.status === "completed" && meta?.notificationSent !== true; - - if (passed) { - logger.debug("\n[PASS] TEST 18 PASSED: Spotify import notification correctly skipped"); - } else { - logger.debug("\n[FAIL] TEST 18 FAILED: Spotify import incorrectly sent notification"); - logger.debug(` notificationSent: ${meta?.notificationSent}`); - } - - return passed; -} - -async function runAllTests() { - logger.debug("\n" + "=".repeat(60)); - logger.debug("DOWNLOAD JOB DEDUPLICATION TEST SUITE"); - logger.debug("=".repeat(60)); - - const results: { name: string; passed: boolean }[] = []; - - try { - // Setup: Get real user ID - await setup(); - - logger.debug(`Test User ID: ${TEST_USER_ID}`); - logger.debug(`Test Artist: ${TEST_ARTIST}`); - logger.debug(`Test Album: ${TEST_ALBUM}`); - - // Test 1: Duplicate detection on grab - await cleanup(); - results.push({ - name: "Duplicate Job Detection on Grab", - passed: await test1_DuplicateJobDetectionOnGrab() - }); - - // Test 2: Completion merges duplicates - await cleanup(); - results.push({ - name: "Completion Merges Duplicates", - passed: await test2_CompletionMergesDuplicates() - }); - - // Test 3: Notification dedup - await cleanup(); - results.push({ - name: "Notification Deduplication", - passed: await test3_NotificationDedup() - }); - - // Test 4: Grab matches pending by name - await cleanup(); - results.push({ - name: "Grab Matches Pending by Name", - passed: await test4_GrabMatchesByNameWhenMbidDiffers() - }); - - // Test 5: Completion matches by name - await cleanup(); - results.push({ - name: "Completion Matches by Name", - passed: await test5_CompletionMatchesByNameWhenNoLidarrRef() - }); - - // Test 6: Case-insensitive matching - await cleanup(); - results.push({ - name: "Case-Insensitive Matching", - passed: await test6_CaseInsensitiveMatching() - }); - - // Test 7: Same artist, different album - await cleanup(); - results.push({ - name: "Same Artist Different Album - No Match", - passed: await test7_SameArtistDifferentAlbum() - }); - - // Test 8: Idempotent completion - await cleanup(); - results.push({ - name: "Idempotent Completion", - passed: await test8_IdempotentCompletion() - }); - - // Test 9: Discovery jobs no notification - await cleanup(); - results.push({ - name: "Discovery Jobs Skip Notification", - passed: await test9_DiscoveryJobsNoNotification() - }); - - // Test 10: Retry updates lidarrRef - await cleanup(); - results.push({ - name: "Retry Updates lidarrRef", - passed: await test10_RetryUpdatesLidarrRef() - }); - - // Test 11: Subject-only matching - await cleanup(); - results.push({ - name: "Subject-Only Matching", - passed: await test11_SubjectOnlyMatching() - }); - - // Test 12: Three duplicates merge - await cleanup(); - results.push({ - name: "Three Duplicates All Merge", - passed: await test12_ThreeDuplicatesMerge() - }); - - // Test 13: Whitespace variations - await cleanup(); - results.push({ - name: "Whitespace Variations", - passed: await test13_WhitespaceVariations() - }); - - // Test 14: Special characters and Unicode - await cleanup(); - results.push({ - name: "Special Characters and Unicode", - passed: await test14_SpecialCharacters() - }); - - // Test 15: Concurrent race condition - await cleanup(); - results.push({ - name: "Concurrent Race Condition", - passed: await test15_ConcurrentGrabs() - }); - - // Test 16: Reconciliation function - await cleanup(); - results.push({ - name: "Reconciliation Function", - passed: await test16_Reconciliation() - }); - - // Test 17: Stale job timeout - await cleanup(); - results.push({ - name: "Stale Job Timeout", - passed: await test17_StaleJobTimeout() - }); - - // Test 18: Spotify import no notification - await cleanup(); - results.push({ - name: "Spotify Import Skip Notification", - passed: await test18_SpotifyImportNoNotification() - }); - - } catch (error) { - logger.error("\n Test execution error:", error); - } finally { - await cleanup(); - await prisma.$disconnect(); - } - - // Summary - logger.debug("\n" + "=".repeat(60)); - logger.debug("TEST RESULTS SUMMARY"); - logger.debug("=".repeat(60)); - - let passedCount = 0; - let failedCount = 0; - - for (const result of results) { - const icon = result.passed ? "PASS" : "FAIL"; - logger.debug(`${icon} ${result.name}`); - if (result.passed) passedCount++; - else failedCount++; - } - - logger.debug("\n" + "-".repeat(60)); - logger.debug(`Total: ${results.length} tests`); - logger.debug(`Passed: ${passedCount}`); - logger.debug(`Failed: ${failedCount}`); - - if (failedCount === 0) { - logger.debug("\n🎉 ALL TESTS PASSED! Download deduplication is working correctly."); - } else { - logger.debug("\n💥 SOME TESTS FAILED. Review the output above for details."); - } - - process.exit(failedCount > 0 ? 1 : 0); -} - -// Run tests -runAllTests(); - diff --git a/backend/src/utils/async.ts b/backend/src/utils/async.ts index a9ef2d2..9f2e6cf 100644 --- a/backend/src/utils/async.ts +++ b/backend/src/utils/async.ts @@ -39,35 +39,3 @@ export async function withRetry(fn: () => Promise, attempts = 3, delayMs = throw new Error("unreachable"); } -/** - * Process items in batches with yielding between batches - * Checks abort signal to support early termination - * - * @param items - Array of items to process - * @param batchSize - Number of items per batch - * @param processor - Function to process each batch - * @param signal - Optional AbortSignal for early termination - * @returns Flattened array of all processor results - */ -export async function processBatched( - items: T[], - batchSize: number, - processor: (batch: T[]) => Promise, - signal?: AbortSignal -): Promise { - const results: R[] = []; - const chunks = chunkArray(items, batchSize); - - for (const chunk of chunks) { - if (signal?.aborted) { - break; // Exit early if operation was cancelled - } - - const batchResults = await processor(chunk); - results.push(...batchResults); - - await yieldToEventLoop(); - } - - return results; -} diff --git a/backend/src/workers/__tests__/discoverCron.test.ts b/backend/src/workers/__tests__/discoverCron.test.ts deleted file mode 100644 index 2842a32..0000000 --- a/backend/src/workers/__tests__/discoverCron.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -describe('Discover Queue Dedup', () => { - describe('discoverCron.ts', () => { - const source = fs.readFileSync( - path.resolve(__dirname, '../discoverCron.ts'), 'utf-8' - ); - - it('should include jobId in discoverQueue.add call', () => { - expect(source).toMatch(/discoverQueue\.add\([\s\S]*?jobId\s*:/); - }); - - it('should use discover-weekly prefix in jobId', () => { - expect(source).toContain('discover-weekly-'); - }); - }); - - describe('discover.ts route', () => { - const source = fs.readFileSync( - path.resolve(__dirname, '../../routes/discover.ts'), 'utf-8' - ); - - it('should include jobId in discoverQueue.add call', () => { - // Find the manual trigger add call (not the scheduled one) - const addCalls = source.match(/discoverQueue\.add\([\s\S]*?\)/g) || []; - const hasJobId = addCalls.some(call => call.includes('jobId')); - expect(hasJobId).toBe(true); - }); - }); -}); diff --git a/backend/tests/services/acquisitionService.test.ts b/backend/tests/services/acquisitionService.test.ts deleted file mode 100644 index d17c321..0000000 --- a/backend/tests/services/acquisitionService.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, it, expect, beforeEach, afterAll, jest } from '@jest/globals'; - -// Mock p-queue to avoid ESM issues -jest.mock('p-queue', () => { - return jest.fn().mockImplementation(() => ({ - add: jest.fn((fn: any) => fn()), - concurrency: 4, - size: 0, - pending: 0, - })); -}); - -// Mock external services to isolate deduplication logic -jest.mock('../../src/services/soulseek', () => ({ - soulseekService: { - isAvailable: jest.fn<() => Promise>().mockResolvedValue(false), - }, -})); - -jest.mock('../../src/services/musicbrainz', () => ({ - musicBrainzService: { - getAlbumTracks: jest.fn<() => Promise>().mockResolvedValue([]), - }, -})); - -jest.mock('../../src/services/lastfm', () => ({ - lastFmService: { - getArtistCorrection: jest.fn<() => Promise>().mockResolvedValue(null), - getAlbumInfo: jest.fn<() => Promise>().mockResolvedValue(null), - }, -})); - -jest.mock('../../src/utils/systemSettings', () => ({ - getSystemSettings: jest.fn<() => Promise>().mockResolvedValue({ - musicPath: '/music', - downloadPath: '/downloads', - soulseekConcurrentDownloads: 4, - lidarrEnabled: false, - downloadSource: 'soulseek', - }), -})); - -import { acquisitionService } from '../../src/services/acquisitionService'; -import { prisma } from '../../src/utils/db'; -import { redisClient } from '../../src/utils/redis'; - -describe('AcquisitionService - Deduplication', () => { - const testUserId = 'test-user-dedup'; - const testBatchId = 'test-batch-dedup'; - - beforeEach(async () => { - // Clean up test data - await prisma.downloadJob.deleteMany({ - where: { - userId: testUserId, - }, - }); - - // Ensure test user exists - await prisma.user.upsert({ - where: { id: testUserId }, - create: { - id: testUserId, - username: `test-user-${Date.now()}`, - passwordHash: 'test-hash', - role: 'user', - }, - update: {}, - }); - - // Create test batch - await prisma.discoveryBatch.upsert({ - where: { id: testBatchId }, - create: { - id: testBatchId, - userId: testUserId, - weekStart: new Date(), - targetSongCount: 40, - status: 'downloading', - }, - update: {}, - }); - }); - - afterAll(async () => { - // Clean up test data - await prisma.downloadJob.deleteMany({ - where: { - userId: testUserId, - }, - }); - await prisma.discoveryBatch.deleteMany({ - where: { - id: testBatchId, - }, - }); - await prisma.user.deleteMany({ - where: { - id: testUserId, - }, - }); - - // Close connections - await redisClient.quit(); - await prisma.$disconnect(); - }); - - it('should not create duplicate download jobs for same album', async () => { - const albumMbid = 'test-mbid-123'; - - // Test by directly calling the private createDownloadJob method - // This isolates the deduplication logic from the full acquisition flow - const createJob = async () => { - return await (acquisitionService as any).createDownloadJob( - { - albumTitle: 'Test Album', - artistName: 'Test Artist', - mbid: albumMbid, - }, - { - userId: testUserId, - discoveryBatchId: testBatchId, - } - ); - }; - - // Create same job twice concurrently - const promises = [createJob(), createJob()]; - - const results = await Promise.allSettled(promises); - - // Both should complete (one creates, one returns existing) - const fulfilled = results.filter((r) => r.status === 'fulfilled'); - expect(fulfilled.length).toBe(2); - - // If both succeeded, they should return the same job ID - if (fulfilled.length === 2) { - const job1 = (fulfilled[0] as any).value; - const job2 = (fulfilled[1] as any).value; - expect(job1.id).toBe(job2.id); - } - - // Verify only one job exists in DB - const jobs = await prisma.downloadJob.findMany({ - where: { - targetMbid: albumMbid, - userId: testUserId, - discoveryBatchId: testBatchId, - }, - }); - expect(jobs.length).toBe(1); - }); - - it('should allow creating job for same album with different batchId', async () => { - const albumMbid = 'test-mbid-456'; - const request = { - albumTitle: 'Test Album 2', - artistName: 'Test Artist 2', - mbid: albumMbid, - }; - - // Create second batch - const testBatchId2 = 'test-batch-dedup-2'; - await prisma.discoveryBatch.create({ - data: { - id: testBatchId2, - userId: testUserId, - weekStart: new Date(), - targetSongCount: 40, - status: 'downloading', - }, - }); - - // Create job in first batch - await (acquisitionService as any).createDownloadJob(request, { - userId: testUserId, - discoveryBatchId: testBatchId, - }); - - // Create job in second batch (should succeed) - await (acquisitionService as any).createDownloadJob(request, { - userId: testUserId, - discoveryBatchId: testBatchId2, - }); - - // Verify two jobs exist (different batches) - const jobs = await prisma.downloadJob.findMany({ - where: { - targetMbid: albumMbid, - userId: testUserId, - }, - }); - expect(jobs.length).toBe(2); - - // Clean up - await prisma.discoveryBatch.delete({ - where: { id: testBatchId2 }, - }); - }); - - it('should allow creating new job after previous job completes', async () => { - const albumMbid = 'test-mbid-789'; - const request = { - albumTitle: 'Test Album 3', - artistName: 'Test Artist 3', - mbid: albumMbid, - }; - const context = { - userId: testUserId, - discoveryBatchId: testBatchId, - }; - - // Create first job - await (acquisitionService as any).createDownloadJob(request, context); - - // Mark it as completed - const job = await prisma.downloadJob.findFirst({ - where: { - targetMbid: albumMbid, - userId: testUserId, - discoveryBatchId: testBatchId, - }, - }); - await prisma.downloadJob.update({ - where: { id: job!.id }, - data: { status: 'completed' }, - }); - - // Create second job (should succeed - previous is completed) - await (acquisitionService as any).createDownloadJob(request, context); - - // Verify two jobs exist (one completed, one pending) - const jobs = await prisma.downloadJob.findMany({ - where: { - targetMbid: albumMbid, - userId: testUserId, - discoveryBatchId: testBatchId, - }, - }); - expect(jobs.length).toBe(2); - expect(jobs.filter((j) => j.status === 'completed').length).toBe(1); - expect( - jobs.filter((j) => j.status === 'pending' || j.status === 'downloading') - .length - ).toBe(1); - }); -}); diff --git a/backend/tests/services/soulseek.test.ts b/backend/tests/services/soulseek.test.ts deleted file mode 100644 index 7d55feb..0000000 --- a/backend/tests/services/soulseek.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect, jest, beforeEach } from '@jest/globals'; - -// Mock p-queue to avoid ESM issues -jest.mock('p-queue', () => { - return jest.fn().mockImplementation(() => ({ - add: jest.fn((fn: any) => fn()), - })); -}); - -import { SoulseekService } from '../../src/services/soulseek'; -import { distributedLock } from '../../src/utils/distributedLock'; - -describe('SoulseekService - Connection Race Condition', () => { - let service: SoulseekService; - let connectSpy: jest.SpiedFunction; - - beforeEach(() => { - service = new SoulseekService(); - // Spy on the private connect method - connectSpy = jest.spyOn(service as any, 'connect').mockResolvedValue(undefined); - }); - - it('should prevent concurrent connection attempts', async () => { - // Simulate 3 concurrent connection requests - const promises = [ - (service as any).ensureConnected(), - (service as any).ensureConnected().catch((e: Error) => { - // Second request fails to acquire lock, error is re-thrown with user-friendly message - expect(e.message).toContain('Soulseek connection already in progress'); - }), - (service as any).ensureConnected().catch((e: Error) => { - // Third request fails to acquire lock, error is re-thrown with user-friendly message - expect(e.message).toContain('Soulseek connection already in progress'); - }), - ]; - - await Promise.all(promises); - - // With distributed lock: should only call connect once - // Other concurrent requests are blocked by the lock - expect(connectSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/backend/tests/utils/distributedLock.test.ts b/backend/tests/utils/distributedLock.test.ts deleted file mode 100644 index 30b4c3c..0000000 --- a/backend/tests/utils/distributedLock.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { DistributedLock } from '../../src/utils/distributedLock'; -import { redisClient } from '../../src/utils/redis'; - -describe('DistributedLock', () => { - let lock: DistributedLock; - - beforeEach(() => { - lock = new DistributedLock(redisClient); - }); - - afterEach(async () => { - await redisClient.flushAll(); - }); - - it('should acquire lock successfully', async () => { - const acquired = await lock.acquire('test-lock', 5000); - expect(acquired).toBe(true); - }); - - it('should fail to acquire already-held lock', async () => { - const lock1 = new DistributedLock(redisClient); - const lock2 = new DistributedLock(redisClient); - await lock1.acquire('test-lock', 5000); - const acquired = await lock2.acquire('test-lock', 5000); - expect(acquired).toBe(false); - }); - - it('should release lock and allow re-acquisition', async () => { - await lock.acquire('test-lock', 5000); - await lock.release('test-lock'); - const acquired = await lock.acquire('test-lock', 5000); - expect(acquired).toBe(true); - }); - - it('should auto-expire lock after TTL', async () => { - await lock.acquire('test-lock', 100); // 100ms TTL - await new Promise(resolve => setTimeout(resolve, 150)); - const acquired = await lock.acquire('test-lock', 5000); - expect(acquired).toBe(true); - }); - - it('should execute callback with lock held', async () => { - let executed = false; - await lock.withLock('test-lock', 5000, async () => { - executed = true; - }); - expect(executed).toBe(true); - }); - - it('should release lock even if callback throws', async () => { - try { - await lock.withLock('test-lock', 5000, async () => { - throw new Error('Test error'); - }); - } catch (e) { - // Expected - } - const acquired = await lock.acquire('test-lock', 5000); - expect(acquired).toBe(true); - }); -}); diff --git a/frontend/assets/icon-background.png b/frontend/assets/icon-background.png deleted file mode 100644 index 8b8d3cc..0000000 Binary files a/frontend/assets/icon-background.png and /dev/null differ diff --git a/frontend/assets/icon-foreground.png b/frontend/assets/icon-foreground.png deleted file mode 100644 index 34362bf..0000000 Binary files a/frontend/assets/icon-foreground.png and /dev/null differ diff --git a/frontend/assets/icon-only.png b/frontend/assets/icon-only.png deleted file mode 100644 index 34362bf..0000000 Binary files a/frontend/assets/icon-only.png and /dev/null differ diff --git a/frontend/assets/splash-dark.png b/frontend/assets/splash-dark.png deleted file mode 100644 index 7958ff6..0000000 Binary files a/frontend/assets/splash-dark.png and /dev/null differ diff --git a/frontend/assets/splash.png b/frontend/assets/splash.png deleted file mode 100644 index 7958ff6..0000000 Binary files a/frontend/assets/splash.png and /dev/null differ diff --git a/frontend/hooks/useImageColor.ts b/frontend/hooks/useImageColor.ts index 7af0de7..e3492a7 100644 --- a/frontend/hooks/useImageColor.ts +++ b/frontend/hooks/useImageColor.ts @@ -339,124 +339,3 @@ export function useImageColor(imageUrl: string | null | undefined) { return { colors, isLoading }; } -/** - * Generate a gradient style object from extracted colors - */ -export function createGradient( - colors: ColorPalette | null, - fallbackColor: string = "#1db954" -): React.CSSProperties { - if (!colors) { - return { - background: `linear-gradient(180deg, ${fallbackColor}40 0%, rgba(0, 0, 0, 0) 100%)`, - }; - } - - // Create a gradient from dark vibrant to transparent black - return { - background: `linear-gradient(180deg, ${colors.darkVibrant} 0%, ${colors.darkMuted} 40%, rgba(0, 0, 0, 0.8) 70%, #000000 100%)`, - }; -} - -/** - * Generate a hero gradient (for the top section) - */ -export function createHeroGradient( - colors: ColorPalette | null, - fallbackColor: string = "#1db954" -): React.CSSProperties { - if (!colors) { - return { - background: `linear-gradient(180deg, ${fallbackColor}60 0%, rgba(18, 18, 18, 0.9) 100%)`, - }; - } - - // Create a more vibrant gradient for the hero section - return { - background: `linear-gradient(180deg, ${colors.vibrant}40 0%, ${colors.darkVibrant}80 50%, rgba(18, 18, 18, 0.95) 100%)`, - }; -} - -/** - * Calculate relative luminance for contrast ratio - * Based on WCAG 2.0 formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef - */ -function getLuminance(r: number, g: number, b: number): number { - const [rs, gs, bs] = [r, g, b].map((c) => { - c = c / 255; - return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); - }); - return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; -} - -/** - * Calculate contrast ratio between two colors - * Returns a value between 1 and 21 (higher is better contrast) - * WCAG AA requires 4.5:1 for normal text, 3:1 for large text - */ -function getContrastRatio(hex1: string, hex2: string): number { - const rgb1 = hexToRgb(hex1); - const rgb2 = hexToRgb(hex2); - - if (!rgb1 || !rgb2) return 1; - - const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b); - const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b); - - const lighter = Math.max(lum1, lum2); - const darker = Math.min(lum1, lum2); - - return (lighter + 0.05) / (darker + 0.05); -} - -/** - * Convert hex color to RGB - */ -function hexToRgb(hex: string): { r: number; g: number; b: number } | null { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result - ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - } - : null; -} - -/** - * Get the best icon color (black or white) for a given background color - * Returns 'black' or 'white' based on contrast ratio - */ -export function getIconColor(backgroundColor: string): "black" | "white" { - const whiteContrast = getContrastRatio(backgroundColor, "#ffffff"); - const blackContrast = getContrastRatio(backgroundColor, "#000000"); - - // Return the color with better contrast - // Use white if contrast is close (within 1.5), as it looks better on colorful backgrounds - return whiteContrast > blackContrast - 1.5 ? "white" : "black"; -} - -/** - * Get play button styles based on vibrant color - * Returns background color and icon color with proper contrast - */ -export function getPlayButtonStyles(colors: ColorPalette | null): { - backgroundColor: string; - iconColor: "black" | "white"; -} { - if (!colors) { - return { - backgroundColor: "#facc15", // Yellow fallback - iconColor: "black", - }; - } - - // Use the vibrant color for the button - const bgColor = colors.vibrant; - const iconColor = getIconColor(bgColor); - - return { - backgroundColor: bgColor, - iconColor, - }; -} diff --git a/frontend/lib/enrichmentApi.ts b/frontend/lib/enrichmentApi.ts index 33d4789..f622e4a 100644 --- a/frontend/lib/enrichmentApi.ts +++ b/frontend/lib/enrichmentApi.ts @@ -226,13 +226,6 @@ export const enrichmentApi = { }); }, - /** - * Retry failed vibe embeddings - */ - retryVibeEmbeddings: async (): Promise<{ message: string; queued: number }> => { - return api.post("/analysis/vibe/retry", {}); - }, - /** * Reset all vibe embeddings (queue all tracks for re-embedding) */ diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png deleted file mode 100644 index b75d94f..0000000 Binary files a/frontend/public/apple-touch-icon.png and /dev/null differ diff --git a/frontend/public/assets/images/Kima__favicon.ico b/frontend/public/assets/images/Kima__favicon.ico deleted file mode 100644 index b2e18dc..0000000 Binary files a/frontend/public/assets/images/Kima__favicon.ico and /dev/null differ diff --git a/frontend/public/assets/images/kima-2.webp b/frontend/public/assets/images/kima-2.webp deleted file mode 100644 index 0dee6cc..0000000 Binary files a/frontend/public/assets/images/kima-2.webp and /dev/null differ diff --git a/frontend/public/assets/images/kima-black.webp b/frontend/public/assets/images/kima-black.webp deleted file mode 100644 index f42a065..0000000 Binary files a/frontend/public/assets/images/kima-black.webp and /dev/null differ diff --git a/frontend/public/assets/images/kima_circular.webp b/frontend/public/assets/images/kima_circular.webp deleted file mode 100644 index a669500..0000000 Binary files a/frontend/public/assets/images/kima_circular.webp and /dev/null differ diff --git a/frontend/scripts/generate-pwa-icons.js b/frontend/scripts/generate-pwa-icons.js deleted file mode 100644 index 60ee497..0000000 --- a/frontend/scripts/generate-pwa-icons.js +++ /dev/null @@ -1,67 +0,0 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ -/** - * Generate PWA icons from the correct Kima logo - * - * Uses the icon-only.png (smooth black circle with yellow soundwave) - * instead of the old sharp-edged version with white borders - */ - -const sharp = require("sharp"); -const path = require("path"); -const fs = require("fs"); - -// Source: The correct logo (smooth black circle, yellow soundwave, no white borders) -const SOURCE_ICON = path.join(__dirname, "..", "assets", "icon-only.png"); -const OUTPUT_DIR = path.join(__dirname, "..", "public", "assets", "icons"); - -// PWA icon sizes -const SIZES = [48, 72, 96, 128, 192, 256, 512]; - -async function generatePwaIcons() { - console.log("Generating PWA icons from icon-only.png..."); - - // Verify source exists - if (!fs.existsSync(SOURCE_ICON)) { - console.error(`Source icon not found: ${SOURCE_ICON}`); - process.exit(1); - } - - // Ensure output directory exists - if (!fs.existsSync(OUTPUT_DIR)) { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - } - - // Get source metadata - const meta = await sharp(SOURCE_ICON).metadata(); - console.log(`Source icon: ${meta.width}x${meta.height}`); - - for (const size of SIZES) { - const outputPath = path.join(OUTPUT_DIR, `icon-${size}.webp`); - - await sharp(SOURCE_ICON) - .resize(size, size, { - fit: "contain", - background: { r: 0, g: 0, b: 0, alpha: 0 } - }) - .webp({ quality: 90 }) - .toFile(outputPath); - - console.log(`✓ Generated icon-${size}.webp`); - } - - // Also generate a PNG version for favicon (some browsers prefer PNG) - const faviconPath = path.join(__dirname, "..", "public", "assets", "images", "favicon-192.png"); - await sharp(SOURCE_ICON) - .resize(192, 192) - .png() - .toFile(faviconPath); - console.log(`✓ Generated favicon-192.png`); - - console.log("\n[SUCCESS] All PWA icons generated!"); -} - -generatePwaIcons().catch((err) => { - console.error("Error generating PWA icons:", err); - process.exit(1); -}); - diff --git a/frontend/tests/e2e/predeploy/analyzers.spec.ts b/frontend/tests/e2e/predeploy/analyzers.spec.ts deleted file mode 100644 index d762839..0000000 --- a/frontend/tests/e2e/predeploy/analyzers.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { loginAsTestUser } from "../fixtures/test-helpers"; - -test.describe("Analyzers", () => { - test("app loads with analyzer detection working", async ({ page }) => { - await loginAsTestUser(page); - await page.goto("/"); - - // The app should load properly - if feature detection fails, the app would error - await expect(page.locator("body")).toBeVisible(); - - // Should see the main navigation - await expect(page.locator("text=Library")).toBeVisible({ timeout: 5000 }); - }); - - test("library shows content (requires working backend)", async ({ page }) => { - await loginAsTestUser(page); - await page.goto("/collection?tab=albums"); - - // If analyzers/backend are broken, library would be empty or error - const albumLinks = page.locator('a[href^="/album/"]'); - await expect(albumLinks.first()).toBeVisible({ timeout: 10000 }); - }); -}); diff --git a/frontend/tests/e2e/predeploy/settings.spec.ts b/frontend/tests/e2e/predeploy/settings.spec.ts deleted file mode 100644 index d8a79cc..0000000 --- a/frontend/tests/e2e/predeploy/settings.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { loginAsTestUser } from "../fixtures/test-helpers"; - -test.describe("Settings", () => { - test.beforeEach(async ({ page }) => { - await loginAsTestUser(page); - }); - - test("settings page loads with account section", async ({ page }) => { - await page.goto("/settings"); - - await expect(page.getByRole("heading", { name: /settings/i })).toBeVisible(); - - // Should have Account section (use first() since multiple elements match) - await expect(page.locator("text=Account").first()).toBeVisible({ timeout: 5000 }); - }); - - test("settings page shows playback section", async ({ page }) => { - await page.goto("/settings"); - - // Should have Playback section (use first() since multiple elements match) - await expect(page.locator("text=Playback").first()).toBeVisible({ timeout: 5000 }); - }); -}); diff --git a/frontend/utils/formatTime.test.ts b/frontend/utils/formatTime.test.ts deleted file mode 100644 index 262ba87..0000000 --- a/frontend/utils/formatTime.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Unit tests for formatTime utility functions - * Run with: npx tsx frontend/utils/formatTime.test.ts - */ - -import { - formatTime, - formatDuration, - clampTime, - formatTimeRemaining, -} from "./formatTime"; - -// Test utilities -let passed = 0; -let failed = 0; - -function assertEqual(actual: unknown, expected: unknown, testName: string) { - if (actual === expected) { - console.log(`✓ ${testName}`); - passed++; - } else { - console.error(`✗ ${testName}`); - console.error(` Expected: ${expected}`); - console.error(` Actual: ${actual}`); - failed++; - } -} - -console.log("\n=== Testing formatTime ===\n"); - -// Basic formatting -assertEqual(formatTime(0), "0:00", "formatTime(0) = 0:00"); -assertEqual(formatTime(30), "0:30", "formatTime(30) = 0:30"); -assertEqual(formatTime(60), "1:00", "formatTime(60) = 1:00"); -assertEqual(formatTime(125), "2:05", "formatTime(125) = 2:05"); -assertEqual(formatTime(3599), "59:59", "formatTime(3599) = 59:59"); -assertEqual(formatTime(3600), "1:00:00", "formatTime(3600) = 1:00:00"); -assertEqual(formatTime(3661), "1:01:01", "formatTime(3661) = 1:01:01"); -assertEqual(formatTime(7325), "2:02:05", "formatTime(7325) = 2:02:05"); - -// Edge cases -assertEqual(formatTime(-1), "0:00", "formatTime(-1) = 0:00 (negative)"); -assertEqual(formatTime(NaN), "0:00", "formatTime(NaN) = 0:00"); -assertEqual(formatTime(Infinity), "0:00", "formatTime(Infinity) = 0:00"); - -console.log("\n=== Testing clampTime ===\n"); - -// Basic clamping - THE CRITICAL FIX -assertEqual(clampTime(0, 100), 0, "clampTime(0, 100) = 0"); -assertEqual(clampTime(50, 100), 50, "clampTime(50, 100) = 50 (within bounds)"); -assertEqual( - clampTime(100, 100), - 100, - "clampTime(100, 100) = 100 (at boundary)" -); -assertEqual( - clampTime(150, 100), - 100, - "clampTime(150, 100) = 100 (CRITICAL: clamp to duration)" -); -assertEqual( - clampTime(3480, 3274), - 3274, - "clampTime(3480, 3274) = 3274 (Office Ladies bug case)" -); - -// Edge cases -assertEqual( - clampTime(-5, 100), - 0, - "clampTime(-5, 100) = 0 (negative clamp to 0)" -); -assertEqual( - clampTime(50, 0), - 50, - "clampTime(50, 0) = 50 (zero duration edge case)" -); -assertEqual( - clampTime(-10, 0), - 0, - "clampTime(-10, 0) = 0 (negative with zero duration)" -); - -console.log("\n=== Testing formatTimeRemaining ===\n"); - -// Basic remaining time format -assertEqual( - formatTimeRemaining(0), - "0:00", - "formatTimeRemaining(0) = 0:00 (complete)" -); -assertEqual( - formatTimeRemaining(30), - "-0:30", - "formatTimeRemaining(30) = -0:30" -); -assertEqual( - formatTimeRemaining(125), - "-2:05", - "formatTimeRemaining(125) = -2:05" -); -assertEqual( - formatTimeRemaining(3600), - "-1:00:00", - "formatTimeRemaining(3600) = -1:00:00" -); -assertEqual( - formatTimeRemaining(7325), - "-2:02:05", - "formatTimeRemaining(7325) = -2:02:05" -); - -// Edge cases -assertEqual( - formatTimeRemaining(-5), - "0:00", - "formatTimeRemaining(-5) = 0:00 (negative)" -); -assertEqual( - formatTimeRemaining(NaN), - "0:00", - "formatTimeRemaining(NaN) = 0:00" -); - -console.log("\n=== Testing formatDuration ===\n"); - -// Basic duration formatting -assertEqual(formatDuration(0), "0m", "formatDuration(0) = 0m"); -assertEqual(formatDuration(60), "1m", "formatDuration(60) = 1m"); -assertEqual(formatDuration(3600), "1h", "formatDuration(3600) = 1h"); -assertEqual(formatDuration(5400), "1h 30m", "formatDuration(5400) = 1h 30m"); - -console.log("\n=== Integration Tests (Bug Scenarios) ===\n"); - -// Simulate the Office Ladies podcast bug -// Current time: 58:00 (3480 seconds), Duration: 54:34 (3274 seconds) -const podcastCurrentTime = 3480; // 58:00 -const podcastDuration = 3274; // 54:34 -const clampedTime = clampTime(podcastCurrentTime, podcastDuration); -assertEqual( - clampedTime, - podcastDuration, - "Office Ladies bug: current time clamped to duration" -); -assertEqual( - formatTime(clampedTime), - "54:34", - "Office Ladies bug: displays 54:34 not 58:00" -); - -// Time remaining should show 0:00 when at end -const remaining = Math.max(0, podcastDuration - clampedTime); -assertEqual( - formatTimeRemaining(remaining), - "0:00", - "Office Ladies bug: time remaining = 0:00 at end" -); - -// Progress should be 100% max -const progress = Math.min( - 100, - Math.max(0, (clampedTime / podcastDuration) * 100) -); -assertEqual(progress, 100, "Office Ladies bug: progress = 100% (not > 100%)"); - -// Test podcast in progress -const podcastInProgress = clampTime(1000, 3274); -const remainingInProgress = Math.max(0, 3274 - podcastInProgress); -assertEqual( - formatTimeRemaining(remainingInProgress), - "-37:54", - "Podcast in progress: shows remaining time" -); - -console.log("\n" + "=".repeat(50)); -console.log(`Results: ${passed} passed, ${failed} failed`); -console.log("=".repeat(50) + "\n"); - -if (failed > 0) { - process.exit(1); -}