fetch DLC ID's from steamcmd #108

This commit is contained in:
Tickbase
2026-05-06 16:12:54 +02:00
parent cbed56ac33
commit 756a6e3d53
+135 -180
View File
@@ -650,106 +650,132 @@ pub async fn uninstall_koaloader(game: EpicGame, app_handle: AppHandle) -> Resul
Ok(()) Ok(())
} }
// Fetch DLC details from Steam API (simple version without progress) // steamcmd helpers
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
let client = reqwest::Client::new();
let base_url = format!(
"https://store.steampowered.com/api/appdetails?appids={}",
app_id
);
let response = client /// Calls `https://api.steamcmd.net/v1/info/{app_id}` and returns the per-app
.get(&base_url) /// JSON object (`data[app_id]`), or `None` on any failure.
.timeout(Duration::from_secs(10)) async fn fetch_steamcmd_info(
client: &reqwest::Client,
app_id: &str,
) -> Option<serde_json::Value> {
let url = format!("https://api.steamcmd.net/v1/info/{}", app_id);
let resp = client
.get(&url)
.header("User-Agent", "CreamLinux-Installer")
.timeout(Duration::from_secs(15))
.send() .send()
.await .await
.map_err(|e| format!("Failed to fetch game details: {}", e))?; .ok()?;
if !response.status().is_success() { let json: serde_json::Value = resp.json().await.ok()?;
return Err(format!(
"Failed to fetch game details: HTTP {}", if json.get("status").and_then(|s| s.as_str()) != Some("success") {
response.status() return None;
));
} }
let data: serde_json::Value = response json.get("data").and_then(|d| d.get(app_id)).cloned()
.json() }
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let dlc_ids = match data /// Extracts DLC app-IDs from a game's steamcmd info object.
.get(app_id) /// Merges two sources and deduplicates:
.and_then(|app| app.get("data")) /// 1. `extended.listofdlc` - comma-separated string
.and_then(|data| data.get("dlc")) /// 2. `depots[*].dlcappid` - per-depot numeric field
fn extract_dlc_ids(info: &serde_json::Value) -> Vec<String> {
let mut ids: std::collections::HashSet<String> = std::collections::HashSet::new();
// Source 1 - extended.listofdlc
if let Some(list) = info
.get("extended")
.and_then(|e| e.get("listofdlc"))
.and_then(|v| v.as_str())
{ {
Some(dlc_array) => match dlc_array.as_array() { for raw in list.split(',') {
Some(array) => array let id = raw.trim();
.iter() if !id.is_empty() {
.filter_map(|id| id.as_u64().map(|n| n.to_string())) ids.insert(id.to_string());
.collect::<Vec<String>>(), }
_ => Vec::new(), }
}, }
_ => Vec::new(),
};
info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id); // Source 2 - depots[*].dlcappid
if let Some(depots) = info.get("depots").and_then(|d| d.as_object()) {
for (_key, depot) in depots {
if let Some(dlc_id) = depot.get("dlcappid").and_then(|v| {
v.as_u64()
.map(|n| n.to_string())
.or_else(|| v.as_str().map(|s| s.to_string()))
}) {
if !dlc_id.is_empty() {
ids.insert(dlc_id);
}
}
}
}
let mut sorted: Vec<String> = ids.into_iter().collect();
sorted.sort();
sorted
}
/// Fetches the display name for a single DLC from steamcmd.net.
/// Returns `None` if the call fails or the name is empty / "Unknown".
async fn fetch_dlc_name(
client: &reqwest::Client,
dlc_id: &str,
) -> Option<String> {
let info = fetch_steamcmd_info(client, dlc_id).await?;
let name = info
.get("common")
.and_then(|c| c.get("name"))
.and_then(|n| n.as_str())?;
if name.is_empty() || name.eq_ignore_ascii_case("unknown") {
return None;
}
Some(name.to_string())
}
// Fetch DLC details from steamcmd.net (simple version without progress)
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
info!("Fetching DLC list via steamcmd.net for game ID: {}", app_id);
let client = reqwest::Client::new();
// Step 1: get game info → extract DLC IDs
let game_info = fetch_steamcmd_info(&client, app_id)
.await
.ok_or_else(|| format!("steamcmd.net returned no data for app {}", app_id))?;
let dlc_ids = extract_dlc_ids(&game_info);
info!("Found {} DLC IDs for game {}", dlc_ids.len(), app_id);
// Step 2: fetch name for each ID; skip any that resolve to Unknown
let mut dlc_details = Vec::new(); let mut dlc_details = Vec::new();
for dlc_id in dlc_ids { for dlc_id in &dlc_ids {
let dlc_url = format!( tokio::time::sleep(Duration::from_millis(100)).await;
"https://store.steampowered.com/api/appdetails?appids={}",
dlc_id
);
// Add a small delay to avoid rate limiting match fetch_dlc_name(&client, dlc_id).await {
tokio::time::sleep(Duration::from_millis(300)).await; Some(name) => {
info!("Found DLC: {} ({})", name, dlc_id);
let dlc_response = client
.get(&dlc_url)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch DLC details: {}", e))?;
if dlc_response.status().is_success() {
let dlc_data: serde_json::Value = dlc_response
.json()
.await
.map_err(|e| format!("Failed to parse DLC response: {}", e))?;
let dlc_name = match dlc_data
.get(&dlc_id)
.and_then(|app| app.get("data"))
.and_then(|data| data.get("name"))
{
Some(name) => match name.as_str() {
Some(s) => s.to_string(),
_ => "Unknown DLC".to_string(),
},
_ => "Unknown DLC".to_string(),
};
info!("Found DLC: {} ({})", dlc_name, dlc_id);
dlc_details.push(DlcInfo { dlc_details.push(DlcInfo {
appid: dlc_id, appid: dlc_id.clone(),
name: dlc_name, name,
}); });
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { }
// If rate limited, wait longer None => {
error!("Rate limited by Steam API, waiting 10 seconds"); info!("Skipping DLC {} - no name / unknown", dlc_id);
tokio::time::sleep(Duration::from_secs(10)).await; }
} }
} }
info!( info!("Successfully retrieved {} named DLCs", dlc_details.len());
"Successfully retrieved details for {} DLCs",
dlc_details.len()
);
Ok(dlc_details) Ok(dlc_details)
} }
// Fetch DLC details from Steam API with progress updates // Fetch DLC details from steamcmd.net with progress updates
pub async fn fetch_dlc_details_with_progress( pub async fn fetch_dlc_details_with_progress(
app_id: &str, app_id: &str,
app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
@@ -759,79 +785,45 @@ pub async fn fetch_dlc_details_with_progress(
app_id app_id
); );
// Get a reference to a cancellation flag from app state
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
let should_cancel = state.fetch_cancellation.clone(); let should_cancel = state.fetch_cancellation.clone();
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let base_url = format!(
"https://store.steampowered.com/api/appdetails?appids={}",
app_id
);
// Emit initial progress // Step 1: fetch game info to get DLC ID list
emit_dlc_progress(app_handle, "Looking up game details...", 5, None); emit_dlc_progress(app_handle, "Looking up game details...", 5, None);
info!("Emitted initial DLC progress: 5%");
let response = client let game_info = fetch_steamcmd_info(&client, app_id)
.get(&base_url)
.timeout(Duration::from_secs(10))
.send()
.await .await
.map_err(|e| format!("Failed to fetch game details: {}", e))?; .ok_or_else(|| format!("steamcmd.net returned no data for app {}", app_id))?;
if !response.status().is_success() { let dlc_ids = extract_dlc_ids(&game_info);
let error_msg = format!("Failed to fetch game details: HTTP {}", response.status()); let total_dlcs = dlc_ids.len();
error!("{}", error_msg);
return Err(error_msg);
}
let data: serde_json::Value = response info!("Found {} DLC IDs for game {}", total_dlcs, app_id);
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let dlc_ids = match data
.get(app_id)
.and_then(|app| app.get("data"))
.and_then(|data| data.get("dlc"))
{
Some(dlc_array) => match dlc_array.as_array() {
Some(array) => array
.iter()
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
.collect::<Vec<String>>(),
_ => Vec::new(),
},
_ => Vec::new(),
};
info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id);
emit_dlc_progress( emit_dlc_progress(
app_handle, app_handle,
&format!("Found {} DLCs. Fetching details...", dlc_ids.len()), &format!("Found {} DLCs. Fetching details...", total_dlcs),
10, 10,
None, None,
); );
info!("Emitted DLC progress: 10%, found {} DLCs", dlc_ids.len());
// Step 2: fetch each DLC name, emit as we go, skip unknowns
let mut dlc_details = Vec::new(); let mut dlc_details = Vec::new();
let total_dlcs = dlc_ids.len();
for (index, dlc_id) in dlc_ids.iter().enumerate() { for (index, dlc_id) in dlc_ids.iter().enumerate() {
// Check if cancellation was requested // Check for cancellation
if should_cancel.load(Ordering::SeqCst) { if should_cancel.load(Ordering::SeqCst) {
info!("DLC fetch cancelled for game {}", app_id); info!("DLC fetch cancelled for game {}", app_id);
return Err("Operation cancelled by user".to_string()); return Err("Operation cancelled by user".to_string());
} }
let progress_percent = 10.0 + (index as f32 / total_dlcs as f32) * 90.0; let progress_rounded = (10.0 + (index as f32 / total_dlcs as f32) * 90.0) as u32;
let progress_rounded = progress_percent as u32; let remaining = total_dlcs - index;
let remaining_dlcs = total_dlcs - index;
// Estimate time remaining (rough calculation - 300ms per DLC) let est_time_left = if remaining > 0 {
let est_time_left = if remaining_dlcs > 0 { // steamcmd is ~100 ms/request, much faster than the old 300 ms Steam API
let seconds = (remaining_dlcs as f32 * 0.3).ceil() as u32; let seconds = (remaining as f32 * 0.15).ceil() as u32;
if seconds < 60 { if seconds < 60 {
format!("~{} seconds", seconds) format!("~{} seconds", seconds)
} else { } else {
@@ -842,10 +834,10 @@ pub async fn fetch_dlc_details_with_progress(
}; };
info!( info!(
"Processing DLC {}/{} - Progress: {}%", "Processing DLC {}/{} ({})",
index + 1, index + 1,
total_dlcs, total_dlcs,
progress_rounded dlc_id
); );
emit_dlc_progress( emit_dlc_progress(
app_handle, app_handle,
@@ -854,72 +846,36 @@ pub async fn fetch_dlc_details_with_progress(
Some(&est_time_left), Some(&est_time_left),
); );
let dlc_url = format!( // Small delay to be polite to the API
"https://store.steampowered.com/api/appdetails?appids={}", tokio::time::sleep(Duration::from_millis(100)).await;
dlc_id
);
// Add a small delay to avoid rate limiting match fetch_dlc_name(&client, dlc_id).await {
tokio::time::sleep(Duration::from_millis(300)).await; Some(name) => {
info!("Found DLC: {} ({})", name, dlc_id);
let dlc_response = client
.get(&dlc_url)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch DLC details: {}", e))?;
if dlc_response.status().is_success() {
let dlc_data: serde_json::Value = dlc_response
.json()
.await
.map_err(|e| format!("Failed to parse DLC response: {}", e))?;
let dlc_name = match dlc_data
.get(&dlc_id)
.and_then(|app| app.get("data"))
.and_then(|data| data.get("name"))
{
Some(name) => match name.as_str() {
Some(s) => s.to_string(),
_ => "Unknown DLC".to_string(),
},
_ => "Unknown DLC".to_string(),
};
info!("Found DLC: {} ({})", dlc_name, dlc_id);
let dlc_info = DlcInfo { let dlc_info = DlcInfo {
appid: dlc_id.clone(), appid: dlc_id.clone(),
name: dlc_name, name,
}; };
// Emit each DLC as we find it // Emit immediately so the UI updates as DLCs arrive
if let Ok(json) = serde_json::to_string(&dlc_info) { if let Ok(json) = serde_json::to_string(&dlc_info) {
if let Err(e) = app_handle.emit("dlc-found", json) { if let Err(e) = app_handle.emit("dlc-found", json) {
warn!("Failed to emit dlc-found event: {}", e); warn!("Failed to emit dlc-found event: {}", e);
} else {
info!("Emitted dlc-found event for DLC: {}", dlc_id);
} }
} }
dlc_details.push(dlc_info); dlc_details.push(dlc_info);
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { }
// If rate limited, wait longer None => {
error!("Rate limited by Steam API, waiting 10 seconds"); info!("Skipping DLC {} - no name / unknown", dlc_id);
emit_dlc_progress( }
app_handle,
"Rate limited by Steam. Waiting...",
progress_rounded,
None,
);
tokio::time::sleep(Duration::from_secs(10)).await;
} }
} }
// Final progress update
info!( info!(
"Completed DLC fetch. Found {} DLCs in total", "Completed DLC fetch. Found {} named DLCs out of {} IDs",
dlc_details.len() dlc_details.len(),
total_dlcs
); );
emit_dlc_progress( emit_dlc_progress(
app_handle, app_handle,
@@ -927,7 +883,6 @@ pub async fn fetch_dlc_details_with_progress(
100, 100,
None, None,
); );
info!("Emitted final DLC progress: 100%");
Ok(dlc_details) Ok(dlc_details)
} }