Skip to main content

steamroom_cli/commands/
workshop.rs

1//! `steamroom workshop`: download a single Workshop item by its
2//! `publishedfileid`.
3//!
4//! `show_progress` mirrors `commands::download::run_download`: T10 will
5//! wire the sink-side progress events and the cancellation `select!`.
6
7use crate::cli::WorkshopArgs;
8use crate::commands::shared::decompress_manifest;
9use crate::commands::shared::fmt_size;
10use crate::download as direct_progress;
11use crate::errors::CliError;
12use crate::sink::JobSink;
13use std::path::PathBuf;
14use std::sync::Arc;
15use steamroom::cdn::CdnClient;
16use steamroom::client::LoggedIn;
17use steamroom::client::SteamClient;
18use steamroom::depot::manifest::DepotManifest;
19use steamroom::depot::*;
20use tokio_util::sync::CancellationToken;
21use tracing::info;
22
23pub async fn run_workshop(
24    args: WorkshopArgs,
25    client: SteamClient<LoggedIn>,
26    sink: Arc<dyn JobSink>,
27    cancel: CancellationToken,
28    show_progress: bool,
29) -> Result<(), CliError> {
30    info!("fetching workshop item {} details...", args.item);
31    let req = steamroom::generated::CPublishedFileGetDetailsRequest {
32        publishedfileids: vec![args.item],
33        includechildren: Some(true),
34        ..Default::default()
35    };
36    let resp = client
37        .call_service_method(
38            "PublishedFile.GetDetails#1",
39            &prost::Message::encode_to_vec(&req),
40        )
41        .await?;
42    let details: steamroom::generated::CPublishedFileGetDetailsResponse = resp.decode()?;
43
44    let item = details
45        .publishedfiledetails
46        .first()
47        .ok_or(CliError::NoProductInfo(args.app))?;
48
49    let title = item.title.as_deref().unwrap_or("(untitled)");
50    let hcontent = item.hcontent_file.unwrap_or(0);
51    let file_size = item.file_size.unwrap_or(0);
52    let consumer_app = item.consumer_appid.unwrap_or(args.app);
53    let filename = item.filename.as_deref().unwrap_or("workshop_content");
54
55    info!("workshop item: {title}");
56    info!("  content manifest: {hcontent}");
57    info!("  file: {filename} ({} bytes)", file_size);
58
59    if hcontent == 0 {
60        info!("no downloadable content for this workshop item");
61        return Ok(());
62    }
63
64    // Workshop items use the app's depot
65    let app_id = AppId(consumer_app);
66    let depot_id = DepotId(consumer_app);
67    let manifest_id = ManifestId(hcontent);
68
69    let depot_key = client.get_depot_decryption_key(depot_id, app_id).await?;
70    let cdn_servers = client.get_cdn_servers(CellId(0), Some(5)).await?;
71    if cdn_servers.is_empty() {
72        return Err(CliError::NoCdnServers);
73    }
74    let cdn_server = &cdn_servers[0];
75    let cdn_pool = steamroom::cdn::CdnServerPool::new(cdn_servers.clone());
76    let cdn = CdnClient::new().map_err(CliError::Steam)?;
77
78    let request_code = client
79        .get_manifest_request_code(app_id, depot_id, manifest_id, None, None)
80        .await?
81        .unwrap_or(0);
82
83    let manifest_data = cdn
84        .download_manifest(cdn_server, depot_id, manifest_id, request_code, None)
85        .await?;
86    let manifest_bytes = decompress_manifest(&manifest_data)?;
87    let mut manifest = DepotManifest::parse(&manifest_bytes)?;
88    if manifest.filenames_encrypted {
89        manifest.decrypt_filenames(&depot_key)?;
90    }
91
92    let output_dir = args
93        .output
94        .unwrap_or_else(|| PathBuf::from("workshop").join(args.item.to_string()));
95    std::fs::create_dir_all(&output_dir)?;
96
97    let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
98    let fetcher = steamroom_client::download::CdnChunkFetcher::new(cdn, cdn_pool, None);
99    let job = steamroom_client::download::DepotJob::builder()
100        .depot_id(depot_id)
101        .depot_key(depot_key)
102        .install_dir(output_dir.clone())
103        .event_sender(event_tx)
104        .build()
105        .map_err(|e| CliError::Io(std::io::Error::other(e)))?;
106
107    info!("downloading to {}", output_dir.display());
108
109    let progress_handle =
110        direct_progress::spawn_progress_renderer(event_rx, show_progress, Some(sink.clone()));
111
112    // Run the download inside a block so the future (and its inner event_tx)
113    // drops before we await the progress renderer. This ensures the renderer
114    // sees the channel close and runs finish_and_clear on both the success
115    // and cancellation paths.
116    let stats_result = {
117        let download_fut = job.download(&manifest, std::sync::Arc::new(fetcher));
118        tokio::pin!(download_fut);
119        tokio::select! {
120            res = &mut download_fut => Some(res.map_err(|e| CliError::Io(std::io::Error::other(e)))),
121            // Dropping the future aborts the orchestration. Spawned chunk-fetch
122            // tasks held inside DepotJob may continue until their next yield
123            // point.
124            _ = cancel.cancelled() => None,
125        }
126    };
127    // download_fut's inner future has now dropped (block scope ended); event_tx is closed.
128    let _ = progress_handle.await;
129    let stats = match stats_result {
130        Some(res) => res?,
131        None => return Err(CliError::Cancelled),
132    };
133
134    info!(
135        "workshop download complete: {} files, {}",
136        stats.files_completed,
137        fmt_size(stats.bytes_downloaded)
138    );
139    Ok(())
140}