workshop.service.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import { WE_APP_ID } from '@shared/constants'
  2. import type {
  3. WorkshopQueryParams,
  4. WorkshopQueryResult,
  5. WorkshopItem,
  6. CreatorWorkshopQueryParams
  7. } from '@shared/types'
  8. import { getClient } from './steam.service'
  9. // Maps our string names to steamworks.js UGCQueryType enum values
  10. const QUERY_TYPE_MAP: Record<string, number> = {
  11. RankedByVote: 0,
  12. RankedByPublicationDate: 1,
  13. RankedByTrend: 3,
  14. RankedByTotalUniqueSubscriptions: 12,
  15. RankedByTextSearch: 11,
  16. RankedByLastUpdatedDate: 19
  17. }
  18. // Maps our string names to steamworks.js UserListOrder enum values, used for
  19. // UserUGCList queries (querying a specific creator) which don't support the
  20. // UGCQueryType ranking options above.
  21. const USER_LIST_ORDER_MAP: Record<string, number> = {
  22. RankedByVote: 5, // VoteScoreDesc
  23. RankedByPublicationDate: 1, // CreationOrderDesc
  24. RankedByTrend: 1, // no trend equivalent, falls back to newest
  25. RankedByTotalUniqueSubscriptions: 4, // SubscriptionDateDesc, closest analog
  26. RankedByTextSearch: 1,
  27. RankedByLastUpdatedDate: 3 // LastUpdatedDesc
  28. }
  29. export async function queryWorkshop(params: WorkshopQueryParams): Promise<WorkshopQueryResult> {
  30. const client = getClient()
  31. const appId = params.appId ?? WE_APP_ID
  32. const page = params.page ?? 1
  33. const queryType = QUERY_TYPE_MAP[params.queryType ?? 'RankedByPublicationDate'] ?? 1
  34. const result = await client.workshop.getAllItems(
  35. page,
  36. queryType,
  37. 13, // UGCType.All, since WE items span multiple subtypes
  38. appId,
  39. appId,
  40. {
  41. searchText: params.searchText,
  42. requiredTags: params.tags,
  43. excludedTags: params.excludedTags,
  44. includeLongDescription: true
  45. }
  46. )
  47. const subscribedSet = new Set(
  48. client.workshop.getSubscribedItems().map((id) => id.toString())
  49. )
  50. const items = result.items.filter(Boolean).map((item) => transformItem(item!, subscribedSet))
  51. await attachFileSizes(items)
  52. return {
  53. items,
  54. page,
  55. totalResults: result.totalResults
  56. }
  57. }
  58. export async function getWorkshopItem(publishedFileId: string): Promise<WorkshopItem | null> {
  59. const client = getClient()
  60. let item: Awaited<ReturnType<typeof client.workshop.getItem>>
  61. try {
  62. item = await client.workshop.getItem(BigInt(publishedFileId), {
  63. includeLongDescription: true
  64. })
  65. } catch {
  66. // Steam's UGC details lookup throws a generic failure for an id it can no longer resolve
  67. // (taken down, banned) rather than returning null the way a batch getItems() call does -
  68. // treat it the same way: "we can't tell you about this item" rather than a hard error.
  69. return null
  70. }
  71. if (!item) return null
  72. const subscribedSet = new Set(
  73. client.workshop.getSubscribedItems().map((id) => id.toString())
  74. )
  75. const transformed = transformItem(item, subscribedSet)
  76. await attachFileSizes([transformed])
  77. return transformed
  78. }
  79. // UserUGCList lets us query another Steam user's published items directly,
  80. // unlike getAllItems which only ranks/filters across the whole workshop.
  81. export async function queryWorkshopByCreator(
  82. creatorSteamId: string,
  83. params: CreatorWorkshopQueryParams = {}
  84. ): Promise<WorkshopQueryResult> {
  85. const client = getClient()
  86. const accountId = Number(BigInt(creatorSteamId) & 0xffffffffn)
  87. const page = params.page ?? 1
  88. const sortOrder = USER_LIST_ORDER_MAP[params.queryType ?? 'RankedByPublicationDate'] ?? 1
  89. const result = await client.workshop.getUserItems(
  90. page,
  91. accountId,
  92. 0, // UserListType.Published
  93. 13, // UGCType.All
  94. sortOrder,
  95. { consumer: WE_APP_ID },
  96. {
  97. searchText: params.searchText,
  98. requiredTags: params.tags,
  99. excludedTags: params.excludedTags,
  100. includeLongDescription: true
  101. }
  102. )
  103. const subscribedSet = new Set(
  104. client.workshop.getSubscribedItems().map((id) => id.toString())
  105. )
  106. const items = result.items.filter(Boolean).map((item) => transformItem(item!, subscribedSet))
  107. await attachFileSizes(items)
  108. return {
  109. items,
  110. page,
  111. totalResults: result.totalResults
  112. }
  113. }
  114. // A details query only fetches its first result page, and Steam pages those at 50 items -
  115. // anything past that in a single getItems() call silently comes back missing.
  116. const DETAILS_QUERY_BATCH = 50
  117. async function getItemsBatched(publishedFileIds: string[]) {
  118. const client = getClient()
  119. const items = []
  120. for (let i = 0; i < publishedFileIds.length; i += DETAILS_QUERY_BATCH) {
  121. const batch = publishedFileIds.slice(i, i + DETAILS_QUERY_BATCH)
  122. const result = await client.workshop.getItems(batch.map((id) => BigInt(id)))
  123. for (const item of result.items) {
  124. if (item) items.push(item)
  125. }
  126. }
  127. return items
  128. }
  129. export async function getWorkshopTimesUpdated(
  130. publishedFileIds: string[]
  131. ): Promise<Map<string, number>> {
  132. const map = new Map<string, number>()
  133. if (publishedFileIds.length === 0) return map
  134. for (const item of await getItemsBatched(publishedFileIds)) {
  135. map.set(item.publishedFileId.toString(), item.timeUpdated)
  136. }
  137. return map
  138. }
  139. // Steam tags an item's type ("Video", "Scene", ...) and age rating ("Everyone", ...)
  140. // alongside its genre/resolution tags, so this works even for items that never
  141. // finished downloading and have no local project.json to read that from.
  142. export async function getWorkshopTags(publishedFileIds: string[]): Promise<Map<string, string[]>> {
  143. const map = new Map<string, string[]>()
  144. if (publishedFileIds.length === 0) return map
  145. for (const item of await getItemsBatched(publishedFileIds)) {
  146. map.set(item.publishedFileId.toString(), item.tags ?? [])
  147. }
  148. return map
  149. }
  150. // Used to backfill authorSteamId on library items imported before this field existed
  151. // (and on items that never finished downloading, since they have no project.json).
  152. export async function getWorkshopAuthors(publishedFileIds: string[]): Promise<Map<string, string>> {
  153. const map = new Map<string, string>()
  154. if (publishedFileIds.length === 0) return map
  155. for (const item of await getItemsBatched(publishedFileIds)) {
  156. map.set(item.publishedFileId.toString(), item.owner.steamId64.toString())
  157. }
  158. return map
  159. }
  160. // Steam returns a null entry for any id that no longer resolves to a workshop
  161. // item (removed by its author or taken down), so anything missing from the
  162. // result is treated as unavailable. Lets a request error (network hiccup)
  163. // propagate rather than swallowing it here - the caller decides what "we
  164. // couldn't tell" should mean, instead of this silently reporting "all clear".
  165. export async function getUnavailableWorkshopItems(publishedFileIds: string[]): Promise<Set<string>> {
  166. const unavailable = new Set(publishedFileIds)
  167. if (publishedFileIds.length === 0) return unavailable
  168. for (const item of await getItemsBatched(publishedFileIds)) {
  169. unavailable.delete(item.publishedFileId.toString())
  170. }
  171. return unavailable
  172. }
  173. // steamworks.js's UGC query bindings don't surface a file's size (the underlying Steamworks SDK
  174. // struct has it, the napi wrapper just doesn't map that field). Steam's own public web API does,
  175. // unauthenticated, for any published file regardless of subscription state - this is the exact
  176. // "File Size" figure shown on the item's Steam Workshop page. Cached per id for the process
  177. // lifetime since it barely ever changes.
  178. const fileSizeCache = new Map<string, number>()
  179. const FILE_SIZE_BATCH = 100
  180. async function fetchFileSizesBatch(ids: string[]): Promise<void> {
  181. if (ids.length === 0) return
  182. const body = new URLSearchParams({ itemcount: String(ids.length) })
  183. ids.forEach((id, i) => body.set(`publishedfileids[${i}]`, id))
  184. const res = await fetch('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', {
  185. method: 'POST',
  186. body
  187. })
  188. const data = await res.json()
  189. const details = data?.response?.publishedfiledetails ?? []
  190. for (const d of details) {
  191. if (d?.publishedfileid && d.file_size != null) {
  192. fileSizeCache.set(d.publishedfileid, Number(d.file_size))
  193. }
  194. }
  195. }
  196. async function attachFileSizes(items: WorkshopItem[]): Promise<void> {
  197. const uncached = [...new Set(items.map((i) => i.publishedFileId).filter((id) => !fileSizeCache.has(id)))]
  198. if (uncached.length > 0) {
  199. try {
  200. for (let i = 0; i < uncached.length; i += FILE_SIZE_BATCH) {
  201. await fetchFileSizesBatch(uncached.slice(i, i + FILE_SIZE_BATCH))
  202. }
  203. } catch (err) {
  204. console.warn('[Workshop] Failed to fetch file sizes:', err)
  205. }
  206. }
  207. for (const item of items) {
  208. const size = fileSizeCache.get(item.publishedFileId)
  209. if (size != null) item.fileSize = size
  210. }
  211. }
  212. function transformItem(
  213. item: NonNullable<Awaited<ReturnType<ReturnType<typeof getClient>['workshop']['getItem']>>>,
  214. subscribedSet: Set<string>
  215. ): WorkshopItem {
  216. return {
  217. publishedFileId: item.publishedFileId.toString(),
  218. title: item.title,
  219. description: item.description,
  220. previewUrl: item.previewUrl ?? '',
  221. creatorSteamId: item.owner.steamId64.toString(),
  222. tags: item.tags ?? [],
  223. timeCreated: item.timeCreated,
  224. timeUpdated: item.timeUpdated,
  225. subscriptions: Number(item.statistics?.numSubscriptions ?? 0),
  226. upvotes: item.numUpvotes,
  227. downvotes: item.numDownvotes,
  228. isSubscribed: subscribedSet.has(item.publishedFileId.toString())
  229. }
  230. }