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
@@ -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,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\(/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 196 KiB |
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 11 KiB |
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||