diff --git a/frontend/js/src/utils/coverArtCache.ts b/frontend/js/src/utils/coverArtCache.ts new file mode 100644 index 0000000000..c88caed02c --- /dev/null +++ b/frontend/js/src/utils/coverArtCache.ts @@ -0,0 +1,105 @@ +/* eslint-disable no-console */ +import localforage from "localforage"; + +// Initialize IndexedDB +const coverArtCache = localforage.createInstance({ + name: "listenbrainz", + driver: [localforage.INDEXEDDB, localforage.LOCALSTORAGE], + storeName: "coverart", +}); + +const coverArtCacheExpiry = localforage.createInstance({ + name: "listenbrainz", + driver: [localforage.INDEXEDDB, localforage.LOCALSTORAGE], + storeName: "coverart-expiry", +}); + +const DEFAULT_CACHE_TTL = 1000 * 60 * 60 * 24 * 7; // 7 days + +/** + * Removes all expired entries from both the cover art cache and expiry cache + * This helps keep the cache size manageable by cleaning up old entries + */ +const removeAllExpiredCacheEntries = async () => { + try { + const keys = await coverArtCacheExpiry.keys(); + // Check each key to see if it's expired2 + const expiredKeys = await Promise.all( + keys.map(async (key) => { + try { + const expiry = await coverArtCacheExpiry.getItem(key); + return expiry && expiry < Date.now() ? key : null; + } catch { + // If we can't read the expiry time, treat the entry as expired + return key; + } + }) + ); + + // Filter out null values and remove expired entries from both caches + const keysToRemove = expiredKeys.filter( + (key): key is string => key !== null + ); + await Promise.allSettled([ + ...keysToRemove.map((key) => coverArtCache.removeItem(key)), + ...keysToRemove.map((key) => coverArtCacheExpiry.removeItem(key)), + ]); + } catch (error) { + console.error("Error removing expired cache entries:", error); + } +}; + +/** + * Stores a cover art URL in the cache with an expiration time + * @param key - Unique identifier for the cover art + * @param value - The URL or data URI of the cover art + */ +const setCoverArtCache = async (key: string, value: string) => { + // Validate inputs to prevent storing invalid data + if (!key || !value) { + console.error("Invalid key or value provided to setCoverArtCache"); + return; + } + + try { + // Store both the cover art and its expiration time simultaneously + await Promise.allSettled([ + coverArtCache.setItem(key, value), + coverArtCacheExpiry.setItem(key, Date.now() + DEFAULT_CACHE_TTL), + ]); + // Run cleanup in background to avoid blocking the main operation + removeAllExpiredCacheEntries().catch(console.error); + } catch (error) { + console.error("Error setting cover art cache:", error); + } +}; + +/** + * Retrieves a cover art URL from the cache if it exists and hasn't expired + * @param key - Unique identifier for the cover art + * @returns The cached cover art URL/data URI, or null if not found/expired + */ +const getCoverArtCache = async (key: string): Promise => { + if (!key) { + console.error("Invalid key provided to getCoverArtCache"); + return null; + } + + try { + // Check if the entry has expired + const expiry = await coverArtCacheExpiry.getItem(key); + if (!expiry || expiry < Date.now()) { + await Promise.allSettled([ + coverArtCache.removeItem(key), + coverArtCacheExpiry.removeItem(key), + ]); + return null; + } + return await coverArtCache.getItem(key); + } catch (error) { + console.error("Error getting cover art cache:", error); + return null; + } +}; + +export { setCoverArtCache, getCoverArtCache }; diff --git a/frontend/js/src/utils/utils.tsx b/frontend/js/src/utils/utils.tsx index 45dc2b441e..f3721e5d9c 100644 --- a/frontend/js/src/utils/utils.tsx +++ b/frontend/js/src/utils/utils.tsx @@ -13,6 +13,7 @@ import { GlobalAppContextT } from "./GlobalAppContext"; import APIServiceClass from "./APIService"; import { ToastMsg } from "../notifications/Notifications"; import RecordingFeedbackManager from "./RecordingFeedbackManager"; +import { getCoverArtCache, setCoverArtCache } from "./coverArtCache"; const originalFetch = window.fetch; const fetchWithRetry = require("fetch-retry")(originalFetch); @@ -756,13 +757,23 @@ const getAlbumArtFromReleaseGroupMBID = async ( optionalSize?: CAAThumbnailSizes ): Promise => { try { + const cacheKey = `rag:${releaseGroupMBID}-${optionalSize}`; + const cachedCoverArt = await getCoverArtCache(cacheKey); + if (cachedCoverArt) { + return cachedCoverArt; + } const CAAResponse = await fetchWithRetry( `https://coverartarchive.org/release-group/${releaseGroupMBID}`, retryParams ); if (CAAResponse.ok) { const body: CoverArtArchiveResponse = await CAAResponse.json(); - return getThumbnailFromCAAResponse(body, optionalSize); + const coverArt = getThumbnailFromCAAResponse(body, optionalSize); + if (coverArt) { + // Cache the successful result + await setCoverArtCache(cacheKey, coverArt); + } + return coverArt; } } catch (error) { // eslint-disable-next-line no-console @@ -781,13 +792,25 @@ const getAlbumArtFromReleaseMBID = async ( optionalSize?: CAAThumbnailSizes ): Promise => { try { + // Check cache first + const cacheKey = `ca:${userSubmittedReleaseMBID}-${optionalSize}-${useReleaseGroupFallback}`; + const cachedCoverArt = await getCoverArtCache(cacheKey); + if (cachedCoverArt) { + return cachedCoverArt; + } + const CAAResponse = await fetchWithRetry( `https://coverartarchive.org/release/${userSubmittedReleaseMBID}`, retryParams ); if (CAAResponse.ok) { const body: CoverArtArchiveResponse = await CAAResponse.json(); - return getThumbnailFromCAAResponse(body, optionalSize); + const coverArt = getThumbnailFromCAAResponse(body, optionalSize); + if (coverArt) { + // Cache the successful result + await setCoverArtCache(cacheKey, coverArt); + } + return coverArt; } if (CAAResponse.status === 404 && useReleaseGroupFallback) { @@ -802,7 +825,14 @@ const getAlbumArtFromReleaseMBID = async ( return undefined; } - return await getAlbumArtFromReleaseGroupMBID(releaseGroupMBID); + const fallbackCoverArt = await getAlbumArtFromReleaseGroupMBID( + releaseGroupMBID + ); + if (fallbackCoverArt) { + // Cache the fallback result + await setCoverArtCache(cacheKey, fallbackCoverArt); + } + return fallbackCoverArt; } } catch (error) { // eslint-disable-next-line no-console diff --git a/frontend/js/tests/__mocks__/localforage.ts b/frontend/js/tests/__mocks__/localforage.ts new file mode 100644 index 0000000000..cb6e7e6a6f --- /dev/null +++ b/frontend/js/tests/__mocks__/localforage.ts @@ -0,0 +1,10 @@ +const localforageMock = { + createInstance: jest.fn(() => ({ + setItem: jest.fn(), + getItem: jest.fn(), + removeItem: jest.fn(), + keys: jest.fn().mockResolvedValue([]), + })), +}; + +export default localforageMock; diff --git a/jest.config.js b/jest.config.js index 68c47cea62..ab722f1104 100644 --- a/jest.config.js +++ b/jest.config.js @@ -40,6 +40,7 @@ module.exports = { }, moduleNameMapper: { 'react-markdown': '/node_modules/react-markdown/react-markdown.min.js', + '^localforage$': '/frontend/js/tests/__mocks__/localforage.ts' }, transform: { "\\.[jt]sx?$": "ts-jest", diff --git a/package-lock.json b/package-lock.json index 79a0374f63..292ad4511f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "jest-mock": "^25.2.3", "less": "^4.1.1", "less-plugin-clean-css": "^1.5.1", + "localforage": "^1.10.0", "lodash": "^4.17.21", "panzoom": "^9.4.3", "rc-slider": "^10.1.0", @@ -9915,6 +9916,11 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/import-fresh": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", @@ -12979,6 +12985,14 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", @@ -12998,6 +13012,14 @@ "node": ">=6.11.5" } }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "dependencies": { + "lie": "3.1.1" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", diff --git a/package.json b/package.json index 1eb2de6116..4dd4e39fa5 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "jest-mock": "^25.2.3", "less": "^4.1.1", "less-plugin-clean-css": "^1.5.1", + "localforage": "^1.10.0", "lodash": "^4.17.21", "panzoom": "^9.4.3", "rc-slider": "^10.1.0",