feat(data): rename *.Services projects, lift TrackEntity onto BlazorBlocks data layer, regenerate initial Postgres migration
DeepDrftWeb.Services → DeepDrftData; DeepDrftContent.Services → DeepDrftContent.Data. TrackEntity:BaseEntity, TrackRepository:Repository<>, TrackManager:Manager<>+ITrackService. Drops DeepDrftModels PagingParameters/PagedResult in favour of Models.Common.* from BlazorBlocks. InitialCreate migration captures full schema including is_deleted index.
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DeepDrftContent.Data.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating WAV audio streams starting from a byte offset.
|
||||
/// Synthesizes a valid WAV header for the remaining audio data.
|
||||
/// </summary>
|
||||
public class WavOffsetService
|
||||
{
|
||||
/// <summary>
|
||||
/// WAV audio format code for linear PCM. The pipeline (AudioProcessor,
|
||||
/// WavOffsetService, and wavutils.ts) is PCM-only by design — IEEE Float
|
||||
/// (format 3) and other formats are rejected at parse time so the
|
||||
/// synthesized header here can safely assume PCM.
|
||||
/// </summary>
|
||||
public const short PcmFormat = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a stream containing a synthesized WAV header followed by audio data from the specified offset.
|
||||
/// The returned stream is composed of a small header buffer and a non-owning slice over the input
|
||||
/// buffer — no copy of the audio payload is made.
|
||||
/// </summary>
|
||||
/// <param name="fullAudioBuffer">The complete WAV file buffer</param>
|
||||
/// <param name="byteOffset">Byte offset into the raw audio data (not including original header)</param>
|
||||
/// <returns>Stream with new WAV header + audio data from offset, or null if invalid</returns>
|
||||
public Stream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset)
|
||||
{
|
||||
var format = ParseWavHeader(fullAudioBuffer);
|
||||
if (format == null)
|
||||
return null;
|
||||
|
||||
// Validate offset is within bounds and block-aligned
|
||||
if (byteOffset < 0 || byteOffset >= format.DataSize)
|
||||
return null;
|
||||
|
||||
// Align to block boundary for clean audio
|
||||
var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign;
|
||||
|
||||
// Calculate new data size (long arithmetic — DataSize may be up to ~4 GB)
|
||||
var newDataSize = format.DataSize - alignedOffset;
|
||||
if (newDataSize <= 0)
|
||||
return null;
|
||||
|
||||
// MemoryStream does not support offsets or lengths beyond int.MaxValue.
|
||||
// RF64 (>2 GB audio segments) is not supported; reject before truncating.
|
||||
var sourcePosition = format.HeaderSize + alignedOffset;
|
||||
if (sourcePosition > int.MaxValue || newDataSize > int.MaxValue)
|
||||
throw new NotSupportedException("Audio file segment exceeds 2 GB; RF64 not supported");
|
||||
|
||||
var newDataSizeInt = (int)newDataSize;
|
||||
var sourcePositionInt = (int)sourcePosition;
|
||||
|
||||
// Create new WAV header using the format reported by the parsed header.
|
||||
// PCM is the only format we accept (see PcmFormat / ParseWavHeader), but
|
||||
// threading format.AudioFormat through keeps the header self-consistent
|
||||
// and prevents drift if the validation contract is ever relaxed.
|
||||
var newHeader = CreateWavHeader(format, newDataSizeInt);
|
||||
|
||||
// Compose: 44-byte header followed by a non-copying slice of the audio payload.
|
||||
// Wrapping the original buffer in a MemoryStream window avoids a 100MB+ copy
|
||||
// that the previous MemoryStream(capacity).Write(...) implementation forced.
|
||||
var headerStream = new MemoryStream(newHeader, writable: false);
|
||||
var dataStream = new MemoryStream(
|
||||
fullAudioBuffer,
|
||||
sourcePositionInt,
|
||||
newDataSizeInt,
|
||||
writable: false,
|
||||
publiclyVisible: false);
|
||||
|
||||
return new ConcatStream(headerStream, dataStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the WAV header from a buffer to extract format information.
|
||||
/// PCM-only — IEEE Float (format 3) and other non-PCM formats are rejected
|
||||
/// so downstream synthesis can safely assume PCM sample encoding.
|
||||
/// </summary>
|
||||
public WavFormat? ParseWavHeader(byte[] buffer)
|
||||
{
|
||||
if (buffer.Length < 44)
|
||||
return null;
|
||||
|
||||
// Check RIFF header
|
||||
var riff = Encoding.ASCII.GetString(buffer, 0, 4);
|
||||
if (riff != "RIFF")
|
||||
return null;
|
||||
|
||||
var wave = Encoding.ASCII.GetString(buffer, 8, 4);
|
||||
if (wave != "WAVE")
|
||||
return null;
|
||||
|
||||
// Variables to store parsed header info
|
||||
int sampleRate = 0;
|
||||
int channels = 0;
|
||||
int bitsPerSample = 0;
|
||||
int byteRate = 0;
|
||||
int blockAlign = 0;
|
||||
long dataSize = 0;
|
||||
int headerSize = 0;
|
||||
short audioFormat = 0;
|
||||
bool foundFmt = false;
|
||||
bool foundData = false;
|
||||
|
||||
// Find fmt and data chunks
|
||||
int chunkOffset = 12;
|
||||
while (chunkOffset < buffer.Length - 8)
|
||||
{
|
||||
var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4);
|
||||
var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4);
|
||||
|
||||
if (chunkSize < 0)
|
||||
return null;
|
||||
|
||||
if (chunkId == "fmt " && !foundFmt)
|
||||
{
|
||||
// Use the first fmt chunk encountered — that is the WAV-spec-authoritative
|
||||
// chunk. Subsequent fmt chunks in a malformed file are ignored, matching
|
||||
// AudioProcessor.FindChunk which also returns the first match.
|
||||
if (chunkSize < 16)
|
||||
return null;
|
||||
|
||||
audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8);
|
||||
// PCM only. Float32 WAVs were previously accepted here but the synthesized
|
||||
// header below is PCM-shaped — accepting Float would produce a corrupt file
|
||||
// claiming PCM with Float-encoded samples. AudioProcessor also rejects
|
||||
// non-PCM at upload time so this branch is defense in depth.
|
||||
if (audioFormat != PcmFormat)
|
||||
return null;
|
||||
|
||||
channels = BitConverter.ToInt16(buffer, chunkOffset + 10);
|
||||
sampleRate = BitConverter.ToInt32(buffer, chunkOffset + 12);
|
||||
byteRate = BitConverter.ToInt32(buffer, chunkOffset + 16);
|
||||
blockAlign = BitConverter.ToInt16(buffer, chunkOffset + 20);
|
||||
bitsPerSample = BitConverter.ToInt16(buffer, chunkOffset + 22);
|
||||
|
||||
// Basic validation
|
||||
if (channels < 1 || channels > 8)
|
||||
return null;
|
||||
|
||||
foundFmt = true;
|
||||
}
|
||||
else if (chunkId == "data")
|
||||
{
|
||||
// WAV stores DataSize as a 32-bit unsigned int. Read as uint to preserve
|
||||
// values above int.MaxValue (files between 2–4 GB), then widen to long.
|
||||
dataSize = (long)BitConverter.ToUInt32(buffer, chunkOffset + 4);
|
||||
headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes)
|
||||
foundData = true;
|
||||
}
|
||||
|
||||
// Move to next chunk with proper alignment (chunks are word-aligned)
|
||||
chunkOffset += 8 + ((chunkSize + 1) & ~1);
|
||||
|
||||
// If we found both chunks, we're done
|
||||
if (foundFmt && foundData)
|
||||
break;
|
||||
}
|
||||
|
||||
// Must have found both fmt and data chunks
|
||||
if (!foundFmt || !foundData)
|
||||
return null;
|
||||
|
||||
return new WavFormat(
|
||||
AudioFormat: audioFormat,
|
||||
SampleRate: sampleRate,
|
||||
Channels: channels,
|
||||
BitsPerSample: bitsPerSample,
|
||||
ByteRate: byteRate,
|
||||
BlockAlign: blockAlign,
|
||||
DataSize: dataSize,
|
||||
HeaderSize: headerSize
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a standard 44-byte WAV header. The audio format code is taken from
|
||||
/// <paramref name="format"/> rather than hardcoded so the synthesized header matches
|
||||
/// what was parsed (today always <see cref="PcmFormat"/>; see ParseWavHeader).
|
||||
/// </summary>
|
||||
public byte[] CreateWavHeader(WavFormat format, int dataSize)
|
||||
{
|
||||
var header = new byte[44];
|
||||
var fileSize = 36 + dataSize;
|
||||
|
||||
// RIFF header
|
||||
header[0] = (byte)'R'; header[1] = (byte)'I'; header[2] = (byte)'F'; header[3] = (byte)'F';
|
||||
BitConverter.GetBytes(fileSize).CopyTo(header, 4);
|
||||
header[8] = (byte)'W'; header[9] = (byte)'A'; header[10] = (byte)'V'; header[11] = (byte)'E';
|
||||
|
||||
// fmt chunk
|
||||
header[12] = (byte)'f'; header[13] = (byte)'m'; header[14] = (byte)'t'; header[15] = (byte)' ';
|
||||
BitConverter.GetBytes(16).CopyTo(header, 16); // fmt chunk size
|
||||
BitConverter.GetBytes(format.AudioFormat).CopyTo(header, 20); // Audio format (from parsed header)
|
||||
BitConverter.GetBytes((short)format.Channels).CopyTo(header, 22);
|
||||
BitConverter.GetBytes(format.SampleRate).CopyTo(header, 24);
|
||||
BitConverter.GetBytes(format.ByteRate).CopyTo(header, 28);
|
||||
BitConverter.GetBytes((short)format.BlockAlign).CopyTo(header, 32);
|
||||
BitConverter.GetBytes((short)format.BitsPerSample).CopyTo(header, 34);
|
||||
|
||||
// data chunk header
|
||||
header[36] = (byte)'d'; header[37] = (byte)'a'; header[38] = (byte)'t'; header[39] = (byte)'a';
|
||||
BitConverter.GetBytes(dataSize).CopyTo(header, 40);
|
||||
|
||||
return header;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WAV format information extracted from header.
|
||||
/// </summary>
|
||||
/// <param name="AudioFormat">WAV fmt-chunk audio format code (1 = PCM; the only value accepted today).</param>
|
||||
public record WavFormat(
|
||||
short AudioFormat,
|
||||
int SampleRate,
|
||||
int Channels,
|
||||
int BitsPerSample,
|
||||
int ByteRate,
|
||||
int BlockAlign,
|
||||
long DataSize,
|
||||
int HeaderSize
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Forward-only read stream over two underlying streams concatenated end-to-end.
|
||||
/// Lets us serve "[synthesized header][slice of original buffer]" without
|
||||
/// allocating a single contiguous buffer for the combined payload.
|
||||
/// </summary>
|
||||
internal sealed class ConcatStream : Stream
|
||||
{
|
||||
private readonly Stream _first;
|
||||
private readonly Stream _second;
|
||||
private readonly long _length;
|
||||
private long _position;
|
||||
|
||||
public ConcatStream(Stream first, Stream second)
|
||||
{
|
||||
_first = first;
|
||||
_second = second;
|
||||
_length = first.Length + second.Length;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
// Loop over _first until it returns 0 (exhausted) or the caller's buffer
|
||||
// is full. Stream.Read is not required to fill the buffer in one call even
|
||||
// when data is available (e.g. a future non-MemoryStream _first), so we must
|
||||
// keep pulling until we get 0 before advancing to _second.
|
||||
while (count > 0 && _position < _first.Length)
|
||||
{
|
||||
var read = _first.Read(buffer, offset, count);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
_position += read;
|
||||
offset += read;
|
||||
count -= read;
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
var read = _second.Read(buffer, offset, count);
|
||||
total += read;
|
||||
_position += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
// Same loop contract as Read() — exhaust _first before reading _second.
|
||||
while (!buffer.IsEmpty && _position < _first.Length)
|
||||
{
|
||||
var read = await _first.ReadAsync(buffer, cancellationToken);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
_position += read;
|
||||
buffer = buffer[read..];
|
||||
}
|
||||
|
||||
if (!buffer.IsEmpty)
|
||||
{
|
||||
var read = await _second.ReadAsync(buffer, cancellationToken);
|
||||
total += read;
|
||||
_position += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_first.Dispose();
|
||||
_second.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
# CLAUDE.md - DeepDrftContent.Services
|
||||
|
||||
Guidance for working in the DeepDrftContent.Services project (the binary-content domain logic).
|
||||
|
||||
See the root `CLAUDE.md` for full architecture overview. This file covers what is specific to this project.
|
||||
|
||||
## One-line purpose
|
||||
|
||||
Binary-content domain logic. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), WAV stream-with-offset, audio processing, and the content-side track service. Consumed by `DeepDrftContent` (the host) and `DeepDrftCli` (the admin CLI).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
DeepDrftContent.Services/
|
||||
├── FileDatabase/ # The subsystem (port of TypeScript system)
|
||||
│ ├── Abstractions/ # Interfaces
|
||||
│ ├── Models/ # Data models, DTOs, enums
|
||||
│ ├── Services/ # FileDatabase, MediaVault, IndexSystem, IndexWatcher
|
||||
│ └── Utils/ # StructuralMap, StructuralSet, FileUtils
|
||||
├── Audio/
|
||||
│ └── WavOffsetService.cs # Byte offset → valid WAV stream
|
||||
├── Processors/
|
||||
│ └── AudioProcessor.cs # WAV file parsing, metadata extraction
|
||||
├── Constants/
|
||||
│ └── VaultConstants.cs # Vault name definitions
|
||||
├── TrackService.cs # Content-side orchestrator
|
||||
└── DeepDrftContent.Services.csproj
|
||||
```
|
||||
|
||||
## FileDatabase model (high-level)
|
||||
|
||||
See `FileDatabase/README.md` for the long-form design discussion — it's a port of a TypeScript system and has deep rationale. This section covers the essentials for an agent walking in cold.
|
||||
|
||||
### Core structure
|
||||
|
||||
- **FileDatabase**: Root object. Created via `FileDatabase.FromAsync(rootPath)`. Holds a collection of `MediaVault` instances and an `IndexWatcher`. Implements `IDisposable`. Singleton in the host.
|
||||
- **MediaVault**: A subdirectory under the FileDatabase root. Each vault has its own JSON `index` file listing entries and per-entry metadata. Typed via `MediaVaultType` enum (`Media | Image | Audio`).
|
||||
- Concrete implementations: `ImageVault` (for images), `AudioVault` (for audio). Do not use `ImageDirectoryVault` (that's stale docs) — the type is `ImageVault`.
|
||||
- **Entry filenames**: `{sanitized-key}{extension}`, where sanitisation is `Regex.Replace(entryKey, @"[^a-zA-Z0-9]", "-")`. So entry id `"my-song"` with extension `.wav` → filename `my-song.wav`.
|
||||
|
||||
### Binary hierarchy
|
||||
|
||||
```
|
||||
FileBinary (base: byte buffer)
|
||||
└── MediaBinary (+ Extension: string, MIME type inferred via MimeTypeExtensions)
|
||||
├── AudioBinary (+ Duration: double, Bitrate: int)
|
||||
└── ImageBinary (+ AspectRatio: double)
|
||||
```
|
||||
|
||||
Each has a matching `*Dto` variant for base64 JSON transport (e.g., `AudioBinaryDto` with buffer encoded as base64).
|
||||
|
||||
### Index lifecycle
|
||||
|
||||
- **DirectoryIndex**: Root index file (at `{rootPath}/index`). Tracks which vaults exist.
|
||||
- **VaultIndex**: Per-vault index (at `{vaultPath}/index`). Records `MediaVaultType` and lists all entries in that vault.
|
||||
- Both are JSON files. Created/loaded via `IndexFactoryService`.
|
||||
- When a file is written externally (e.g., the CLI calls `FileDatabase.RegisterResourceAsync` directly), the **IndexWatcher** detects the write to the vault's `index` file and triggers `MediaVault.ReloadIndexAsync`, so a long-running web host stays consistent without restart.
|
||||
|
||||
## Error-handling philosophy (load-bearing)
|
||||
|
||||
Public `Load*` / `Register*` operations **swallow exceptions and return `null` / `false`** to match the TypeScript original.
|
||||
|
||||
```csharp
|
||||
public async Task<T?> LoadResourceAsync<T>(string vaultId, string entryId) where T : FileBinary
|
||||
{
|
||||
try { /* load and deserialize */ }
|
||||
catch { return null; } // Swallow, return null
|
||||
}
|
||||
|
||||
public async Task<bool> RegisterResourceAsync(string vaultId, string entryId, FileBinary media)
|
||||
{
|
||||
try { /* store and update index */ }
|
||||
catch { return false; } // Swallow, return false
|
||||
}
|
||||
```
|
||||
|
||||
**Callers must check return values.** Do not change this without a deliberate design pass — it's embedded in all FileDatabase tests and client code.
|
||||
|
||||
## WAV offset service
|
||||
|
||||
`WavOffsetService.CreateOffsetStream(buffer, byteOffset)`:
|
||||
|
||||
1. Parses the WAV header from the buffer.
|
||||
2. Block-aligns the byte offset to the nearest block boundary (required for clean audio — misalignment causes clicks).
|
||||
3. Synthesises a new 44-byte WAV header sized for the remaining data (from offset to EOF).
|
||||
4. Returns a `MemoryStream` containing `[new header][data from offset]`.
|
||||
|
||||
Used by the content API to serve seek-beyond-buffer requests. The player asks for a new stream at the byte offset it wants to seek to; the server returns a valid WAV that starts there.
|
||||
|
||||
**Block alignment is critical.** Do not bypass it. The WAV fmt chunk tells you the block size; use it.
|
||||
|
||||
## Audio processor
|
||||
|
||||
`AudioProcessor.ProcessWavFileAsync(filePath)`:
|
||||
|
||||
1. Validates the RIFF/WAVE/PCM structure.
|
||||
2. Parses the fmt and data chunks.
|
||||
3. Extracts duration (sample count / sample rate) and bitrate (file size / duration).
|
||||
4. Returns `AudioBinary` with all metadata.
|
||||
5. **Fallback**: If parsing fails, logs a warning and returns defaults (180s / 1411 kbps / 44.1 kHz / 16-bit stereo).
|
||||
|
||||
PCM-only today. Other formats (mp3, flac, aac, ogg, m4a) are listed in `MimeTypeExtensions` but not implemented. The processor validates RIFF/WAVE/PCM format — anything else is rejected.
|
||||
|
||||
## Content-side TrackService (orchestrator)
|
||||
|
||||
### AddTrackFromWavAsync(filePath)
|
||||
|
||||
1. Reads a WAV file from disk.
|
||||
2. Calls `AudioProcessor.ProcessWavFileAsync` → `AudioBinary`.
|
||||
3. Generates a GUID entry key (via `Guid.NewGuid().ToString()`).
|
||||
4. Ensures the `tracks` vault exists (creates if missing).
|
||||
5. Calls `FileDatabase.RegisterResourceAsync("tracks", entryKey, audioBinary)`.
|
||||
6. Returns a populated `TrackEntity` (with `Id = 0` since it's not yet in SQL).
|
||||
|
||||
**Note**: The caller (CLI or web) is responsible for then saving this entity to SQL via `DeepDrftWeb.Services.TrackService.Create`. If the vault write succeeds and SQL write fails, audio is orphaned (no compensating rollback).
|
||||
|
||||
### GetAudioBinaryAsync(entryKey)
|
||||
|
||||
Reads audio from the `tracks` vault via `FileDatabase.LoadResourceAsync<AudioBinary>("tracks", entryKey)`. Returns `null` if not found or read fails.
|
||||
|
||||
### InitializeTracksVaultAsync()
|
||||
|
||||
Safety call to ensure the `tracks` vault exists (creates if missing). Called on host startup.
|
||||
|
||||
## Vault constants
|
||||
|
||||
`VaultConstants.Tracks = "tracks"` — the one vault name in production use. New vault names go here when adding new vault types (e.g., `VaultConstants.Images = "images"` if image uploads are added).
|
||||
|
||||
## Service registration
|
||||
|
||||
In `DeepDrftContent/Startup.ConfigureDomainServices()` and `DeepDrftCli/Program.cs`:
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<WavOffsetService>();
|
||||
services.AddSingleton<FileDatabase>(/* from FileDatabase.FromAsync */);
|
||||
services.AddScoped<AudioProcessor>();
|
||||
services.AddScoped<TrackService>(); // DeepDrftContent.Services.TrackService
|
||||
```
|
||||
|
||||
## Development commands
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build DeepDrftContent.Services
|
||||
|
||||
# Run tests (FileDatabase tests cover vault/index/factory/utilities thoroughly)
|
||||
dotnet test DeepDrftTests/
|
||||
|
||||
# Run CLI (which consumes this service)
|
||||
dotnet run --project DeepDrftCli -- add myfile.wav "Track Name" "Artist"
|
||||
```
|
||||
|
||||
## Important patterns
|
||||
|
||||
- **Async/await**: All FileDatabase operations are async. No sync methods.
|
||||
- **Type safety**: Generic `LoadResourceAsync<T>` ensures callers know what they're loading.
|
||||
- **Vault lifecycle**: Vaults are created on first boot, then reused. The `FileDatabase` singleton holds them in memory with live `IndexWatcher`es.
|
||||
- **Entry sanitisation**: Keys are sanitised client-side (in the CLI and web host) *and* by `MediaVault` (defensive). Always sanitise before registering — it's the only way to ensure safe filenames.
|
||||
- **Metadata hierarchy**: Use the appropriate media type (`AudioBinary`, `ImageBinary`) so downstream code can rely on the metadata. Don't store an audio file as a generic `MediaBinary` — use `AudioBinary` with duration/bitrate.
|
||||
|
||||
## What does NOT live here
|
||||
|
||||
- HTTP controllers or middleware — that's `DeepDrftContent`.
|
||||
- SQL database code — that's `DeepDrftWeb.Services`.
|
||||
- Blazor components or UI logic — that's `DeepDrftWeb.Client`.
|
||||
- Configuration files (`appsettings.json`, `filedatabase.json`) — those are in the host project.
|
||||
|
||||
When working with this project, focus on the FileDatabase subsystem (the most complex piece of the codebase), audio processing, and the orchestration logic that bridges binary and SQL databases. The tests (in `DeepDrftTests/`) are the load-bearing documentation of FileDatabase behaviour — consult them when FileDatabase semantics are unclear.
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace DeepDrftContent.Data.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for FileDatabase vault names
|
||||
/// </summary>
|
||||
public static class VaultConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// Vault name for storing audio tracks
|
||||
/// </summary>
|
||||
public const string Tracks = "tracks";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DeepDrftModels\DeepDrftModels.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,51 @@
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
using IndexType = DeepDrftContent.Data.FileDatabase.Services.IndexType;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for creating index instances
|
||||
/// </summary>
|
||||
public interface IIndexFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads an existing index of the specified type
|
||||
/// </summary>
|
||||
Task<IIndex?> LoadIndexAsync(IndexType type, string rootPath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a directory index
|
||||
/// </summary>
|
||||
Task<IDirectoryIndex?> CreateDirectoryIndexAsync(string rootPath);
|
||||
|
||||
/// <summary>
|
||||
/// Loads existing directory index or creates new one if loading fails
|
||||
/// </summary>
|
||||
Task<IDirectoryIndex?> LoadOrCreateDirectoryIndexAsync(string rootPath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a vault index with the specified vault type
|
||||
/// </summary>
|
||||
Task<IVaultIndex?> CreateVaultIndexAsync(string rootPath, MediaVaultType vaultType);
|
||||
|
||||
/// <summary>
|
||||
/// Loads existing vault index or creates new one with the specified vault type if loading fails
|
||||
/// </summary>
|
||||
Task<IVaultIndex?> LoadOrCreateVaultIndexAsync(string rootPath, MediaVaultType vaultType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for creating index data objects
|
||||
/// </summary>
|
||||
public interface IIndexDataFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates index data for serialization
|
||||
/// </summary>
|
||||
object CreateIndexData(IndexType type, IIndex index);
|
||||
|
||||
/// <summary>
|
||||
/// Creates index instance from data
|
||||
/// </summary>
|
||||
IIndex CreateIndexFromData(IndexType type, object indexData);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
using DeepDrftContent.Data.FileDatabase.Services;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for registering media type factories
|
||||
/// </summary>
|
||||
public interface IMediaTypeRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Register a factory for a specific media vault type
|
||||
/// </summary>
|
||||
void RegisterMediaType<TBinary, TParams, TDto, TMetaData, TVault>(MediaVaultType vaultType)
|
||||
where TBinary : FileBinary
|
||||
where TParams : FileBinaryParams
|
||||
where TDto : FileBinaryDto
|
||||
where TMetaData : MetaData;
|
||||
|
||||
/// <summary>
|
||||
/// Create a binary object from parameters
|
||||
/// </summary>
|
||||
FileBinary CreateBinary(MediaVaultType vaultType, object parameters);
|
||||
|
||||
/// <summary>
|
||||
/// Create a binary object from DTO
|
||||
/// </summary>
|
||||
FileBinary CreateBinaryFromDto(MediaVaultType vaultType, object dto);
|
||||
|
||||
/// <summary>
|
||||
/// Create a DTO from binary object
|
||||
/// </summary>
|
||||
FileBinaryDto CreateDto(MediaVaultType vaultType, FileBinary binary);
|
||||
|
||||
/// <summary>
|
||||
/// Create metadata from media object
|
||||
/// </summary>
|
||||
MetaData CreateMetaDataFromMedia(MediaVaultType vaultType, string entryKey, string extension, object media);
|
||||
|
||||
/// <summary>
|
||||
/// Create parameters from binary and metadata
|
||||
/// </summary>
|
||||
object CreateParams(MediaVaultType vaultType, FileBinary fileBinary, MetaData metaData);
|
||||
|
||||
/// <summary>
|
||||
/// Create media vault
|
||||
/// </summary>
|
||||
Task<MediaVault?> CreateVaultAsync(MediaVaultType vaultType, string rootPath);
|
||||
|
||||
/// <summary>
|
||||
/// Get the binary type for a vault type
|
||||
/// </summary>
|
||||
Type GetBinaryType(MediaVaultType vaultType);
|
||||
|
||||
/// <summary>
|
||||
/// Get the DTO type for a vault type
|
||||
/// </summary>
|
||||
Type GetDtoType(MediaVaultType vaultType);
|
||||
|
||||
/// <summary>
|
||||
/// Get the parameters type for a vault type
|
||||
/// </summary>
|
||||
Type GetParamsType(MediaVaultType vaultType);
|
||||
|
||||
/// <summary>
|
||||
/// Get the metadata type for a vault type
|
||||
/// </summary>
|
||||
Type GetMetaDataType(MediaVaultType vaultType);
|
||||
|
||||
/// <summary>
|
||||
/// Get the vault type for a binary type (reverse mapping)
|
||||
/// </summary>
|
||||
MediaVaultType GetVaultType(Type binaryType);
|
||||
|
||||
/// <summary>
|
||||
/// Get the vault type for a binary type using generics (reverse mapping)
|
||||
/// </summary>
|
||||
MediaVaultType GetVaultType<T>() where T : FileBinary;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace DeepDrftContent.Data.FileDatabase.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Base interface for all index types - minimal contract
|
||||
/// </summary>
|
||||
public interface IIndex
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the key identifier for this index
|
||||
/// </summary>
|
||||
string GetKey();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for indexes that support entry queries
|
||||
/// </summary>
|
||||
public interface IEntryQueryable : IIndex
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all entry IDs in this index
|
||||
/// </summary>
|
||||
IReadOnlyList<string> GetEntries();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of entries in this index
|
||||
/// </summary>
|
||||
int GetEntriesSize();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the index contains the specified entry ID
|
||||
/// </summary>
|
||||
bool HasEntry(string entryId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for indexes that support directory operations
|
||||
/// </summary>
|
||||
public interface IDirectoryIndex : IEntryQueryable
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds an entry to the directory index
|
||||
/// </summary>
|
||||
void PutEntry(string entryId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for indexes that support vault operations with metadata
|
||||
/// </summary>
|
||||
public interface IVaultIndex : IEntryQueryable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets metadata for a specific entry
|
||||
/// </summary>
|
||||
MetaData? GetEntry(string entryId);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an entry with metadata to the vault index
|
||||
/// </summary>
|
||||
void PutEntry(string entryId, MetaData metaData);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an entry (and its metadata) from the vault index.
|
||||
/// Returns true if an entry was removed, false if it was not present.
|
||||
/// </summary>
|
||||
bool RemoveEntry(string entryId);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using DeepDrftContent.Data.FileDatabase.Utils;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for index data used in serialization
|
||||
/// </summary>
|
||||
public abstract class IndexData
|
||||
{
|
||||
public string IndexKey { get; }
|
||||
|
||||
protected IndexData(string indexKey)
|
||||
{
|
||||
IndexKey = indexKey;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializable data for directory indexes
|
||||
/// </summary>
|
||||
public class DirectoryIndexData : IndexData
|
||||
{
|
||||
public List<string> Entries { get; set; } = new();
|
||||
|
||||
public DirectoryIndexData(string indexKey) : base(indexKey) { }
|
||||
|
||||
public static DirectoryIndexData FromIndex(DirectoryIndex index)
|
||||
{
|
||||
var data = new DirectoryIndexData(index.GetKey())
|
||||
{
|
||||
Entries = index.GetEntries().ToList()
|
||||
};
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry data for vault index serialization
|
||||
/// </summary>
|
||||
public class VaultEntryData
|
||||
{
|
||||
public string Key { get; set; } = null!;
|
||||
public MetaData Value { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializable data for vault indexes
|
||||
/// </summary>
|
||||
public class VaultIndexData : IndexData
|
||||
{
|
||||
public List<VaultEntryData> Entries { get; set; } = new();
|
||||
public MediaVaultType VaultType { get; set; }
|
||||
|
||||
public VaultIndexData(string indexKey) : base(indexKey)
|
||||
{
|
||||
VaultType = MediaVaultType.Media; // Default vault type for legacy compatibility
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public VaultIndexData(string indexKey, MediaVaultType vaultType) : base(indexKey)
|
||||
{
|
||||
VaultType = vaultType;
|
||||
}
|
||||
|
||||
public static VaultIndexData FromIndex(VaultIndex index)
|
||||
{
|
||||
var data = new VaultIndexData(index.GetKey(), index.VaultType)
|
||||
{
|
||||
Entries = index.Entries.Select(kvp => new VaultEntryData { Key = kvp.Key, Value = kvp.Value }).ToList()
|
||||
};
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Directory index implementation using StructuralSet for entries
|
||||
/// </summary>
|
||||
public class DirectoryIndex : IndexData, IDirectoryIndex
|
||||
{
|
||||
public StructuralSet<string> Entries { get; }
|
||||
|
||||
public DirectoryIndex(DirectoryIndexData indexData) : base(indexData.IndexKey)
|
||||
{
|
||||
Entries = new StructuralSet<string>();
|
||||
// Load entries from data
|
||||
foreach (var entry in indexData.Entries)
|
||||
{
|
||||
Entries.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public string GetKey() => IndexKey;
|
||||
|
||||
public IReadOnlyList<string> GetEntries() => Entries.ToList().AsReadOnly();
|
||||
|
||||
public int GetEntriesSize() => Entries.Size;
|
||||
|
||||
public bool HasEntry(string entryId) => Entries.Has(entryId);
|
||||
|
||||
public void PutEntry(string entryId) => Entries.Add(entryId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vault index implementation using StructuralMap for entries with metadata
|
||||
/// </summary>
|
||||
public class VaultIndex : IndexData, IVaultIndex
|
||||
{
|
||||
public StructuralMap<string, MetaData> Entries { get; }
|
||||
public MediaVaultType VaultType { get; }
|
||||
|
||||
public VaultIndex(VaultIndexData indexData) : base(indexData.IndexKey)
|
||||
{
|
||||
Entries = new StructuralMap<string, MetaData>();
|
||||
VaultType = indexData.VaultType;
|
||||
// Load entries from data
|
||||
foreach (var entry in indexData.Entries)
|
||||
{
|
||||
Entries.Set(entry.Key, entry.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public string GetKey() => IndexKey;
|
||||
|
||||
public IReadOnlyList<string> GetEntries() => Entries.Keys.ToList().AsReadOnly();
|
||||
|
||||
public int GetEntriesSize() => Entries.Size;
|
||||
|
||||
public bool HasEntry(string entryId) => Entries.Has(entryId);
|
||||
|
||||
public MetaData? GetEntry(string entryId) => Entries.Get(entryId);
|
||||
|
||||
public void PutEntry(string entryId, MetaData metaData) => Entries.Set(entryId, metaData);
|
||||
|
||||
public bool RemoveEntry(string entryId) => Entries.Delete(entryId);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using DeepDrftContent.Data.FileDatabase.Abstractions;
|
||||
using DeepDrftContent.Data.FileDatabase.Services;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Shared media type registry instance — one allocation for all factory classes in this file.
|
||||
/// </summary>
|
||||
file static class SharedMediaTypeRegistry
|
||||
{
|
||||
internal static readonly IMediaTypeRegistry Instance = new SimpleMediaTypeRegistry();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type mappings for media vault types - simple dictionary-based approach
|
||||
/// </summary>
|
||||
public static class MediaVaultTypeMap
|
||||
{
|
||||
private static readonly IMediaTypeRegistry _registry = SharedMediaTypeRegistry.Instance;
|
||||
|
||||
public static Type GetBinaryType(MediaVaultType vaultType) => _registry.GetBinaryType(vaultType);
|
||||
|
||||
public static Type GetDtoType(MediaVaultType vaultType) => _registry.GetDtoType(vaultType);
|
||||
|
||||
public static Type GetParamsType(MediaVaultType vaultType) => _registry.GetParamsType(vaultType);
|
||||
|
||||
public static Type GetMetaDataType(MediaVaultType vaultType) => _registry.GetMetaDataType(vaultType);
|
||||
|
||||
/// <summary>
|
||||
/// Get the vault type for a binary type (reverse mapping)
|
||||
/// </summary>
|
||||
public static MediaVaultType GetVaultType(Type binaryType) => _registry.GetVaultType(binaryType);
|
||||
|
||||
/// <summary>
|
||||
/// Get the vault type for a binary type using generics (reverse mapping)
|
||||
/// </summary>
|
||||
public static MediaVaultType GetVaultType<T>() where T : FileBinary => _registry.GetVaultType<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating metadata objects based on vault type
|
||||
/// </summary>
|
||||
public static class MetaDataFactory
|
||||
{
|
||||
public static MetaData Create(MediaVaultType type, string entryKey, string extension)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
MediaVaultType.Media => new MetaData(entryKey, extension),
|
||||
MediaVaultType.Image => throw new ArgumentException("Image metadata requires aspect ratio. Use CreateImageMetaData instead."),
|
||||
MediaVaultType.Audio => throw new ArgumentException("Audio metadata requires duration and bitrate. Use CreateAudioMetaData instead."),
|
||||
_ => throw new ArgumentException($"Unknown vault type: {type}")
|
||||
};
|
||||
}
|
||||
|
||||
public static ImageMetaData CreateImageMetaData(string entryKey, string extension, double aspectRatio)
|
||||
{
|
||||
return new ImageMetaData(entryKey, extension, aspectRatio);
|
||||
}
|
||||
|
||||
public static AudioMetaData CreateAudioMetaData(string entryKey, string extension, double duration, int bitrate)
|
||||
{
|
||||
return new AudioMetaData(entryKey, extension, duration, bitrate);
|
||||
}
|
||||
|
||||
private static readonly IMediaTypeRegistry _metaDataRegistry = SharedMediaTypeRegistry.Instance;
|
||||
|
||||
public static MetaData CreateFromMedia(MediaVaultType type, string entryKey, string extension, object media)
|
||||
{
|
||||
return _metaDataRegistry.CreateMetaDataFromMedia(type, entryKey, extension, media);
|
||||
}
|
||||
|
||||
public static T Create<T>(MediaVaultType type, string entryKey, string extension)
|
||||
where T : MetaData
|
||||
{
|
||||
var metaData = Create(type, entryKey, extension);
|
||||
return (T)metaData;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating media parameter objects - simple dictionary-based approach
|
||||
/// </summary>
|
||||
public static class MediaParamsFactory
|
||||
{
|
||||
private static readonly IMediaTypeRegistry _registry = SharedMediaTypeRegistry.Instance;
|
||||
|
||||
public static object Create(MediaVaultType type, FileBinary fileBinary, MetaData metaData)
|
||||
{
|
||||
return _registry.CreateParams(type, fileBinary, metaData);
|
||||
}
|
||||
|
||||
public static T Create<T>(MediaVaultType type, FileBinary fileBinary, MetaData metaData)
|
||||
{
|
||||
var parameters = Create(type, fileBinary, metaData);
|
||||
return (T)parameters;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating media binary objects - simple dictionary-based approach
|
||||
/// </summary>
|
||||
public static class FileBinaryFactory
|
||||
{
|
||||
private static readonly IMediaTypeRegistry _registry = SharedMediaTypeRegistry.Instance;
|
||||
|
||||
public static object Create(MediaVaultType vaultType, object parameters)
|
||||
{
|
||||
return _registry.CreateBinary(vaultType, parameters);
|
||||
}
|
||||
|
||||
public static T Create<T>(MediaVaultType vaultType, object parameters) where T : FileBinary
|
||||
{
|
||||
var binary = Create(vaultType, parameters);
|
||||
return (T)binary;
|
||||
}
|
||||
|
||||
public static object From(MediaVaultType type, object mediaBinaryDto)
|
||||
{
|
||||
return _registry.CreateBinaryFromDto(type, mediaBinaryDto);
|
||||
}
|
||||
|
||||
public static T From<T>(MediaVaultType type, object mediaBinaryDto) where T : FileBinary
|
||||
{
|
||||
var binary = From(type, mediaBinaryDto);
|
||||
return (T)binary;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating DTO objects from media binaries - simple dictionary-based approach
|
||||
/// </summary>
|
||||
public static class FileBinaryDtoFactory
|
||||
{
|
||||
private static readonly IMediaTypeRegistry _registry = SharedMediaTypeRegistry.Instance;
|
||||
|
||||
public static object From(MediaVaultType type, object mediaBinary)
|
||||
{
|
||||
if (mediaBinary is not FileBinary fileBinary)
|
||||
throw new ArgumentException($"Expected FileBinary but got {mediaBinary.GetType()}");
|
||||
|
||||
return _registry.CreateDto(type, fileBinary);
|
||||
}
|
||||
|
||||
public static T From<T>(MediaVaultType type, object mediaBinary)
|
||||
{
|
||||
var dto = From(type, mediaBinary);
|
||||
return (T)dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
namespace DeepDrftContent.Data.FileDatabase.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for creating a FileBinary
|
||||
/// </summary>
|
||||
/// <param name="Buffer">The binary data</param>
|
||||
/// <param name="Size">The size of the data in bytes</param>
|
||||
public record FileBinaryParams(byte[] Buffer, int Size);
|
||||
|
||||
/// <summary>
|
||||
/// Base class for file binary data
|
||||
/// </summary>
|
||||
public class FileBinary
|
||||
{
|
||||
public byte[] Buffer { get; }
|
||||
public int Size { get; }
|
||||
|
||||
public FileBinary(FileBinaryParams parameters)
|
||||
{
|
||||
Buffer = parameters.Buffer;
|
||||
Size = parameters.Size;
|
||||
}
|
||||
|
||||
public static FileBinary From(FileBinaryDto dto)
|
||||
{
|
||||
var buffer = Convert.FromBase64String(dto.Base64);
|
||||
return new FileBinary(new FileBinaryParams(buffer, dto.Size));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for FileBinary serialization
|
||||
/// </summary>
|
||||
/// <param name="Base64">Base64 encoded binary data</param>
|
||||
/// <param name="Size">Size of the original data</param>
|
||||
public record FileBinaryDto(string Base64, int Size)
|
||||
{
|
||||
public FileBinaryDto(FileBinary fileBinary) : this(
|
||||
Convert.ToBase64String(fileBinary.Buffer),
|
||||
fileBinary.Size) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for creating a MediaBinary
|
||||
/// </summary>
|
||||
/// <param name="Buffer">The binary data</param>
|
||||
/// <param name="Size">The size of the data in bytes</param>
|
||||
/// <param name="Extension">The file extension</param>
|
||||
public record MediaBinaryParams(byte[] Buffer, int Size, string Extension)
|
||||
: FileBinaryParams(Buffer, Size);
|
||||
|
||||
/// <summary>
|
||||
/// Media binary with extension information
|
||||
/// </summary>
|
||||
public class MediaBinary : FileBinary
|
||||
{
|
||||
public string Extension { get; }
|
||||
|
||||
public MediaBinary(MediaBinaryParams parameters) : base(parameters)
|
||||
{
|
||||
Extension = parameters.Extension;
|
||||
}
|
||||
|
||||
public static MediaBinary From(MediaBinaryDto dto)
|
||||
{
|
||||
var buffer = Convert.FromBase64String(dto.Base64);
|
||||
var extension = GetExtensionType(dto.Mime);
|
||||
return new MediaBinary(new MediaBinaryParams(buffer, dto.Size, extension));
|
||||
}
|
||||
|
||||
protected static string GetExtensionType(string mime)
|
||||
{
|
||||
return MimeTypeExtensions.GetExtension(mime);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for MediaBinary serialization
|
||||
/// </summary>
|
||||
/// <param name="Base64">Base64 encoded binary data</param>
|
||||
/// <param name="Size">Size of the original data</param>
|
||||
/// <param name="Mime">MIME type of the media</param>
|
||||
public record MediaBinaryDto(string Base64, int Size, string Mime) : FileBinaryDto(Base64, Size)
|
||||
{
|
||||
public MediaBinaryDto(MediaBinary mediaBinary) : this(
|
||||
Convert.ToBase64String(mediaBinary.Buffer),
|
||||
mediaBinary.Size,
|
||||
MimeTypeExtensions.GetMimeType(mediaBinary.Extension)) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for creating an ImageBinary
|
||||
/// </summary>
|
||||
/// <param name="Buffer">The binary data</param>
|
||||
/// <param name="Size">The size of the data in bytes</param>
|
||||
/// <param name="Extension">The file extension</param>
|
||||
/// <param name="AspectRatio">The aspect ratio of the image</param>
|
||||
public record ImageBinaryParams(byte[] Buffer, int Size, string Extension, double AspectRatio)
|
||||
: MediaBinaryParams(Buffer, Size, Extension);
|
||||
|
||||
/// <summary>
|
||||
/// Image binary with aspect ratio information
|
||||
/// </summary>
|
||||
public class ImageBinary : MediaBinary
|
||||
{
|
||||
public double AspectRatio { get; }
|
||||
|
||||
public ImageBinary(ImageBinaryParams parameters) : base(parameters)
|
||||
{
|
||||
AspectRatio = parameters.AspectRatio;
|
||||
}
|
||||
|
||||
public static ImageBinary From(ImageBinaryDto dto)
|
||||
{
|
||||
var buffer = Convert.FromBase64String(dto.Base64);
|
||||
var extension = GetExtensionType(dto.Mime);
|
||||
return new ImageBinary(new ImageBinaryParams(buffer, dto.Size, extension, dto.AspectRatio));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for ImageBinary serialization
|
||||
/// </summary>
|
||||
/// <param name="Base64">Base64 encoded binary data</param>
|
||||
/// <param name="Size">Size of the original data</param>
|
||||
/// <param name="Mime">MIME type of the media</param>
|
||||
/// <param name="AspectRatio">The aspect ratio of the image</param>
|
||||
public record ImageBinaryDto(string Base64, int Size, string Mime, double AspectRatio)
|
||||
: MediaBinaryDto(Base64, Size, Mime)
|
||||
{
|
||||
public ImageBinaryDto(ImageBinary imageBinary) : this(
|
||||
Convert.ToBase64String(imageBinary.Buffer),
|
||||
imageBinary.Size,
|
||||
MimeTypeExtensions.GetMimeType(imageBinary.Extension),
|
||||
imageBinary.AspectRatio) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for creating an AudioBinary
|
||||
/// </summary>
|
||||
/// <param name="Buffer">The binary data</param>
|
||||
/// <param name="Size">The size of the data in bytes</param>
|
||||
/// <param name="Extension">The file extension</param>
|
||||
/// <param name="Duration">The duration of the audio in seconds</param>
|
||||
/// <param name="Bitrate">The bitrate of the audio in kbps</param>
|
||||
public record AudioBinaryParams(byte[] Buffer, int Size, string Extension, double Duration, int Bitrate)
|
||||
: MediaBinaryParams(Buffer, Size, Extension);
|
||||
|
||||
/// <summary>
|
||||
/// Audio binary with duration and bitrate information
|
||||
/// </summary>
|
||||
public class AudioBinary : MediaBinary
|
||||
{
|
||||
public double Duration { get; }
|
||||
public int Bitrate { get; }
|
||||
|
||||
public AudioBinary(AudioBinaryParams parameters) : base(parameters)
|
||||
{
|
||||
Duration = parameters.Duration;
|
||||
Bitrate = parameters.Bitrate;
|
||||
}
|
||||
|
||||
public static AudioBinary From(AudioBinaryDto dto)
|
||||
{
|
||||
var buffer = Convert.FromBase64String(dto.Base64);
|
||||
var extension = GetExtensionType(dto.Mime);
|
||||
return new AudioBinary(new AudioBinaryParams(buffer, dto.Size, extension, dto.Duration, dto.Bitrate));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for AudioBinary serialization
|
||||
/// </summary>
|
||||
/// <param name="Base64">Base64 encoded binary data</param>
|
||||
/// <param name="Size">Size of the original data</param>
|
||||
/// <param name="Mime">MIME type of the media</param>
|
||||
/// <param name="Duration">The duration of the audio in seconds</param>
|
||||
/// <param name="Bitrate">The bitrate of the audio in kbps</param>
|
||||
public record AudioBinaryDto(string Base64, int Size, string Mime, double Duration, int Bitrate)
|
||||
: MediaBinaryDto(Base64, Size, Mime)
|
||||
{
|
||||
public AudioBinaryDto(AudioBinary audioBinary) : this(
|
||||
Convert.ToBase64String(audioBinary.Buffer),
|
||||
audioBinary.Size,
|
||||
MimeTypeExtensions.GetMimeType(audioBinary.Extension),
|
||||
audioBinary.Duration,
|
||||
audioBinary.Bitrate) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for MIME type and extension conversions
|
||||
/// </summary>
|
||||
public static class MimeTypeExtensions
|
||||
{
|
||||
private static readonly Dictionary<string, string> MimeTypes = new()
|
||||
{
|
||||
{ ".jpg", "image/jpeg" },
|
||||
{ ".jpeg", "image/jpeg" },
|
||||
{ ".png", "image/png" },
|
||||
{ ".gif", "image/gif" },
|
||||
{ ".webp", "image/webp" },
|
||||
{ ".svg", "image/svg+xml" },
|
||||
{ ".bmp", "image/bmp" },
|
||||
{ ".mp3", "audio/mpeg" },
|
||||
{ ".wav", "audio/wav" },
|
||||
{ ".flac", "audio/flac" },
|
||||
{ ".aac", "audio/aac" },
|
||||
{ ".ogg", "audio/ogg" },
|
||||
{ ".m4a", "audio/mp4" }
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> Extensions = new()
|
||||
{
|
||||
{ "image/jpeg", ".jpg" },
|
||||
{ "image/png", ".png" },
|
||||
{ "image/gif", ".gif" },
|
||||
{ "image/webp", ".webp" },
|
||||
{ "image/svg+xml", ".svg" },
|
||||
{ "image/bmp", ".bmp" },
|
||||
{ "audio/mpeg", ".mp3" },
|
||||
{ "audio/wav", ".wav" },
|
||||
{ "audio/flac", ".flac" },
|
||||
{ "audio/aac", ".aac" },
|
||||
{ "audio/ogg", ".ogg" },
|
||||
{ "audio/mp4", ".m4a" }
|
||||
};
|
||||
|
||||
public static string GetMimeType(string extension)
|
||||
{
|
||||
return MimeTypes.TryGetValue(extension.ToLowerInvariant(), out var mime)
|
||||
? mime
|
||||
: "application/octet-stream";
|
||||
}
|
||||
|
||||
public static string GetExtension(string mime)
|
||||
{
|
||||
return Extensions.TryGetValue(mime.ToLowerInvariant(), out var extension)
|
||||
? extension
|
||||
: ".bin";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DeepDrftContent.Data.FileDatabase.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Enum representing different types of media vaults
|
||||
/// </summary>
|
||||
public enum MediaVaultType
|
||||
{
|
||||
Media,
|
||||
Image,
|
||||
Audio
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Base metadata for media entries
|
||||
/// </summary>
|
||||
/// <param name="MediaKey">The key used to identify the media file</param>
|
||||
/// <param name="Extension">The file extension of the media</param>
|
||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
|
||||
[JsonDerivedType(typeof(MetaData), typeDiscriminator: "media")]
|
||||
[JsonDerivedType(typeof(ImageMetaData), typeDiscriminator: "image")]
|
||||
[JsonDerivedType(typeof(AudioMetaData), typeDiscriminator: "audio")]
|
||||
public record MetaData(string MediaKey, string Extension);
|
||||
|
||||
/// <summary>
|
||||
/// Extended metadata for image entries, including aspect ratio
|
||||
/// </summary>
|
||||
/// <param name="MediaKey">The key used to identify the media file</param>
|
||||
/// <param name="Extension">The file extension of the media</param>
|
||||
/// <param name="AspectRatio">The aspect ratio of the image</param>
|
||||
public record ImageMetaData(string MediaKey, string Extension, double AspectRatio)
|
||||
: MetaData(MediaKey, Extension);
|
||||
|
||||
/// <summary>
|
||||
/// Extended metadata for audio entries, including duration and bitrate
|
||||
/// </summary>
|
||||
/// <param name="MediaKey">The key used to identify the media file</param>
|
||||
/// <param name="Extension">The file extension of the media</param>
|
||||
/// <param name="Duration">The duration of the audio in seconds</param>
|
||||
/// <param name="Bitrate">The bitrate of the audio in kbps</param>
|
||||
public record AudioMetaData(string MediaKey, string Extension, double Duration, int Bitrate)
|
||||
: MetaData(MediaKey, Extension);
|
||||
@@ -0,0 +1,131 @@
|
||||
# FileDatabase C# Port
|
||||
|
||||
This is a C# port of the TypeScript file database system, maintaining architectural integrity while leveraging C# language features.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The C# port preserves the original three-layer architecture:
|
||||
|
||||
### 1. **FileDatabase** (Main Orchestrator)
|
||||
- **Location**: `Services/FileDatabase.cs`
|
||||
- **Purpose**: Root-level manager coordinating multiple media vaults
|
||||
- **Key Features**:
|
||||
- Manages collection of `MediaVault` instances using `StructuralMap<string, MediaVault>`
|
||||
- Provides async factory method `FromAsync()` for initialization
|
||||
- Handles vault creation, resource loading, and resource registration
|
||||
|
||||
### 2. **MediaVault System** (Storage Containers)
|
||||
- **Location**: `Services/MediaVault.cs`
|
||||
- **Components**:
|
||||
- `MediaVault` (Abstract base class)
|
||||
- `ImageVault` (Concrete implementation for images)
|
||||
- `AudioVault` (Concrete implementation for audio)
|
||||
- **Key Features**:
|
||||
- File path normalization and media key generation
|
||||
- Generic type-safe operations using `MediaVaultType` enum
|
||||
- Metadata association with stored files
|
||||
- Async factory pattern for initialization
|
||||
|
||||
### 3. **Index Management** (Metadata & Organization)
|
||||
- **Location**: `Services/IndexSystem.cs`
|
||||
- **Two-tier indexing**:
|
||||
- `DirectoryIndex`: Manages vault entries (what vaults exist)
|
||||
- `VaultIndex`: Manages media entries within vaults (what files exist + metadata)
|
||||
- **Features**:
|
||||
- `IndexFactory` handles index creation/loading
|
||||
- Automatic JSON serialization to filesystem
|
||||
- Strong typing with generic constraints
|
||||
|
||||
## Key Components
|
||||
|
||||
### Models (`Models/` directory)
|
||||
- **String-based keys**: Simple string identifiers for vault and entry management
|
||||
- **`MetaData` hierarchy**: Base metadata → `ImageMetaData` with aspect ratio
|
||||
- **Media Types**: `FileBinary` → `MediaBinary` → `ImageBinary`
|
||||
- **Factory Classes**: Type-safe creation of media objects and metadata
|
||||
|
||||
### Utilities (`Utils/` directory)
|
||||
- **`StructuralMap<TKey, TValue>`**: JSON-based structural equality for complex keys
|
||||
- **`StructuralSet<T>`**: Set with structural equality semantics
|
||||
- **`FileUtils`**: Async file I/O operations with chunked reading/writing
|
||||
|
||||
## C# Design Improvements
|
||||
|
||||
### SOLID Principles Applied
|
||||
1. **Single Responsibility**: Each class handles one concern
|
||||
2. **Open/Closed**: Extensible media types via generic constraints
|
||||
3. **Liskov Substitution**: Proper inheritance hierarchies
|
||||
4. **Interface Segregation**: Focused interfaces (`IIndex`)
|
||||
5. **Dependency Inversion**: Abstract base classes and interfaces
|
||||
|
||||
### C# Language Features
|
||||
- **Records**: Immutable data structures for `MetaData` hierarchy
|
||||
- **Pattern Matching**: Switch expressions for type-safe factory methods
|
||||
- **Nullable Reference Types**: Explicit nullability handling
|
||||
- **Async/Await**: Full async support with `Task<T>` and `ValueTask<T>`
|
||||
- **Generic Constraints**: Strong typing with `where` clauses
|
||||
|
||||
### DRY Implementation
|
||||
- **Factory Pattern**: Centralized object creation logic
|
||||
- **Generic Type Maps**: Reusable type mappings for different media types
|
||||
- **Template Method Pattern**: Common functionality in base classes
|
||||
|
||||
## Usage Example
|
||||
|
||||
```csharp
|
||||
// Initialize the database
|
||||
var database = await FileDatabase.FromAsync("/path/to/database");
|
||||
|
||||
// Create a vault
|
||||
var vaultId = "images";
|
||||
await database.CreateVaultAsync(vaultId, MediaVaultType.Image);
|
||||
|
||||
// Store an image (MediaVaultType inferred from ImageBinary)
|
||||
var imageData = new ImageBinary(new ImageBinaryParams(buffer, size, ".jpg", 1.5));
|
||||
await database.RegisterResourceAsync("gallery", "photo1", imageData);
|
||||
|
||||
// Load an image (MediaVaultType inferred from ImageBinary generic type)
|
||||
var loadedImage = await database.LoadResourceAsync<ImageBinary>("gallery", "photo1");
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
FileDatabase/
|
||||
├── Models/
|
||||
│ ├── [EntryKey removed] # Now using simple string keys
|
||||
│ ├── MetaData.cs # Metadata hierarchy
|
||||
│ ├── MediaModels.cs # Binary data classes
|
||||
│ ├── MediaFactories.cs # Factory pattern implementations
|
||||
│ ├── MediaVaultType.cs # Enum for vault types
|
||||
│ ├── IIndex.cs # Index interface
|
||||
│ └── IndexData.cs # Index implementations
|
||||
├── Services/
|
||||
│ ├── FileDatabase.cs # Main orchestrator
|
||||
│ ├── MediaVault.cs # Vault system
|
||||
│ └── IndexSystem.cs # Index management
|
||||
├── Utils/
|
||||
│ ├── StructuralMap.cs # Structural equality map
|
||||
│ ├── StructuralSet.cs # Structural equality set
|
||||
│ └── FileUtils.cs # File I/O utilities
|
||||
└── [FileDatabase is now part of DeepDrftContent.Services.csproj]
|
||||
```
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
1. **Async-First Design**: All I/O operations are asynchronous
|
||||
2. **Strong Type Safety**: Extensive use of generics and constraints
|
||||
3. **Structural Equality**: JSON-based equality for composite keys
|
||||
4. **Separation of Concerns**: Clear boundaries between indexing, storage, and media handling
|
||||
5. **Factory-Based Initialization**: Handles complex async setup patterns
|
||||
6. **Metadata-Driven**: Rich metadata system supporting extensible media types
|
||||
|
||||
## Differences from TypeScript Version
|
||||
|
||||
1. **Explicit Type Safety**: C# compiler enforces type constraints at compile time
|
||||
2. **Memory Management**: Automatic garbage collection vs manual buffer management
|
||||
3. **Serialization**: System.Text.Json instead of V8 serialization
|
||||
4. **Error Handling**: Exceptions vs try/catch patterns (maintained original behavior)
|
||||
5. **Nullability**: Explicit nullable reference types for better null safety
|
||||
|
||||
This port maintains the architectural integrity of the original TypeScript design while leveraging C#'s type system and language features for improved maintainability and performance.
|
||||
@@ -0,0 +1,232 @@
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
using DeepDrftContent.Data.FileDatabase.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Main file database class that orchestrates multiple media vaults.
|
||||
/// Includes file watching for automatic index reloading when modified by external processes.
|
||||
/// </summary>
|
||||
public class FileDatabase : DirectoryIndexDirectory, IDisposable
|
||||
{
|
||||
private readonly StructuralMap<string, MediaVault> _vaults;
|
||||
private readonly IndexWatcher _indexWatcher;
|
||||
private readonly IndexFactoryService _indexFactory;
|
||||
private readonly ILogger<FileDatabase> _logger;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Factory method to create a FileDatabase instance
|
||||
/// </summary>
|
||||
public static async Task<FileDatabase?> FromAsync(string rootPath, ILogger<FileDatabase>? logger = null)
|
||||
{
|
||||
var factoryService = new IndexFactoryService();
|
||||
var rootIndex = await factoryService.LoadOrCreateDirectoryIndexAsync(rootPath);
|
||||
|
||||
if (rootIndex != null)
|
||||
{
|
||||
var db = new FileDatabase(rootPath, rootIndex, factoryService, logger);
|
||||
await db.InitVaultsAsync();
|
||||
return db;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private FileDatabase(string rootPath, IDirectoryIndex index, IndexFactoryService indexFactory, ILogger<FileDatabase>? logger = null) : base(rootPath, index)
|
||||
{
|
||||
_vaults = new StructuralMap<string, MediaVault>();
|
||||
_indexWatcher = new IndexWatcher();
|
||||
_indexFactory = indexFactory;
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FileDatabase>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes all vaults found in the index
|
||||
/// </summary>
|
||||
private async Task InitVaultsAsync()
|
||||
{
|
||||
foreach (var vaultId in GetIndexEntries())
|
||||
{
|
||||
var vaultType = await GetVaultTypeFromIndex(vaultId);
|
||||
if (vaultType.HasValue)
|
||||
{
|
||||
await InitVaultAsync(vaultId, vaultType.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a specific vault and sets up file watching for its index
|
||||
/// </summary>
|
||||
private async Task InitVaultAsync(string vaultId, MediaVaultType vaultType)
|
||||
{
|
||||
var path = Path.Combine(RootPath, vaultId);
|
||||
var directoryVault = await MediaVaultFactory.From(path, vaultType, _indexFactory);
|
||||
|
||||
if (directoryVault != null)
|
||||
{
|
||||
_vaults.Set(vaultId, directoryVault);
|
||||
|
||||
// Watch the vault's index file for external modifications
|
||||
_indexWatcher.Watch(path, () =>
|
||||
{
|
||||
// Reload the index asynchronously when file changes
|
||||
_ = directoryVault.ReloadIndexAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets vault type from the vault's index file
|
||||
/// </summary>
|
||||
private async Task<MediaVaultType?> GetVaultTypeFromIndex(string vaultId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var vaultPath = Path.Combine(RootPath, vaultId);
|
||||
var index = await _indexFactory.LoadIndexAsync(IndexType.Vault, vaultPath);
|
||||
|
||||
if (index is VaultIndex vaultIndex)
|
||||
{
|
||||
return vaultIndex.VaultType;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If we can't load the index, we can't determine the vault type
|
||||
// This might happen for legacy vaults or corrupted indexes
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a vault exists for the given vault ID
|
||||
/// </summary>
|
||||
public bool HasVault(string vaultId)
|
||||
{
|
||||
return _vaults.Has(vaultId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a vault by vault ID
|
||||
/// </summary>
|
||||
public MediaVault? GetVault(string vaultId)
|
||||
{
|
||||
return HasVault(vaultId) ? _vaults.Get(vaultId) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new vault. Propagates exceptions to the caller — vault creation failure is not
|
||||
/// silently swallowable because a partially-created vault would leave the index inconsistent.
|
||||
/// </summary>
|
||||
public async Task CreateVaultAsync(string vaultId, MediaVaultType vaultType)
|
||||
{
|
||||
var path = Path.Combine(RootPath, vaultId);
|
||||
var directoryVault = await MediaVaultFactory.From(path, vaultType, _indexFactory);
|
||||
|
||||
if (directoryVault != null)
|
||||
{
|
||||
_vaults.Set(vaultId, directoryVault);
|
||||
await AddToIndexAsync(vaultId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a resource from a specific vault (MediaVaultType inferred from T)
|
||||
/// </summary>
|
||||
public async Task<T?> LoadResourceAsync<T>(string vaultId, string entryId)
|
||||
where T : FileBinary
|
||||
{
|
||||
try
|
||||
{
|
||||
var vault = _vaults.Get(vaultId);
|
||||
if (vault != null)
|
||||
{
|
||||
return await vault.GetEntryAsync<T>(entryId);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow exceptions and return null, matching TypeScript behavior
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a resource in a specific vault (MediaVaultType inferred from media type)
|
||||
/// </summary>
|
||||
public async Task<bool> RegisterResourceAsync(string vaultId, string entryId, FileBinary media)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directoryVault = _vaults.Get(vaultId);
|
||||
if (directoryVault != null)
|
||||
{
|
||||
await directoryVault.AddEntryAsync(entryId, media);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow exceptions and return false, matching TypeScript behavior
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a resource from a specific vault. Returns null if the vault does not exist,
|
||||
/// false if the entry was not found, true if the entry was removed. Distinguishing
|
||||
/// "no such vault" / "no such entry" / "removed" lets the HTTP host map cleanly to
|
||||
/// 404 vs. 200. Follows the FileDatabase error-swallow contract: any unexpected failure
|
||||
/// returns null so callers can surface 5xx without try/catch at the controller layer.
|
||||
/// </summary>
|
||||
public async Task<bool?> RemoveResourceAsync(string vaultId, string entryId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directoryVault = _vaults.Get(vaultId);
|
||||
if (directoryVault == null)
|
||||
return null;
|
||||
|
||||
return await directoryVault.RemoveEntryAsync(entryId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "RemoveResourceAsync failed for vault {VaultName} key {Key}", vaultId, entryId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all vault IDs managed by this database
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetVaultIds()
|
||||
{
|
||||
return _vaults.Keys.ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of vaults
|
||||
/// </summary>
|
||||
public int GetVaultCount()
|
||||
{
|
||||
return _vaults.Size;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the file database and stops all file watchers
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_indexWatcher.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using DeepDrftContent.Data.FileDatabase.Abstractions;
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
using DeepDrftContent.Data.FileDatabase.Utils;
|
||||
using IndexType = DeepDrftContent.Data.FileDatabase.Services.IndexType;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Factory service for creating and managing indexes
|
||||
/// </summary>
|
||||
public class IndexFactoryService : IIndexFactory, IIndexDataFactory
|
||||
{
|
||||
private readonly Dictionary<IndexType, Func<object, IIndex>> _indexFromDataCreators;
|
||||
private readonly Dictionary<IndexType, Func<IIndex, object>> _indexDataCreators;
|
||||
|
||||
public IndexFactoryService()
|
||||
{
|
||||
_indexFromDataCreators = new Dictionary<IndexType, Func<object, IIndex>>
|
||||
{
|
||||
{ IndexType.Directory, data => new DirectoryIndex((DirectoryIndexData)data) },
|
||||
{ IndexType.Vault, data => new VaultIndex((VaultIndexData)data) }
|
||||
};
|
||||
|
||||
_indexDataCreators = new Dictionary<IndexType, Func<IIndex, object>>
|
||||
{
|
||||
{ IndexType.Directory, index => DirectoryIndexData.FromIndex((DirectoryIndex)index) },
|
||||
{ IndexType.Vault, index => VaultIndexData.FromIndex((VaultIndex)index) }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IDirectoryIndex?> CreateDirectoryIndexAsync(string rootPath)
|
||||
{
|
||||
var indexData = new DirectoryIndexData(Path.GetFileName(rootPath));
|
||||
var index = new DirectoryIndex(indexData);
|
||||
|
||||
// Ensure directory exists and save the index
|
||||
await FileUtils.MakeVaultDirectoryAsync(rootPath);
|
||||
await SaveIndexAsync(rootPath, IndexType.Directory, index);
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
public async Task<IIndex?> LoadIndexAsync(IndexType type, string rootPath)
|
||||
{
|
||||
if (!_indexFromDataCreators.TryGetValue(type, out var creator))
|
||||
{
|
||||
throw new ArgumentException($"Unknown index type: {type}");
|
||||
}
|
||||
|
||||
var indexPath = Path.Combine(rootPath, "index");
|
||||
|
||||
object indexData = type switch
|
||||
{
|
||||
IndexType.Directory => await FileUtils.FetchObjectAsync<DirectoryIndexData>(indexPath),
|
||||
IndexType.Vault => await FileUtils.FetchObjectAsync<VaultIndexData>(indexPath),
|
||||
_ => throw new ArgumentException($"Unknown index type: {type}")
|
||||
};
|
||||
|
||||
return creator(indexData);
|
||||
}
|
||||
|
||||
public async Task<IDirectoryIndex?> LoadOrCreateDirectoryIndexAsync(string rootPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var index = await LoadIndexAsync(IndexType.Directory, rootPath);
|
||||
return index as IDirectoryIndex;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return await CreateDirectoryIndexAsync(rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IVaultIndex?> CreateVaultIndexAsync(string rootPath, MediaVaultType vaultType)
|
||||
{
|
||||
var vaultIndexData = new VaultIndexData(Path.GetFileName(rootPath), vaultType);
|
||||
var index = new VaultIndex(vaultIndexData);
|
||||
|
||||
// Ensure directory exists and save the index
|
||||
await FileUtils.MakeVaultDirectoryAsync(rootPath);
|
||||
await SaveIndexAsync(rootPath, IndexType.Vault, index);
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
public async Task<IVaultIndex?> LoadOrCreateVaultIndexAsync(string rootPath, MediaVaultType vaultType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var index = await LoadIndexAsync(IndexType.Vault, rootPath);
|
||||
return index as IVaultIndex;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return await CreateVaultIndexAsync(rootPath, vaultType);
|
||||
}
|
||||
}
|
||||
|
||||
public object CreateIndexData(IndexType type, IIndex index)
|
||||
{
|
||||
if (!_indexDataCreators.TryGetValue(type, out var creator))
|
||||
{
|
||||
throw new ArgumentException($"Unknown index type: {type}");
|
||||
}
|
||||
|
||||
return creator(index);
|
||||
}
|
||||
|
||||
public IIndex CreateIndexFromData(IndexType type, object indexData)
|
||||
{
|
||||
if (!_indexFromDataCreators.TryGetValue(type, out var creator))
|
||||
{
|
||||
throw new ArgumentException($"Unknown index type: {type}");
|
||||
}
|
||||
|
||||
return creator(indexData);
|
||||
}
|
||||
|
||||
private async Task SaveIndexAsync(string rootPath, IndexType type, IIndex index)
|
||||
{
|
||||
var indexPath = Path.Combine(rootPath, "index");
|
||||
var indexData = CreateIndexData(type, index);
|
||||
await FileUtils.PutObjectAsync(indexPath, indexData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using DeepDrftContent.Data.FileDatabase.Abstractions;
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
using DeepDrftContent.Data.FileDatabase.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Enum representing different types of indexes
|
||||
/// </summary>
|
||||
public enum IndexType
|
||||
{
|
||||
Directory,
|
||||
Vault
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for index containers
|
||||
/// </summary>
|
||||
public abstract class AbstractIndexContainer
|
||||
{
|
||||
protected IndexType Type { get; }
|
||||
public string RootPath { get; }
|
||||
private readonly IIndexDataFactory _indexDataFactory;
|
||||
|
||||
protected AbstractIndexContainer(string path, IndexType type, IIndexDataFactory? indexDataFactory = null)
|
||||
{
|
||||
RootPath = path;
|
||||
Type = type;
|
||||
_indexDataFactory = indexDataFactory ?? new IndexFactoryService();
|
||||
}
|
||||
|
||||
public string GetKey() => Path.GetFileName(RootPath);
|
||||
|
||||
protected async Task SaveIndexAsync<T>(T index) where T : IIndex
|
||||
{
|
||||
var indexPath = Path.Combine(RootPath, "index");
|
||||
var indexData = _indexDataFactory.CreateIndexData(Type, index);
|
||||
await FileUtils.PutObjectAsync(indexPath, indexData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for directory containers that manage indexes
|
||||
/// </summary>
|
||||
public abstract class IndexDirectory : AbstractIndexContainer
|
||||
{
|
||||
protected IEntryQueryable Index { get; set; }
|
||||
|
||||
protected IndexDirectory(string rootPath, IndexType type, IEntryQueryable index, IIndexDataFactory? indexDataFactory = null)
|
||||
: base(rootPath, type, indexDataFactory)
|
||||
{
|
||||
Index = index;
|
||||
}
|
||||
|
||||
protected IReadOnlyList<string> GetIndexEntries() => Index.GetEntries();
|
||||
|
||||
public int GetIndexSize() => Index.GetEntriesSize();
|
||||
|
||||
public virtual Task<bool> HasIndexEntry(string entryId) => Task.FromResult(Index.HasEntry(entryId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Directory index directory implementation
|
||||
/// </summary>
|
||||
public class DirectoryIndexDirectory : IndexDirectory
|
||||
{
|
||||
private readonly IDirectoryIndex _directoryIndex;
|
||||
private readonly SemaphoreSlim _indexLock = new(1, 1);
|
||||
|
||||
public DirectoryIndexDirectory(string rootPath, IDirectoryIndex index, IIndexDataFactory? indexDataFactory = null)
|
||||
: base(rootPath, IndexType.Directory, index, indexDataFactory)
|
||||
{
|
||||
_directoryIndex = index;
|
||||
}
|
||||
|
||||
protected async Task AddToIndexAsync(string entryId)
|
||||
{
|
||||
await _indexLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
_directoryIndex.PutEntry(entryId);
|
||||
await SaveIndexAsync(_directoryIndex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_indexLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vault index directory implementation with support for index reloading
|
||||
/// </summary>
|
||||
public class VaultIndexDirectory : IndexDirectory
|
||||
{
|
||||
private IVaultIndex _vaultIndex;
|
||||
private readonly SemaphoreSlim _indexLock = new(1, 1);
|
||||
private readonly IndexFactoryService _factoryService;
|
||||
private readonly ILogger<VaultIndexDirectory>? _logger;
|
||||
|
||||
public VaultIndexDirectory(string rootPath, IVaultIndex index, IIndexDataFactory? indexDataFactory = null, ILogger<VaultIndexDirectory>? logger = null, IndexFactoryService? factoryService = null)
|
||||
: base(rootPath, IndexType.Vault, index, indexDataFactory ?? factoryService)
|
||||
{
|
||||
_vaultIndex = index;
|
||||
_logger = logger;
|
||||
_factoryService = factoryService ?? new IndexFactoryService();
|
||||
}
|
||||
|
||||
protected async Task AddToIndexAsync(string entryId, MetaData metaData)
|
||||
{
|
||||
await _indexLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
_vaultIndex.PutEntry(entryId, metaData);
|
||||
await SaveIndexAsync(_vaultIndex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_indexLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an entry from the index under the index lock, persisting on success.
|
||||
/// Returns the removed entry's metadata, or null if the entry did not exist.
|
||||
/// Caller is responsible for any backing-file cleanup using the returned metadata.
|
||||
/// </summary>
|
||||
protected async Task<MetaData?> RemoveFromIndexAsync(string entryId)
|
||||
{
|
||||
await _indexLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var metaData = _vaultIndex.GetEntry(entryId);
|
||||
if (metaData == null)
|
||||
return null;
|
||||
|
||||
if (!_vaultIndex.RemoveEntry(entryId))
|
||||
return null;
|
||||
|
||||
await SaveIndexAsync(_vaultIndex);
|
||||
return metaData;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_indexLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the index from disk. Called when the index file is modified externally.
|
||||
/// </summary>
|
||||
public async Task ReloadIndexAsync()
|
||||
{
|
||||
await _indexLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var newIndex = await _factoryService.LoadIndexAsync(IndexType.Vault, RootPath);
|
||||
if (newIndex is IVaultIndex vaultIndex)
|
||||
{
|
||||
_vaultIndex = vaultIndex;
|
||||
Index = vaultIndex;
|
||||
if (_logger != null)
|
||||
_logger.LogDebug("VaultIndexDirectory: Reloaded index for {RootPath}, {EntryCount} entries", RootPath, vaultIndex.GetEntriesSize());
|
||||
else
|
||||
Console.WriteLine($"VaultIndexDirectory: Reloaded index for {RootPath}, {vaultIndex.GetEntriesSize()} entries");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger != null)
|
||||
_logger.LogWarning(ex, "VaultIndexDirectory: Failed to reload index for {RootPath}", RootPath);
|
||||
else
|
||||
Console.WriteLine($"VaultIndexDirectory: Failed to reload index for {RootPath}: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_indexLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe check for index entry
|
||||
/// </summary>
|
||||
public override async Task<bool> HasIndexEntry(string entryId)
|
||||
{
|
||||
await _indexLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
return _vaultIndex.HasEntry(entryId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_indexLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe get entry metadata
|
||||
/// </summary>
|
||||
public async Task<MetaData?> GetEntryMetadata(string entryId)
|
||||
{
|
||||
await _indexLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
return _vaultIndex.GetEntry(entryId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_indexLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Watches index files for external modifications and triggers reloads.
|
||||
/// Uses FileSystemWatcher to detect changes made by other processes (e.g., CLI).
|
||||
/// </summary>
|
||||
public class IndexWatcher : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, FileSystemWatcher> _watchers = new();
|
||||
private readonly Dictionary<string, Action> _reloadCallbacks = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly ILogger<IndexWatcher>? _logger;
|
||||
private bool _disposed;
|
||||
|
||||
public IndexWatcher(ILogger<IndexWatcher>? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an index file to be watched for changes.
|
||||
/// </summary>
|
||||
/// <param name="indexPath">Full path to the directory containing the index file</param>
|
||||
/// <param name="onChanged">Callback to invoke when the index file changes</param>
|
||||
public void Watch(string indexPath, Action onChanged)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
// Already watching this path
|
||||
if (_watchers.ContainsKey(indexPath))
|
||||
{
|
||||
_reloadCallbacks[indexPath] = onChanged;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var watcher = new FileSystemWatcher(indexPath)
|
||||
{
|
||||
Filter = "index",
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
|
||||
watcher.Changed += OnIndexChanged;
|
||||
watcher.Created += OnIndexChanged;
|
||||
|
||||
_watchers[indexPath] = watcher;
|
||||
_reloadCallbacks[indexPath] = onChanged;
|
||||
|
||||
if (_logger != null)
|
||||
_logger.LogDebug("IndexWatcher: Watching {IndexPath}/index", indexPath);
|
||||
else
|
||||
Console.WriteLine($"IndexWatcher: Watching {indexPath}/index");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger != null)
|
||||
_logger.LogWarning(ex, "IndexWatcher: Failed to watch {IndexPath}", indexPath);
|
||||
else
|
||||
Console.WriteLine($"IndexWatcher: Failed to watch {indexPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops watching an index file.
|
||||
/// </summary>
|
||||
public void Unwatch(string indexPath)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_watchers.TryGetValue(indexPath, out var watcher))
|
||||
{
|
||||
watcher.EnableRaisingEvents = false;
|
||||
watcher.Dispose();
|
||||
_watchers.Remove(indexPath);
|
||||
_reloadCallbacks.Remove(indexPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnIndexChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
var watcher = sender as FileSystemWatcher;
|
||||
if (watcher == null) return;
|
||||
|
||||
var indexPath = watcher.Path;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_reloadCallbacks.TryGetValue(indexPath, out var callback))
|
||||
{
|
||||
if (_logger != null)
|
||||
_logger.LogDebug("IndexWatcher: Index changed at {IndexPath}, triggering reload", indexPath);
|
||||
else
|
||||
Console.WriteLine($"IndexWatcher: Index changed at {indexPath}, triggering reload");
|
||||
|
||||
// Invoke callback on a background thread to avoid blocking the watcher
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
callback();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger != null)
|
||||
_logger.LogWarning(ex, "IndexWatcher: Reload callback failed for {IndexPath}", indexPath);
|
||||
else
|
||||
Console.WriteLine($"IndexWatcher: Reload callback failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
foreach (var watcher in _watchers.Values)
|
||||
{
|
||||
watcher.EnableRaisingEvents = false;
|
||||
watcher.Dispose();
|
||||
}
|
||||
_watchers.Clear();
|
||||
_reloadCallbacks.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
using DeepDrftContent.Data.FileDatabase.Utils;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for media vaults that store and manage media files
|
||||
/// </summary>
|
||||
public abstract class MediaVault : VaultIndexDirectory
|
||||
{
|
||||
protected MediaVault(string rootPath, VaultIndex index, IndexFactoryService? factoryService = null)
|
||||
: base(rootPath, index, factoryService: factoryService) { }
|
||||
|
||||
/// <summary>
|
||||
/// Generates a media key from an entry key by sanitizing special characters
|
||||
/// </summary>
|
||||
protected string GetMediaKey(string entryKey, string extension)
|
||||
{
|
||||
var sanitized = Regex.Replace(entryKey, @"[^a-zA-Z0-9]", "-");
|
||||
return $"{sanitized}{extension}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full file path for a media file from an entry key
|
||||
/// </summary>
|
||||
protected string GetMediaPathFromEntryKey(string entryKey, string extension)
|
||||
{
|
||||
return Path.Combine(RootPath, GetMediaKey(entryKey, extension));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full file path for a media file from a media key
|
||||
/// </summary>
|
||||
protected string GetMediaPathFromMediaKey(string mediaKey)
|
||||
{
|
||||
return Path.Combine(RootPath, mediaKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new entry to the vault with the specified media data (MediaVaultType inferred from media type)
|
||||
/// </summary>
|
||||
public async Task AddEntryAsync(string entryId, FileBinary media)
|
||||
{
|
||||
// Extract properties from media object based on type
|
||||
var (buffer, extension) = ExtractMediaProperties(media);
|
||||
|
||||
// Infer MediaVaultType from the media object type
|
||||
var vaultType = MediaVaultTypeMap.GetVaultType(media.GetType());
|
||||
|
||||
var mediaPath = GetMediaPathFromEntryKey(entryId, extension);
|
||||
var metaData = MetaDataFactory.CreateFromMedia(vaultType, entryId, extension, media);
|
||||
|
||||
// Use string-based index operations
|
||||
await AddToIndexAsync(entryId, metaData);
|
||||
await FileUtils.PutFileAsync(mediaPath, buffer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an entry from the vault (MediaVaultType inferred from T)
|
||||
/// </summary>
|
||||
public async Task<T?> GetEntryAsync<T>(string entryId) where T : FileBinary
|
||||
{
|
||||
// Infer MediaVaultType from the generic type T
|
||||
var vaultType = MediaVaultTypeMap.GetVaultType<T>();
|
||||
|
||||
// Use thread-safe method from VaultIndexDirectory
|
||||
if (!await HasIndexEntry(entryId))
|
||||
return null;
|
||||
|
||||
// Use thread-safe metadata retrieval
|
||||
var metaData = await GetEntryMetadata(entryId);
|
||||
if (metaData == null)
|
||||
return null;
|
||||
|
||||
var mediaPath = GetMediaPathFromEntryKey(metaData.MediaKey, metaData.Extension);
|
||||
|
||||
if (!FileUtils.FileExists(mediaPath))
|
||||
return null;
|
||||
|
||||
var fileBinary = await FileUtils.FetchFileAsync(mediaPath);
|
||||
var parameters = MediaParamsFactory.Create(vaultType, fileBinary, metaData);
|
||||
|
||||
var result = FileBinaryFactory.Create(vaultType, parameters);
|
||||
return (T)result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a read-only stream over an entry's backing file plus its metadata
|
||||
/// (extension/MIME), without buffering the file into memory.
|
||||
/// Returns null if the entry is unknown or the backing file is missing.
|
||||
///
|
||||
/// Use this when the caller will forward bytes to a network response — the
|
||||
/// existing <see cref="GetEntryAsync{T}"/> allocates a full <c>byte[]</c>
|
||||
/// and pushes large WAVs onto the LOH for every request.
|
||||
///
|
||||
/// The caller owns the returned stream and must dispose it. Error-handling
|
||||
/// follows the same swallow-and-return-null contract as the rest of the
|
||||
/// FileDatabase API; the caller checks for null.
|
||||
/// </summary>
|
||||
public async Task<MediaStream?> GetEntryStreamAsync(string entryId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await HasIndexEntry(entryId))
|
||||
return null;
|
||||
|
||||
var metaData = await GetEntryMetadata(entryId);
|
||||
if (metaData == null)
|
||||
return null;
|
||||
|
||||
var mediaPath = GetMediaPathFromEntryKey(metaData.MediaKey, metaData.Extension);
|
||||
if (!FileUtils.FileExists(mediaPath))
|
||||
return null;
|
||||
|
||||
// Async-capable, sequential-scan FileStream — the response writer will pull
|
||||
// bytes in order. bufferSize matches FileUtils.FetchFileAsync (64 KB).
|
||||
var stream = new FileStream(
|
||||
mediaPath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 64 * 1024,
|
||||
useAsync: true);
|
||||
|
||||
return new MediaStream(stream, metaData.Extension);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Match FileDatabase error-swallow contract.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an entry from the vault: drops it from the index (persisting the change)
|
||||
/// and deletes the backing file from disk. Returns true if an entry was removed,
|
||||
/// false if the entry was not present. Follows the FileDatabase error-swallow contract
|
||||
/// for read failures; index/file write failures propagate so the caller can map them
|
||||
/// to a 5xx.
|
||||
/// </summary>
|
||||
public async Task<bool> RemoveEntryAsync(string entryId)
|
||||
{
|
||||
var metaData = await RemoveFromIndexAsync(entryId);
|
||||
if (metaData == null)
|
||||
return false;
|
||||
|
||||
// Index already persisted; if the file is missing or fails to delete, the entry
|
||||
// is still gone from the catalogue. Treat a missing file as success (callers asked
|
||||
// for the entry to go away, and it has). A failure deleting an existing file leaves
|
||||
// an orphan on disk; surface it to the caller via exception so the host can log,
|
||||
// matching the AddEntryAsync error-propagation shape.
|
||||
var mediaPath = GetMediaPathFromEntryKey(metaData.MediaKey, metaData.Extension);
|
||||
if (FileUtils.FileExists(mediaPath))
|
||||
{
|
||||
File.Delete(mediaPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts buffer and extension from a media binary
|
||||
/// </summary>
|
||||
private static (byte[] buffer, string extension) ExtractMediaProperties(FileBinary media)
|
||||
{
|
||||
return media switch
|
||||
{
|
||||
ImageBinary imageBinary => (imageBinary.Buffer, imageBinary.Extension),
|
||||
AudioBinary audioBinary => (audioBinary.Buffer, audioBinary.Extension),
|
||||
MediaBinary mediaBinary => (mediaBinary.Buffer, mediaBinary.Extension),
|
||||
FileBinary fileBinary => throw new ArgumentException($"FileBinary must be a specific media type (ImageBinary, AudioBinary, or MediaBinary), not base FileBinary"),
|
||||
_ => throw new ArgumentException($"Unsupported media type: {media.GetType()}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concrete implementation of MediaVault for image storage
|
||||
/// </summary>
|
||||
public class ImageVault : MediaVault
|
||||
{
|
||||
private ImageVault(string rootPath, VaultIndex index, IndexFactoryService? factoryService = null)
|
||||
: base(rootPath, index, factoryService) { }
|
||||
|
||||
/// <summary>
|
||||
/// Factory method to create an ImageVault instance
|
||||
/// </summary>
|
||||
public static async Task<ImageVault?> FromAsync(string rootPath, IndexFactoryService? factoryService = null)
|
||||
{
|
||||
var factory = factoryService ?? new IndexFactoryService();
|
||||
var index = await factory.LoadOrCreateVaultIndexAsync(rootPath, MediaVaultType.Image);
|
||||
|
||||
if (index != null)
|
||||
{
|
||||
return new ImageVault(rootPath, (VaultIndex)index, factory);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioVault : MediaVault
|
||||
{
|
||||
private AudioVault(string rootPath, VaultIndex index, IndexFactoryService? factoryService = null)
|
||||
: base(rootPath, index, factoryService) { }
|
||||
|
||||
public static async Task<AudioVault?> FromAsync(string rootPath, IndexFactoryService? factoryService = null)
|
||||
{
|
||||
var factory = factoryService ?? new IndexFactoryService();
|
||||
var index = await factory.LoadOrCreateVaultIndexAsync(rootPath, MediaVaultType.Audio);
|
||||
|
||||
if (index != null)
|
||||
{
|
||||
return new AudioVault(rootPath, (VaultIndex)index, factory);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An open read-only stream over a vault entry plus the extension needed to
|
||||
/// resolve its MIME type. Caller owns the stream and must dispose it.
|
||||
/// </summary>
|
||||
public sealed class MediaStream : IDisposable, IAsyncDisposable
|
||||
{
|
||||
public Stream Stream { get; }
|
||||
public string Extension { get; }
|
||||
|
||||
public MediaStream(Stream stream, string extension)
|
||||
{
|
||||
Stream = stream;
|
||||
Extension = extension;
|
||||
}
|
||||
|
||||
public void Dispose() => Stream.Dispose();
|
||||
public ValueTask DisposeAsync() => Stream.DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating media vaults
|
||||
/// </summary>
|
||||
public static class MediaVaultFactory
|
||||
{
|
||||
public static async Task<MediaVault?> From(string rootPath, MediaVaultType mediaType, IndexFactoryService? factoryService = null)
|
||||
{
|
||||
return mediaType switch
|
||||
{
|
||||
MediaVaultType.Image => await ImageVault.FromAsync(rootPath, factoryService),
|
||||
MediaVaultType.Audio => await AudioVault.FromAsync(rootPath, factoryService),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using DeepDrftContent.Data.FileDatabase.Abstractions;
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Simple dictionary-based registry for media type factories
|
||||
/// </summary>
|
||||
public class SimpleMediaTypeRegistry : IMediaTypeRegistry
|
||||
{
|
||||
private readonly Dictionary<MediaVaultType, Func<object, FileBinary>> _binaryFactories = new();
|
||||
private readonly Dictionary<MediaVaultType, Func<object, FileBinary>> _binaryFromDtoFactories = new();
|
||||
private readonly Dictionary<MediaVaultType, Func<FileBinary, FileBinaryDto>> _dtoFactories = new();
|
||||
private readonly Dictionary<MediaVaultType, Func<string, string, object, MetaData>> _metaDataFromMediaFactories = new();
|
||||
private readonly Dictionary<MediaVaultType, Func<FileBinary, MetaData, object>> _paramsFactories = new();
|
||||
private readonly Dictionary<MediaVaultType, Func<string, Task<MediaVault?>>> _vaultFactories = new();
|
||||
private readonly Dictionary<MediaVaultType, Type> _binaryTypes = new();
|
||||
private readonly Dictionary<MediaVaultType, Type> _dtoTypes = new();
|
||||
private readonly Dictionary<MediaVaultType, Type> _paramsTypes = new();
|
||||
private readonly Dictionary<MediaVaultType, Type> _metaDataTypes = new();
|
||||
|
||||
// Reverse mapping: Type -> MediaVaultType
|
||||
private readonly Dictionary<Type, MediaVaultType> _typeToVaultType = new();
|
||||
|
||||
public SimpleMediaTypeRegistry()
|
||||
{
|
||||
// Clean one-line registrations with generics - no reflection!
|
||||
RegisterType<MediaBinary, MediaBinaryParams, MediaBinaryDto, MetaData>(
|
||||
MediaVaultType.Media,
|
||||
p => new MediaBinary(p),
|
||||
dto => MediaBinary.From(dto),
|
||||
binary => new MediaBinaryDto(binary),
|
||||
(key, ext, _) => new MetaData(key, ext),
|
||||
(binary, meta) => new MediaBinaryParams(binary.Buffer, binary.Size, meta.Extension));
|
||||
|
||||
RegisterType<ImageBinary, ImageBinaryParams, ImageBinaryDto, ImageMetaData>(
|
||||
MediaVaultType.Image,
|
||||
p => new ImageBinary(p),
|
||||
dto => ImageBinary.From(dto),
|
||||
binary => new ImageBinaryDto(binary),
|
||||
(key, ext, media) => media is ImageBinary img ? new ImageMetaData(key, ext, img.AspectRatio) : new MetaData(key, ext),
|
||||
(binary, meta) => meta is ImageMetaData imgMeta
|
||||
? new ImageBinaryParams(binary.Buffer, binary.Size, meta.Extension, imgMeta.AspectRatio)
|
||||
: throw new ArgumentException("ImageBinary requires ImageMetaData"),
|
||||
async path => await ImageVault.FromAsync(path));
|
||||
|
||||
RegisterType<AudioBinary, AudioBinaryParams, AudioBinaryDto, AudioMetaData>(
|
||||
MediaVaultType.Audio,
|
||||
p => new AudioBinary(p),
|
||||
dto => AudioBinary.From(dto),
|
||||
binary => new AudioBinaryDto(binary),
|
||||
(key, ext, media) => media is AudioBinary audio ? new AudioMetaData(key, ext, audio.Duration, audio.Bitrate) : new MetaData(key, ext),
|
||||
(binary, meta) => meta is AudioMetaData audioMeta
|
||||
? new AudioBinaryParams(binary.Buffer, binary.Size, meta.Extension, audioMeta.Duration, audioMeta.Bitrate)
|
||||
: throw new ArgumentException("AudioBinary requires AudioMetaData"),
|
||||
async path => await AudioVault.FromAsync(path));
|
||||
}
|
||||
|
||||
private void RegisterType<TBinary, TParams, TDto, TMetaData>(
|
||||
MediaVaultType vaultType,
|
||||
Func<TParams, TBinary> binaryFactory,
|
||||
Func<TDto, TBinary> binaryFromDtoFactory,
|
||||
Func<TBinary, TDto> dtoFactory,
|
||||
Func<string, string, object, MetaData> metaDataFactory,
|
||||
Func<FileBinary, MetaData, object> paramsFactory,
|
||||
Func<string, Task<MediaVault?>>? vaultFactory = null)
|
||||
where TBinary : FileBinary
|
||||
where TParams : FileBinaryParams
|
||||
where TDto : FileBinaryDto
|
||||
where TMetaData : MetaData
|
||||
{
|
||||
_binaryFactories[vaultType] = p => binaryFactory((TParams)p);
|
||||
_binaryFromDtoFactories[vaultType] = dto => binaryFromDtoFactory((TDto)dto);
|
||||
_dtoFactories[vaultType] = binary => dtoFactory((TBinary)binary);
|
||||
_metaDataFromMediaFactories[vaultType] = metaDataFactory;
|
||||
_paramsFactories[vaultType] = paramsFactory;
|
||||
_binaryTypes[vaultType] = typeof(TBinary);
|
||||
_dtoTypes[vaultType] = typeof(TDto);
|
||||
_paramsTypes[vaultType] = typeof(TParams);
|
||||
_metaDataTypes[vaultType] = typeof(TMetaData);
|
||||
|
||||
// Populate reverse mapping
|
||||
_typeToVaultType[typeof(TBinary)] = vaultType;
|
||||
|
||||
if (vaultFactory != null)
|
||||
_vaultFactories[vaultType] = vaultFactory;
|
||||
}
|
||||
|
||||
// Public interface implementation - allows external registration
|
||||
public void RegisterMediaType<TBinary, TParams, TDto, TMetaData, TVault>(MediaVaultType vaultType)
|
||||
where TBinary : FileBinary
|
||||
where TParams : FileBinaryParams
|
||||
where TDto : FileBinaryDto
|
||||
where TMetaData : MetaData
|
||||
{
|
||||
// For now, we can't auto-generate the factories without reflection
|
||||
// This would need to be implemented if external registration is needed
|
||||
throw new NotImplementedException("Use RegisterType method for internal registration. External registration not yet implemented.");
|
||||
}
|
||||
|
||||
|
||||
public FileBinary CreateBinary(MediaVaultType vaultType, object parameters)
|
||||
{
|
||||
return _binaryFactories.TryGetValue(vaultType, out var factory)
|
||||
? factory(parameters)
|
||||
: throw new ArgumentException($"Unknown vault type: {vaultType}");
|
||||
}
|
||||
|
||||
public FileBinary CreateBinaryFromDto(MediaVaultType vaultType, object dto)
|
||||
{
|
||||
return _binaryFromDtoFactories.TryGetValue(vaultType, out var factory)
|
||||
? factory(dto)
|
||||
: throw new ArgumentException($"Unknown vault type: {vaultType}");
|
||||
}
|
||||
|
||||
public FileBinaryDto CreateDto(MediaVaultType vaultType, FileBinary binary)
|
||||
{
|
||||
return _dtoFactories.TryGetValue(vaultType, out var factory)
|
||||
? factory(binary)
|
||||
: throw new ArgumentException($"Unknown vault type: {vaultType}");
|
||||
}
|
||||
|
||||
public MetaData CreateMetaDataFromMedia(MediaVaultType vaultType, string entryKey, string extension, object media)
|
||||
{
|
||||
return _metaDataFromMediaFactories.TryGetValue(vaultType, out var factory)
|
||||
? factory(entryKey, extension, media)
|
||||
: throw new ArgumentException($"Unknown vault type: {vaultType}");
|
||||
}
|
||||
|
||||
public object CreateParams(MediaVaultType vaultType, FileBinary fileBinary, MetaData metaData)
|
||||
{
|
||||
return _paramsFactories.TryGetValue(vaultType, out var factory)
|
||||
? factory(fileBinary, metaData)
|
||||
: throw new ArgumentException($"Unknown vault type: {vaultType}");
|
||||
}
|
||||
|
||||
public async Task<MediaVault?> CreateVaultAsync(MediaVaultType vaultType, string rootPath)
|
||||
{
|
||||
return _vaultFactories.TryGetValue(vaultType, out var factory)
|
||||
? await factory(rootPath)
|
||||
: null;
|
||||
}
|
||||
|
||||
public Type GetBinaryType(MediaVaultType vaultType) =>
|
||||
_binaryTypes.TryGetValue(vaultType, out var type) ? type : throw new ArgumentException($"Unknown vault type: {vaultType}");
|
||||
|
||||
public Type GetDtoType(MediaVaultType vaultType) =>
|
||||
_dtoTypes.TryGetValue(vaultType, out var type) ? type : throw new ArgumentException($"Unknown vault type: {vaultType}");
|
||||
|
||||
public Type GetParamsType(MediaVaultType vaultType) =>
|
||||
_paramsTypes.TryGetValue(vaultType, out var type) ? type : throw new ArgumentException($"Unknown vault type: {vaultType}");
|
||||
|
||||
public Type GetMetaDataType(MediaVaultType vaultType) =>
|
||||
_metaDataTypes.TryGetValue(vaultType, out var type) ? type : throw new ArgumentException($"Unknown vault type: {vaultType}");
|
||||
|
||||
public MediaVaultType GetVaultType(Type binaryType)
|
||||
{
|
||||
if (_typeToVaultType.TryGetValue(binaryType, out var vaultType))
|
||||
return vaultType;
|
||||
|
||||
// Check inheritance hierarchy for derived types
|
||||
foreach (var kvp in _typeToVaultType)
|
||||
{
|
||||
if (kvp.Key.IsAssignableFrom(binaryType))
|
||||
return kvp.Value;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Cannot infer MediaVaultType for {binaryType.Name}. Type not registered.");
|
||||
}
|
||||
|
||||
public MediaVaultType GetVaultType<T>() where T : FileBinary
|
||||
=> GetVaultType(typeof(T));
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json;
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for file I/O operations, matching the TypeScript file utilities
|
||||
/// </summary>
|
||||
public static class FileUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads a file and returns it as a FileBinary object
|
||||
/// </summary>
|
||||
/// <param name="mediaPath">Path to the media file</param>
|
||||
/// <returns>FileBinary containing the file data</returns>
|
||||
public static async Task<FileBinary> FetchFileAsync(string mediaPath)
|
||||
{
|
||||
using var fileStream = new FileStream(mediaPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 64 * 1024);
|
||||
|
||||
var buffer = new byte[fileStream.Length];
|
||||
var totalBytesRead = 0;
|
||||
|
||||
while (totalBytesRead < fileStream.Length)
|
||||
{
|
||||
var bytesRead = await fileStream.ReadAsync(buffer.AsMemory(totalBytesRead));
|
||||
if (bytesRead == 0)
|
||||
throw new EndOfStreamException($"Unexpected end of stream while reading {mediaPath}");
|
||||
|
||||
totalBytesRead += bytesRead;
|
||||
}
|
||||
|
||||
return new FileBinary(new FileBinaryParams(buffer, buffer.Length));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes binary data to a file
|
||||
/// </summary>
|
||||
/// <param name="mediaPath">Path where to write the file</param>
|
||||
/// <param name="buffer">Binary data to write</param>
|
||||
public static async Task PutFileAsync(string mediaPath, byte[] buffer)
|
||||
{
|
||||
const int chunkSize = 64 * 1024;
|
||||
|
||||
using var fileStream = new FileStream(mediaPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: chunkSize);
|
||||
|
||||
for (int offset = 0; offset < buffer.Length; offset += chunkSize)
|
||||
{
|
||||
var length = Math.Min(chunkSize, buffer.Length - offset);
|
||||
await fileStream.WriteAsync(buffer.AsMemory(offset, length));
|
||||
}
|
||||
|
||||
await fileStream.FlushAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes an object to a file using JSON
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to the file</param>
|
||||
/// <param name="obj">Object to serialize</param>
|
||||
public static async Task PutObjectAsync<T>(string filePath, T obj)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(obj, options);
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes an object from a JSON file
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to the file</param>
|
||||
/// <returns>Deserialized object</returns>
|
||||
public static async Task<T> FetchObjectAsync<T>(string filePath)
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
var result = JsonSerializer.Deserialize<T>(json, options);
|
||||
return result ?? throw new InvalidOperationException($"Failed to deserialize object from {filePath}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a directory if it doesn't exist
|
||||
/// </summary>
|
||||
/// <param name="directoryPath">Path to the directory</param>
|
||||
public static Task MakeVaultDirectoryAsync(string directoryPath)
|
||||
{
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a file exists
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to check</param>
|
||||
/// <returns>True if file exists</returns>
|
||||
public static bool FileExists(string filePath)
|
||||
{
|
||||
return File.Exists(filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a directory exists
|
||||
/// </summary>
|
||||
/// <param name="directoryPath">Path to check</param>
|
||||
/// <returns>True if directory exists</returns>
|
||||
public static bool DirectoryExists(string directoryPath)
|
||||
{
|
||||
return Directory.Exists(directoryPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Collections;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// A map implementation that uses structural equality for keys by serializing them to JSON.
|
||||
/// This provides the same behavior as the TypeScript StructuralMap.
|
||||
/// Optimized with caching to avoid repeated serialization.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type</typeparam>
|
||||
/// <typeparam name="TValue">The value type</typeparam>
|
||||
public class StructuralMap<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>> where TKey : notnull
|
||||
{
|
||||
private readonly Dictionary<string, KeyValuePair<TKey, TValue>> _innerMap = new();
|
||||
private readonly Dictionary<TKey, string> _keyStringCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Converts a key to its string representation for structural comparison
|
||||
/// Uses caching to avoid expensive serialization on repeated lookups
|
||||
/// </summary>
|
||||
private string GetKeyString(TKey key)
|
||||
{
|
||||
if (key == null) return "null";
|
||||
|
||||
// For reference types, use cache to avoid repeated serialization
|
||||
if (!typeof(TKey).IsValueType && _keyStringCache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var keyString = key switch
|
||||
{
|
||||
string s => s,
|
||||
int or long or float or double or decimal => key.ToString()!,
|
||||
_ => JsonSerializer.Serialize(key)
|
||||
};
|
||||
|
||||
// Cache for reference types only (value types are cheap to convert)
|
||||
if (!typeof(TKey).IsValueType)
|
||||
{
|
||||
_keyStringCache[key] = keyString;
|
||||
}
|
||||
|
||||
return keyString;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a key-value pair in the map
|
||||
/// </summary>
|
||||
public StructuralMap<TKey, TValue> Set(TKey key, TValue value)
|
||||
{
|
||||
var keyString = GetKeyString(key);
|
||||
_innerMap[keyString] = new KeyValuePair<TKey, TValue>(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value by key, or default if not found
|
||||
/// </summary>
|
||||
public TValue? Get(TKey key)
|
||||
{
|
||||
var keyString = GetKeyString(key);
|
||||
return _innerMap.TryGetValue(keyString, out var pair) ? pair.Value : default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the map contains the specified key
|
||||
/// </summary>
|
||||
public bool Has(TKey key)
|
||||
{
|
||||
var keyString = GetKeyString(key);
|
||||
return _innerMap.ContainsKey(keyString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a key-value pair from the map
|
||||
/// </summary>
|
||||
public bool Delete(TKey key)
|
||||
{
|
||||
var keyString = GetKeyString(key);
|
||||
return _innerMap.Remove(keyString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all entries from the map
|
||||
/// </summary>
|
||||
public void Clear() => _innerMap.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of entries in the map
|
||||
/// </summary>
|
||||
public int Size => _innerMap.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates all key-value pairs
|
||||
/// </summary>
|
||||
public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
|
||||
{
|
||||
return _innerMap.Values.GetEnumerator();
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all keys in the map
|
||||
/// </summary>
|
||||
public IEnumerable<TKey> Keys => _innerMap.Values.Select(pair => pair.Key);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all values in the map
|
||||
/// </summary>
|
||||
public IEnumerable<TValue> Values => _innerMap.Values.Select(pair => pair.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a callback for each key-value pair
|
||||
/// </summary>
|
||||
public void ForEach(Action<TValue, TKey, StructuralMap<TKey, TValue>> callback)
|
||||
{
|
||||
foreach (var (key, value) in this)
|
||||
{
|
||||
callback(value, key, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Collections;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DeepDrftContent.Data.FileDatabase.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// A set implementation that uses structural equality for values by serializing them to JSON.
|
||||
/// This provides the same behavior as the TypeScript StructuralSet.
|
||||
/// Optimized with caching to avoid repeated serialization.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The value type</typeparam>
|
||||
public class StructuralSet<T> : IEnumerable<T> where T : notnull
|
||||
{
|
||||
private readonly Dictionary<string, T> _innerMap = new();
|
||||
private readonly Dictionary<T, string> _valueStringCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Converts a value to its string representation for structural comparison
|
||||
/// Uses caching to avoid expensive serialization on repeated lookups
|
||||
/// </summary>
|
||||
private string GetValueString(T value)
|
||||
{
|
||||
if (value == null) return "null";
|
||||
|
||||
// For reference types, use cache to avoid repeated serialization
|
||||
if (!typeof(T).IsValueType && _valueStringCache.TryGetValue(value, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var valueString = value switch
|
||||
{
|
||||
string s => s,
|
||||
int or long or float or double or decimal => value.ToString()!,
|
||||
_ => JsonSerializer.Serialize(value)
|
||||
};
|
||||
|
||||
// Cache for reference types only (value types are cheap to convert)
|
||||
if (!typeof(T).IsValueType)
|
||||
{
|
||||
_valueStringCache[value] = valueString;
|
||||
}
|
||||
|
||||
return valueString;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a value to the set
|
||||
/// </summary>
|
||||
public StructuralSet<T> Add(T value)
|
||||
{
|
||||
var valueString = GetValueString(value);
|
||||
if (!_innerMap.ContainsKey(valueString))
|
||||
{
|
||||
_innerMap[valueString] = value;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the set contains the specified value
|
||||
/// </summary>
|
||||
public bool Has(T value)
|
||||
{
|
||||
var valueString = GetValueString(value);
|
||||
return _innerMap.ContainsKey(valueString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a value from the set
|
||||
/// </summary>
|
||||
public bool Delete(T value)
|
||||
{
|
||||
var valueString = GetValueString(value);
|
||||
return _innerMap.Remove(valueString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all values from the set
|
||||
/// </summary>
|
||||
public void Clear() => _innerMap.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of values in the set
|
||||
/// </summary>
|
||||
public int Size => _innerMap.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates all values in the set
|
||||
/// </summary>
|
||||
public IEnumerator<T> GetEnumerator()
|
||||
{
|
||||
return _innerMap.Values.GetEnumerator();
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all values in the set
|
||||
/// </summary>
|
||||
public IEnumerable<T> Values => _innerMap.Values;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Data.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Service for processing audio files and extracting metadata
|
||||
/// </summary>
|
||||
public class AudioProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes a WAV file and creates an AudioBinary object
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to the WAV file</param>
|
||||
/// <returns>AudioBinary object with metadata</returns>
|
||||
public async Task<AudioBinary?> ProcessWavFileAsync(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"WAV file not found: {filePath}");
|
||||
}
|
||||
|
||||
if (!Path.GetExtension(filePath).Equals(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("File must be a WAV file", nameof(filePath));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var buffer = await File.ReadAllBytesAsync(filePath);
|
||||
var wavInfo = ExtractWavMetadata(buffer);
|
||||
|
||||
var parameters = new AudioBinaryParams(
|
||||
Buffer: buffer,
|
||||
Size: buffer.Length,
|
||||
Extension: ".wav",
|
||||
Duration: wavInfo.Duration,
|
||||
Bitrate: wavInfo.Bitrate
|
||||
);
|
||||
|
||||
return new AudioBinary(parameters);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to process WAV file: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from WAV file buffer with comprehensive validation
|
||||
/// </summary>
|
||||
private WavMetadata ExtractWavMetadata(byte[] buffer)
|
||||
{
|
||||
try
|
||||
{
|
||||
var validationResult = ValidateWavStructure(buffer);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
throw new InvalidDataException($"WAV validation failed: {validationResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
var metadata = ParseWavMetadata(buffer, validationResult);
|
||||
ValidateAudioParameters(metadata);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: WAV parsing failed, using defaults: {ex.Message}");
|
||||
return GetDefaultWavMetadata();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates WAV file structure and returns parsing information
|
||||
/// </summary>
|
||||
private WavValidationResult ValidateWavStructure(byte[] buffer)
|
||||
{
|
||||
if (buffer.Length < 44)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "File too short" };
|
||||
}
|
||||
|
||||
// Validate RIFF signature
|
||||
var riffSignature = System.Text.Encoding.ASCII.GetString(buffer, 0, 4);
|
||||
if (riffSignature != "RIFF")
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid RIFF signature" };
|
||||
}
|
||||
|
||||
// Validate WAVE signature
|
||||
var waveSignature = System.Text.Encoding.ASCII.GetString(buffer, 8, 4);
|
||||
if (waveSignature != "WAVE")
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid WAVE signature" };
|
||||
}
|
||||
|
||||
// Find and validate fmt chunk
|
||||
var fmtChunkPos = FindChunk(buffer, "fmt ");
|
||||
if (fmtChunkPos == -1)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Missing fmt chunk" };
|
||||
}
|
||||
|
||||
var fmtChunkSize = BitConverter.ToUInt32(buffer, fmtChunkPos + 4);
|
||||
if (fmtChunkSize < 16)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "fmt chunk too small" };
|
||||
}
|
||||
|
||||
// Validate audio format (PCM only)
|
||||
var audioFormat = BitConverter.ToUInt16(buffer, fmtChunkPos + 8);
|
||||
if (audioFormat != 1)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Only PCM format supported" };
|
||||
}
|
||||
|
||||
// Find data chunk
|
||||
var dataChunkPos = FindChunk(buffer, "data");
|
||||
if (dataChunkPos == -1)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Missing data chunk" };
|
||||
}
|
||||
|
||||
return new WavValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
FmtChunkPos = fmtChunkPos,
|
||||
DataChunkPos = dataChunkPos
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses WAV metadata from validated buffer
|
||||
/// </summary>
|
||||
private WavMetadata ParseWavMetadata(byte[] buffer, WavValidationResult validation)
|
||||
{
|
||||
var channels = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 10);
|
||||
var sampleRate = BitConverter.ToUInt32(buffer, validation.FmtChunkPos + 12);
|
||||
var byteRate = BitConverter.ToUInt32(buffer, validation.FmtChunkPos + 16);
|
||||
var blockAlign = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 20);
|
||||
var bitsPerSample = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 22);
|
||||
var dataSize = BitConverter.ToUInt32(buffer, validation.DataChunkPos + 4);
|
||||
|
||||
var duration = byteRate > 0 ? (double)dataSize / byteRate : 0.0;
|
||||
var bitrate = (int)((sampleRate * channels * bitsPerSample) / 1000);
|
||||
|
||||
return new WavMetadata
|
||||
{
|
||||
Duration = duration,
|
||||
Bitrate = bitrate,
|
||||
SampleRate = (int)sampleRate,
|
||||
Channels = channels,
|
||||
BitsPerSample = bitsPerSample,
|
||||
BlockAlign = blockAlign,
|
||||
DataSize = (int)dataSize
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates audio parameters for reasonableness
|
||||
/// </summary>
|
||||
private void ValidateAudioParameters(WavMetadata metadata)
|
||||
{
|
||||
var validSampleRates = new[] { 8000, 11025, 16000, 22050, 44100, 48000, 88200, 96000, 176400, 192000 };
|
||||
var validBitDepths = new[] { 8, 16, 24, 32 };
|
||||
|
||||
if (metadata.Channels < 1 || metadata.Channels > 8)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid channel count: {metadata.Channels}");
|
||||
}
|
||||
|
||||
if (!validSampleRates.Contains(metadata.SampleRate))
|
||||
{
|
||||
throw new InvalidDataException($"Unsupported sample rate: {metadata.SampleRate}");
|
||||
}
|
||||
|
||||
if (!validBitDepths.Contains(metadata.BitsPerSample))
|
||||
{
|
||||
throw new InvalidDataException($"Unsupported bit depth: {metadata.BitsPerSample}");
|
||||
}
|
||||
|
||||
var expectedBlockAlign = metadata.Channels * (metadata.BitsPerSample / 8);
|
||||
if (metadata.BlockAlign != expectedBlockAlign)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid block align: expected {expectedBlockAlign}, got {metadata.BlockAlign}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns default WAV metadata for fallback scenarios
|
||||
/// </summary>
|
||||
private WavMetadata GetDefaultWavMetadata()
|
||||
{
|
||||
return new WavMetadata
|
||||
{
|
||||
Duration = 180.0,
|
||||
Bitrate = 1411,
|
||||
SampleRate = 44100,
|
||||
Channels = 2,
|
||||
BitsPerSample = 16,
|
||||
BlockAlign = 4,
|
||||
DataSize = 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a chunk in the WAV file buffer with proper alignment handling
|
||||
/// </summary>
|
||||
private int FindChunk(byte[] buffer, string chunkId)
|
||||
{
|
||||
var chunkBytes = System.Text.Encoding.ASCII.GetBytes(chunkId);
|
||||
int offset = 12; // Start after RIFF header
|
||||
|
||||
while (offset <= buffer.Length - 8)
|
||||
{
|
||||
// Check for chunk signature match
|
||||
bool match = true;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
if (buffer[offset + i] != chunkBytes[i])
|
||||
{
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match)
|
||||
{
|
||||
return offset;
|
||||
}
|
||||
|
||||
// Move to next chunk with proper alignment
|
||||
if (offset + 4 < buffer.Length)
|
||||
{
|
||||
var chunkSize = BitConverter.ToUInt32(buffer, offset + 4);
|
||||
offset += 8 + (int)((chunkSize + 1) & ~1U); // Ensure even alignment
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WAV file metadata with complete audio information
|
||||
/// </summary>
|
||||
private class WavMetadata
|
||||
{
|
||||
public double Duration { get; set; }
|
||||
public int Bitrate { get; set; }
|
||||
public int SampleRate { get; set; }
|
||||
public int Channels { get; set; }
|
||||
public int BitsPerSample { get; set; }
|
||||
public int BlockAlign { get; set; }
|
||||
public int DataSize { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of WAV structure validation
|
||||
/// </summary>
|
||||
private class WavValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public string ErrorMessage { get; set; } = string.Empty;
|
||||
public int FmtChunkPos { get; set; }
|
||||
public int DataChunkPos { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using DeepDrftContent.Data.Constants;
|
||||
using DeepDrftContent.Data.FileDatabase.Services;
|
||||
using DeepDrftContent.Data.Processors;
|
||||
using DeepDrftModels.Entities;
|
||||
|
||||
namespace DeepDrftContent.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing tracks in both SQL and FileDatabase
|
||||
/// </summary>
|
||||
public class TrackService
|
||||
{
|
||||
private readonly FileDatabase.Services.FileDatabase _fileDatabase;
|
||||
private readonly AudioProcessor _audioProcessor;
|
||||
|
||||
public TrackService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessor audioProcessor)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_audioProcessor = audioProcessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new track from a WAV file to both databases
|
||||
/// </summary>
|
||||
/// <param name="wavFilePath">Path to the WAV file</param>
|
||||
/// <param name="trackName">Name of the track</param>
|
||||
/// <param name="artist">Artist name</param>
|
||||
/// <param name="album">Optional album name</param>
|
||||
/// <param name="genre">Optional genre</param>
|
||||
/// <param name="releaseDate">Optional release date</param>
|
||||
/// <returns>The track entity with generated ID and media path</returns>
|
||||
public async Task<TrackEntity?> AddTrackFromWavAsync(
|
||||
string wavFilePath,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album = null,
|
||||
string? genre = null,
|
||||
DateOnly? releaseDate = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Process the WAV file
|
||||
var audioBinary = await _audioProcessor.ProcessWavFileAsync(wavFilePath);
|
||||
if (audioBinary == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to process WAV file");
|
||||
}
|
||||
|
||||
// Generate a unique track ID
|
||||
var trackId = Guid.NewGuid().ToString();
|
||||
|
||||
// Ensure tracks vault exists
|
||||
if (!_fileDatabase.HasVault(VaultConstants.Tracks))
|
||||
{
|
||||
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, DeepDrftContent.Data.FileDatabase.Models.MediaVaultType.Audio);
|
||||
}
|
||||
|
||||
// Store the audio in FileDatabase
|
||||
var success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, trackId, audioBinary);
|
||||
if (!success)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to store audio in FileDatabase");
|
||||
}
|
||||
|
||||
// Create the track entity for SQL database
|
||||
var trackEntity = new TrackEntity
|
||||
{
|
||||
EntryKey = trackId, // FileDatabase entry ID
|
||||
TrackName = trackName,
|
||||
Artist = artist,
|
||||
Album = album,
|
||||
Genre = genre,
|
||||
ReleaseDate = releaseDate
|
||||
};
|
||||
|
||||
return trackEntity;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine($"TrackService.AddTrackFromWavAsync failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves audio binary from FileDatabase
|
||||
/// </summary>
|
||||
/// <param name="trackId">Track ID (EntryKey)</param>
|
||||
/// <returns>Audio binary or null if not found</returns>
|
||||
public async Task<DeepDrftContent.Data.FileDatabase.Models.AudioBinary?> GetAudioBinaryAsync(string trackId)
|
||||
{
|
||||
return await _fileDatabase.LoadResourceAsync<DeepDrftContent.Data.FileDatabase.Models.AudioBinary>(VaultConstants.Tracks, trackId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if FileDatabase is available and tracks vault exists
|
||||
/// </summary>
|
||||
public bool IsFileDatabaseReady()
|
||||
{
|
||||
return _fileDatabase.HasVault(VaultConstants.Tracks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the tracks vault if it doesn't exist
|
||||
/// </summary>
|
||||
public async Task InitializeTracksVaultAsync()
|
||||
{
|
||||
if (!_fileDatabase.HasVault(VaultConstants.Tracks))
|
||||
{
|
||||
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, DeepDrftContent.Data.FileDatabase.Models.MediaVaultType.Audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user