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,9 +66,23 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header bg-primary text-white">
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<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">
|
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -122,7 +136,7 @@
|
|||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="isRequired" name="isRequired" checked>
|
<input class="form-check-input" type="checkbox" id="isRequired" name="isRequired" checked>
|
||||||
<label class="form-check-label" for="isRequired">
|
<label class="form-check-label" for="isRequired">
|
||||||
Required Asset (mandatory download)
|
Required Asset
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +151,55 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using RR3CommunityServer.Data;
|
using RR3CommunityServer.Data;
|
||||||
using RR3CommunityServer.Models;
|
using RR3CommunityServer.Models;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
namespace RR3CommunityServer.Pages;
|
namespace RR3CommunityServer.Pages;
|
||||||
|
|
||||||
@@ -150,6 +151,172 @@ public class AssetsModel : PageModel
|
|||||||
return Page();
|
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)
|
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -278,6 +445,19 @@ public class AssetsModel : PageModel
|
|||||||
_ => "application/octet-stream"
|
_ => "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
|
public class AssetStats
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("RR3CommunityServer")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("RR3CommunityServer")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+f1d0d43cb70abf0fc44598a01a6250f3b7b73922")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+15e842ce855b748cc97c43261eba408067128330")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("RR3CommunityServer")]
|
[assembly: System.Reflection.AssemblyProductAttribute("RR3CommunityServer")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("RR3CommunityServer")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("RR3CommunityServer")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
42ee391f7017f761577d9436f4160570943e6c116049654eb80a0c5412651e8a
|
57cc4aab962a2ad12c842505535b2be3f034220ea319669b999c8dc52168130d
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
|||||||
{"documents":{"E:\\rr3\\RR3CommunityServer\\*":"https://raw.githubusercontent.com/ssfdre38/rr3-server/f1d0d43cb70abf0fc44598a01a6250f3b7b73922/*"}}
|
{"documents":{"E:\\rr3\\RR3CommunityServer\\*":"https://raw.githubusercontent.com/ssfdre38/rr3-server/15e842ce855b748cc97c43261eba408067128330/*"}}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"GlobalPropertiesHash":"gdYA/PLOQysRMD9wt3+IrqBqQw0g/GZFOcojepf8P6w=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["7Gcs8uTS1W2TjgmuuoBwaL/zy\u002B2wcKht3msEI7xtxEM=","UWedSjPPgrw4tts2Bk2ce0nYJfnBV9zMYOAjYg0PED8=","GecKXPxV0EAagvAtrRNTytwMtFCxZmgKm9sjLyEe8oI=","AD8WKv0o3OeySN/Mlu5s1a4y3Dt/ik0jFKKHCrGjFtA=","hnhSRoeFpk3C6XWICUlX/lNip6TfbZWFYZv4weSCyrw=","fVR30KYkDSf6Wvsw9TujzlqruhwIMbw1wHxa1z/mksA=","VpFNnyDFqynPhhZPcyqeWcncA9QmAv\u002BG3ez5PxfzaTQ=","EoVh8vBcGohUnEMEoZuTXrpZ9uBDHT19VmDHc/D\u002Bm0I=","eB7z8UswjcYO/RErEzjxxHwVLVba/7iPOUH17NS53Fw=","IdEjAFCVk3xZYjiEMESONot/jkvTj/gnwS5nnpGaIMc=","JVRe\u002Be2d47FunIfxVYRpqRFtljZ8gqrK3xMRy6TCd\u002BQ=","DQG0T8n9f5ohwv9akihU55D4/3WR7\u002BlDnvkdsAHHSgc=","VxDQNRQXYUU41o9SG4HrkKWR59FJIv8lmnwBolB/wE0=","x88k5Bg2fv\u002Bie1eIqFd4doOTQY0lwCNPv/5eJfhIK\u002Bw=","0Slg2/xnc5E9nXprYyph/57wQou\u002BhGSGgKchbo4aNOg="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
{"GlobalPropertiesHash":"gdYA/PLOQysRMD9wt3+IrqBqQw0g/GZFOcojepf8P6w=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["7Gcs8uTS1W2TjgmuuoBwaL/zy\u002B2wcKht3msEI7xtxEM=","UWedSjPPgrw4tts2Bk2ce0nYJfnBV9zMYOAjYg0PED8=","GecKXPxV0EAagvAtrRNTytwMtFCxZmgKm9sjLyEe8oI=","hf09a5b9bLKSNiG0xcVt876q/fUaHURnZieQui5FrSU=","hnhSRoeFpk3C6XWICUlX/lNip6TfbZWFYZv4weSCyrw=","fVR30KYkDSf6Wvsw9TujzlqruhwIMbw1wHxa1z/mksA=","bGtvAdvcs6Zz1qOTjdKz5gd/5jOpXDLvMjTZye3i/QI=","EoVh8vBcGohUnEMEoZuTXrpZ9uBDHT19VmDHc/D\u002Bm0I=","7gMXO5\u002Bhli7od21x4gC/qf3G6ddyyMyoSF6YFX9IaKg=","IdEjAFCVk3xZYjiEMESONot/jkvTj/gnwS5nnpGaIMc=","JVRe\u002Be2d47FunIfxVYRpqRFtljZ8gqrK3xMRy6TCd\u002BQ=","DQG0T8n9f5ohwv9akihU55D4/3WR7\u002BlDnvkdsAHHSgc=","VxDQNRQXYUU41o9SG4HrkKWR59FJIv8lmnwBolB/wE0=","x88k5Bg2fv\u002Bie1eIqFd4doOTQY0lwCNPv/5eJfhIK\u002Bw=","0Slg2/xnc5E9nXprYyph/57wQou\u002BhGSGgKchbo4aNOg="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user