diff --git a/apps/proxy/src/routes/items.test.ts b/apps/proxy/src/routes/items.test.ts index 3e8a763..0d5ec4c 100644 --- a/apps/proxy/src/routes/items.test.ts +++ b/apps/proxy/src/routes/items.test.ts @@ -1,10 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts'; -import type { TTSMod } from '@tts/shared'; +import type { ModDetails } from '@tts/shared'; import items from './items.js'; const env = { STEAM_API_KEY: 'test-key', PORT: 3000 }; +const mod = { + GameMode: 'Tabletop', + Date: '2024-01-01', + ObjectStates: [], +}; + +const details: ModDetails = { mod }; + afterEach(() => { vi.unstubAllGlobals(); }); @@ -17,7 +25,7 @@ describe('items route', () => { }); it('returns 500 when the API key is missing and no fileUrl is given', async () => { - vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue( + vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue( new TtsError('STEAM_API_KEY is not configured', 500), ); const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 }); @@ -26,11 +34,6 @@ describe('items route', () => { }); it('loads from a fileUrl without an API key', async () => { - const mod: TTSMod = { - GameMode: 'Tabletop', - Date: '2024-01-01', - ObjectStates: [], - }; const fetchModFromUrl = vi .spyOn(await import('@tts/tts'), 'fetchModFromUrl') .mockResolvedValue(mod); @@ -40,26 +43,26 @@ describe('items route', () => { { STEAM_API_KEY: '', PORT: 3000 }, ); expect(res.status).toBe(200); - expect(await res.json()).toEqual(mod); + expect(await res.json()).toEqual(details); expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json'); }); - it('returns the parsed mod on success', async () => { - const mod: TTSMod = { - GameMode: 'Tabletop', - Date: '2024-01-01', - ObjectStates: [], + it('returns the parsed mod with metadata on success', async () => { + const withMeta: ModDetails = { + mod, + title: 'My Mod', + previewImageUrl: 'https://example.com/preview.png', }; vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}'))); - vi.spyOn(await import('@tts/tts'), 'fetchMod').mockResolvedValue(mod); + vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockResolvedValue(withMeta); const res = await items.request('/123', {}, env); expect(res.status).toBe(200); - expect(await res.json()).toEqual(mod); + expect(await res.json()).toEqual(withMeta); }); it('maps TtsError subclasses to their status', async () => { - vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue( + vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue( new ItemNotFoundError('123'), ); const res = await items.request('/123', {}, env); @@ -70,7 +73,7 @@ describe('items route', () => { }); it('returns 500 for unexpected errors', async () => { - vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue( + vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue( new Error('boom'), ); const res = await items.request('/123', {}, env); diff --git a/apps/proxy/src/routes/items.ts b/apps/proxy/src/routes/items.ts index cb2ca93..ed3c490 100644 --- a/apps/proxy/src/routes/items.ts +++ b/apps/proxy/src/routes/items.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; -import { itemIdSchema, type TTSMod } from '@tts/shared'; +import { itemIdSchema, type ModDetails } from '@tts/shared'; import { - fetchMod, + fetchModDetails, fetchModFile, fetchModFileFromUrl, fetchModFromUrl, @@ -20,11 +20,12 @@ app.get('/:id', async (c) => { try { const fileUrl = c.req.query('fileUrl'); // With no Steam API key, the caller must supply the save URL (e.g. from a - // search result). Otherwise resolve it via the Steam API. - const mod: TTSMod = fileUrl - ? await fetchModFromUrl(fileUrl) - : await fetchMod(id, c.env.STEAM_API_KEY ?? ''); - return c.json(mod); + // search result). Otherwise resolve it via the Steam API, which also + // yields the Workshop title and preview image. + const details: ModDetails = fileUrl + ? { mod: await fetchModFromUrl(fileUrl) } + : await fetchModDetails(id, c.env.STEAM_API_KEY ?? ''); + return c.json(details); } catch (err) { if (err instanceof TtsError) { return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index e13e4d8..1fcc8ba 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,4 +1,4 @@ -import type { SearchResult, TTSMod } from '@tts/shared'; +import type { ModDetails, SearchResult } from '@tts/shared'; import { traceImage } from '@tts/http'; export { traceImage }; @@ -21,13 +21,13 @@ export function searchWorkshop(q: string, page = 1): Promise { } /** - * Fetch a full parsed TTS save. + * Fetch a full parsed TTS save plus its Workshop metadata. */ -export function fetchMod(id: string, fileUrl?: string): Promise { +export function fetchMod(id: string, fileUrl?: string): Promise { const params = new URLSearchParams(); if (fileUrl) params.set('fileUrl', fileUrl); const qs = params.toString(); - return getJson(`/items/${id}${qs ? `?${qs}` : ''}`); + return getJson(`/items/${id}${qs ? `?${qs}` : ''}`); } /** Build a URL for the raw save file download. */ diff --git a/apps/web/src/pages/ModPage.tsx b/apps/web/src/pages/ModPage.tsx index 31efc2e..0ac0653 100644 --- a/apps/web/src/pages/ModPage.tsx +++ b/apps/web/src/pages/ModPage.tsx @@ -25,8 +25,8 @@ export default function ModPage() { if (id) load(id, item?.fileUrl); }, [id, item?.fileUrl, load]); - const tree = useMemo(() => (mod ? buildTree(mod) : []), [mod]); - const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]); + const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]); + const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]); const selected = useMemo( () => (mod ? findInTree(tree, selectedPath) : undefined), diff --git a/apps/web/src/stores/modStore.ts b/apps/web/src/stores/modStore.ts index 17285ea..6b603b5 100644 --- a/apps/web/src/stores/modStore.ts +++ b/apps/web/src/stores/modStore.ts @@ -1,11 +1,11 @@ import { create } from 'zustand'; -import type { TTSMod } from '@tts/shared'; +import type { ModDetails } from '@tts/shared'; import { fetchMod } from '../api'; interface ModState { id: string | null; fileUrl?: string; - mod: TTSMod | null; + mod: ModDetails | null; loading: boolean; error: string | null; load: (id: string, fileUrl?: string) => Promise; @@ -21,11 +21,11 @@ export const useModStore = create((set) => ({ load: async (id, fileUrl) => { set({ loading: true, error: null, id, fileUrl }); try { - const mod = await fetchMod(id, fileUrl); - set({ mod, loading: false }); + const details = await fetchMod(id, fileUrl); + set({ mod: details, loading: false }); } catch (err) { set({ loading: false, error: String(err) }); } }, - clear: () => set({ id: null, fileUrl: undefined, mod: null, error: null, loading: false }), + clear: () => set({ id: null, fileUrl: undefined, mod: null, loading: false, error: null }), })); \ No newline at end of file diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4b602df..014bd69 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -87,6 +87,14 @@ export interface TTSMod { ObjectStates: TTSObject[]; } +/** A parsed save plus its Workshop metadata, returned by `GET /items/:id`. */ +export interface ModDetails { + mod: TTSMod; + /** Present when the metadata could be resolved via the Steam API. */ + title?: string; + previewImageUrl?: string; +} + /** Metadata for a Workshop item, from the Steam Web API. */ export interface WorkshopItem { id: string; diff --git a/packages/tts/src/index.ts b/packages/tts/src/index.ts index 3ff3cb5..b556de0 100644 --- a/packages/tts/src/index.ts +++ b/packages/tts/src/index.ts @@ -1,5 +1,5 @@ import { deserialize } from 'bson'; -import type { TTSMod } from '@tts/shared'; +import type { ModDetails, TTSMod } from '@tts/shared'; import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js'; const STEAM_API_URL = @@ -36,6 +36,30 @@ export async function fetchMod(id: string, apiKey: string): Promise { return fetchModFromUrl(fileUrl); } +/** + * Fetch a save plus its Workshop metadata (title, preview image) in one call. + * The metadata comes from the same Steam API request that resolves the save + * URL, so it costs no extra round-trip. + * + * @param id Workshop item ID (digits only). + * @param apiKey Steam Web API key. + */ +export async function fetchModDetails( + id: string, + apiKey: string, +): Promise { + const details = await getPublishedFileDetails(id, apiKey); + if (!details.file_url) { + throw new NoFileError(id); + } + const mod = await fetchModFromUrl(details.file_url); + return { + mod, + title: details.title, + previewImageUrl: details.preview_url, + }; +} + /** * Download a TTS save from a direct URL and BSON-deserialize it. * @@ -94,6 +118,18 @@ async function downloadSave(fileUrl: string): Promise { /** Resolve the `file_url` for a Workshop item via the Steam API. */ async function getFileUrl(id: string, apiKey: string): Promise { + const details = await getPublishedFileDetails(id, apiKey); + if (!details.file_url) { + throw new NoFileError(id); + } + return details.file_url; +} + +/** Fetch the Steam published-file details for a Workshop item. */ +async function getPublishedFileDetails( + id: string, + apiKey: string, +): Promise { const params = new URLSearchParams(); params.append('key', apiKey); params.append('itemcount', '1'); @@ -112,10 +148,7 @@ async function getFileUrl(id: string, apiKey: string): Promise { if (!details) { throw new ItemNotFoundError(id); } - if (!details.file_url) { - throw new NoFileError(id); - } - return details.file_url; + return details; } export * from './errors.js'; \ No newline at end of file