Files
lidify/backend/src/services/podcastDownload.ts
T
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

443 lines
16 KiB
TypeScript

import { prisma } from "../utils/db";
import { logger } from "../utils/logger";
import { config } from "../config";
import fs from "fs/promises";
import path from "path";
import axios from "axios";
/**
* PodcastDownloadService - Background download and caching of podcast episodes
*
* Features:
* - Non-blocking background downloads when episodes are played
* - 30-day cache expiry with automatic cleanup
* - Proper range request support for cached files
*/
// Track in-progress downloads to avoid duplicates
const downloadingEpisodes = new Set<string>();
// Track download progress (episodeId -> { bytesDownloaded, totalBytes })
interface DownloadProgress {
bytesDownloaded: number;
totalBytes: number;
}
const downloadProgress = new Map<string, DownloadProgress>();
// Cache directory for podcast audio files
const getPodcastCacheDir = (): string => {
return path.join(config.music.transcodeCachePath, "../podcast-audio");
};
/**
* Get download progress for an episode
* Returns { progress: 0-100, downloading: boolean } or null if not downloading
*/
export function getDownloadProgress(episodeId: string): { progress: number; downloading: boolean } | null {
if (!downloadingEpisodes.has(episodeId)) {
return null;
}
const progress = downloadProgress.get(episodeId);
if (!progress || progress.totalBytes === 0) {
return { progress: 0, downloading: true };
}
const percent = Math.round((progress.bytesDownloaded / progress.totalBytes) * 100);
return { progress: Math.min(100, percent), downloading: true };
}
/**
* Check if a cached file exists and is valid
* Returns null if file doesn't exist, is empty, or is still being downloaded
*/
export async function getCachedFilePath(episodeId: string): Promise<string | null> {
// Don't return cache path if still downloading - file may be incomplete
if (downloadingEpisodes.has(episodeId)) {
logger.debug(`[PODCAST-DL] Episode ${episodeId} is still downloading, not using cache`);
return null;
}
const cacheDir = getPodcastCacheDir();
const cachedPath = path.join(cacheDir, `${episodeId}.mp3`);
try {
await fs.access(cachedPath, fs.constants.F_OK);
const stats = await fs.stat(cachedPath);
// File must be > 0 bytes to be valid
if (stats.size > 0) {
// Strong validation: if we know the canonical remote file size, require the cache to match.
// This prevents "cached=true" when we only downloaded part of the file (which breaks seeking and causes 416s).
try {
const episode = await prisma.podcastEpisode.findUnique({
where: { id: episodeId },
select: { fileSize: true },
});
if (episode?.fileSize && episode.fileSize > 0) {
const expected = episode.fileSize;
const actual = stats.size;
const variance = Math.abs(actual - expected) / expected;
if (variance > 0.01) {
logger.debug(
`[PODCAST-DL] Episode size mismatch vs episode.fileSize for ${episodeId}: actual ${actual} vs expected ${expected} (variance ${Math.round(
variance * 100
)}%), deleting cache`
);
await fs.unlink(cachedPath).catch(() => {});
await prisma.podcastDownload.deleteMany({
where: { episodeId },
});
return null;
}
}
} catch {
// If this check fails, fall back to prior DB-record based validation
}
// Check database record exists
const dbRecord = await prisma.podcastDownload.findFirst({
where: { episodeId }
});
// If no DB record, file might be incomplete or stale
if (!dbRecord) {
logger.debug(`[PODCAST-DL] No DB record for ${episodeId}, deleting stale cache file`);
await fs.unlink(cachedPath).catch(() => {});
return null;
}
// Validate file size matches what we recorded (allow 1% variance for filesystem differences)
const expectedSize = dbRecord.fileSizeMb * 1024 * 1024;
const actualSize = stats.size;
const variance = Math.abs(actualSize - expectedSize) / expectedSize;
if (expectedSize > 0 && variance > 0.01) {
logger.debug(`[PODCAST-DL] Size mismatch for ${episodeId}: actual ${actualSize} vs expected ${Math.round(expectedSize)}, deleting`);
await fs.unlink(cachedPath).catch(() => {});
await prisma.podcastDownload.deleteMany({ where: { episodeId } });
return null;
}
// Update last accessed time
await prisma.podcastDownload.updateMany({
where: { episodeId },
data: { lastAccessedAt: new Date() }
});
logger.debug(`[PODCAST-DL] Cache valid for ${episodeId}: ${stats.size} bytes`);
return cachedPath;
}
return null;
} catch {
return null;
}
}
/**
* Start a background download for an episode
* Returns immediately, download happens asynchronously
*/
export function downloadInBackground(
episodeId: string,
audioUrl: string,
userId: string
): void {
// Skip if already downloading
if (downloadingEpisodes.has(episodeId)) {
logger.debug(`[PODCAST-DL] Already downloading episode ${episodeId}, skipping`);
return;
}
// Mark as downloading
downloadingEpisodes.add(episodeId);
// Start download in background (don't await)
performDownload(episodeId, audioUrl, userId)
.catch(err => {
logger.error(`[PODCAST-DL] Background download failed for ${episodeId}:`, err.message);
})
.finally(() => {
downloadingEpisodes.delete(episodeId);
});
}
/**
* Perform the actual download with retry support
*/
async function performDownload(
episodeId: string,
audioUrl: string,
userId: string,
attempt: number = 1
): Promise<void> {
const maxAttempts = 3;
logger.debug(`[PODCAST-DL] Starting background download for episode ${episodeId} (attempt ${attempt}/${maxAttempts})`);
const cacheDir = getPodcastCacheDir();
// Ensure cache directory exists
await fs.mkdir(cacheDir, { recursive: true });
const tempPath = path.join(cacheDir, `${episodeId}.tmp`);
const finalPath = path.join(cacheDir, `${episodeId}.mp3`);
try {
// Check if already cached (and validated)
downloadingEpisodes.delete(episodeId); // Temporarily remove to check cache
const existingCached = await getCachedFilePath(episodeId);
downloadingEpisodes.add(episodeId); // Re-add
if (existingCached) {
logger.debug(`[PODCAST-DL] Episode ${episodeId} already cached, skipping download`);
return;
}
// Clean up any partial temp files from previous attempts
await fs.unlink(tempPath).catch(() => {});
// Download the file with longer timeout for large podcasts
const response = await axios.get(audioUrl, {
responseType: 'stream',
timeout: 600000, // 10 minute timeout for large files (3+ hour podcasts)
headers: {
'User-Agent': 'Lidify/1.0 (https://github.com/Chevron7Locked/lidify)'
},
// Don't let axios decompress - we want raw bytes
decompress: false
});
const contentLength = parseInt(response.headers["content-length"] || "0", 10);
let expectedBytes = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : 0;
// If the origin provides Content-Length, treat it as ground truth and persist it.
// This prevents us from "accepting" partial caches that later break seeking.
if (expectedBytes > 0) {
try {
const episode = await prisma.podcastEpisode.findUnique({
where: { id: episodeId },
select: { fileSize: true },
});
const existing = episode?.fileSize || 0;
if (!existing) {
await prisma.podcastEpisode.update({
where: { id: episodeId },
data: { fileSize: expectedBytes },
});
} else {
const variance = Math.abs(existing - expectedBytes) / existing;
if (variance > 0.01) {
await prisma.podcastEpisode.update({
where: { id: episodeId },
data: { fileSize: expectedBytes },
});
}
}
} catch {
// Non-fatal
}
} else {
// Fallback: use DB fileSize if present (better than nothing)
try {
const episode = await prisma.podcastEpisode.findUnique({
where: { id: episodeId },
select: { fileSize: true },
});
if (episode?.fileSize && episode.fileSize > 0) {
expectedBytes = episode.fileSize;
}
} catch {}
}
logger.debug(
`[PODCAST-DL] Downloading ${episodeId} (${expectedBytes > 0 ? Math.round(expectedBytes / 1024 / 1024) : 0}MB)`
);
// Initialize progress tracking
downloadProgress.set(episodeId, {
bytesDownloaded: 0,
totalBytes: expectedBytes || 0,
});
// Write to temp file first with progress tracking
const writeStream = (await import('fs')).createWriteStream(tempPath);
let bytesDownloaded = 0;
let lastLogTime = Date.now();
await new Promise<void>((resolve, reject) => {
response.data.on('data', (chunk: Buffer) => {
bytesDownloaded += chunk.length;
downloadProgress.set(episodeId, { bytesDownloaded, totalBytes: contentLength });
// Log progress every 30 seconds for long downloads
const now = Date.now();
if (now - lastLogTime > 30000) {
const percent = contentLength > 0 ? Math.round((bytesDownloaded / contentLength) * 100) : 0;
logger.debug(`[PODCAST-DL] Download progress ${episodeId}: ${percent}% (${Math.round(bytesDownloaded / 1024 / 1024)}MB)`);
lastLogTime = now;
}
});
response.data.on('end', () => {
writeStream.end(() => resolve());
});
response.data.pipe(writeStream, { end: false });
writeStream.on('error', (err) => {
response.data.destroy();
reject(err);
});
response.data.on('error', (err: Error) => {
writeStream.destroy();
reject(err);
});
// Handle aborted connections
response.data.on('aborted', () => {
writeStream.destroy();
reject(new Error('Download aborted by server'));
});
});
// Verify file was written and is complete
const stats = await fs.stat(tempPath);
if (stats.size === 0) {
await fs.unlink(tempPath).catch(() => {});
throw new Error('Downloaded file is empty');
}
// Check completeness when we know an expected size (prefer Content-Length).
// Allow a small variance because some servers are inconsistent at the byte level.
if (expectedBytes > 0) {
const variance = Math.abs(stats.size - expectedBytes) / expectedBytes;
if (variance > 0.01) {
const percentComplete = Math.round((stats.size / expectedBytes) * 100);
logger.error(`[PODCAST-DL] Incomplete download for ${episodeId}: ${stats.size}/${expectedBytes} bytes (${percentComplete}%)`);
await fs.unlink(tempPath).catch(() => {});
throw new Error(`Download incomplete: got ${stats.size} bytes, expected ${expectedBytes}`);
}
}
// Move temp file to final location
await fs.rename(tempPath, finalPath);
// Record in database
const fileSizeMb = stats.size / 1024 / 1024;
await prisma.podcastDownload.upsert({
where: {
userId_episodeId: { userId, episodeId }
},
create: {
userId,
episodeId,
localPath: finalPath,
fileSizeMb,
downloadedAt: new Date(),
lastAccessedAt: new Date()
},
update: {
localPath: finalPath,
fileSizeMb,
downloadedAt: new Date(),
lastAccessedAt: new Date()
}
});
logger.debug(`[PODCAST-DL] Successfully cached episode ${episodeId} (${fileSizeMb.toFixed(1)}MB)`);
// Clean up progress tracking
downloadProgress.delete(episodeId);
} catch (error: any) {
// Clean up temp file and progress tracking on error
await fs.unlink(tempPath).catch(() => {});
downloadProgress.delete(episodeId);
// Retry on failure
if (attempt < maxAttempts) {
logger.debug(`[PODCAST-DL] Download failed (attempt ${attempt}), retrying in 5s: ${error.message}`);
await new Promise(resolve => setTimeout(resolve, 5000));
return performDownload(episodeId, audioUrl, userId, attempt + 1);
}
throw error;
}
}
/**
* Clean up cached episodes older than 30 days
* Should be called periodically (e.g., daily)
*/
export async function cleanupExpiredCache(): Promise<{ deleted: number; freedMb: number }> {
logger.debug('[PODCAST-DL] Starting cache cleanup...');
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Find expired downloads
const expiredDownloads = await prisma.podcastDownload.findMany({
where: {
lastAccessedAt: { lt: thirtyDaysAgo }
}
});
let deleted = 0;
let freedMb = 0;
for (const download of expiredDownloads) {
try {
// Delete file from disk
await fs.unlink(download.localPath).catch(() => {});
// Delete database record
await prisma.podcastDownload.delete({
where: { id: download.id }
});
deleted++;
freedMb += download.fileSizeMb;
logger.debug(`[PODCAST-DL] Deleted expired cache: ${path.basename(download.localPath)}`);
} catch (err: any) {
logger.error(`[PODCAST-DL] Failed to delete ${download.localPath}:`, err.message);
}
}
logger.debug(`[PODCAST-DL] Cleanup complete: ${deleted} files deleted, ${freedMb.toFixed(1)}MB freed`);
return { deleted, freedMb };
}
/**
* Get cache statistics
*/
export async function getCacheStats(): Promise<{
totalFiles: number;
totalSizeMb: number;
oldestFile: Date | null;
}> {
const downloads = await prisma.podcastDownload.findMany({
select: {
fileSizeMb: true,
downloadedAt: true
},
orderBy: { downloadedAt: 'asc' }
});
return {
totalFiles: downloads.length,
totalSizeMb: downloads.reduce((sum, d) => sum + d.fileSizeMb, 0),
oldestFile: downloads.length > 0 ? downloads[0].downloadedAt : null
};
}
/**
* Check if an episode is currently being downloaded
*/
export function isDownloading(episodeId: string): boolean {
return downloadingEpisodes.has(episodeId);
}