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; } } } }