Files
BlueArchiveToolkit/internal/api/release_index.go
T
nyaKazuha 7f465523e1
bat-rust / Build and test Go API (push) Canceled after 0s
bat-rust / Build and test Rust (push) Canceled after 0s
fix(bat-api): 完成 issue #19 同机 live 联调
2026-08-29 22:58:01 +08:00

300 lines
10 KiB
Go

package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// ResourceEntry is one distributable object from the published release.
type ResourceEntry struct {
URL string `json:"url"`
RelativePath string `json:"relative_path"`
Bytes uint64 `json:"bytes"`
BLAKE3 string `json:"blake3,omitempty"`
Present bool `json:"present"`
SizeMatch bool `json:"size_match"`
}
// SnapshotSummary is a subset of official-sync-snapshot.json / catalog.status.
type SnapshotSummary struct {
Status string `json:"status,omitempty"`
StatusCode string `json:"status_code,omitempty"`
DistributionStatus string `json:"distribution_status,omitempty"`
DistributionStatusCode string `json:"distribution_status_code,omitempty"`
AppVersion string `json:"app_version,omitempty"`
BundleVersion string `json:"bundle_version,omitempty"`
ConnectionGroupName string `json:"connection_group_name,omitempty"`
AddressablesRoot string `json:"addressables_root,omitempty"`
VersionID string `json:"version_id,omitempty"`
CompletedUnixSeconds *uint64 `json:"completed_unix_seconds,omitempty"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"`
GameMainConfig *GameMainConfigSummary `json:"game_main_config,omitempty"`
}
// LauncherMetadataSummary mirrors the resource-relevant part of Rust's launcher metadata snapshot.
type LauncherMetadataSummary struct {
LauncherVersion string `json:"launcher_version,omitempty"`
GameLatestVersion string `json:"game_latest_version,omitempty"`
GameLatestFilePath string `json:"game_latest_file_path,omitempty"`
GameLowestVersion string `json:"game_lowest_version,omitempty"`
GameStartExeName string `json:"game_start_exe_name,omitempty"`
GameStartParams []string `json:"game_start_params,omitempty"`
ManifestURL string `json:"manifest_url,omitempty"`
ManifestSource string `json:"manifest_source,omitempty"`
ManifestFileCount int `json:"manifest_file_count,omitempty"`
}
// GameMainConfigSummary mirrors the resource-relevant decrypted GameMainConfig fields.
type GameMainConfigSummary struct {
ServerInfoDataURL string `json:"server_info_data_url,omitempty"`
DefaultConnectionGroup string `json:"default_connection_group,omitempty"`
}
// ReleaseIndex is the in-memory view of a published resource root.
type ReleaseIndex struct {
mu sync.RWMutex
ResourceRoot string `json:"resource_root"`
Source string `json:"source"` // "rpc" | "resource_root" | "empty"
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
Entries []ResourceEntry `json:"entries"`
// byRel maps relative path (host/path...) to entry index.
byRel map[string]int
// MissingOnDisk lists relative paths present in the index but absent on disk.
MissingOnDisk []string `json:"missing_on_disk,omitempty"`
}
// Summary returns a JSON-serializable overview without the full entry list.
type ReleaseSummary struct {
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
Ready bool `json:"ready"`
}
// Summary builds a compact release overview.
func (idx *ReleaseIndex) Summary() ReleaseSummary {
idx.mu.RLock()
defer idx.mu.RUnlock()
present := 0
for _, e := range idx.Entries {
if e.Present && e.SizeMatch {
present++
}
}
return ReleaseSummary{
ResourceRoot: idx.ResourceRoot,
Source: idx.Source,
RPCAvailable: idx.RPCAvailable,
DoctorHealthy: idx.DoctorHealthy,
Snapshot: idx.Snapshot,
ManifestVersion: idx.ManifestVersion,
EntryCount: len(idx.Entries),
PresentCount: present,
MissingCount: len(idx.MissingOnDisk),
// A release is distributable only when every manifest entry is present
// and has the expected size. Serving a partial release can leave clients
// with an apparently valid bootstrap and an unrecoverable download set.
Ready: idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries),
}
}
// Lookup returns the entry for a host/path relative path.
func (idx *ReleaseIndex) Lookup(rel string) (ResourceEntry, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
rel = filepath.ToSlash(rel)
rel = strings.TrimPrefix(rel, "/")
i, ok := idx.byRel[rel]
if !ok {
return ResourceEntry{}, false
}
return idx.Entries[i], true
}
// List returns a stable page of entries.
func (idx *ReleaseIndex) List(offset, limit int) (items []ResourceEntry, total int) {
idx.mu.RLock()
defer idx.mu.RUnlock()
total = len(idx.Entries)
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = 100
}
if offset >= total {
return []ResourceEntry{}, total
}
end := offset + limit
if end > total {
end = total
}
out := make([]ResourceEntry, end-offset)
copy(out, idx.Entries[offset:end])
return out, total
}
// BuildIndexFromManifestEntries builds an index against resourceRoot.
func BuildIndexFromManifestEntries(
resourceRoot string,
source string,
rpcAvailable bool,
doctorHealthy *bool,
snapshot *SnapshotSummary,
manifestVersion int,
entries []manifestEntry,
) (*ReleaseIndex, error) {
rootAbs, err := filepath.Abs(resourceRoot)
if err != nil {
return nil, err
}
idx := &ReleaseIndex{
ResourceRoot: rootAbs,
Source: source,
RPCAvailable: rpcAvailable,
DoctorHealthy: doctorHealthy,
Snapshot: snapshot,
ManifestVersion: manifestVersion,
byRel: make(map[string]int),
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].URL < entries[j].URL
})
for _, raw := range entries {
rel := filepath.ToSlash(raw.Destination)
rel = strings.TrimPrefix(rel, "/")
if rel == "" {
// Derive from URL when destination is empty.
rel = relativePathFromURL(raw.URL)
}
if rel == "" {
continue
}
entry := ResourceEntry{
URL: raw.URL,
RelativePath: rel,
Bytes: raw.Bytes,
BLAKE3: raw.BLAKE3,
}
abs, err := ResolveUnderRoot(rootAbs, rel, false)
if err != nil {
entry.Present = false
entry.SizeMatch = false
idx.MissingOnDisk = append(idx.MissingOnDisk, rel)
} else {
info, err := os.Lstat(abs)
if err != nil || !info.Mode().IsRegular() {
entry.Present = false
entry.SizeMatch = false
idx.MissingOnDisk = append(idx.MissingOnDisk, rel)
} else {
entry.Present = true
entry.SizeMatch = uint64(info.Size()) == raw.Bytes || raw.Bytes == 0
if !entry.SizeMatch {
idx.MissingOnDisk = append(idx.MissingOnDisk, rel+"#size_mismatch")
}
}
}
idx.byRel[rel] = len(idx.Entries)
idx.Entries = append(idx.Entries, entry)
}
return idx, nil
}
// LoadIndexFromResourceRoot reads official-download-manifest.json under root.
// Used for tests and --resource-root fallback without RPC.
func LoadIndexFromResourceRoot(resourceRoot string) (*ReleaseIndex, error) {
rootAbs, err := filepath.Abs(resourceRoot)
if err != nil {
return nil, err
}
manifestPath := filepath.Join(rootAbs, "official-download-manifest.json")
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, fmt.Errorf("read download manifest: %w", err)
}
var manifest fileManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parse download manifest: %w", err)
}
if manifest.Version != 0 && manifest.Version != 1 {
return nil, fmt.Errorf("unsupported download manifest version %d", manifest.Version)
}
entries := make([]manifestEntry, 0, len(manifest.Entries))
for _, e := range manifest.Entries {
entries = append(entries, manifestEntry{
URL: e.URL,
Destination: e.Destination,
Bytes: e.Bytes,
BLAKE3: e.BLAKE3,
})
}
var snapshot *SnapshotSummary
if snapData, err := os.ReadFile(filepath.Join(rootAbs, "official-sync-snapshot.json")); err == nil {
var s fileSnapshot
if json.Unmarshal(snapData, &s) == nil {
snapshot = &SnapshotSummary{
AppVersion: s.AppVersion,
BundleVersion: s.BundleVersion,
ConnectionGroupName: s.ConnectionGroupName,
AddressablesRoot: s.AddressablesRoot,
LauncherMetadata: s.LauncherMetadata,
GameMainConfig: s.GameMainConfigBootstrap,
}
}
}
return BuildIndexFromManifestEntries(rootAbs, "resource_root", false, nil, snapshot, manifest.Version, entries)
}
type manifestEntry struct {
URL string
Destination string
Bytes uint64
BLAKE3 string
}
type fileManifest struct {
Version int `json:"version"`
Entries map[string]struct {
URL string `json:"url"`
Destination string `json:"destination"`
Bytes uint64 `json:"bytes"`
BLAKE3 string `json:"blake3"`
} `json:"entries"`
}
type fileSnapshot struct {
AppVersion string `json:"app_version"`
BundleVersion string `json:"bundle_version"`
ConnectionGroupName string `json:"connection_group_name"`
AddressablesRoot string `json:"addressables_root"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"`
GameMainConfigBootstrap *GameMainConfigSummary `json:"game_main_config_bootstrap,omitempty"`
}
func relativePathFromURL(rawURL string) string {
const prefix = "https://"
if !strings.HasPrefix(rawURL, prefix) {
return ""
}
rest := strings.TrimPrefix(rawURL, prefix)
rest = strings.SplitN(rest, "?", 2)[0]
rest = strings.SplitN(rest, "#", 2)[0]
return filepath.ToSlash(rest)
}