steamworks/steam/
ugc.rs

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    /// Neither the creator App ID nor the consumer App ID was set to the App ID of the currently running application
212    #[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    /// `CreateQueryAllUGCRequest()` failed
218    #[snafu(display("CreateQueryAllUGCRequest() failed"))]
219    CreateQueryAllUGCRequest,
220
221    /// `SendQueryUGCRequest()` failed
222    #[snafu(display("SendQueryUGCRequest() failed: {}", steam_result))]
223    SendQueryUGCRequest { steam_result: SteamResult },
224}
225
226/// A builder for configuring a request to query all UGC.
227///
228/// See <https://partner.steamgames.com/doc/features/workshop/implementation#QueryContent> for an
229/// overview of how querying UGC content works in Steamworks.
230///
231/// # Example
232///
233/// ```no_run
234/// # let client: steamworks::Client = unimplemented!();
235/// use steamworks::ugc::{MatchingUgcType, QueryType};
236///
237/// let ugc = client
238///     .query_all_ugc(MatchingUgcType::ItemsReadyToUse)
239///     .query_type(QueryType::RankedByPublicationDate)
240///     .required_tag("Sprint")
241///     .run();
242/// ```
243#[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    /// Sets the eQueryType argument of
272    /// [CreateQueryAllUGCRequest](https://partner.steamgames.com/doc/api/ISteamUGC#CreateQueryAllUGCRequest)
273    ///
274    /// Defaults to `RankedByPublicationDate`
275    pub fn query_type(self, query_type: QueryType) -> Self {
276        QueryAllUgc { query_type, ..self }
277    }
278
279    /// Sets the nCreatorAppID argument of
280    /// [CreateQueryAllUGCRequest](https://partner.steamgames.com/doc/api/ISteamUGC#CreateQueryAllUGCRequest)
281    ///
282    /// Defaults to the current application's App ID.
283    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    /// Sets the nConsumerAppID argument of
291    /// [CreateQueryAllUGCRequest](https://partner.steamgames.com/doc/api/ISteamUGC#CreateQueryAllUGCRequest)
292    ///
293    /// Defaults to the current application's App ID.
294    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    /// <https://partner.steamgames.com/doc/api/ISteamUGC#SetMatchAnyTag>
302    pub fn match_any_tags(self) -> Self {
303        QueryAllUgc {
304            match_any_tag: true,
305            ..self
306        }
307    }
308
309    /// <https://partner.steamgames.com/doc/api/ISteamUGC#SetMatchAnyTag>
310    pub fn match_all_tags(self) -> Self {
311        QueryAllUgc {
312            match_any_tag: false,
313            ..self
314        }
315    }
316
317    /// <https://partner.steamgames.com/doc/api/ISteamUGC#AddRequiredTag>
318    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    /// <https://partner.steamgames.com/doc/api/ISteamUGC#AddRequiredTag>
325    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    /// <https://partner.steamgames.com/doc/api/ISteamUGC#AddExcludedTag>
334    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    /// <https://partner.steamgames.com/doc/api/ISteamUGC#AddExcludedTag>
341    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    /// <https://partner.steamgames.com/doc/api/ISteamUGC#SetReturnLongDescription>
350    pub fn return_long_description(self) -> Self {
351        QueryAllUgc {
352            return_long_description: true,
353            ..self
354        }
355    }
356
357    /// Executes the query.
358    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}