Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .serena/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,14 @@ default_modes:
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []

# override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used.
symbol_info_budget:

# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
18 changes: 12 additions & 6 deletions src/MatroskaBatchFlow.Core/Services/FileScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ public partial class FileScanner(IOptionsMonitor<ScanOptions> optionsMonitor, IL
/// <param name="files">An array of <see cref="FileInfo"/> objects representing the files to scan.</param>
/// <returns>A collection of <see cref="ScannedFileInfo"/>.</returns>
/// <exception cref="ArgumentException">Thrown when no files are provided for scanning.</exception>
public async Task<IEnumerable<ScannedFileInfo>> ScanAsync(FileInfo[] files)
public async Task<IEnumerable<ScannedFileInfo>> ScanAsync(FileInfo[] files, IProgress<(int current, int total)>? progress = null)
{
if (files == null || files.Length == 0)
throw new ArgumentException("No files provided for scanning.", nameof(files));

LogScanningFiles(files.Length);
var scannedFiles = await AnalyzeFilesWithMediaInfoAsync(files.Select(f => f.FullName));
var filePaths = files.Select(f => f.FullName).ToList();
var scannedFiles = await AnalyzeFilesWithMediaInfoAsync(filePaths, progress);

_scannedFiles.Clear();
_scannedFiles.AddRange(scannedFiles);
Expand All @@ -55,8 +56,8 @@ public async Task<IEnumerable<ScannedFileInfo>> ScanWithMediaInfoAsync()
{
EnsureDirectoryExists();
LogScanningDirectory(_options.DirectoryPath, _options.Recursive);
var files = await Task.Run(() => GetFilteredFiles());
LogFilesFound(files.Count());
var files = await Task.Run(() => GetFilteredFiles().ToList());
LogFilesFound(files.Count);
var scannedFiles = await AnalyzeFilesWithMediaInfoAsync(files);

_scannedFiles.Clear();
Expand Down Expand Up @@ -125,15 +126,19 @@ private static ScannedFileInfo ParseMediaInfoJson(string json, string filePath)
/// </summary>
/// <param name="files"> The collection of file paths to analyze.</param>
/// <returns> A collection of <see cref="ScannedFileInfo"/>.</returns>
private static async Task<IEnumerable<ScannedFileInfo>> AnalyzeFilesWithMediaInfoAsync(IEnumerable<string> files)
private static async Task<IEnumerable<ScannedFileInfo>> AnalyzeFilesWithMediaInfoAsync(
IReadOnlyList<string> files,
IProgress<(int current, int total)>? progress = null)
{
return await Task.Run(() =>
{
var scannedFiles = new List<ScannedFileInfo>();
var mediaInfo = new MediaInfo();
var total = files.Count;

foreach (var file in files)
for (var index = 0; index < total; index++)
{
var file = files[index];
mediaInfo.Open(file);
mediaInfo.Option("Complete", "1");
mediaInfo.Option("Output", "JSON");
Expand All @@ -142,6 +147,7 @@ private static async Task<IEnumerable<ScannedFileInfo>> AnalyzeFilesWithMediaInf
// Parse the JSON into a ScannedFileInfo object
var scannedFile = ParseMediaInfoJson(info, file);
scannedFiles.Add(scannedFile);
progress?.Report((index + 1, total));
}

return scannedFiles;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,11 @@ public interface IValidationStateService : IDisposable
/// Forces a re-validation of all files in the current batch against the current validation settings.
/// </summary>
void Revalidate();

/// <summary>
/// Asynchronously forces a re-validation of all files in the current batch.
/// The expensive validation work runs on a background thread; state is updated
/// on the calling synchronization context so UI-bound subscribers remain safe.
/// </summary>
Task RevalidateAsync();
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ namespace MatroskaBatchFlow.Core.Services.FileValidation;
/// </summary>
public sealed partial class ValidationStateService
{
[LoggerMessage(Level = LogLevel.Debug, Message = "Re-validation triggered by file list change: {Action}")]
private partial void LogFileListChangeTriggered(string action);

[LoggerMessage(Level = LogLevel.Debug, Message = "Validation skipped: no files in batch")]
private partial void LogValidationSkipped();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
using System.Collections.Specialized;
using MatroskaBatchFlow.Core.Models;
using Microsoft.Extensions.Logging;

namespace MatroskaBatchFlow.Core.Services.FileValidation;

/// <summary>
/// Manages ongoing validation state for the current batch of files.
/// Automatically re-validates when the file list changes, and exposes a <see cref="Revalidate"/>
/// method for explicit triggers (e.g., after validation settings change).
/// Exposes <see cref="Revalidate"/> and <see cref="RevalidateAsync"/> for explicit triggers
/// (e.g., after the file list or validation settings change).
/// </summary>
public sealed partial class ValidationStateService : IValidationStateService
{
Expand Down Expand Up @@ -49,8 +48,6 @@ public ValidationStateService(
_validationSettingsService = validationSettingsService;
_userSettings = userSettings;
_logger = logger;

_batchConfiguration.FileList.CollectionChanged += OnFileListChanged;
}

/// <inheritdoc/>
Expand All @@ -74,10 +71,32 @@ public void Revalidate()
UpdateState(results);
}

private void OnFileListChanged(object? sender, NotifyCollectionChangedEventArgs e)
/// <inheritdoc/>
/// <exception cref="ObjectDisposedException">Thrown if the service has been disposed.</exception>
public async Task RevalidateAsync()
{
LogFileListChangeTriggered(e.Action.ToString());
Revalidate();
ObjectDisposedException.ThrowIf(_disposed, this);

// Snapshot both collections on the calling (UI) thread before going off-thread,
// since ObservableCollection and the settings graph are not thread-safe.
List<ScannedFileInfo> fileSnapshot = [.. _batchConfiguration.FileList];

if (fileSnapshot.Count == 0)
{
LogValidationSkipped();
UpdateState([]); // Always clear results when there are no files, to reset any previous state
return;
}

var settings = _validationSettingsService.GetEffectiveSettings(_userSettings.Value);

// Run the CPU-bound validation work off the UI thread.
// The continuation (and therefore UpdateState + StateChanged) resumes on the
// original synchronization context, keeping UI-bound subscribers safe.
List<FileValidationResult> results = await Task.Run(
() => _validationEngine.Validate(fileSnapshot, settings).ToList());

UpdateState(results);
}

/// <summary>
Expand Down Expand Up @@ -132,6 +151,5 @@ private void UpdateState(IReadOnlyList<FileValidationResult> results)
public void Dispose()
{
_disposed = true;
_batchConfiguration.FileList.CollectionChanged -= OnFileListChanged;
}
}
2 changes: 1 addition & 1 deletion src/MatroskaBatchFlow.Core/Services/IFileScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ namespace MatroskaBatchFlow.Core.Services;

public interface IFileScanner
{
Task<IEnumerable<ScannedFileInfo>> ScanAsync(FileInfo[] files);
Task<IEnumerable<ScannedFileInfo>> ScanAsync(FileInfo[] files, IProgress<(int current, int total)>? progress);
Task<IEnumerable<ScannedFileInfo>> ScanWithMediaInfoAsync();
}
39 changes: 39 additions & 0 deletions src/MatroskaBatchFlow.Core/Services/Pipeline/IPipelineStage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Diagnostics.CodeAnalysis;

namespace MatroskaBatchFlow.Core.Services.Pipeline;

/// <summary>
/// Represents a single stage in a composable pipeline.
/// Each stage performs a discrete unit of work and communicates with other stages via a shared <see cref="PipelineContext"/>.
/// </summary>
/// <remarks>
/// Stages should be stateless singletons — all per-run state lives in the <see cref="PipelineContext"/>.
/// If a stage has no work to do (e.g., no files in context), it should return gracefully without throwing.
/// </remarks>
public interface IPipelineStage
{
/// <summary>
/// User-visible label for overlay display (e.g., "Scanning files…").
/// </summary>
string DisplayName { get; }

/// <summary>
/// Whether progress is indeterminate (unknown duration) or determinate (known current/total).
/// </summary>
bool IsIndeterminate { get; }

/// <summary>
/// Whether this stage should display an overlay to the user.
/// Near-instant stages can return <see langword="false"/> to avoid visual flicker.
/// </summary>
[ExcludeFromCodeCoverage]
bool ShowsOverlay => true;

/// <summary>
/// Executes this stage's work.
/// </summary>
/// <param name="context">Shared data bag for passing data between stages.</param>
/// <param name="progress">Optional progress reporter for determinate stages.</param>
/// <param name="ct">Cancellation token for cooperative cancellation.</param>
Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using MatroskaBatchFlow.Core.Enums;
using MatroskaBatchFlow.Core.Models;
using MatroskaBatchFlow.Core.Services.FileProcessing;

namespace MatroskaBatchFlow.Core.Services.Pipeline;

/// <summary>
/// Pipeline stage that initializes per-file track configurations and applies processing rules
/// for all scanned files in the context.
/// </summary>
/// <remarks>
/// Reads <see cref="PipelineContextKeys.ScannedFiles"/> from the context.
/// Reports determinate progress as each file is processed.
/// </remarks>
public sealed class InitializeTrackConfigStage(
IBatchTrackConfigurationInitializer trackConfigInitializer,
IFileProcessingEngine fileProcessingRuleEngine,
IBatchConfiguration batchConfig) : IPipelineStage
{
/// <inheritdoc />
public string DisplayName => "Applying track configuration\u2026";

/// <inheritdoc />
public bool IsIndeterminate => false;

/// <inheritdoc />
public bool ShowsOverlay => true;

/// <inheritdoc />
public async Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct)
{
if (!context.TryGet<List<ScannedFileInfo>>(PipelineContextKeys.ScannedFiles, out var scannedFiles) || scannedFiles.Count == 0)
return;

var totalFiles = scannedFiles.Count;

for (var index = 0; index < totalFiles; index++)
{
ct.ThrowIfCancellationRequested();
var file = scannedFiles[index];

trackConfigInitializer.Initialize(file, TrackType.Audio, TrackType.Video, TrackType.Text);
fileProcessingRuleEngine.Apply(file, batchConfig);

progress?.Report((index + 1, totalFiles));

// Yield to the UI thread's message loop after each file so that progress
// callbacks and overlay repaints can render between iterations, preventing
// the UI from freezing during a large batch.
await Task.Yield();
}
}
}
63 changes: 63 additions & 0 deletions src/MatroskaBatchFlow.Core/Services/Pipeline/PipelineContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Diagnostics.CodeAnalysis;

namespace MatroskaBatchFlow.Core.Services.Pipeline;

/// <summary>
/// Shared data bag passed between pipeline stages.
/// Stages write outputs for downstream stages to consume.
/// </summary>
/// <remarks>
/// This is a per-run object, create a new instance for each pipeline execution.
/// This class is not thread-safe. The <see cref="IPipelineRunner"/> executes stages sequentially,
/// so concurrent access is not expected.
/// </remarks>
public sealed class PipelineContext
{
private readonly Dictionary<string, object> _data = [];

/// <summary>
/// When set to <see langword="true"/>, signals the <see cref="IPipelineRunner"/>
/// to stop executing subsequent stages. A stage sets this when continuing
/// the pipeline would be pointless (e.g., all input was filtered out).
/// </summary>
public bool IsAborted { get; set; }

/// <summary>
/// Stores a value in the context under the specified key.
/// </summary>
/// <typeparam name="T">The type of the value.</typeparam>
/// <param name="key">The context key.</param>
/// <param name="value">The value to store.</param>
public void Set<T>(string key, T value) where T : notnull
=> _data[key] = value;

/// <summary>
/// Retrieves a value from the context by key.
/// </summary>
/// <typeparam name="T">The expected type of the value.</typeparam>
/// <param name="key">The context key.</param>
/// <returns>The stored value.</returns>
/// <exception cref="KeyNotFoundException">Thrown when the key does not exist in the context.</exception>
/// <exception cref="InvalidCastException">Thrown when the stored value cannot be cast to <typeparamref name="T"/>.</exception>
public T Get<T>(string key)
=> (T)_data[key];

/// <summary>
/// Attempts to retrieve a value from the context by key.
/// </summary>
/// <typeparam name="T">The expected type of the value.</typeparam>
/// <param name="key">The context key.</param>
/// <param name="value">When this method returns, contains the value if found; otherwise, the default value.</param>
/// <returns><see langword="true"/> if the key was found and the value is of the expected type; otherwise, <see langword="false"/>.</returns>
public bool TryGet<T>(string key, [MaybeNullWhen(false)] out T value)
{
if (_data.TryGetValue(key, out var obj) && obj is T typed)
{
value = typed;
return true;
}

value = default;
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace MatroskaBatchFlow.Core.Services.Pipeline;

/// <summary>
/// Well-known keys for values stored in <see cref="PipelineContext"/>.
/// </summary>
public static class PipelineContextKeys
{
/// <summary>
/// <see cref="FileInfo"/>[] — raw file references to import.
/// </summary>
public const string InputFiles = nameof(InputFiles);

/// <summary>
/// <see cref="List{ScannedFileInfo}"/> — files after MediaInfo scanning.
/// </summary>
public const string ScannedFiles = nameof(ScannedFiles);

/// <summary>
/// <see cref="List{ScannedFileInfo}"/> — files to remove from the batch.
/// </summary>
public const string FilesToRemove = nameof(FilesToRemove);
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
namespace MatroskaBatchFlow.Uno.Presentation;
using Microsoft.Extensions.Logging;

namespace MatroskaBatchFlow.Core.Services.Pipeline;

/// <summary>
/// LoggerMessage definitions for <see cref="InputViewModel"/>.
/// LoggerMessage definitions for <see cref="RefreshStaleMetadataStage"/>.
/// </summary>
public sealed partial class InputViewModel
public sealed partial class RefreshStaleMetadataStage
{
[LoggerMessage(Level = LogLevel.Debug, Message = "Importing {FileCount} file(s)")]
private partial void LogImportingFiles(int fileCount);

[LoggerMessage(Level = LogLevel.Debug, Message = "Skipped {DuplicateCount} duplicate file(s)")]
private partial void LogDuplicatesSkipped(int duplicateCount);

[LoggerMessage(Level = LogLevel.Debug, Message = "Scanned {ScannedCount} file(s)")]
private partial void LogFilesScanned(int scannedCount);

[LoggerMessage(Level = LogLevel.Information, Message = "Re-scanning {Count} file(s) with stale metadata before validation")]
private partial void LogRescanningStaleFiles(int count);

Expand Down
Loading
Loading