Files
lidify/backend/src/services/podcastCache.ts
Your Name cc8d0f6969 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
2026-01-06 20:07:33 -06:00

254 lines
8.0 KiB
TypeScript

import { prisma } from "../utils/db";
import { logger } from "../utils/logger";
import fs from "fs/promises";
import path from "path";
import { config } from "../config";
/**
* Service to cache podcast cover images locally
* Podcasts are already stored in database (from RSS feeds)
* This service adds cover image caching to avoid repeated downloads
*/
interface CoverSyncResult {
synced: number;
failed: number;
skipped: number;
errors: string[];
}
export class PodcastCacheService {
private coverCacheDir: string;
constructor() {
// Store covers in: <MUSIC_PATH>/cover-cache/podcasts/
this.coverCacheDir = path.join(
config.music.musicPath,
"cover-cache",
"podcasts"
);
}
/**
* Sync cover images for all podcasts
*/
async syncAllCovers(): Promise<CoverSyncResult> {
const result: CoverSyncResult = {
synced: 0,
failed: 0,
skipped: 0,
errors: [],
};
try {
logger.debug(" Starting podcast cover sync...");
// Ensure cover cache directory exists
await fs.mkdir(this.coverCacheDir, { recursive: true });
// Fetch all podcasts from database
const podcasts = await prisma.podcast.findMany({
where: {
localCoverPath: null, // Only sync podcasts without local covers
imageUrl: { not: null },
},
});
logger.debug(
`[PODCAST] Found ${podcasts.length} podcasts needing cover sync`
);
for (const podcast of podcasts) {
try {
if (podcast.imageUrl) {
const localPath = await this.downloadCover(
podcast.id,
podcast.imageUrl,
"podcast"
);
if (localPath) {
await prisma.podcast.update({
where: { id: podcast.id },
data: { localCoverPath: localPath },
});
result.synced++;
logger.debug(` Synced cover for: ${podcast.title}`);
} else {
result.skipped++;
}
}
} catch (error: any) {
result.failed++;
const errorMsg = `Failed to sync cover for ${podcast.title}: ${error.message}`;
result.errors.push(errorMsg);
logger.error(` ${errorMsg}`);
}
}
logger.debug("\nPodcast Cover Sync Summary:");
logger.debug(` Synced: ${result.synced}`);
logger.debug(` Failed: ${result.failed}`);
logger.debug(` Skipped: ${result.skipped}`);
return result;
} catch (error: any) {
logger.error(" Podcast cover sync failed:", error);
throw error;
}
}
/**
* Sync cover images for all podcast episodes (if they have unique covers)
*/
async syncEpisodeCovers(): Promise<CoverSyncResult> {
const result: CoverSyncResult = {
synced: 0,
failed: 0,
skipped: 0,
errors: [],
};
try {
logger.debug(" Starting podcast episode cover sync...");
await fs.mkdir(this.coverCacheDir, { recursive: true });
// Fetch episodes with unique covers (different from podcast cover)
const episodes = await prisma.podcastEpisode.findMany({
where: {
localCoverPath: null,
imageUrl: { not: null },
},
include: {
podcast: {
select: {
imageUrl: true,
},
},
},
});
// Filter to only episodes with unique covers
const uniqueEpisodes = episodes.filter(
(ep) => ep.imageUrl !== ep.podcast.imageUrl
);
logger.debug(
`[PODCAST] Found ${uniqueEpisodes.length} episodes with unique covers`
);
for (const episode of uniqueEpisodes) {
try {
if (episode.imageUrl) {
const localPath = await this.downloadCover(
episode.id,
episode.imageUrl,
"episode"
);
if (localPath) {
await prisma.podcastEpisode.update({
where: { id: episode.id },
data: { localCoverPath: localPath },
});
result.synced++;
logger.debug(
` Synced cover for episode: ${episode.title}`
);
} else {
result.skipped++;
}
}
} catch (error: any) {
result.failed++;
const errorMsg = `Failed to sync cover for episode ${episode.title}: ${error.message}`;
result.errors.push(errorMsg);
logger.error(` ${errorMsg}`);
}
}
logger.debug("\nEpisode Cover Sync Summary:");
logger.debug(` Synced: ${result.synced}`);
logger.debug(` Failed: ${result.failed}`);
logger.debug(` Skipped: ${result.skipped}`);
return result;
} catch (error: any) {
logger.error(" Episode cover sync failed:", error);
throw error;
}
}
/**
* Download a cover image and save it locally
*/
private async downloadCover(
id: string,
imageUrl: string,
type: "podcast" | "episode"
): Promise<string | null> {
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${response.statusText}`
);
}
const buffer = await response.arrayBuffer();
const fileName = `${type}_${id}.jpg`;
const filePath = path.join(this.coverCacheDir, fileName);
await fs.writeFile(filePath, Buffer.from(buffer));
return filePath;
} catch (error: any) {
logger.error(
`Failed to download cover for ${type} ${id}:`,
error.message
);
return null;
}
}
/**
* Clean up orphaned covers
*/
async cleanupOrphanedCovers(): Promise<number> {
const podcasts = await prisma.podcast.findMany({
select: { localCoverPath: true },
});
const episodes = await prisma.podcastEpisode.findMany({
select: { localCoverPath: true },
});
const validCoverPaths = new Set([
...podcasts
.filter((p) => p.localCoverPath)
.map((p) => path.basename(p.localCoverPath!)),
...episodes
.filter((e) => e.localCoverPath)
.map((e) => path.basename(e.localCoverPath!)),
]);
let deleted = 0;
const files = await fs.readdir(this.coverCacheDir);
for (const file of files) {
if (!validCoverPaths.has(file)) {
await fs.unlink(path.join(this.coverCacheDir, file));
deleted++;
logger.debug(` [DELETE] Deleted orphaned podcast cover: ${file}`);
}
}
return deleted;
}
}
// Export singleton instance
export const podcastCacheService = new PodcastCacheService();