using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using Google.Protobuf;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
///
/// admin.rec.net/api/admin/v1/rooms/zip-bulk-import — preferred room
/// import path going forward. The legacy multi-file endpoint
/// (POST api/admin/v1/rooms/import) stays alongside this for
/// tooling that already targets it.
///
/// The archive format mirrors RecNet's own room export shape so a
/// user can download a room from rec.net, drop the zip into the admin
/// page, and we re-create it with full fidelity — including the
/// .htr AudioSampler / Holotar assets already extracted into
/// each subroom folder so we never have to hit Rec Room's CDN:
///
/// root.zip/
/// Rooms/Room_<id>_<name>_<timestamp>/
/// RoomDetails.json (full RecNet v4 details schema)
/// RoomImage.jpg | <hash>.jpg
/// Promo/Image/PromoImage_*.jpg
/// SubRooms/<SceneName>/
/// Subroom.json (per-subroom manifest)
/// <hash>.room (referenced by Subroom.CurrentSave.DataBlob)
/// AudioSampler/<hash>.htr
/// Holotar/<hash>.htr
/// Image/PVImage_<hash>.jpg
/// Inventions/Invention_<id>_<name>_<timestamp>/
/// Invention.json
/// InventionDetails.json
/// InventionVersion.json
/// InventionImage.jpg
/// <hash>.inv
///
/// Entry-scene resolution: the order in RoomDetails.SubRooms[]
/// is the entry order (RecNet API convention). The first entry is the
/// entry scene. The admin can override with an explicit
/// RoomDetails.EntryScene field.
///
[ApiController]
[Route("api/admin/v1/rooms")]
[Authorize]
[AdminOnly]
public class RoomZipImportController(
DorkNetDbContext db,
RoomBlobNormalizerService roomBlobNormalizer,
IObjectStorage storage,
IServiceScopeFactory scopeFactory,
ILogger logger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
// ── Room-blob normaliser toggle ─────────────────────────────────────
// The normaliser does two things now (see RoomBlobNormalizerService):
// 1. Project modern Rec Room's TransformData.quaternion_rotation
// (field 6) onto the legacy Euler rotation Vector3 (field 2)
// the 2020.12 watch reads. Without this, every shape in a
// modern-authored room renders at rotation=(0,0,0) — the
// "Studio-imported shapes are misplaced" bug.
// 2. Re-encode through the 2020 PersistedRoomData schema to drop
// any non-canonical wire encodings the watch's stricter parser
// doesn't accept.
//
// Per-import flag (`normalizeBlobs` in the form / finalize body)
// defaults ON now that the projection pass exists. The SPA still
// surfaces it as a checkbox so an admin can opt out for an
// already-Euler-encoded blob if a particular import misbehaves.
// The diagnostic parse always runs (its parse-OK / parse-FAIL log
// line is useful telemetry).
/// Write a blob to S3 at its canonical (bucket, key) AND
/// record the metadata row in .
/// Bytes are NOT persisted on the DB row — S3 is the only place
/// they live. Previously the importer wrote bytes to the
/// Bytes column and skipped S3 entirely, so freshly
/// imported rooms were only reachable through the read-path's
/// DB-fallback branch. Centralised here so every importer call
/// site (scene saves, .htr/PV/polaroid assets, .meta sentinels,
/// invention saves, room thumbnails, promo images) ends up in
/// S3 the same way.
private async Task WriteBlobAsync(
long roomId, string blobName, byte[] bytes, long uploaderPlayerId,
string? referencesCsv = null, string? contentTypeOverride = null,
DateTime? uploadedAt = null, long? subRoomId = null)
{
var (bucket, key) = BlobRouter.Route(blobName);
var contentType = contentTypeOverride ?? MimeFromName(blobName);
try
{
await storage.PutAsync(bucket, key, bytes, contentType);
}
catch (Exception ex)
{
logger.LogError(ex,
"[zip-import] S3 PUT failed bucket={Bucket} key={Key} blob={Blob} bytes={Bytes} — row will still be recorded so the import isn't lost",
bucket, key, blobName, bytes.Length);
}
var entity = new RoomDataBlobEntity
{
RoomId = roomId,
BlobName = blobName,
UploadedByPlayerId = uploaderPlayerId,
UploadedAt = uploadedAt ?? DateTime.UtcNow,
ReferencedFilenamesCsv = referencesCsv ?? string.Empty,
SubRoomId = subRoomId,
};
db.RoomDataBlobs.Add(entity);
return entity;
}
private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".mp4" => "video/mp4",
".htr" => "application/octet-stream",
".room" => "application/octet-stream",
".meta" => "application/octet-stream",
".inv" => "application/octet-stream",
".dat" => "application/octet-stream",
_ => "application/octet-stream",
};
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
// ── RoomDetails.json DTOs ──────────────────────────────────────────
public sealed class RoomDetailsDto
{
public long? RoomId { get; set; }
public string? Name { get; set; }
public string? FriendlyName { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public int? WarningMask { get; set; }
public string? CustomWarning { get; set; }
public long? CreatorAccountId { get; set; }
public int? State { get; set; }
public int? Accessibility { get; set; }
public bool? SupportsLevelVoting { get; set; }
public bool? IsRRO { get; set; }
public bool? SupportsScreens { get; set; }
public bool? SupportsWalkVR { get; set; }
public bool? SupportsTeleportVR { get; set; }
public bool? SupportsVRLow { get; set; }
public bool? SupportsMobile { get; set; }
public bool? SupportsJuniors { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public bool? IsDorm { get; set; }
public int? MaxPlayers { get; set; }
public bool? CloningAllowed { get; set; }
public bool? DisableMicAutoMute { get; set; }
public StatsDto? Stats { get; set; }
public List? Tags { get; set; }
public List? SubRooms { get; set; }
public string? EntryScene { get; set; } // admin override; not in real exports
}
public sealed class StatsDto
{
public int? CheerCount { get; set; }
public int? FavoriteCount { get; set; }
public int? VisitorCount { get; set; }
public int? VisitCount { get; set; }
}
public sealed class TagDto
{
public string? Tag { get; set; }
public int? Type { get; set; }
public bool? IsPrimaryGenre { get; set; }
}
public sealed class SubRoomDto
{
public long? SubRoomId { get; set; }
public string? UnitySceneId { get; set; }
public string? Name { get; set; }
public bool? IsSandbox { get; set; }
public int? MaxPlayers { get; set; }
public int? Accessibility { get; set; }
public CurrentSaveDto? CurrentSave { get; set; }
}
public sealed class CurrentSaveDto
{
public long? SubRoomDataSaveId { get; set; }
public string? DataBlob { get; set; }
}
/// Sidecar JSON next to a History/<blob>.room entry. All
/// fields optional — the importer reads them to populate
/// RoomDataBlobEntity.UploadedAt + audit trail when present, and falls
/// back to for the timestamp otherwise.
public sealed class HistorySidecarDto
{
public long? SubRoomDataSaveId { get; set; }
/// Parent sub-room id. The Restore UI fetches
/// rooms/{roomId}/subrooms/{subRoomId}/datahistory; without
/// a SubRoomId on each row we'd have to return the whole room's
/// blob mix under every sub-room's history list. Optional: when
/// missing, the importer derives it from the SubRooms/<scene>
/// folder name suffix (SubRoomName__<subRoomId>).
public long? SubRoomId { get; set; }
public string? DataBlob { get; set; }
public DateTime? CreatedAt { get; set; }
public string? Description { get; set; }
public long? SavedByAccountId { get; set; }
}
// ── Invention DTOs ────────────────────────────────────────────────
public sealed class InventionJsonDto
{
public long? InventionId { get; set; }
public string? ReplicationId { get; set; }
public long? CreatorPlayerId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? LongDescription { get; set; }
public string? ImageName { get; set; }
public int? UgcVersion { get; set; }
public int? CurrentVersionNumber { get; set; }
public int? LatestVersionNumber { get; set; }
public int? Accessibility { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
public DateTime? FirstPublishedAt { get; set; }
public long? CreationRoomId { get; set; }
public int? NumPlayersHaveUsedInRoom { get; set; }
public int? CheerCount { get; set; }
public int? CreatorPermission { get; set; }
public int? GeneralPermission { get; set; }
public bool? IsAgInvention { get; set; }
public bool? IsRecRoomApproved { get; set; }
}
public sealed class InventionVersionDto
{
public int? VersionNumber { get; set; }
public string? BlobName { get; set; }
}
public sealed class InventionDetailsDto
{
public List? Tags { get; set; }
}
// ── Endpoint ──────────────────────────────────────────────────────
[HttpPost("zip-bulk-import")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 5_000_000_000)] // 5 GB
public async Task ZipImport(
[FromForm(Name = "archive")] IFormFile? archive,
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
[FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs)
{
if (archive is null || archive.Length == 0)
return BadRequest(new { error = "missing_archive" });
await using var stream = archive.OpenReadStream();
return await RunImportAsync(
stream, archive.Length, creatorPlayerId,
normalizeBlobs: normalizeBlobs ?? true);
}
// ── Chunked upload (Cloudflare 100MB workaround) ─────────────────
//
// The whole multipart-zip path 413s through Cloudflare's edge for
// anything over 100 MB on free/pro plans. Real RecNet exports
// routinely run 150-300 MB. Splitting the upload into ≤90 MB
// chunks keeps each request safely under the cap; the server
// appends chunks to a temp file and only invokes the import logic
// once the finalize call comes in.
//
// Sessions are in-memory and short-lived (admins use the importer
// synchronously). If the process restarts before finalize, the
// upload is lost — acceptable for an admin tool used by 1-2 people.
private static readonly System.Collections.Concurrent.ConcurrentDictionary Sessions = new();
public sealed class ZipUploadSession
{
public Guid Id { get; init; }
public string TempPath { get; init; } = string.Empty;
public long TotalBytes { get; set; }
public long ReceivedBytes { get; set; }
public string FileName { get; init; } = string.Empty;
public long AdminPlayerId { get; init; }
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
}
public sealed record ChunkInitRequest(string FileName, long TotalBytes);
/// POST zip-upload-init — allocates a temp file and
/// returns an uploadId the SPA threads through subsequent
/// chunk POSTs. Capped at 5 GB total; opens a sparse temp file so
/// disk usage tracks actual bytes received. The cap is generous
/// because the live RecNet exports of large story-mode rooms
/// (large multi-scene Studio quests) easily
/// run 2-3 GB before any of the per-room recovery zip bundling.
[HttpPost("zip-upload-init")]
public ActionResult ChunkInit([FromBody] ChunkInitRequest body)
{
if (body.TotalBytes <= 0 || body.TotalBytes > 5_000_000_000)
return BadRequest(new { error = "invalid_size", maxBytes = 5_000_000_000L, gotBytes = body.TotalBytes });
// Prune stale sessions before allocating a new one. Anything
// older than 2 hours with no activity is abandoned.
foreach (var (sid, sess) in Sessions)
{
if (DateTime.UtcNow - sess.CreatedAt > TimeSpan.FromHours(2))
{
Sessions.TryRemove(sid, out _);
try { if (System.IO.File.Exists(sess.TempPath)) System.IO.File.Delete(sess.TempPath); } catch { /* best effort */ }
}
}
var id = Guid.NewGuid();
var safeName = Path.GetFileName(body.FileName);
var tempPath = Path.Combine(Path.GetTempPath(), $"dorknet-ziplupload-{id:N}.zip");
// Pre-allocate the file at full length so chunk writes at
// arbitrary offsets work without the OS having to grow it
// page-by-page during the random writes.
using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write))
{
fs.SetLength(body.TotalBytes);
}
Sessions[id] = new ZipUploadSession
{
Id = id,
TempPath = tempPath,
TotalBytes = body.TotalBytes,
FileName = safeName,
AdminPlayerId = CurrentAdminId,
};
return Ok(new { uploadId = id });
}
/// POST zip-upload-chunk/{id}?offset=N — writes the
/// posted body at offset in the session's temp file.
/// Per-call size capped at 100 MB (Kestrel-side, well below the
/// Cloudflare 100 MB edge cap so we never see a 413). Body is the
/// raw chunk bytes; the SPA's uploadZipInChunks helper sends
/// the right slice.
[HttpPost("zip-upload-chunk/{id:guid}")]
[DisableRequestSizeLimit]
[RequestSizeLimit(100_000_000)]
public async Task ChunkAppend(Guid id, [FromQuery] long offset)
{
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
if (offset < 0 || offset >= session.TotalBytes)
return BadRequest(new { error = "bad_offset", offset, total = session.TotalBytes });
await using var fs = new FileStream(session.TempPath, FileMode.Open, FileAccess.Write, FileShare.Read);
fs.Position = offset;
var written = 0L;
await Request.Body.CopyToAsync(fs);
written = fs.Position - offset;
session.ReceivedBytes = Math.Max(session.ReceivedBytes, fs.Position);
return Ok(new
{
uploadId = id,
offset,
written,
received = session.ReceivedBytes,
total = session.TotalBytes,
});
}
/// Body for zip-upload-finalize.
/// stamps every imported room/invention with that player as the
/// owner. and
/// , when non-null and
/// non-empty, restrict the import to only those top-level folders
/// inside the archive — the SPA's preview offers a checkbox per
/// room/invention so admins can skip a bad export's broken entry
/// without rebuilding the zip. Null/empty means "import
/// everything" (back-compat with older SPA builds).
public sealed record ChunkFinalizeRequest(
long? CreatorPlayerId,
List? SelectedRoomFolders,
List? SelectedInventionFolders,
bool? NormalizeBlobs);
/// In-memory background job state for an async import.
/// The HTTP request that kicked the import off only stays alive
/// for milliseconds (it returns 202 immediately); the work runs
/// in Task.Run against a freshly-created DI scope so it
/// outlives the request. The SPA polls
/// GET zip-import-status/{jobId} for progress + result.
/// Jobs auto-expire 1 hour after finish (so the dictionary doesn't
/// grow forever).
public sealed class ImportJob
{
public Guid Id { get; init; }
public long AdminPlayerId { get; init; }
public DateTime StartedAt { get; init; } = DateTime.UtcNow;
public DateTime? FinishedAt { get; set; }
/// "running" | "done" | "failed".
public string State { get; set; } = "running";
/// Short progress string updated by the import core
/// after each room/invention completes ("room 5/12: …").
public string? Phase { get; set; }
/// On success: the same anonymous payload the sync
/// endpoint would have returned. On failure (validation
/// error): the { error = ..., ... } object the sync
/// endpoint would have wrapped in BadRequest.
public object? Result { get; set; }
/// Exception message when is
/// "failed". stays null in that case.
public string? Error { get; set; }
}
private static readonly System.Collections.Concurrent.ConcurrentDictionary Jobs = new();
/// POST zip-upload-finalize/{id} — kicks off the
/// import on the assembled temp file in a background task and
/// returns 202 immediately with a jobId. The synchronous
/// path got 524'd by Cloudflare on multi-GB archives (edge
/// disconnects clients at 100s but the server kept processing
/// successfully). Now: the upload session is consumed here, the
/// finalize request returns before any timeout, and the SPA polls
/// the status endpoint for completion.
[HttpPost("zip-upload-finalize/{id:guid}")]
public ActionResult ChunkFinalize(Guid id, [FromBody] ChunkFinalizeRequest? body)
{
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
var adminId = CurrentAdminId;
if (session.AdminPlayerId != adminId) return Forbid();
// Capture before we leave the request context.
var tempPath = session.TempPath;
var totalBytes = session.TotalBytes;
var creator = body?.CreatorPlayerId ?? adminId;
var selectedRoomFolders = body?.SelectedRoomFolders;
var selectedInventionFolders = body?.SelectedInventionFolders;
var normalizeBlobs = body?.NormalizeBlobs ?? true;
var jobId = Guid.NewGuid();
var job = new ImportJob { Id = jobId, AdminPlayerId = adminId };
Jobs[jobId] = job;
Sessions.TryRemove(id, out _);
// Prune jobs older than 1 hour past completion to keep the
// dictionary bounded. Admin only runs the importer a few times
// a day, so this stays tiny.
foreach (var (jid, j) in Jobs)
{
if (j.FinishedAt is { } fin && DateTime.UtcNow - fin > TimeSpan.FromHours(1))
Jobs.TryRemove(jid, out _);
}
_ = Task.Run(async () =>
{
using var scope = scopeFactory.CreateScope();
var sp = scope.ServiceProvider;
// Build a fresh background controller instance bound to
// the new scope's services. The original `this` controller
// is tied to the request scope and will be disposed the
// moment we return below — we cannot reference its `db`,
// `storage`, etc. from the bg task.
var bgDb = sp.GetRequiredService();
var bgNorm = sp.GetRequiredService();
var bgStorage = sp.GetRequiredService();
var bgLogger = sp.GetRequiredService>();
var bgController = new RoomZipImportController(bgDb, bgNorm, bgStorage, scopeFactory, bgLogger);
try
{
await using var stream = System.IO.File.OpenRead(tempPath);
var outcome = await bgController.RunImportCoreAsync(
stream, totalBytes, creator, adminId,
selectedRoomFolders, selectedInventionFolders, normalizeBlobs,
jobPhase: p => job.Phase = p);
job.Result = outcome.Payload;
job.State = outcome.Success ? "done" : "failed";
}
catch (Exception ex)
{
bgLogger.LogError(ex, "[zip-import job {Job}] failed", jobId);
job.Error = ex.Message;
job.State = "failed";
}
finally
{
job.FinishedAt = DateTime.UtcNow;
try { if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath); }
catch (Exception ex) { bgLogger.LogWarning(ex, "[zip-import] failed to clean up {Path}", tempPath); }
}
});
return Accepted(new
{
jobId,
state = job.State,
statusUrl = $"/api/admin/v1/rooms/zip-import-status/{jobId}",
});
}
/// GET zip-import-status/{jobId} — SPA polls this
/// every few seconds while the import is running. Returns the
/// final result payload once state == "done" or
/// "failed"; on "failed" the payload either includes a
/// validation error (creator_not_found etc.) or just an
/// error string from the caught exception.
[HttpGet("zip-import-status/{jobId:guid}")]
public ActionResult GetImportStatus(Guid jobId)
{
if (!Jobs.TryGetValue(jobId, out var job)) return NotFound(new { error = "unknown_job" });
if (job.AdminPlayerId != CurrentAdminId) return Forbid();
return Ok(new
{
jobId = job.Id,
state = job.State,
phase = job.Phase,
startedAt = job.StartedAt,
finishedAt = job.FinishedAt,
elapsedSeconds = ((job.FinishedAt ?? DateTime.UtcNow) - job.StartedAt).TotalSeconds,
result = job.Result,
error = job.Error,
});
}
/// DELETE zip-upload-abort/{id} — drop an in-flight
/// upload session (e.g. user navigated away). No-op if the id is
/// already gone.
[HttpDelete("zip-upload-abort/{id:guid}")]
public IActionResult ChunkAbort(Guid id)
{
if (Sessions.TryRemove(id, out var session))
{
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
}
return Ok();
}
// ── Shared import core (called by both single-shot and chunked) ──
/// Outcome of a single zip import. Wraps both success
/// payloads and validation errors into one object so the same
/// import code can serve the sync zip-bulk-import endpoint
/// AND the async zip-upload-finalize background job (which
/// has no to return — it stores the
/// outcome in an for the SPA to poll).
public sealed class ImportOutcome
{
/// true → payload is the success response;
/// false → payload includes an "error" property and the
/// sync endpoint should return 400.
public bool Success { get; init; }
/// The anonymous payload that would have been wrapped
/// in Ok()/BadRequest() in the sync path.
public object Payload { get; init; } = new { };
}
private async Task RunImportAsync(
Stream archiveStream,
long archiveBytes,
long? creatorPlayerId,
List? selectedRoomFolders = null,
List? selectedInventionFolders = null,
bool normalizeBlobs = false)
{
var adminId = CurrentAdminId;
var outcome = await RunImportCoreAsync(
archiveStream, archiveBytes,
creatorPlayerId ?? adminId,
adminId,
selectedRoomFolders, selectedInventionFolders, normalizeBlobs,
jobPhase: null);
return outcome.Success
? Ok(outcome.Payload)
: BadRequest(outcome.Payload);
}
/// Pure import worker: no HttpContext access (caller passes
/// the resolved ), no IActionResult
/// wrapping. Background jobs call this via a freshly-resolved
/// scope so they can outlive the request that kicked them off.
/// , when non-null, gets updated with a
/// short progress string after each room/invention is processed so
/// the status endpoint can report meaningful progress.
private async Task RunImportCoreAsync(
Stream archiveStream,
long archiveBytes,
long creator,
long adminId,
List? selectedRoomFolders,
List? selectedInventionFolders,
bool normalizeBlobs,
Action? jobPhase)
{
if (!await db.Players.AnyAsync(p => p.Id == creator))
return new ImportOutcome { Success = false, Payload = new { error = "creator_not_found", creator } };
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false);
// Index entries by normalized path. We do path-based lookups
// throughout (per-room folder, per-subroom folder, per-invention
// folder), so a flat dictionary keyed by full path is simplest.
var entryByPath = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var e in zip.Entries)
{
if (e.FullName.EndsWith("/", StringComparison.Ordinal)) continue;
entryByPath[Normalize(e.FullName)] = e;
}
// Find every room manifest (`Rooms//RoomDetails.json`).
// Folder names are like "Room_3163127_YarrHarrHeist_20260509_180319"
// but we don't parse anything out of them — RoomDetails.json is
// the source of truth.
var roomFolders = entryByPath.Keys
.Where(k => k.StartsWith("Rooms/", StringComparison.OrdinalIgnoreCase)
&& k.EndsWith("/RoomDetails.json", StringComparison.OrdinalIgnoreCase))
.Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length))
.ToList();
// Inventions can live either at the zip root (Inventions/Invention___/)
// OR inside a room folder (Rooms/Room___/Inventions/Invention_…).
// Both layouts show up in the wild — handle either.
var inventionFolders = entryByPath.Keys
.Where(k => k.EndsWith("/Invention.json", StringComparison.OrdinalIgnoreCase)
&& (k.StartsWith("Inventions/", StringComparison.OrdinalIgnoreCase)
|| k.Contains("/Inventions/", StringComparison.OrdinalIgnoreCase)))
.Select(k => k[..^"/Invention.json".Length])
.ToList();
if (roomFolders.Count == 0 && inventionFolders.Count == 0)
return new ImportOutcome { Success = false, Payload = new { error = "empty_archive_no_manifests" } };
// If the admin supplied an explicit selection (non-null,
// non-empty), filter to just those folders. Comparison is
// case-insensitive and tolerates leading/trailing slashes that
// the client might have included. Anything in the selection
// that we can't match against the archive is just ignored —
// the per-room report will simply not include it.
if (selectedRoomFolders is { Count: > 0 })
{
var allowed = selectedRoomFolders
.Select(s => Normalize(s).TrimEnd('/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
roomFolders = roomFolders.Where(f => allowed.Contains(f)).ToList();
}
if (selectedInventionFolders is { Count: > 0 })
{
var allowed = selectedInventionFolders
.Select(s => Normalize(s).TrimEnd('/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
inventionFolders = inventionFolders.Where(f => allowed.Contains(f)).ToList();
}
var roomReports = new List