From c2f7c1fcfdee167650b76e7a78b1260985030e17 Mon Sep 17 00:00:00 2001 From: Bradley Shellnutt Date: Tue, 12 May 2026 22:47:48 -0700 Subject: [PATCH] Deepen currently listening module --- src/lib/server/currentlyListening.test.ts | 102 +++++++++++++++ src/lib/server/currentlyListening.ts | 71 ++++++++++ src/routes/api/bandcamp/albums/+server.ts | 61 +-------- src/routes/api/bandcamp/albums/server.test.ts | 122 +++++------------- 4 files changed, 210 insertions(+), 146 deletions(-) create mode 100644 src/lib/server/currentlyListening.test.ts create mode 100644 src/lib/server/currentlyListening.ts diff --git a/src/lib/server/currentlyListening.test.ts b/src/lib/server/currentlyListening.test.ts new file mode 100644 index 0000000..3d4a2e0 --- /dev/null +++ b/src/lib/server/currentlyListening.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const redisGetMock = vi.fn(); +const redisTtlMock = vi.fn(); +const redisSetWithExpiryMock = vi.fn(); +const scrapeItMock = vi.fn(); + +vi.mock('$lib/server/redis', () => ({ + redisService: { + get: (data: unknown) => redisGetMock(data), + ttl: (data: unknown) => redisTtlMock(data), + setWithExpiry: (data: unknown) => redisSetWithExpiryMock(data), + }, + REDIS_PREFIXES: { + ARTICLES: 'articles', + BANDCAMP_ALBUMS: 'bandcampAlbums', + PAGE_CACHE: 'pageCache', + }, +})); + +vi.mock('scrape-it', () => ({ default: (...args: unknown[]) => scrapeItMock(...args) })); + +vi.mock('$lib/util/retry', () => ({ + retryWithBackoff: (fn: () => Promise) => fn(), +})); + +import { getCurrentlyListeningAlbums } from './currentlyListening'; + +const makeAlbum = () => ({ + url: 'https://bandcamp.com/album/123', + artwork: 'https://img.bandcamp.com/art.jpg', + title: 'Test Album', + artist: 'Test Artist', +}); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('getCurrentlyListeningAlbums', () => { + it('returns cached albums with TTL cache metadata', async () => { + const cached = [makeAlbum()]; + redisGetMock.mockResolvedValueOnce(JSON.stringify(cached)); + redisTtlMock.mockResolvedValueOnce(3600); + + const result = await getCurrentlyListeningAlbums(); + + expect(result).toEqual({ albums: cached, cacheMaxAge: 3600 }); + expect(redisGetMock).toHaveBeenCalledWith({ prefix: 'bandcampAlbums', key: 'albums' }); + expect(redisTtlMock).toHaveBeenCalledWith({ prefix: 'bandcampAlbums', key: 'albums' }); + expect(scrapeItMock).not.toHaveBeenCalled(); + }); + + it('returns cached albums with fallback cache metadata when TTL is unavailable', async () => { + const cached = [makeAlbum()]; + redisGetMock.mockResolvedValueOnce(JSON.stringify(cached)); + redisTtlMock.mockResolvedValueOnce(0); + + const result = await getCurrentlyListeningAlbums(); + + expect(result).toEqual({ albums: cached, cacheMaxAge: 43200 }); + expect(scrapeItMock).not.toHaveBeenCalled(); + }); + + it('scrapes, caches, and returns albums with default cache metadata on cache miss', async () => { + const scraped = [makeAlbum()]; + redisGetMock.mockResolvedValueOnce(null); + scrapeItMock.mockResolvedValueOnce({ data: { collectionItems: scraped } }); + + const result = await getCurrentlyListeningAlbums(); + + expect(result).toEqual({ albums: scraped, cacheMaxAge: 43200 }); + expect(scrapeItMock).toHaveBeenCalledWith('https://bandcamp.com/test-user', expect.any(Object)); + expect(redisSetWithExpiryMock).toHaveBeenCalledWith({ + prefix: 'bandcampAlbums', + key: 'albums', + value: JSON.stringify(scraped), + expiry: 43200, + }); + }); + + it('returns empty albums without cache metadata when scrape returns no items', async () => { + redisGetMock.mockResolvedValueOnce(null); + scrapeItMock.mockResolvedValueOnce({ data: { collectionItems: [] } }); + + const result = await getCurrentlyListeningAlbums(); + + expect(result).toEqual({ albums: [], cacheMaxAge: null }); + expect(redisSetWithExpiryMock).not.toHaveBeenCalled(); + }); + + it('returns empty albums without cache metadata when scrape fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + redisGetMock.mockResolvedValueOnce(null); + scrapeItMock.mockRejectedValueOnce(new Error('scrape failed')); + + const result = await getCurrentlyListeningAlbums(); + + expect(result).toEqual({ albums: [], cacheMaxAge: null }); + expect(redisSetWithExpiryMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/currentlyListening.ts b/src/lib/server/currentlyListening.ts new file mode 100644 index 0000000..053e2f6 --- /dev/null +++ b/src/lib/server/currentlyListening.ts @@ -0,0 +1,71 @@ +import scrapeIt, { type ScrapeResult } from 'scrape-it'; +import { ENV } from 'varlock/env'; +import { REDIS_PREFIXES, redisService } from '$lib/server/redis'; +import type { Album, BandCampResults } from '$lib/types/album'; +import { retryWithBackoff } from '$lib/util/retry'; + +const BANDCAMP_ALBUMS_CACHE_KEY = 'albums'; +const BANDCAMP_ALBUMS_CACHE_MAX_AGE = 43200; + +export type CurrentlyListeningAlbumsResult = { + albums: Album[]; + cacheMaxAge: number | null; +}; + +export async function getCurrentlyListeningAlbums(): Promise { + try { + const cached = await getCachedAlbums(); + if (cached) return cached; + + const albums = await scrapeCurrentlyListeningAlbums(); + if (albums.length === 0) return { albums: [], cacheMaxAge: null }; + + await cacheAlbums(albums); + return { albums, cacheMaxAge: BANDCAMP_ALBUMS_CACHE_MAX_AGE }; + } catch (error) { + console.error(error); + return { albums: [], cacheMaxAge: null }; + } +} + +async function getCachedAlbums(): Promise { + if (!ENV.USE_REDIS_CACHE) return null; + + const cached = await redisService.get({ prefix: REDIS_PREFIXES.BANDCAMP_ALBUMS, key: BANDCAMP_ALBUMS_CACHE_KEY }); + if (!cached) return null; + + const albums: Album[] = JSON.parse(cached); + const ttl = await redisService.ttl({ prefix: REDIS_PREFIXES.BANDCAMP_ALBUMS, key: BANDCAMP_ALBUMS_CACHE_KEY }); + return { albums, cacheMaxAge: ttl || BANDCAMP_ALBUMS_CACHE_MAX_AGE }; +} + +async function scrapeCurrentlyListeningAlbums(): Promise { + // Scrape Bandcamp with realistic headers, plus retry/backoff + const { data }: ScrapeResult = await retryWithBackoff( + async () => + await scrapeIt(`https://bandcamp.com/${ENV.BANDCAMP_USERNAME}`, { + collectionItems: { + listItem: '.collection-item-container', + data: { + url: { selector: '.collection-title-details > a.item-link', attr: 'href' }, + artwork: { selector: 'div.collection-item-art-container a img', attr: 'src' }, + title: { selector: 'span.item-link-alt > div.collection-item-title' }, + artist: { selector: 'span.item-link-alt > div.collection-item-artist' }, + }, + }, + }), + ); + + return data?.collectionItems || []; +} + +async function cacheAlbums(albums: Album[]): Promise { + if (!ENV.USE_REDIS_CACHE) return; + + await redisService.setWithExpiry({ + prefix: REDIS_PREFIXES.BANDCAMP_ALBUMS, + key: BANDCAMP_ALBUMS_CACHE_KEY, + value: JSON.stringify(albums), + expiry: BANDCAMP_ALBUMS_CACHE_MAX_AGE, + }); +} diff --git a/src/routes/api/bandcamp/albums/+server.ts b/src/routes/api/bandcamp/albums/+server.ts index ba57c56..bc91119 100644 --- a/src/routes/api/bandcamp/albums/+server.ts +++ b/src/routes/api/bandcamp/albums/+server.ts @@ -1,60 +1,13 @@ import { json, type RequestEvent } from '@sveltejs/kit'; -import scrapeIt, { type ScrapeResult } from 'scrape-it'; -import { ENV } from 'varlock/env'; -import { REDIS_PREFIXES, redisService } from '$lib/server/redis'; -import type { Album, BandCampResults } from '$lib/types/album'; -import { retryWithBackoff } from '$lib/util/retry'; +import { getCurrentlyListeningAlbums } from '$lib/server/currentlyListening'; export async function GET(event: RequestEvent) { - const { setHeaders } = event; + const { setHeaders } = event; + const { albums, cacheMaxAge } = await getCurrentlyListeningAlbums(); - try { - if (ENV.USE_REDIS_CACHE) { - const cached: string | null = await redisService.get({ prefix: REDIS_PREFIXES.BANDCAMP_ALBUMS, key: 'albums' }); + if (cacheMaxAge !== null) { + setHeaders({ 'cache-control': `max-age=${cacheMaxAge}` }); + } - if (cached) { - const response: Album[] = JSON.parse(cached); - const ttl = await redisService.ttl({ prefix: REDIS_PREFIXES.BANDCAMP_ALBUMS, key: 'albums' }); - if (ttl) { - setHeaders({ - 'cache-control': `max-age=${ttl}`, - }); - } else { - setHeaders({ - 'cache-control': 'max-age=43200', - }); - } - return json(response); - } - } - - // Scrape Bandcamp with realistic headers, plus retry/backoff - const { data }: ScrapeResult = await retryWithBackoff( - async () => - await scrapeIt(`https://bandcamp.com/${ENV.BANDCAMP_USERNAME}`, { - collectionItems: { - listItem: '.collection-item-container', - data: { - url: { selector: '.collection-title-details > a.item-link', attr: 'href' }, - artwork: { selector: 'div.collection-item-art-container a img', attr: 'src' }, - title: { selector: 'span.item-link-alt > div.collection-item-title' }, - artist: { selector: 'span.item-link-alt > div.collection-item-artist' }, - }, - }, - }), - ); - - const albums: Album[] = data?.collectionItems || []; - if (albums && albums.length > 0) { - if (ENV.USE_REDIS_CACHE) { - await redisService.setWithExpiry({ prefix: REDIS_PREFIXES.BANDCAMP_ALBUMS, key: 'albums', value: JSON.stringify(albums), expiry: 43200 }); - } - setHeaders({ 'cache-control': 'max-age=43200' }); - return json(albums); - } - return json([]); - } catch (error) { - console.error(error); - return json([]); - } + return json(albums); } diff --git a/src/routes/api/bandcamp/albums/server.test.ts b/src/routes/api/bandcamp/albums/server.test.ts index ba9f1c4..cc50fad 100644 --- a/src/routes/api/bandcamp/albums/server.test.ts +++ b/src/routes/api/bandcamp/albums/server.test.ts @@ -1,115 +1,53 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const scrapeItMock = vi.fn(); -const redisGetMock = vi.fn(); -const redisTtlMock = vi.fn(); -const redisSetWithExpiryMock = vi.fn(); +const getCurrentlyListeningAlbumsMock = vi.fn(); -vi.mock('scrape-it', () => ({ default: (...args: unknown[]) => scrapeItMock(...args) })); - -vi.mock('$lib/server/redis', () => ({ - redisService: { - get: (d: unknown) => redisGetMock(d), - ttl: (d: unknown) => redisTtlMock(d), - setWithExpiry: (d: unknown) => redisSetWithExpiryMock(d), - set: vi.fn(), - delete: vi.fn(), - scan: vi.fn(), - redis: null, - }, - REDIS_PREFIXES: { - ARTICLES: 'articles', - BANDCAMP_ALBUMS: 'bandcampAlbums', - PAGE_CACHE: 'pageCache', - }, -})); - -vi.mock('$lib/util/retry', () => ({ - retryWithBackoff: (fn: () => Promise) => fn(), +vi.mock('$lib/server/currentlyListening', () => ({ + getCurrentlyListeningAlbums: () => getCurrentlyListeningAlbumsMock(), })); import { GET } from './+server.js'; const makeAlbum = () => ({ - url: 'https://bandcamp.com/album/123', - artwork: 'https://img.bandcamp.com/art.jpg', - title: 'Test Album', - artist: 'Test Artist', + url: 'https://bandcamp.com/album/123', + artwork: 'https://img.bandcamp.com/art.jpg', + title: 'Test Album', + artist: 'Test Artist', }); function makeRequestEvent() { - const capturedHeaders: Record = {}; - const event = { - setHeaders: (h: Record) => Object.assign(capturedHeaders, h), - } as unknown as Parameters[0]; - return { event, capturedHeaders }; + const capturedHeaders: Record = {}; + const event = { + setHeaders: (h: Record) => Object.assign(capturedHeaders, h), + } as unknown as Parameters[0]; + return { event, capturedHeaders }; } beforeEach(() => { - vi.resetAllMocks(); + vi.resetAllMocks(); }); describe('GET /api/bandcamp/albums', () => { - it('returns cached albums with TTL-based cache-control', async () => { - const cached = [makeAlbum()]; - redisGetMock.mockResolvedValueOnce(JSON.stringify(cached)); - redisTtlMock.mockResolvedValueOnce(3600); - - const { event, capturedHeaders } = makeRequestEvent(); - const response = await GET(event); - const body = await response.json(); - - expect(body).toHaveLength(1); - expect(body[0].title).toBe('Test Album'); - expect(capturedHeaders['cache-control']).toBe('max-age=3600'); - expect(scrapeItMock).not.toHaveBeenCalled(); - }); - - it('returns cached albums with fallback cache-control when no TTL', async () => { - const cached = [makeAlbum()]; - redisGetMock.mockResolvedValueOnce(JSON.stringify(cached)); - redisTtlMock.mockResolvedValueOnce(0); - - const { event, capturedHeaders } = makeRequestEvent(); - await GET(event); - - expect(capturedHeaders['cache-control']).toBe('max-age=43200'); - }); - - it('scrapes and returns albums on cache miss', async () => { - redisGetMock.mockResolvedValueOnce(null); - scrapeItMock.mockResolvedValueOnce({ data: { collectionItems: [makeAlbum()] } }); - - const { event, capturedHeaders } = makeRequestEvent(); - const response = await GET(event); - const body = await response.json(); - - expect(body).toHaveLength(1); - expect(body[0].artist).toBe('Test Artist'); - expect(capturedHeaders['cache-control']).toBe('max-age=43200'); - expect(redisSetWithExpiryMock).toHaveBeenCalledOnce(); - }); - - it('returns empty array when scrape returns no items', async () => { - redisGetMock.mockResolvedValueOnce(null); - scrapeItMock.mockResolvedValueOnce({ data: { collectionItems: [] } }); + it('returns albums and applies cache metadata from the Currently Listening module', async () => { + const albums = [makeAlbum()]; + getCurrentlyListeningAlbumsMock.mockResolvedValueOnce({ albums, cacheMaxAge: 3600 }); - const { event } = makeRequestEvent(); - const response = await GET(event); - const body = await response.json(); + const { event, capturedHeaders } = makeRequestEvent(); + const response = await GET(event); + const body = await response.json(); - expect(body).toEqual([]); - expect(redisSetWithExpiryMock).not.toHaveBeenCalled(); - }); + expect(body).toEqual(albums); + expect(capturedHeaders['cache-control']).toBe('max-age=3600'); + }); - it('returns empty array when scrape throws', async () => { - redisGetMock.mockResolvedValueOnce(null); - scrapeItMock.mockRejectedValueOnce(new Error('scrape failed')); + it('returns albums without cache-control when the module has no cache metadata', async () => { + getCurrentlyListeningAlbumsMock.mockResolvedValueOnce({ albums: [], cacheMaxAge: null }); - const { event } = makeRequestEvent(); - const response = await GET(event); - const body = await response.json(); + const { event, capturedHeaders } = makeRequestEvent(); + const response = await GET(event); + const body = await response.json(); - expect(body).toEqual([]); - }); + expect(body).toEqual([]); + expect(capturedHeaders).toEqual({}); + }); });