Add game version management system with manifest support

Features:
- Version dropdown in single/ZIP upload (9.3.0, 9.2.0, etc.)
- Patch-compatible matching (9.3.x assets work with 9.3.0)
- manifest.json/xml support for automatic metadata detection
- Smart category auto-detection from folder structure
- Version field stored in GameAssets table

Manifest support:
- JSON format with gameVersion, category, assets array
- Per-file metadata overrides (type, required, description)
- Auto-detect falls back if no manifest present
- See ASSET-MANIFEST-SPECIFICATION.md for full spec

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-20 09:55:05 -08:00
parent f289cdfce9
commit dd2c23000f
21 changed files with 1409 additions and 32 deletions

View File

@@ -74,7 +74,12 @@
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="zip-tab" data-bs-toggle="tab" data-bs-target="#zip" type="button" role="tab">
📦 ZIP Bulk Upload
📦 ZIP Upload
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="url-tab" data-bs-toggle="tab" data-bs-target="#url" type="button" role="tab">
🌐 URL Download
</button>
</li>
</ul>
@@ -132,7 +137,30 @@
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">&nbsp;</label>
<label for="gameVersion" class="form-label">Game Version</label>
<select class="form-select" id="gameVersion" name="gameVersion" required>
<option value="">Select version...</option>
<option value="9.3.0">9.3.0</option>
<option value="9.2.0">9.2.0</option>
<option value="9.1.0">9.1.0</option>
<option value="9.0.0">9.0.0</option>
<option value="8.9.0">8.9.0</option>
<option value="universal">Universal (All Versions)</option>
</select>
<small class="text-muted">Patch-compatible: 9.3.x works with 9.3.0</small>
</div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="2" placeholder="Brief description of this asset..."></textarea>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">Options</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isRequired" name="isRequired" checked>
<label class="form-check-label" for="isRequired">
@@ -142,10 +170,6 @@
</div>
</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="2" placeholder="Brief description of this asset..."></textarea>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-upload"></i> Upload Asset
</button>
@@ -156,19 +180,35 @@
<div class="tab-pane fade" id="zip" role="tabpanel">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> <strong>ZIP Upload:</strong>
Folder structure preserved • Auto MD5 calculation • Existing assets updated
Folder structure preserved • Auto MD5 calculation • Manifest.json support
</div>
<form method="post" enctype="multipart/form-data" asp-page-handler="UploadZip">
<div class="mb-3">
<label for="zipFile" class="form-label">ZIP Archive</label>
<input class="form-control" type="file" id="zipFile" name="zipFile" accept=".zip" required>
<small class="text-muted">Example: cars/porsche_911.dat → /cars/porsche_911.dat</small>
<small class="text-muted">Include manifest.json for auto-detection • Example: cars/porsche_911.dat → /cars/porsche_911.dat</small>
</div>
<div class="row">
<div class="col-md-8">
<div class="col-md-4">
<div class="mb-3">
<label for="zipGameVersion" class="form-label">Game Version</label>
<select class="form-select" id="zipGameVersion" name="gameVersion" required>
<option value="">Detect from manifest...</option>
<option value="9.3.0">9.3.0 (Latest)</option>
<option value="9.2.0">9.2.0</option>
<option value="9.1.0">9.1.0</option>
<option value="9.0.0">9.0.0</option>
<option value="8.9.0">8.9.0</option>
<option value="universal">Universal</option>
</select>
<small class="text-muted">Or specify in manifest.json</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="baseCategory" class="form-label">Base Category</label>
<select class="form-select" id="baseCategory" name="baseCategory">
<option value="auto">🤖 Auto-detect</option>
<option value="base">Base Assets</option>
<option value="cars">Cars</option>
<option value="tracks">Tracks</option>
@@ -183,11 +223,11 @@
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">&nbsp;</label>
<label class="form-label d-block">Options</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isRequiredZip" name="isRequired" checked>
<label class="form-check-label" for="isRequiredZip">
All required
Mark as required
</label>
</div>
</div>
@@ -198,6 +238,55 @@
</button>
</form>
</div>
<!-- URL Download Tab -->
<div class="tab-pane fade" id="url" role="tabpanel">
<div class="alert alert-success">
<i class="bi bi-cloud-arrow-down"></i> <strong>Direct Download:</strong>
Server downloads ZIP directly • No browser upload needed • Perfect for large files
</div>
<form method="post" asp-page-handler="DownloadZip">
<div class="mb-3">
<label for="zipUrl" class="form-label">ZIP File URL</label>
<input type="url" class="form-control" id="zipUrl" name="zipUrl"
placeholder="https://example.com/assets/rr3-cars-pack.zip" required>
<small class="text-muted">Direct link to ZIP file (http:// or https://)</small>
</div>
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label for="baseCategoryUrl" class="form-label">Base Category (optional)</label>
<select class="form-select" id="baseCategoryUrl" name="baseCategory">
<option value="base">Auto-Detect (Smart)</option>
<option value="cars">Cars</option>
<option value="tracks">Tracks</option>
<option value="audio">Audio</option>
<option value="textures">Textures</option>
<option value="ui">UI</option>
<option value="events">Events</option>
<option value="dlc">DLC</option>
<option value="updates">Updates</option>
</select>
<small class="text-muted">System will auto-detect categories from folder names</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">&nbsp;</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isRequiredUrl" name="isRequired" checked>
<label class="form-check-label" for="isRequiredUrl">
All required
</label>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-download"></i> Download and Extract
</button>
</form>
</div>
</div>
</div>
</div>

View File

@@ -6,6 +6,7 @@ using RR3CommunityServer.Data;
using RR3CommunityServer.Models;
using System.Security.Cryptography;
using System.IO.Compression;
using System.Text.Json;
namespace RR3CommunityServer.Pages;
@@ -46,6 +47,7 @@ public class AssetsModel : PageModel
string category,
string assetType,
bool isRequired,
string gameVersion,
string? description)
{
try
@@ -108,6 +110,7 @@ public class AssetsModel : PageModel
existingAsset.IsRequired = isRequired;
existingAsset.Description = description;
existingAsset.ContentType = GetContentType(fileName);
existingAsset.Version = gameVersion;
existingAsset.UploadedAt = DateTime.UtcNow;
Message = $"Asset '{fileName}' updated successfully!";
@@ -128,6 +131,7 @@ public class AssetsModel : PageModel
IsRequired = isRequired,
Description = description,
ContentType = GetContentType(fileName),
Version = gameVersion,
UploadedAt = DateTime.UtcNow,
DownloadedAt = DateTime.UtcNow
};
@@ -154,6 +158,7 @@ public class AssetsModel : PageModel
public async Task<IActionResult> OnPostUploadZipAsync(
IFormFile zipFile,
string baseCategory,
string? gameVersion,
bool isRequired)
{
try
@@ -190,6 +195,47 @@ public class AssetsModel : PageModel
int extractedCount = 0;
int skippedCount = 0;
var errors = new List<string>();
// Try to parse manifest file if exists
AssetManifest? manifest = null;
using (var archive = ZipFile.OpenRead(tempZipPath))
{
var manifestEntry = archive.Entries.FirstOrDefault(e =>
e.Name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) ||
e.Name.Equals("manifest.xml", StringComparison.OrdinalIgnoreCase));
if (manifestEntry != null)
{
try
{
using var stream = manifestEntry.Open();
using var reader = new StreamReader(stream);
var manifestContent = await reader.ReadToEndAsync();
if (manifestEntry.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
manifest = JsonSerializer.Deserialize<AssetManifest>(manifestContent,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
if (manifest != null)
{
_logger.LogInformation("Loaded manifest: Version={Version}, GameVersion={GameVersion}",
manifest.Version, manifest.GameVersion);
// Override parameters from manifest if provided
if (!string.IsNullOrEmpty(manifest.GameVersion))
gameVersion = manifest.GameVersion;
if (!string.IsNullOrEmpty(manifest.Category) && baseCategory == "auto")
baseCategory = manifest.Category;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse manifest file, using defaults");
}
}
}
// Extract ZIP and process each file
using (var archive = ZipFile.OpenRead(tempZipPath))
@@ -198,18 +244,31 @@ public class AssetsModel : PageModel
{
try
{
// Skip directories
if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith("/"))
// Skip directories and manifest files
if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith("/") ||
entry.Name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) ||
entry.Name.Equals("manifest.xml", StringComparison.OrdinalIgnoreCase))
continue;
// Determine category from path in ZIP
// Check if this file has manifest metadata
ManifestAsset? manifestAsset = manifest?.Assets?.FirstOrDefault(a =>
a.File.Replace("\\", "/").Equals(entry.FullName.Replace("\\", "/"),
StringComparison.OrdinalIgnoreCase));
// Determine category from manifest or path in ZIP
var pathParts = entry.FullName.Split('/', '\\');
var category = baseCategory;
var category = manifestAsset?.Category ?? baseCategory;
// If ZIP has folders, use first folder as subcategory
if (pathParts.Length > 1)
// Auto-detect if category is "auto"
if (category == "auto")
{
category = Path.Combine(baseCategory, pathParts[0]);
category = SmartDetectCategory(entry.FullName) ?? "base";
}
// If ZIP has folders and no manifest, use first folder as subcategory
if (manifestAsset == null && pathParts.Length > 1 && category != "auto")
{
category = Path.Combine(category, pathParts[0]);
}
// Create category subdirectory
@@ -233,6 +292,11 @@ public class AssetsModel : PageModel
// Build EA CDN path from ZIP structure
var eaCdnPath = "/" + entry.FullName.Replace("\\", "/");
// Determine asset type from manifest or filename
var assetType = manifestAsset?.Type ?? DetermineAssetType(fileName);
var required = manifestAsset?.Required ?? isRequired;
var description = manifestAsset?.Description;
// Check if asset already exists
var existingAsset = await _context.GameAssets
@@ -247,8 +311,11 @@ public class AssetsModel : PageModel
existingAsset.Md5Hash = md5Hash;
existingAsset.FileSha256 = sha256Hash;
existingAsset.Category = category;
existingAsset.IsRequired = isRequired;
existingAsset.AssetType = assetType;
existingAsset.IsRequired = required;
existingAsset.Description = description;
existingAsset.ContentType = GetContentType(fileName);
existingAsset.Version = gameVersion;
existingAsset.UploadedAt = DateTime.UtcNow;
skippedCount++;
}
@@ -264,10 +331,11 @@ public class AssetsModel : PageModel
Md5Hash = md5Hash,
FileSha256 = sha256Hash,
Category = category,
AssetType = DetermineAssetType(fileName),
IsRequired = isRequired,
Description = $"Extracted from {zipFile.FileName}",
AssetType = assetType,
IsRequired = required,
Description = description ?? $"Extracted from {zipFile.FileName}",
ContentType = GetContentType(fileName),
Version = gameVersion,
UploadedAt = DateTime.UtcNow,
DownloadedAt = DateTime.UtcNow
};
@@ -446,6 +514,36 @@ public class AssetsModel : PageModel
};
}
private string? SmartDetectCategory(string fullPath)
{
var lowerPath = fullPath.ToLowerInvariant();
// Check for known keywords in path
if (lowerPath.Contains("car") || lowerPath.Contains("vehicle") || lowerPath.Contains("automobile"))
return "cars";
if (lowerPath.Contains("track") || lowerPath.Contains("circuit") || lowerPath.Contains("course"))
return "tracks";
if (lowerPath.Contains("audio") || lowerPath.Contains("sound") || lowerPath.Contains("music"))
return "audio";
if (lowerPath.Contains("texture") || lowerPath.Contains("material") || lowerPath.Contains("skin"))
return "textures";
if (lowerPath.Contains("ui") || lowerPath.Contains("hud") || lowerPath.Contains("menu"))
return "ui";
if (lowerPath.Contains("event") || lowerPath.Contains("race") || lowerPath.Contains("challenge"))
return "events";
if (lowerPath.Contains("dlc") || lowerPath.Contains("expansion") || lowerPath.Contains("addon"))
return "dlc";
if (lowerPath.Contains("update") || lowerPath.Contains("patch"))
return "updates";
// Fall back to first folder name
var pathParts = fullPath.Split('/', '\\');
if (pathParts.Length > 1)
return pathParts[0].ToLowerInvariant();
return null; // Will default to "base"
}
private string DetermineAssetType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
@@ -467,3 +565,23 @@ public class AssetStats
public long TotalSizeMB { get; set; }
public int TotalDownloads { get; set; }
}
// Manifest models for automatic ZIP metadata detection
public class AssetManifest
{
public string? Version { get; set; }
public string? GameVersion { get; set; }
public string? Description { get; set; }
public string? Author { get; set; }
public string? Category { get; set; }
public List<ManifestAsset>? Assets { get; set; }
}
public class ManifestAsset
{
public string File { get; set; } = string.Empty;
public string? Category { get; set; }
public string? Type { get; set; }
public bool Required { get; set; } = true;
public string? Description { get; set; }
}