workshop.service.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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. export async function getWorkshopTimesUpdated(
  115. publishedFileIds: string[]
  116. ): Promise<Map<string, number>> {
  117. const map = new Map<string, number>()
  118. if (publishedFileIds.length === 0) return map
  119. const client = getClient()
  120. const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
  121. for (const item of result.items) {
  122. if (item) map.set(item.publishedFileId.toString(), item.timeUpdated)
  123. }
  124. return map
  125. }
  126. // Steam tags an item's type ("Video", "Scene", ...) and age rating ("Everyone", ...)
  127. // alongside its genre/resolution tags, so this works even for items that never
  128. // finished downloading and have no local project.json to read that from.
  129. export async function getWorkshopTags(publishedFileIds: string[]): Promise<Map<string, string[]>> {
  130. const map = new Map<string, string[]>()
  131. if (publishedFileIds.length === 0) return map
  132. const client = getClient()
  133. const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
  134. for (const item of result.items) {
  135. if (item) map.set(item.publishedFileId.toString(), item.tags ?? [])
  136. }
  137. return map
  138. }
  139. // Used to backfill authorSteamId on library items imported before this field existed
  140. // (and on items that never finished downloading, since they have no project.json).
  141. export async function getWorkshopAuthors(publishedFileIds: string[]): Promise<Map<string, string>> {
  142. const map = new Map<string, string>()
  143. if (publishedFileIds.length === 0) return map
  144. const client = getClient()
  145. const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
  146. for (const item of result.items) {
  147. if (item) map.set(item.publishedFileId.toString(), item.owner.steamId64.toString())
  148. }
  149. return map
  150. }
  151. // Steam returns a null entry for any id that no longer resolves to a workshop
  152. // item (removed by its author or taken down), so anything missing from the
  153. // result is treated as unavailable. Lets a request error (network hiccup)
  154. // propagate rather than swallowing it here - the caller decides what "we
  155. // couldn't tell" should mean, instead of this silently reporting "all clear".
  156. export async function getUnavailableWorkshopItems(publishedFileIds: string[]): Promise<Set<string>> {
  157. const unavailable = new Set(publishedFileIds)
  158. if (publishedFileIds.length === 0) return unavailable
  159. const client = getClient()
  160. const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
  161. for (const item of result.items) {
  162. if (item) unavailable.delete(item.publishedFileId.toString())
  163. }
  164. return unavailable
  165. }
  166. // steamworks.js's UGC query bindings don't surface a file's size (the underlying Steamworks SDK
  167. // struct has it, the napi wrapper just doesn't map that field). Steam's own public web API does,
  168. // unauthenticated, for any published file regardless of subscription state - this is the exact
  169. // "File Size" figure shown on the item's Steam Workshop page. Cached per id for the process
  170. // lifetime since it barely ever changes.
  171. const fileSizeCache = new Map<string, number>()
  172. const FILE_SIZE_BATCH = 100
  173. async function fetchFileSizesBatch(ids: string[]): Promise<void> {
  174. if (ids.length === 0) return
  175. const body = new URLSearchParams({ itemcount: String(ids.length) })
  176. ids.forEach((id, i) => body.set(`publishedfileids[${i}]`, id))
  177. const res = await fetch('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', {
  178. method: 'POST',
  179. body
  180. })
  181. const data = await res.json()
  182. const details = data?.response?.publishedfiledetails ?? []
  183. for (const d of details) {
  184. if (d?.publishedfileid && d.file_size != null) {
  185. fileSizeCache.set(d.publishedfileid, Number(d.file_size))
  186. }
  187. }
  188. }
  189. async function attachFileSizes(items: WorkshopItem[]): Promise<void> {
  190. const uncached = [...new Set(items.map((i) => i.publishedFileId).filter((id) => !fileSizeCache.has(id)))]
  191. if (uncached.length > 0) {
  192. try {
  193. for (let i = 0; i < uncached.length; i += FILE_SIZE_BATCH) {
  194. await fetchFileSizesBatch(uncached.slice(i, i + FILE_SIZE_BATCH))
  195. }
  196. } catch (err) {
  197. console.warn('[Workshop] Failed to fetch file sizes:', err)
  198. }
  199. }
  200. for (const item of items) {
  201. const size = fileSizeCache.get(item.publishedFileId)
  202. if (size != null) item.fileSize = size
  203. }
  204. }
  205. function transformItem(
  206. item: NonNullable<Awaited<ReturnType<ReturnType<typeof getClient>['workshop']['getItem']>>>,
  207. subscribedSet: Set<string>
  208. ): WorkshopItem {
  209. return {
  210. publishedFileId: item.publishedFileId.toString(),
  211. title: item.title,
  212. description: item.description,
  213. previewUrl: item.previewUrl ?? '',
  214. creatorSteamId: item.owner.steamId64.toString(),
  215. tags: item.tags ?? [],
  216. timeCreated: item.timeCreated,
  217. timeUpdated: item.timeUpdated,
  218. subscriptions: Number(item.statistics?.numSubscriptions ?? 0),
  219. upvotes: item.numUpvotes,
  220. downvotes: item.numDownvotes,
  221. isSubscribed: subscribedSet.has(item.publishedFileId.toString())
  222. }
  223. }