1mod search;
43
44static USER_AGENT: LazyLock<String> = LazyLock::new(|| format!("{}/v{}", "rs-steamwebapi", env!("CARGO_PKG_VERSION")) );
45
46use serde::{Deserialize, Serialize};
47use std::{fs, path::Path, collections::HashMap, fmt};
48use std::fmt::{Debug, Display, Formatter};
49use std::fs::DirEntry;
50use std::sync::LazyLock;
51use reqwest::blocking::Client;
52use serde_json::Value;
53use crate::search::{WSSearchItem, WSSearchResponse};
54
55#[derive(Serialize, Deserialize, Clone, PartialEq)]
56pub struct WorkshopItem {
57 pub result: i8,
58 pub publishedfileid: String,
59 pub creator: String,
60 #[serde(alias = "creator_appid")]
61 pub creator_app_id: u32,
62 #[serde(alias = "consumer_appid")]
63 pub consumer_app_id: u32,
64 pub filename: String,
65 pub file_size: String,
66 pub file_url: Option<String>,
67 pub preview_url: String,
68 pub hcontent_file: String,
69 pub hcontent_preview: String,
70 pub title: String,
71 #[serde(alias = "file_description")]
72 pub description: String,
73 pub time_created: usize,
74 pub time_updated: usize,
75 pub subscriptions: u32,
76 pub favorited: u32,
77 pub views: u32,
78 pub tags: Vec<WorkshopItemTag>,
79 pub visibility: u8
80}
81
82pub enum PublishedFileQueryType {
83 RankedByVote = 0,
84 RankedByPublicationDate = 1,
85 AcceptedForGameRankedByAcceptanceDate = 2,
86 RankedByTrend = 3,
87 FavoritedByFriendsRankedByPublicationDate = 4,
88 CreatedByFriendsRankedByPublicationDate = 5,
89 RankedByNumTimesReported = 6,
90 CreatedByFollowedUsersRankedByPublicationDate = 7,
91 NotYetRated = 8,
92 RankedByTotalUniqueSubscriptions = 9,
93 RankedByTotalVotesAsc = 10,
94 RankedByVotesUp = 11,
95 RankedByTextSearch = 12,
96 RankedByPlaytimeTrend = 13,
97 RankedByTotalPlaytime = 14,
98 RankedByAveragePlaytimeTrend = 15,
99 RankedByLifetimeAveragePlaytime = 16,
100 RankedByPlaytimeSessionsTrend = 17,
101 RankedByLifetimePlaytimeSessions = 18,
102 RankedByInappropriateContentRating = 19,
103 RankedByBanContentCheck = 20,
104 RankedByLastUpdatedDate = 21,
105}
106
107#[derive(Clone)]
108pub struct SearchTagOptions {
109 pub tags: Vec<String>,
110 pub require_all: bool
113}
114pub enum QueryType {
115 RankedByTrend { days: Option<u32> }
119}
120#[derive(Default, Clone)]
121pub struct SearchOptions {
122 pub count: u32,
123 pub app_id: u32,
124 pub query: String,
125 pub cursor: Option<String>,
127 pub required_tags: Option<SearchTagOptions>,
128 pub excluded_tags: Option<Vec<String>>
130}
131
132pub struct SearchResult {
133 pub options: SearchOptions,
134
135 pub next_cursor: String,
136 pub items: Vec<WSSearchItem>,
137 pub total_items: u32
138}
139
140impl SearchResult {
141 pub fn next(&self, ws: &SteamWorkshop) -> Result<SearchResult, Error> {
142 ws.search_items(&self.options)
143 }
144}
145
146#[derive(Serialize, Deserialize, Clone, PartialEq)]
147pub struct ItemResponse {
148 pub result: i8,
149 pub publishedfileid: String,
150}
151
152impl fmt::Display for WorkshopItem {
153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154 write!(f, "{} - {}", self.title, self.publishedfileid)
155 }
156}
157
158#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
159pub struct WorkshopItemTag {
160 pub tag: String,
161 #[serde(rename = "display_name")]
162 pub display_name: Option<String>,
163}
164
165#[doc(hidden)]
167#[derive(Serialize, Deserialize)]
168struct WSResponse<T> {
169 response: T
170}
171
172#[doc(hidden)]
174#[derive(Serialize, Deserialize)]
175struct WSCollectionResponse {
176 response: WSCollectionResponseBody
177}
178#[doc(hidden)]
179#[derive(Serialize, Deserialize)]
180struct WSCollectionResponseBody {
181 result: u8,
182 resultcount: u8,
183 collectiondetails: Vec<WSCollectionBody>
184}
185#[doc(hidden)]
186#[derive(Serialize, Deserialize)]
187struct WSCollectionBody {
188 publishedfileid: String,
189 result: u8,
190 children: Vec<WSCollectionChildren>
191}
192#[doc(hidden)]
193#[derive(Serialize, Deserialize)]
194struct WSCollectionChildren {
195 publishedfileid: String,
196 sortorder: u8,
197 filetype: u8
198}
199#[derive(Clone)]
200pub struct SteamWorkshop {
201 client: Client,
202 apikey: Option<String>,
203 request_domain: String
204}
205
206pub enum Error {
207 NotAuthorized,
209 RequestError(reqwest::Error),
210 BadRequest(String)
211}
212
213impl Debug for Error {
214 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
215 match self {
216 Error::NotAuthorized => write!(f, "Request is not authorized, please use .set_apikey, or .set_proxy_domain"),
217 Error::RequestError(e) => write!(f, "request error: {}", e),
218 Error::BadRequest(e) => write!(f, "bad request data: {}", e),
219 }
220 }
221}
222
223impl Display for Error {
224 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
225 match self {
226 Error::NotAuthorized => write!(f, "Not authorized"),
227 Error::RequestError(e) => write!(f, "Request Error: {}", e),
228 Error::BadRequest(e) => write!(f, "Incorrect request: {}", e),
229 }
230 }
231}
232
233impl std::error::Error for Error {}
234
235#[allow(dead_code)]
236impl SteamWorkshop {
237 pub fn new() -> SteamWorkshop {
239 let client= Client::new();
240 SteamWorkshop::new_with_client(client)
241 }
242 pub fn new_with_client(client: Client) -> SteamWorkshop {
243 SteamWorkshop {
244 client,
245 request_domain: "api.steampowered.com".to_string(),
246 apikey: None
247 }
248 }
249
250 pub fn set_apikey(&mut self, apikey: Option<String>) {
253 self.apikey = apikey;
254 }
255
256 pub fn set_proxy_domain(&mut self, proxy_domain: Option<String>) {
258 self.request_domain = proxy_domain.unwrap_or("api.steampowered.com".to_string());
259 }
260
261 pub fn get_vpks_in_folder(dir: &Path) -> Result<Vec<DirEntry>, String> {
263 let entries = fs::read_dir(dir).map_err(|e| e.to_string())?;
264 let mut files: Vec<DirEntry> = Vec::new();
265 for entry in entries {
266 let entry = entry.map_err(|e| e.to_string())?;
267 let file_name = entry.file_name();
268 let file_name = file_name.to_string_lossy();
269 if file_name.ends_with(".vpk") {
270 files.push(entry)
271 }
272 }
273 return Ok(files);
274 }
275
276 pub fn get_published_file_details(&self, fileids: &[String]) -> Result<Vec<WorkshopItem>, Error> {
279 let mut params = HashMap::new();
280 let length = fileids.len().to_string();
281 params.insert("itemcount".to_string(), length);
282 for (i, vpk) in fileids.iter().enumerate() {
283 if !vpk.parse::<u64>().is_ok() {
284 return Err(Error::BadRequest(format!("Item is not valid publishedfileid: {}", vpk)));
285 }
286 let name = format!("publishedfileids[{i}]", i=i);
287 params.insert(name, vpk.to_string());
288 }
289 let mut details = self.client
290 .post(format!("https://{}/ISteamRemoteStorage/GetPublishedFileDetails/v1/", self.request_domain))
291 .header("User-Agent", &USER_AGENT.to_string())
292 .form(¶ms)
293 .send().map_err(|e| Error::RequestError(e))?
294 .error_for_status().map_err(|e| Error::RequestError(e))?
295 .json::<Value>().map_err(|e| Error::RequestError(e))?;
296
297 Ok(details["response"]["publishedfiledetails"].as_array_mut().unwrap().iter_mut()
298 .filter(|v| v["result"] == 1)
299 .map(|v| serde_json::from_value(v.take()).unwrap())
300 .collect()
301 )
302 }
303
304 pub fn get_collection_details(&self, fileid: &str) -> Result<Option<Vec<String>>, Error> {
308 let mut params = HashMap::new();
309 params.insert("collectioncount", "1");
310 params.insert("publishedfileids[0]", &fileid);
311 let details: WSCollectionResponse = self.client
312 .post(format!("https://{}/ISteamRemoteStorage/GetCollectionDetails/v1/", self.request_domain))
313 .header("User-Agent", USER_AGENT.to_string())
314 .form(¶ms)
315 .send().map_err(|e| Error::RequestError(e))?
316 .error_for_status().map_err(|e| Error::RequestError(e))?
317 .json::<WSCollectionResponse>().map_err(|e| Error::RequestError(e))?;
318
319 if details.response.resultcount > 0 {
320 let mut ids: Vec<String> = Vec::new();
321 for children in &details.response.collectiondetails[0].children {
322 ids.push(children.publishedfileid.to_string());
323 }
324 Ok(Some(ids))
325 } else {
326 Ok(None)
327 }
328 }
329
330 pub fn search_items(&self, options: &SearchOptions) -> Result<SearchResult, Error> {
333 if self.apikey.is_none() || self.request_domain != "api.steampowered.com" {
334 return Err(Error::NotAuthorized)
335 }
336 let apikey: &str = self.apikey.as_deref().unwrap_or("");
337 let appid = options.app_id.to_string();
338 let mut query: Vec<(&str, String)> = vec![
339 ("page", "1".to_string()),
340 ("numperpage", options.count.to_string()),
341 ("cursor", options.cursor.as_deref().unwrap_or("*").to_string()),
342 ("search_text", options.query.to_string()),
343 ("appid", appid.clone()),
344 ("creator_appid", appid),
345 ("return_metadata", "1".to_string()),
346 ("key", apikey.to_string()),
347 ];
348 if let Some(rt) = &options.required_tags {
349 query.push(("requiredtags", rt.tags.join(",")));
350 query.push(("match_all_tags", if rt.require_all { "1".to_string() } else { "0".to_string() }));
351 }
352 if let Some(tags) = &options.excluded_tags {
353 query.push(("excludedtags", tags.join(",")));
354 }
355 let details = self.client
356 .get(format!("https://{}/IPublishedFileService/QueryFiles/v1/?", self.request_domain))
357 .header("User-Agent", USER_AGENT.to_string())
358 .query(&query)
359 .body("")
360 .send()
361 .map_err(|e| Error::RequestError(e))?
362 .json::<WSResponse<WSSearchResponse>>()
363 .map_err(|e| Error::RequestError(e))?;
364 let details = details.response;
365 let mut next_options = options.clone();
366 let next_cursor = details.next_cursor.expect("no cursor found");
367 next_options.cursor = Some(next_cursor.clone());
368
369 Ok(SearchResult {
370 options: next_options,
371 next_cursor,
372 items: details.publishedfiledetails,
373 total_items: details.total
374 })
375 }
376
377 pub fn can_subscribe(&self, publishedfileid: &str) -> Result<bool, Error> {
380 if self.apikey.is_none() {
381 return Err(Error::NotAuthorized)
382 }
383
384 let details: Value = self.client
385 .get(format!("https://{}/IPublishedFileService/CanSubscribe/v1/?", self.request_domain))
386 .header("User-Agent", USER_AGENT.to_string())
387 .query(&[
388 "key", &self.apikey.as_ref().unwrap(),
389 "publishedfileid", publishedfileid
390 ])
391 .send().map_err(|e| Error::RequestError(e))?
392 .error_for_status().map_err(|e| Error::RequestError(e))?
393 .json().map_err(|e| Error::RequestError(e))?;
394 Ok(details["response"]["can_subscribe"].as_bool().unwrap_or(false))
395 }
396
397 pub fn subscribe(&self, publishedfileid: &str, include_dependencies: bool) -> Result<(), Error> {
405 if self.apikey.is_none() {
406 return Err(Error::NotAuthorized)
407 }
408 self.client
409 .post(format!("https://{}/IPublishedFileService/Subscribe/v1/?", self.request_domain))
410 .header("User-Agent", USER_AGENT.to_string())
411 .body("")
412 .query(&[
413 ("list_type", "1"),
414 ("key", &self.apikey.as_deref().unwrap()),
415 ("publishedfileid", publishedfileid),
416 ("include_dependencies", if include_dependencies { "1" } else { "0" })
417 ])
418 .send().map_err(|e| Error::RequestError(e))?
419 .error_for_status().map_err(|e| Error::RequestError(e))?;
420 Ok(())
421 }
422
423 pub fn unsubscribe(&self, publishedfileid: &str) -> Result<(), Error> {
426 if self.apikey.is_none() {
427 return Err(Error::NotAuthorized)
428 }
429 self.client
430 .post(format!("https://{}/IPublishedFileService/Unsubscribe/v1/?", self.request_domain))
431 .header("User-Agent", USER_AGENT.to_string())
432 .body("")
433 .query(&[
434 ("list_type", "1"),
435 ("key", &self.apikey.as_deref().unwrap()),
436 ("publishedfileid", publishedfileid),
437 ])
438 .send().map_err(|e| Error::RequestError(e))?
439 .error_for_status().map_err(|e| Error::RequestError(e))?;
440 Ok(())
441 }
442}