feat: add official sync progress logging

This commit is contained in:
2026-07-07 22:13:07 +08:00
parent b2d6871926
commit ea0920e4e3
10 changed files with 639 additions and 21 deletions
+355 -6
View File
@@ -8,8 +8,9 @@
use crate::{
build_official_pull_plan_for_platform_inventory, build_official_sync_plan,
changed_endpoint_urls, default_official_platforms, OfficialGameMainConfigBootstrapService,
OfficialLauncherBootstrapService, OfficialResourcePullPlan, OfficialResourcePullService,
YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
OfficialLauncherBootstrapService, OfficialResourcePullPlan, OfficialResourcePullProgress,
OfficialResourcePullProgressKind, OfficialResourcePullService, YostarJpLauncherGameConfig,
YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
};
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use bat_adapters::official::inventory::{
@@ -385,6 +386,25 @@ pub struct OfficialUpdateReport {
pub snapshot_written: Option<PathBuf>,
}
/// Human-readable progress emitted while one official update run is executing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialUpdateProgress {
/// Stable progress stage label.
pub stage: &'static str,
/// Human-readable status line.
pub message: String,
}
impl OfficialUpdateProgress {
/// Creates a progress event.
pub fn new(stage: &'static str, message: impl Into<String>) -> Self {
Self {
stage,
message: message.into(),
}
}
}
/// Official update runner.
#[derive(Debug, Clone, Default)]
pub struct OfficialUpdateService;
@@ -397,10 +417,40 @@ impl OfficialUpdateService {
/// Executes one official update run.
pub fn run(&self, config: &OfficialUpdateConfig) -> anyhow::Result<OfficialUpdateReport> {
self.run_with_progress(config, |_| {})
}
/// Executes one official update run and emits human-readable progress
/// events. Structured reports are still returned separately.
pub fn run_with_progress(
&self,
config: &OfficialUpdateConfig,
mut progress: impl FnMut(OfficialUpdateProgress),
) -> anyhow::Result<OfficialUpdateReport> {
progress(OfficialUpdateProgress::new(
"start",
format!(
"starting official resource sync: output={} dry_run={} auto_discover={}",
config.output_root.display(),
config.dry_run,
config.auto_discover
),
));
let _lock = if config.dry_run {
progress(OfficialUpdateProgress::new(
"lock",
"dry-run: skipping state lock",
));
None
} else {
Some(OfficialUpdateLock::acquire(config)?)
progress(OfficialUpdateProgress::new(
"lock",
format!("acquiring state lock {}", config.lock_path().display()),
));
let lock = OfficialUpdateLock::acquire(config)?;
progress(OfficialUpdateProgress::new("lock", "state lock acquired"));
Some(lock)
};
let default_platforms = default_official_platforms();
@@ -416,14 +466,27 @@ impl OfficialUpdateService {
let bootstrap_cache_path = config.bootstrap_cache_path();
let bootstrap = if config.auto_discover {
progress(OfficialUpdateProgress::new(
"bootstrap",
format!(
"auto-discover enabled; launcher_version={} cache={}",
config.launcher_version,
bootstrap_cache_path.display()
),
));
Some(resolve_bootstrap(
&config.launcher_version,
&config.curl_command,
&config.unzip_command,
&bootstrap_cache_path,
!config.dry_run,
&mut progress,
)?)
} else {
progress(OfficialUpdateProgress::new(
"bootstrap",
"auto-discover disabled; using explicit metadata inputs",
));
None
};
@@ -451,8 +514,24 @@ impl OfficialUpdateService {
"missing connection group; pass it explicitly or enable auto-discover"
)
})?;
progress(OfficialUpdateProgress::new(
"metadata",
format!(
"using app_version={} connection_group={} platforms={}",
app_version,
connection_group,
platforms_label(platforms)
),
));
let server_info_bytes = if let Some(source) = config.server_info_source.as_ref() {
progress(OfficialUpdateProgress::new(
"server-info",
format!(
"loading server-info from {}",
server_info_source_label(source)
),
));
load_server_info(source, &fetcher)?
} else {
let bootstrap = bootstrap.as_ref().ok_or_else(|| {
@@ -467,19 +546,47 @@ impl OfficialUpdateService {
.ok_or_else(|| {
anyhow::anyhow!("official GameMainConfig has no ServerInfoDataUrl")
})?;
progress(OfficialUpdateProgress::new(
"server-info",
format!("fetching discovered server-info {url}"),
));
fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)?
};
progress(OfficialUpdateProgress::new(
"server-info",
format!("parsing server-info bytes={}", server_info_bytes.len()),
));
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?;
let current_snapshot = server_info
.sync_snapshot(&connection_group, &app_version, platforms)
.map_err(anyhow::Error::msg)?;
let endpoint_markers = collect_endpoint_markers(&fetcher, &current_snapshot)?;
progress(OfficialUpdateProgress::new(
"discovery",
format!(
"resolved addressables_root={} endpoints={}",
current_snapshot.addressables_root,
current_snapshot.endpoints.len()
),
));
progress(OfficialUpdateProgress::new(
"markers",
format!(
"checking {} remote marker endpoints",
marker_endpoint_count(&current_snapshot)
),
));
let endpoint_markers =
collect_endpoint_markers(&fetcher, &current_snapshot, &mut progress)?;
let current_update_snapshot = OfficialUpdateSnapshot::new(
current_snapshot.clone(),
endpoint_markers,
bootstrap.as_ref(),
);
progress(OfficialUpdateProgress::new(
"snapshot",
format!("reading previous snapshot {}", snapshot_path.display()),
));
let previous_snapshot = read_snapshot(&snapshot_path)?;
let previous_base_snapshot = previous_snapshot
.as_ref()
@@ -491,18 +598,48 @@ impl OfficialUpdateService {
let changed_urls = changed_endpoint_urls(&sync_plan.delta);
let remote_should_download =
config.force || sync_plan.should_download() || extended_delta.has_changes();
progress(OfficialUpdateProgress::new(
"decision",
format!(
"remote decision={:?} force={} remote_should_download={}",
sync_plan.decision, config.force, remote_should_download
),
));
let pull_plan = if config.audit_local || remote_should_download || config.plan {
progress(OfficialUpdateProgress::new(
"plan",
"building official pull plan from seed catalogs",
));
Some(build_pull_plan(
&server_info,
&connection_group,
&app_version,
platforms,
&fetcher,
&mut progress,
)?)
} else {
progress(OfficialUpdateProgress::new(
"plan",
"pull plan not needed for clean remote state and disabled plan output",
));
None
};
if let Some(pull_plan) = pull_plan.as_ref() {
let url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
progress(OfficialUpdateProgress::new(
"plan",
format!("pull plan contains {url_count} official URLs"),
));
}
let local_audit = if config.audit_local {
progress(OfficialUpdateProgress::new(
"audit",
format!(
"auditing local download manifest {}",
fetcher.download_manifest_path().display()
),
));
Some(
fetcher
.audit_local_manifest(
@@ -513,6 +650,10 @@ impl OfficialUpdateService {
.map_err(anyhow::Error::msg)?,
)
} else {
progress(OfficialUpdateProgress::new(
"audit",
"local manifest audit disabled",
));
None
};
let local_manifest_verified_count = local_audit
@@ -529,6 +670,20 @@ impl OfficialUpdateService {
.map(|audit| !audit.is_clean())
.unwrap_or(false);
let should_download = remote_should_download || repair_needed;
progress(OfficialUpdateProgress::new(
"audit",
format!(
"local manifest verified={} repair_needed={}",
local_manifest_verified_count, local_manifest_repair_needed_count
),
));
progress(OfficialUpdateProgress::new(
"decision",
format!(
"final should_download={} repair_needed={}",
should_download, repair_needed
),
));
let mut report = OfficialUpdateReport {
update_status: if should_download {
OfficialUpdateStatus::WouldDownload
@@ -572,6 +727,10 @@ impl OfficialUpdateService {
};
if !should_download {
progress(OfficialUpdateProgress::new(
"finish",
"up to date; no download needed",
));
return Ok(report);
}
@@ -586,18 +745,61 @@ impl OfficialUpdateService {
.map_err(anyhow::Error::msg)?;
report.download_url_count = Some(urls.len());
report.download_urls = urls;
progress(OfficialUpdateProgress::new(
"dry-run",
format!(
"dry-run plan generated {} URLs",
report.download_url_count.unwrap_or(0)
),
));
} else {
progress(OfficialUpdateProgress::new(
"dry-run",
"dry-run detected pending download; full URL plan disabled",
));
}
report.update_status = OfficialUpdateStatus::WouldDownload;
progress(OfficialUpdateProgress::new(
"finish",
"dry-run finished without writing state",
));
return Ok(report);
}
let pull_plan = pull_plan
.as_ref()
.ok_or_else(|| anyhow::anyhow!("download requested but no pull plan exists"))?;
let pull_report = fetcher.pull(pull_plan).map_err(anyhow::Error::msg)?;
let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
progress(OfficialUpdateProgress::new(
"download",
format!("downloading or reusing {download_url_count} official URLs"),
));
let pull_report = fetcher
.pull_with_progress(pull_plan, |event| {
progress(progress_from_pull_event(event));
})
.map_err(anyhow::Error::msg)?;
progress(OfficialUpdateProgress::new(
"download",
format!(
"download phase finished: downloaded={} resumed={} skipped={} transferred_bytes={}",
pull_report.downloaded_count(),
pull_report.resumed_count(),
pull_report.skipped_count(),
pull_report.transferred_bytes()
),
));
progress(OfficialUpdateProgress::new(
"audit",
"running final local manifest audit",
));
let final_audit = fetcher
.audit_local_manifest(pull_plan)
.map_err(anyhow::Error::msg)?;
progress(OfficialUpdateProgress::new(
"snapshot",
format!("writing snapshot {}", snapshot_path.display()),
));
write_snapshot(&snapshot_path, &current_update_snapshot)?;
report.update_status = OfficialUpdateStatus::Downloaded;
@@ -612,6 +814,14 @@ impl OfficialUpdateService {
report.local_manifest_repair_needed_count = final_audit.repair_needed_count();
report.snapshot_written = Some(snapshot_path);
progress(OfficialUpdateProgress::new(
"finish",
format!(
"sync finished: resources={} official_hashes_verified={}",
report.resource_count.unwrap_or(0),
report.official_seed_hash_verified_count
),
));
Ok(report)
}
}
@@ -622,11 +832,25 @@ fn build_pull_plan(
app_version: &str,
platforms: &[PatchPlatform],
fetcher: &OfficialResourcePullService,
progress: &mut dyn FnMut(OfficialUpdateProgress),
) -> anyhow::Result<OfficialResourcePullPlan> {
let discovery = server_info
.discovery_plan(connection_group, app_version, platforms)
.map_err(anyhow::Error::msg)?;
let seed_catalogs = fetch_seed_catalogs(fetcher, &discovery)?;
progress(OfficialUpdateProgress::new(
"plan",
format!(
"discovery plan resolved: connection_group={} app_version={} endpoints={}",
discovery.connection_group_name,
discovery.app_version,
discovery.endpoints.len()
),
));
let seed_catalogs = fetch_seed_catalogs(fetcher, &discovery, progress)?;
progress(OfficialUpdateProgress::new(
"inventory",
"parsing seed catalogs into download inventory",
));
let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?;
Ok(build_official_pull_plan_for_platform_inventory(
discovery, inventory, platforms,
@@ -652,6 +876,7 @@ fn load_server_info(
fn collect_endpoint_markers(
fetcher: &OfficialResourcePullService,
snapshot: &YostarJpSyncSnapshot,
progress: &mut dyn FnMut(OfficialUpdateProgress),
) -> anyhow::Result<Vec<OfficialEndpointMarkerSnapshot>> {
let mut markers = Vec::new();
@@ -659,6 +884,15 @@ fn collect_endpoint_markers(
let Some(role) = marker_role(endpoint.kind) else {
continue;
};
progress(OfficialUpdateProgress::new(
"marker",
format!(
"fetching {} marker{} {}",
endpoint_kind_label(endpoint.kind),
platform_suffix(endpoint.platform),
endpoint.url
),
));
let bytes = fetcher
.fetch_bytes(&endpoint.url)
.map_err(anyhow::Error::msg)?;
@@ -689,6 +923,78 @@ fn marker_role(kind: YostarJpResourceEndpointKind) -> Option<OfficialEndpointMar
}
}
fn marker_endpoint_count(snapshot: &YostarJpSyncSnapshot) -> usize {
snapshot
.endpoints
.iter()
.filter(|endpoint| marker_role(endpoint.kind).is_some())
.count()
}
fn server_info_source_label(source: &OfficialServerInfoSource) -> String {
match source {
OfficialServerInfoSource::LocalPath(path) => format!("local path {}", path.display()),
OfficialServerInfoSource::OfficialFile(file_name) => {
format!("official file {file_name}")
}
OfficialServerInfoSource::OfficialUrl(url) => format!("official URL {url}"),
}
}
fn platforms_label(platforms: &[PatchPlatform]) -> String {
platforms
.iter()
.map(|platform| platform.as_str())
.collect::<Vec<_>>()
.join(",")
}
fn endpoint_kind_label(kind: YostarJpResourceEndpointKind) -> &'static str {
match kind {
YostarJpResourceEndpointKind::TableCatalog => "table_catalog",
YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash",
YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog",
YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash",
YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info",
YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash",
YostarJpResourceEndpointKind::MediaCatalog => "media_catalog",
YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash",
}
}
fn platform_suffix(platform: Option<PatchPlatform>) -> String {
platform
.map(|platform| format!(" ({})", platform.as_str()))
.unwrap_or_default()
}
fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpdateProgress {
match event.kind {
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
"download",
format!("({}/{}) started {}", event.index, event.total, event.url),
),
OfficialResourcePullProgressKind::Finished => {
let status = event
.status
.map(|status| status.as_str())
.unwrap_or("unknown");
OfficialUpdateProgress::new(
"download",
format!(
"({}/{}) finished status={} bytes={} transferred_bytes={} {}",
event.index,
event.total,
status,
event.bytes.unwrap_or(0),
event.transferred_bytes.unwrap_or(0),
event.url
),
)
}
}
}
/// Computes the extended snapshot delta.
pub fn diff_extended_snapshot(
current: &OfficialUpdateSnapshot,
@@ -817,21 +1123,41 @@ fn resolve_bootstrap(
unzip_command: &Path,
cache_path: &Path,
write_cache: bool,
progress: &mut dyn FnMut(OfficialUpdateProgress),
) -> anyhow::Result<ResolvedBootstrap> {
let launcher = OfficialLauncherBootstrapService::with_curl_command(
launcher_version,
curl_command.to_string_lossy().to_string(),
);
progress(OfficialUpdateProgress::new(
"launcher",
"fetching official launcher game config and remote manifest",
));
let (game_config, manifest_url, manifest) = launcher
.fetch_latest_remote_manifest()
.map_err(anyhow::Error::msg)?;
let launcher_metadata =
launcher_metadata_from_parts(launcher_version, &game_config, &manifest_url, &manifest);
progress(OfficialUpdateProgress::new(
"launcher",
format!(
"launcher metadata resolved: latest_version={} manifest_files={}",
launcher_metadata.game_latest_version, launcher_metadata.manifest_file_count
),
));
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
format!("checking bootstrap cache {}", cache_path.display()),
));
if let Some(cache) = read_bootstrap_cache(cache_path)? {
if let Some(game_main_config) =
cached_game_main_config_for_metadata(&cache, &launcher_metadata)
{
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
"cache hit; reusing parsed GameMainConfig",
));
return Ok(ResolvedBootstrap {
launcher_metadata,
game_main_config,
@@ -840,6 +1166,10 @@ fn resolve_bootstrap(
}
}
progress(OfficialUpdateProgress::new(
"game-main-config",
"cache miss; fetching resources.assets and parsing GameMainConfig",
));
let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands(
launcher_version,
curl_command.to_path_buf(),
@@ -867,6 +1197,15 @@ fn resolve_bootstrap(
game_main_config: game_main_config.clone(),
},
)?;
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
format!("bootstrap cache written {}", cache_path.display()),
));
} else {
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
"dry-run: bootstrap cache not written",
));
}
Ok(ResolvedBootstrap {
@@ -879,6 +1218,7 @@ fn resolve_bootstrap(
fn fetch_seed_catalogs(
fetcher: &OfficialResourcePullService,
discovery: &YostarJpResourceDiscoveryPlan,
progress: &mut dyn FnMut(OfficialUpdateProgress),
) -> anyhow::Result<SeedCatalogs> {
let mut catalogs = SeedCatalogs::default();
@@ -892,6 +1232,15 @@ fn fetch_seed_catalogs(
continue;
}
progress(OfficialUpdateProgress::new(
"catalog",
format!(
"fetching seed catalog {}{} {}",
endpoint_kind_label(endpoint.kind),
platform_suffix(endpoint.platform),
endpoint.url
),
));
let bytes = fetcher
.fetch_bytes(&endpoint.url)
.map_err(anyhow::Error::msg)?;