Add ZIP bulk upload to asset manager
Features: - Upload ZIP files with folder structure - Automatic extraction and MD5/SHA256 calculation - Preserve folder paths as EA CDN paths - Auto-categorize based on file extensions - Update existing assets automatically - Bootstrap tabs for single/bulk upload UI - Progress feedback (X new, Y updated) Example: cars/porsche.dat → /cars/porsche.dat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -66,76 +66,139 @@
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">⬆️ Upload New Asset</h5>
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="single-tab" data-bs-toggle="tab" data-bs-target="#single" type="button" role="tab">
|
||||
📄 Single File
|
||||
</button>
|
||||
</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
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="assetFile" class="form-label">Asset File</label>
|
||||
<input type="file" class="form-control" id="assetFile" name="assetFile" required>
|
||||
<small class="text-muted">Supported: .pak, .z, .dat, .nct, .json, .xml, images, audio</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="eaCdnPath" class="form-label">EA CDN Path</label>
|
||||
<input type="text" class="form-control" id="eaCdnPath" name="eaCdnPath" placeholder="/rr3/assets/file.pak" required>
|
||||
<small class="text-muted">Path format: /rr3/category/filename.ext</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="category" class="form-label">Category</label>
|
||||
<select class="form-select" id="category" name="category" required>
|
||||
<option value="">Select category...</option>
|
||||
<option value="base">Base Assets</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="assetType" class="form-label">Asset Type</label>
|
||||
<select class="form-select" id="assetType" name="assetType">
|
||||
<option value="Data">Data File</option>
|
||||
<option value="Texture">Texture</option>
|
||||
<option value="Audio">Audio</option>
|
||||
<option value="Model">3D Model</option>
|
||||
<option value="Config">Configuration</option>
|
||||
</select>
|
||||
</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="isRequired" name="isRequired" checked>
|
||||
<label class="form-check-label" for="isRequired">
|
||||
Required Asset (mandatory download)
|
||||
</label>
|
||||
<div class="tab-content">
|
||||
<!-- Single File Upload -->
|
||||
<div class="tab-pane fade show active" id="single" role="tabpanel">
|
||||
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="assetFile" class="form-label">Asset File</label>
|
||||
<input type="file" class="form-control" id="assetFile" name="assetFile" required>
|
||||
<small class="text-muted">Supported: .pak, .z, .dat, .nct, .json, .xml, images, audio</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="eaCdnPath" class="form-label">EA CDN Path</label>
|
||||
<input type="text" class="form-control" id="eaCdnPath" name="eaCdnPath" placeholder="/rr3/assets/file.pak" required>
|
||||
<small class="text-muted">Path format: /rr3/category/filename.ext</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="category" class="form-label">Category</label>
|
||||
<select class="form-select" id="category" name="category" required>
|
||||
<option value="">Select category...</option>
|
||||
<option value="base">Base Assets</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="assetType" class="form-label">Asset Type</label>
|
||||
<select class="form-select" id="assetType" name="assetType">
|
||||
<option value="Data">Data File</option>
|
||||
<option value="Texture">Texture</option>
|
||||
<option value="Audio">Audio</option>
|
||||
<option value="Model">3D Model</option>
|
||||
<option value="Config">Configuration</option>
|
||||
</select>
|
||||
</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="isRequired" name="isRequired" checked>
|
||||
<label class="form-check-label" for="isRequired">
|
||||
Required Asset
|
||||
</label>
|
||||
</div>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ZIP Bulk Upload -->
|
||||
<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
|
||||
</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>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label for="baseCategory" class="form-label">Base Category</label>
|
||||
<select class="form-select" id="baseCategory" name="baseCategory">
|
||||
<option value="base">Base Assets</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>
|
||||
</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="isRequiredZip" name="isRequired" checked>
|
||||
<label class="form-check-label" for="isRequiredZip">
|
||||
All required
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-file-zip"></i> Extract and Upload
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using RR3CommunityServer.Data;
|
||||
using RR3CommunityServer.Models;
|
||||
using System.Security.Cryptography;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace RR3CommunityServer.Pages;
|
||||
|
||||
@@ -150,6 +151,172 @@ public class AssetsModel : PageModel
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostUploadZipAsync(
|
||||
IFormFile zipFile,
|
||||
string baseCategory,
|
||||
bool isRequired)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (zipFile == null || zipFile.Length == 0)
|
||||
{
|
||||
Message = "No ZIP file selected.";
|
||||
IsError = true;
|
||||
await OnGetAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
if (!zipFile.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Message = "Please upload a ZIP file.";
|
||||
IsError = true;
|
||||
await OnGetAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
// Ensure assets directory exists
|
||||
if (!Directory.Exists(_assetsBasePath))
|
||||
{
|
||||
Directory.CreateDirectory(_assetsBasePath);
|
||||
}
|
||||
|
||||
// Save ZIP to temp location
|
||||
var tempZipPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".zip");
|
||||
using (var stream = new FileStream(tempZipPath, FileMode.Create))
|
||||
{
|
||||
await zipFile.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
int extractedCount = 0;
|
||||
int skippedCount = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
// Extract ZIP and process each file
|
||||
using (var archive = ZipFile.OpenRead(tempZipPath))
|
||||
{
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Skip directories
|
||||
if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith("/"))
|
||||
continue;
|
||||
|
||||
// Determine category from path in ZIP
|
||||
var pathParts = entry.FullName.Split('/', '\\');
|
||||
var category = baseCategory;
|
||||
|
||||
// If ZIP has folders, use first folder as subcategory
|
||||
if (pathParts.Length > 1)
|
||||
{
|
||||
category = Path.Combine(baseCategory, pathParts[0]);
|
||||
}
|
||||
|
||||
// Create category subdirectory
|
||||
var categoryPath = Path.Combine(_assetsBasePath, category);
|
||||
if (!Directory.Exists(categoryPath))
|
||||
{
|
||||
Directory.CreateDirectory(categoryPath);
|
||||
}
|
||||
|
||||
// Extract file
|
||||
var fileName = entry.Name;
|
||||
var localPath = Path.Combine(categoryPath, fileName);
|
||||
|
||||
// Extract to disk
|
||||
entry.ExtractToFile(localPath, overwrite: true);
|
||||
|
||||
// Calculate hashes
|
||||
var md5Hash = await CalculateMd5Async(localPath);
|
||||
var sha256Hash = await CalculateSha256Async(localPath);
|
||||
var fileInfo = new FileInfo(localPath);
|
||||
|
||||
// Build EA CDN path from ZIP structure
|
||||
var eaCdnPath = "/" + entry.FullName.Replace("\\", "/");
|
||||
|
||||
// Check if asset already exists
|
||||
var existingAsset = await _context.GameAssets
|
||||
.FirstOrDefaultAsync(a => a.EaCdnPath == eaCdnPath);
|
||||
|
||||
if (existingAsset != null)
|
||||
{
|
||||
// Update existing
|
||||
existingAsset.FileName = fileName;
|
||||
existingAsset.LocalPath = localPath;
|
||||
existingAsset.FileSize = fileInfo.Length;
|
||||
existingAsset.Md5Hash = md5Hash;
|
||||
existingAsset.FileSha256 = sha256Hash;
|
||||
existingAsset.Category = category;
|
||||
existingAsset.IsRequired = isRequired;
|
||||
existingAsset.ContentType = GetContentType(fileName);
|
||||
existingAsset.UploadedAt = DateTime.UtcNow;
|
||||
skippedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new asset
|
||||
var asset = new GameAsset
|
||||
{
|
||||
FileName = fileName,
|
||||
EaCdnPath = eaCdnPath,
|
||||
LocalPath = localPath,
|
||||
FileSize = fileInfo.Length,
|
||||
Md5Hash = md5Hash,
|
||||
FileSha256 = sha256Hash,
|
||||
Category = category,
|
||||
AssetType = DetermineAssetType(fileName),
|
||||
IsRequired = isRequired,
|
||||
Description = $"Extracted from {zipFile.FileName}",
|
||||
ContentType = GetContentType(fileName),
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
DownloadedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.GameAssets.Add(asset);
|
||||
extractedCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{entry.FullName}: {ex.Message}");
|
||||
_logger.LogError(ex, "Error extracting file from ZIP: {FileName}", entry.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save changes
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Clean up temp ZIP
|
||||
if (System.IO.File.Exists(tempZipPath))
|
||||
{
|
||||
System.IO.File.Delete(tempZipPath);
|
||||
}
|
||||
|
||||
// Build message
|
||||
if (errors.Any())
|
||||
{
|
||||
Message = $"ZIP processed: {extractedCount} new, {skippedCount} updated. Errors: {errors.Count}";
|
||||
IsError = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Message = $"ZIP extracted successfully! {extractedCount} new files, {skippedCount} updated.";
|
||||
}
|
||||
|
||||
_logger.LogInformation("ZIP uploaded: {FileName} -> {ExtractedCount} files", zipFile.FileName, extractedCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing ZIP file");
|
||||
Message = $"Error processing ZIP: {ex.Message}";
|
||||
IsError = true;
|
||||
}
|
||||
|
||||
await OnGetAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||
{
|
||||
try
|
||||
@@ -278,6 +445,19 @@ public class AssetsModel : PageModel
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
private string DetermineAssetType(string fileName)
|
||||
{
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
return extension switch
|
||||
{
|
||||
".png" or ".jpg" or ".jpeg" or ".pvr" or ".atlas" => "Texture",
|
||||
".mp3" or ".ogg" or ".wav" => "Audio",
|
||||
".json" or ".xml" => "Config",
|
||||
".pak" or ".dat" or ".nct" => "Data",
|
||||
_ => "Data"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class AssetStats
|
||||
|
||||
Reference in New Issue
Block a user