DeepDrftAPI Rename
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DeepDrftContent.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);
|
||||
}
|
||||
}
|
||||
+110
-163
@@ -1,221 +1,168 @@
|
||||
# CLAUDE.md - DeepDrftContent
|
||||
# CLAUDE.md - DeepDrftContent.Services
|
||||
|
||||
Guidance for working in the DeepDrftContent project (the binary content API host).
|
||||
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
|
||||
|
||||
The dual-database authority for tracks: SQL metadata and FileDatabase binary. Seven endpoints expose track CRUD with upload+persist, delete+cleanup, paged listing, and metadata operations. ApiKey middleware, CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent.Services`; SQL services in `DeepDrftData`.**
|
||||
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).
|
||||
|
||||
## What lives here now (only)
|
||||
## Layout
|
||||
|
||||
- `Program.cs`, `Startup.cs`: HTTP host config, DI wiring, middleware setup, port binding.
|
||||
- `Services/UnifiedTrackService.cs`: Host-internal orchestrator. Coordinates vault write + SQL persist for upload (`UploadAsync`), and SQL delete + vault remove for delete (`DeleteAsync`).
|
||||
- `Controllers/TrackController.cs`: Seven endpoints (see below).
|
||||
- `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic.
|
||||
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
|
||||
- `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo).
|
||||
- `environment/apikey.json`: API key (loaded via CredentialTools, not in repo, must be created locally or at deployment).
|
||||
- `environment/connections.json`: SQL connection string (loaded via CredentialTools, not in repo, format: `{ "ConnectionStrings": { "DefaultConnection": "..." } }`).
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## What does NOT live here anymore
|
||||
## FileDatabase model (high-level)
|
||||
|
||||
- `FileDatabase/`, `Processors/`, media models (`AudioBinary`, `ImageBinary`, etc.), `WavOffsetService` — all in `DeepDrftContent.Services`.
|
||||
- EF Core context and repository — in `DeepDrftData`.
|
||||
- **Hosts only own HTTP surface and wiring.** New domain code goes in `*.Services` (shared libraries) or host-internal `Services/` folders (e.g., `UnifiedTrackService` here for dual-database orchestration).
|
||||
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.
|
||||
|
||||
## The endpoint surface (seven endpoints)
|
||||
### Core structure
|
||||
|
||||
### GET api/track/{trackId}?offset=0 (unauthenticated)
|
||||
- **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`.
|
||||
|
||||
Returns the WAV bytes from the `tracks` vault with optional offset support.
|
||||
### Binary hierarchy
|
||||
|
||||
- **Route parameter `trackId`** (string): the entry id inside the `tracks` vault (i.e. `TrackEntity.EntryKey`).
|
||||
- **Query parameter `offset`** (optional, default 0): byte position to start streaming from.
|
||||
- If `offset == 0`: streams the entire file directly from disk without buffering (so 100 MB WAVs do not force 100 MB LOH allocations per request).
|
||||
- If `offset > 0`: `WavOffsetService.CreateOffsetStream` block-aligns the offset and synthesises a fresh 44-byte WAV header so the response is a valid standalone WAV starting from that byte position. This is load-bearing for seek-beyond-buffer — the player asks for a new stream at the offset it wants to seek to, gets back a valid WAV that starts there, and tears down/re-initialises the decoder.
|
||||
- Returns 404 if track not found. Returns 500 if vault operations fail (with error swallowing — the vault returns `null`).
|
||||
```
|
||||
FileBinary (base: byte buffer)
|
||||
└── MediaBinary (+ Extension: string, MIME type inferred via MimeTypeExtensions)
|
||||
├── AudioBinary (+ Duration: double, Bitrate: int)
|
||||
└── ImageBinary (+ AspectRatio: double)
|
||||
```
|
||||
|
||||
### PUT api/track/{trackId} ([ApiKeyAuthorize])
|
||||
Each has a matching `*Dto` variant for base64 JSON transport (e.g., `AudioBinaryDto` with buffer encoded as base64).
|
||||
|
||||
**Authenticated endpoint.** Writes pre-processed audio bytes to the `tracks` vault.
|
||||
### Index lifecycle
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `trackId`** (string): the entry id to store under.
|
||||
- **Body**: `AudioBinaryDto` (base64 buffer + size + mime + duration + bitrate). This endpoint receives an already-processed audio DTO, not a raw WAV file.
|
||||
- Validates MIME type (rejects unsupported types with `.bin` sentinel). Delegates to `FileDatabase.RegisterResourceAsync`.
|
||||
- Rarely used in production (the CLI calls `FileDatabase.RegisterResourceAsync` directly). Exists for potential future web-side intake paths.
|
||||
- Returns 200 on success, 401 if ApiKey invalid, 400 if MIME invalid.
|
||||
- **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.
|
||||
|
||||
### POST api/track/upload ([ApiKeyAuthorize])
|
||||
## Error-handling philosophy (load-bearing)
|
||||
|
||||
**Authenticated endpoint.** Accepts a raw WAV upload + metadata as `multipart/form-data`, processes the WAV, stores it in the vault, and persists metadata to SQL. Returns the fully persisted `TrackEntity` with `Id` populated.
|
||||
Public `Load*` / `Register*` operations **swallow exceptions and return `null` / `false`** to match the TypeScript original.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Form fields**:
|
||||
- `wav` (`IFormFile`, required): the WAV bytes. File name must end in `.wav`.
|
||||
- `trackName` (string, required)
|
||||
- `artist` (string, required)
|
||||
- `album` (string, optional)
|
||||
- `genre` (string, optional)
|
||||
- `releaseDate` (string, optional, format `YYYY-MM-DD`)
|
||||
- `createdByUserId` (long, required): audit trail — who uploaded this track.
|
||||
- The upload stream is copied to a `.wav`-suffixed temp file under `Path.GetTempPath()` (the audio processor requires that extension and reads from disk). The temp file is always deleted in a `finally` block — success or failure.
|
||||
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized WAVs are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackService.AddTrackFromWavAsync` (vault write) → `TrackManager` (SQL persist with `createdByUserId`).
|
||||
- Returns 200 with the **persisted** `TrackEntity` JSON (Id populated) on success. Returns 400 for missing/invalid form fields. Returns 500 if processing fails.
|
||||
```csharp
|
||||
public async Task<T?> LoadResourceAsync<T>(string vaultId, string entryId) where T : FileBinary
|
||||
{
|
||||
try { /* load and deserialize */ }
|
||||
catch { return null; } // Swallow, return null
|
||||
}
|
||||
|
||||
### DELETE api/track/{id:long} ([ApiKeyAuthorize])
|
||||
public async Task<bool> RegisterResourceAsync(string vaultId, string entryId, FileBinary media)
|
||||
{
|
||||
try { /* store and update index */ }
|
||||
catch { return false; } // Swallow, return false
|
||||
}
|
||||
```
|
||||
|
||||
**Authenticated endpoint.** Removes a track: SQL row first, then vault entry. `UnifiedTrackService` owns the ordering.
|
||||
**Callers must check return values.** Do not change this without a deliberate design pass — it's embedded in all FileDatabase tests and client code.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL track ID (not EntryKey).
|
||||
- Calls `UnifiedTrackService.DeleteAsync`, which: looks up SQL row → deletes SQL row → deletes vault entry via EntryKey.
|
||||
- Returns 200 on success, 404 if track not found, 500 if deletion fails.
|
||||
## WAV offset service
|
||||
|
||||
### GET api/track/page ([ApiKeyAuthorize])
|
||||
`WavOffsetService.CreateOffsetStream(buffer, byteOffset)`:
|
||||
|
||||
**Authenticated endpoint.** Paged metadata list from SQL. Used by CMS track browser.
|
||||
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]`.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Query parameters**:
|
||||
- `page` (int, optional, default 1): 1-based page number.
|
||||
- `pageSize` (int, optional, default 20): tracks per page.
|
||||
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`. Defaults to `Id`.
|
||||
- `sortDescending` (bool, optional, default false): sort direction.
|
||||
- Calls `ITrackService.GetPaged` (via DI), which is actually `TrackManager` from `DeepDrftData`.
|
||||
- Returns 200 with `PagedResult<TrackEntity>` JSON (`Items`, `TotalCount`, `PageNumber`, `PageSize`). Returns 500 on query error.
|
||||
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.
|
||||
|
||||
### GET api/track/meta/{id:long} ([ApiKeyAuthorize])
|
||||
**Block alignment is critical.** Do not bypass it. The WAV fmt chunk tells you the block size; use it.
|
||||
|
||||
**Authenticated endpoint.** Single track metadata from SQL by ID.
|
||||
## Audio processor
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL track ID.
|
||||
- Calls `ITrackService.GetById`, which returns the track or null.
|
||||
- Returns 200 with `TrackEntity` JSON on success. Returns 404 if not found. Returns 500 on query error.
|
||||
`AudioProcessor.ProcessWavFileAsync(filePath)`:
|
||||
|
||||
### PUT api/track/meta/{id:long} ([ApiKeyAuthorize])
|
||||
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).
|
||||
|
||||
**Authenticated endpoint.** Updates track metadata in SQL. EntryKey (the vault link) is immutable.
|
||||
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.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL track ID.
|
||||
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`.
|
||||
- Looks up SQL row by ID, updates the provided fields (nulls in the request clear optional fields), and persists via `ITrackService.Update`.
|
||||
- Returns 200 on success. Returns 404 if track not found. Returns 500 on update error.
|
||||
## Content-side TrackService (orchestrator)
|
||||
|
||||
## ApiKey middleware behaviour
|
||||
### AddTrackFromWavAsync(filePath)
|
||||
|
||||
`ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata.
|
||||
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).
|
||||
|
||||
- Reads header `ApiKey`.
|
||||
- Compares against `ApiKeySettings.ApiKey` from `environment/apikey.json`.
|
||||
- Returns 401 with body `"API Key was not provided"` or `"Unauthorized client"` if validation fails.
|
||||
- Endpoints without `[ApiKeyAuthorize]` skip the check entirely (e.g., `GET api/track/{id}` is unauthenticated).
|
||||
**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).
|
||||
|
||||
## CORS configuration
|
||||
### GetAudioBinaryAsync(entryKey)
|
||||
|
||||
`CorsSettings.AllowedOrigins` is **required** — the app throws on startup if missing. Policy is named `ContentApiPolicy`:
|
||||
Reads audio from the `tracks` vault via `FileDatabase.LoadResourceAsync<AudioBinary>("tracks", entryKey)`. Returns `null` if not found or read fails.
|
||||
|
||||
- `AllowCredentials()`
|
||||
- `AllowAnyMethod()`
|
||||
- `AllowAnyHeader()`
|
||||
### InitializeTracksVaultAsync()
|
||||
|
||||
Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via `UseCors()`.
|
||||
Safety call to ensure the `tracks` vault exists (creates if missing). Called on host startup.
|
||||
|
||||
## Forwarded headers
|
||||
## Vault constants
|
||||
|
||||
**Enabled only in `Production` mode** (via `if (app.Environment.IsProduction())`). This differs from `DeepDrftWeb`, which enables them always. Be aware when debugging proxy issues.
|
||||
`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).
|
||||
|
||||
`UseForwardedHeaders()` processes `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host` so the app knows its real client IP and scheme when sitting behind nginx.
|
||||
## Service registration
|
||||
|
||||
## Startup wiring (Startup.ConfigureDomainServices + Program.cs)
|
||||
In `DeepDrftContent/Startup.ConfigureDomainServices()` and `DeepDrftCli/Program.cs`:
|
||||
|
||||
**In `Startup.ConfigureDomainServices`** (FileDatabase + binary services):
|
||||
|
||||
1. Load `environment/filedatabase.json` via `CredentialTools.ResolvePathOrThrow("filedatabase", ...)` and bind `FileDatabaseSettings`.
|
||||
2. Await `FileDatabase.FromAsync(VaultPath)` to load or create the database.
|
||||
3. Register `FileDatabase` as singleton.
|
||||
4. Ensure the `tracks` vault exists (type `MediaVaultType.Audio`, created on first boot if missing).
|
||||
5. Register singletons: `WavOffsetService`, `AudioProcessor`, `TrackService` (the `DeepDrftContent.Data` version for vault operations).
|
||||
|
||||
**In `Program.cs`** (SQL + wiring):
|
||||
|
||||
6. Load `environment/connections.json` via `CredentialTools.ResolvePathOrThrow("connections", ...)`.
|
||||
7. Register `DbContext<DeepDrftContext>` (scoped) with connection string from config.
|
||||
8. Register scoped: `TrackRepository`, `TrackManager`, `ITrackService` (factory resolves to `TrackManager`), `UnifiedTrackService`.
|
||||
9. Configure forwarded headers (production-only) for reverse proxy support.
|
||||
10. Load `environment/apikey.json` and register API key middleware.
|
||||
11. Configure CORS policy (`ContentApiPolicy`): AllowAnyMethod, AllowAnyHeader, AllowCredentials, specific origins from config.
|
||||
|
||||
The singleton `FileDatabase` is thread-safe for reads. Writes are atomic at the vault level (index updates are serialised). The `IndexWatcher` reloads the vault index if an external process (e.g., CLI) writes to it, so a long-running web host stays consistent. SQL services are scoped (DbContext not thread-safe).
|
||||
|
||||
## OpenAPI
|
||||
|
||||
Mapped in `Development` only. Swagger UI at `/swagger` for testing endpoints locally.
|
||||
|
||||
## Configuration files
|
||||
|
||||
- `appsettings.json`: Logging, hosting, and CORS config. **Does not contain secrets.**
|
||||
- `Logging`: standard ASP.NET structure.
|
||||
- `CorsSettings.AllowedOrigins`: array of origin URLs allowed to call the API (required; throws on startup if missing).
|
||||
- `environment/filedatabase.json` (required, loaded via CredentialTools, not in repo):
|
||||
```json
|
||||
{
|
||||
"FileDatabaseSettings": {
|
||||
"VaultPath": "../Database/Vaults"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `environment/apikey.json` (required at runtime, loaded via CredentialTools, not in repo):
|
||||
```json
|
||||
{
|
||||
"ApiKeySettings": {
|
||||
"ApiKey": "your-secret-key"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `environment/connections.json` (required, loaded via CredentialTools, not in repo):
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=deepdrft;Username=postgres;Password=..."
|
||||
}
|
||||
}
|
||||
```
|
||||
```csharp
|
||||
services.AddSingleton<WavOffsetService>();
|
||||
services.AddSingleton<FileDatabase>(/* from FileDatabase.FromAsync */);
|
||||
services.AddScoped<AudioProcessor>();
|
||||
services.AddScoped<TrackService>(); // DeepDrftContent.Services.TrackService
|
||||
```
|
||||
|
||||
## Development commands
|
||||
|
||||
```bash
|
||||
# Run the content API (default https://localhost:5002)
|
||||
dotnet run --project DeepDrftContent
|
||||
|
||||
# Watch during development
|
||||
dotnet watch run --project DeepDrftContent
|
||||
|
||||
# Build
|
||||
dotnet build DeepDrftContent
|
||||
dotnet build DeepDrftContent.Services
|
||||
|
||||
# Test endpoints (requires API key in environment/apikey.json)
|
||||
curl -H "ApiKey: your-secret-key" -X PUT https://localhost:5002/api/track/test-id \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"buffer":"base64-encoded-audio","size":1000,"mime":"audio/wav"}'
|
||||
# Run tests (FileDatabase tests cover vault/index/factory/utilities thoroughly)
|
||||
dotnet test DeepDrftTests/
|
||||
|
||||
curl https://localhost:5002/api/track/test-id?offset=0
|
||||
# Run CLI (which consumes this service)
|
||||
dotnet run --project DeepDrftCli -- add myfile.wav "Track Name" "Artist"
|
||||
```
|
||||
|
||||
## Important patterns
|
||||
|
||||
- **Result types**: Controllers return `ActionResult<T>`. Service calls return `Result` or `ResultContainer<T>` from NetBlocks. The controller checks `Success` and returns 200/4xx/5xx accordingly.
|
||||
- **Error swallowing**: FileDatabase operations return `null` or `false` on failure. The controller surfaces this as 500. Never throw — check return values.
|
||||
- **Async/await**: All operations are async.
|
||||
- **Vault operations**: Always use the injected `FileDatabase` singleton. Never construct a new one — it has the `IndexWatcher` and is the source of truth.
|
||||
- **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.
|
||||
|
||||
## The FileDatabase import
|
||||
## What does NOT live here
|
||||
|
||||
See `DeepDrftContent.Services/CLAUDE.md` for the FileDatabase API and semantics. This host only provides the HTTP surface over it.
|
||||
- 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 HTTP surface (controllers, middleware, CORS, forwarded headers) and the wiring that connects the host to the FileDatabase. New domain logic goes in `DeepDrftContent.Services`.
|
||||
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.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";
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
using DeepDrftContent.Data.Audio;
|
||||
using DeepDrftContent.Data.Constants;
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
using DeepDrftContent.Data.FileDatabase.Services;
|
||||
using DeepDrftContent.Middleware;
|
||||
using DeepDrftContent.Models;
|
||||
using DeepDrftContent.Services;
|
||||
using DeepDrftData;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftContent.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class TrackController : ControllerBase
|
||||
{
|
||||
private readonly DeepDrftContent.Data.TrackService _trackService;
|
||||
private readonly WavOffsetService _wavOffsetService;
|
||||
private readonly UnifiedTrackService _unifiedService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly ILogger<TrackController> _logger;
|
||||
|
||||
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
|
||||
// AudioBinaryDto over the wire, not a WAV file path. TrackService.AddTrackFromWavAsync is
|
||||
// file-path-oriented and not applicable here. If a file-upload flow is added in future,
|
||||
// route it through TrackService instead.
|
||||
private readonly DeepDrftContent.Data.FileDatabase.Services.FileDatabase _fileDatabase;
|
||||
|
||||
public TrackController(
|
||||
DeepDrftContent.Data.TrackService trackService,
|
||||
DeepDrftContent.Data.FileDatabase.Services.FileDatabase fileDatabase,
|
||||
WavOffsetService wavOffsetService,
|
||||
UnifiedTrackService unifiedService,
|
||||
ITrackService sqlTrackService,
|
||||
ILogger<TrackController> logger)
|
||||
{
|
||||
_trackService = trackService;
|
||||
_fileDatabase = fileDatabase;
|
||||
_wavOffsetService = wavOffsetService;
|
||||
_unifiedService = unifiedService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// --- Literal-segment routes first ---
|
||||
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
|
||||
// resolution never treats "page", "upload", or "meta" as a trackId.
|
||||
|
||||
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false
|
||||
// CMS metadata listing — paged read straight from SQL.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpGet("page")]
|
||||
public async Task<ActionResult> GetPage(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? sortColumn = null,
|
||||
[FromQuery] bool sortDescending = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, cancellationToken);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetPage failed: {Error}", error);
|
||||
return StatusCode(500, "Failed to load tracks");
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// POST api/track/upload: raw WAV in (multipart/form-data) + metadata → persisted TrackEntity out.
|
||||
// Used by the CMS upload flow on DeepDrftManager; that host proxies the upload here so it never
|
||||
// touches the vault disk path or SQL directly. UnifiedTrackService owns the two-database write.
|
||||
//
|
||||
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: WAV uploads can be tens to hundreds
|
||||
// of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams
|
||||
// the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling,
|
||||
// not a buffered allocation.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(1_073_741_824)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
|
||||
public async Task<ActionResult<DeepDrftModels.Entities.TrackEntity>> UploadTrack(
|
||||
[FromForm] IFormFile? wav,
|
||||
[FromForm] string? trackName,
|
||||
[FromForm] string? artist,
|
||||
[FromForm] string? album,
|
||||
[FromForm] string? genre,
|
||||
[FromForm] string? releaseDate,
|
||||
[FromForm] long createdByUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, size={Size}",
|
||||
trackName, artist, wav?.Length);
|
||||
|
||||
if (wav is null || wav.Length == 0)
|
||||
{
|
||||
return BadRequest("WAV file is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trackName))
|
||||
{
|
||||
return BadRequest("trackName is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artist))
|
||||
{
|
||||
return BadRequest("artist is required");
|
||||
}
|
||||
|
||||
if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest("Uploaded file must have a .wav extension");
|
||||
}
|
||||
|
||||
DateOnly? parsedReleaseDate = null;
|
||||
if (!string.IsNullOrWhiteSpace(releaseDate))
|
||||
{
|
||||
if (!DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out var parsed))
|
||||
{
|
||||
return BadRequest("releaseDate must be in YYYY-MM-DD format");
|
||||
}
|
||||
parsedReleaseDate = parsed;
|
||||
}
|
||||
|
||||
// AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk.
|
||||
// Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path.
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav");
|
||||
|
||||
try
|
||||
{
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true))
|
||||
await using (var uploadStream = wav.OpenReadStream())
|
||||
{
|
||||
await uploadStream.CopyToAsync(tempStream, cancellationToken);
|
||||
}
|
||||
|
||||
var result = await _unifiedService.UploadAsync(
|
||||
tempPath,
|
||||
trackName,
|
||||
artist,
|
||||
string.IsNullOrWhiteSpace(album) ? null : album,
|
||||
string.IsNullOrWhiteSpace(genre) ? null : genre,
|
||||
parsedReleaseDate,
|
||||
createdByUserId,
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store WAV";
|
||||
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
_logger.LogInformation("UploadTrack succeeded: id={Id}, entryKey={EntryKey}", result.Value.Id, result.Value.EntryKey);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "UploadTrack failed for {TrackName}", trackName);
|
||||
return StatusCode(500, "Internal server error");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(tempPath))
|
||||
{
|
||||
System.IO.File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET api/track/meta/{id}: single track metadata from SQL.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpGet("meta/{id:long}")]
|
||||
public async Task<ActionResult> GetMeta(long id)
|
||||
{
|
||||
var result = await _sqlTrackService.GetById(id);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetMeta failed for {TrackId}: {Error}", id, error);
|
||||
return StatusCode(500, "Failed to load track");
|
||||
}
|
||||
|
||||
if (result.Value is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// PUT api/track/meta/{id}: metadata-only update. EntryKey is immutable and not part of the body.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPut("meta/{id:long}")]
|
||||
public async Task<ActionResult> UpdateMeta(long id, [FromBody] UpdateTrackMetadataRequest request)
|
||||
{
|
||||
var lookup = await _sqlTrackService.GetById(id);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("UpdateMeta lookup failed for {TrackId}: {Error}", id, error);
|
||||
return StatusCode(500, "Failed to load track");
|
||||
}
|
||||
|
||||
if (lookup.Value is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var track = lookup.Value;
|
||||
track.TrackName = request.TrackName;
|
||||
track.Artist = request.Artist;
|
||||
track.Album = request.Album;
|
||||
track.Genre = request.Genre;
|
||||
track.ReleaseDate = request.ReleaseDate;
|
||||
|
||||
var update = await _sqlTrackService.Update(track);
|
||||
if (!update.Success)
|
||||
{
|
||||
var error = update.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("UpdateMeta failed for {TrackId}: {Error}", id, error);
|
||||
return StatusCode(500, "Failed to update track");
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// DELETE api/track/{id}: removes the SQL row then the vault entry. UnifiedTrackService owns
|
||||
// the ordering and orphan handling. Declared (with the long route constraint) before the
|
||||
// string "{trackId}" GET so a numeric id routes here.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpDelete("{id:long}")]
|
||||
public async Task<ActionResult> DeleteTrack(long id, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("DeleteTrack called with id: {Id}", id);
|
||||
|
||||
var result = await _unifiedService.DeleteAsync(id, cancellationToken);
|
||||
if (result.Success)
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_logger.LogError("DeleteTrack failed for id {Id}: {Error}", id, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// --- Parameterized routes ---
|
||||
|
||||
[HttpGet("{trackId}")]
|
||||
public async Task<ActionResult> GetTrack(string trackId, [FromQuery] long offset = 0)
|
||||
{
|
||||
_logger.LogInformation("GetTrack called with trackId: {TrackId}, offset: {Offset}", trackId, offset);
|
||||
|
||||
try
|
||||
{
|
||||
// No-offset path: stream the file straight from disk so a 100 MB WAV does not
|
||||
// force a 100 MB LOH allocation per request. The offset path still loads
|
||||
// the full buffer because WavOffsetService block-aligns and reslices into
|
||||
// a composite stream over the in-memory buffer.
|
||||
if (offset == 0)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault == null)
|
||||
{
|
||||
_logger.LogWarning("Tracks vault not found");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mediaStream = await vault.GetEntryStreamAsync(trackId);
|
||||
if (mediaStream == null)
|
||||
{
|
||||
_logger.LogWarning("Track not found: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Resolve MIME and log before handing the stream to File().
|
||||
// If anything here throws, the finally block disposes the wrapper
|
||||
// (and its inner FileStream) so neither leaks. On the success path
|
||||
// File() takes ownership of the inner stream; ASP.NET Core disposes
|
||||
// it after the response body is sent. The wrapper is a thin struct
|
||||
// with no extra resources, so disposing it after extracting the
|
||||
// inner stream is a no-op — we only call Dispose() in the catch path.
|
||||
string streamMimeType;
|
||||
long streamLength;
|
||||
Stream innerStream;
|
||||
try
|
||||
{
|
||||
streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
|
||||
streamLength = mediaStream.Stream.Length;
|
||||
innerStream = mediaStream.Stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await mediaStream.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
|
||||
trackId, streamLength);
|
||||
// enableRangeProcessing: false — seek is served by WavOffsetService, not Range.
|
||||
return File(innerStream, streamMimeType, enableRangeProcessing: false);
|
||||
}
|
||||
|
||||
// Offset path: route through TrackService.GetAudioBinaryAsync (Track B's
|
||||
// orchestrator boundary) so the controller stays out of FileDatabase directly.
|
||||
// The buffered AudioBinary is required because WavOffsetService block-aligns
|
||||
// and reslices into a composite stream over the in-memory buffer.
|
||||
var file = await _trackService.GetAudioBinaryAsync(trackId);
|
||||
if (file == null)
|
||||
{
|
||||
_logger.LogWarning("Track not found: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mimeType = MimeTypeExtensions.GetMimeType(file.Extension);
|
||||
|
||||
var offsetStream = _wavOffsetService.CreateOffsetStream(file.Buffer, offset);
|
||||
if (offsetStream == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid offset {Offset} for track: {TrackId}", offset, trackId);
|
||||
return BadRequest("Invalid offset");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Successfully retrieved track with offset: {TrackId}, Offset: {Offset}, StreamSize: {Size} bytes",
|
||||
trackId, offset, offsetStream.Length);
|
||||
return File(offsetStream, mimeType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving track: {TrackId}", trackId);
|
||||
return StatusCode(500, "Internal server error");
|
||||
}
|
||||
}
|
||||
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPut("{trackId}")]
|
||||
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
||||
{
|
||||
_logger.LogInformation("PutTrack called with trackId: {TrackId}", trackId);
|
||||
|
||||
// Reject unknown MIME types up front rather than silently storing the binary
|
||||
// with a ".bin" extension. GetExtension returns ".bin" for any unrecognised
|
||||
// MIME, so treat that as the sentinel for an unsupported type.
|
||||
if (MimeTypeExtensions.GetExtension(track.Mime) == ".bin")
|
||||
{
|
||||
_logger.LogWarning("PutTrack rejected: unsupported MIME type '{Mime}' for track {TrackId}", track.Mime, trackId);
|
||||
return BadRequest($"Unsupported MIME type: {track.Mime}");
|
||||
}
|
||||
|
||||
var audioBinary = AudioBinary.From(track);
|
||||
// Direct FileDatabase write: this endpoint receives an already-processed AudioBinaryDto,
|
||||
// not a WAV file, so TrackService.AddTrackFromWavAsync does not apply. See constructor comment.
|
||||
var success = await _fileDatabase.RegisterResourceAsync(
|
||||
DeepDrftContent.Data.Constants.VaultConstants.Tracks, trackId, audioBinary);
|
||||
return success ? Ok() : BadRequest("Failed to store audio track");
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
||||
<!-- EF Core / Npgsql kept in sync with DeepDrftData / DeepDrftManager so the same DbContext registration compiles. -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DeepDrftModels\DeepDrftModels.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DeepDrftModels\DeepDrftModels.csproj" />
|
||||
<ProjectReference Include="..\DeepDrftContent.Data\DeepDrftContent.Data.csproj" />
|
||||
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Middleware\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
@DeepDrftContent_HostAddress = http://localhost:5070
|
||||
|
||||
GET {{DeepDrftContent_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,52 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using IndexType = DeepDrftContent.FileDatabase.Services.IndexType;
|
||||
using Services_IndexType = DeepDrftContent.FileDatabase.Services.IndexType;
|
||||
|
||||
namespace DeepDrftContent.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(Services_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(Services_IndexType type, IIndex index);
|
||||
|
||||
/// <summary>
|
||||
/// Creates index instance from data
|
||||
/// </summary>
|
||||
IIndex CreateIndexFromData(Services_IndexType type, object indexData);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
|
||||
namespace DeepDrftContent.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.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 System.Text.Json.Serialization;
|
||||
using DeepDrftContent.FileDatabase.Utils;
|
||||
|
||||
namespace DeepDrftContent.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.FileDatabase.Abstractions;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
|
||||
namespace DeepDrftContent.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.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.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.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.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftContent.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.FileDatabase.Abstractions;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Utils;
|
||||
using IndexType = DeepDrftContent.FileDatabase.Services.IndexType;
|
||||
|
||||
namespace DeepDrftContent.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.FileDatabase.Abstractions;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftContent.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.FileDatabase.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftContent.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.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Utils;
|
||||
|
||||
namespace DeepDrftContent.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.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.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.FileDatabase.Abstractions;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.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.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.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.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.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;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace DeepDrftContent.Middleware
|
||||
{
|
||||
public class ApiKeyAuthenticationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly string _apiKey;
|
||||
|
||||
public ApiKeyAuthenticationMiddleware(RequestDelegate next, string apiKey)
|
||||
{
|
||||
_next = next;
|
||||
_apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey));
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var endpoint = context.GetEndpoint();
|
||||
if (endpoint == null)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var hasApiKeyAuthorize = endpoint.Metadata.GetMetadata<ApiKeyAuthorizeAttribute>() != null;
|
||||
if (!hasApiKeyAuthorize)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.TryGetValue("ApiKey", out var extractedApiKey))
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsync("API Key was not provided");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(extractedApiKey, _apiKey, StringComparison.Ordinal))
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsync("Unauthorized client");
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ApiKeyAuthenticationMiddlewareExtensions
|
||||
{
|
||||
public static IApplicationBuilder UseApiKeyAuthentication(this IApplicationBuilder builder, string apiKey)
|
||||
{
|
||||
return builder.UseMiddleware<ApiKeyAuthenticationMiddleware>(apiKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace DeepDrftContent.Middleware
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
|
||||
public class ApiKeyAuthorizeAttribute : Attribute
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace DeepDrftContent.Models
|
||||
{
|
||||
public class ApiKeySettings
|
||||
{
|
||||
public required string ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace DeepDrftContent.Models;
|
||||
|
||||
public class CorsSettings
|
||||
{
|
||||
public string[] AllowedOrigins { get; set; } = [];
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace DeepDrftContent.Models
|
||||
{
|
||||
public class FileDatabaseSettings
|
||||
{
|
||||
public required string VaultPath { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace DeepDrftContent.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Body of <c>PUT api/track/meta/{id}</c>. Metadata-only — EntryKey is immutable and never
|
||||
/// travels over this surface.
|
||||
/// </summary>
|
||||
public record UpdateTrackMetadataRequest(
|
||||
string TrackName,
|
||||
string Artist,
|
||||
string? Album,
|
||||
string? Genre,
|
||||
DateOnly? ReleaseDate);
|
||||
@@ -0,0 +1,271 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.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; }
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Middleware;
|
||||
using DeepDrftContent.Models;
|
||||
using DeepDrftContent.Services;
|
||||
using DeepDrftData;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NetBlocks.Utilities.Environment;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
await Startup.ConfigureDomainServices(builder);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// Add CORS policy using configured origins
|
||||
var corsSettings = builder.Configuration.GetSection(nameof(CorsSettings)).Get<CorsSettings>();
|
||||
if (corsSettings?.AllowedOrigins == null || corsSettings.AllowedOrigins.Length == 0)
|
||||
{
|
||||
throw new Exception("CorsSettings.AllowedOrigins configuration is required for CORS policy");
|
||||
}
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("ContentApiPolicy", policy =>
|
||||
{
|
||||
policy.WithOrigins(corsSettings.AllowedOrigins)
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
|
||||
// Load API key via CredentialTools (dev: environment/apikey.json; prod: CREDENTIALS_DIRECTORY/apikey)
|
||||
var apiKeyPath = CredentialTools.ResolvePathOrThrow("apikey", "environment/apikey.json");
|
||||
builder.Configuration.AddJsonFile(apiKeyPath, optional: false, reloadOnChange: false);
|
||||
var apiKeySettings = builder.Configuration.GetSection(nameof(ApiKeySettings)).Get<ApiKeySettings>();
|
||||
if (apiKeySettings is null) { throw new Exception("API key settings are not configured"); }
|
||||
|
||||
// SQL connection string — DeepDrftContent now owns both vault (FileDatabase) and SQL metadata.
|
||||
var connectionsPath = CredentialTools.ResolvePathOrThrow("connections", "environment/connections.json");
|
||||
builder.Configuration.AddJsonFile(connectionsPath, optional: false, reloadOnChange: false);
|
||||
|
||||
// SQL metadata domain — DbContext + repository + manager (scoped; DbContext is not thread-safe).
|
||||
// UnifiedTrackService orchestrates the two databases and is the single authority over track data.
|
||||
builder.Services.AddDbContext<DeepDrftContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
builder.Services
|
||||
.AddScoped<TrackRepository>()
|
||||
.AddScoped<TrackManager>()
|
||||
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
builder.Services.AddScoped<UnifiedTrackService>();
|
||||
|
||||
// Configure forwarded headers for reverse proxy support
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
|
||||
// Trust any proxy (nginx) - in production, specify known proxy networks
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsProduction())
|
||||
{
|
||||
// Use forwarded headers before other middleware
|
||||
app.UseForwardedHeaders();
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseCors("ContentApiPolicy");
|
||||
app.UseApiKeyAuthentication(apiKeySettings.ApiKey);
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
@@ -1,118 +0,0 @@
|
||||
using DeepDrftContent.Data.Constants;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.Entities;
|
||||
using NetBlocks.Models;
|
||||
using ContentTrackService = DeepDrftContent.Data.TrackService;
|
||||
using FileDb = DeepDrftContent.Data.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftContent.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Host-internal orchestrator that makes DeepDrftContent the single authority over both the
|
||||
/// vault (FileDatabase) and SQL metadata (DeepDrftData). Owns the two-database write/delete
|
||||
/// flow so the controller stays a thin HTTP boundary and no caller coordinates the two stores.
|
||||
/// </summary>
|
||||
public class UnifiedTrackService
|
||||
{
|
||||
internal const string TrackNotFoundMessage = "Track not found.";
|
||||
private readonly ContentTrackService _contentTrackService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly ILogger<UnifiedTrackService> _logger;
|
||||
|
||||
public UnifiedTrackService(
|
||||
ContentTrackService contentTrackService,
|
||||
ITrackService sqlTrackService,
|
||||
FileDb fileDatabase,
|
||||
ILogger<UnifiedTrackService> logger)
|
||||
{
|
||||
_contentTrackService = contentTrackService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_fileDatabase = fileDatabase;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a WAV into the vault, then persist its metadata to SQL. On success the returned
|
||||
/// entity carries the SQL-assigned Id. If the vault write succeeds but the SQL persist fails,
|
||||
/// the audio is orphaned under EntryKey — logged loudly so it is recoverable manually.
|
||||
/// </summary>
|
||||
public async Task<ResultContainer<TrackEntity>> UploadAsync(
|
||||
string tempFilePath,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
DateOnly? releaseDate,
|
||||
long createdByUserId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var unpersisted = await _contentTrackService.AddTrackFromWavAsync(
|
||||
tempFilePath, trackName, artist, album, genre, releaseDate);
|
||||
|
||||
if (unpersisted is null)
|
||||
{
|
||||
_logger.LogWarning("UploadAsync: content TrackService returned null for {TrackName}", trackName);
|
||||
return ResultContainer<TrackEntity>.CreateFailResult("Failed to process and store WAV.");
|
||||
}
|
||||
|
||||
unpersisted.CreatedByUserId = createdByUserId;
|
||||
|
||||
var saveResult = await _sqlTrackService.Create(unpersisted);
|
||||
if (!saveResult.Success || saveResult.Value is null)
|
||||
{
|
||||
// Vault write succeeded, SQL persist failed — audio is orphaned in the tracks vault
|
||||
// under EntryKey. Log loudly (include EntryKey) so it is recoverable manually.
|
||||
var error = saveResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"Track persisted to vault but SQL save failed. Orphaned entry: {EntryKey}. Error: {Error}",
|
||||
unpersisted.EntryKey, error);
|
||||
return ResultContainer<TrackEntity>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
|
||||
}
|
||||
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete
|
||||
/// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete
|
||||
/// failure is logged as an orphan and swallowed — it is a maintenance concern, not user-facing.
|
||||
/// </summary>
|
||||
public async Task<Result> DeleteAsync(long id, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _sqlTrackService.GetById(id);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogError("DeleteAsync: GetById failed for track {TrackId}: {Error}", id, error);
|
||||
return Result.CreateFailResult("Failed to load track.");
|
||||
}
|
||||
|
||||
if (lookup.Value is null)
|
||||
{
|
||||
return Result.CreateFailResult(TrackNotFoundMessage);
|
||||
}
|
||||
|
||||
var entryKey = lookup.Value.EntryKey;
|
||||
|
||||
var sqlDelete = await _sqlTrackService.Delete(id);
|
||||
if (!sqlDelete.Success)
|
||||
{
|
||||
var error = sqlDelete.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogError("DeleteAsync: SQL delete failed for track {TrackId}: {Error}", id, error);
|
||||
return Result.CreateFailResult("Failed to delete track.");
|
||||
}
|
||||
|
||||
// Tri-state per FileDatabase's error-swallow contract: null = vault missing/error,
|
||||
// false = entry not present, true = removed. Anything but a clean removal is an orphan.
|
||||
var removed = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey);
|
||||
if (removed is not true)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Vault delete did not remove entry after SQL delete. {TrackId} {EntryKey} outcome={Outcome}",
|
||||
id, entryKey, removed);
|
||||
}
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using DeepDrftContent.Data;
|
||||
using DeepDrftContent.Data.Audio;
|
||||
using DeepDrftContent.Data.Constants;
|
||||
using DeepDrftContent.Data.FileDatabase.Models;
|
||||
using DeepDrftContent.Data.FileDatabase.Services;
|
||||
using DeepDrftContent.Data.Processors;
|
||||
using DeepDrftContent.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetBlocks.Utilities.Environment;
|
||||
|
||||
namespace DeepDrftContent
|
||||
{
|
||||
public static class Startup
|
||||
{
|
||||
public static Task ConfigureDomainServices(WebApplicationBuilder builder)
|
||||
{
|
||||
// Audio services
|
||||
builder.Services.AddSingleton<WavOffsetService>();
|
||||
builder.Services.AddSingleton<AudioProcessor>();
|
||||
builder.Services.AddSingleton<TrackService>();
|
||||
|
||||
// File Database
|
||||
var fileDatabasePath = CredentialTools.ResolvePathOrThrow("filedatabase", "environment/filedatabase.json");
|
||||
builder.Configuration.AddJsonFile(fileDatabasePath, optional: false, reloadOnChange: false);
|
||||
var fileDatabaseSettings = builder.Configuration.GetSection(nameof(FileDatabaseSettings)).Get<FileDatabaseSettings>();
|
||||
if (fileDatabaseSettings is null) { throw new Exception("File database settings are not configured"); }
|
||||
|
||||
var vaultPath = fileDatabaseSettings.VaultPath;
|
||||
builder.Services.AddSingleton(sp =>
|
||||
{
|
||||
var logger = sp.GetRequiredService<ILogger<FileDatabase>>();
|
||||
var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult();
|
||||
if (db is null) throw new Exception("Unable to initialize file database");
|
||||
InitializeTrackVault(db).GetAwaiter().GetResult();
|
||||
return db;
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static async Task InitializeTrackVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.Tracks))
|
||||
{
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftModels.Entities;
|
||||
|
||||
namespace DeepDrftContent;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing tracks in both SQL and FileDatabase
|
||||
/// </summary>
|
||||
public class TrackContentService
|
||||
{
|
||||
private readonly FileDatabase.Services.FileDatabase _fileDatabase;
|
||||
private readonly AudioProcessor _audioProcessor;
|
||||
|
||||
public TrackContentService(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, 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($"TrackContentService.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<AudioBinary?> GetAudioBinaryAsync(string trackId)
|
||||
{
|
||||
return await _fileDatabase.LoadResourceAsync<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, MediaVaultType.Audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"ApiKeySettings": {
|
||||
"ApiKey": "your-secret-api-key-here"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"CorsSettings": {
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:5070",
|
||||
"https://localhost:5071",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5070"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"DeepDrftContent.Controllers.TrackController": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"CorsSettings": {
|
||||
"AllowedOrigins": [
|
||||
"https://localhost:12778",
|
||||
"https://deepdrft.com",
|
||||
"https://www.deepdrft.com"
|
||||
]
|
||||
},
|
||||
"ForwardedHeaders": {
|
||||
"DisableHttpsRedirection": "true"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"FileDatabaseSettings": {
|
||||
"VaultPath": "<path-to-vault>"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user