using System.Diagnostics;
using Blog.Models;
using Microsoft.Extensions.Options;
namespace Blog.Services;
/// Where the drop tables are read from.
public sealed class WarframeOptions
{
public const string Section = "Warframe";
///
/// The vendored copy of DE's drop tables, relative to the deployed binary. warframe.com blocks
/// requests from the server, so the page ships with the build instead;
/// scripts/update-droptables.sh refreshes it before a publish.
///
public string FilePath { get; set; } = "Resources/droptables.html";
}
///
/// Keeps one parsed copy of the Warframe drop tables in memory and hands it to whoever asks.
///
///
/// Not HybridCache, which the rest of this site uses: the parsed tables are tens of
/// thousands of records, and HybridCache serializes what it stores. One value behind a gate is the
/// whole mechanism, and it only works while the service is a singleton.
///
/// The file cannot change without a deploy, which restarts the process, so the parse would be a
/// one-off if not for `dotnet watch`. Keying the copy on the file's last write time costs one stat
/// per request and picks up a refresh in development without a restart.
///
public sealed class WarframeDropService(
IOptions options,
ILogger logger)
{
private readonly WarframeOptions _options = options.Value;
private readonly SemaphoreSlim _gate = new(1, 1);
private DropTables? _tables;
private DateTimeOffset _parsedStamp;
///
/// Resolved against the directory the binary sits in, not the content root and not the working
/// directory. Both of those default to wherever the process was started from, so a unit file
/// without a WorkingDirectory would send this looking in /. The drop tables ship next to the
/// assembly, so that is what to anchor to.
///
private string Path => System.IO.Path.Combine(AppContext.BaseDirectory, _options.FilePath);
public async Task GetTablesAsync(CancellationToken cancellationToken = default)
{
var file = new FileInfo(Path);
if (!file.Exists)
{
logger.LogError("The drop tables are missing from {Path}", Path);
return DropTableStatus.Failed($"The drop tables are missing from {_options.FilePath}.", _tables);
}
var stamp = new DateTimeOffset(file.LastWriteTimeUtc, TimeSpan.Zero);
if (_tables is { } fresh && stamp == _parsedStamp)
{
return DropTableStatus.Ready(fresh);
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Someone else may have parsed the same file while this request queued for the gate.
if (_tables is { } current && stamp == _parsedStamp)
{
return DropTableStatus.Ready(current);
}
var tables = await ParseAsync(stamp, cancellationToken).ConfigureAwait(false);
_tables = tables;
_parsedStamp = stamp;
return DropTableStatus.Ready(tables);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Could not read the drop tables from {Path}", Path);
return DropTableStatus.Failed($"Could not read the drop tables ({ex.Message})", _tables);
}
finally
{
_gate.Release();
}
}
private async Task ParseAsync(DateTimeOffset stamp, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
var html = await File.ReadAllTextAsync(Path, cancellationToken).ConfigureAwait(false);
var tables = DropTableParser.Parse(html, stamp);
logger.LogInformation(
"Read the Warframe drop tables ({Update}): {Bytes} bytes, {Items} items in {Elapsed} ms",
tables.LastUpdate, html.Length, tables.Names.Count, stopwatch.ElapsedMilliseconds);
return tables;
}
}