using AvParser.Core.Collecting;
using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.Logging;
namespace AvParser.Infrastructure.Media;
/// Whether a promotion actually wrote anything.
public enum BlobPromotion
{
/// The file was moved into the store; these bytes were new.
Written = 0,
/// The content was already held, so the temporary file was discarded.
AlreadyPresent = 1,
}
///
/// The files on disk: content-addressed, sharded, and written only once complete.
///
///
/// The invariant this type exists to hold is that blobs/ contains nothing but complete,
/// hashed content. Downloads are staged in a sibling temporary directory on the same volume and
/// promoted by rename, so a crash mid-download leaves a stray temporary file rather than a
/// truncated image that would then be indistinguishable from a real one for ever.
///
public sealed class BlobStore(IAppPaths paths, ILogger logger)
{
private readonly IAppPaths _paths = paths ?? throw new ArgumentNullException(nameof(paths));
private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
/// Absolute path this content would live at.
public string PathFor(MediaBlob blob)
{
ArgumentNullException.ThrowIfNull(blob);
return Path.Combine(_paths.BlobDirectory, blob.RelativePath);
}
/// Absolute path for a hash and extension already known.
public string PathFor(string sha256, string extension)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sha256);
return Path.Combine(
_paths.BlobDirectory,
sha256[..2],
sha256.Substring(2, 2),
string.Concat(sha256, extension)
);
}
/// Whether this content is already held.
public bool Exists(MediaBlob blob) => File.Exists(PathFor(blob));
/// Reserves a path for an in-progress download.
///
/// The .part suffix is load-bearing for the sweep below: it is how a leftover from a
/// killed process is told apart from a file some other part of the app is using.
///
public string CreateTempPath()
{
Directory.CreateDirectory(_paths.MediaTempDirectory);
return Path.Combine(_paths.MediaTempDirectory, $"{Guid.NewGuid():N}.part");
}
///
/// Takes ownership of a completed temporary file and moves it into the store.
///
///
/// The temporary file is consumed either way: moved when the content is new, deleted when it
/// was already held. Callers must not touch it afterwards.
///
public BlobPromotion Promote(MediaBlob blob, string tempFilePath)
{
ArgumentNullException.ThrowIfNull(blob);
ArgumentException.ThrowIfNullOrWhiteSpace(tempFilePath);
var target = PathFor(blob);
if (File.Exists(target))
{
// Identical hash means identical bytes; there is nothing to compare and nothing to write.
TryDelete(tempFilePath);
return BlobPromotion.AlreadyPresent;
}
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
try
{
// Deliberately without overwrite: if a concurrent worker just wrote the same content,
// its file is as good as ours and clobbering it would break any reader mid-stream.
File.Move(tempFilePath, target);
return BlobPromotion.Written;
}
catch (IOException) when (File.Exists(target))
{
TryDelete(tempFilePath);
return BlobPromotion.AlreadyPresent;
}
}
/// Removes content from disk. Missing is success — the caller wants it gone.
public bool Delete(string sha256, string extension)
{
var path = PathFor(sha256, extension);
if (!File.Exists(path))
{
return false;
}
if (!TryDelete(path))
{
return false;
}
TrimEmptyShards(Path.GetDirectoryName(path));
return true;
}
///
/// Deletes temporary files left behind by a previous process.
///
/// How many were removed.
///
/// Only files older than are touched, so a sweep at startup
/// cannot delete a download that another instance of the app is running right now.
///
public int SweepTemp(TimeSpan olderThan)
{
if (!Directory.Exists(_paths.MediaTempDirectory))
{
return 0;
}
var cutoff = DateTime.UtcNow - olderThan;
var removed = 0;
foreach (var file in Directory.EnumerateFiles(_paths.MediaTempDirectory, "*.part"))
{
try
{
if (File.GetLastWriteTimeUtc(file) < cutoff && TryDelete(file))
{
removed++;
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Being unable to tidy up is not a reason to fail whatever asked for the sweep.
}
}
if (removed > 0)
{
_logger.LogInformation("Removed {Count} unfinished download(s) from a previous run", removed);
}
return removed;
}
private bool TryDelete(string path)
{
try
{
File.Delete(path);
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Could not delete {Path}", path);
return false;
}
}
/// Removes the two shard directories once the last file in them is gone.
private void TrimEmptyShards(string? leaf)
{
for (var directory = leaf; directory is not null; directory = Path.GetDirectoryName(directory))
{
if (
string.Equals(
Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar),
Path.GetFullPath(_paths.BlobDirectory).TrimEnd(Path.DirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase
)
)
{
return;
}
try
{
if (Directory.EnumerateFileSystemEntries(directory).Any())
{
return;
}
Directory.Delete(directory);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return;
}
}
}
}