feat: return workshop metadata with mod details

Include the Workshop title and preview image in the items response so the mod header survives a page refresh, not just navigation from search. Resolve both from the same Steam API call.
This commit is contained in:
2026-08-14 10:59:14 +08:00
parent 7163451d1f
commit 30ef76632f
7 changed files with 85 additions and 40 deletions
+20 -17
View File
@@ -1,10 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts'; import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
import type { TTSMod } from '@tts/shared'; import type { ModDetails } from '@tts/shared';
import items from './items.js'; import items from './items.js';
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 }; const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
const mod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [],
};
const details: ModDetails = { mod };
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -17,7 +25,7 @@ describe('items route', () => {
}); });
it('returns 500 when the API key is missing and no fileUrl is given', async () => { 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), new TtsError('STEAM_API_KEY is not configured', 500),
); );
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 }); 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 () => { it('loads from a fileUrl without an API key', async () => {
const mod: TTSMod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [],
};
const fetchModFromUrl = vi const fetchModFromUrl = vi
.spyOn(await import('@tts/tts'), 'fetchModFromUrl') .spyOn(await import('@tts/tts'), 'fetchModFromUrl')
.mockResolvedValue(mod); .mockResolvedValue(mod);
@@ -40,26 +43,26 @@ describe('items route', () => {
{ STEAM_API_KEY: '', PORT: 3000 }, { STEAM_API_KEY: '', PORT: 3000 },
); );
expect(res.status).toBe(200); 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'); expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
}); });
it('returns the parsed mod on success', async () => { it('returns the parsed mod with metadata on success', async () => {
const mod: TTSMod = { const withMeta: ModDetails = {
GameMode: 'Tabletop', mod,
Date: '2024-01-01', title: 'My Mod',
ObjectStates: [], previewImageUrl: 'https://example.com/preview.png',
}; };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}'))); 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); const res = await items.request('/123', {}, env);
expect(res.status).toBe(200); 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 () => { 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'), new ItemNotFoundError('123'),
); );
const res = await items.request('/123', {}, env); const res = await items.request('/123', {}, env);
@@ -70,7 +73,7 @@ describe('items route', () => {
}); });
it('returns 500 for unexpected errors', async () => { 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'), new Error('boom'),
); );
const res = await items.request('/123', {}, env); const res = await items.request('/123', {}, env);
+8 -7
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { itemIdSchema, type TTSMod } from '@tts/shared'; import { itemIdSchema, type ModDetails } from '@tts/shared';
import { import {
fetchMod, fetchModDetails,
fetchModFile, fetchModFile,
fetchModFileFromUrl, fetchModFileFromUrl,
fetchModFromUrl, fetchModFromUrl,
@@ -20,11 +20,12 @@ app.get('/:id', async (c) => {
try { try {
const fileUrl = c.req.query('fileUrl'); const fileUrl = c.req.query('fileUrl');
// With no Steam API key, the caller must supply the save URL (e.g. from a // 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. // search result). Otherwise resolve it via the Steam API, which also
const mod: TTSMod = fileUrl // yields the Workshop title and preview image.
? await fetchModFromUrl(fileUrl) const details: ModDetails = fileUrl
: await fetchMod(id, c.env.STEAM_API_KEY ?? ''); ? { mod: await fetchModFromUrl(fileUrl) }
return c.json<TTSMod>(mod); : await fetchModDetails(id, c.env.STEAM_API_KEY ?? '');
return c.json<ModDetails>(details);
} catch (err) { } catch (err) {
if (err instanceof TtsError) { if (err instanceof TtsError) {
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502); return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
+4 -4
View File
@@ -1,4 +1,4 @@
import type { SearchResult, TTSMod } from '@tts/shared'; import type { ModDetails, SearchResult } from '@tts/shared';
import { traceImage } from '@tts/http'; import { traceImage } from '@tts/http';
export { traceImage }; export { traceImage };
@@ -21,13 +21,13 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
} }
/** /**
* Fetch a full parsed TTS save. * Fetch a full parsed TTS save plus its Workshop metadata.
*/ */
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> { export function fetchMod(id: string, fileUrl?: string): Promise<ModDetails> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (fileUrl) params.set('fileUrl', fileUrl); if (fileUrl) params.set('fileUrl', fileUrl);
const qs = params.toString(); const qs = params.toString();
return getJson<TTSMod>(`/items/${id}${qs ? `?${qs}` : ''}`); return getJson<ModDetails>(`/items/${id}${qs ? `?${qs}` : ''}`);
} }
/** Build a URL for the raw save file download. */ /** Build a URL for the raw save file download. */
+2 -2
View File
@@ -25,8 +25,8 @@ export default function ModPage() {
if (id) load(id, item?.fileUrl); if (id) load(id, item?.fileUrl);
}, [id, item?.fileUrl, load]); }, [id, item?.fileUrl, load]);
const tree = useMemo(() => (mod ? buildTree(mod) : []), [mod]); const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]); const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
const selected = useMemo( const selected = useMemo(
() => (mod ? findInTree(tree, selectedPath) : undefined), () => (mod ? findInTree(tree, selectedPath) : undefined),
+5 -5
View File
@@ -1,11 +1,11 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { TTSMod } from '@tts/shared'; import type { ModDetails } from '@tts/shared';
import { fetchMod } from '../api'; import { fetchMod } from '../api';
interface ModState { interface ModState {
id: string | null; id: string | null;
fileUrl?: string; fileUrl?: string;
mod: TTSMod | null; mod: ModDetails | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
load: (id: string, fileUrl?: string) => Promise<void>; load: (id: string, fileUrl?: string) => Promise<void>;
@@ -21,11 +21,11 @@ export const useModStore = create<ModState>((set) => ({
load: async (id, fileUrl) => { load: async (id, fileUrl) => {
set({ loading: true, error: null, id, fileUrl }); set({ loading: true, error: null, id, fileUrl });
try { try {
const mod = await fetchMod(id, fileUrl); const details = await fetchMod(id, fileUrl);
set({ mod, loading: false }); set({ mod: details, loading: false });
} catch (err) { } catch (err) {
set({ loading: false, error: String(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 }),
})); }));
+8
View File
@@ -87,6 +87,14 @@ export interface TTSMod {
ObjectStates: TTSObject[]; 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. */ /** Metadata for a Workshop item, from the Steam Web API. */
export interface WorkshopItem { export interface WorkshopItem {
id: string; id: string;
+38 -5
View File
@@ -1,5 +1,5 @@
import { deserialize } from 'bson'; import { deserialize } from 'bson';
import type { TTSMod } from '@tts/shared'; import type { ModDetails, TTSMod } from '@tts/shared';
import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js'; import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js';
const STEAM_API_URL = const STEAM_API_URL =
@@ -36,6 +36,30 @@ export async function fetchMod(id: string, apiKey: string): Promise<TTSMod> {
return fetchModFromUrl(fileUrl); 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<ModDetails> {
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. * Download a TTS save from a direct URL and BSON-deserialize it.
* *
@@ -94,6 +118,18 @@ async function downloadSave(fileUrl: string): Promise<ArrayBuffer> {
/** Resolve the `file_url` for a Workshop item via the Steam API. */ /** Resolve the `file_url` for a Workshop item via the Steam API. */
async function getFileUrl(id: string, apiKey: string): Promise<string> { async function getFileUrl(id: string, apiKey: string): Promise<string> {
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<SteamPublishedFileDetails> {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('key', apiKey); params.append('key', apiKey);
params.append('itemcount', '1'); params.append('itemcount', '1');
@@ -112,10 +148,7 @@ async function getFileUrl(id: string, apiKey: string): Promise<string> {
if (!details) { if (!details) {
throw new ItemNotFoundError(id); throw new ItemNotFoundError(id);
} }
if (!details.file_url) { return details;
throw new NoFileError(id);
}
return details.file_url;
} }
export * from './errors.js'; export * from './errors.js';