diff --git a/apps/backend/controllers/requestLine.controller.ts b/apps/backend/controllers/requestLine.controller.ts index d5ba88c..d6b755c 100644 --- a/apps/backend/controllers/requestLine.controller.ts +++ b/apps/backend/controllers/requestLine.controller.ts @@ -1,28 +1,38 @@ import { RequestHandler } from 'express'; import * as RequestLineService from '../services/requestLine.service.js'; -import * as AnonymousDeviceService from '../services/anonymousDevice.service.js'; +import { processRequest, parseOnly, getConfig, isParsingEnabled } from '../services/requestLine/index.js'; +import { searchLibrary } from '../services/library.service.js'; export type RequestLineBody = { message: string; + skipSlack?: boolean; + skipParsing?: boolean; }; export type RegisterDeviceBody = { deviceId: string; }; +export type LibrarySearchQuery = { + artist?: string; + title?: string; + query?: string; + limit?: string; +}; + // Message validation constants const MESSAGE_MIN_LENGTH = 1; const MESSAGE_MAX_LENGTH = 500; /** - * Register an anonymous device and receive a JWT token. + * Legacy device registration endpoint (deprecated). + * Redirects clients to use the better-auth anonymous sign-in endpoint. * POST /request/register */ -export const registerDevice: RequestHandler = async (req, res, next) => { - const requestId = `reg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const startTime = Date.now(); +export const registerDevice: RequestHandler = async (req, res) => { + const authUrl = process.env.BETTER_AUTH_URL || 'http://localhost:8082/auth'; - console.log(`[${requestId}] Device registration request:`, { + console.log('Legacy /request/register endpoint called - redirecting to better-auth', { method: req.method, url: req.originalUrl, ip: req.ip, @@ -30,72 +40,23 @@ export const registerDevice: RequestHandler timestamp: new Date().toISOString(), }); - const { deviceId } = req.body; - - // Validate deviceId - if (!deviceId || typeof deviceId !== 'string') { - const responseTime = Date.now() - startTime; - console.log(`[${requestId}] Registration failed: missing deviceId`, { responseTime: `${responseTime}ms` }); - res.status(400).json({ message: 'deviceId is required' }); - return; - } - - if (!AnonymousDeviceService.isValidDeviceId(deviceId)) { - const responseTime = Date.now() - startTime; - console.log(`[${requestId}] Registration failed: invalid deviceId format`, { responseTime: `${responseTime}ms` }); - res.status(400).json({ message: 'Invalid deviceId format. Must be a valid UUID.' }); - return; - } - - try { - // Register or retrieve device - const result = await AnonymousDeviceService.registerDevice(deviceId); - - if (!result) { - // Device is blocked - const responseTime = Date.now() - startTime; - console.log(`[${requestId}] Registration rejected: device blocked`, { deviceId, responseTime: `${responseTime}ms` }); - res.status(403).json({ message: 'Device has been blocked' }); - return; - } - - // Generate token - const tokenResult = await AnonymousDeviceService.generateToken(deviceId); - - const responseTime = Date.now() - startTime; - console.log(`[${requestId}] Registration successful:`, { - deviceId, - isNew: result.isNew, - responseTime: `${responseTime}ms`, - }); - - res.status(200).json({ - token: tokenResult.token, - expiresAt: tokenResult.expiresAt.toISOString(), - }); - } catch (e) { - const error = e instanceof Error ? e : new Error(String(e)); - const responseTime = Date.now() - startTime; - console.error(`[${requestId}] Registration error:`, { - error: error.message, - stack: error.stack, - responseTime: `${responseTime}ms`, - }); - next(e); - } + res.status(301).json({ + message: 'This endpoint is deprecated. Use POST /auth/sign-in/anonymous for registration.', + endpoint: `${authUrl}/sign-in/anonymous`, + }); }; export const submitRequestLine: RequestHandler = async (req, res, next) => { const logId = `rl-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const startTime = Date.now(); - const deviceId = req.anonymousDevice?.deviceId || 'unknown'; + const userId = req.user?.id || 'unknown'; // Log incoming request console.log(`[${logId}] Request line received:`, { method: req.method, url: req.originalUrl, ip: req.ip, - deviceId, + userId, userAgent: req.get('User-Agent'), messageLength: req.body.message?.length || 0, timestamp: new Date().toISOString(), @@ -150,33 +111,176 @@ export const submitRequestLine: RequestHandler } try { - const result = await RequestLineService.submitRequestLine(trimmedMessage); + // Use enhanced service if AI parsing is available, otherwise fall back to simple Slack post + const config = getConfig(); + + if (isParsingEnabled(config)) { + // Use enhanced pipeline with AI parsing, library search, and artwork + const result = await processRequest({ + message: trimmedMessage, + skipSlack: req.body.skipSlack, + skipParsing: req.body.skipParsing, + }); + + const responseTime = Date.now() - startTime; + console.log(`[${logId}] Request completed successfully (enhanced):`, { + statusCode: 200, + responseTime: `${responseTime}ms`, + messageLength: trimmedMessage.length, + userId, + searchType: result.searchType, + libraryResultsCount: result.libraryResults.length, + hasArtwork: !!result.artwork?.artworkUrl, + parsed: { + isRequest: result.parsed.isRequest, + messageType: result.parsed.messageType, + hasArtist: !!result.parsed.artist, + hasAlbum: !!result.parsed.album, + hasSong: !!result.parsed.song, + }, + }); + + res.status(200).json(result); + } else { + // Fall back to simple Slack post (legacy behavior) + const result = await RequestLineService.submitRequestLine(trimmedMessage); + + const responseTime = Date.now() - startTime; + console.log(`[${logId}] Request completed successfully (legacy):`, { + statusCode: 200, + responseTime: `${responseTime}ms`, + messageLength: trimmedMessage.length, + userId, + slackResponse: result, + }); + + res.status(200).json({ + success: true, + message: 'Request line submitted successfully', + result, + }); + } + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + const responseTime = Date.now() - startTime; + + console.error(`[${logId}] Request failed:`, { + statusCode: 500, + responseTime: `${responseTime}ms`, + error: error.message, + stack: error.stack, + messageLength: req.body.message?.length || 0, + userId, + }); + + next(e); + } +}; + +/** + * Parse a message only (for debugging). + * POST /request/parse + */ +export const parseMessage: RequestHandler = async (req, res, next) => { + const logId = `parse-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const startTime = Date.now(); + + console.log(`[${logId}] Parse request received:`, { + method: req.method, + url: req.originalUrl, + ip: req.ip, + messageLength: req.body.message?.length || 0, + timestamp: new Date().toISOString(), + }); + + const message = req.body.message?.trim(); + if (!message) { + res.status(400).json({ message: 'Message is required' }); + return; + } + + try { + const parsed = await parseOnly(message); + + const responseTime = Date.now() - startTime; + console.log(`[${logId}] Parse completed:`, { + responseTime: `${responseTime}ms`, + parsed: { + isRequest: parsed.isRequest, + messageType: parsed.messageType, + hasArtist: !!parsed.artist, + hasAlbum: !!parsed.album, + hasSong: !!parsed.song, + }, + }); + + res.status(200).json({ success: true, parsed }); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + const responseTime = Date.now() - startTime; + + console.error(`[${logId}] Parse failed:`, { + responseTime: `${responseTime}ms`, + error: error.message, + }); + + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * Search the library. + * GET /library/search + */ +export const searchLibraryEndpoint: RequestHandler = async ( + req, + res, + next +) => { + const logId = `search-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const startTime = Date.now(); + + const { artist, title, query, limit } = req.query; + const limitNum = limit ? parseInt(limit, 10) : 5; + + console.log(`[${logId}] Library search request:`, { + method: req.method, + url: req.originalUrl, + ip: req.ip, + artist, + title, + query, + limit: limitNum, + timestamp: new Date().toISOString(), + }); + + if (!artist && !title && !query) { + res.status(400).json({ message: 'At least one of artist, title, or query is required' }); + return; + } + + try { + const results = await searchLibrary(query, artist, title, limitNum); const responseTime = Date.now() - startTime; - console.log(`[${logId}] Request completed successfully:`, { - statusCode: 200, + console.log(`[${logId}] Search completed:`, { responseTime: `${responseTime}ms`, - messageLength: trimmedMessage.length, - deviceId, - slackResponse: result, + resultsCount: results.length, }); res.status(200).json({ success: true, - message: 'Request line submitted successfully', - result, + results, + total: results.length, + query: { artist, title, query, limit: limitNum }, }); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); const responseTime = Date.now() - startTime; - console.error(`[${logId}] Request failed:`, { - statusCode: 500, + console.error(`[${logId}] Search failed:`, { responseTime: `${responseTime}ms`, error: error.message, - stack: error.stack, - messageLength: req.body.message?.length || 0, - deviceId, }); next(e); diff --git a/apps/backend/routes/library.route.ts b/apps/backend/routes/library.route.ts index 6dbd987..f7f5ea9 100644 --- a/apps/backend/routes/library.route.ts +++ b/apps/backend/routes/library.route.ts @@ -1,9 +1,19 @@ import { requirePermissions } from "@wxyc/authentication"; import { Router } from "express"; import * as libraryController from "../controllers/library.controller.js"; +import * as requestLineController from "../controllers/requestLine.controller.js"; +import { requireAnonymousAuth } from "../middleware/anonymousAuth.js"; export const library_route = Router(); +// Public library search endpoint (for request line feature) +// Uses anonymous auth instead of DJ permissions +library_route.get( + "/search", + requireAnonymousAuth, + requestLineController.searchLibraryEndpoint +); + library_route.get( "/", requirePermissions({ catalog: ["read"] }), diff --git a/apps/backend/routes/requestLine.route.ts b/apps/backend/routes/requestLine.route.ts index 8d0568d..8fef27a 100644 --- a/apps/backend/routes/requestLine.route.ts +++ b/apps/backend/routes/requestLine.route.ts @@ -12,3 +12,6 @@ request_line_route.post('/register', registrationRateLimit, requestLineControlle // Request Line - song requests from listeners (requires anonymous auth) // Rate limited by device ID after authentication request_line_route.post('/', requireAnonymousAuth, songRequestRateLimit, requestLineController.submitRequestLine); + +// Parse only - for debugging AI parser (requires anonymous auth) +request_line_route.post('/parse', requireAnonymousAuth, requestLineController.parseMessage); diff --git a/apps/backend/services/ai/index.ts b/apps/backend/services/ai/index.ts new file mode 100644 index 0000000..72da3fb --- /dev/null +++ b/apps/backend/services/ai/index.ts @@ -0,0 +1,6 @@ +/** + * Barrel export for AI services. + */ + +export { parseRequest, isParserAvailable, resetGroqClient } from './parser.service.js'; +export { SYSTEM_PROMPT, USER_PROMPT_TEMPLATE, formatUserPrompt } from './prompts.js'; diff --git a/apps/backend/services/ai/parser.service.ts b/apps/backend/services/ai/parser.service.ts new file mode 100644 index 0000000..b5c0007 --- /dev/null +++ b/apps/backend/services/ai/parser.service.ts @@ -0,0 +1,124 @@ +/** + * AI Parser Service - Groq LLM integration for parsing song requests. + * + * Ported from request-parser services/parser.py + */ + +import Groq from 'groq-sdk'; +import { MessageType, ParsedRequest } from '../requestLine/types.js'; +import { SYSTEM_PROMPT, formatUserPrompt } from './prompts.js'; +import { getConfig } from '../requestLine/config.js'; + +/** + * Groq client singleton. + */ +let _groqClient: Groq | null = null; + +/** + * Get or create the Groq client. + */ +function getGroqClient(): Groq { + if (!_groqClient) { + const config = getConfig(); + if (!config.groqApiKey) { + throw new Error('GROQ_API_KEY is not configured'); + } + _groqClient = new Groq({ apiKey: config.groqApiKey }); + } + return _groqClient; +} + +/** + * Reset the Groq client (useful for testing). + */ +export function resetGroqClient(): void { + _groqClient = null; +} + +/** + * Raw response from the AI parser. + */ +interface RawParsedResponse { + song?: string | null; + album?: string | null; + artist?: string | null; + is_request?: boolean; + message_type?: string; +} + +/** + * Validate and normalize the message type from AI response. + */ +function normalizeMessageType(type: string | undefined): MessageType { + if (!type) return MessageType.OTHER; + const normalized = type.toLowerCase(); + switch (normalized) { + case 'request': + return MessageType.REQUEST; + case 'dj_message': + return MessageType.DJ_MESSAGE; + case 'feedback': + return MessageType.FEEDBACK; + default: + return MessageType.OTHER; + } +} + +/** + * Parse a listener message and extract song request metadata. + * + * @param message - The raw listener message + * @returns Parsed request with extracted metadata + * @throws Error if Groq API fails or returns invalid response + */ +export async function parseRequest(message: string): Promise { + const config = getConfig(); + + console.log(`[AI Parser] Parsing message: ${message.slice(0, 100)}...`); + + const client = getGroqClient(); + + try { + const response = await client.chat.completions.create({ + model: config.groqModel, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: formatUserPrompt(message) }, + ], + response_format: { type: 'json_object' }, + temperature: 0.1, + }); + + const content = response.choices[0]?.message?.content; + if (!content) { + throw new Error('Empty response from Groq'); + } + + const parsed: RawParsedResponse = JSON.parse(content); + console.log(`[AI Parser] Raw parsed response:`, JSON.stringify(parsed)); + + return { + song: parsed.song ?? null, + album: parsed.album ?? null, + artist: parsed.artist ?? null, + isRequest: parsed.is_request ?? false, + messageType: normalizeMessageType(parsed.message_type), + rawMessage: message, + }; + } catch (error) { + if (error instanceof SyntaxError) { + console.error(`[AI Parser] Failed to parse JSON response:`, error); + throw new Error(`Invalid JSON response from Groq: ${error.message}`); + } + console.error(`[AI Parser] Error parsing request:`, error); + throw error; + } +} + +/** + * Check if AI parsing is available. + */ +export function isParserAvailable(): boolean { + const config = getConfig(); + return !!config.groqApiKey; +} diff --git a/apps/backend/services/ai/prompts.ts b/apps/backend/services/ai/prompts.ts new file mode 100644 index 0000000..cecadf0 --- /dev/null +++ b/apps/backend/services/ai/prompts.ts @@ -0,0 +1,47 @@ +/** + * System prompts for AI parsing. + * + * Ported from request-parser services/parser.py + */ + +/** + * System prompt for the song request parser. + */ +export const SYSTEM_PROMPT = `You are a parser for a radio station's song request system. Extract structured metadata from listener messages. + +For each message, determine: +1. **song**: The specific song title requested, or null if not specified (e.g., "any song by X") +2. **album**: The album name, or null if not specified +3. **artist**: The artist/band name, or null if not specified +4. **is_request**: true if the listener wants the DJ to play something, false otherwise +5. **message_type**: One of: + - "request": A song/artist/album request + - "dj_message": Conversational message to the DJ (may also contain a request) + - "feedback": Thanks, complaints, technical issues + - "other": Unclassifiable + +Guidelines: +- Normalize artist/song/album names to proper title case +- Preserve intentional stylization like asterisks, numbers, or special characters in artist/song/album names (e.g., "Quix*o*tic" stays "Quix*o*tic", "P!nk" stays "P!nk", "deadmau5" stays "deadmau5") +- Ignore parenthetical asides like "(rip Mani)" or "(2021 remaster)" +- Correct obvious typos when you can confidently identify the intended artist/song, but don't remove intentional special characters +- If someone says "anything by X" or "any song off Y album", that's still a request +- A message can be both a dj_message AND contain a request (is_request: true) +- Terse messages like "song title. artist name.", "song - artist", or "song title, artist name" should extract both song and artist +- When in doubt about whether something is a song title or album, prefer treating it as a song title + +Respond with valid JSON only, no markdown formatting.`; + +/** + * Template for the user prompt. + */ +export const USER_PROMPT_TEMPLATE = `Parse this message: + +{message}`; + +/** + * Format the user prompt with the message. + */ +export function formatUserPrompt(message: string): string { + return USER_PROMPT_TEMPLATE.replace('{message}', message); +} diff --git a/apps/backend/services/artwork/finder.ts b/apps/backend/services/artwork/finder.ts new file mode 100644 index 0000000..7591a5c --- /dev/null +++ b/apps/backend/services/artwork/finder.ts @@ -0,0 +1,162 @@ +/** + * ArtworkFinder - Orchestrates artwork search across multiple providers. + * + * Ported from request-parser artwork/finder.py + */ + +import { ArtworkProvider, discogsProvider } from './providers/index.js'; +import { + ArtworkRequest, + ArtworkResponse, + ArtworkSearchResult, + EnrichedLibraryResult, +} from '../requestLine/types.js'; +import { isCompilationArtist } from '../requestLine/matching/index.js'; +import { getConfig } from '../requestLine/config.js'; + +/** + * Orchestrates artwork search across multiple providers. + */ +export class ArtworkFinder { + private providers: ArtworkProvider[]; + + constructor(providers?: ArtworkProvider[]) { + // Default to Discogs provider + this.providers = providers || [discogsProvider]; + } + + /** + * Find artwork for the given request. + * + * Tries each provider in order and returns the best result + * based on confidence score. + */ + async find(request: ArtworkRequest): Promise { + if (!request.song && !request.album && !request.artist) { + console.warn('[ArtworkFinder] Empty request - no fields to search'); + return this.emptyResponse(); + } + + const allResults: ArtworkSearchResult[] = []; + + for (const provider of this.providers) { + try { + const results = await provider.search(request); + allResults.push(...results); + console.log(`[ArtworkFinder] Provider ${provider.name} returned ${results.length} results`); + } catch (error) { + console.error(`[ArtworkFinder] Provider ${provider.name} failed:`, error); + continue; + } + } + + if (allResults.length === 0) { + console.log('[ArtworkFinder] No artwork found from any provider'); + return this.emptyResponse(); + } + + // Sort by confidence and return the best match + allResults.sort((a, b) => b.confidence - a.confidence); + const best = allResults[0]; + + console.log( + `[ArtworkFinder] Best match: ${best.artist} - ${best.album} ` + + `(confidence: ${best.confidence.toFixed(2)}, source: ${best.source})` + ); + + return { + artworkUrl: best.artworkUrl, + releaseUrl: best.releaseUrl, + album: best.album, + artist: best.artist, + source: best.source, + confidence: best.confidence, + }; + } + + /** + * Create an empty artwork response. + */ + private emptyResponse(): ArtworkResponse { + return { + artworkUrl: null, + releaseUrl: null, + album: null, + artist: null, + source: null, + confidence: 0, + }; + } +} + +/** + * Singleton finder instance. + */ +let _finder: ArtworkFinder | null = null; + +/** + * Get the artwork finder instance. + */ +export function getArtworkFinder(): ArtworkFinder { + if (!_finder) { + _finder = new ArtworkFinder(); + } + return _finder; +} + +/** + * Reset the artwork finder (useful for testing). + */ +export function resetArtworkFinder(): void { + _finder = null; +} + +/** + * Fetch artwork for multiple library items in parallel. + * + * @param items - List of library items + * @param discogsTitles - Optional map of item ID to Discogs album title + * @returns List of [item, artwork] tuples + */ +export async function fetchArtworkForItems( + items: EnrichedLibraryResult[], + discogsTitles?: Map +): Promise> { + const config = getConfig(); + + if (!config.enableArtworkLookup) { + return items.map((item) => [item, null]); + } + + const finder = getArtworkFinder(); + const discogsTitlesMap = discogsTitles || new Map(); + + const fetchOne = async ( + item: EnrichedLibraryResult + ): Promise => { + try { + // Use Discogs album title if we have it (from compilation search) + const album = discogsTitlesMap.get(item.id) || item.title; + + // For compilations, simplify artist to "Various" for Discogs lookup + // Library formats like "Various Artists - Rock - C" won't match Discogs + let artist = item.artist; + if (isCompilationArtist(artist)) { + artist = 'Various'; + } + + const result = await finder.find({ + album: album || undefined, + artist: artist || undefined, + }); + + return result; + } catch (error) { + console.warn(`[ArtworkFinder] Lookup failed for ${item.title}:`, error); + return null; + } + }; + + const artworkResults = await Promise.all(items.map(fetchOne)); + return items.map((item, index) => [item, artworkResults[index]]); +} diff --git a/apps/backend/services/artwork/index.ts b/apps/backend/services/artwork/index.ts new file mode 100644 index 0000000..14d09d4 --- /dev/null +++ b/apps/backend/services/artwork/index.ts @@ -0,0 +1,12 @@ +/** + * Barrel export for artwork services. + */ + +export { + ArtworkFinder, + getArtworkFinder, + resetArtworkFinder, + fetchArtworkForItems, +} from './finder.js'; +export type { ArtworkProvider } from './providers/index.js'; +export { DiscogsProvider, discogsProvider } from './providers/index.js'; diff --git a/apps/backend/services/artwork/providers/base.ts b/apps/backend/services/artwork/providers/base.ts new file mode 100644 index 0000000..c8c768e --- /dev/null +++ b/apps/backend/services/artwork/providers/base.ts @@ -0,0 +1,23 @@ +/** + * Base interface for artwork providers. + * + * Ported from request-parser artwork/providers/base.py + */ + +import { ArtworkRequest, ArtworkSearchResult } from '../../requestLine/types.js'; + +/** + * Interface for artwork providers. + */ +export interface ArtworkProvider { + /** Provider name for attribution */ + readonly name: string; + + /** + * Search for album artwork matching the request. + * + * @param request - The artwork request containing song/album/artist info + * @returns List of search results, ordered by relevance. Empty list if no results found. + */ + search(request: ArtworkRequest): Promise; +} diff --git a/apps/backend/services/artwork/providers/discogs.ts b/apps/backend/services/artwork/providers/discogs.ts new file mode 100644 index 0000000..74142f1 --- /dev/null +++ b/apps/backend/services/artwork/providers/discogs.ts @@ -0,0 +1,144 @@ +/** + * Discogs artwork provider. + * + * Delegates to DiscogsService for API calls to avoid code duplication. + * Ported from request-parser artwork/providers/discogs.py + */ + +import { ArtworkProvider } from './base.js'; +import { ArtworkRequest, ArtworkSearchResult } from '../../requestLine/types.js'; +import { DiscogsService, isDiscogsAvailable } from '../../discogs/index.js'; +import { calculateConfidence } from '../../requestLine/matching/index.js'; + +/** + * Artwork provider using the Discogs API. + */ +export class DiscogsProvider implements ArtworkProvider { + readonly name = 'discogs'; + + /** + * Search Discogs for album artwork. + */ + async search(request: ArtworkRequest): Promise { + if (!isDiscogsAvailable()) { + console.warn('[DiscogsProvider] Discogs token not configured'); + return []; + } + + // Check if there's anything to search for + if (!request.artist && !request.album && !request.song) { + console.warn('[DiscogsProvider] No searchable fields in request'); + return []; + } + + // Delegate to service + const response = await DiscogsService.search({ + artist: request.artist, + album: request.album, + track: request.song, + }); + + // Convert results to ArtworkSearchResult format + const results: ArtworkSearchResult[] = []; + for (const item of response.results) { + // Skip results without artwork + if (!item.artworkUrl || item.artworkUrl.includes('spacer.gif')) { + continue; + } + + // Calculate confidence score for this result + const confidence = calculateConfidence( + request.artist, + request.album, + item.artist || '', + item.album || '' + ); + + results.push({ + artworkUrl: item.artworkUrl, + releaseUrl: item.releaseUrl, + album: item.album || '', + artist: item.artist || '', + source: this.name, + confidence, + }); + } + + // Sort by confidence + results.sort((a, b) => b.confidence - a.confidence); + return results; + } + + /** + * Search Discogs for a track and return the album name. + */ + async searchTrack(track: string, artist?: string): Promise { + if (!isDiscogsAvailable()) { + return null; + } + + const result = await DiscogsService.searchTrack(track, artist); + return result.album; + } + + /** + * Search Discogs for ALL releases containing a track. + * + * For Various Artists / compilation releases, validates the tracklist + * to ensure the track by the artist actually exists on the release. + * + * @returns List of [artist, album] tuples for releases containing the track. + */ + async searchReleasesByTrack( + track: string, + artist?: string, + limit = 20 + ): Promise> { + if (!isDiscogsAvailable()) { + return []; + } + + const response = await DiscogsService.searchReleasesByTrack(track, artist, limit); + + // If searching with artist, validate compilation releases + const releases: Array<[string, string]> = []; + for (const releaseInfo of response.releases) { + // For Various Artists / compilations, validate the tracklist + if (artist && releaseInfo.isCompilation) { + const isValid = await DiscogsService.validateTrackOnRelease( + releaseInfo.releaseId, + track, + artist + ); + if (!isValid) { + console.log( + `[DiscogsProvider] Skipping '${releaseInfo.album}' - track/artist not validated on release` + ); + continue; + } + } + + releases.push([releaseInfo.artist, releaseInfo.album]); + } + + return releases; + } + + /** + * Validate that a track by an artist exists on a release. + */ + async validateTrackOnRelease( + releaseId: number, + track: string, + artist: string + ): Promise { + if (!isDiscogsAvailable()) { + return false; + } + + return DiscogsService.validateTrackOnRelease(releaseId, track, artist); + } +} + +// Singleton instance +export const discogsProvider = new DiscogsProvider(); diff --git a/apps/backend/services/artwork/providers/index.ts b/apps/backend/services/artwork/providers/index.ts new file mode 100644 index 0000000..b093a6f --- /dev/null +++ b/apps/backend/services/artwork/providers/index.ts @@ -0,0 +1,6 @@ +/** + * Barrel export for artwork providers. + */ + +export type { ArtworkProvider } from './base.js'; +export { DiscogsProvider, discogsProvider } from './discogs.js'; diff --git a/apps/backend/services/library.service.ts b/apps/backend/services/library.service.ts index e8ae054..a62615b 100644 --- a/apps/backend/services/library.service.ts +++ b/apps/backend/services/library.service.ts @@ -13,7 +13,10 @@ import { library, library_artist_view, rotation, + LibraryArtistViewEntry, } from "@wxyc/database"; +import { LibraryResult, EnrichedLibraryResult, enrichLibraryResult } from './requestLine/types.js'; +import { extractSignificantWords } from './requestLine/matching/index.js'; export const getFormatsFromDB = async () => { const formats = await db @@ -220,3 +223,244 @@ export const isISODate = (date: string): boolean => { const regex = /^\d{4}-\d{2}-\d{2}$/; return date.match(regex) !== null; }; + +// ============================================================================= +// Request Line Enhanced Search Functions +// ============================================================================= + +/** + * Convert a library_artist_view row to LibraryResult. + */ +function viewRowToLibraryResult(row: LibraryArtistViewEntry): LibraryResult { + return { + id: row.id, + title: row.album_title, + artist: row.artist_name, + codeLetters: row.code_letters, + codeArtistNumber: row.code_artist_number, + codeNumber: row.code_number, + genre: row.genre_name, + format: row.format_name, + }; +} + +/** + * Search the library catalog with flexible query options. + * + * Uses PostgreSQL pg_trgm for fuzzy matching. Supports: + * - Combined artist + album/song queries + * - Artist-only queries + * - Album/title-only queries + * + * @param query - Free text search query (artist and/or album) + * @param artist - Artist name filter + * @param title - Album/title filter + * @param limit - Maximum results to return + * @returns Array of enriched library results + */ +export async function searchLibrary( + query?: string, + artist?: string, + title?: string, + limit = 5 +): Promise { + let results: LibraryArtistViewEntry[] = []; + + if (query) { + // Full text search using pg_trgm similarity + const searchQuery = sql` + SELECT *, + similarity(${library_artist_view.artist_name}, ${query}) as artist_sim, + similarity(${library_artist_view.album_title}, ${query}) as album_sim + FROM ${library_artist_view} + WHERE ${library_artist_view.artist_name} % ${query} + OR ${library_artist_view.album_title} % ${query} + ORDER BY GREATEST( + similarity(${library_artist_view.artist_name}, ${query}), + similarity(${library_artist_view.album_title}, ${query}) + ) DESC + LIMIT ${limit} + `; + + const response = await db.execute(searchQuery); + results = response.rows as LibraryArtistViewEntry[]; + + // If no results with trigram, try LIKE fallback with significant words + if (results.length === 0) { + const words = extractSignificantWords(query); + if (words.length > 0) { + // Build LIKE conditions for each word + const likeConditions = words + .map((w) => `(artist_name ILIKE '%${w}%' OR album_title ILIKE '%${w}%')`) + .join(' AND '); + + const fallbackQuery = sql.raw(` + SELECT * FROM wxyc_schema.library_artist_view + WHERE ${likeConditions} + LIMIT ${limit} + `); + + const fallbackResponse = await db.execute(fallbackQuery); + results = fallbackResponse.rows as LibraryArtistViewEntry[]; + } + } + } else if (artist || title) { + // Filtered search by artist and/or title + const response = await fuzzySearchLibrary(artist, title, limit); + results = response.rows as LibraryArtistViewEntry[]; + } + + return results.map((row) => enrichLibraryResult(viewRowToLibraryResult(row))); +} + +/** + * Find a similar artist name in the library using fuzzy matching. + * + * Useful for correcting typos or spelling variants (e.g., "Color" vs "Colour"). + * + * @param artistName - Artist name to match + * @param threshold - Minimum similarity score (0.0 to 1.0) to accept + * @returns Corrected artist name if a good match is found, null otherwise + */ +export async function findSimilarArtist( + artistName: string, + threshold = 0.85 +): Promise { + // Use pg_trgm similarity function to find close matches + const query = sql` + SELECT DISTINCT artist_name, + similarity(artist_name, ${artistName}) as sim + FROM ${library_artist_view} + WHERE similarity(artist_name, ${artistName}) > ${threshold} + ORDER BY sim DESC + LIMIT 1 + `; + + const response = await db.execute(query); + const rows = response.rows as Array<{ artist_name: string; sim: number }>; + + if (rows.length > 0) { + const match = rows[0]; + // Only return if it's actually different (i.e., a correction) + if (match.artist_name.toLowerCase() !== artistName.toLowerCase()) { + console.log( + `[Library] Corrected artist '${artistName}' to '${match.artist_name}' (similarity: ${match.sim.toFixed(2)})` + ); + return match.artist_name; + } + } + + return null; +} + +/** + * Search for albums by title with fuzzy matching. + * + * Useful for cross-referencing Discogs album titles with the library. + * + * @param albumTitle - Album title to search for + * @param limit - Maximum results to return + * @returns Array of enriched library results + */ +export async function searchAlbumsByTitle( + albumTitle: string, + limit = 5 +): Promise { + const query = sql` + SELECT *, + similarity(${library_artist_view.album_title}, ${albumTitle}) as sim + FROM ${library_artist_view} + WHERE ${library_artist_view.album_title} % ${albumTitle} + ORDER BY sim DESC + LIMIT ${limit} + `; + + const response = await db.execute(query); + const rows = response.rows as LibraryArtistViewEntry[]; + + // If no trigram matches, try keyword search + if (rows.length === 0) { + const words = extractSignificantWords(albumTitle); + if (words.length > 0) { + const significantWords = words.slice(0, 4); // Use up to 4 significant words + const likeConditions = significantWords + .map((w) => `album_title ILIKE '%${w}%'`) + .join(' AND '); + + const fallbackQuery = sql.raw(` + SELECT * FROM wxyc_schema.library_artist_view + WHERE ${likeConditions} + LIMIT ${limit} + `); + + const fallbackResponse = await db.execute(fallbackQuery); + return (fallbackResponse.rows as LibraryArtistViewEntry[]).map((row) => + enrichLibraryResult(viewRowToLibraryResult(row)) + ); + } + } + + return rows.map((row) => enrichLibraryResult(viewRowToLibraryResult(row))); +} + +/** + * Search the library for releases by a specific artist. + * + * @param artistName - Artist name to search for + * @param limit - Maximum results to return + * @returns Array of enriched library results + */ +export async function searchByArtist( + artistName: string, + limit = 5 +): Promise { + const query = sql` + SELECT *, + similarity(${library_artist_view.artist_name}, ${artistName}) as sim + FROM ${library_artist_view} + WHERE ${library_artist_view.artist_name} % ${artistName} + ORDER BY sim DESC + LIMIT ${limit} + `; + + const response = await db.execute(query); + const rows = response.rows as LibraryArtistViewEntry[]; + + return rows.map((row) => enrichLibraryResult(viewRowToLibraryResult(row))); +} + +/** + * Filter library results to only include those matching the artist. + * + * Requires the searched artist name to appear at the START of the result's + * artist field (case-insensitive). This prevents false positives like + * "Toy" matching "Chew Toy" while still allowing "Various" to match + * "Various Artists - Rock - D". + * + * @param results - List of library items from search + * @param artist - Artist name to filter by + * @returns Filtered list containing only items where artist matches + */ +export function filterResultsByArtist( + results: EnrichedLibraryResult[], + artist: string | null | undefined +): EnrichedLibraryResult[] { + if (!artist) { + return results; + } + + const artistLower = artist.toLowerCase(); + const filtered = results.filter((item) => { + const itemArtist = (item.artist || '').toLowerCase(); + // Check if result's artist starts with searched artist + return itemArtist.startsWith(artistLower); + }); + + if (filtered.length < results.length) { + console.log( + `[Library] Filtered ${results.length} results to ${filtered.length} matching artist '${artist}'` + ); + } + + return filtered; +} diff --git a/apps/backend/services/requestLine.service.ts b/apps/backend/services/requestLine.service.ts index 600f095..8051cee 100644 --- a/apps/backend/services/requestLine.service.ts +++ b/apps/backend/services/requestLine.service.ts @@ -17,6 +17,11 @@ export const submitRequestLine = async ( ): Promise<{ success: boolean; message?: string; statusCode?: number; response?: string }> => { // Use mock in test environments if (process.env.USE_MOCK_SERVICES === 'true') { + // Allow simulating Slack failures in test mode + if (process.env.SIMULATE_SLACK_FAILURE === 'true') { + console.log('[RequestLine Service] Mock mode - simulating Slack failure'); + return { success: false, statusCode: 500, response: 'Mock: Simulated Slack failure' }; + } console.log('[RequestLine Service] Mock mode - would send to Slack:', message); return { success: true, message: 'Mock: Message sent to Slack successfully' }; } diff --git a/apps/backend/services/requestLine/config.ts b/apps/backend/services/requestLine/config.ts new file mode 100644 index 0000000..a428b22 --- /dev/null +++ b/apps/backend/services/requestLine/config.ts @@ -0,0 +1,110 @@ +/** + * Configuration for Request Line NLP + Library Search feature. + * + * All configuration is loaded from environment variables with sensible defaults. + */ + +export interface RequestLineConfig { + // AI Parsing (Required - requests fail without this) + groqApiKey: string | undefined; + groqModel: string; + + // Discogs (Optional - artwork/compilation search degrades gracefully) + discogsApiKey: string | undefined; + discogsApiSecret: string | undefined; + discogsCacheTtlTrack: number; + discogsCacheTtlRelease: number; + discogsCacheTtlSearch: number; + discogsCacheMaxSize: number; + + // Feature Flags + enableArtworkLookup: boolean; + enableLibrarySearch: boolean; + + // Search behavior + maxSearchResults: number; + artistSimilarityThreshold: number; +} + +/** + * Load configuration from environment variables. + */ +export function loadConfig(): RequestLineConfig { + return { + // AI Parsing + groqApiKey: process.env.GROQ_API_KEY, + groqModel: process.env.GROQ_MODEL || 'llama-3.1-8b-instant', + + // Discogs + discogsApiKey: process.env.DISCOGS_API_KEY, + discogsApiSecret: process.env.DISCOGS_API_SECRET, + discogsCacheTtlTrack: parseInt(process.env.DISCOGS_CACHE_TTL_TRACK || '3600', 10), // 1 hour + discogsCacheTtlRelease: parseInt(process.env.DISCOGS_CACHE_TTL_RELEASE || '14400', 10), // 4 hours + discogsCacheTtlSearch: parseInt(process.env.DISCOGS_CACHE_TTL_SEARCH || '3600', 10), // 1 hour + discogsCacheMaxSize: parseInt(process.env.DISCOGS_CACHE_MAX_SIZE || '1000', 10), + + // Feature Flags + enableArtworkLookup: process.env.ENABLE_ARTWORK_LOOKUP !== 'false', + enableLibrarySearch: process.env.ENABLE_LIBRARY_SEARCH !== 'false', + + // Search behavior + maxSearchResults: parseInt(process.env.MAX_SEARCH_RESULTS || '5', 10), + artistSimilarityThreshold: parseFloat(process.env.ARTIST_SIMILARITY_THRESHOLD || '0.85'), + }; +} + +/** + * Validate that required configuration is present. + * Returns an array of error messages for missing/invalid config. + */ +export function validateConfig(config: RequestLineConfig): string[] { + const errors: string[] = []; + + // AI parsing is mandatory according to the plan + if (!config.groqApiKey) { + errors.push('GROQ_API_KEY is required for AI parsing'); + } + + // Discogs is optional but warn if not configured + if (!config.discogsApiKey || !config.discogsApiSecret) { + console.warn('[RequestLine Config] DISCOGS_API_KEY or DISCOGS_API_SECRET not set - artwork lookup and compilation search will be disabled'); + } + + return errors; +} + +/** + * Check if AI parsing is available. + */ +export function isParsingEnabled(config: RequestLineConfig): boolean { + return !!config.groqApiKey; +} + +/** + * Check if Discogs integration is available. + */ +export function isDiscogsEnabled(config: RequestLineConfig): boolean { + return !!(config.discogsApiKey && config.discogsApiSecret); +} + +/** + * Singleton config instance. + */ +let _config: RequestLineConfig | null = null; + +/** + * Get the configuration, loading it if necessary. + */ +export function getConfig(): RequestLineConfig { + if (!_config) { + _config = loadConfig(); + } + return _config; +} + +/** + * Reset the configuration (useful for testing). + */ +export function resetConfig(): void { + _config = null; +} diff --git a/apps/backend/services/requestLine/index.ts b/apps/backend/services/requestLine/index.ts new file mode 100644 index 0000000..0d29734 --- /dev/null +++ b/apps/backend/services/requestLine/index.ts @@ -0,0 +1,18 @@ +/** + * Barrel export for Request Line services. + */ + +// Main orchestration +export { processRequest, parseOnly } from './requestLine.enhanced.service.js'; + +// Types +export * from './types.js'; + +// Config +export { getConfig, loadConfig, validateConfig, isParsingEnabled, isDiscogsEnabled } from './config.js'; + +// Matching utilities +export * from './matching/index.js'; + +// Search pipeline +export { executeSearchPipeline, getSearchTypeFromState } from './search/index.js'; diff --git a/apps/backend/services/requestLine/matching/ambiguous.ts b/apps/backend/services/requestLine/matching/ambiguous.ts new file mode 100644 index 0000000..c759fb3 --- /dev/null +++ b/apps/backend/services/requestLine/matching/ambiguous.ts @@ -0,0 +1,51 @@ +/** + * Ambiguous format detection for "X - Y" or "X. Y" patterns. + * + * Ported from request-parser core/matching.py + */ + +/** + * Result of detecting an ambiguous format. + */ +export interface AmbiguousParts { + part1: string; + part2: string; +} + +/** + * Detect if message has ambiguous 'X - Y' or 'X. Y' format. + * + * These formats are ambiguous because they could be interpreted as either: + * - Artist: X, Title: Y + * - Title: X, Artist: Y + * + * @param rawMessage - The original request message + * @returns Object with part1 and part2 if ambiguous format detected, null otherwise + */ +export function detectAmbiguousFormat(rawMessage: string): AmbiguousParts | null { + // Check for "X - Y" pattern (with spaces around dash) + if (rawMessage.includes(' - ')) { + const parts = rawMessage.split(' - '); + if (parts.length >= 2) { + const part1 = parts[0].trim(); + const part2 = parts.slice(1).join(' - ').trim(); + if (part1 && part2) { + return { part1, part2 }; + } + } + } + + // Check for "X. Y" pattern (period followed by space) + if (rawMessage.includes('. ')) { + const parts = rawMessage.split('. '); + if (parts.length >= 2) { + const part1 = parts[0].trim(); + const part2 = parts.slice(1).join('. ').trim(); + if (part1 && part2) { + return { part1, part2 }; + } + } + } + + return null; +} diff --git a/apps/backend/services/requestLine/matching/compilation.ts b/apps/backend/services/requestLine/matching/compilation.ts new file mode 100644 index 0000000..22564bc --- /dev/null +++ b/apps/backend/services/requestLine/matching/compilation.ts @@ -0,0 +1,35 @@ +/** + * Compilation detection - keywords indicating compilation/soundtrack albums. + * + * Ported from request-parser core/matching.py + */ + +/** + * Keywords indicating a compilation/soundtrack album (case-insensitive substring match). + */ +export const COMPILATION_KEYWORDS = new Set([ + 'various', + 'soundtrack', + 'compilation', + 'v/a', + 'v.a.', +]); + +/** + * Check if an artist name indicates a compilation/soundtrack album. + * + * @param artist - Artist name to check + * @returns True if artist contains compilation keywords + */ +export function isCompilationArtist(artist: string | null | undefined): boolean { + if (!artist) { + return false; + } + const artistLower = artist.toLowerCase(); + for (const keyword of COMPILATION_KEYWORDS) { + if (artistLower.includes(keyword)) { + return true; + } + } + return false; +} diff --git a/apps/backend/services/requestLine/matching/confidence.ts b/apps/backend/services/requestLine/matching/confidence.ts new file mode 100644 index 0000000..2cccca4 --- /dev/null +++ b/apps/backend/services/requestLine/matching/confidence.ts @@ -0,0 +1,73 @@ +/** + * Confidence scoring for search result matching. + * + * Ported from request-parser core/matching.py + */ + +/** + * Normalize a string for comparison (lowercase, trimmed). + */ +function normalize(s: string | null | undefined): string { + return s ? s.toLowerCase().trim() : ''; +} + +/** + * Calculate confidence score for how well a search result matches a request. + * + * Scoring rules: + * - Exact artist match: +0.4 + * - Partial artist match (substring): +0.3 + * - Exact album match: +0.4 + * - Partial album match (substring): +0.3 + * - Both fields match well (score >= 0.6): +0.2 bonus + * - Minimum score for any result: 0.2 + * + * @param requestArtist - Artist from the search request + * @param requestAlbum - Album from the search request + * @param resultArtist - Artist from the search result + * @param resultAlbum - Album from the search result + * @returns Confidence score between 0.2 and 1.0 + */ +export function calculateConfidence( + requestArtist: string | null | undefined, + requestAlbum: string | null | undefined, + resultArtist: string, + resultAlbum: string +): number { + let score = 0.0; + + const reqArtist = normalize(requestArtist); + const reqAlbum = normalize(requestAlbum); + const resArtist = normalize(resultArtist); + const resAlbum = normalize(resultAlbum); + + // Artist match + if (reqArtist && resArtist) { + if (reqArtist === resArtist) { + score += 0.4; + } else if (reqArtist.includes(resArtist) || resArtist.includes(reqArtist)) { + score += 0.3; + } + } + + // Album match + if (reqAlbum && resAlbum) { + if (reqAlbum === resAlbum) { + score += 0.4; + } else if (reqAlbum.includes(resAlbum) || resAlbum.includes(reqAlbum)) { + score += 0.3; + } + } + + // Bonus for both matches + if (score >= 0.6) { + score += 0.2; + } + + // Base score if we got any result + if (score === 0) { + score = 0.2; + } + + return Math.min(score, 1.0); +} diff --git a/apps/backend/services/requestLine/matching/index.ts b/apps/backend/services/requestLine/matching/index.ts new file mode 100644 index 0000000..ae1ba8d --- /dev/null +++ b/apps/backend/services/requestLine/matching/index.ts @@ -0,0 +1,13 @@ +/** + * Barrel export for matching utilities. + */ + +export { STOPWORDS, extractSignificantWords } from './stopwords.js'; +export { COMPILATION_KEYWORDS, isCompilationArtist } from './compilation.js'; +export { calculateConfidence } from './confidence.js'; +export { detectAmbiguousFormat, type AmbiguousParts } from './ambiguous.js'; + +/** + * Maximum number of results to return from search operations. + */ +export const MAX_SEARCH_RESULTS = 5; diff --git a/apps/backend/services/requestLine/matching/stopwords.ts b/apps/backend/services/requestLine/matching/stopwords.ts new file mode 100644 index 0000000..ff7fe7f --- /dev/null +++ b/apps/backend/services/requestLine/matching/stopwords.ts @@ -0,0 +1,46 @@ +/** + * Stopwords - words to exclude when extracting significant keywords from search queries. + * + * Ported from request-parser core/matching.py + */ + +export const STOPWORDS = new Set([ + // Articles + 'the', + 'a', + 'an', + // Conjunctions/prepositions + 'and', + 'with', + 'from', + // Demonstratives + 'that', + 'this', + // Request-specific noise + 'play', + 'song', + 'remix', + // Label/format noise + 'story', + 'records', +]); + +/** + * Extract significant words from a query string. + * Removes stopwords and words with length <= 1. + */ +export function extractSignificantWords(query: string, minLength = 2): string[] { + // Normalize: remove special chars, keep only alphanumeric and spaces + const normalized = query.toLowerCase().replace(/[^a-z0-9\s]/g, ' '); + const words = normalized.split(/\s+/).filter((w) => w.length > 0); + + // Remove stopwords that might cause mismatches + const significant = words.filter((w) => !STOPWORDS.has(w) && w.length >= minLength); + + // If we removed all words, use original words + if (significant.length === 0) { + return words.filter((w) => w.length >= minLength); + } + + return significant; +} diff --git a/apps/backend/services/requestLine/requestLine.enhanced.service.ts b/apps/backend/services/requestLine/requestLine.enhanced.service.ts new file mode 100644 index 0000000..4fa0cd3 --- /dev/null +++ b/apps/backend/services/requestLine/requestLine.enhanced.service.ts @@ -0,0 +1,301 @@ +/** + * Enhanced Request Line Service with AI parsing and library search. + * + * This is the main orchestration layer that: + * 1. Parses messages with AI + * 2. Searches the library + * 3. Fetches artwork + * 4. Posts to Slack with rich formatting + */ + +import { + ParsedRequest, + EnrichedLibraryResult, + ArtworkResponse, + UnifiedRequestResponse, + RequestLineRequestBody, + MESSAGE_TYPE_LABELS, + MessageType, +} from './types.js'; +import { getConfig, isParsingEnabled, isDiscogsEnabled } from './config.js'; +import { parseRequest, isParserAvailable } from '../ai/index.js'; +import { executeSearchPipeline, getSearchTypeFromState } from './search/index.js'; +import { findSimilarArtist } from '../library.service.js'; +import { DiscogsService, isDiscogsAvailable } from '../discogs/index.js'; +import { fetchArtworkForItems } from '../artwork/index.js'; +import { + buildSlackBlocks, + buildSimpleSlackBlocks, + postBlocksToSlack, + postTextToSlack, + SlackPostResult, +} from '../slack/index.js'; +import { discogsProvider } from '../artwork/providers/index.js'; +import { MAX_SEARCH_RESULTS } from './matching/index.js'; + +/** + * Resolve album names for a track if not provided. + * + * Searches Discogs for ALL releases containing the track, not just the first one. + */ +async function resolveAlbumsForTrack( + parsed: ParsedRequest +): Promise<{ albums: string[]; songNotFound: boolean }> { + // Check if album is missing or if album == artist (parser error) + const albumIsMissing = !parsed.album; + const albumIsArtist = + parsed.album && + parsed.artist && + parsed.album.toLowerCase().trim() === parsed.artist.toLowerCase().trim(); + + // Only do track lookup if we have an artist + if (parsed.song && parsed.artist && (albumIsMissing || albumIsArtist)) { + if (albumIsArtist) { + console.log( + `[RequestLine] Album '${parsed.album}' appears to be artist name, looking up albums` + ); + } + + if (!isDiscogsAvailable()) { + console.log('[RequestLine] Discogs not available for album lookup'); + return { albums: [], songNotFound: true }; + } + + try { + // Get ALL releases containing this track + const releases = await discogsProvider.searchReleasesByTrack( + parsed.song, + parsed.artist, + 10 + ); + + if (releases.length > 0) { + // Extract unique album names, filtering to releases by this artist + const albums: string[] = []; + const artistLower = parsed.artist.toLowerCase(); + + for (const [releaseArtist, album] of releases) { + // Only include releases by the requested artist (not compilations) + if (releaseArtist.toLowerCase().startsWith(artistLower)) { + if (!albums.includes(album)) { + albums.push(album); + } + } + } + + if (albums.length > 0) { + console.log( + `[RequestLine] Found ${albums.length} albums for song '${parsed.song}': ${albums.join(', ')}` + ); + return { albums, songNotFound: false }; + } + } + + console.log(`[RequestLine] Could not find albums for song '${parsed.song}'`); + return { albums: [], songNotFound: true }; + } catch (error) { + console.warn(`[RequestLine] Track lookup failed:`, error); + return { albums: [], songNotFound: true }; + } + } + + return { albums: parsed.album ? [parsed.album] : [], songNotFound: false }; +} + +/** + * Build context message for Slack based on search results. + */ +function buildContextMessage( + parsed: ParsedRequest, + foundOnCompilation: boolean, + songNotFound: boolean, + hasResults: boolean +): string | undefined { + if (foundOnCompilation) { + return `Found "${parsed.song}" by ${parsed.artist} on:`; + } + + if (songNotFound && hasResults) { + // Show "here are other albums" only if we have results to show + if (parsed.song && parsed.album) { + return `"${parsed.album}" not found in the library, but here are other albums by ${parsed.artist}:`; + } else if (parsed.song) { + return `"${parsed.song}" is not on any album in the library, but here are some albums by ${parsed.artist}:`; + } + } else if (songNotFound && !hasResults) { + // No results at all after filtering + if (parsed.song && parsed.artist) { + return `"${parsed.song}" by ${parsed.artist} not found in library.`; + } + } + + return undefined; +} + +/** + * Post results to Slack with rich formatting. + */ +async function postResultsToSlack( + message: string, + parsed: ParsedRequest, + itemsWithArtwork: Array<[EnrichedLibraryResult, ArtworkResponse | null]>, + context?: string +): Promise { + if (itemsWithArtwork.length > 0) { + const blocks = buildSlackBlocks(message, itemsWithArtwork, context); + return postBlocksToSlack(blocks, message); + } else if (!parsed.isRequest) { + const label = MESSAGE_TYPE_LABELS[parsed.messageType] || 'Other'; + const blocks = buildSimpleSlackBlocks(message, `_${label}_`); + return postBlocksToSlack(blocks, message); + } else { + // Request but no results found + const contextParts: string[] = []; + if (parsed.artist) contextParts.push(`Artist: ${parsed.artist}`); + if (parsed.album) contextParts.push(`Album: ${parsed.album}`); + if (parsed.song) contextParts.push(`Song: ${parsed.song}`); + const ctx = contextParts.length > 0 ? contextParts.join(' | ') : undefined; + const blocks = buildSimpleSlackBlocks(message, `_No results found_ ${ctx || ''}`); + return postBlocksToSlack(blocks, message); + } +} + +/** + * Process a song request through the full pipeline. + * + * This is the main entry point for the enhanced request line service. + */ +export async function processRequest( + body: RequestLineRequestBody +): Promise { + const config = getConfig(); + const message = body.message.trim(); + + if (!message) { + throw new Error('Message cannot be empty'); + } + + let parsed: ParsedRequest; + let libraryResults: EnrichedLibraryResult[] = []; + let itemsWithArtwork: Array<[EnrichedLibraryResult, ArtworkResponse | null]> = []; + let songNotFound = false; + let foundOnCompilation = false; + let discogsTitles = new Map(); + let searchType = 'none'; + + // Step 1: Parse the message + if (!body.skipParsing && isParserAvailable()) { + try { + parsed = await parseRequest(message); + console.log( + `[RequestLine] Parsed request: is_request=${parsed.isRequest}, type=${parsed.messageType}` + ); + } catch (error) { + console.error('[RequestLine] Parsing failed:', error); + // Per the plan: "Requests fail if Groq is unavailable" + throw new Error(`AI parsing failed: ${error instanceof Error ? error.message : String(error)}`); + } + } else if (body.skipParsing) { + // Create a minimal parsed request when parsing is skipped + parsed = { + song: null, + album: null, + artist: null, + isRequest: true, + messageType: MessageType.REQUEST, + rawMessage: message, + }; + } else { + // AI parsing is required but not available + throw new Error('GROQ_API_KEY is not configured - AI parsing is required'); + } + + // Step 1b: Correct artist spelling + if (parsed.artist) { + try { + const correctedArtist = await findSimilarArtist(parsed.artist); + if (correctedArtist) { + console.log(`[RequestLine] Corrected artist: '${parsed.artist}' -> '${correctedArtist}'`); + parsed = { ...parsed, artist: correctedArtist }; + } + } catch (error) { + console.warn('[RequestLine] Artist correction failed:', error); + } + } + + // Step 2: Look up albums from Discogs if we have a song but no album + const { albums: albumsForSearch, songNotFound: initialSongNotFound } = + await resolveAlbumsForTrack(parsed); + songNotFound = initialSongNotFound; + + // Step 3: Execute search strategy pipeline + if (config.enableLibrarySearch) { + const searchState = await executeSearchPipeline(parsed, message, { + discogsService: isDiscogsAvailable() ? DiscogsService : undefined, + albumsForSearch, + }); + + libraryResults = searchState.results.slice(0, MAX_SEARCH_RESULTS); + songNotFound = searchState.songNotFound; + foundOnCompilation = searchState.foundOnCompilation; + discogsTitles = searchState.discogsTitles; + searchType = getSearchTypeFromState(searchState); + } + + // Step 4: Fetch artwork for library items + if (libraryResults.length > 0 && config.enableArtworkLookup) { + try { + itemsWithArtwork = await fetchArtworkForItems(libraryResults, discogsTitles); + } catch (error) { + console.warn('[RequestLine] Artwork fetch failed:', error); + itemsWithArtwork = libraryResults.map((item) => [item, null]); + } + } else { + itemsWithArtwork = libraryResults.map((item) => [item, null]); + } + + // Step 5: Post to Slack (unless skipSlack is set) + let slackResult: SlackPostResult = { success: true, message: 'Slack posting skipped' }; + + if (!body.skipSlack) { + const context = buildContextMessage( + parsed, + foundOnCompilation, + songNotFound, + libraryResults.length > 0 + ); + + try { + slackResult = await postResultsToSlack(message, parsed, itemsWithArtwork, context); + } catch (error) { + console.error('[RequestLine] Slack posting failed:', error); + slackResult = { + success: false, + message: `Slack posting failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + // Extract main artwork from first result + const artwork = + itemsWithArtwork.find(([_, art]) => art !== null)?.[1] || null; + + return { + success: true, + parsed, + artwork, + libraryResults, + searchType, + result: slackResult, + }; +} + +/** + * Parse a message without searching or posting (for debugging). + */ +export async function parseOnly(message: string): Promise { + if (!isParserAvailable()) { + throw new Error('GROQ_API_KEY is not configured'); + } + return parseRequest(message); +} diff --git a/apps/backend/services/requestLine/search/index.ts b/apps/backend/services/requestLine/search/index.ts new file mode 100644 index 0000000..88b6df5 --- /dev/null +++ b/apps/backend/services/requestLine/search/index.ts @@ -0,0 +1,7 @@ +/** + * Barrel export for search module. + */ + +export { executeSearchPipeline, type PipelineOptions } from './pipeline.js'; +export { getSearchTypeFromState, createSearchState, type SearchState } from './state.js'; +export * from './strategies/index.js'; diff --git a/apps/backend/services/requestLine/search/pipeline.ts b/apps/backend/services/requestLine/search/pipeline.ts new file mode 100644 index 0000000..b6e951a --- /dev/null +++ b/apps/backend/services/requestLine/search/pipeline.ts @@ -0,0 +1,113 @@ +/** + * Search pipeline executor. + * + * Orchestrates the execution of search strategies in order until results are found. + * Ported from request-parser core/search.py execute_search_pipeline() + */ + +import { ParsedRequest, SearchState, SearchStrategyType, createSearchState } from '../types.js'; +import { MAX_SEARCH_RESULTS } from '../matching/index.js'; +import { + shouldRunArtistPlusAlbum, + executeArtistPlusAlbum, + shouldRunSwappedInterpretation, + executeSwappedInterpretation, + shouldRunTrackOnCompilation, + executeTrackOnCompilation, + shouldRunSongAsArtist, + executeSongAsArtist, +} from './strategies/index.js'; + +// Forward declaration for Discogs service +interface DiscogsService { + searchReleasesByTrack: ( + track: string, + artist?: string, + limit?: number + ) => Promise>; + searchReleasesByArtist: ( + artist: string, + limit?: number + ) => Promise>; + validateTrackOnRelease: (releaseId: number, track: string, artist: string) => Promise; +} + +export interface PipelineOptions { + /** Optional Discogs service for enhanced search */ + discogsService?: DiscogsService; + /** Album names resolved from Discogs track lookup */ + albumsForSearch?: string[]; +} + +/** + * Execute strategies in array order until results found. + * + * The pipeline tries strategies in this order: + * 1. ARTIST_PLUS_ALBUM - search by artist + album/song + * 2. SWAPPED_INTERPRETATION - try "X - Y" as both orderings + * 3. TRACK_ON_COMPILATION - find song on compilations via Discogs + * 4. SONG_AS_ARTIST - try parsed song as artist (parser misidentification) + * + * @param parsed - The parsed request with artist/song/album + * @param rawMessage - Original request message (for ambiguous format detection) + * @param options - Pipeline options including Discogs service + * @returns SearchState with results and metadata about the search + */ +export async function executeSearchPipeline( + parsed: ParsedRequest, + rawMessage: string, + options: PipelineOptions = {} +): Promise { + const { discogsService, albumsForSearch = [] } = options; + + const state = createSearchState(albumsForSearch); + + // Strategy 1: Artist + Album/Song + if (shouldRunArtistPlusAlbum(parsed, state, rawMessage)) { + state.strategiesTried.push(SearchStrategyType.ARTIST_PLUS_ALBUM); + const [results, fallbackUsed] = await executeArtistPlusAlbum(parsed, state); + if (results.length > 0) { + state.results = results; + } + if (fallbackUsed) { + state.songNotFound = true; + } + } + + // Strategy 2: Swapped Interpretation (only if no results) + if (shouldRunSwappedInterpretation(parsed, state, rawMessage)) { + state.strategiesTried.push(SearchStrategyType.SWAPPED_INTERPRETATION); + const results = await executeSwappedInterpretation(rawMessage); + if (results.length > 0) { + state.results = results; + state.songNotFound = false; + } + } + + // Strategy 3: Track on Compilation (if song not found but we have artist and song) + if (shouldRunTrackOnCompilation(parsed, state, rawMessage)) { + state.strategiesTried.push(SearchStrategyType.TRACK_ON_COMPILATION); + const [results, discogsTitles] = await executeTrackOnCompilation(parsed, discogsService); + if (results.length > 0) { + state.results = results; + state.foundOnCompilation = true; + state.songNotFound = false; + state.discogsTitles = discogsTitles; + } + } + + // Strategy 4: Song as Artist (only if no results and song but no artist) + if (shouldRunSongAsArtist(parsed, state, rawMessage)) { + state.strategiesTried.push(SearchStrategyType.SONG_AS_ARTIST); + const results = await executeSongAsArtist(parsed.song!, discogsService); + if (results.length > 0) { + state.results = results; + state.songNotFound = false; + } + } + + // Limit final results + state.results = state.results.slice(0, MAX_SEARCH_RESULTS); + + return state; +} diff --git a/apps/backend/services/requestLine/search/state.ts b/apps/backend/services/requestLine/search/state.ts new file mode 100644 index 0000000..f6601f2 --- /dev/null +++ b/apps/backend/services/requestLine/search/state.ts @@ -0,0 +1,38 @@ +/** + * Search state management for the search pipeline. + * + * Ported from request-parser core/search.py + */ + +import { EnrichedLibraryResult, SearchState, SearchStrategyType, createSearchState } from '../types.js'; + +/** + * Get the search type string for telemetry from state. + */ +export function getSearchTypeFromState(state: SearchState): string { + if (state.foundOnCompilation) { + return 'compilation'; + } + + if (state.strategiesTried.length === 0) { + return 'none'; + } + + const lastStrategy = state.strategiesTried[state.strategiesTried.length - 1]; + + switch (lastStrategy) { + case SearchStrategyType.ARTIST_PLUS_ALBUM: + return state.songNotFound ? 'fallback' : 'direct'; + case SearchStrategyType.SWAPPED_INTERPRETATION: + return 'alternative'; + case SearchStrategyType.TRACK_ON_COMPILATION: + return 'compilation'; + case SearchStrategyType.SONG_AS_ARTIST: + return 'song_as_artist'; + default: + return 'none'; + } +} + +export { createSearchState }; +export type { SearchState }; diff --git a/apps/backend/services/requestLine/search/strategies/artistPlusAlbum.ts b/apps/backend/services/requestLine/search/strategies/artistPlusAlbum.ts new file mode 100644 index 0000000..67b2437 --- /dev/null +++ b/apps/backend/services/requestLine/search/strategies/artistPlusAlbum.ts @@ -0,0 +1,98 @@ +/** + * Artist + Album/Song search strategy. + * + * This is the primary search strategy that tries: + * 1. Artist + each album (from Discogs lookup) + * 2. Artist + song (song title might match album title) + * 3. Artist only (fallback) + * + * Ported from request-parser routers/request.py search_library_with_fallback() + */ + +import { ParsedRequest, EnrichedLibraryResult, SearchState, SearchStrategyType } from '../../types.js'; +import { searchLibrary, filterResultsByArtist } from '../../../library.service.js'; +import { MAX_SEARCH_RESULTS } from '../../matching/index.js'; + +/** + * Check if this strategy should run. + */ +export function shouldRunArtistPlusAlbum( + parsed: ParsedRequest, + state: SearchState, + _rawMessage: string +): boolean { + return !!(parsed.artist && (state.albumsForSearch.length > 0 || parsed.song)); +} + +/** + * Execute the artist + album/song search strategy. + * + * @returns Tuple of [results, fallbackUsed] + */ +export async function executeArtistPlusAlbum( + parsed: ParsedRequest, + state: SearchState +): Promise<[EnrichedLibraryResult[], boolean]> { + const allResults: EnrichedLibraryResult[] = []; + const seenIds = new Set(); + + // Search for each album from Discogs + if (parsed.artist && state.albumsForSearch.length > 0) { + for (const album of state.albumsForSearch) { + const query = `${parsed.artist} ${album}`; + const results = await searchLibrary(query, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered = filterResultsByArtist(results, parsed.artist); + + // Add unique results + for (const item of filtered) { + if (!seenIds.has(item.id)) { + seenIds.add(item.id); + allResults.push(item); + } + } + } + + if (allResults.length > 0) { + // Sort to prioritize results matching the first (primary) album + const primaryAlbumLower = state.albumsForSearch[0].toLowerCase(); + allResults.sort((a, b) => { + const aMatches = (a.title || '').toLowerCase().includes(primaryAlbumLower) ? 1 : 0; + const bMatches = (b.title || '').toLowerCase().includes(primaryAlbumLower) ? 1 : 0; + return bMatches - aMatches; + }); + return [allResults.slice(0, MAX_SEARCH_RESULTS), false]; + } + } + + // If no albums from Discogs, try artist + song + if (parsed.artist && parsed.song) { + const query = `${parsed.artist} ${parsed.song}`; + const results = await searchLibrary(query, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered = filterResultsByArtist(results, parsed.artist); + + if (filtered.length > 0) { + // Prioritize results where album title matches song title + const songLower = parsed.song.toLowerCase(); + filtered.sort((a, b) => { + const aMatches = (a.title || '').toLowerCase().includes(songLower) ? 1 : 0; + const bMatches = (b.title || '').toLowerCase().includes(songLower) ? 1 : 0; + return bMatches - aMatches; + }); + return [filtered, false]; + } + } + + // If still no results, try just artist (fallback) + if (allResults.length === 0 && parsed.artist) { + console.log( + `[Search] No results for albums ${JSON.stringify(state.albumsForSearch)}, trying artist only: '${parsed.artist}'` + ); + const results = await searchLibrary(parsed.artist, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered = filterResultsByArtist(results, parsed.artist); + if (filtered.length > 0) { + return [filtered, true]; // fallback was used + } + } + + return [allResults, false]; +} diff --git a/apps/backend/services/requestLine/search/strategies/index.ts b/apps/backend/services/requestLine/search/strategies/index.ts new file mode 100644 index 0000000..b1905bf --- /dev/null +++ b/apps/backend/services/requestLine/search/strategies/index.ts @@ -0,0 +1,8 @@ +/** + * Barrel export for search strategies. + */ + +export { shouldRunArtistPlusAlbum, executeArtistPlusAlbum } from './artistPlusAlbum.js'; +export { shouldRunSwappedInterpretation, executeSwappedInterpretation } from './swappedInterpretation.js'; +export { shouldRunTrackOnCompilation, executeTrackOnCompilation } from './trackOnCompilation.js'; +export { shouldRunSongAsArtist, executeSongAsArtist } from './songAsArtist.js'; diff --git a/apps/backend/services/requestLine/search/strategies/songAsArtist.ts b/apps/backend/services/requestLine/search/strategies/songAsArtist.ts new file mode 100644 index 0000000..8d087df --- /dev/null +++ b/apps/backend/services/requestLine/search/strategies/songAsArtist.ts @@ -0,0 +1,118 @@ +/** + * Song as Artist search strategy. + * + * Fallback strategy that tries using the parsed song title as an artist name. + * This handles cases where the AI parser misinterpreted an artist name + * as a song title (e.g., "Laid Back" parsed as song instead of artist). + * + * Ported from request-parser routers/request.py search_song_as_artist() + */ + +import { ParsedRequest, EnrichedLibraryResult, SearchState, SearchStrategyType } from '../../types.js'; +import { searchLibrary, filterResultsByArtist, searchAlbumsByTitle } from '../../../library.service.js'; +import { isCompilationArtist, MAX_SEARCH_RESULTS } from '../../matching/index.js'; + +// Forward declaration - will be imported when Discogs service is ready +type DiscogsService = { + searchReleasesByArtist: ( + artist: string, + limit?: number + ) => Promise>; +}; + +/** + * Check if this strategy should run. + */ +export function shouldRunSongAsArtist( + parsed: ParsedRequest, + state: SearchState, + _rawMessage: string +): boolean { + // Only run if no results AND parsed song but no artist + return state.results.length === 0 && !!parsed.song && !parsed.artist; +} + +/** + * Execute the song as artist search strategy. + * + * Strategy: + * 1. Search library for direct artist match + * 2. If no results and Discogs available, search Discogs for releases by that artist + * 3. Cross-reference Discogs album titles with library (for compilations) + * + * @param songAsArtist - The song title to try as an artist name + * @param discogsService - Optional Discogs service for cross-referencing + */ +export async function executeSongAsArtist( + songAsArtist: string, + discogsService?: DiscogsService +): Promise { + console.log(`[Search] Trying song '${songAsArtist}' as artist name`); + + // Step 1: Direct library search for artist + const results = await searchLibrary(songAsArtist, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered = filterResultsByArtist(results, songAsArtist); + if (filtered.length > 0) { + console.log(`[Search] Found ${filtered.length} results treating '${songAsArtist}' as artist`); + return filtered; + } + + // Step 2: Search Discogs for releases by this artist (if available) + if (!discogsService) { + return []; + } + + console.log(`[Search] No direct matches, searching Discogs for releases by '${songAsArtist}'`); + const discogsReleases = await discogsService.searchReleasesByArtist(songAsArtist, 10); + + if (discogsReleases.length === 0) { + console.log(`[Search] No Discogs releases found for '${songAsArtist}'`); + return []; + } + + console.log(`[Search] Found ${discogsReleases.length} Discogs releases for '${songAsArtist}'`); + + // Step 3: Cross-reference album titles with library + const crossRefResults: EnrichedLibraryResult[] = []; + const seenIds = new Set(); + + for (const { artist: discogsArtist, album: albumTitle } of discogsReleases) { + if (!albumTitle) { + continue; + } + + // Search library for this album title + const albumResults = await searchAlbumsByTitle(albumTitle, MAX_SEARCH_RESULTS); + + for (const item of albumResults) { + if (seenIds.has(item.id)) { + continue; + } + + // Accept if it's the actual artist or a compilation + const itemArtist = (item.artist || '').toLowerCase(); + if ( + itemArtist.startsWith(songAsArtist.toLowerCase()) || + isCompilationArtist(item.artist) + ) { + crossRefResults.push(item); + seenIds.add(item.id); + console.log( + `[Search] Found '${item.artist} - ${item.title}' via Discogs cross-reference` + ); + } + } + + if (crossRefResults.length >= MAX_SEARCH_RESULTS) { + break; + } + } + + if (crossRefResults.length > 0) { + console.log( + `[Search] Found ${crossRefResults.length} results via Discogs cross-reference for '${songAsArtist}'` + ); + } + + return crossRefResults.slice(0, MAX_SEARCH_RESULTS); +} diff --git a/apps/backend/services/requestLine/search/strategies/swappedInterpretation.ts b/apps/backend/services/requestLine/search/strategies/swappedInterpretation.ts new file mode 100644 index 0000000..b0f4603 --- /dev/null +++ b/apps/backend/services/requestLine/search/strategies/swappedInterpretation.ts @@ -0,0 +1,75 @@ +/** + * Swapped Interpretation search strategy. + * + * For ambiguous "X - Y" formats, tries both: + * - X as artist, Y as title + * - Y as artist, X as title + * + * Ported from request-parser routers/request.py search_with_alternative_interpretation() + */ + +import { ParsedRequest, EnrichedLibraryResult, SearchState, SearchStrategyType } from '../../types.js'; +import { searchLibrary, filterResultsByArtist } from '../../../library.service.js'; +import { detectAmbiguousFormat, MAX_SEARCH_RESULTS } from '../../matching/index.js'; + +/** + * Check if this strategy should run. + */ +export function shouldRunSwappedInterpretation( + _parsed: ParsedRequest, + state: SearchState, + rawMessage: string +): boolean { + // Only run if no results yet AND message has ambiguous X - Y format + if (state.results.length > 0) { + return false; + } + return detectAmbiguousFormat(rawMessage) !== null; +} + +/** + * Execute the swapped interpretation search strategy. + */ +export async function executeSwappedInterpretation( + rawMessage: string +): Promise { + const parts = detectAmbiguousFormat(rawMessage); + if (!parts) { + return []; + } + + const { part1, part2 } = parts; + + // Try interpretation 1: part1 = artist + const query1 = `${part1} ${part2}`; + const results1 = await searchLibrary(query1, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered1 = filterResultsByArtist(results1, part1); + + // Try interpretation 2: part2 = artist + const query2 = `${part2} ${part1}`; + const results2 = await searchLibrary(query2, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered2 = filterResultsByArtist(results2, part2); + + // Return whichever has results (prefer the one with more/better matches) + if (filtered1.length > 0 && filtered2.length === 0) { + console.log(`[Search] Alternative search matched with '${part1}' as artist`); + return filtered1; + } else if (filtered2.length > 0 && filtered1.length === 0) { + console.log(`[Search] Alternative search matched with '${part2}' as artist`); + return filtered2; + } else if (filtered1.length > 0 && filtered2.length > 0) { + // Both have results - combine and dedupe by id + console.log(`[Search] Alternative search matched both interpretations, combining results`); + const seenIds = new Set(); + const combined: EnrichedLibraryResult[] = []; + for (const item of [...filtered1, ...filtered2]) { + if (!seenIds.has(item.id)) { + combined.push(item); + seenIds.add(item.id); + } + } + return combined.slice(0, MAX_SEARCH_RESULTS); + } + + return []; +} diff --git a/apps/backend/services/requestLine/search/strategies/trackOnCompilation.ts b/apps/backend/services/requestLine/search/strategies/trackOnCompilation.ts new file mode 100644 index 0000000..68b7a5e --- /dev/null +++ b/apps/backend/services/requestLine/search/strategies/trackOnCompilation.ts @@ -0,0 +1,212 @@ +/** + * Track on Compilation search strategy. + * + * Searches for track on compilation albums using Discogs and library keyword search. + * This handles cases where a song exists in the library but on a compilation or + * soundtrack rather than the artist's own album. + * + * Ported from request-parser routers/request.py search_compilations_for_track() + */ + +import { ParsedRequest, EnrichedLibraryResult, SearchState, SearchStrategyType } from '../../types.js'; +import { searchLibrary, searchAlbumsByTitle, filterResultsByArtist } from '../../../library.service.js'; +import { + extractSignificantWords, + isCompilationArtist, + STOPWORDS, + MAX_SEARCH_RESULTS, +} from '../../matching/index.js'; + +// Forward declaration - will be imported when Discogs service is ready +type DiscogsService = { + searchReleasesByTrack: ( + track: string, + artist?: string, + limit?: number + ) => Promise>; + validateTrackOnRelease: (releaseId: number, track: string, artist: string) => Promise; +}; + +/** + * Check if this strategy should run. + */ +export function shouldRunTrackOnCompilation( + parsed: ParsedRequest, + state: SearchState, + _rawMessage: string +): boolean { + // Only run if song not found AND we have both artist and song + return state.songNotFound && !!parsed.artist && !!parsed.song; +} + +/** + * Execute the track on compilation search strategy. + * + * @param parsed - Parsed request + * @param discogsService - Optional Discogs service for cross-referencing + * @returns Tuple of [results, discogsTitles map] + */ +export async function executeTrackOnCompilation( + parsed: ParsedRequest, + discogsService?: DiscogsService +): Promise<[EnrichedLibraryResult[], Map]> { + if (!parsed.song || !parsed.artist) { + return [[], new Map()]; + } + + console.log(`[Search] Searching for '${parsed.song}' on other releases (compilations, etc.)`); + + const results: EnrichedLibraryResult[] = []; + const seenIds = new Set(); + const discogsTitles = new Map(); + + // First, try a direct library keyword search + let keywordMatches: EnrichedLibraryResult[] = []; + try { + const artistWords = extractSignificantWords(parsed.artist); + const songWords = extractSignificantWords(parsed.song); + + // Include both artist words (max 2) and song words (max 2) to find the right album + const queryWords = [...artistWords.slice(0, 2), ...songWords.slice(0, 2)]; + + if (queryWords.length > 0) { + const keywordQuery = queryWords.join(' '); + console.log(`[Search] Trying direct keyword search: '${keywordQuery}'`); + const keywordResults = await searchLibrary(keywordQuery, undefined, undefined, MAX_SEARCH_RESULTS); + + if (keywordResults.length > 0) { + // Filter by artist unless it's a compilation album + const filtered: EnrichedLibraryResult[] = []; + const artistLower = parsed.artist.toLowerCase(); + + for (const item of keywordResults) { + const itemArtist = (item.artist || '').toLowerCase(); + if (itemArtist.startsWith(artistLower)) { + filtered.push(item); + } else if (isCompilationArtist(item.artist)) { + // Allow Various Artists/Soundtracks/Compilation albums + filtered.push(item); + } + } + + if (filtered.length > 0) { + console.log( + `[Search] Found ${filtered.length} matches via keyword search (after artist filter)` + ); + // Don't add to results yet - prefer Discogs results which know actual track listings + keywordMatches = filtered; + } + } + } + } catch (e) { + console.warn(`[Search] Keyword search failed:`, e); + keywordMatches = []; + } + + // If Discogs service is available, use it for more accurate results + if (discogsService) { + try { + const releases = await discogsService.searchReleasesByTrack( + parsed.song, + parsed.artist, + 20 + ); + console.log(`[Search] Found ${releases.length} releases with '${parsed.song}' on Discogs`); + + // Check each release against our library + for (const release of releases) { + // Skip if the "album" is just the artist name (Discogs artifact) + if ( + parsed.artist && + release.album.toLowerCase().trim() === parsed.artist.toLowerCase().trim() + ) { + console.log(`[Search] Skipping '${release.album}' - appears to be artist name, not album`); + continue; + } + + // Skip very short album titles (likely artifacts) + if (release.album.trim().length < 3) { + continue; + } + + // For Various Artists / compilations, validate the tracklist + if (release.isCompilation) { + const isValid = await discogsService.validateTrackOnRelease( + release.releaseId, + parsed.song, + parsed.artist + ); + if (!isValid) { + console.log( + `[Search] Skipping '${release.album}' - track/artist not validated on release` + ); + continue; + } + } + + const matches = await searchAlbumsByTitle(release.album, MAX_SEARCH_RESULTS); + + // Filter matches to only include albums by the requested artist OR compilations + const filteredMatches: EnrichedLibraryResult[] = []; + const artistLower = parsed.artist.toLowerCase(); + + for (const match of matches) { + const matchArtist = (match.artist || '').toLowerCase(); + + // If Discogs says it's by the artist, only match artist albums + // If Discogs says it's a compilation, allow compilation matches + if (matchArtist.startsWith(artistLower)) { + filteredMatches.push(match); + } else if (release.isCompilation && isCompilationArtist(match.artist)) { + filteredMatches.push(match); + } + } + + if (filteredMatches.length > 0) { + console.log( + `[Search] Found '${parsed.song}' in library on '${filteredMatches[0].title}' ` + + `(matched from Discogs: '${release.album}')` + ); + // Add matches, deduplicating by ID + for (const match of filteredMatches) { + if (!seenIds.has(match.id)) { + results.push(match); + seenIds.add(match.id); + // Store the Discogs album title for artwork lookup + discogsTitles.set(match.id, release.album); + } + } + + if (results.length >= MAX_SEARCH_RESULTS) { + break; + } + } + } + } catch (e) { + console.warn(`[Search] Failed to search for track on other releases:`, e); + } + } + + // If Discogs didn't find anything, fall back to keyword matches + if (results.length === 0 && keywordMatches.length > 0) { + console.log(`[Search] Discogs search found nothing, using keyword matches as fallback`); + for (const item of keywordMatches.slice(0, 1)) { + if (!seenIds.has(item.id)) { + results.push(item); + seenIds.add(item.id); + } + } + } + + // Prioritize albums whose title matches the song title + if (results.length > 0 && parsed.song) { + const songLower = parsed.song.toLowerCase(); + results.sort((a, b) => { + const aMatches = (a.title || '').toLowerCase().includes(songLower) ? 1 : 0; + const bMatches = (b.title || '').toLowerCase().includes(songLower) ? 1 : 0; + return bMatches - aMatches; + }); + } + + return [results.slice(0, MAX_SEARCH_RESULTS), discogsTitles]; +} diff --git a/apps/backend/services/requestLine/types.ts b/apps/backend/services/requestLine/types.ts new file mode 100644 index 0000000..f0b219d --- /dev/null +++ b/apps/backend/services/requestLine/types.ts @@ -0,0 +1,340 @@ +/** + * Type definitions for the Request Line NLP + Library Search feature. + * + * These types are ported from the Python request-parser project + * and adapted for TypeScript/Express. + */ + +// ============================================================================= +// Message Parsing Types +// ============================================================================= + +/** + * Message type classification from AI parser. + */ +export enum MessageType { + REQUEST = 'request', + DJ_MESSAGE = 'dj_message', + FEEDBACK = 'feedback', + OTHER = 'other', +} + +/** + * Result of AI parsing a listener message. + */ +export interface ParsedRequest { + /** The specific song title requested, or null if not specified */ + song: string | null; + /** The album name, or null if not specified */ + album: string | null; + /** The artist/band name, or null if not specified */ + artist: string | null; + /** True if the listener wants the DJ to play something */ + isRequest: boolean; + /** Classification of the message type */ + messageType: MessageType; + /** The original unparsed message */ + rawMessage: string; +} + +// ============================================================================= +// Library Search Types +// ============================================================================= + +/** + * A single item from the library catalog. + */ +export interface LibraryResult { + /** Database ID */ + id: number; + /** Album title */ + title: string | null; + /** Artist name */ + artist: string | null; + /** Genre code letters (e.g., "RO" for Rock) */ + codeLetters: string | null; + /** Artist number within genre */ + codeArtistNumber: number | null; + /** Release number for this artist */ + codeNumber: number | null; + /** Genre name */ + genre: string | null; + /** Format name (CD, Vinyl, etc.) */ + format: string | null; +} + +/** + * Extended library result with computed fields. + */ +export interface EnrichedLibraryResult extends LibraryResult { + /** Full call number for shelf lookup: / */ + callNumber: string; + /** URL to view this release in the WXYC library */ + libraryUrl: string; +} + +/** + * Compute the call number from library result fields. + */ +export function computeCallNumber(result: LibraryResult): string { + const parts: string[] = []; + if (result.genre) parts.push(result.genre); + if (result.format) parts.push(result.format); + if (result.codeLetters) parts.push(result.codeLetters); + if (result.codeArtistNumber !== null) { + if (result.codeNumber !== null) { + parts.push(`${result.codeArtistNumber}/${result.codeNumber}`); + } else { + parts.push(String(result.codeArtistNumber)); + } + } + return parts.join(' '); +} + +/** + * Compute the library URL from a result ID. + */ +export function computeLibraryUrl(id: number): string { + return `http://www.wxyc.info/wxycdb/libraryRelease?id=${id}`; +} + +/** + * Enrich a library result with computed fields. + */ +export function enrichLibraryResult(result: LibraryResult): EnrichedLibraryResult { + return { + ...result, + callNumber: computeCallNumber(result), + libraryUrl: computeLibraryUrl(result.id), + }; +} + +// ============================================================================= +// Search Strategy Types +// ============================================================================= + +/** + * Descriptive names for each search strategy. + * Used in telemetry to track which strategy succeeded. + */ +export enum SearchStrategyType { + /** Search by artist + album/song name */ + ARTIST_PLUS_ALBUM = 'artist_plus_album', + /** Fallback to just artist name when album/song search fails */ + ARTIST_ONLY = 'artist_only', + /** Try "X - Y" format as both artist/title orderings */ + SWAPPED_INTERPRETATION = 'swapped_interpretation', + /** Find song on compilation albums via Discogs cross-reference */ + TRACK_ON_COMPILATION = 'track_on_compilation', + /** Fallback: try parsed song as artist when no results and no artist parsed */ + SONG_AS_ARTIST = 'song_as_artist', + /** Significant word extraction search */ + KEYWORD_MATCH = 'keyword_match', +} + +/** + * Tracks state across strategy execution. + */ +export interface SearchState { + /** Current search results */ + results: EnrichedLibraryResult[]; + /** True if the exact song/album wasn't found (fell back to artist-only) */ + songNotFound: boolean; + /** True if the song was found on a compilation album */ + foundOnCompilation: boolean; + /** List of strategies that have been executed */ + strategiesTried: SearchStrategyType[]; + /** Map of library item ID to Discogs album title (for artwork lookup) */ + discogsTitles: Map; + /** Album names resolved from Discogs track lookup (may contain multiple) */ + albumsForSearch: string[]; +} + +/** + * Create initial search state. + */ +export function createSearchState(albumsForSearch: string[] = []): SearchState { + return { + results: [], + songNotFound: false, + foundOnCompilation: false, + strategiesTried: [], + discogsTitles: new Map(), + albumsForSearch, + }; +} + +// ============================================================================= +// Artwork Types +// ============================================================================= + +/** + * Request to find album artwork. + */ +export interface ArtworkRequest { + song?: string; + album?: string; + artist?: string; +} + +/** + * Response containing artwork URL and metadata. + */ +export interface ArtworkResponse { + artworkUrl: string | null; + releaseUrl: string | null; + album: string | null; + artist: string | null; + source: string | null; + confidence: number; +} + +/** + * A single search result from an artwork provider. + */ +export interface ArtworkSearchResult { + artworkUrl: string; + releaseUrl: string; + album: string; + artist: string; + source: string; + confidence: number; +} + +// ============================================================================= +// Discogs Types +// ============================================================================= + +/** + * A single track on a release. + */ +export interface DiscogsTrackItem { + position: string; + title: string; + duration?: string; +} + +/** + * Response for track-to-album lookup. + */ +export interface DiscogsTrackAlbumResponse { + album: string | null; + artist: string | null; + releaseId: number | null; + releaseUrl: string | null; + cached: boolean; +} + +/** + * Information about a single release containing a track. + */ +export interface DiscogsReleaseInfo { + album: string; + artist: string; + releaseId: number; + releaseUrl: string; + isCompilation: boolean; +} + +/** + * Response for finding all releases containing a track. + */ +export interface DiscogsTrackReleasesResponse { + track: string; + artist: string | null; + releases: DiscogsReleaseInfo[]; + total: number; + cached: boolean; +} + +/** + * Full release metadata from Discogs. + */ +export interface DiscogsReleaseMetadata { + releaseId: number; + title: string; + artist: string; + year: number | null; + label: string | null; + genres: string[]; + styles: string[]; + tracklist: DiscogsTrackItem[]; + artworkUrl: string | null; + releaseUrl: string; + cached: boolean; +} + +/** + * Request for general Discogs search. + */ +export interface DiscogsSearchRequest { + artist?: string; + album?: string; + track?: string; +} + +/** + * A single result from Discogs search. + */ +export interface DiscogsSearchResult { + album: string | null; + artist: string | null; + releaseId: number; + releaseUrl: string; + artworkUrl: string | null; + confidence: number; +} + +/** + * Response for general Discogs search. + */ +export interface DiscogsSearchResponse { + results: DiscogsSearchResult[]; + total: number; + cached: boolean; +} + +// ============================================================================= +// API Response Types +// ============================================================================= + +/** + * Combined response from parsing, artwork lookup, and library search. + */ +export interface UnifiedRequestResponse { + /** Whether the operation was successful */ + success: boolean; + /** Parsed request metadata */ + parsed: ParsedRequest; + /** Artwork information (best match) */ + artwork: ArtworkResponse | null; + /** Library search results */ + libraryResults: EnrichedLibraryResult[]; + /** Which search strategy succeeded */ + searchType: string; + /** Result from Slack posting */ + result: { success: boolean; message?: string }; +} + +/** + * Request body for song request parsing. + */ +export interface RequestLineRequestBody { + message: string; + skipSlack?: boolean; + skipParsing?: boolean; +} + +// ============================================================================= +// Friendly Labels +// ============================================================================= + +/** + * Human-readable labels for message types in Slack. + */ +export const MESSAGE_TYPE_LABELS: Record = { + [MessageType.REQUEST]: 'Song Request', + [MessageType.DJ_MESSAGE]: 'Message to DJ', + [MessageType.FEEDBACK]: 'Feedback', + [MessageType.OTHER]: 'Other', +}; diff --git a/apps/backend/services/slack/builder.ts b/apps/backend/services/slack/builder.ts new file mode 100644 index 0000000..95c0f1b --- /dev/null +++ b/apps/backend/services/slack/builder.ts @@ -0,0 +1,152 @@ +/** + * Slack message block builders for rich formatting. + * + * Ported from request-parser services/slack.py + */ + +import { EnrichedLibraryResult, ArtworkResponse } from '../requestLine/types.js'; + +/** + * Slack block type definitions. + */ +interface SlackTextBlock { + type: 'mrkdwn' | 'plain_text'; + text: string; +} + +interface SlackImageAccessory { + type: 'image'; + image_url: string; + alt_text: string; +} + +interface SlackSectionBlock { + type: 'section'; + text: SlackTextBlock; + accessory?: SlackImageAccessory; +} + +interface SlackContextBlock { + type: 'context'; + elements: SlackTextBlock[]; +} + +interface SlackDividerBlock { + type: 'divider'; +} + +export type SlackBlock = SlackSectionBlock | SlackContextBlock | SlackDividerBlock; + +/** + * Build Slack message blocks from library results with artwork. + * + * @param message - Original request message + * @param itemsWithArtwork - Library items paired with their artwork + * @param context - Optional context message (e.g., "song not found, showing artist albums") + */ +export function buildSlackBlocks( + message: string, + itemsWithArtwork: Array<[EnrichedLibraryResult, ArtworkResponse | null]>, + context?: string +): SlackBlock[] { + const blocks: SlackBlock[] = [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${escapeSlackText(message)}*`, + }, + }, + ]; + + // Add context message if provided (e.g., "song not found, showing artist albums") + if (context) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: context }, + }); + } + + for (const [item, artwork] of itemsWithArtwork) { + // Build text with links to library and Discogs + const textLines = [ + `*${escapeSlackText(item.artist || 'Unknown Artist')}*`, + escapeSlackText(item.title || 'Unknown Title'), + `_${escapeSlackText(item.callNumber)}_`, + ]; + + if (artwork && artwork.releaseUrl) { + textLines.push( + `<${artwork.releaseUrl}|Discogs> | <${item.libraryUrl}|WXYC>` + ); + } else { + textLines.push(`<${item.libraryUrl}|WXYC Library>`); + } + + const block: SlackSectionBlock = { + type: 'section', + text: { + type: 'mrkdwn', + text: textLines.join('\n'), + }, + }; + + if (artwork && artwork.artworkUrl) { + block.accessory = { + type: 'image', + image_url: artwork.artworkUrl, + alt_text: `${item.title} album cover`, + }; + } + + blocks.push(block); + } + + return blocks; +} + +/** + * Build simple Slack message blocks for feedback or no-results messages. + * + * @param message - Original request message + * @param context - Optional context message + */ +export function buildSimpleSlackBlocks( + message: string, + context?: string +): SlackBlock[] { + const blocks: SlackBlock[] = [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${escapeSlackText(message)}*`, + }, + }, + ]; + + if (context) { + blocks.push({ + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: context, + }, + ], + }); + } + + return blocks; +} + +/** + * Escape special characters in text for Slack mrkdwn format. + */ +function escapeSlackText(text: string): string { + // Escape &, <, > which have special meaning in Slack + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} diff --git a/apps/backend/services/slack/index.ts b/apps/backend/services/slack/index.ts new file mode 100644 index 0000000..00d97e9 --- /dev/null +++ b/apps/backend/services/slack/index.ts @@ -0,0 +1,15 @@ +/** + * Barrel export for Slack services. + */ + +export { + buildSlackBlocks, + buildSimpleSlackBlocks, + type SlackBlock, +} from './builder.js'; +export { + postTextToSlack, + postBlocksToSlack, + isSlackConfigured, + type SlackPostResult, +} from './slack.service.js'; diff --git a/apps/backend/services/slack/slack.service.ts b/apps/backend/services/slack/slack.service.ts new file mode 100644 index 0000000..105fec2 --- /dev/null +++ b/apps/backend/services/slack/slack.service.ts @@ -0,0 +1,121 @@ +/** + * Enhanced Slack service with block support. + * + * Extends the existing requestLine.service.ts functionality + * to support rich block messages. + */ + +import https from 'https'; +import { SlackBlock } from './builder.js'; + +const SLACK_TIMEOUT_MS = 10_000; + +const getSlackConfig = () => ({ + hostname: 'hooks.slack.com', + port: 443, + path: process.env.SLACK_WXYC_REQUESTS_WEBHOOK || '', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, +}); + +export interface SlackPostResult { + success: boolean; + message?: string; + statusCode?: number; + response?: string; +} + +/** + * Post a simple text message to Slack. + */ +export async function postTextToSlack(message: string): Promise { + return postToSlack({ text: message }); +} + +/** + * Post blocks to Slack webhook. + * + * @param blocks - Slack block array + * @param fallbackText - Optional fallback text for notifications + */ +export async function postBlocksToSlack( + blocks: SlackBlock[], + fallbackText?: string +): Promise { + const payload: { blocks: SlackBlock[]; text?: string } = { blocks }; + + // Add fallback text for notifications (shown in push notifications, etc.) + if (fallbackText) { + payload.text = fallbackText; + } + + return postToSlack(payload); +} + +/** + * Post payload to Slack webhook. + */ +async function postToSlack(payload: object): Promise { + // Use mock in test environments + if (process.env.USE_MOCK_SERVICES === 'true') { + // Allow simulating Slack failures in test mode + if (process.env.SIMULATE_SLACK_FAILURE === 'true') { + console.log('[Slack Service] Mock mode - simulating Slack failure'); + return { success: false, statusCode: 500, response: 'Mock: Simulated Slack failure' }; + } + console.log('[Slack Service] Mock mode - would send to Slack:', JSON.stringify(payload).slice(0, 200)); + return { success: true, message: 'Mock: Message sent to Slack successfully' }; + } + + const webhookPath = process.env.SLACK_WXYC_REQUESTS_WEBHOOK; + if (!webhookPath) { + console.error('[Slack Service] SLACK_WXYC_REQUESTS_WEBHOOK not configured'); + return { success: false, message: 'Slack webhook not configured' }; + } + + return new Promise((resolve, reject) => { + const postData = JSON.stringify(payload); + + const req = https.request(getSlackConfig(), (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 200) { + resolve({ success: true, message: 'Message sent to Slack successfully' }); + } else { + resolve({ success: false, statusCode: res.statusCode, response: data }); + } + }); + }); + + req.on('error', (e) => { + console.error('[Slack Service] Error sending message to Slack:', e); + reject(e); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Slack request timeout')); + }); + + req.setTimeout(SLACK_TIMEOUT_MS); + req.write(postData); + req.end(); + }); +} + +/** + * Check if Slack is configured. + */ +export function isSlackConfigured(): boolean { + return !!( + process.env.SLACK_WXYC_REQUESTS_WEBHOOK && + process.env.USE_MOCK_SERVICES !== 'true' + ); +} diff --git a/tests/integration/requestLine.spec.js b/tests/integration/requestLine.spec.js index 3615103..1882f0f 100644 --- a/tests/integration/requestLine.spec.js +++ b/tests/integration/requestLine.spec.js @@ -1,157 +1,44 @@ require('dotenv').config({ path: '../../.env' }); const request = require('supertest')(`${process.env.TEST_HOST}:${process.env.PORT}`); -const crypto = require('crypto'); -const postgres = require('postgres'); - -// Database connection for test utilities -const getDbConnection = () => { - const dbPort = process.env.DB_PORT || 5432; - return postgres({ - host: process.env.DB_HOST || 'localhost', - port: dbPort, - database: process.env.DB_NAME, - username: process.env.DB_USERNAME, - password: process.env.DB_PASSWORD, - }); -}; - -// Helper to generate a valid UUID for test device IDs -const generateTestDeviceId = () => crypto.randomUUID(); - -// Helper to block a device in the database -const blockDevice = async (deviceId) => { - const sql = getDbConnection(); - try { - await sql` - UPDATE anonymous_devices - SET blocked = true, blocked_at = NOW(), blocked_reason = 'Test block' - WHERE device_id = ${deviceId} - `; - } finally { - await sql.end(); - } -}; - -// Helper to unblock a device (cleanup) -const unblockDevice = async (deviceId) => { - const sql = getDbConnection(); - try { - await sql` - UPDATE anonymous_devices - SET blocked = false, blocked_at = NULL, blocked_reason = NULL - WHERE device_id = ${deviceId} - `; - } finally { - await sql.end(); - } -}; +const { signInAnonymous, banUser, unbanUser, getAdminToken } = require('../utils/anonymous_auth'); -// Helper to register a test device and get a token -const registerTestDevice = async (deviceId = null) => { - const testDeviceId = deviceId || generateTestDeviceId(); - const response = await request - .post('/request/register') - .send({ deviceId: testDeviceId }); - - return { - deviceId: testDeviceId, - token: response.body.token, - expiresAt: response.body.expiresAt, - response, - }; +// Helper to get a new anonymous auth token +const getTestToken = async () => { + const { token, userId, user } = await signInAnonymous(); + return { token, userId, user }; }; describe('Request Line Endpoint', () => { - describe('Device Registration', () => { - it('should register a new device and return a token', async () => { - const { response, deviceId } = await registerTestDevice(); + describe('Device Registration (Legacy Endpoint)', () => { + it('should return 301 redirect for legacy registration endpoint', async () => { + const response = await request.post('/request/register').send({ deviceId: 'test-uuid' }); - expect(response.status).toBe(200); - expect(response.body.token).toBeDefined(); - expect(response.body.expiresAt).toBeDefined(); - expect(typeof response.body.token).toBe('string'); - expect(response.body.token.length).toBeGreaterThan(0); - }); - - it('should return 400 when deviceId is missing', async () => { - const response = await request.post('/request/register').send({}); - - expect(response.status).toBe(400); - expect(response.body.message).toMatch(/deviceId/i); + expect(response.status).toBe(301); + expect(response.body.message).toMatch(/deprecated/i); + expect(response.body.endpoint).toMatch(/sign-in\/anonymous/); }); + }); - it('should return 400 when deviceId is invalid format', async () => { - const response = await request - .post('/request/register') - .send({ deviceId: 'not-a-valid-uuid' }); - - expect(response.status).toBe(400); - expect(response.body.message).toMatch(/invalid/i); - }); - - it('should return same device for repeated registration with same deviceId', async () => { - const deviceId = generateTestDeviceId(); - - const response1 = await request.post('/request/register').send({ deviceId }); - const response2 = await request.post('/request/register').send({ deviceId }); - - expect(response1.status).toBe(200); - expect(response2.status).toBe(200); - // Both should succeed (tokens may differ but both should be valid) - expect(response1.body.token).toBeDefined(); - expect(response2.body.token).toBeDefined(); - }); - - it('should return 403 when device is blocked', async () => { - // Register a device first - const deviceId = generateTestDeviceId(); - const registerResponse = await request.post('/request/register').send({ deviceId }); - expect(registerResponse.status).toBe(200); - const { token } = registerResponse.body; - - // Make a request with the token - should succeed before blocking - const successResponse = await request - .post('/request') - .set('Authorization', `Bearer ${token}`) - .send({ message: 'Test before block' }); - expect(successResponse.status).toBe(200); - - // Block the device in the database - await blockDevice(deviceId); + describe('Anonymous Authentication', () => { + it('should return 401 without Authorization header', async () => { + const response = await request.post('/request').send({ message: 'Test song request' }); - try { - // Attempt to make another request - should return 403 - const blockedResponse = await request - .post('/request') - .set('Authorization', `Bearer ${token}`) - .send({ message: 'Test after block' }); - expect(blockedResponse.status).toBe(403); - expect(blockedResponse.body.message).toMatch(/blocked/i); - - // Attempt to re-register - should also return 403 - const reRegisterResponse = await request.post('/request/register').send({ deviceId }); - expect(reRegisterResponse.status).toBe(403); - expect(reRegisterResponse.body.message).toMatch(/blocked/i); - } finally { - // Cleanup: unblock the device - await unblockDevice(deviceId); - } + expect(response.status).toBe(401); }); - }); - describe('Anonymous Authentication', () => { - it('should return 401 when Authorization header is missing', async () => { + it('should return 401 with malformed Authorization header', async () => { const response = await request .post('/request') + .set('Authorization', 'not-bearer-format') .send({ message: 'Test song request' }); expect(response.status).toBe(401); }); - it('should return 401 with invalid token format', async () => { + it('should return 401 with empty Bearer token', async () => { const response = await request .post('/request') - .set('Authorization', 'invalid-token') + .set('Authorization', 'Bearer ') .send({ message: 'Test song request' }); expect(response.status).toBe(401); @@ -160,14 +47,14 @@ describe('Request Line Endpoint', () => { it('should return 401 with invalid Bearer token', async () => { const response = await request .post('/request') - .set('Authorization', 'Bearer invalid-jwt-token') + .set('Authorization', 'Bearer invalid-token') .send({ message: 'Test song request' }); expect(response.status).toBe(401); }); - it('should accept valid anonymous device token', async () => { - const { token } = await registerTestDevice(); + it('should accept valid anonymous session token', async () => { + const { token } = await getTestToken(); const response = await request .post('/request') @@ -182,7 +69,7 @@ describe('Request Line Endpoint', () => { let testToken; beforeAll(async () => { - const { token } = await registerTestDevice(); + const { token } = await getTestToken(); testToken = token; }); @@ -255,56 +142,55 @@ describe('Request Line Endpoint', () => { let testToken; beforeAll(async () => { - const { token } = await registerTestDevice(); + const { token } = await getTestToken(); testToken = token; }); - it('should handle simple text message', async () => { - const testMessage = 'Please play Carry the Zero by Built to Spill'; - + it('should accept song request messages', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: testMessage }); + .send({ message: 'Play Blue Monday by New Order' }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); - expect(response.body.message).toBe('Request line submitted successfully'); - expect(response.body.result).toBeDefined(); - expect(response.body.result.success).toBe(true); }); - it('should handle message with special characters', async () => { - const testMessage = 'Request: "Señor" by Los Ángeles Azules!'; - + it('should handle special characters in message', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: testMessage }); + .send({ message: 'Play "Smells Like Teen Spirit" by Nirvana & friends!' }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); }); - it('should handle message with newlines', async () => { - const testMessage = 'Song Request:\nArtist: Built to Spill\nTrack: Carry the Zero'; - + it('should handle unicode characters in message', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: testMessage }); + .send({ message: 'Play Mötley Crüe or 日本語 music' }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); }); - it('should handle message at maximum length (500 characters)', async () => { - const maxMessage = 'A'.repeat(500); + it('should handle emoji in message', async () => { + const response = await request + .post('/request') + .set('Authorization', `Bearer ${testToken}`) + .send({ message: 'Play some music 🎵🎸' }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it('should trim leading and trailing whitespace', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: maxMessage }); + .send({ message: ' Test song request ' }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); @@ -315,29 +201,25 @@ describe('Request Line Endpoint', () => { let testToken; beforeAll(async () => { - const { token } = await registerTestDevice(); + const { token } = await getTestToken(); testToken = token; }); - it('should return correct response structure', async () => { + it('should return JSON response with success field', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) .send({ message: 'Test message' }); expect(response.status).toBe(200); - expect(response.body).toHaveProperty('success'); - expect(response.body).toHaveProperty('message'); - expect(response.body).toHaveProperty('result'); expect(response.body.success).toBe(true); - expect(response.body.message).toBe('Request line submitted successfully'); }); - it('should include result object with success flag', async () => { + it('should include result object on success', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: 'Test message' }); + .send({ message: 'Test song request' }); expect(response.status).toBe(200); expect(response.body.result).toBeDefined(); @@ -359,22 +241,22 @@ describe('Request Line Endpoint', () => { let testToken; beforeAll(async () => { - const { token } = await registerTestDevice(); + const { token } = await getTestToken(); testToken = token; }); it('should handle rapid successive requests (up to rate limit)', async () => { - // Note: Rate limit is 10 per 15 minutes per device - const requests = Array(5) - .fill(null) - .map((_, i) => + const promises = []; + for (let i = 0; i < 3; i++) { + promises.push( request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: `Rapid request ${i}` }) + .send({ message: `Test request ${i}` }) ); + } - const responses = await Promise.all(requests); + const responses = await Promise.all(promises); responses.forEach((response) => { expect(response.status).toBe(200); @@ -389,7 +271,7 @@ describe('Request Line Endpoint', () => { .send({ message: 'Test message', extraField: 'should be ignored', - anotherField: 123, + anotherExtra: 123, }); expect(response.status).toBe(200); @@ -397,63 +279,37 @@ describe('Request Line Endpoint', () => { }); }); - describe('HTTP Methods', () => { - let testToken; + describe('User Banning', () => { + // Skip these tests if admin credentials aren't configured or TEST_ADMIN_BAN isn't explicitly enabled + // Admin tests require valid credentials that exist in the auth database + const enableAdminTests = process.env.TEST_ADMIN_BAN === 'true'; + const describeOrSkip = enableAdminTests ? describe : describe.skip; - beforeAll(async () => { - const { token } = await registerTestDevice(); - testToken = token; - }); - - it('should reject GET requests', async () => { - const response = await request - .get('/request') - .set('Authorization', `Bearer ${testToken}`); - - expect(response.status).toBe(404); - }); - - it('should reject PUT requests', async () => { - const response = await request - .put('/request') - .set('Authorization', `Bearer ${testToken}`) - .send({ message: 'Test message' }); - - expect(response.status).toBe(404); - }); + describeOrSkip('with admin credentials', () => { + it('should return 403 when user is banned', async () => { + // Get a new anonymous user + const { token, userId } = await getTestToken(); - it('should reject DELETE requests', async () => { - const response = await request - .delete('/request') - .set('Authorization', `Bearer ${testToken}`); - - expect(response.status).toBe(404); - }); - - it('should reject PATCH requests', async () => { - const response = await request - .patch('/request') - .set('Authorization', `Bearer ${testToken}`) - .send({ message: 'Test message' }); - - expect(response.status).toBe(404); - }); - }); + // Verify request works before banning + const beforeBanResponse = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Test before ban' }); + expect(beforeBanResponse.status).toBe(200); - describe('Token Refresh', () => { - it('should include refresh token headers when token is nearing expiration', async () => { - // This test would require mocking the token expiration - // For now, we just verify the headers can be present - const { token } = await registerTestDevice(); + // Ban the user + await banUser(userId, 'Test ban'); - const response = await request - .post('/request') - .set('Authorization', `Bearer ${token}`) - .send({ message: 'Test message' }); + // Request should now return 403 + const afterBanResponse = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Test after ban' }); + expect(afterBanResponse.status).toBe(403); - expect(response.status).toBe(200); - // Note: X-Refresh-Token headers will only be present if token is within refresh threshold - // In normal test conditions, a fresh token won't trigger refresh + // Clean up: unban the user + await unbanUser(userId); + }); }); }); });