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:
@@ -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"> </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"> </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"> </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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user