From 8c58edd5f91652abc5aa74e9a355ba65f26aaae2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 7 Dec 2025 04:40:52 -0500 Subject: [PATCH] File Database Index watching --- .../FileDatabase/Services/FileDatabase.cs | 28 +++- .../FileDatabase/Services/IndexSystem.cs | 65 +++++++++- .../FileDatabase/Services/IndexWatcher.cs | 120 ++++++++++++++++++ .../FileDatabase/Services/MediaVault.cs | 13 +- DeepDrftWeb/Interop/audio/AudioPlayer.ts | 101 ++++++++++++++- 5 files changed, 308 insertions(+), 19 deletions(-) create mode 100644 DeepDrftContent.Services/FileDatabase/Services/IndexWatcher.cs diff --git a/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs b/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs index 7441b4d..975d463 100644 --- a/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs +++ b/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs @@ -4,11 +4,14 @@ using DeepDrftContent.Services.FileDatabase.Utils; namespace DeepDrftContent.Services.FileDatabase.Services; /// -/// Main file database class that orchestrates multiple media vaults +/// Main file database class that orchestrates multiple media vaults. +/// Includes file watching for automatic index reloading when modified by external processes. /// -public class FileDatabase : DirectoryIndexDirectory +public class FileDatabase : DirectoryIndexDirectory, IDisposable { private readonly StructuralMap _vaults; + private readonly IndexWatcher _indexWatcher; + private bool _disposed; /// /// Factory method to create a FileDatabase instance @@ -31,6 +34,7 @@ public class FileDatabase : DirectoryIndexDirectory private FileDatabase(string rootPath, IDirectoryIndex index) : base(rootPath, index) { _vaults = new StructuralMap(); + _indexWatcher = new IndexWatcher(); } /// @@ -49,7 +53,7 @@ public class FileDatabase : DirectoryIndexDirectory } /// - /// Initializes a specific vault + /// Initializes a specific vault and sets up file watching for its index /// private async Task InitVaultAsync(string vaultId, MediaVaultType vaultType) { @@ -59,6 +63,13 @@ public class FileDatabase : DirectoryIndexDirectory 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(); + }); } } @@ -186,4 +197,15 @@ public class FileDatabase : DirectoryIndexDirectory return _vaults.Size; } + /// + /// Disposes the file database and stops all file watchers + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _indexWatcher.Dispose(); + GC.SuppressFinalize(this); + } } diff --git a/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs b/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs index 56ccd3d..a22b742 100644 --- a/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs +++ b/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs @@ -45,9 +45,9 @@ public abstract class AbstractIndexContainer /// public abstract class IndexDirectory : AbstractIndexContainer { - protected IEntryQueryable Index { get; } + protected IEntryQueryable Index { get; set; } - protected IndexDirectory(string rootPath, IndexType type, IEntryQueryable index, IIndexDataFactory? indexDataFactory = null) + protected IndexDirectory(string rootPath, IndexType type, IEntryQueryable index, IIndexDataFactory? indexDataFactory = null) : base(rootPath, type, indexDataFactory) { Index = index; @@ -81,21 +81,72 @@ public class DirectoryIndexDirectory : IndexDirectory } /// -/// Vault index directory implementation +/// Vault index directory implementation with support for index reloading /// public class VaultIndexDirectory : IndexDirectory { - private readonly IVaultIndex _vaultIndex; + private IVaultIndex _vaultIndex; + private readonly object _indexLock = new(); + private readonly IndexFactoryService _factoryService = new(); - public VaultIndexDirectory(string rootPath, IVaultIndex index, IIndexDataFactory? indexDataFactory = null) - : base(rootPath, IndexType.Vault, index, indexDataFactory) + public VaultIndexDirectory(string rootPath, IVaultIndex index, IIndexDataFactory? indexDataFactory = null) + : base(rootPath, IndexType.Vault, index, indexDataFactory) { _vaultIndex = index; } protected async Task AddToIndexAsync(string entryId, MetaData metaData) { - _vaultIndex.PutEntry(entryId, metaData); + lock (_indexLock) + { + _vaultIndex.PutEntry(entryId, metaData); + } await SaveIndexAsync(_vaultIndex); } + + /// + /// Reloads the index from disk. Called when the index file is modified externally. + /// + public async Task ReloadIndexAsync() + { + try + { + var newIndex = await _factoryService.LoadIndexAsync(IndexType.Vault, RootPath); + if (newIndex is IVaultIndex vaultIndex) + { + lock (_indexLock) + { + _vaultIndex = vaultIndex; + Index = vaultIndex; + } + Console.WriteLine($"VaultIndexDirectory: Reloaded index for {RootPath}, {vaultIndex.GetEntriesSize()} entries"); + } + } + catch (Exception ex) + { + Console.WriteLine($"VaultIndexDirectory: Failed to reload index for {RootPath}: {ex.Message}"); + } + } + + /// + /// Thread-safe check for index entry + /// + public new bool HasIndexEntry(string entryId) + { + lock (_indexLock) + { + return _vaultIndex.HasEntry(entryId); + } + } + + /// + /// Thread-safe get entry metadata + /// + public MetaData? GetEntryMetadata(string entryId) + { + lock (_indexLock) + { + return _vaultIndex.GetEntry(entryId); + } + } } diff --git a/DeepDrftContent.Services/FileDatabase/Services/IndexWatcher.cs b/DeepDrftContent.Services/FileDatabase/Services/IndexWatcher.cs new file mode 100644 index 0000000..9daad5b --- /dev/null +++ b/DeepDrftContent.Services/FileDatabase/Services/IndexWatcher.cs @@ -0,0 +1,120 @@ +using DeepDrftContent.Services.FileDatabase.Models; + +namespace DeepDrftContent.Services.FileDatabase.Services; + +/// +/// Watches index files for external modifications and triggers reloads. +/// Uses FileSystemWatcher to detect changes made by other processes (e.g., CLI). +/// +public class IndexWatcher : IDisposable +{ + private readonly Dictionary _watchers = new(); + private readonly Dictionary _reloadCallbacks = new(); + private readonly object _lock = new(); + private bool _disposed; + + /// + /// Registers an index file to be watched for changes. + /// + /// Full path to the directory containing the index file + /// Callback to invoke when the index file changes + 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; + + Console.WriteLine($"IndexWatcher: Watching {indexPath}/index"); + } + catch (Exception ex) + { + Console.WriteLine($"IndexWatcher: Failed to watch {indexPath}: {ex.Message}"); + } + } + } + + /// + /// Stops watching an index file. + /// + 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)) + { + 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) + { + 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(); + } + } +} diff --git a/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs b/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs index 059be78..8f5636d 100644 --- a/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs +++ b/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs @@ -62,25 +62,24 @@ public abstract class MediaVault : VaultIndexDirectory { // Infer MediaVaultType from the generic type T var vaultType = MediaVaultTypeMap.GetVaultType(); - + + // Use thread-safe method from VaultIndexDirectory if (!HasIndexEntry(entryId)) return null; - if (Index is not VaultIndex vaultIndex) - return null; - - var metaData = vaultIndex.GetEntry(entryId); + // Use thread-safe metadata retrieval + var metaData = 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; } diff --git a/DeepDrftWeb/Interop/audio/AudioPlayer.ts b/DeepDrftWeb/Interop/audio/AudioPlayer.ts index 15f8733..6c3d02e 100644 --- a/DeepDrftWeb/Interop/audio/AudioPlayer.ts +++ b/DeepDrftWeb/Interop/audio/AudioPlayer.ts @@ -14,6 +14,8 @@ import { PlaybackScheduler } from './PlaybackScheduler.js'; export interface AudioResult { success: boolean; error?: string; + seekBeyondBuffer?: boolean; + byteOffset?: number; } export interface StreamingResult extends AudioResult { @@ -89,7 +91,13 @@ export class AudioPlayer { initializeStreaming(totalStreamLength: number): AudioResult { try { + // Full cleanup before starting new stream + this.stopProgressTracking(); + this.scheduler.clear(); + this.streamDecoder.reset(); this.resetState(); + + // Initialize new stream this.isStreamingMode = true; this.streamDecoder.initialize(totalStreamLength); console.log(`Streaming initialized: ${totalStreamLength} bytes expected`); @@ -236,16 +244,105 @@ export class AudioPlayer { return { success: false, error: 'Invalid seek position' }; } + // Get buffered duration (accounting for playback offset) + const bufferedDuration = this.scheduler.getTotalDuration() + this.scheduler.getPlaybackOffset(); + + // Check if seeking within buffered content + if (position <= bufferedDuration) { + return this.seekWithinBuffer(position); + } else { + // Seeking beyond buffer - signal C# to fetch new stream + return this.seekBeyondBuffer(position); + } + } + + /** + * Seek within currently buffered content + */ + private seekWithinBuffer(position: number): AudioResult { try { const wasPlaying = this.isPlaying; this.scheduler.stopAllSources(); + + // Adjust position relative to buffer start (subtract playback offset) + const bufferRelativePosition = position - this.scheduler.getPlaybackOffset(); this.pausePosition = position; if (wasPlaying) { - this.scheduler.playFromPosition(position); + this.scheduler.playFromPosition(Math.max(0, bufferRelativePosition)); } - console.log(`🔍 Seeked to ${position.toFixed(3)}s`); + console.log(`🔍 Seeked within buffer to ${position.toFixed(3)}s (buffer-relative: ${bufferRelativePosition.toFixed(3)}s)`); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + /** + * Seek beyond buffered content - calculate byte offset for server request + */ + private seekBeyondBuffer(position: number): AudioResult { + try { + const byteOffset = this.streamDecoder.calculateByteOffset(position); + if (byteOffset <= 0) { + return { success: false, error: 'Cannot calculate byte offset' }; + } + + console.log(`🔍 Seek beyond buffer to ${position.toFixed(3)}s requires byte offset ${byteOffset}`); + + // Signal that C# needs to request new stream from offset + return { + success: true, + seekBeyondBuffer: true, + byteOffset: byteOffset + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + /** + * Get the total buffered duration (for C# to check if seek is within buffer) + */ + getBufferedDuration(): number { + return this.scheduler.getTotalDuration() + this.scheduler.getPlaybackOffset(); + } + + /** + * Calculate byte offset for a time position (for C# layer) + */ + calculateByteOffset(positionSeconds: number): number { + return this.streamDecoder.calculateByteOffset(positionSeconds); + } + + /** + * Reinitialize for offset streaming after seek-beyond-buffer + * Called by C# after receiving new stream from server + */ + reinitializeFromOffset(totalStreamLength: number, seekPosition: number): AudioResult { + try { + console.log(`\n=== Reinitializing for offset stream ===`); + console.log(`Seek position: ${seekPosition.toFixed(3)}s, Stream length: ${totalStreamLength}`); + + // Stop current playback + this.stopProgressTracking(); + const wasPlaying = this.isPlaying; + this.isPlaying = false; + + // Clear buffers and set new offset + this.scheduler.clearForSeek(); + this.scheduler.setPlaybackOffset(seekPosition); + + // Reinitialize decoder for new stream + this.streamDecoder.reinitializeForOffset(totalStreamLength); + + // Update state + this.pausePosition = seekPosition; + this.streamingStarted = false; // Will restart when new buffers arrive + this.streamingCompleted = false; + + console.log(`✅ Reinitialized for offset, was playing: ${wasPlaying}`); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message };