//
// Copyright (c) PlaceholderCompany. All rights reserved.
//
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
///
/// Processes file system changes from the database.
/// Only the primary instance processes changes; all instances can record them.
///
public sealed class FileSystemChangeProcessor : IFileSystemChangeProcessor, IHostedService, IAsyncDisposable
{
private readonly IDbContextFactory _dbContextFactory;
private readonly IInstanceRegistry _instanceRegistry;
private readonly IPrimaryElectionService _primaryElectionService;
private readonly ILibraryManager? _libraryManager;
private readonly ILogger _logger;
private CancellationTokenSource? _processingCts;
private Task? _processingTask;
private bool _disposed;
///
/// Initializes a new instance of the class.
///
/// The database context factory.
/// The instance registry.
/// The primary election service.
/// The library manager (optional, may not be available at startup).
/// The logger.
public FileSystemChangeProcessor(
IDbContextFactory dbContextFactory,
IInstanceRegistry instanceRegistry,
IPrimaryElectionService primaryElectionService,
ILibraryManager? libraryManager,
ILogger logger)
{
_dbContextFactory = dbContextFactory;
_instanceRegistry = instanceRegistry;
_primaryElectionService = primaryElectionService;
_libraryManager = libraryManager;
_logger = logger;
}
///
Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
return StartAsync(cancellationToken);
}
///
Task IHostedService.StopAsync(CancellationToken cancellationToken)
{
return StopAsync(cancellationToken);
}
///
public async Task StartAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Starting file system change processor");
// Subscribe to primary changes
_primaryElectionService.PrimaryInstanceChanged += OnPrimaryInstanceChanged;
// If we're currently primary, start processing
if (_primaryElectionService.IsPrimary)
{
StartProcessingLoop();
}
_logger.LogInformation("File system change processor started");
await Task.CompletedTask.ConfigureAwait(false);
}
///
public async Task StopAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Stopping file system change processor");
_primaryElectionService.PrimaryInstanceChanged -= OnPrimaryInstanceChanged;
StopProcessingLoop();
_logger.LogInformation("File system change processor stopped");
await Task.CompletedTask.ConfigureAwait(false);
}
///
public async Task RecordChangeAsync(string path, string changeType, string? oldPath = null, CancellationToken cancellationToken = default)
{
try
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var change = new FileSystemChange
{
Path = path,
ChangeType = changeType,
OldPath = oldPath,
DetectedAt = DateTime.UtcNow,
DetectedBy = _instanceRegistry.CurrentInstanceId
};
context.FileSystemChanges.Add(change);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Recorded file system change: {ChangeType} - {Path}",
changeType,
path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to record file system change for {Path}", path);
}
}
///
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
await StopAsync(CancellationToken.None).ConfigureAwait(false);
_processingCts?.Dispose();
}
private void OnPrimaryInstanceChanged(object? sender, PrimaryInstanceChangedEventArgs e)
{
if (e.IsCurrentInstance)
{
_logger.LogInformation("Became primary instance, starting file system change processing");
StartProcessingLoop();
}
else
{
_logger.LogInformation("No longer primary instance, stopping file system change processing");
StopProcessingLoop();
}
}
private void StartProcessingLoop()
{
if (_processingTask is not null)
{
return; // Already running
}
_processingCts = new CancellationTokenSource();
_processingTask = ProcessChangesLoopAsync(_processingCts.Token);
}
private void StopProcessingLoop()
{
if (_processingTask is null)
{
return; // Not running
}
_processingCts?.Cancel();
_processingTask = null;
_processingCts?.Dispose();
_processingCts = null;
}
private async Task ProcessChangesLoopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("File system change processing loop started");
while (!cancellationToken.IsCancellationRequested)
{
try
{
// Process a batch of changes
await ProcessPendingChangesAsync(cancellationToken).ConfigureAwait(false);
// Wait before next batch (5 seconds)
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in file system change processing loop");
// Wait before retrying on error
try
{
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
_logger.LogInformation("File system change processing loop stopped");
}
private async Task ProcessPendingChangesAsync(CancellationToken cancellationToken)
{
await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
// Get unprocessed changes (oldest first, limit 100 per batch)
var changes = await context.FileSystemChanges
.Where(c => c.ProcessedAt == null)
.OrderBy(c => c.DetectedAt)
.Take(100)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (changes.Count == 0)
{
return; // Nothing to process
}
_logger.LogDebug("Processing {Count} file system changes", changes.Count);
foreach (var change in changes)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
await ProcessSingleChangeAsync(change, context, cancellationToken).ConfigureAwait(false);
}
// Save all processed changes
try
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save processed file system changes");
}
}
private async Task ProcessSingleChangeAsync(FileSystemChange change, JellyfinDbContext context, CancellationToken cancellationToken)
{
try
{
if (_libraryManager is null)
{
// LibraryManager not available yet (still starting up)
return;
}
// Process the change based on type
switch (change.ChangeType)
{
case "Created":
case "Modified":
// LibraryManager will handle these through its file watcher
// We just mark as processed since detection happened
_logger.LogDebug("File change detected: {Type} - {Path}", change.ChangeType, change.Path);
break;
case "Deleted":
_logger.LogDebug("File deleted: {Path}", change.Path);
break;
case "Renamed":
_logger.LogDebug("File renamed from {OldPath} to {NewPath}", change.OldPath, change.Path);
break;
}
// Mark as processed
change.ProcessedAt = DateTime.UtcNow;
change.ProcessedBy = _instanceRegistry.CurrentInstanceId;
// Note: Actual file refresh is handled by LibraryManager's existing file watcher
// This processor just coordinates change detection across instances
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing file system change for {Path}", change.Path);
change.Error = ex.Message;
change.ProcessedAt = DateTime.UtcNow;
change.ProcessedBy = _instanceRegistry.CurrentInstanceId;
}
await Task.CompletedTask.ConfigureAwait(false);
}
}
}