Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features: - Multi-source download system (Soulseek/Lidarr with fallback) - Configurable enrichment speed control (1-5x) - Mobile touch drag support for seek sliders - iOS PWA media controls (Control Center, Lock Screen) - Artist name alias resolution via Last.fm - Circuit breaker pattern for audio analysis Critical Fixes: - Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM) - Discovery system race conditions and import failures - Radio decade categorization using originalYear - LastFM API response normalization - Mood bucket infinite loop prevention Security: - Bull Board admin authentication - Lidarr webhook signature verification - JWT token expiration and refresh - Encryption key validation on startup Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
This commit is contained in:
+308
-25
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "../utils/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { redisClient } from "../utils/redis";
|
||||
|
||||
interface SearchOptions {
|
||||
@@ -43,6 +44,31 @@ interface PodcastSearchResult {
|
||||
description: string | null;
|
||||
imageUrl: string | null;
|
||||
episodeCount: number;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
interface EpisodeSearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
podcastId: string;
|
||||
podcastTitle: string;
|
||||
publishedAt: Date;
|
||||
duration: number;
|
||||
audioUrl: string;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
interface AudiobookSearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
narrator: string | null;
|
||||
series: string | null;
|
||||
description: string | null;
|
||||
coverUrl: string | null;
|
||||
duration: number | null;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export class SearchService {
|
||||
@@ -54,6 +80,7 @@ export class SearchService {
|
||||
private queryToTsquery(query: string): string {
|
||||
return query
|
||||
.trim()
|
||||
.replace(/\s*&\s*/g, " and ")
|
||||
.split(/\s+/)
|
||||
.map((term) => `${term.replace(/[^\w]/g, "")}:*`)
|
||||
.join(" & ");
|
||||
@@ -77,9 +104,9 @@ export class SearchService {
|
||||
name,
|
||||
mbid,
|
||||
"heroUrl",
|
||||
ts_rank(search_vector, to_tsquery('english', ${tsquery})) AS rank
|
||||
ts_rank("searchVector", to_tsquery('english', ${tsquery})) AS rank
|
||||
FROM "Artist"
|
||||
WHERE search_vector @@ to_tsquery('english', ${tsquery})
|
||||
WHERE "searchVector" @@ to_tsquery('english', ${tsquery})
|
||||
ORDER BY rank DESC, name ASC
|
||||
LIMIT ${limit}
|
||||
OFFSET ${offset}
|
||||
@@ -87,7 +114,7 @@ export class SearchService {
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("Artist search error:", error);
|
||||
logger.error("Artist search error:", error);
|
||||
// Fallback to LIKE query if full-text search fails
|
||||
const results = await prisma.artist.findMany({
|
||||
where: {
|
||||
@@ -134,13 +161,13 @@ export class SearchService {
|
||||
a.year,
|
||||
a."coverUrl",
|
||||
GREATEST(
|
||||
ts_rank(a.search_vector, to_tsquery('english', ${tsquery})),
|
||||
ts_rank(ar.search_vector, to_tsquery('english', ${tsquery}))
|
||||
ts_rank(a."searchVector", to_tsquery('english', ${tsquery})),
|
||||
ts_rank(ar."searchVector", to_tsquery('english', ${tsquery}))
|
||||
) AS rank
|
||||
FROM "Album" a
|
||||
LEFT JOIN "Artist" ar ON a."artistId" = ar.id
|
||||
WHERE a.search_vector @@ to_tsquery('english', ${tsquery})
|
||||
OR ar.search_vector @@ to_tsquery('english', ${tsquery})
|
||||
WHERE a."searchVector" @@ to_tsquery('english', ${tsquery})
|
||||
OR ar."searchVector" @@ to_tsquery('english', ${tsquery})
|
||||
ORDER BY rank DESC, a.title ASC
|
||||
LIMIT ${limit}
|
||||
OFFSET ${offset}
|
||||
@@ -148,7 +175,7 @@ export class SearchService {
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("Album search error:", error);
|
||||
logger.error("Album search error:", error);
|
||||
// Fallback to LIKE query - search both album title and artist name
|
||||
const results = await prisma.album.findMany({
|
||||
where: {
|
||||
@@ -221,11 +248,11 @@ export class SearchService {
|
||||
a.title as "albumTitle",
|
||||
a."artistId",
|
||||
ar.name as "artistName",
|
||||
ts_rank(t.search_vector, to_tsquery('english', ${tsquery})) AS rank
|
||||
ts_rank(t."searchVector", to_tsquery('english', ${tsquery})) AS rank
|
||||
FROM "Track" t
|
||||
LEFT JOIN "Album" a ON t."albumId" = a.id
|
||||
LEFT JOIN "Artist" ar ON a."artistId" = ar.id
|
||||
WHERE t.search_vector @@ to_tsquery('english', ${tsquery})
|
||||
WHERE t."searchVector" @@ to_tsquery('english', ${tsquery})
|
||||
ORDER BY rank DESC, t.title ASC
|
||||
LIMIT ${limit}
|
||||
OFFSET ${offset}
|
||||
@@ -233,7 +260,7 @@ export class SearchService {
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("Track search error:", error);
|
||||
logger.error("Track search error:", error);
|
||||
// Fallback to LIKE query
|
||||
const results = await prisma.track.findMany({
|
||||
where: {
|
||||
@@ -279,6 +306,238 @@ export class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search podcasts using PostgreSQL full-text search
|
||||
*/
|
||||
async searchPodcastsFTS({
|
||||
query,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
}: SearchOptions): Promise<PodcastSearchResult[]> {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tsquery = this.queryToTsquery(query);
|
||||
|
||||
try {
|
||||
const results = await prisma.$queryRaw<PodcastSearchResult[]>`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
author,
|
||||
description,
|
||||
"imageUrl",
|
||||
"episodeCount",
|
||||
ts_rank("searchVector", to_tsquery('english', ${tsquery})) AS rank
|
||||
FROM "Podcast"
|
||||
WHERE "searchVector" @@ to_tsquery('english', ${tsquery})
|
||||
ORDER BY rank DESC, title ASC
|
||||
LIMIT ${limit}
|
||||
OFFSET ${offset}
|
||||
`;
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("Podcast FTS search error:", error);
|
||||
// Fallback to LIKE search
|
||||
return this.searchPodcasts({ query, limit, offset });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search podcast episodes using PostgreSQL full-text search
|
||||
*/
|
||||
async searchEpisodes({
|
||||
query,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
}: SearchOptions): Promise<EpisodeSearchResult[]> {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tsquery = this.queryToTsquery(query);
|
||||
|
||||
try {
|
||||
const results = await prisma.$queryRaw<EpisodeSearchResult[]>`
|
||||
SELECT
|
||||
e.id,
|
||||
e.title,
|
||||
e.description,
|
||||
e."podcastId",
|
||||
e."publishedAt",
|
||||
e.duration,
|
||||
e."audioUrl",
|
||||
p.title as "podcastTitle",
|
||||
ts_rank(e."searchVector", to_tsquery('english', ${tsquery})) AS rank
|
||||
FROM "PodcastEpisode" e
|
||||
LEFT JOIN "Podcast" p ON e."podcastId" = p.id
|
||||
WHERE e."searchVector" @@ to_tsquery('english', ${tsquery})
|
||||
ORDER BY rank DESC, e."publishedAt" DESC
|
||||
LIMIT ${limit}
|
||||
OFFSET ${offset}
|
||||
`;
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("Episode search error:", error);
|
||||
// Fallback to LIKE search
|
||||
const results = await prisma.podcastEpisode.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
contains: query,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: {
|
||||
contains: query,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
podcastId: true,
|
||||
publishedAt: true,
|
||||
duration: true,
|
||||
audioUrl: true,
|
||||
podcast: {
|
||||
select: {
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
orderBy: {
|
||||
publishedAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return results.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
podcastId: r.podcastId,
|
||||
podcastTitle: r.podcast.title,
|
||||
publishedAt: r.publishedAt,
|
||||
duration: r.duration,
|
||||
audioUrl: r.audioUrl,
|
||||
rank: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search audiobooks using PostgreSQL full-text search
|
||||
* Falls back to external API if local cache is empty
|
||||
*/
|
||||
async searchAudiobooksFTS({
|
||||
query,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
}: SearchOptions): Promise<AudiobookSearchResult[]> {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tsquery = this.queryToTsquery(query);
|
||||
|
||||
try {
|
||||
const results = await prisma.$queryRaw<AudiobookSearchResult[]>`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
author,
|
||||
narrator,
|
||||
series,
|
||||
description,
|
||||
"coverUrl",
|
||||
duration,
|
||||
ts_rank("searchVector", to_tsquery('english', ${tsquery})) AS rank
|
||||
FROM "Audiobook"
|
||||
WHERE "searchVector" @@ to_tsquery('english', ${tsquery})
|
||||
ORDER BY rank DESC, title ASC
|
||||
LIMIT ${limit}
|
||||
OFFSET ${offset}
|
||||
`;
|
||||
|
||||
// If we have results from cache, return them with transformed coverUrl
|
||||
if (results.length > 0) {
|
||||
return results.map((r) => ({
|
||||
...r,
|
||||
coverUrl: r.coverUrl ? `/audiobooks/${r.id}/cover` : null,
|
||||
}));
|
||||
}
|
||||
|
||||
// If cache is empty, fall back to LIKE search on cached audiobooks
|
||||
const likeResults = await prisma.audiobook.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
contains: query,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
author: {
|
||||
contains: query,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
narrator: {
|
||||
contains: query,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
series: {
|
||||
contains: query,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
series: true,
|
||||
description: true,
|
||||
coverUrl: true,
|
||||
duration: true,
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
orderBy: {
|
||||
title: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
return likeResults.map((r) => ({
|
||||
...r,
|
||||
coverUrl: r.coverUrl ? `/audiobooks/${r.id}/cover` : null,
|
||||
rank: 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error("Audiobook FTS search error:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy LIKE-based podcast search (kept as fallback)
|
||||
*/
|
||||
async searchPodcasts({
|
||||
query,
|
||||
limit = 20,
|
||||
@@ -288,7 +547,7 @@ export class SearchService {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Simple LIKE search for podcasts (no full-text search vector on podcasts yet)
|
||||
// Simple LIKE search for podcasts (fallback)
|
||||
try {
|
||||
const results = await prisma.podcast.findMany({
|
||||
where: {
|
||||
@@ -330,7 +589,7 @@ export class SearchService {
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("Podcast search error:", error);
|
||||
logger.error("Podcast search error:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -342,6 +601,8 @@ export class SearchService {
|
||||
albums: [],
|
||||
tracks: [],
|
||||
podcasts: [],
|
||||
audiobooks: [],
|
||||
episodes: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,31 +611,53 @@ export class SearchService {
|
||||
try {
|
||||
const cached = await redisClient.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`[SEARCH] Cache HIT for query: "${query}"`);
|
||||
return JSON.parse(cached);
|
||||
logger.debug(`[SEARCH] Cache HIT for query: "${query}"`);
|
||||
const parsed = JSON.parse(cached);
|
||||
// Transform cached audiobook coverUrls to ensure consistency
|
||||
if (parsed.audiobooks && Array.isArray(parsed.audiobooks)) {
|
||||
parsed.audiobooks = parsed.audiobooks.map(
|
||||
(book: AudiobookSearchResult) => ({
|
||||
...book,
|
||||
coverUrl: book.coverUrl
|
||||
? `/audiobooks/${book.id}/cover`
|
||||
: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[SEARCH] Redis cache read error:", err);
|
||||
logger.warn("[SEARCH] Redis cache read error:", err);
|
||||
}
|
||||
|
||||
console.log(
|
||||
logger.debug(
|
||||
`[SEARCH] Cache MISS for query: "${query}" - fetching from database`
|
||||
);
|
||||
|
||||
const [artists, albums, tracks, podcasts] = await Promise.all([
|
||||
this.searchArtists({ query, limit }),
|
||||
this.searchAlbums({ query, limit }),
|
||||
this.searchTracks({ query, limit }),
|
||||
this.searchPodcasts({ query, limit }),
|
||||
]);
|
||||
const [artists, albums, tracks, podcasts, audiobooks, episodes] =
|
||||
await Promise.all([
|
||||
this.searchArtists({ query, limit }),
|
||||
this.searchAlbums({ query, limit }),
|
||||
this.searchTracks({ query, limit }),
|
||||
this.searchPodcastsFTS({ query, limit }),
|
||||
this.searchAudiobooksFTS({ query, limit }),
|
||||
this.searchEpisodes({ query, limit }),
|
||||
]);
|
||||
|
||||
const results = { artists, albums, tracks, podcasts };
|
||||
const results = {
|
||||
artists,
|
||||
albums,
|
||||
tracks,
|
||||
podcasts,
|
||||
audiobooks,
|
||||
episodes,
|
||||
};
|
||||
|
||||
// Cache for 1 hour (search results don't change often)
|
||||
try {
|
||||
await redisClient.setEx(cacheKey, 3600, JSON.stringify(results));
|
||||
} catch (err) {
|
||||
console.warn("[SEARCH] Redis cache write error:", err);
|
||||
logger.warn("[SEARCH] Redis cache write error:", err);
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
Reference in New Issue
Block a user