Add asset management system
- Created Assets.cshtml and Assets.cshtml.cs for admin panel
- Upload assets with MD5/SHA256 hash calculation
- Generate asset manifests in RR3 format (tab-separated)
- Integrated with Nimble SDK asset download system
- Updated GameAsset model with IsRequired, UploadedAt, Description
- Added navigation link in _Layout.cshtml
- Supports categories: base, cars, tracks, audio, textures, UI, DLC
- Asset download endpoint at /content/api/{assetPath}
- Manifest endpoint at /content/api/manifest
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -381,16 +381,18 @@ public class GameAsset
|
||||
public string? EaCdnPath { get; set; }
|
||||
|
||||
// Local storage
|
||||
public string LocalPath { get; set; } = string.Empty;
|
||||
public string? LocalPath { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
public string FileSha256 { get; set; } = string.Empty;
|
||||
public string? FileSha256 { get; set; }
|
||||
public string? Version { get; set; }
|
||||
|
||||
// Metadata
|
||||
public DateTime DownloadedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime LastAccessedAt { get; set; } = DateTime.UtcNow;
|
||||
public int AccessCount { get; set; } = 0;
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
public bool IsRequired { get; set; } = false;
|
||||
|
||||
// Game-specific (optional)
|
||||
public string? CarId { get; set; }
|
||||
@@ -398,6 +400,7 @@ public class GameAsset
|
||||
public string Category { get; set; } = "misc"; // models, textures, audio, etc.
|
||||
public long? CompressedSize { get; set; }
|
||||
public string? Md5Hash { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
// Custom content support
|
||||
public bool IsCustomContent { get; set; }
|
||||
|
||||
321
RR3CommunityServer/Pages/Assets.cshtml
Normal file
321
RR3CommunityServer/Pages/Assets.cshtml
Normal file
@@ -0,0 +1,321 @@
|
||||
@page
|
||||
@model RR3CommunityServer.Pages.AssetsModel
|
||||
@{
|
||||
ViewData["Title"] = "Asset Management";
|
||||
}
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1>📦 Asset Management</h1>
|
||||
<p class="text-muted">Upload and manage game assets for client downloads</p>
|
||||
</div>
|
||||
<a href="/admin" class="btn btn-outline-secondary">← Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Message))
|
||||
{
|
||||
<div class="alert alert-@(Model.IsError ? "danger" : "success") alert-dismissible fade show" role="alert">
|
||||
@Model.Message
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Asset Statistics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-primary">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="text-primary">@Model.Stats.TotalAssets</h3>
|
||||
<p class="mb-0 text-muted">Total Assets</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-success">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="text-success">@Model.Stats.AvailableAssets</h3>
|
||||
<p class="mb-0 text-muted">Available</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-info">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="text-info">@Model.Stats.TotalSizeMB MB</h3>
|
||||
<p class="mb-0 text-muted">Total Size</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-warning">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="text-warning">@Model.Stats.TotalDownloads</h3>
|
||||
<p class="mb-0 text-muted">Downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Asset Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">⬆️ Upload New Asset</h5>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Asset List -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">📋 Asset Inventory</h5>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-light" onclick="refreshAssets()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
<form method="post" asp-page-handler="GenerateManifest" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-file-text"></i> Generate Manifest
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (!Model.Assets.Any())
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> No assets uploaded yet. Use the form above to upload your first asset.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File Name</th>
|
||||
<th>EA CDN Path</th>
|
||||
<th>Category</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>MD5</th>
|
||||
<th>Downloads</th>
|
||||
<th>Required</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var asset in Model.Assets)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<strong>@asset.FileName</strong>
|
||||
@if (!string.IsNullOrEmpty(asset.Description))
|
||||
{
|
||||
<br><small class="text-muted">@asset.Description</small>
|
||||
}
|
||||
</td>
|
||||
<td><code>@asset.EaCdnPath</code></td>
|
||||
<td><span class="badge bg-secondary">@asset.Category</span></td>
|
||||
<td><span class="badge bg-info">@asset.AssetType</span></td>
|
||||
<td>@FormatFileSize(asset.FileSize)</td>
|
||||
<td>
|
||||
<code class="small">@(asset.Md5Hash?.Substring(0, 8) ?? "N/A")...</code>
|
||||
@if (!string.IsNullOrEmpty(asset.Md5Hash))
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="copyToClipboard('@asset.Md5Hash')">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
<td>@asset.AccessCount</td>
|
||||
<td>
|
||||
@if (asset.IsRequired)
|
||||
{
|
||||
<span class="badge bg-danger">Required</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Optional</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="/content/api@asset.EaCdnPath" class="btn btn-outline-primary" target="_blank" title="Download">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
<form method="post" asp-page-handler="Delete" asp-route-id="@asset.Id" class="d-inline"
|
||||
onsubmit="return confirm('Delete @asset.FileName?')">
|
||||
<button type="submit" class="btn btn-outline-danger" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How Nimble SDK Downloads Assets -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">ℹ️ Nimble SDK Asset Download System</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6>How RR3 Downloads Assets:</h6>
|
||||
<ol>
|
||||
<li><strong>Game Startup:</strong> UnpackAssetsActivity extracts bundled APK assets</li>
|
||||
<li><strong>Manifest Request:</strong> Game calls <code>GET /content/api/manifest</code></li>
|
||||
<li><strong>Verification:</strong> Compares local assets with manifest (MD5 checksums)</li>
|
||||
<li><strong>Download Missing:</strong> Calls <code>GET /content/api/[asset-path]</code> for missing files</li>
|
||||
<li><strong>Storage:</strong> Saves to <code>/external/storage/apk/</code> directory</li>
|
||||
<li><strong>Launch Game:</strong> All required assets present, game starts</li>
|
||||
</ol>
|
||||
|
||||
<h6 class="mt-3">Asset Manifest Format:</h6>
|
||||
<pre><code>{
|
||||
"resultCode": 0,
|
||||
"message": "Success",
|
||||
"data": [
|
||||
{
|
||||
"path": "/rr3/base/game_data.pak",
|
||||
"md5": "a1b2c3d4e5f6...",
|
||||
"compressedSize": 1048576,
|
||||
"uncompressedSize": 2097152,
|
||||
"category": "base"
|
||||
}
|
||||
]
|
||||
}</code></pre>
|
||||
|
||||
<h6 class="mt-3">Nimble SDK Authentication Headers:</h6>
|
||||
<ul>
|
||||
<li><code>EAM-SESSION</code> - Session UUID</li>
|
||||
<li><code>EAM-USER-ID</code> - User identifier</li>
|
||||
<li><code>EA-SELL-ID</code> - Marketplace (e.g., GOOGLE_PLAY)</li>
|
||||
<li><code>SDK-VERSION</code> - Nimble SDK version</li>
|
||||
</ul>
|
||||
|
||||
<div class="alert alert-warning mt-3">
|
||||
<strong>Important:</strong> Assets must have correct MD5 hashes or the game will reject them and re-download.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('Copied to clipboard: ' + text);
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAssets() {
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
@functions {
|
||||
private string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len = len / 1024;
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
289
RR3CommunityServer/Pages/Assets.cshtml.cs
Normal file
289
RR3CommunityServer/Pages/Assets.cshtml.cs
Normal file
@@ -0,0 +1,289 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RR3CommunityServer.Data;
|
||||
using RR3CommunityServer.Models;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace RR3CommunityServer.Pages;
|
||||
|
||||
[Authorize]
|
||||
public class AssetsModel : PageModel
|
||||
{
|
||||
private readonly RR3DbContext _context;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<AssetsModel> _logger;
|
||||
private readonly string _assetsBasePath;
|
||||
|
||||
public AssetsModel(RR3DbContext context, IConfiguration configuration, ILogger<AssetsModel> logger)
|
||||
{
|
||||
_context = context;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_assetsBasePath = configuration.GetValue<string>("AssetsBasePath")
|
||||
?? Path.Combine(Directory.GetCurrentDirectory(), "Assets", "downloaded");
|
||||
}
|
||||
|
||||
public List<GameAsset> Assets { get; set; } = new();
|
||||
public AssetStats Stats { get; set; } = new();
|
||||
public string? Message { get; set; }
|
||||
public bool IsError { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
Assets = await _context.GameAssets
|
||||
.OrderByDescending(a => a.UploadedAt)
|
||||
.ToListAsync();
|
||||
|
||||
await CalculateStatsAsync();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostUploadAsync(
|
||||
IFormFile assetFile,
|
||||
string eaCdnPath,
|
||||
string category,
|
||||
string assetType,
|
||||
bool isRequired,
|
||||
string? description)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (assetFile == null || assetFile.Length == 0)
|
||||
{
|
||||
Message = "No file selected.";
|
||||
IsError = true;
|
||||
await OnGetAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
// Ensure assets directory exists
|
||||
if (!Directory.Exists(_assetsBasePath))
|
||||
{
|
||||
Directory.CreateDirectory(_assetsBasePath);
|
||||
}
|
||||
|
||||
// Create category subdirectory
|
||||
var categoryPath = Path.Combine(_assetsBasePath, category);
|
||||
if (!Directory.Exists(categoryPath))
|
||||
{
|
||||
Directory.CreateDirectory(categoryPath);
|
||||
}
|
||||
|
||||
// Save file to disk
|
||||
var fileName = Path.GetFileName(assetFile.FileName);
|
||||
var localPath = Path.Combine(categoryPath, fileName);
|
||||
|
||||
using (var stream = new FileStream(localPath, FileMode.Create))
|
||||
{
|
||||
await assetFile.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
// Calculate MD5 and SHA256
|
||||
var md5Hash = await CalculateMd5Async(localPath);
|
||||
var sha256Hash = await CalculateSha256Async(localPath);
|
||||
var fileInfo = new FileInfo(localPath);
|
||||
|
||||
// Normalize EA CDN path
|
||||
if (!eaCdnPath.StartsWith("/"))
|
||||
{
|
||||
eaCdnPath = "/" + eaCdnPath;
|
||||
}
|
||||
|
||||
// Check if asset already exists
|
||||
var existingAsset = await _context.GameAssets
|
||||
.FirstOrDefaultAsync(a => a.EaCdnPath == eaCdnPath);
|
||||
|
||||
if (existingAsset != null)
|
||||
{
|
||||
// Update existing asset
|
||||
existingAsset.FileName = fileName;
|
||||
existingAsset.LocalPath = localPath;
|
||||
existingAsset.FileSize = fileInfo.Length;
|
||||
existingAsset.Md5Hash = md5Hash;
|
||||
existingAsset.FileSha256 = sha256Hash;
|
||||
existingAsset.Category = category;
|
||||
existingAsset.AssetType = assetType;
|
||||
existingAsset.IsRequired = isRequired;
|
||||
existingAsset.Description = description;
|
||||
existingAsset.ContentType = GetContentType(fileName);
|
||||
existingAsset.UploadedAt = DateTime.UtcNow;
|
||||
|
||||
Message = $"Asset '{fileName}' updated successfully!";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new asset
|
||||
var asset = new GameAsset
|
||||
{
|
||||
FileName = fileName,
|
||||
EaCdnPath = eaCdnPath,
|
||||
LocalPath = localPath,
|
||||
FileSize = fileInfo.Length,
|
||||
Md5Hash = md5Hash,
|
||||
FileSha256 = sha256Hash,
|
||||
Category = category,
|
||||
AssetType = assetType,
|
||||
IsRequired = isRequired,
|
||||
Description = description,
|
||||
ContentType = GetContentType(fileName),
|
||||
UploadedAt = DateTime.UtcNow,
|
||||
DownloadedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.GameAssets.Add(asset);
|
||||
Message = $"Asset '{fileName}' uploaded successfully!";
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Asset uploaded: {FileName} -> {CdnPath}", fileName, eaCdnPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error uploading asset");
|
||||
Message = $"Error uploading asset: {ex.Message}";
|
||||
IsError = true;
|
||||
}
|
||||
|
||||
await OnGetAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var asset = await _context.GameAssets.FindAsync(id);
|
||||
if (asset == null)
|
||||
{
|
||||
Message = "Asset not found.";
|
||||
IsError = true;
|
||||
await OnGetAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
// Delete file from disk
|
||||
if (!string.IsNullOrEmpty(asset.LocalPath) && System.IO.File.Exists(asset.LocalPath))
|
||||
{
|
||||
System.IO.File.Delete(asset.LocalPath);
|
||||
}
|
||||
|
||||
_context.GameAssets.Remove(asset);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
Message = $"Asset '{asset.FileName}' deleted successfully!";
|
||||
_logger.LogInformation("Asset deleted: {FileName}", asset.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting asset");
|
||||
Message = $"Error deleting asset: {ex.Message}";
|
||||
IsError = true;
|
||||
}
|
||||
|
||||
await OnGetAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostGenerateManifestAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var assets = await _context.GameAssets.ToListAsync();
|
||||
|
||||
// Generate manifest in RR3 format (tab-separated)
|
||||
var manifestContent = new System.Text.StringBuilder();
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
// Format: /path/to/file.ext md5hash compressedSize uncompressedSize
|
||||
manifestContent.AppendLine($"{asset.EaCdnPath}\t{asset.Md5Hash}\t{asset.CompressedSize ?? asset.FileSize}\t{asset.FileSize}");
|
||||
}
|
||||
|
||||
// Save to Assets directory
|
||||
var manifestPath = Path.Combine(_assetsBasePath, "asset_list_community.txt");
|
||||
await System.IO.File.WriteAllTextAsync(manifestPath, manifestContent.ToString());
|
||||
|
||||
// Also generate JSON manifest for API
|
||||
var jsonManifest = assets.Select(a => new
|
||||
{
|
||||
path = a.EaCdnPath,
|
||||
md5 = a.Md5Hash,
|
||||
compressedSize = a.CompressedSize ?? a.FileSize,
|
||||
uncompressedSize = a.FileSize,
|
||||
category = a.Category,
|
||||
required = a.IsRequired
|
||||
});
|
||||
|
||||
var jsonPath = Path.Combine(_assetsBasePath, "asset_manifest_community.json");
|
||||
await System.IO.File.WriteAllTextAsync(jsonPath,
|
||||
System.Text.Json.JsonSerializer.Serialize(jsonManifest, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
Message = $"Manifest generated successfully! ({assets.Count} assets)";
|
||||
_logger.LogInformation("Asset manifest generated with {Count} assets", assets.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating manifest");
|
||||
Message = $"Error generating manifest: {ex.Message}";
|
||||
IsError = true;
|
||||
}
|
||||
|
||||
await OnGetAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
private async Task CalculateStatsAsync()
|
||||
{
|
||||
Stats.TotalAssets = Assets.Count;
|
||||
Stats.AvailableAssets = Assets.Count(a => !string.IsNullOrEmpty(a.LocalPath) && System.IO.File.Exists(a.LocalPath));
|
||||
Stats.TotalSizeMB = (long)(Assets.Sum(a => a.FileSize) / 1024.0 / 1024.0);
|
||||
Stats.TotalDownloads = Assets.Sum(a => a.AccessCount);
|
||||
}
|
||||
|
||||
private async Task<string> CalculateMd5Async(string filePath)
|
||||
{
|
||||
using var md5 = MD5.Create();
|
||||
using var stream = System.IO.File.OpenRead(filePath);
|
||||
var hash = await md5.ComputeHashAsync(stream);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task<string> CalculateSha256Async(string filePath)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var stream = System.IO.File.OpenRead(filePath);
|
||||
var hash = await sha256.ComputeHashAsync(stream);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
private string GetContentType(string fileName)
|
||||
{
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
return extension switch
|
||||
{
|
||||
".pak" => "application/octet-stream",
|
||||
".dat" => "application/octet-stream",
|
||||
".nct" => "application/octet-stream",
|
||||
".z" => "application/x-compress",
|
||||
".json" => "application/json",
|
||||
".xml" => "application/xml",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".pvr" => "image/pvr",
|
||||
".atlas" => "application/octet-stream",
|
||||
".mp3" => "audio/mpeg",
|
||||
".ogg" => "audio/ogg",
|
||||
".wav" => "audio/wav",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class AssetStats
|
||||
{
|
||||
public int TotalAssets { get; set; }
|
||||
public int AvailableAssets { get; set; }
|
||||
public long TotalSizeMB { get; set; }
|
||||
public int TotalDownloads { get; set; }
|
||||
}
|
||||
@@ -103,6 +103,16 @@
|
||||
<i class="bi bi-cart"></i> Purchases
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/assets">
|
||||
<i class="bi bi-box-seam"></i> Assets
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/settings">
|
||||
<i class="bi bi-gear"></i> Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/swagger" target="_blank">
|
||||
<i class="bi bi-code-slash"></i> API
|
||||
|
||||
Reference in New Issue
Block a user