diff --git a/src/lib/server/cache-manager.ts b/src/lib/server/cache-manager.ts new file mode 100644 index 0000000..2ef1458 --- /dev/null +++ b/src/lib/server/cache-manager.ts @@ -0,0 +1,165 @@ +import redis from '../../routes/api/redis-client' +import { logger } from './logger' + +export interface CacheConfig { + prefix: string + defaultTTL: number + description: string +} + +export class CacheManager { + private static cacheTypes: Map = new Map([ + ['lastfm-recent', { prefix: 'lastfm:recent:', defaultTTL: 30, description: 'Last.fm recent tracks' }], + ['lastfm-album', { prefix: 'lastfm:albuminfo:', defaultTTL: 3600, description: 'Last.fm album info' }], + ['apple-album', { prefix: 'apple:album:', defaultTTL: 86400, description: 'Apple Music album data' }], + ['apple-notfound', { prefix: 'notfound:apple-music:', defaultTTL: 3600, description: 'Apple Music not found records' }], + ['apple-failure', { prefix: 'failure:apple-music:', defaultTTL: 86400, description: 'Apple Music API failures' }], + ['apple-ratelimit', { prefix: 'ratelimit:apple-music:', defaultTTL: 3600, description: 'Apple Music rate limit state' }] + ]) + + /** + * Get a value from cache + */ + static async get(type: string, key: string): Promise { + const config = this.cacheTypes.get(type) + if (!config) { + logger.error(`Unknown cache type: ${type}`) + return null + } + + const fullKey = `${config.prefix}${key}` + return await redis.get(fullKey) + } + + /** + * Set a value in cache + */ + static async set(type: string, key: string, value: string, ttl?: number): Promise { + const config = this.cacheTypes.get(type) + if (!config) { + logger.error(`Unknown cache type: ${type}`) + return + } + + const fullKey = `${config.prefix}${key}` + const expiry = ttl || config.defaultTTL + + await redis.set(fullKey, value, 'EX', expiry) + logger.music('debug', `Cached ${type} for key: ${key} (TTL: ${expiry}s)`) + } + + /** + * Delete a specific cache entry + */ + static async delete(type: string, key: string): Promise { + const config = this.cacheTypes.get(type) + if (!config) { + logger.error(`Unknown cache type: ${type}`) + return false + } + + const fullKey = `${config.prefix}${key}` + const deleted = await redis.del(fullKey) + return deleted > 0 + } + + /** + * Clear all entries for a specific cache type + */ + static async clearType(type: string): Promise { + const config = this.cacheTypes.get(type) + if (!config) { + logger.error(`Unknown cache type: ${type}`) + return 0 + } + + const pattern = `${config.prefix}*` + const keys = await redis.keys(pattern) + + if (keys.length === 0) return 0 + + const deleted = await redis.del(...keys) + logger.music('info', `Cleared ${deleted} entries from ${type} cache`) + return deleted + } + + /** + * Clear all entries matching a pattern within a cache type + */ + static async clearPattern(type: string, pattern: string): Promise { + const config = this.cacheTypes.get(type) + if (!config) { + logger.error(`Unknown cache type: ${type}`) + return 0 + } + + const searchPattern = `${config.prefix}*${pattern}*` + const keys = await redis.keys(searchPattern) + + if (keys.length === 0) return 0 + + const deleted = await redis.del(...keys) + logger.music('info', `Cleared ${deleted} entries matching "${pattern}" from ${type} cache`) + return deleted + } + + /** + * Clear all caches for a specific album + */ + static async clearAlbum(artist: string, album: string): Promise { + const albumKey = `${artist}:${album}` + let totalDeleted = 0 + + // Clear all cache types that might contain this album + for (const [type, config] of this.cacheTypes) { + if (type.includes('album') || type.includes('notfound')) { + const deleted = await this.clearPattern(type, albumKey) + totalDeleted += deleted + } + } + + logger.music('info', `Cleared ${totalDeleted} cache entries for album "${album}" by "${artist}"`) + return totalDeleted + } + + /** + * Get all cache types and their info + */ + static getCacheTypes(): Array<{ type: string; config: CacheConfig }> { + return Array.from(this.cacheTypes.entries()).map(([type, config]) => ({ type, config })) + } + + /** + * Get cache statistics + */ + static async getStats(): Promise> { + const stats = [] + + for (const [type, config] of this.cacheTypes) { + const keys = await redis.keys(`${config.prefix}*`) + stats.push({ + type, + count: keys.length, + description: config.description + }) + } + + return stats + } +} + +// Export convenience functions for common operations +export const cache = { + lastfm: { + getRecent: (username: string) => CacheManager.get('lastfm-recent', username), + setRecent: (username: string, data: string) => CacheManager.set('lastfm-recent', username, data), + getAlbum: (artist: string, album: string) => CacheManager.get('lastfm-album', `${artist}:${album}`), + setAlbum: (artist: string, album: string, data: string) => CacheManager.set('lastfm-album', `${artist}:${album}`, data) + }, + apple: { + getAlbum: (artist: string, album: string) => CacheManager.get('apple-album', `${artist}:${album}`), + setAlbum: (artist: string, album: string, data: string, ttl?: number) => CacheManager.set('apple-album', `${artist}:${album}`, data, ttl), + isNotFound: (artist: string, album: string) => CacheManager.get('apple-notfound', `${artist}:${album}`), + markNotFound: (artist: string, album: string, ttl?: number) => CacheManager.set('apple-notfound', `${artist}:${album}`, '1', ttl) + } +} \ No newline at end of file