Skip to content

Commit

Permalink
Merge pull request #3015 from metabrainz/ansh/cache-caa-calls
Browse files Browse the repository at this point in the history
feat: Cache Cover Art Calls
  • Loading branch information
MonkeyDo authored Nov 19, 2024
2 parents 537e7f6 + 57894b2 commit 63ae751
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 3 deletions.
105 changes: 105 additions & 0 deletions frontend/js/src/utils/coverArtCache.ts
Original file line number Diff line number Diff line change
@@ -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<number>(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<string | null> => {
if (!key) {
console.error("Invalid key provided to getCoverArtCache");
return null;
}

try {
// Check if the entry has expired
const expiry = await coverArtCacheExpiry.getItem<number>(key);
if (!expiry || expiry < Date.now()) {
await Promise.allSettled([
coverArtCache.removeItem(key),
coverArtCacheExpiry.removeItem(key),
]);
return null;
}
return await coverArtCache.getItem<string>(key);
} catch (error) {
console.error("Error getting cover art cache:", error);
return null;
}
};

export { setCoverArtCache, getCoverArtCache };
36 changes: 33 additions & 3 deletions frontend/js/src/utils/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -756,13 +757,23 @@ const getAlbumArtFromReleaseGroupMBID = async (
optionalSize?: CAAThumbnailSizes
): Promise<string | undefined> => {
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
Expand All @@ -781,13 +792,25 @@ const getAlbumArtFromReleaseMBID = async (
optionalSize?: CAAThumbnailSizes
): Promise<string | undefined> => {
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) {
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions frontend/js/tests/__mocks__/localforage.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ module.exports = {
},
moduleNameMapper: {
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
'^localforage$': '<rootDir>/frontend/js/tests/__mocks__/localforage.ts'
},
transform: {
"\\.[jt]sx?$": "ts-jest",
Expand Down
22 changes: 22 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit 63ae751

Please sign in to comment.