Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/background/fetchJson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** Fetch JSON with abort timeout; non-OK / network / parse failures return null. */

export async function fetchJsonWithTimeout<T>(
url: string,
timeoutMs: number,
): Promise<T | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
47 changes: 9 additions & 38 deletions src/background/googleBooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ import type {
import { isValidIsbn, normalizeIsbn, toIsbn13 } from '@/shared/isbn';
import type { BookSearchResult } from './openLibrary';
import { getPreferences } from './storage';
import { BoundedTtlCache } from './ttlCache';
import { fetchJsonWithTimeout } from './fetchJson';

const GB_BASE = 'https://www.googleapis.com/books/v1/volumes';
const REQUEST_TIMEOUT_MS = 12_000;
const CACHE_TTL_MS = 1000 * 60 * 60 * 6; // 6 hours

const CACHE = new Map<string, { data: unknown; timestamp: number }>();
export const MAX_CACHE_SIZE = 500;
const cache = new BoundedTtlCache<unknown>(MAX_CACHE_SIZE, CACHE_TTL_MS);

let googleBooksApiKey: string | null = null;

Expand Down Expand Up @@ -97,36 +100,20 @@ interface GbVolumesResponse {
// ─── Cache ───────────────────────────────────────────────────────────

function cacheGet<T>(key: string): T | undefined {
const entry = CACHE.get(key);
if (!entry) return undefined;
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
CACHE.delete(key);
return undefined;
}
return entry.data as T;
return cache.get(key) as T | undefined;
}

export const MAX_CACHE_SIZE = 500;

function cacheSet(key: string, data: unknown): void {
if (CACHE.has(key)) {
CACHE.delete(key);
} else if (CACHE.size >= MAX_CACHE_SIZE) {
const oldestKey = CACHE.keys().next().value;
if (oldestKey !== undefined) {
CACHE.delete(oldestKey);
}
}
CACHE.set(key, { data, timestamp: Date.now() });
cache.set(key, data);
}

/** Test helper — clears in-memory cache. */
export function clearGoogleBooksCache(): void {
CACHE.clear();
cache.clear();
}

export function getGoogleBooksCacheSizeForTesting(): number {
return CACHE.size;
return cache.size;
}

export function setGoogleBooksCacheEntryForTesting(key: string, data: unknown): void {
Expand Down Expand Up @@ -159,23 +146,7 @@ function volumeSourceUrl(volumeId: string): string {
// ─── Fetch with timeout ──────────────────────────────────────────────

async function fetchJson<T>(url: string): Promise<T | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
// Unauthenticated quota / forbidden — fail gracefully
if (res.status === 403 || res.status === 401) return null;
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
} finally {
clearTimeout(timer);
}
return fetchJsonWithTimeout<T>(url, REQUEST_TIMEOUT_MS);
}

function buildVolumesUrl(params: Record<string, string | number>): string {
Expand Down
64 changes: 22 additions & 42 deletions src/background/handlers/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
CheckLibraryStatusRequest,
CheckLibraryStatusResponse,
LibraryItem,
BroadcastMessage,
RatingHistoryEntry,
} from '@/shared/types';
import { v4 as uuidv4 } from 'uuid';
import type { Reflection } from '@/shared/catalogTypes';
Expand All @@ -37,7 +39,20 @@ import { isSafeNavMediaId } from '@/shared/mediaIds';
import { invalidateProfileCache } from '../context';
import { mergeMediaItems } from '../mediaMerge';
import { broadcastMessage, parseSetUserNotesRequest, parseUpdateStatusRequest } from './utils';
import type { RatingHistoryEntry } from '@/shared/types';

async function notifyLibraryUpdated(
action: Extract<BroadcastMessage, { type: 'LIBRARY_UPDATED' }>['action'],
mediaId: string,
libraryItem?: LibraryItem,
): Promise<void> {
invalidateProfileCache();
await broadcastMessage({
type: 'LIBRARY_UPDATED',
action,
mediaId,
...(libraryItem ? { libraryItem } : {}),
});
}

/** Max entries kept on `LibraryItem.ratingHistory`. */
export const RATING_HISTORY_CAP = 50;
Expand Down Expand Up @@ -132,13 +147,7 @@ export const libraryHandlers: MessageHandlerMap = {
await onNewLibraryItemCreated();
}

invalidateProfileCache();
await broadcastMessage({
type: 'LIBRARY_UPDATED',
action: 'add',
mediaId: libraryItem.mediaId,
libraryItem,
});
await notifyLibraryUpdated('add', libraryItem.mediaId, libraryItem);
return libraryItem;
},

Expand All @@ -156,13 +165,7 @@ export const libraryHandlers: MessageHandlerMap = {
existing.sanctuaryIntent = intentForStatus(req.status);
existing.updatedAt = Date.now();
await putLibraryItem(existing);
invalidateProfileCache();
await broadcastMessage({
type: 'LIBRARY_UPDATED',
action: 'update',
mediaId: existing.mediaId,
libraryItem: existing,
});
await notifyLibraryUpdated('update', existing.mediaId, existing);
return { updated: true };
},

Expand All @@ -181,13 +184,7 @@ export const libraryHandlers: MessageHandlerMap = {
existing.userRating = req.rating;
existing.updatedAt = Date.now();
await putLibraryItem(existing);
invalidateProfileCache();
await broadcastMessage({
type: 'LIBRARY_UPDATED',
action: 'update',
mediaId: existing.mediaId,
libraryItem: existing,
});
await notifyLibraryUpdated('update', existing.mediaId, existing);
return { updated: true };
},

Expand All @@ -201,13 +198,7 @@ export const libraryHandlers: MessageHandlerMap = {
existing.userTags = req.tags;
existing.updatedAt = Date.now();
await putLibraryItem(existing);
invalidateProfileCache();
await broadcastMessage({
type: 'LIBRARY_UPDATED',
action: 'update',
mediaId: existing.mediaId,
libraryItem: existing,
});
await notifyLibraryUpdated('update', existing.mediaId, existing);
return { updated: true };
},

Expand Down Expand Up @@ -246,13 +237,7 @@ export const libraryHandlers: MessageHandlerMap = {
logger.warn('[Subsume] SET_USER_NOTES reflection append failed:', err);
}

invalidateProfileCache();
await broadcastMessage({
type: 'LIBRARY_UPDATED',
action: 'update',
mediaId: existing.mediaId,
libraryItem: existing,
});
await notifyLibraryUpdated('update', existing.mediaId, existing);
return { updated: true };
},

Expand All @@ -263,12 +248,7 @@ export const libraryHandlers: MessageHandlerMap = {
throw new Error('Invalid mediaId');
}
await removeLibraryItem(req.mediaId);
invalidateProfileCache();
await broadcastMessage({
type: 'LIBRARY_UPDATED',
action: 'remove',
mediaId: req.mediaId,
});
await notifyLibraryUpdated('remove', req.mediaId);
return { removed: true };
},

Expand Down
31 changes: 10 additions & 21 deletions src/background/omdb.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,23 @@
import { MediaRating } from '@/shared/types';
import { getPreferences } from './storage';
import { BoundedTtlCache } from './ttlCache';

const BASE_URL = 'https://www.omdbapi.com/';

const CACHE = new Map<string, { data: MediaRating[]; timestamp: number }>();
const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
export const MAX_CACHE_SIZE = 500;

function setCacheEntry(key: string, data: MediaRating[]): void {
if (CACHE.has(key)) {
CACHE.delete(key);
} else if (CACHE.size >= MAX_CACHE_SIZE) {
const oldestKey = CACHE.keys().next().value;
if (oldestKey !== undefined) {
CACHE.delete(oldestKey);
}
}
CACHE.set(key, { data, timestamp: Date.now() });
}
const cache = new BoundedTtlCache<MediaRating[]>(MAX_CACHE_SIZE, CACHE_TTL);

export function clearOmdbCache(): void {
CACHE.clear();
cache.clear();
}

export function getOmdbCacheSizeForTesting(): number {
return CACHE.size;
return cache.size;
}

export function setOmdbCacheEntryForTesting(key: string, data: MediaRating[]): void {
setCacheEntry(key, data);
cache.set(key, data);
}

let omdbApiKey: string | null = null;
Expand Down Expand Up @@ -92,9 +81,9 @@ export async function fetchOmdbRatings(
}

const cacheKey = `omdb_${title}_${year}_${type}`;
const cached = CACHE.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
const cached = cache.get(cacheKey);
if (cached !== undefined) {
return cached;
}

const omdbType = type === 'tv' ? 'series' : 'movie';
Expand All @@ -112,7 +101,7 @@ export async function fetchOmdbRatings(
const isTransientError =
error.includes('Invalid API key') || error.toLowerCase().includes('limit');
if (!isTransientError) {
setCacheEntry(cacheKey, []);
cache.set(cacheKey, []);
}
return [];
}
Expand All @@ -137,7 +126,7 @@ export async function fetchOmdbRatings(
}
}

setCacheEntry(cacheKey, ratings);
cache.set(cacheKey, ratings);
return ratings;
} catch (err) {
console.error('[Subsume OMDb] Fetch failed', err);
Expand Down
34 changes: 8 additions & 26 deletions src/background/openLibrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import type {
SourceProvenance,
} from '@/shared/catalogTypes';
import { isValidIsbn, normalizeIsbn, toIsbn13 } from '@/shared/isbn';
import { BoundedTtlCache } from './ttlCache';
import { fetchJsonWithTimeout } from './fetchJson';

const OL_BASE = 'https://openlibrary.org';
const COVERS_BASE = 'https://covers.openlibrary.org';
const REQUEST_TIMEOUT_MS = 12_000;
const CACHE_TTL_MS = 1000 * 60 * 60 * 6; // 6 hours

const CACHE = new Map<string, { data: unknown; timestamp: number }>();
export const MAX_CACHE_SIZE = 500;
const cache = new BoundedTtlCache<unknown>(MAX_CACHE_SIZE, CACHE_TTL_MS);

export interface BookSearchQuery {
query: string;
Expand Down Expand Up @@ -147,22 +150,16 @@ interface OlAuthorWorksResponse {
// ─── Cache ──────────────────────────────────────────────────────────────────

function cacheGet<T>(key: string): T | undefined {
const entry = CACHE.get(key);
if (!entry) return undefined;
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
CACHE.delete(key);
return undefined;
}
return entry.data as T;
return cache.get(key) as T | undefined;
}

function cacheSet(key: string, data: unknown): void {
CACHE.set(key, { data, timestamp: Date.now() });
cache.set(key, data);
}

/** Test helper — clears in-memory cache. */
export function clearOpenLibraryCache(): void {
CACHE.clear();
cache.clear();
}

// ─── ID helpers ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -262,22 +259,7 @@ export function coverUrlFromIsbn(isbn: string, size: 'S' | 'M' | 'L' = 'L'): str
// ─── Fetch with timeout ─────────────────────────────────────────────────────

async function fetchJson<T>(url: string): Promise<T | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
// AbortError, network failure, parse error — graceful empty
return null;
} finally {
clearTimeout(timer);
}
return fetchJsonWithTimeout<T>(url, REQUEST_TIMEOUT_MS);
}

// ─── Mapping helpers ────────────────────────────────────────────────────────
Expand Down
Loading
Loading