feat: skip MusicBrainz when Lidarr disabled, import cancellation, codebase cleanup

Skip all MusicBrainz API calls during playlist import preview and
acquisition when Lidarr is not configured. Soulseek searches by
artist+album+track text and never uses MBIDs, so MB resolution was
pure waste for Soulseek-only users (~15 min bottleneck on 170 songs).

Import cancellation: thread AbortSignal through Soulseek search
strategies and rate limiter so cancel propagates immediately.

Codebase sweep: remove 15 brittle backend tests (string-matching via
fs.readFileSync, live-infrastructure tests, manual scripts), 3 hollow
frontend E2E tests, 16 unused frontend assets/scripts, and dead code
across both stacks.

Backend dead code removed:
- podcastindex.ts: 7 unused exported functions
- artistCountsService.ts: 4 unused exports, unexport internal helper
- imageBackfill.ts: unexport 2 internal functions
- async.ts: remove unused processBatched

Frontend dead code removed:
- useImageColor.ts: 7 dead functions (createGradient, createHeroGradient,
  getPlayButtonStyles, getIconColor, getLuminance, getContrastRatio, hexToRgb)
- enrichmentApi.ts: remove dead retryVibeEmbeddings method
This commit is contained in:
Your Name
2026-03-04 12:39:21 -06:00
parent 7d0d9a8f71
commit 2b1ca33ed7
41 changed files with 646 additions and 4094 deletions
+5
View File
@@ -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).
+2 -2
View File
@@ -2,8 +2,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: ['**/__tests__/**/*.test.ts', '**/tests/**/*.test.ts'],
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
clearMocks: true,
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
@@ -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');
});
});
@@ -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();
});
});
@@ -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_')");
});
});
@@ -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);
});
});
});
@@ -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<string>");
});
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);
});
});
});
@@ -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");
});
});
});
@@ -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);
});
});
@@ -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\(/);
});
});
});
+91 -90
View File
@@ -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<AcquisitionResult>((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<AcquisitionResult> {
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<AcquisitionResult> {
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,
},
};
+1 -73
View File
@@ -25,7 +25,7 @@ interface ArtistCounts {
/**
* Calculate counts for a single artist
*/
export async function calculateArtistCounts(
async function calculateArtistCounts(
artistId: string
): Promise<ArtistCounts> {
const [libraryAlbums, discoveryAlbums, trackCount] = await Promise.all([
@@ -77,63 +77,6 @@ export async function updateArtistCounts(artistId: string): Promise<void> {
}
}
/**
* 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<void> {
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<void> {
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<void> {
logger.info("[ArtistCounts] Force recalculating all counts...");
// Reset countsLastUpdated to trigger backfill
await prisma.artist.updateMany({
data: { countsLastUpdated: null },
});
// Run backfill
await backfillAllArtistCounts();
}
@@ -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);
});
});
+2 -2
View File
@@ -75,7 +75,7 @@ export async function isImageBackfillNeeded(): Promise<{
/**
* Backfill artist images - download external URLs and store locally
*/
export async function backfillArtistImages(): Promise<void> {
async function backfillArtistImages(): Promise<void> {
if (backfillProgress.inProgress) {
logger.warn("[ImageBackfill] Backfill already in progress");
return;
@@ -186,7 +186,7 @@ export async function backfillArtistImages(): Promise<void> {
/**
* Backfill album covers - download external URLs and store locally
*/
export async function backfillAlbumCovers(): Promise<void> {
async function backfillAlbumCovers(): Promise<void> {
if (backfillProgress.inProgress) {
logger.warn("[ImageBackfill] Backfill already in progress");
return;
-66
View File
@@ -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;
}
@@ -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<FileSearchResponse[]>;
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
});
+237 -47
View File
@@ -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<void> {
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<void>((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<FileSearchResponse[]> {
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<SearchTrackResult> {
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");
}
+301 -319
View File
@@ -351,6 +351,8 @@ function stringSimilarity(a: string, b: string): number {
}
class SpotifyImportService {
private activeImportControllers = new Map<string, AbortController>();
/**
* 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<void> {
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<string>();
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<string>();
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({
@@ -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> → Various Artists",
input: "<Various Artists>",
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();
File diff suppressed because it is too large Load Diff
-32
View File
@@ -39,35 +39,3 @@ export async function withRetry<T>(fn: () => Promise<T>, 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<T, R>(
items: T[],
batchSize: number,
processor: (batch: T[]) => Promise<R[]>,
signal?: AbortSignal
): Promise<R[]> {
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;
}
@@ -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);
});
});
});
@@ -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<boolean>>().mockResolvedValue(false),
},
}));
jest.mock('../../src/services/musicbrainz', () => ({
musicBrainzService: {
getAlbumTracks: jest.fn<() => Promise<any[]>>().mockResolvedValue([]),
},
}));
jest.mock('../../src/services/lastfm', () => ({
lastFmService: {
getArtistCorrection: jest.fn<() => Promise<any>>().mockResolvedValue(null),
getAlbumInfo: jest.fn<() => Promise<any>>().mockResolvedValue(null),
},
}));
jest.mock('../../src/utils/systemSettings', () => ({
getSystemSettings: jest.fn<() => Promise<any>>().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);
});
});
-43
View File
@@ -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<any>;
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);
});
});
@@ -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);
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

-121
View File
@@ -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,
};
}
-7
View File
@@ -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)
*/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

-67
View File
@@ -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);
});
@@ -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 });
});
});
@@ -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 });
});
});
-181
View File
@@ -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);
}