mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add BookDate card stack animations and thumbnail caching
Implements pure CSS card stack animations for BookDate recommendations, including smooth exit and advance transitions. Adds local caching of library cover thumbnails during scans, updates database schema and API to serve cached covers, and enhances BookDate to support 'favorites' scope with a book picker modal. Updates admin settings validation logic for Prowlarr, improves indexer state management, and documents new features and backend changes.
This commit is contained in:
+93
-15
@@ -232,12 +232,12 @@ async function enrichWithUserRatings(
|
||||
/**
|
||||
* Get user's Plex library books based on scope
|
||||
* @param userId - User ID
|
||||
* @param scope - 'full' | 'listened' | 'rated'
|
||||
* @param scope - 'full' | 'listened' | 'rated' | 'favorites'
|
||||
* @returns Array of library books (max 40)
|
||||
*/
|
||||
export async function getUserLibraryBooks(
|
||||
userId: string,
|
||||
scope: 'full' | 'listened' | 'rated'
|
||||
scope: 'full' | 'listened' | 'rated' | 'favorites'
|
||||
): Promise<LibraryBook[]> {
|
||||
try {
|
||||
const configService = getConfigService();
|
||||
@@ -249,6 +249,74 @@ export async function getUserLibraryBooks(
|
||||
scope = 'full';
|
||||
}
|
||||
|
||||
// Handle favorites scope
|
||||
if (scope === 'favorites') {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { bookDateFavoriteBookIds: true },
|
||||
});
|
||||
|
||||
const favoriteIds = user?.bookDateFavoriteBookIds
|
||||
? JSON.parse(user.bookDateFavoriteBookIds)
|
||||
: [];
|
||||
|
||||
if (favoriteIds.length === 0) {
|
||||
logger.warn('Favorites scope selected but no favorites stored, falling back to full library');
|
||||
scope = 'full';
|
||||
} else {
|
||||
// Get library ID for filtering
|
||||
let libraryId: string;
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
const absLibraryId = await configService.get('audiobookshelf.library_id');
|
||||
if (!absLibraryId) {
|
||||
logger.warn('No Audiobookshelf library ID configured');
|
||||
return [];
|
||||
}
|
||||
libraryId = absLibraryId;
|
||||
} else {
|
||||
const plexConfig = await configService.getPlexConfig();
|
||||
if (!plexConfig.libraryId) {
|
||||
logger.warn('No Plex library ID configured');
|
||||
return [];
|
||||
}
|
||||
libraryId = plexConfig.libraryId;
|
||||
}
|
||||
|
||||
// Query favorite books
|
||||
const cachedBooks = await prisma.plexLibrary.findMany({
|
||||
where: {
|
||||
id: { in: favoriteIds },
|
||||
plexLibraryId: libraryId, // Ensure books are from current library
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
plexGuid: true,
|
||||
plexRatingKey: true,
|
||||
userRating: true,
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
});
|
||||
|
||||
logger.info(`Fetched ${cachedBooks.length} favorite books for user ${userId}`);
|
||||
|
||||
// For Plex: Enrich with user's personal ratings
|
||||
// For Audiobookshelf: Skip enrichment (no rating support)
|
||||
if (backendMode === 'plex') {
|
||||
return await enrichWithUserRatings(userId, cachedBooks);
|
||||
} else {
|
||||
// Audiobookshelf: Map to LibraryBook without ratings
|
||||
return cachedBooks.map(book => ({
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
rating: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get library ID based on backend mode
|
||||
let libraryId: string;
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
@@ -422,7 +490,7 @@ export async function buildAIPrompt(
|
||||
): Promise<string> {
|
||||
const libraryBooks = await getUserLibraryBooks(
|
||||
userId,
|
||||
config.libraryScope as 'full' | 'listened' | 'rated'
|
||||
config.libraryScope as 'full' | 'listened' | 'rated' | 'favorites'
|
||||
);
|
||||
|
||||
const swipeHistory = await getUserRecentSwipes(userId, 10);
|
||||
@@ -434,6 +502,27 @@ export async function buildAIPrompt(
|
||||
libraryScope: config.libraryScope,
|
||||
});
|
||||
|
||||
let instructions =
|
||||
'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' +
|
||||
'CRITICAL RULES:\n' +
|
||||
'1. DO NOT recommend any books already in the user\'s library (check titles carefully)\n' +
|
||||
'2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' +
|
||||
'3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
|
||||
'4. Focus on variety across genres, authors, and styles\n' +
|
||||
'5. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
|
||||
'6. Learn from rejected books to avoid similar recommendations\n' +
|
||||
'7. Learn from requested books to find similar ones\n' +
|
||||
'8. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
|
||||
'9. Each recommendation should be a NEW book not mentioned anywhere in the user context';
|
||||
|
||||
// Add special instruction for favorites scope
|
||||
if (config.libraryScope === 'favorites') {
|
||||
instructions += '\n\n' +
|
||||
'IMPORTANT: The user has specifically handpicked these ' + libraryBooks.length + ' books as their personal favorites. ' +
|
||||
'These represent their preferred genres, authors, themes, and styles. Use these as PRIMARY INSPIRATION for your recommendations. ' +
|
||||
'Find books that capture the essence of what makes these favorites special to the user.';
|
||||
}
|
||||
|
||||
const prompt = {
|
||||
task: 'recommend_audiobooks',
|
||||
user_context: {
|
||||
@@ -447,18 +536,7 @@ export async function buildAIPrompt(
|
||||
})),
|
||||
custom_preferences: config.customPrompt || null,
|
||||
},
|
||||
instructions:
|
||||
'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' +
|
||||
'CRITICAL RULES:\n' +
|
||||
'1. DO NOT recommend any books already in the user\'s library (check titles carefully)\n' +
|
||||
'2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' +
|
||||
'3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
|
||||
'4. Focus on variety across genres, authors, and styles\n' +
|
||||
'5. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
|
||||
'6. Learn from rejected books to avoid similar recommendations\n' +
|
||||
'7. Learn from requested books to find similar ones\n' +
|
||||
'8. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
|
||||
'9. Each recommendation should be a NEW book not mentioned anywhere in the user context',
|
||||
instructions,
|
||||
};
|
||||
|
||||
const promptString = JSON.stringify(prompt);
|
||||
|
||||
@@ -121,7 +121,7 @@ export class AudibleService {
|
||||
private async fetchWithRetry(
|
||||
url: string,
|
||||
config: any = {},
|
||||
maxRetries: number = 3
|
||||
maxRetries: number = 5
|
||||
): Promise<any> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getLibraryService } from '../services/library';
|
||||
import { getThumbnailCacheService } from '../services/thumbnail-cache.service';
|
||||
|
||||
export interface PlexRecentlyAddedPayload {
|
||||
jobId?: string;
|
||||
@@ -66,6 +67,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
|
||||
// Get library service (automatically selects Plex or Audiobookshelf)
|
||||
const libraryService = await getLibraryService();
|
||||
const thumbnailCacheService = getThumbnailCacheService();
|
||||
|
||||
try {
|
||||
// Get configured library ID
|
||||
@@ -73,6 +75,9 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
? await configService.get('audiobookshelf.library_id')
|
||||
: await configService.get('plex_audiobook_library_id');
|
||||
|
||||
// Get cover caching parameters (needed for thumbnail caching)
|
||||
const coverCachingParams = await (libraryService as any).getCoverCachingParams();
|
||||
|
||||
// Fetch top 10 recently added items using abstraction layer
|
||||
const recentItems = await libraryService.getRecentlyAdded(libraryId!, 10);
|
||||
|
||||
@@ -93,7 +98,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.plexLibrary.create({
|
||||
const newLibraryItem = await prisma.plexLibrary.create({
|
||||
data: {
|
||||
plexGuid: item.externalId,
|
||||
plexRatingKey: item.id,
|
||||
@@ -111,6 +116,26 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
lastScannedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Cache library cover (synchronous with smart skip-if-exists logic)
|
||||
if (item.coverUrl && item.externalId) {
|
||||
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
|
||||
item.externalId,
|
||||
item.coverUrl,
|
||||
coverCachingParams.backendBaseUrl,
|
||||
coverCachingParams.authToken,
|
||||
coverCachingParams.backendMode
|
||||
);
|
||||
|
||||
// Update database with cached path if successful
|
||||
if (cachedPath) {
|
||||
await prisma.plexLibrary.update({
|
||||
where: { id: newLibraryItem.id },
|
||||
data: { cachedLibraryCoverPath: cachedPath },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
newCount++;
|
||||
logger.info(`New item added: ${item.title} by ${item.author}`);
|
||||
} else {
|
||||
@@ -129,6 +154,26 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
lastScannedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Cache library cover (synchronous with smart skip-if-exists logic)
|
||||
if (item.coverUrl && item.externalId) {
|
||||
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
|
||||
item.externalId,
|
||||
item.coverUrl,
|
||||
coverCachingParams.backendBaseUrl,
|
||||
coverCachingParams.authToken,
|
||||
coverCachingParams.backendMode
|
||||
);
|
||||
|
||||
// Update database with cached path if successful
|
||||
if (cachedPath) {
|
||||
await prisma.plexLibrary.update({
|
||||
where: { id: existing.id },
|
||||
data: { cachedLibraryCoverPath: cachedPath },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ScanPlexPayload } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getLibraryService } from '../services/library';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getThumbnailCacheService } from '../services/thumbnail-cache.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
@@ -28,6 +29,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
const libraryService = await getLibraryService();
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
const thumbnailCacheService = getThumbnailCacheService();
|
||||
|
||||
logger.info(`Backend mode: ${backendMode}`);
|
||||
|
||||
@@ -50,6 +52,9 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
// Get cover caching parameters (needed for thumbnail caching)
|
||||
const coverCachingParams = await (libraryService as any).getCoverCachingParams();
|
||||
|
||||
logger.info(`Fetching content from library ${targetLibraryId}`);
|
||||
|
||||
// 3. Get all audiobooks from library using abstraction layer
|
||||
@@ -97,6 +102,25 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
},
|
||||
});
|
||||
|
||||
// Cache library cover (synchronous with smart skip-if-exists logic)
|
||||
if (item.coverUrl && item.externalId) {
|
||||
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
|
||||
item.externalId,
|
||||
item.coverUrl,
|
||||
coverCachingParams.backendBaseUrl,
|
||||
coverCachingParams.authToken,
|
||||
coverCachingParams.backendMode
|
||||
);
|
||||
|
||||
// Update database with cached path if successful
|
||||
if (cachedPath) {
|
||||
await prisma.plexLibrary.update({
|
||||
where: { id: existing.id },
|
||||
data: { cachedLibraryCoverPath: cachedPath },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updatedCount++;
|
||||
} else {
|
||||
// Create new plex_library entry
|
||||
@@ -119,6 +143,25 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
},
|
||||
});
|
||||
|
||||
// Cache library cover (synchronous with smart skip-if-exists logic)
|
||||
if (item.coverUrl && item.externalId) {
|
||||
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
|
||||
item.externalId,
|
||||
item.coverUrl,
|
||||
coverCachingParams.backendBaseUrl,
|
||||
coverCachingParams.authToken,
|
||||
coverCachingParams.backendMode
|
||||
);
|
||||
|
||||
// Update database with cached path if successful
|
||||
if (cachedPath) {
|
||||
await prisma.plexLibrary.update({
|
||||
where: { id: newLibraryItem.id },
|
||||
data: { cachedLibraryCoverPath: cachedPath },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
newCount++;
|
||||
logger.info(`Added new: "${item.title}" by ${item.author}`);
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
triggerABSScan,
|
||||
} from '../audiobookshelf/api';
|
||||
import { ABSLibraryItem } from '../audiobookshelf/types';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
|
||||
export class AudiobookshelfLibraryService implements ILibraryService {
|
||||
private configService = getConfigService();
|
||||
|
||||
async testConnection(): Promise<LibraryConnectionResult> {
|
||||
try {
|
||||
@@ -87,6 +89,34 @@ export class AudiobookshelfLibraryService implements ILibraryService {
|
||||
await triggerABSScan(libraryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parameters needed for caching library covers
|
||||
* @returns Parameters for ThumbnailCacheService.cacheLibraryThumbnail()
|
||||
*/
|
||||
async getCoverCachingParams(): Promise<{
|
||||
backendBaseUrl: string;
|
||||
authToken: string;
|
||||
backendMode: 'plex' | 'audiobookshelf';
|
||||
}> {
|
||||
const config = await this.configService.getMany([
|
||||
'audiobookshelf.server_url',
|
||||
'audiobookshelf.api_token',
|
||||
]);
|
||||
|
||||
const serverUrl = config['audiobookshelf.server_url'];
|
||||
const authToken = config['audiobookshelf.api_token'];
|
||||
|
||||
if (!serverUrl || !authToken) {
|
||||
throw new Error('Audiobookshelf server configuration is incomplete');
|
||||
}
|
||||
|
||||
return {
|
||||
backendBaseUrl: serverUrl,
|
||||
authToken: authToken,
|
||||
backendMode: 'audiobookshelf',
|
||||
};
|
||||
}
|
||||
|
||||
private mapABSItemToLibraryItem(item: ABSLibraryItem): LibraryItem {
|
||||
const metadata = item.media.metadata;
|
||||
return {
|
||||
|
||||
@@ -220,6 +220,28 @@ export class PlexLibraryService implements ILibraryService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parameters needed for caching library covers
|
||||
* @returns Parameters for ThumbnailCacheService.cacheLibraryThumbnail()
|
||||
*/
|
||||
async getCoverCachingParams(): Promise<{
|
||||
backendBaseUrl: string;
|
||||
authToken: string;
|
||||
backendMode: 'plex' | 'audiobookshelf';
|
||||
}> {
|
||||
const config = await this.configService.getPlexConfig();
|
||||
|
||||
if (!config.serverUrl || !config.authToken) {
|
||||
throw new Error('Plex server configuration is incomplete');
|
||||
}
|
||||
|
||||
return {
|
||||
backendBaseUrl: config.serverUrl,
|
||||
authToken: config.authToken,
|
||||
backendMode: 'plex',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Plex audiobook to generic LibraryItem interface
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ import { RMABLogger } from '../utils/logger';
|
||||
const logger = RMABLogger.create('ThumbnailCache');
|
||||
|
||||
const CACHE_DIR = '/app/cache/thumbnails';
|
||||
const LIBRARY_CACHE_DIR = '/app/cache/library';
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB max per image
|
||||
const TIMEOUT_MS = 10000; // 10 second timeout for downloads
|
||||
|
||||
@@ -28,6 +29,18 @@ export class ThumbnailCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure library cache directory exists
|
||||
*/
|
||||
private async ensureLibraryCacheDir(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(LIBRARY_CACHE_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create library cache directory', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique filename for a cached thumbnail
|
||||
* @param asin - Audible ASIN
|
||||
@@ -43,6 +56,28 @@ export class ThumbnailCacheService {
|
||||
return `${asin}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique filename for a library cover using SHA-256 hash
|
||||
* @param plexGuid - Plex/ABS unique identifier (may contain special chars)
|
||||
* @param url - Original URL (used for extension)
|
||||
* @returns Filename for cached library cover
|
||||
*/
|
||||
private generateLibraryFilename(plexGuid: string, url: string): string {
|
||||
// Hash the plexGuid to handle special characters (://, ?, etc.)
|
||||
const hash = crypto.createHash('sha256').update(plexGuid).digest('hex').substring(0, 16);
|
||||
|
||||
// Extract file extension from URL (default to .jpg if not found)
|
||||
let ext = '.jpg';
|
||||
try {
|
||||
const urlPath = new URL(url).pathname;
|
||||
ext = path.extname(urlPath) || '.jpg';
|
||||
} catch {
|
||||
// If URL parsing fails, use default extension
|
||||
}
|
||||
|
||||
return `${hash}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and cache a thumbnail from a URL
|
||||
* @param asin - Audible ASIN
|
||||
@@ -98,6 +133,84 @@ export class ThumbnailCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and cache a library thumbnail from Plex/Audiobookshelf
|
||||
* @param plexGuid - Plex/ABS unique identifier
|
||||
* @param coverUrl - URL of the cover (full URL or relative path)
|
||||
* @param backendBaseUrl - Base URL of backend (Plex or ABS server)
|
||||
* @param authToken - Authentication token
|
||||
* @param backendMode - 'plex' or 'audiobookshelf'
|
||||
* @returns Local file path of cached thumbnail, or null if failed
|
||||
*/
|
||||
async cacheLibraryThumbnail(
|
||||
plexGuid: string,
|
||||
coverUrl: string,
|
||||
backendBaseUrl: string,
|
||||
authToken: string,
|
||||
backendMode: 'plex' | 'audiobookshelf'
|
||||
): Promise<string | null> {
|
||||
if (!coverUrl || !plexGuid || !backendBaseUrl || !authToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureLibraryCacheDir();
|
||||
|
||||
const filename = this.generateLibraryFilename(plexGuid, coverUrl);
|
||||
const filePath = path.join(LIBRARY_CACHE_DIR, filename);
|
||||
|
||||
// Check if file already exists (skip download for subsequent scans)
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
// File exists, return path immediately
|
||||
return filePath;
|
||||
} catch {
|
||||
// File doesn't exist, proceed with download
|
||||
}
|
||||
|
||||
// Construct full URL based on backend mode
|
||||
let fullUrl: string;
|
||||
if (backendMode === 'plex') {
|
||||
// Plex uses token in query string
|
||||
const separator = coverUrl.includes('?') ? '&' : '?';
|
||||
fullUrl = `${backendBaseUrl}${coverUrl}${separator}X-Plex-Token=${authToken}`;
|
||||
} else {
|
||||
// Audiobookshelf uses Authorization header
|
||||
fullUrl = coverUrl.startsWith('http') ? coverUrl : `${backendBaseUrl}${coverUrl}`;
|
||||
}
|
||||
|
||||
// Download image
|
||||
const response = await axios.get(fullUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: TIMEOUT_MS,
|
||||
maxContentLength: MAX_FILE_SIZE,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
...(backendMode === 'audiobookshelf' && { Authorization: `Bearer ${authToken}` }),
|
||||
},
|
||||
});
|
||||
|
||||
// Verify content type is an image
|
||||
const contentType = response.headers['content-type'];
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
logger.warn(`Invalid content type for library cover ${plexGuid}: ${contentType}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(filePath, Buffer.from(response.data));
|
||||
|
||||
logger.info(`Cached library thumbnail for ${plexGuid}: ${filePath}`);
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
// Log error but don't throw - graceful degradation
|
||||
logger.warn(`Failed to cache library thumbnail for ${plexGuid}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cached thumbnail
|
||||
* @param asin - Audible ASIN
|
||||
@@ -150,6 +263,49 @@ export class ThumbnailCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up library thumbnails that are no longer referenced in the database
|
||||
* @param plexGuidToHashMap - Map of plexGuid to hash (for reverse lookup)
|
||||
* @returns Number of deleted files
|
||||
*/
|
||||
async cleanupLibraryThumbnails(plexGuidToHashMap: Map<string, string>): Promise<number> {
|
||||
try {
|
||||
await this.ensureLibraryCacheDir();
|
||||
|
||||
const files = await fs.readdir(LIBRARY_CACHE_DIR);
|
||||
let deletedCount = 0;
|
||||
|
||||
// Build reverse map: hash -> plexGuid
|
||||
const activeHashes = new Set<string>();
|
||||
for (const [plexGuid] of plexGuidToHashMap) {
|
||||
// Generate hash for each plexGuid (consistent with generateLibraryFilename)
|
||||
const hash = crypto.createHash('sha256').update(plexGuid).digest('hex').substring(0, 16);
|
||||
activeHashes.add(hash);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
// Extract hash from filename (remove extension)
|
||||
const hash = path.parse(file).name;
|
||||
|
||||
// If hash is not in active set, delete the file
|
||||
if (!activeHashes.has(hash)) {
|
||||
const filePath = path.join(LIBRARY_CACHE_DIR, file);
|
||||
await fs.unlink(filePath);
|
||||
deletedCount++;
|
||||
logger.info(`Deleted unused library thumbnail: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Library cleanup complete: ${deletedCount} thumbnails deleted`);
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup library thumbnails', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached path for a thumbnail
|
||||
* @param cachedPath - Path from database
|
||||
|
||||
Reference in New Issue
Block a user