import { deserialize } from 'bson'; import type { TTSMod } from '@tts/shared'; import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js'; const STEAM_API_URL = 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/'; interface SteamPublishedFileDetails { publishedfileid: string; title: string; creator: string; preview_url: string; file_url?: string; description?: string; tags?: { tag: string }[]; time_created?: number; time_updated?: number; } interface SteamResponse { response: { result: number; resultcount: number; publishedfiledetails?: SteamPublishedFileDetails[]; }; } /** * Fetch a full TTS save for a Workshop item and BSON-deserialize it. * * @param id Workshop item ID (digits only). * @param apiKey Steam Web API key. */ export async function fetchMod(id: string, apiKey: string): Promise { const fileUrl = await getFileUrl(id, apiKey); return fetchModFromUrl(fileUrl); } /** * Download a TTS save from a direct URL and BSON-deserialize it. * * @param fileUrl Direct URL to the save file (e.g. from a search result). */ export async function fetchModFromUrl(fileUrl: string): Promise { const buffer = await downloadSave(fileUrl); return deserialize(new Uint8Array(buffer)) as TTSMod; } /** * Derive a filename from a `content-disposition` header. * Parses `filename="..."`; falls back to the URL path, then a default. */ export function getFileName(url: string, disposition: string | null): string { if (disposition) { const match = disposition.match(/filename\*?=(?:"([^"]*)"|([^;\s]*))/i); const name = match?.[1] ?? match?.[2]; if (name) { return name; } } return new URL(url).pathname.split('/').pop() || 'save.json'; } /** * Fetch the raw save file bytes for a Workshop item. * Returns the bytes plus a derived filename. */ export async function fetchModFile( id: string, apiKey: string, ): Promise<{ data: ArrayBuffer; filename: string }> { const fileUrl = await getFileUrl(id, apiKey); return fetchModFileFromUrl(fileUrl); } /** * Fetch the raw save file bytes from a direct URL. * Returns the bytes plus a derived filename. */ export async function fetchModFileFromUrl( fileUrl: string, ): Promise<{ data: ArrayBuffer; filename: string }> { const data = await downloadSave(fileUrl); const filename = getFileName(fileUrl, null); return { data, filename }; } /** Download a save file's bytes from a URL, throwing on failure. */ async function downloadSave(fileUrl: string): Promise { const res = await fetch(fileUrl); if (!res.ok) { throw new SteamApiError( `Failed to download save file (${res.status})`, 502, ); } return res.arrayBuffer(); } /** Resolve the `file_url` for a Workshop item via the Steam API. */ async function getFileUrl(id: string, apiKey: string): Promise { const params = new URLSearchParams(); params.append('key', apiKey); params.append('itemcount', '1'); params.append('publishedfileids[0]', id); const res = await fetch(STEAM_API_URL, { method: 'POST', body: params, }); if (!res.ok) { throw new SteamApiError(`Steam API responded ${res.status}`, 502); } const json = (await res.json()) as SteamResponse; const details = json.response.publishedfiledetails?.[0]; if (!details) { throw new ItemNotFoundError(id); } if (!details.file_url) { throw new NoFileError(id); } return details.file_url; } export * from './errors.js';