1use crate::Client;
2use crate::steam::remote_storage::UgcHandle;
3use crate::steam::{AppId, SteamId, SteamResult};
4use crate::string_ext::FromUtf8NulTruncating;
5use chrono::offset::TimeZone;
6use chrono::{DateTime, Utc};
7use derive_more::{From, Into};
8use enum_primitive_derive::Primitive;
9use futures::Stream;
10use genawaiter::sync::Gen;
11use num_traits::FromPrimitive;
12use std::collections::BTreeMap;
13use std::convert::TryFrom;
14use std::ffi::CString;
15use std::mem::MaybeUninit;
16use std::os::raw::c_char;
17use std::{cmp, ptr, str};
18use steamworks_sys as sys;
19
20#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
21pub enum QueryType {
22 RankedByVote,
23 RankedByPublicationDate,
24 AcceptedForGameRankedByAcceptanceDate,
25 RankedByTrend,
26 FavoritedByFriendsRankedByPublicationDate,
27 CreatedByFriendsRankedByPublicationDate,
28 RankedByNumTimesReported,
29 CreatedByFollowedUsersRankedByPublicationDate,
30 NotYetRated,
31 RankedByTotalVotesAsc,
32 RankedByVotesUp,
33 RankedByTextSearch,
34 RankedByTotalUniqueSubscriptions,
35 RankedByPlaytimeTrend,
36 RankedByTotalPlaytime,
37 RankedByAveragePlaytimeTrend,
38 RankedByLifetimeAveragePlaytime,
39 RankedByPlaytimeSessionsTrend,
40 RankedByLifetimePlaytimeSessions,
41}
42
43impl From<QueryType> for sys::EUGCQuery {
44 fn from(x: QueryType) -> Self {
45 x as sys::EUGCQuery
46 }
47}
48
49#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Primitive)]
50#[repr(i32)]
51pub enum MatchingUgcType {
52 Items = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Items,
53 ItemsMtx = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Items_Mtx,
54 ItemsReadyToUse = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Items_ReadyToUse,
55 Collections = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Collections,
56 Artwork = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Artwork,
57 Videos = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Videos,
58 Screenshots = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Screenshots,
59 AllGuides = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_AllGuides,
60 WebGuides = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_WebGuides,
61 IntegratedGuides = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_IntegratedGuides,
62 UsableInGame = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_UsableInGame,
63 ControllerBindings = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_ControllerBindings,
64 GameManagedItems = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_GameManagedItems,
65 All = sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_All,
66}
67
68impl From<MatchingUgcType> for sys::EUGCMatchingUGCType {
69 fn from(x: MatchingUgcType) -> Self {
70 match x {
71 MatchingUgcType::Items => sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Items,
72 MatchingUgcType::ItemsMtx => sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Items_Mtx,
73 MatchingUgcType::ItemsReadyToUse => {
74 sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Items_ReadyToUse
75 }
76 MatchingUgcType::Collections => {
77 sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Collections
78 }
79 MatchingUgcType::Artwork => sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Artwork,
80 MatchingUgcType::Videos => sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Videos,
81 MatchingUgcType::Screenshots => {
82 sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_Screenshots
83 }
84 MatchingUgcType::AllGuides => sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_AllGuides,
85 MatchingUgcType::WebGuides => sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_WebGuides,
86 MatchingUgcType::IntegratedGuides => {
87 sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_IntegratedGuides
88 }
89 MatchingUgcType::UsableInGame => {
90 sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_UsableInGame
91 }
92 MatchingUgcType::ControllerBindings => {
93 sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_ControllerBindings
94 }
95 MatchingUgcType::GameManagedItems => {
96 sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_GameManagedItems
97 }
98 MatchingUgcType::All => sys::EUGCMatchingUGCType_k_EUGCMatchingUGCType_All,
99 }
100 }
101}
102
103#[derive(Debug, Clone)]
104pub struct UgcDetails {
105 pub published_file_id: PublishedFileId,
106 pub file_type: WorkshopFileType,
107 pub creator_app_id: AppId,
108 pub title: String,
109 pub description: String,
110 pub steam_id_owner: SteamId,
111 pub time_created: DateTime<Utc>,
112 pub time_updated: DateTime<Utc>,
113 pub time_added_to_user_list: Option<DateTime<Utc>>,
114 pub visibility: PublishedFileVisibility,
115 pub banned: bool,
116 pub accepted_for_use: bool,
117 pub tags_truncated: bool,
118 pub tags: Tags,
119 pub file: Option<UgcHandle>,
120 pub preview_file: Option<UgcHandle>,
121 pub preview_url: String,
122 pub file_name: String,
123 pub file_size: i32,
124 pub preview_file_size: i32,
125 pub url: String,
126 pub votes_up: u32,
127 pub votes_down: u32,
128 pub score: f32,
129 pub num_children: u32,
130}
131
132#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, From, Into)]
133pub struct PublishedFileId(pub u64);
134
135#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Primitive)]
136#[repr(i32)]
137pub enum WorkshopFileType {
138 Community = sys::EWorkshopFileType_k_EWorkshopFileTypeCommunity as i32,
139 Microtransaction = sys::EWorkshopFileType_k_EWorkshopFileTypeMicrotransaction as i32,
140 Collection = sys::EWorkshopFileType_k_EWorkshopFileTypeCollection as i32,
141 Art = sys::EWorkshopFileType_k_EWorkshopFileTypeArt as i32,
142 Video = sys::EWorkshopFileType_k_EWorkshopFileTypeVideo as i32,
143 Screenshot = sys::EWorkshopFileType_k_EWorkshopFileTypeScreenshot as i32,
144 Game = sys::EWorkshopFileType_k_EWorkshopFileTypeGame as i32,
145 Software = sys::EWorkshopFileType_k_EWorkshopFileTypeSoftware as i32,
146 Concept = sys::EWorkshopFileType_k_EWorkshopFileTypeConcept as i32,
147 WebGuide = sys::EWorkshopFileType_k_EWorkshopFileTypeWebGuide as i32,
148 IntegratedGuide = sys::EWorkshopFileType_k_EWorkshopFileTypeIntegratedGuide as i32,
149 Merch = sys::EWorkshopFileType_k_EWorkshopFileTypeMerch as i32,
150 ControllerBinding = sys::EWorkshopFileType_k_EWorkshopFileTypeControllerBinding as i32,
151 SteamworksAccessInvite =
152 sys::EWorkshopFileType_k_EWorkshopFileTypeSteamworksAccessInvite as i32,
153 SteamVideo = sys::EWorkshopFileType_k_EWorkshopFileTypeSteamVideo as i32,
154 GameManagedItem = sys::EWorkshopFileType_k_EWorkshopFileTypeGameManagedItem as i32,
155}
156
157impl WorkshopFileType {
158 pub(crate) fn from_inner(inner: sys::EWorkshopFileType) -> Self {
159 WorkshopFileType::from_i32(inner as i32)
160 .unwrap_or_else(|| panic!("Unknown EWorkshopFileType discriminant: {inner}"))
161 }
162}
163
164#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Primitive)]
165#[repr(i32)]
166pub enum PublishedFileVisibility {
167 Public =
168 sys::ERemoteStoragePublishedFileVisibility_k_ERemoteStoragePublishedFileVisibilityPublic as i32,
169 FriendsOnly =
170 sys::ERemoteStoragePublishedFileVisibility_k_ERemoteStoragePublishedFileVisibilityFriendsOnly as i32,
171 Private =
172 sys::ERemoteStoragePublishedFileVisibility_k_ERemoteStoragePublishedFileVisibilityPrivate as i32,
173}
174
175impl PublishedFileVisibility {
176 pub(crate) fn from_inner(inner: sys::ERemoteStoragePublishedFileVisibility) -> Self {
177 PublishedFileVisibility::from_i32(inner as i32).unwrap_or_else(|| {
178 panic!("Unknown ERemoteStoragePublishedFileVisibility discriminant: {inner}")
179 })
180 }
181}
182
183#[derive(Debug, Clone, Default)]
184pub struct Tags(String);
185
186impl Tags {
187 pub fn into_inner(self) -> String {
188 self.0
189 }
190
191 pub fn as_str(&self) -> &str {
192 &self.0
193 }
194
195 pub fn iter(&self) -> impl Iterator<Item = &str> {
196 self.into_iter()
197 }
198}
199
200impl<'a> IntoIterator for &'a Tags {
201 type Item = &'a str;
202 type IntoIter = str::Split<'a, char>;
203
204 fn into_iter(self) -> Self::IntoIter {
205 self.0.split(',')
206 }
207}
208
209#[derive(Debug, snafu::Snafu)]
210pub enum QueryAllUgcError {
211 #[snafu(display(
213 "Neither the creator App ID nor the consumer App ID was set to the App ID of the currently running application"
214 ))]
215 AppId,
216
217 #[snafu(display("CreateQueryAllUGCRequest() failed"))]
219 CreateQueryAllUGCRequest,
220
221 #[snafu(display("SendQueryUGCRequest() failed: {}", steam_result))]
223 SendQueryUGCRequest { steam_result: SteamResult },
224}
225
226#[derive(Debug, Clone)]
244pub struct QueryAllUgc {
245 client: Client,
246 query_type: QueryType,
247 matching_ugc_type: MatchingUgcType,
248 creator_app_id: Option<AppId>,
249 consumer_app_id: Option<AppId>,
250 max_results: Option<u32>,
251 match_any_tag: bool,
252 tags: BTreeMap<CString, bool>,
253 return_long_description: bool,
254}
255
256impl QueryAllUgc {
257 pub fn new(client: Client, matching_ugc_type: MatchingUgcType) -> Self {
258 QueryAllUgc {
259 client,
260 query_type: QueryType::RankedByPublicationDate,
261 matching_ugc_type,
262 creator_app_id: None,
263 consumer_app_id: None,
264 max_results: None,
265 match_any_tag: false,
266 tags: BTreeMap::new(),
267 return_long_description: false,
268 }
269 }
270
271 pub fn query_type(self, query_type: QueryType) -> Self {
276 QueryAllUgc { query_type, ..self }
277 }
278
279 pub fn creator_app_id(self, app_id: AppId) -> Self {
284 QueryAllUgc {
285 creator_app_id: Some(app_id),
286 ..self
287 }
288 }
289
290 pub fn consumer_app_id(self, app_id: AppId) -> Self {
295 QueryAllUgc {
296 consumer_app_id: Some(app_id),
297 ..self
298 }
299 }
300
301 pub fn match_any_tags(self) -> Self {
303 QueryAllUgc {
304 match_any_tag: true,
305 ..self
306 }
307 }
308
309 pub fn match_all_tags(self) -> Self {
311 QueryAllUgc {
312 match_any_tag: false,
313 ..self
314 }
315 }
316
317 pub fn required_tag(mut self, tag: impl Into<Vec<u8>>) -> Self {
319 self.tags
320 .insert(CString::new(tag).expect("Tag contains nul byte(s)"), true);
321 self
322 }
323
324 pub fn required_tags<T: Into<Vec<u8>>>(mut self, tags: impl IntoIterator<Item = T>) -> Self {
326 let tags = tags
327 .into_iter()
328 .map(|tag| (CString::new(tag).expect("Tag contains nul byte(s)"), true));
329 self.tags.extend(tags);
330 self
331 }
332
333 pub fn excluded_tag(mut self, tag: impl Into<Vec<u8>>) -> Self {
335 self.tags
336 .insert(CString::new(tag).expect("Tag contains nul byte(s)"), false);
337 self
338 }
339
340 pub fn excluded_tags<T: Into<Vec<u8>>>(mut self, tags: impl IntoIterator<Item = T>) -> Self {
342 let tags = tags
343 .into_iter()
344 .map(|tag| (CString::new(tag).expect("Tag contains nul byte(s)"), false));
345 self.tags.extend(tags);
346 self
347 }
348
349 pub fn return_long_description(self) -> Self {
351 QueryAllUgc {
352 return_long_description: true,
353 ..self
354 }
355 }
356
357 pub fn run(self) -> impl Stream<Item = Result<UgcDetails, QueryAllUgcError>> + Send {
359 Gen::new(|co| async move {
360 let current_app_id = self.client.app_id();
361 if let (Some(x), Some(y)) = (self.creator_app_id, self.consumer_app_id)
362 && x != current_app_id
363 && y != current_app_id
364 {
365 co.yield_(AppIdSnafu.fail()).await;
366 }
367
368 let max_results = self.max_results.unwrap_or(u32::MAX);
369
370 let client = self.client.clone();
371 let mut cursor: Option<Vec<c_char>> = None;
372 let mut details_returned = 0;
373 loop {
374 let handle = unsafe {
375 let pointer = match &cursor {
376 Some(x) => x.as_ptr(),
377 None => ptr::null(),
378 };
379 sys::SteamAPI_ISteamUGC_CreateQueryAllUGCRequestCursor(
380 *client.0.ugc,
381 self.query_type.into(),
382 self.matching_ugc_type.into(),
383 self.creator_app_id.unwrap_or(current_app_id).into(),
384 self.consumer_app_id.unwrap_or(current_app_id).into(),
385 pointer,
386 )
387 };
388 if handle == sys::k_UGCQueryHandleInvalid {
389 co.yield_(CreateQueryAllUGCRequestSnafu.fail()).await;
390 break;
391 }
392
393 unsafe {
394 let success = sys::SteamAPI_ISteamUGC_SetReturnLongDescription(
395 *client.0.ugc,
396 handle,
397 self.return_long_description,
398 );
399 assert!(success, "SetReturnLongDescription failed");
400
401 let success = sys::SteamAPI_ISteamUGC_SetMatchAnyTag(
402 *client.0.ugc,
403 handle,
404 self.match_any_tag,
405 );
406 assert!(success, "SetMatchAnyTag failed");
407
408 for (tag, required) in &self.tags {
409 if *required {
410 sys::SteamAPI_ISteamUGC_AddRequiredTag(
411 *client.0.ugc,
412 handle,
413 tag.as_ptr(),
414 );
415 } else {
416 sys::SteamAPI_ISteamUGC_AddExcludedTag(
417 *client.0.ugc,
418 handle,
419 tag.as_ptr(),
420 );
421 }
422 }
423 }
424
425 let response: sys::SteamUGCQueryCompleted_t = unsafe {
426 let handle = sys::SteamAPI_ISteamUGC_SendQueryUGCRequest(*client.0.ugc, handle);
427
428 self.client.register_for_call_result(handle).await
429 };
430
431 {
432 let result = SteamResult::from_inner(response.m_eResult);
433 if result != SteamResult::OK {
434 co.yield_(
435 SendQueryUGCRequestSnafu {
436 steam_result: result,
437 }
438 .fail(),
439 )
440 .await;
441 break;
442 }
443 }
444
445 let items_to_reach_quota = max_results - details_returned;
446 for i in 0..cmp::min(items_to_reach_quota, response.m_unNumResultsReturned) {
447 let mut details: MaybeUninit<sys::SteamUGCDetails_t> = MaybeUninit::uninit();
448 let success = unsafe {
449 sys::SteamAPI_ISteamUGC_GetQueryUGCResult(
450 *client.0.ugc,
451 response.m_handle,
452 i,
453 details.as_mut_ptr(),
454 )
455 };
456 assert!(success, "GetQueryUGCResult failed");
457 let details = unsafe { details.assume_init() };
458 let preview_url = unsafe {
459 let mut buf = vec![0_u8; 256];
460 sys::SteamAPI_ISteamUGC_GetQueryUGCPreviewURL(
461 *client.0.ugc,
462 response.m_handle,
463 i,
464 buf.as_mut_ptr() as *mut c_char,
465 u32::try_from(buf.len()).unwrap(),
466 );
467 String::from_utf8_nul_truncating(buf)
468 .expect("Workshop item's preview image URL is not valid UTF-8")
469 };
470 let details = UgcDetails {
471 published_file_id: PublishedFileId(details.m_nPublishedFileId),
472 file_type: WorkshopFileType::from_inner(details.m_eFileType),
473 creator_app_id: AppId(details.m_nCreatorAppID),
474 title: String::from_utf8_nul_truncating(&details.m_rgchTitle[..])
475 .expect("Workshop item's title is not valid UTF-8"),
476 description: String::from_utf8_nul_truncating(
477 &details.m_rgchDescription[..],
478 )
479 .expect("Workshop item's description is not valid UTF-8"),
480 steam_id_owner: details.m_ulSteamIDOwner.into(),
481 time_created: Utc
482 .timestamp_opt(i64::from(details.m_rtimeCreated), 0)
483 .unwrap(),
484 time_updated: Utc
485 .timestamp_opt(i64::from(details.m_rtimeUpdated), 0)
486 .unwrap(),
487 time_added_to_user_list: if details.m_rtimeAddedToUserList == 0 {
488 None
489 } else {
490 Some(
491 Utc.timestamp_opt(i64::from(details.m_rtimeAddedToUserList), 0)
492 .unwrap(),
493 )
494 },
495 visibility: PublishedFileVisibility::from_inner(details.m_eVisibility),
496 banned: details.m_bBanned,
497 accepted_for_use: details.m_bAcceptedForUse,
498 tags_truncated: details.m_bTagsTruncated,
499 tags: Tags(
500 String::from_utf8_nul_truncating(&details.m_rgchTags[..])
501 .expect("Workshop item's tags are not valid UTF-8"),
502 ),
503 file: UgcHandle::from_inner(details.m_hFile),
504 preview_file: UgcHandle::from_inner(details.m_hPreviewFile),
505 preview_url,
506 file_name: String::from_utf8_nul_truncating(&details.m_pchFileName[..])
507 .expect("Workshop item's file name is not valid UTF-8"),
508 file_size: details.m_nFileSize,
509 preview_file_size: details.m_nPreviewFileSize,
510 url: String::from_utf8_nul_truncating(&details.m_rgchURL[..])
511 .expect("Workshop item's url is not valid UTF-8"),
512 votes_up: details.m_unVotesUp,
513 votes_down: details.m_unVotesDown,
514 score: details.m_flScore,
515 num_children: details.m_unNumChildren,
516 };
517
518 co.yield_(Ok(details)).await;
519 details_returned += 1;
520 }
521
522 unsafe { sys::SteamAPI_ISteamUGC_ReleaseQueryUGCRequest(*client.0.ugc, handle) };
523
524 let more_items_wanted = items_to_reach_quota > 0;
525 let more_items_available = response.m_unTotalMatchingResults > details_returned;
526 if !more_items_wanted || !more_items_available {
527 break;
528 }
529
530 cursor = match cursor {
531 Some(mut x) => {
532 x.copy_from_slice(&response.m_rgchNextCursor);
533 Some(x)
534 }
535 None => Some(Vec::from(&response.m_rgchNextCursor[..])),
536 };
537 }
538 })
539 }
540}