| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250 |
- import { WE_APP_ID } from '@shared/constants'
- import type {
- WorkshopQueryParams,
- WorkshopQueryResult,
- WorkshopItem,
- CreatorWorkshopQueryParams
- } from '@shared/types'
- import { getClient } from './steam.service'
- // Maps our string names to steamworks.js UGCQueryType enum values
- const QUERY_TYPE_MAP: Record<string, number> = {
- RankedByVote: 0,
- RankedByPublicationDate: 1,
- RankedByTrend: 3,
- RankedByTotalUniqueSubscriptions: 12,
- RankedByTextSearch: 11,
- RankedByLastUpdatedDate: 19
- }
- // Maps our string names to steamworks.js UserListOrder enum values, used for
- // UserUGCList queries (querying a specific creator) which don't support the
- // UGCQueryType ranking options above.
- const USER_LIST_ORDER_MAP: Record<string, number> = {
- RankedByVote: 5, // VoteScoreDesc
- RankedByPublicationDate: 1, // CreationOrderDesc
- RankedByTrend: 1, // no trend equivalent, falls back to newest
- RankedByTotalUniqueSubscriptions: 4, // SubscriptionDateDesc, closest analog
- RankedByTextSearch: 1,
- RankedByLastUpdatedDate: 3 // LastUpdatedDesc
- }
- export async function queryWorkshop(params: WorkshopQueryParams): Promise<WorkshopQueryResult> {
- const client = getClient()
- const appId = params.appId ?? WE_APP_ID
- const page = params.page ?? 1
- const queryType = QUERY_TYPE_MAP[params.queryType ?? 'RankedByPublicationDate'] ?? 1
- const result = await client.workshop.getAllItems(
- page,
- queryType,
- 13, // UGCType.All, since WE items span multiple subtypes
- appId,
- appId,
- {
- searchText: params.searchText,
- requiredTags: params.tags,
- excludedTags: params.excludedTags,
- includeLongDescription: true
- }
- )
- const subscribedSet = new Set(
- client.workshop.getSubscribedItems().map((id) => id.toString())
- )
- const items = result.items.filter(Boolean).map((item) => transformItem(item!, subscribedSet))
- await attachFileSizes(items)
- return {
- items,
- page,
- totalResults: result.totalResults
- }
- }
- export async function getWorkshopItem(publishedFileId: string): Promise<WorkshopItem | null> {
- const client = getClient()
- let item: Awaited<ReturnType<typeof client.workshop.getItem>>
- try {
- item = await client.workshop.getItem(BigInt(publishedFileId), {
- includeLongDescription: true
- })
- } catch {
- // Steam's UGC details lookup throws a generic failure for an id it can no longer resolve
- // (taken down, banned) rather than returning null the way a batch getItems() call does -
- // treat it the same way: "we can't tell you about this item" rather than a hard error.
- return null
- }
- if (!item) return null
- const subscribedSet = new Set(
- client.workshop.getSubscribedItems().map((id) => id.toString())
- )
- const transformed = transformItem(item, subscribedSet)
- await attachFileSizes([transformed])
- return transformed
- }
- // UserUGCList lets us query another Steam user's published items directly,
- // unlike getAllItems which only ranks/filters across the whole workshop.
- export async function queryWorkshopByCreator(
- creatorSteamId: string,
- params: CreatorWorkshopQueryParams = {}
- ): Promise<WorkshopQueryResult> {
- const client = getClient()
- const accountId = Number(BigInt(creatorSteamId) & 0xffffffffn)
- const page = params.page ?? 1
- const sortOrder = USER_LIST_ORDER_MAP[params.queryType ?? 'RankedByPublicationDate'] ?? 1
- const result = await client.workshop.getUserItems(
- page,
- accountId,
- 0, // UserListType.Published
- 13, // UGCType.All
- sortOrder,
- { consumer: WE_APP_ID },
- {
- searchText: params.searchText,
- requiredTags: params.tags,
- excludedTags: params.excludedTags,
- includeLongDescription: true
- }
- )
- const subscribedSet = new Set(
- client.workshop.getSubscribedItems().map((id) => id.toString())
- )
- const items = result.items.filter(Boolean).map((item) => transformItem(item!, subscribedSet))
- await attachFileSizes(items)
- return {
- items,
- page,
- totalResults: result.totalResults
- }
- }
- export async function getWorkshopTimesUpdated(
- publishedFileIds: string[]
- ): Promise<Map<string, number>> {
- const map = new Map<string, number>()
- if (publishedFileIds.length === 0) return map
- const client = getClient()
- const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
- for (const item of result.items) {
- if (item) map.set(item.publishedFileId.toString(), item.timeUpdated)
- }
- return map
- }
- // Steam tags an item's type ("Video", "Scene", ...) and age rating ("Everyone", ...)
- // alongside its genre/resolution tags, so this works even for items that never
- // finished downloading and have no local project.json to read that from.
- export async function getWorkshopTags(publishedFileIds: string[]): Promise<Map<string, string[]>> {
- const map = new Map<string, string[]>()
- if (publishedFileIds.length === 0) return map
- const client = getClient()
- const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
- for (const item of result.items) {
- if (item) map.set(item.publishedFileId.toString(), item.tags ?? [])
- }
- return map
- }
- // Used to backfill authorSteamId on library items imported before this field existed
- // (and on items that never finished downloading, since they have no project.json).
- export async function getWorkshopAuthors(publishedFileIds: string[]): Promise<Map<string, string>> {
- const map = new Map<string, string>()
- if (publishedFileIds.length === 0) return map
- const client = getClient()
- const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
- for (const item of result.items) {
- if (item) map.set(item.publishedFileId.toString(), item.owner.steamId64.toString())
- }
- return map
- }
- // Steam returns a null entry for any id that no longer resolves to a workshop
- // item (removed by its author or taken down), so anything missing from the
- // result is treated as unavailable. Lets a request error (network hiccup)
- // propagate rather than swallowing it here - the caller decides what "we
- // couldn't tell" should mean, instead of this silently reporting "all clear".
- export async function getUnavailableWorkshopItems(publishedFileIds: string[]): Promise<Set<string>> {
- const unavailable = new Set(publishedFileIds)
- if (publishedFileIds.length === 0) return unavailable
- const client = getClient()
- const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
- for (const item of result.items) {
- if (item) unavailable.delete(item.publishedFileId.toString())
- }
- return unavailable
- }
- // steamworks.js's UGC query bindings don't surface a file's size (the underlying Steamworks SDK
- // struct has it, the napi wrapper just doesn't map that field). Steam's own public web API does,
- // unauthenticated, for any published file regardless of subscription state - this is the exact
- // "File Size" figure shown on the item's Steam Workshop page. Cached per id for the process
- // lifetime since it barely ever changes.
- const fileSizeCache = new Map<string, number>()
- const FILE_SIZE_BATCH = 100
- async function fetchFileSizesBatch(ids: string[]): Promise<void> {
- if (ids.length === 0) return
- const body = new URLSearchParams({ itemcount: String(ids.length) })
- ids.forEach((id, i) => body.set(`publishedfileids[${i}]`, id))
- const res = await fetch('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', {
- method: 'POST',
- body
- })
- const data = await res.json()
- const details = data?.response?.publishedfiledetails ?? []
- for (const d of details) {
- if (d?.publishedfileid && d.file_size != null) {
- fileSizeCache.set(d.publishedfileid, Number(d.file_size))
- }
- }
- }
- async function attachFileSizes(items: WorkshopItem[]): Promise<void> {
- const uncached = [...new Set(items.map((i) => i.publishedFileId).filter((id) => !fileSizeCache.has(id)))]
- if (uncached.length > 0) {
- try {
- for (let i = 0; i < uncached.length; i += FILE_SIZE_BATCH) {
- await fetchFileSizesBatch(uncached.slice(i, i + FILE_SIZE_BATCH))
- }
- } catch (err) {
- console.warn('[Workshop] Failed to fetch file sizes:', err)
- }
- }
- for (const item of items) {
- const size = fileSizeCache.get(item.publishedFileId)
- if (size != null) item.fileSize = size
- }
- }
- function transformItem(
- item: NonNullable<Awaited<ReturnType<ReturnType<typeof getClient>['workshop']['getItem']>>>,
- subscribedSet: Set<string>
- ): WorkshopItem {
- return {
- publishedFileId: item.publishedFileId.toString(),
- title: item.title,
- description: item.description,
- previewUrl: item.previewUrl ?? '',
- creatorSteamId: item.owner.steamId64.toString(),
- tags: item.tags ?? [],
- timeCreated: item.timeCreated,
- timeUpdated: item.timeUpdated,
- subscriptions: Number(item.statistics?.numSubscriptions ?? 0),
- upvotes: item.numUpvotes,
- downvotes: item.numDownvotes,
- isSubscribed: subscribedSet.has(item.publishedFileId.toString())
- }
- }
|