From 067035ce72b96908037898ef67fc0490ef1a51fd Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Sat, 2 May 2026 16:16:06 +0200 Subject: [PATCH 1/2] refactor(tracks,processing,ui): adopt TrackIntent batch model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - replace the legacy TrackConfiguration/FileTrackConfiguration/FileTrackValues split with a single TrackIntent model owned per batch track slot - remove duplicated mutable per-file track state and treat scanned files as the source of truth for per-file availability and ordering - add ScannedFileInfo.GetTracks(TrackType) so initialization, rule evaluation, and mkvpropedit argument generation share one canonical ordered track lookup path - replace TrackConfigurationFactory with TrackIntentFactory and update dependency registration to build TrackIntent instances directly from scanned metadata - simplify BatchConfiguration by exposing only global TrackIntent collections and removing file-configuration migration/indexing code that belonged to the old model - keep the design centered on batch intent rather than per-file mirrored state: the batch owns the desired values and ShouldModify* flags, while each file contributes only scanned metadata and track presence - preserve uneven track-count support by treating TrackIntent.Index as the stable global slot and skipping missing tracks at resolution time instead of maintaining placeholder per-file config objects - make command generation more direct and predictable by reading mkvpropedit output straight from TrackIntent values and resolving file applicability from scanned tracks on demand - separate stable batch intent from scanned per-file metadata so future resolution strategies can evolve without reintroducing mirrored mutable state - keep today’s model intentionally simple while leaving room for later policy layers, smarter defaulting, per-file override strategies, or more advanced track-resolution logic - update naming, default, forced, and language rules to derive values from the current batch file set through canonical scanned track lookup - normalize language aggregation through ILanguageProvider and ISO 639-2/B grouping so aliases such as en/eng are counted as the same language - simplify track view models and XAML bindings to edit TrackIntent directly instead of synchronizing separate global and per-file models - remove obsolete TrackPositionRule and legacy-model tests that only existed to support the old architecture - fix the import-time derived-default regression introduced by the refactor by adding scanned files to the batch before InitializeTrackConfigStage applies aggregate rules - add orchestrator regression coverage for both the pipeline order and the real import behavior so derived defaults are verified against all files imported in the same run - add focused tests for TrackIntentFactory, ScannedFileInfo ordered track lookup, mkvpropedit argument generation, normalized language defaulting, and stale refresh replacement behavior - refresh assistant/project metadata to document the TrackIntent architecture and the current track-configuration flow --- .github/copilot-instructions.md | 12 +- .serena/project.yml | 127 ++++---- .../Models/FileTrackConfiguration.cs | 46 --- .../Models/FileTrackValues.cs | 68 ---- .../Models/ScannedFileInfo.cs | 24 ++ .../Services/BatchConfiguration.Logging.cs | 8 - .../Services/BatchConfiguration.cs | 308 ++---------------- .../BatchTrackConfigurationInitializer.cs | 71 +--- .../Track/Name/AudioTrackNamingRule.cs | 54 +-- .../Track/Name/SubtitleTrackNamingRule.cs | 13 +- .../Track/Name/VideoTrackNamingRule.cs | 13 +- .../FileProcessing/Track/TrackDefaultRule.cs | 14 +- .../FileProcessing/Track/TrackForcedRule.cs | 14 +- .../FileProcessing/Track/TrackLanguageRule.cs | 44 ++- .../FileProcessing/Track/TrackPositionRule.cs | 45 --- .../Services/IBatchConfiguration.cs | 43 +-- .../IBatchTrackConfigurationInitializer.cs | 14 +- ...ationFactory.cs => ITrackIntentFactory.cs} | 16 +- .../MkvPropeditArgumentsGenerator.Logging.cs | 8 +- .../Services/MkvPropeditArgumentsGenerator.cs | 74 ++--- .../Pipeline/RefreshStaleMetadataStage.cs | 5 +- .../Processing/FileProcessingOrchestrator.cs | 19 +- .../Services/TrackIntent.cs | 230 +++++++++++++ ...rationFactory.cs => TrackIntentFactory.cs} | 18 +- .../Extensions/ServiceCollectionExtensions.cs | 3 +- .../Presentation/AudioPage.xaml | 2 +- .../Presentation/AudioPage.xaml.cs | 2 +- .../Presentation/AudioViewModel.cs | 12 +- .../Controls/TrackSettingsControl.xaml.cs | 6 +- .../Presentation/SubtitlePage.xaml | 2 +- .../Presentation/SubtitlePage.xaml.cs | 2 +- .../Presentation/SubtitleViewModel.cs | 12 +- .../Presentation/TrackViewModelBase.cs | 188 +++-------- .../Presentation/VideoPage.xaml | 2 +- .../Presentation/VideoPage.xaml.cs | 2 +- .../Presentation/VideoViewModel.cs | 12 +- .../Services/BatchOperationOrchestrator.cs | 2 +- .../Models/FileTrackConfigurationTests.cs | 90 ----- .../Models/ScannedFileInfoTests.cs | 33 ++ .../Services/BatchConfigurationTests.cs | 152 +-------- ...BatchTrackConfigurationInitializerTests.cs | 139 +++----- .../Track/TrackLanguageRuleTests.cs | 119 +++++++ .../MkvPropeditArgumentsGeneratorTests.cs | 138 ++++++++ .../RefreshStaleMetadataStageTests.cs | 152 +++++++++ .../FileProcessingOrchestratorTests.cs | 32 +- ...oryTests.cs => TrackIntentFactoryTests.cs} | 24 +- .../TrackModificationIntegrationTests.cs | 19 +- .../Presentation/AudioViewModelTests.cs | 29 +- .../Presentation/SubtitleViewModelTests.cs | 29 +- .../Presentation/TrackViewModelBaseTests.cs | 217 +++--------- .../Presentation/VideoViewModelTests.cs | 29 +- .../BatchOperationOrchestratorTests.cs | 134 +++++++- 52 files changed, 1339 insertions(+), 1532 deletions(-) delete mode 100644 src/MatroskaBatchFlow.Core/Models/FileTrackConfiguration.cs delete mode 100644 src/MatroskaBatchFlow.Core/Models/FileTrackValues.cs delete mode 100644 src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackPositionRule.cs rename src/MatroskaBatchFlow.Core/Services/{ITrackConfigurationFactory.cs => ITrackIntentFactory.cs} (59%) create mode 100644 src/MatroskaBatchFlow.Core/Services/TrackIntent.cs rename src/MatroskaBatchFlow.Core/Services/{TrackConfigurationFactory.cs => TrackIntentFactory.cs} (55%) delete mode 100644 tests/MatroskaBatchFlow.Core.UnitTests/Models/FileTrackConfigurationTests.cs create mode 100644 tests/MatroskaBatchFlow.Core.UnitTests/Models/ScannedFileInfoTests.cs create mode 100644 tests/MatroskaBatchFlow.Core.UnitTests/Services/FileProcessing/Track/TrackLanguageRuleTests.cs create mode 100644 tests/MatroskaBatchFlow.Core.UnitTests/Services/MkvPropeditArgumentsGeneratorTests.cs create mode 100644 tests/MatroskaBatchFlow.Core.UnitTests/Services/Pipeline/RefreshStaleMetadataStageTests.cs rename tests/MatroskaBatchFlow.Core.UnitTests/Services/{TrackConfigurationFactoryTests.cs => TrackIntentFactoryTests.cs} (84%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2753cbd..86b2779 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,7 +3,7 @@ > **Audience**: These instructions are for AI coding assistants, not for human developers. > **Human developers**: See [README.md](../README.md) for setup and contribution guidelines. -> **Last Updated**: 2026-02-19 +> **Last Updated**: 2026-04-28 > **Next Review**: When architecture changes, new patterns are introduced, or build commands change > > **Maintenance**: When making significant code changes, update this file and Serena memories to keep them in sync with the codebase. @@ -80,9 +80,9 @@ dotnet test --filter "FullyQualifiedName~BatchConfigurationTests" - Results are severity-based: Error, Warning, Information - Cross-file property comparison rules (e.g., `DefaultFlagConsistencyRule`, `ForcedFlagConsistencyRule`, `LanguageConsistencyRule`) use `RollingReferenceComparer` to handle batches where files have different track counts: for each track position, the first file that has that position serves as the rolling reference for all subsequent files -- **Configuration Management**: `IBatchConfiguration` tracks file-level and track-level configurations - - `IBatchTrackConfigurationInitializer`: Initializes track configurations from scanned files - - `ITrackConfigurationFactory`: Creates track configurations by type (Video, Audio, Subtitle, General) +- **Configuration Management**: `IBatchConfiguration` tracks file-level settings and global per-slot `TrackIntent` collections + - `IBatchTrackConfigurationInitializer`: Expands global track intent collections from scanned files + - `ITrackIntentFactory`: Creates `TrackIntent` instances from scanned track data #### Presentation Layer (MVVM + Messaging) - **Architecture**: MVVM with CommunityToolkit.Mvvm (source generators for commands/properties) @@ -221,8 +221,8 @@ dotnet test --filter "FullyQualifiedName~BatchConfigurationTests" ### Working with Track Configurations 1. Track-specific ViewModels inherit from `TrackViewModelBase` 2. Use `_suppressBatchConfigUpdate` flag when bulk-updating properties to prevent excessive batch config updates -3. Track collections are `ObservableCollection` for change notifications -4. Use `ITrackConfigurationFactory` to create track configurations by type +3. Track collections are `ObservableCollection` for change notifications +4. `TrackIntent` stores direct values (`Name`, `Language`, `Default`, `Forced`, `Enabled`) plus `ShouldModify*` flags ### Debugging Batch Processing 1. Check `BatchExecutionReport` in `IBatchReportStore` for detailed results diff --git a/.serena/project.yml b/.serena/project.yml index 8821de0..45f2cba 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,15 +1,28 @@ + + # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp csharp_omnisharp -# dart elixir elm erlang fortran go -# haskell java julia kotlin lua markdown -# nix perl php python python_jedi r -# rego ruby ruby_solargraph rust scala swift -# terraform typescript typescript_vts yaml zig +# al ansible bash clojure cpp +# cpp_ccls crystal csharp csharp_omnisharp dart +# elixir elm erlang fortran fsharp +# go groovy haskell haxe hlsl +# java json julia kotlin lean4 +# lua luau markdown matlab msl +# nix ocaml pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r rego ruby ruby_solargraph rust +# scala solidity swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal # Special requirements: -# - csharp: Requires the presence of a .sln file in the project folder. +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers # When using multiple languages, the first language server that supports a given file will be used for that file. # The first language is the default language and the respective language server will be used as a fallback. # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. @@ -20,14 +33,12 @@ languages: # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: "utf-8" -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 +# whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true -# list of additional paths to ignore -# same syntax as gitignore, so you can use * and ** -# Was previously called `ignored_dirs`, please update your config if you are using that. -# Added (renamed) on 2025-04-07 +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode @@ -35,45 +46,9 @@ ignored_paths: [] # Added on 2025-04-18 read_only: false -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # initial prompt for the project. It will always be given to the LLM upon activating the project @@ -82,7 +57,9 @@ initial_prompt: "" # the name by which the project can be referenced within Serena project_name: "Matroska Batch Flow" -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # list of mode names to that are always to be included in the set of active modes @@ -93,19 +70,25 @@ included_optional_tools: [] # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes 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. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html 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. +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. symbol_info_budget: # The language backend to use for this project. @@ -114,3 +97,31 @@ symbol_info_budget: # 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: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: diff --git a/src/MatroskaBatchFlow.Core/Models/FileTrackConfiguration.cs b/src/MatroskaBatchFlow.Core/Models/FileTrackConfiguration.cs deleted file mode 100644 index fe53338..0000000 --- a/src/MatroskaBatchFlow.Core/Models/FileTrackConfiguration.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Collections.ObjectModel; -using MatroskaBatchFlow.Core.Enums; - -namespace MatroskaBatchFlow.Core.Models; - -/// -/// Stores per-file track configuration. -/// -public sealed class FileTrackConfiguration -{ - /// - /// Path to the file this configuration applies to. - /// - public string FilePath { get; set; } = string.Empty; - - /// - /// Audio track values for this file. - /// - public ObservableCollection AudioTracks { get; set; } = []; - - /// - /// Video track values for this file. - /// - public ObservableCollection VideoTracks { get; set; } = []; - - /// - /// Subtitle track values for this file. - /// - public ObservableCollection SubtitleTracks { get; set; } = []; - - /// - /// Gets the track list for a specific track type. - /// - /// The track type to retrieve. - /// Observable collection of track values for the specified type. - public ObservableCollection GetTrackListForType(TrackType trackType) - { - return trackType switch - { - TrackType.Audio => AudioTracks, - TrackType.Video => VideoTracks, - TrackType.Text => SubtitleTracks, - _ => [] - }; - } -} diff --git a/src/MatroskaBatchFlow.Core/Models/FileTrackValues.cs b/src/MatroskaBatchFlow.Core/Models/FileTrackValues.cs deleted file mode 100644 index 90a6fd1..0000000 --- a/src/MatroskaBatchFlow.Core/Models/FileTrackValues.cs +++ /dev/null @@ -1,68 +0,0 @@ -using MatroskaBatchFlow.Core.Enums; - -namespace MatroskaBatchFlow.Core.Models; - -/// -/// Stores per-file track values that are initially populated from the MediaInfo scan. -/// The original scanned data is always available via . -/// -/// -/// The batch configuration uses a dual-model approach for tracks: -/// -/// -/// - one per track index in the global collection. -/// Carries modification intent (ShouldModify* flags) and the effective values -/// (Name, Language, flags) that should be written, and is shared across all files. -/// -/// -/// - one per track per file in . -/// Represents the scanned/current per-file values and indicates whether a given file actually -/// has a track at a particular index. -/// -/// -/// During command generation, reads both -/// ShouldModify* and the values to write from the global track configuration, and uses -/// only to determine per-file track existence and indexing. -/// -public sealed class FileTrackValues -{ - /// - /// The raw track information as returned by MediaInfo. - /// - public required MediaInfoResult.MediaInfo.TrackInfo ScannedTrackInfo { get; init; } - - /// - /// The type of this track (Audio, Video, Text). - /// - public TrackType Type { get; init; } - - /// - /// Zero-based index of this track within its type (e.g. 0 = first audio track). - /// - public int Index { get; init; } - - /// - /// The track name as scanned from the file. - /// - public string Name { get; set; } = string.Empty; - - /// - /// The track language as scanned from the file. - /// - public MatroskaLanguageOption Language { get; set; } = MatroskaLanguageOption.Undetermined; - - /// - /// Whether this track has the default flag set. - /// - public bool Default { get; set; } - - /// - /// Whether this track has the forced flag set. - /// - public bool Forced { get; set; } - - /// - /// Whether this track is enabled (corresponds to the Matroska FlagEnabled element). - /// - public bool Enabled { get; set; } -} diff --git a/src/MatroskaBatchFlow.Core/Models/ScannedFileInfo.cs b/src/MatroskaBatchFlow.Core/Models/ScannedFileInfo.cs index 7ccd0ef..e5da0fb 100644 --- a/src/MatroskaBatchFlow.Core/Models/ScannedFileInfo.cs +++ b/src/MatroskaBatchFlow.Core/Models/ScannedFileInfo.cs @@ -1,4 +1,5 @@ using MatroskaBatchFlow.Core.Enums; +using TrackInfo = MatroskaBatchFlow.Core.Models.MediaInfoResult.MediaInfo.TrackInfo; namespace MatroskaBatchFlow.Core.Models; @@ -78,4 +79,27 @@ public bool HasTrack(TrackType trackType, int trackIndex) _ => false }; } + + /// + /// Gets the tracks of a specific type in file order. + /// + /// Type of track to return. + /// An ordered read-only list of tracks for the requested type. + public IReadOnlyList GetTracks(TrackType trackType) + { + if (trackType is not (TrackType.Audio or TrackType.Video or TrackType.Text)) + { + return Array.Empty(); + } + + var tracks = Result?.Media?.Track; + if (tracks is null) + { + return Array.Empty(); + } + + return [.. tracks + .Where(track => track.Type == trackType) + .OrderBy(track => track.StreamKindID)]; + } } diff --git a/src/MatroskaBatchFlow.Core/Services/BatchConfiguration.Logging.cs b/src/MatroskaBatchFlow.Core/Services/BatchConfiguration.Logging.cs index 17eaee6..fa48143 100644 --- a/src/MatroskaBatchFlow.Core/Services/BatchConfiguration.Logging.cs +++ b/src/MatroskaBatchFlow.Core/Services/BatchConfiguration.Logging.cs @@ -11,12 +11,4 @@ public partial class BatchConfiguration [LoggerMessage(Level = LogLevel.Debug, Message = "Stale flag cleared for file: {FilePath}")] private partial void LogStaleFlagCleared(string filePath); - - [LoggerMessage(Level = LogLevel.Debug, - Message = "Migrated file configuration from {OldFileId} to {NewFileId}")] - private partial void LogFileConfigurationMigrated(Guid oldFileId, Guid newFileId); - - [LoggerMessage(Level = LogLevel.Debug, - Message = "Migration skipped: no configuration found for {OldFileId}")] - private partial void LogMigrationSkippedNoConfiguration(Guid oldFileId); } diff --git a/src/MatroskaBatchFlow.Core/Services/BatchConfiguration.cs b/src/MatroskaBatchFlow.Core/Services/BatchConfiguration.cs index e032a6c..e99cab7 100644 --- a/src/MatroskaBatchFlow.Core/Services/BatchConfiguration.cs +++ b/src/MatroskaBatchFlow.Core/Services/BatchConfiguration.cs @@ -6,7 +6,6 @@ using MatroskaBatchFlow.Core.Models; using MatroskaBatchFlow.Core.Utilities; using Microsoft.Extensions.Logging; -using static MatroskaBatchFlow.Core.Models.MediaInfoResult.MediaInfo; namespace MatroskaBatchFlow.Core.Services; @@ -30,12 +29,11 @@ public partial class BatchConfiguration : IBatchConfiguration /// same physical file, avoiding redundant processing during batch operations. private readonly UniqueObservableCollection _fileList; private readonly HashSet _staleFileIds = []; - private ObservableCollection _audioTracks = []; - private ObservableCollection _videoTracks = []; - private ObservableCollection _subtitleTracks = []; - private static readonly ImmutableList _emptyTrackList = []; + private ObservableCollection _audioTracks = []; + private ObservableCollection _videoTracks = []; + private ObservableCollection _subtitleTracks = []; + private static readonly ImmutableList _emptyTrackList = []; private string _mkvpropeditArguments = string.Empty; - private Dictionary _fileConfigurations = []; public event PropertyChangedEventHandler? PropertyChanged; public event EventHandler? StateChanged; @@ -67,26 +65,26 @@ public BatchConfiguration(IScannedFileInfoPathComparer fileComparer, ILogger - /// Handles changes to an of objects and + /// Handles changes to an of objects and /// updates subscriptions to their events accordingly. /// - /// The collection of objects being tracked. + /// The collection of objects being tracked. /// The event data describing the changes to the collection. - private void TrackCollectionChanged(ObservableCollection collection, NotifyCollectionChangedEventArgs eventArgs) + private void TrackCollectionChanged(ObservableCollection collection, NotifyCollectionChangedEventArgs eventArgs) { - void Subscribe(IEnumerable trackConfigurations) + void Subscribe(IEnumerable trackIntents) { - foreach (var trackConfiguration in trackConfigurations) + foreach (var trackIntent in trackIntents) { - trackConfiguration.PropertyChanged += TrackConfiguration_PropertyChanged; + trackIntent.PropertyChanged += TrackIntent_PropertyChanged; } } - void Unsubscribe(IEnumerable trackConfigurations) + void Unsubscribe(IEnumerable trackIntents) { - foreach (var trackConfiguration in trackConfigurations) + foreach (var trackIntent in trackIntents) { - trackConfiguration.PropertyChanged -= TrackConfiguration_PropertyChanged; + trackIntent.PropertyChanged -= TrackIntent_PropertyChanged; } } @@ -97,26 +95,26 @@ void Unsubscribe(IEnumerable trackConfigurations) case NotifyCollectionChangedAction.Add: if (eventArgs.NewItems?.Count > 0) { - Subscribe(eventArgs.NewItems.Cast()); + Subscribe(eventArgs.NewItems.Cast()); stateChanged = true; } break; case NotifyCollectionChangedAction.Remove: if (eventArgs.OldItems?.Count > 0) { - Unsubscribe(eventArgs.OldItems.Cast()); + Unsubscribe(eventArgs.OldItems.Cast()); stateChanged = true; } break; case NotifyCollectionChangedAction.Replace: if (eventArgs.OldItems?.Count > 0) { - Unsubscribe(eventArgs.OldItems.Cast()); + Unsubscribe(eventArgs.OldItems.Cast()); } if (eventArgs.NewItems?.Count > 0) { - Subscribe(eventArgs.NewItems.Cast()); + Subscribe(eventArgs.NewItems.Cast()); } stateChanged = true; @@ -137,7 +135,7 @@ void Unsubscribe(IEnumerable trackConfigurations) } } - private void TrackConfiguration_PropertyChanged(object? sender, PropertyChangedEventArgs eventArgs) + private void TrackIntent_PropertyChanged(object? sender, PropertyChangedEventArgs eventArgs) { OnStateChanged(); } @@ -223,7 +221,7 @@ public bool ShouldModifyTrackStatisticsTags public UniqueObservableCollection FileList => _fileList; - public ObservableCollection AudioTracks + public ObservableCollection AudioTracks { get => _audioTracks; set @@ -236,7 +234,7 @@ public ObservableCollection AudioTracks } } - public ObservableCollection VideoTracks + public ObservableCollection VideoTracks { get => _videoTracks; set @@ -249,7 +247,7 @@ public ObservableCollection VideoTracks } } - public ObservableCollection SubtitleTracks + public ObservableCollection SubtitleTracks { get => _subtitleTracks; set @@ -275,19 +273,6 @@ public string MkvpropeditArguments } } - public Dictionary FileConfigurations - { - get => _fileConfigurations; - set - { - if (!ReferenceEquals(_fileConfigurations, value)) - { - _fileConfigurations = value; - OnPropertyChanged(nameof(FileConfigurations)); - } - } - } - protected virtual void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); @@ -303,12 +288,11 @@ public void Clear() AudioTracks.Clear(); VideoTracks.Clear(); SubtitleTracks.Clear(); - FileConfigurations.Clear(); MkvpropeditArguments = string.Empty; } /// - public IList GetTrackListForType(TrackType trackType) + public IList GetTrackListForType(TrackType trackType) { return trackType switch { @@ -319,19 +303,6 @@ public IList GetTrackListForType(TrackType trackType) }; } - /// - /// Thrown if no per-file track configuration exists for the specified file ID. - public IList GetTrackListForFile(Guid fileId, TrackType trackType) - { - // Prefer per-file configuration. If none exists, fail fast by throwing an exception. - if (FileConfigurations.TryGetValue(fileId, out var fileConfig)) - { - return fileConfig.GetTrackListForType(trackType); - } - - throw new InvalidOperationException($"No per-file track configuration found for file ID '{fileId}'."); - } - /// /// Clears all audio, video, and subtitle tracks from the current collection. /// @@ -351,14 +322,13 @@ private void ClearTracks() /// The event data associated with the file removal. private void OnFileRemoval(object? sender, NotifyCollectionChangedEventArgs eventArgs) { - // Clean up per-file state for removed files. + // Clean up stale tracking for removed files. // OldItems is populated for Remove/Replace but null for Reset (Clear). if (eventArgs.OldItems != null) { foreach (ScannedFileInfo file in eventArgs.OldItems) { - _staleFileIds.Remove(file.Id); // Clear any stale tracking for the removed file. - _fileConfigurations.Remove(file.Id); // Remove per-file track configuration for the removed file. + _staleFileIds.Remove(file.Id); } } @@ -374,7 +344,6 @@ private void OnFileRemoval(object? sender, NotifyCollectionChangedEventArgs even DeleteTrackStatisticsTags = false; ShouldModifyTrackStatisticsTags = false; ShouldModifyTitle = false; - FileConfigurations.Clear(); ClearTracks(); } @@ -386,27 +355,6 @@ private void OnStateChanged() StateChanged?.Invoke(this, EventArgs.Empty); } - /// - /// Migrates file configuration from an old file ID to a new file ID. - /// Used when replacing a file with a re-scanned version that has a new Guid. - /// Preserves user's configuration while updating the file's metadata. - /// - /// The original file's unique identifier. - /// The new file's unique identifier. - public void MigrateFileConfiguration(Guid oldFileId, Guid newFileId) - { - if (_fileConfigurations.TryGetValue(oldFileId, out var config)) - { - _fileConfigurations.Remove(oldFileId); - _fileConfigurations[newFileId] = config; - LogFileConfigurationMigrated(oldFileId, newFileId); - } - else - { - LogMigrationSkippedNoConfiguration(oldFileId); - } - } - /// /// Marks a file's metadata as stale (needs re-scanning). /// @@ -453,211 +401,3 @@ public void ClearStaleFlag(Guid fileId) public IEnumerable GetStaleFiles() => _fileList.Where(f => _staleFileIds.Contains(f.Id)); } - -/// -/// Represents the configuration for a specific media track. Properties that are null should be seen as -/// missing from the scanned Matroska file. -/// -/// Constructor parameter is needed to due generation error when using object initializer syntax. -/// The instance containing the scanned track information. Must not be null. -public sealed class TrackConfiguration(TrackInfo trackInfo) : INotifyPropertyChanged -{ - private TrackType _type; - private int _index; - /// - /// Represents a human-readable label for a track or segment. - /// - /// For , , and , - /// this property corresponds to the track's Name element as defined in the Matroska specification. - /// - /// - /// For , this property represents the segment's Title element. - /// - /// - /// See the Matroska specification for details: - /// - /// - /// Name (track): specification - /// - /// - /// Title (segment): specification - /// - /// - /// - /// - private string _name = string.Empty; - private MatroskaLanguageOption _language = MatroskaLanguageOption.Undetermined; - private bool _default; - private bool _forced; - private bool _remove; - private bool _shouldModifyDefaultFlag = false; - private bool _shouldModifyForcedFlag = false; - private bool _shouldModifyEnabledFlag = false; - private bool _shouldModifyName = false; - private bool _shouldModifyLanguage = false; - - public event PropertyChangedEventHandler? PropertyChanged; - - public TrackInfo ScannedTrackInfo { get; init; } = trackInfo ?? throw new ArgumentNullException(nameof(trackInfo)); - - public TrackType Type - { - get => _type; - set - { - if (_type != value) - { - _type = value; - OnPropertyChanged(nameof(Type)); - } - } - } - - public int Index - { - get => _index; - set - { - if (_index != value) - { - _index = value; - OnPropertyChanged(nameof(Index)); - } - } - } - - public string Name - { - get => _name; - set - { - if (_name != value) - { - _name = value; - OnPropertyChanged(nameof(Name)); - } - } - } - - public bool ShouldModifyName - { - get => _shouldModifyName; - set - { - if (_shouldModifyName != value) - { - _shouldModifyName = value; - OnPropertyChanged(nameof(ShouldModifyName)); - } - } - } - - public MatroskaLanguageOption Language - { - get => _language; - set - { - if (_language != value) - { - _language = value; - OnPropertyChanged(nameof(Language)); - } - } - } - - public bool ShouldModifyLanguage - { - get => _shouldModifyLanguage; - set - { - if (_shouldModifyLanguage != value) - { - _shouldModifyLanguage = value; - OnPropertyChanged(nameof(ShouldModifyLanguage)); - } - } - } - - public bool Default - { - get => _default; - set - { - if (_default != value) - { - _default = value; - OnPropertyChanged(nameof(Default)); - } - } - } - - public bool ShouldModifyDefaultFlag - { - get => _shouldModifyDefaultFlag; - set - { - if (_shouldModifyDefaultFlag != value) - { - _shouldModifyDefaultFlag = value; - OnPropertyChanged(nameof(ShouldModifyDefaultFlag)); - } - } - } - - public bool Forced - { - get => _forced; - set - { - if (_forced != value) - { - _forced = value; - OnPropertyChanged(nameof(Forced)); - } - } - } - - public bool ShouldModifyForcedFlag - { - get => _shouldModifyForcedFlag; - set - { - if (_shouldModifyForcedFlag != value) - { - _shouldModifyForcedFlag = value; - OnPropertyChanged(nameof(ShouldModifyForcedFlag)); - } - } - } - - public bool Enabled - { - get => _remove; - set - { - if (_remove != value) - { - _remove = value; - OnPropertyChanged(nameof(Enabled)); - } - } - } - - public bool ShouldModifyEnabledFlag - { - get => _shouldModifyEnabledFlag; - set - { - if (_shouldModifyEnabledFlag != value) - { - _shouldModifyEnabledFlag = value; - OnPropertyChanged(nameof(ShouldModifyEnabledFlag)); - } - } - } - - private void OnPropertyChanged(string propertyName) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } -} diff --git a/src/MatroskaBatchFlow.Core/Services/BatchTrackConfigurationInitializer.cs b/src/MatroskaBatchFlow.Core/Services/BatchTrackConfigurationInitializer.cs index e21b06b..02d4636 100644 --- a/src/MatroskaBatchFlow.Core/Services/BatchTrackConfigurationInitializer.cs +++ b/src/MatroskaBatchFlow.Core/Services/BatchTrackConfigurationInitializer.cs @@ -4,23 +4,18 @@ namespace MatroskaBatchFlow.Core.Services; /// -/// Initializes per-file track configurations based on scanned file information. +/// Initializes global track intent collections based on scanned file information. /// /// -/// This class orchestrates track initialization for each file: -/// -/// Creates per-file directly from scanned track metadata -/// Delegates global creation to when expanding to match maximum track counts -/// Populates file-specific track lists in -/// +/// This class ensures that the global track collections +/// (Audio, Video, Subtitle) are expanded to reflect the maximum track count across all files. +/// Per-file values are no longer stored — they are computed on demand via the transform pipeline. /// /// The batch configuration to be modified. -/// The factory for creating global track configurations. -/// The language provider for resolving track language codes. +/// The factory for creating track intents. public class BatchTrackConfigurationInitializer( IBatchConfiguration batchConfig, - ITrackConfigurationFactory trackConfigFactory, - ILanguageProvider languageProvider) : IBatchTrackConfigurationInitializer + ITrackIntentFactory trackIntentFactory) : IBatchTrackConfigurationInitializer { /// public void Initialize(ScannedFileInfo scannedFile, params TrackType[] trackTypes) @@ -28,48 +23,8 @@ public void Initialize(ScannedFileInfo scannedFile, params TrackType[] trackType if (scannedFile?.Result?.Media?.Track == null || trackTypes.Length == 0) return; - // Ensure per-file configuration exists - if (!batchConfig.FileConfigurations.TryGetValue(scannedFile.Id, out FileTrackConfiguration? fileConfig)) - { - fileConfig = new FileTrackConfiguration - { - FilePath = scannedFile.Path - }; - batchConfig.FileConfigurations.Add(scannedFile.Id, fileConfig); - } - - // Populate file-specific track values based on what this file has - foreach (var trackType in trackTypes) - { - // Ordering tracks by StreamKindID as it represents the track order in the file - var scannedTracks = scannedFile.Result.Media.Track - .Where(t => t.Type == trackType) - .OrderBy(t => t.StreamKindID) - .ToList(); - - var fileTracks = fileConfig.GetTrackListForType(trackType); - - int existingCount = fileTracks.Count; - int scannedCount = scannedTracks.Count; - - for (int i = existingCount; i < scannedCount; i++) - { - var scannedTrack = scannedTracks[i]; - fileTracks.Add(new FileTrackValues - { - ScannedTrackInfo = scannedTrack, - Type = trackType, - Index = i, - Name = scannedTrack.Title ?? string.Empty, - Language = languageProvider.Resolve(scannedTrack.Language), - Default = scannedTrack.Default, - Forced = scannedTrack.Forced, - }); - } - } - // Update global collections for UI display based on maximum track counts - UpdateGlobalTracksForMaximumCounts(fileConfig, trackTypes); + UpdateGlobalTracksForMaximumCounts(scannedFile, trackTypes); } /// @@ -80,23 +35,21 @@ public void Initialize(ScannedFileInfo scannedFile, params TrackType[] trackType /// /// This will not remove any existing global tracks; it only adds new ones as needed. /// - /// The file configuration being initialized. + /// The scanned file to check track counts against. /// The track types to update. - private void UpdateGlobalTracksForMaximumCounts(FileTrackConfiguration fileConfig, TrackType[] trackTypes) + private void UpdateGlobalTracksForMaximumCounts(ScannedFileInfo scannedFile, TrackType[] trackTypes) { foreach (var trackType in trackTypes) { - var fileTracks = fileConfig.GetTrackListForType(trackType); + var scannedTracks = scannedFile.GetTracks(trackType); + int fileTrackCount = scannedTracks.Count; var batchTracks = batchConfig.GetTrackListForType(trackType); - - int fileTrackCount = fileTracks.Count; int batchTrackCount = batchTracks.Count; // Expand global collection if this file has more tracks than currently represented for (int i = batchTrackCount; i < fileTrackCount; i++) { - var sourceTrack = fileTracks[i]; - batchTracks.Add(trackConfigFactory.Create(sourceTrack.ScannedTrackInfo, trackType, i)); + batchTracks.Add(trackIntentFactory.Create(scannedTracks[i], trackType, i)); } } } diff --git a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/AudioTrackNamingRule.cs b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/AudioTrackNamingRule.cs index 5549df9..933a781 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/AudioTrackNamingRule.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/AudioTrackNamingRule.cs @@ -4,8 +4,7 @@ namespace MatroskaBatchFlow.Core.Services.FileProcessing.Track.Name; /// -/// Analyzes per-file audio track names and populates global UI properties with smart defaults. -/// Per-file configurations are already populated by . +/// Analyzes per-file audio track names and populates global TrackIntent properties with smart defaults. /// This rule can implement advanced naming logic based on codec, channel layout, etc. /// public class AudioTrackNamingRule : IFileProcessingRule @@ -22,43 +21,60 @@ public void Apply(ScannedFileInfo scannedFile, IBatchConfiguration batchConfig) if (scannedFile?.Result?.Media?.Track == null || batchConfig == null) return; - // Per-file configs already populated by synchronizer - we just populate global UI var globalTracks = batchConfig.GetTrackListForType(TrackType.Audio); for (int i = 0; i < globalTracks.Count; i++) { - // Collect names from all files that have this track - var names = batchConfig.FileConfigurations.Values - .Select(fc => fc.GetTrackListForType(TrackType.Audio)) - .Where(tracks => i < tracks.Count) - .Select(tracks => tracks[i].Name) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct() - .ToList(); + // Gather all track names for this index across files to determine a common name. + var names = GetDistinctAudioTrackNames(batchConfig.FileList, i); - // Business logic: Use common name if all files agree, otherwise use most common or empty if (names.Count == 1) { globalTracks[i].Name = names[0]; } else if (names.Count > 0) { - // Multiple different names - use most common + // If multiple distinct names exist, attempt to find the most common one to use as a default. var mostCommonName = names .GroupBy(n => n) .OrderByDescending(g => g.Count()) .First() .Key; + globalTracks[i].Name = mostCommonName; } + } + } + + /// + /// Retrieves a list of unique audio track names from a collection of scanned files based on the specified track + /// index. + /// + /// A collection of objects representing the files to analyze for audio track names. + /// The zero-based index of the audio track to retrieve from each file's audio tracks. + /// A list of distinct audio track names corresponding to the specified track index from the provided files. The + /// list may be empty if no valid audio tracks are found. + private static List GetDistinctAudioTrackNames(IEnumerable files, int trackIndex) + { + var names = new List(); + + foreach (var file in files) + { + var audioTracks = file.GetTracks(TrackType.Audio); - // TODO: Future enhancement - generate smart names based on codec/channel layout if no title exists - //var format = track.Format ?? string.Empty; - //var layout = track.ChannelLayout ?? string.Empty; - // : $"{layoutName} {format}"; + if (trackIndex >= audioTracks.Count) + { + continue; + } - //if (!int.TryParse(track.StreamKindPos, out int position)) - // continue; + var name = audioTracks[trackIndex].Title; + if (!string.IsNullOrWhiteSpace(name)) + { + names.Add(name); + } } + + // Multiple files may share the same title; callers only need unique candidates. + return names.Distinct().ToList(); } } diff --git a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/SubtitleTrackNamingRule.cs b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/SubtitleTrackNamingRule.cs index 1acc309..a5eec16 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/SubtitleTrackNamingRule.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/SubtitleTrackNamingRule.cs @@ -4,8 +4,7 @@ namespace MatroskaBatchFlow.Core.Services.FileProcessing.Track.Name; /// -/// Analyzes per-file subtitle track names and populates global UI properties with smart defaults. -/// Per-file configurations are already populated by . +/// Analyzes per-file subtitle track names and populates global TrackIntent properties with smart defaults. /// This rule can implement advanced naming logic based on subtitle format. /// public class SubtitleTrackNamingRule : IFileProcessingRule @@ -25,28 +24,24 @@ public void Apply(ScannedFileInfo scannedFile, IBatchConfiguration batchConfig) if (scannedFile?.Result?.Media?.Track == null || batchConfig == null) return; - // Per-file configs already populated by synchronizer - we just populate global UI var globalTracks = batchConfig.GetTrackListForType(TrackType.Text); for (int i = 0; i < globalTracks.Count; i++) { - // Collect names from all files that have this track - var names = batchConfig.FileConfigurations.Values - .Select(fc => fc.GetTrackListForType(TrackType.Text)) + var names = batchConfig.FileList + .Select(f => f.GetTracks(TrackType.Text)) .Where(tracks => i < tracks.Count) - .Select(tracks => tracks[i].Name) + .Select(tracks => tracks[i].Title ?? string.Empty) .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct() .ToList(); - // Business logic: Use common name if all files agree, otherwise use most common or empty if (names.Count == 1) { globalTracks[i].Name = names[0]; } else if (names.Count > 0) { - // Multiple different names - use most common var mostCommonName = names .GroupBy(n => n) .OrderByDescending(g => g.Count()) diff --git a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/VideoTrackNamingRule.cs b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/VideoTrackNamingRule.cs index bd4e7e4..b9d8b88 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/VideoTrackNamingRule.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/Name/VideoTrackNamingRule.cs @@ -4,8 +4,7 @@ namespace MatroskaBatchFlow.Core.Services.FileProcessing.Track.Name; /// -/// Analyzes per-file video track names and populates global UI properties with smart defaults. -/// Per-file configurations are already populated by . +/// Analyzes per-file video track names and populates global TrackIntent properties with smart defaults. /// public class VideoTrackNamingRule : IFileProcessingRule { @@ -14,28 +13,24 @@ public void Apply(ScannedFileInfo scannedFile, IBatchConfiguration batchConfig) if (scannedFile?.Result?.Media?.Track == null || batchConfig == null) return; - // Per-file configs already populated by synchronizer - we just populate global UI var globalTracks = batchConfig.GetTrackListForType(TrackType.Video); for (int i = 0; i < globalTracks.Count; i++) { - // Collect names from all files that have this track - var names = batchConfig.FileConfigurations.Values - .Select(fc => fc.GetTrackListForType(TrackType.Video)) + var names = batchConfig.FileList + .Select(f => f.GetTracks(TrackType.Video)) .Where(tracks => i < tracks.Count) - .Select(tracks => tracks[i].Name) + .Select(tracks => tracks[i].Title ?? string.Empty) .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct() .ToList(); - // Business logic: Use common name if all files agree, otherwise use most common or empty if (names.Count == 1) { globalTracks[i].Name = names[0]; } else if (names.Count > 0) { - // Multiple different names - use most common var mostCommonName = names .GroupBy(n => n) .OrderByDescending(g => g.Count()) diff --git a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackDefaultRule.cs b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackDefaultRule.cs index 41bd19a..37647f3 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackDefaultRule.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackDefaultRule.cs @@ -4,14 +4,13 @@ namespace MatroskaBatchFlow.Core.Services.FileProcessing.Track; /// -/// Analyzes per-file track default flags and populates global UI properties. -/// Per-file configurations are already populated by . -/// This rule determines what default flag to display in the UI based on all files. +/// Analyzes per-file track default flags and populates global TrackIntent properties. +/// This rule determines the default flag to display in the UI based on all scanned files. /// public class TrackDefaultRule : IFileProcessingRule { /// - /// Analyzes per-file default flags and populates global UI with most common value. + /// Analyzes per-file default flags and populates global intents with the most common value. /// /// The scanned file information (used for context). /// The batch configuration to update with global UI defaults. @@ -20,16 +19,14 @@ public void Apply(ScannedFileInfo scannedFile, IBatchConfiguration batchConfig) ArgumentNullException.ThrowIfNull(scannedFile); ArgumentNullException.ThrowIfNull(batchConfig); - // Per-file configs already populated by synchronizer - we just populate global UI foreach (var trackType in Enum.GetValues().Where(t => t.IsMatroskaTrackElement())) { var globalTracks = batchConfig.GetTrackListForType(trackType); for (int i = 0; i < globalTracks.Count; i++) { - // Collect default flags from all files that have this track - var defaultFlags = batchConfig.FileConfigurations.Values - .Select(fc => fc.GetTrackListForType(trackType)) + var defaultFlags = batchConfig.FileList + .Select(f => f.GetTracks(trackType)) .Where(tracks => i < tracks.Count) .Select(tracks => tracks[i].Default) .ToList(); @@ -37,7 +34,6 @@ public void Apply(ScannedFileInfo scannedFile, IBatchConfiguration batchConfig) if (defaultFlags.Count == 0) continue; - // Business logic: Use most common value (true if majority are true) globalTracks[i].Default = defaultFlags.Count(f => f) > defaultFlags.Count / 2; } } diff --git a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackForcedRule.cs b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackForcedRule.cs index fd89e0a..c341d22 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackForcedRule.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackForcedRule.cs @@ -4,14 +4,13 @@ namespace MatroskaBatchFlow.Core.Services.FileProcessing.Track; /// -/// Analyzes per-file track forced flags and populates global UI properties. -/// Per-file configurations are already populated by . -/// This rule determines what forced flag to display in the UI based on all files. +/// Analyzes per-file track forced flags and populates global TrackIntent properties. +/// This rule determines the forced flag to display in the UI based on all scanned files. /// public class TrackForcedRule : IFileProcessingRule { /// - /// Analyzes per-file forced flags and populates global UI with most common value. + /// Analyzes per-file forced flags and populates global intents with the most common value. /// /// The scanned file information (used for context). /// The batch configuration to update with global UI forced flags. @@ -20,16 +19,14 @@ public void Apply(ScannedFileInfo scannedFile, IBatchConfiguration batchConfig) ArgumentNullException.ThrowIfNull(scannedFile); ArgumentNullException.ThrowIfNull(batchConfig); - // Per-file configs already populated by synchronizer - we just populate global UI foreach (var trackType in Enum.GetValues().Where(t => t.IsMatroskaTrackElement())) { var globalTracks = batchConfig.GetTrackListForType(trackType); for (int i = 0; i < globalTracks.Count; i++) { - // Collect forced flags from all files that have this track - var forcedFlags = batchConfig.FileConfigurations.Values - .Select(fc => fc.GetTrackListForType(trackType)) + var forcedFlags = batchConfig.FileList + .Select(f => f.GetTracks(trackType)) .Where(tracks => i < tracks.Count) .Select(tracks => tracks[i].Forced) .ToList(); @@ -37,7 +34,6 @@ public void Apply(ScannedFileInfo scannedFile, IBatchConfiguration batchConfig) if (forcedFlags.Count == 0) continue; - // Business logic: Use most common value (true if majority are true) globalTracks[i].Forced = forcedFlags.Count(f => f) > forcedFlags.Count / 2; } } diff --git a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackLanguageRule.cs b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackLanguageRule.cs index 75cb04e..86d8225 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackLanguageRule.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackLanguageRule.cs @@ -4,67 +4,65 @@ namespace MatroskaBatchFlow.Core.Services.FileProcessing.Track; /// -/// Analyzes per-file track languages and populates global UI properties with smart defaults. -/// Per-file configurations are already populated by . -/// This rule determines what language to display in the UI based on all files. +/// Analyzes per-file track languages and populates global TrackIntent properties with smart defaults. +/// This rule determines what language to display in the UI based on all scanned files. /// -public class TrackLanguageRule : IFileProcessingRule +public class TrackLanguageRule(ILanguageProvider languageProvider) : IFileProcessingRule { public void Apply(ScannedFileInfo scannedFile, IBatchConfiguration batchConfig) { ArgumentNullException.ThrowIfNull(scannedFile); ArgumentNullException.ThrowIfNull(batchConfig); - // Per-file configs already populated by synchronizer - we just populate global UI foreach (var trackType in Enum.GetValues().Where(t => t.IsMatroskaTrackElement())) { var globalTracks = batchConfig.GetTrackListForType(trackType); for (int i = 0; i < globalTracks.Count; i++) { - // Collect languages from all files that have this track - var languages = batchConfig.FileConfigurations.Values - .Select(fc => fc.GetTrackListForType(trackType)) + // Resolve raw MediaInfo values before grouping so aliases such as "en" and "eng" + // are counted as the same canonical language. + var languages = batchConfig.FileList + .Select(f => f.GetTracks(trackType)) .Where(tracks => i < tracks.Count) - .Select(tracks => tracks[i].Language) - .Where(lang => lang != null) + .Select(tracks => languageProvider.Resolve(tracks[i].Language)) .ToList(); if (languages.Count == 0) continue; - // Business logic: Use most common language, or Undetermined if no clear winner globalTracks[i].Language = DetermineMostCommonLanguage(languages); } } } /// - /// Determines the most common language from a list of languages across multiple files. + /// Determines the most common normalized language across multiple files. /// - /// List of languages from all files for a specific track position. - /// The most frequently occurring language, or Undetermined if all languages differ equally. + /// List of resolved languages from all files for a specific track position. + /// The most frequently occurring language, or if there is no clear winner. private static MatroskaLanguageOption DetermineMostCommonLanguage(List languages) { if (languages.Count == 0) return MatroskaLanguageOption.Undetermined; - // If all files have the same language, use it - if (languages.Distinct().Count() == 1) + // If all languages are the same (including aliases), return that language immediately. + if (languages.Select(l => l.Iso639_2_b).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1) return languages[0]; - // Find most common language - var languageGroups = languages - .GroupBy(l => l.Iso639_2_b) + // Group languages by their ISO 639-2/B code and find the most common one. + // This handles cases where different aliases represent the same language. + var groups = languages + .GroupBy(l => l.Iso639_2_b, StringComparer.OrdinalIgnoreCase) .OrderByDescending(g => g.Count()) .ToList(); - // If there's a clear winner (more than half), use it - var mostCommon = languageGroups[0]; + // If the most common language represents a majority, return it. + var mostCommon = groups[0]; if (mostCommon.Count() > languages.Count / 2) - return mostCommon.First(); + return languages.First(l => string.Equals(l.Iso639_2_b, mostCommon.Key, StringComparison.OrdinalIgnoreCase)); - // Otherwise, no clear default - return Undetermined + // No language has a majority, return undetermined to avoid making an arbitrary choice. return MatroskaLanguageOption.Undetermined; } } diff --git a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackPositionRule.cs b/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackPositionRule.cs deleted file mode 100644 index d61d48e..0000000 --- a/src/MatroskaBatchFlow.Core/Services/FileProcessing/Track/TrackPositionRule.cs +++ /dev/null @@ -1,45 +0,0 @@ -using MatroskaBatchFlow.Core.Enums; -using MatroskaBatchFlow.Core.Models; - -namespace MatroskaBatchFlow.Core.Services.FileProcessing.Track; - -/// -/// Assigns the Index property of each in the -/// " to match the StreamKindPos of tracks in the scanned file info. -/// -public class TrackPositionRule : IFileProcessingRule -{ - - /// - /// Assigns the Index property of each in the batch configuration. - /// - /// - /// For each supported , this method will assign the Index property of each - /// in the . - /// - /// The containing the scanned media file and its tracks. Must not be null. - /// The whose track configurations will be updated. Must not be null. - /// Thrown if or is null. - /// Thrown if a scanned track's StreamKindPos is missing or not a valid integer. - public void Apply(ScannedFileInfo scannedFile, IBatchConfiguration batchConfig) - { - ArgumentNullException.ThrowIfNull(scannedFile); - ArgumentNullException.ThrowIfNull(batchConfig); - - foreach (var trackType in Enum.GetValues().Where(t => t.IsMatroskaTrackElement())) - { - var scannedTracks = scannedFile.Result.Media.Track - .Where(t => t.Type == trackType) - .ToList(); - - var batchTracks = batchConfig.GetTrackListForType(trackType); - - for (int i = 0; i < batchTracks.Count && i < scannedTracks.Count; i++) - { - var streamKindID = scannedTracks[i].StreamKindID; - - batchTracks[i].Index = streamKindID; - } - } - } -} diff --git a/src/MatroskaBatchFlow.Core/Services/IBatchConfiguration.cs b/src/MatroskaBatchFlow.Core/Services/IBatchConfiguration.cs index 96155fe..46e22d2 100644 --- a/src/MatroskaBatchFlow.Core/Services/IBatchConfiguration.cs +++ b/src/MatroskaBatchFlow.Core/Services/IBatchConfiguration.cs @@ -9,9 +9,9 @@ namespace MatroskaBatchFlow.Core.Services; /// /// Defines the contract for batch configuration of media files. ///
-/// Note: The TrackConfiguration items in the global collections implement INotifyPropertyChanged, -/// so property changes within global tracks can be observed. -/// Per-file track values are stored as which carry no modification intent. +/// Track intents in the global collections implement INotifyPropertyChanged, +/// so property changes within tracks can be observed. +/// Per-file values are computed on demand via the transform pipeline at resolution time. ///
public interface IBatchConfiguration : INotifyPropertyChanged { @@ -24,15 +24,9 @@ public interface IBatchConfiguration : INotifyPropertyChanged bool ShouldModifyTrackStatisticsTags { get; set; } UniqueObservableCollection FileList { get; } - ObservableCollection AudioTracks { get; set; } - ObservableCollection VideoTracks { get; set; } - ObservableCollection SubtitleTracks { get; set; } - - /// - /// Per-file track configurations for flexible batch processing. - /// Key is the ScannedFileInfo.Id (Guid), not the file path. - /// - Dictionary FileConfigurations { get; set; } + ObservableCollection AudioTracks { get; set; } + ObservableCollection VideoTracks { get; set; } + ObservableCollection SubtitleTracks { get; set; } event EventHandler? StateChanged; @@ -42,33 +36,14 @@ public interface IBatchConfiguration : INotifyPropertyChanged public void Clear(); /// - /// Returns the list of objects for the specified . + /// Returns the list of objects for the specified . /// /// The track type. /// - /// The corresponding list of objects for the given track type. + /// The corresponding list of objects for the given track type. /// If the track type is not , , or , it returns an empty list. /// - public IList GetTrackListForType(TrackType trackType); - - /// - /// Gets the per-file track values for a specific file and track type. - /// These carry only scanned values (Name, Language, flags) — no modification intent. - /// Modification flags (ShouldModify*) live on the global objects returned by . - /// - /// The ScannedFileInfo.Id (Guid) of the file. - /// Type of track to retrieve. - /// List of per-file track values for the specified file and track type. - public IList GetTrackListForFile(Guid fileId, TrackType trackType); - - /// - /// Migrates file configuration from an old file ID to a new file ID. - /// Used when replacing a file with a re-scanned version that has a new Guid. - /// Preserves user's configuration while updating the file's metadata. - /// - /// The original file's unique identifier. - /// The new file's unique identifier. - public void MigrateFileConfiguration(Guid oldFileId, Guid newFileId); + public IList GetTrackListForType(TrackType trackType); /// /// Marks a file's metadata as stale (needs re-scanning). diff --git a/src/MatroskaBatchFlow.Core/Services/IBatchTrackConfigurationInitializer.cs b/src/MatroskaBatchFlow.Core/Services/IBatchTrackConfigurationInitializer.cs index cc54e6f..2ec7503 100644 --- a/src/MatroskaBatchFlow.Core/Services/IBatchTrackConfigurationInitializer.cs +++ b/src/MatroskaBatchFlow.Core/Services/IBatchTrackConfigurationInitializer.cs @@ -7,21 +7,17 @@ namespace MatroskaBatchFlow.Core.Services; /// Provides functionality to initialize the track configuration for files in a batch process. /// /// -/// This interface defines a method to initialize track configurations for scanned files, -/// creating per-file track configurations and updating global collections for UI display. +/// This interface defines a method to initialize track intents for scanned files, +/// expanding global track collections to reflect maximum track counts across all files. /// public interface IBatchTrackConfigurationInitializer { /// - /// Initializes track configurations for a scanned file based on its detected tracks. + /// Initializes track intents for a scanned file based on its detected tracks. /// /// - /// This method: - /// - /// Records track availability in - /// Creates file-specific track configurations in - /// Updates global track collections to reflect maximum track counts across all files - /// + /// This method expands global track collections to reflect maximum track counts across all files. + /// Per-file values are computed on demand via the transform pipeline at resolution time. /// /// The scanned file to initialize configurations for. /// The track types to initialize. If empty, no action is taken. diff --git a/src/MatroskaBatchFlow.Core/Services/ITrackConfigurationFactory.cs b/src/MatroskaBatchFlow.Core/Services/ITrackIntentFactory.cs similarity index 59% rename from src/MatroskaBatchFlow.Core/Services/ITrackConfigurationFactory.cs rename to src/MatroskaBatchFlow.Core/Services/ITrackIntentFactory.cs index 94be8e1..1008956 100644 --- a/src/MatroskaBatchFlow.Core/Services/ITrackConfigurationFactory.cs +++ b/src/MatroskaBatchFlow.Core/Services/ITrackIntentFactory.cs @@ -4,24 +4,24 @@ namespace MatroskaBatchFlow.Core.Services; /// -/// Creates track configurations from scanned track information. +/// Creates track intents from scanned track information. /// /// /// This factory is responsible for converting raw MediaInfo track data -/// into objects with properly resolved -/// language codes and initialized properties. +/// into objects with properly resolved +/// language codes and initialized property configs. /// -public interface ITrackConfigurationFactory +public interface ITrackIntentFactory { /// - /// Creates a track configuration from scanned track information. + /// Creates a track intent from scanned track information. /// /// The scanned track information from MediaInfo. /// The type of track (Audio, Video, Text). /// The zero-based index of the track. - /// A with properties initialized from the scanned track. - TrackConfiguration Create( + /// A with property configs initialized from the scanned track. + TrackIntent Create( MediaInfoResult.MediaInfo.TrackInfo scannedTrackInfo, TrackType trackType, int index); -} +} \ No newline at end of file diff --git a/src/MatroskaBatchFlow.Core/Services/MkvPropeditArgumentsGenerator.Logging.cs b/src/MatroskaBatchFlow.Core/Services/MkvPropeditArgumentsGenerator.Logging.cs index 6c9e6f5..f1241e2 100644 --- a/src/MatroskaBatchFlow.Core/Services/MkvPropeditArgumentsGenerator.Logging.cs +++ b/src/MatroskaBatchFlow.Core/Services/MkvPropeditArgumentsGenerator.Logging.cs @@ -8,7 +8,9 @@ namespace MatroskaBatchFlow.Core.Services; ///
public sealed partial class MkvPropeditArgumentsGenerator { - [LoggerMessage(Level = LogLevel.Warning, - Message = "Per-file {TrackType} track index {TrackIndex} exceeds global track count {GlobalTrackCount} for file: {FilePath}")] - private partial void LogPerFileTrackExceedsGlobalCount(string filePath, TrackType trackType, int trackIndex, int globalTrackCount); + [LoggerMessage(Level = LogLevel.Debug, Message = "Generated mkvpropedit arguments for {CommandCount} of {FileCount} file(s).")] + private partial void LogBatchArgumentsGenerated(int fileCount, int commandCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Skipping {TrackType} track index {TrackIndex} for file '{FilePath}' because the track does not exist.")] + private partial void LogTrackMissingInFile(string filePath, TrackType trackType, int trackIndex); } diff --git a/src/MatroskaBatchFlow.Core/Services/MkvPropeditArgumentsGenerator.cs b/src/MatroskaBatchFlow.Core/Services/MkvPropeditArgumentsGenerator.cs index f6a038e..a77b38b 100644 --- a/src/MatroskaBatchFlow.Core/Services/MkvPropeditArgumentsGenerator.cs +++ b/src/MatroskaBatchFlow.Core/Services/MkvPropeditArgumentsGenerator.cs @@ -24,6 +24,8 @@ public string[] BuildBatchArguments(IBatchConfiguration batchConfiguration) } } + LogBatchArgumentsGenerated(batchConfiguration.FileList.Count, results.Count); + return [.. results]; } @@ -40,8 +42,8 @@ public string BuildFileArgumentString(ScannedFileInfo file, IBatchConfiguration /// modification indicators. /// /// The scanned file whose path will be set as the mkvpropedit input. - /// Contains global title settings and per-file track configurations. - /// Token array suitable for joining. Returns an empty array if no modifications are requested + /// Contains global title settings and track intents. + /// Token array suitable for joining. Returns an empty array if no modifications are requested /// (to signal "no-op"). private string[] BuildFileArgumentTokens(ScannedFileInfo file, IBatchConfiguration batchConfiguration) { @@ -64,7 +66,6 @@ private string[] BuildFileArgumentTokens(ScannedFileInfo file, IBatchConfigurati } } - // Per-track modifications using file-specific configurations AddTracksForFile(builder, file, TrackType.Audio, batchConfiguration); AddTracksForFile(builder, file, TrackType.Video, batchConfiguration); AddTracksForFile(builder, file, TrackType.Text, batchConfiguration); @@ -82,18 +83,12 @@ private string[] BuildFileArgumentTokens(ScannedFileInfo file, IBatchConfigurati } /// - /// Adds track-specific modifications to the builder for a specific file, filtering out tracks that have - /// no requested changes or don't exist in the file. + /// Adds track-specific modifications to the builder for a specific file. /// - /// - /// Both the modification intent (ShouldModify*) and the actual values to write (Name, Language, - /// flags) are read from the global at the matching index. Per-file - /// are used only to determine which tracks exist in each file. - /// /// The accumulating mkvpropedit argument builder. /// The file being processed. /// The track type (must map to a Matroska track element). - /// The batch configuration containing track availability data. + /// The batch configuration containing track intents. private void AddTracksForFile( MkvPropeditArgumentsBuilder builder, ScannedFileInfo file, @@ -105,69 +100,56 @@ private void AddTracksForFile( return; } - // Per-file values (Name, Language, flags as scanned from this file). - var perFileTracks = batchConfig.GetTrackListForFile(file.Id, type); + var trackIntents = batchConfig.GetTrackListForType(type); - // Global tracks carry the modification intent (ShouldModify* flags). - var globalTracks = batchConfig.GetTrackListForType(type); + var scannedTracks = file.GetTracks(type); - foreach (var track in perFileTracks) + foreach (var intent in trackIntents) { - // Defensive: the initializer always expands global tracks to the maximum - // count, so this should not be true under normal operation. - if (track.Index >= globalTracks.Count) - { - LogPerFileTrackExceedsGlobalCount(file.Path, type, track.Index, globalTracks.Count); - continue; - } - - var globalTrack = globalTracks[track.Index]; - - // Skip inert tracks (no requested modifications). - if (!(globalTrack.ShouldModifyLanguage || - globalTrack.ShouldModifyName || - globalTrack.ShouldModifyDefaultFlag || - globalTrack.ShouldModifyForcedFlag || - globalTrack.ShouldModifyEnabledFlag)) + // Check if this track actually exists in the file + if (intent.Index < 0 || intent.Index >= scannedTracks.Count) { + LogTrackMissingInFile(file.Path, type, intent.Index); continue; } - // Check if this track actually exists in the file - if (!file.HasTrack(type, track.Index)) + if (!(intent.ShouldModifyLanguage || + intent.ShouldModifyName || + intent.ShouldModifyDefaultFlag || + intent.ShouldModifyForcedFlag || + intent.ShouldModifyEnabledFlag)) { - // Track doesn't exist in this file, skip gracefully continue; } builder.AddTrack(tb => { // Track ID converted to 1-based indexing for mkvpropedit conventions. - tb.SetTrackId(track.Index + 1).SetTrackType(type); + tb.SetTrackId(intent.Index + 1).SetTrackType(type); - if (globalTrack.ShouldModifyLanguage) + if (intent.ShouldModifyLanguage) { - tb.WithLanguage(globalTrack.Language.Code); + tb.WithLanguage(intent.Language.Code); } - if (globalTrack.ShouldModifyName) + if (intent.ShouldModifyName) { - tb.WithName(globalTrack.Name); + tb.WithName(intent.Name); } - if (globalTrack.ShouldModifyDefaultFlag) + if (intent.ShouldModifyDefaultFlag) { - tb.WithIsDefault(globalTrack.Default); + tb.WithIsDefault(intent.Default); } - if (globalTrack.ShouldModifyForcedFlag) + if (intent.ShouldModifyForcedFlag) { - tb.WithIsForced(globalTrack.Forced); + tb.WithIsForced(intent.Forced); } - if (globalTrack.ShouldModifyEnabledFlag) + if (intent.ShouldModifyEnabledFlag) { - tb.WithIsEnabled(globalTrack.Enabled); + tb.WithIsEnabled(intent.Enabled); } return tb; diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs index c61e421..ff10ad7 100644 --- a/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs @@ -7,7 +7,7 @@ namespace MatroskaBatchFlow.Core.Services.Pipeline; /// /// /// Stale files are files whose metadata may be outdated (e.g., modified externally since the last scan). -/// This stage performs a single batch re-scan operation, migrates existing configurations, and clears stale flags. +/// This stage performs a single batch re-scan operation, replaces stale file entries with fresh scans, and clears stale flags. /// If a re-scan fails for any file the error is logged and the stale flag is cleared to prevent repeated attempts. /// public sealed partial class RefreshStaleMetadataStage( @@ -46,7 +46,8 @@ public async Task ExecuteAsync(PipelineContext context, IProgress<(int current, if (freshScan != null) { - batchConfig.MigrateFileConfiguration(staleFile.Id, freshScan.Id); + // TODO: Refreshing the scanned file updates FileList but does not reconcile batch-global TrackIntent state. + // Re-running the structural initializer may be needed when refreshed metadata introduces new track slots, and any follow-up recomputation of derived defaults must avoid clobbering user edits. batchConfig.FileList.Remove(staleFile); batchConfig.FileList.Add(freshScan); batchConfig.ClearStaleFlag(staleFile.Id); diff --git a/src/MatroskaBatchFlow.Core/Services/Processing/FileProcessingOrchestrator.cs b/src/MatroskaBatchFlow.Core/Services/Processing/FileProcessingOrchestrator.cs index 042c93b..41b1424 100644 --- a/src/MatroskaBatchFlow.Core/Services/Processing/FileProcessingOrchestrator.cs +++ b/src/MatroskaBatchFlow.Core/Services/Processing/FileProcessingOrchestrator.cs @@ -60,6 +60,16 @@ private List EnrollFiles(IEnumerable scan /// public async Task ProcessFileAsync(FileProcessingReport fileReport, CancellationToken ct = default) + => await ProcessFileCoreAsync(fileReport, ct); + + /// + /// Processes a single file report. + /// + /// The file report being processed. + /// Cancellation token. + private async Task ProcessFileCoreAsync( + FileProcessingReport fileReport, + CancellationToken ct) { // If already running, return existing report. if (fileReport.Status == ProcessingStatus.Running) @@ -147,12 +157,19 @@ public async Task ProcessFileAsync(FileProcessingReport fi public async Task> ProcessAllAsync(IEnumerable scannedFiles, CancellationToken ct = default) { List reports = EnrollFiles(scannedFiles); + + // Skip timer and logging if there are no files to process. + if (reports.Count == 0) + { + return reports; + } + LogBatchStart(reports.Count); var stopwatch = Stopwatch.StartNew(); foreach (var report in reports) { - await ProcessFileAsync(report, ct); + await ProcessFileCoreAsync(report, ct); } stopwatch.Stop(); diff --git a/src/MatroskaBatchFlow.Core/Services/TrackIntent.cs b/src/MatroskaBatchFlow.Core/Services/TrackIntent.cs new file mode 100644 index 0000000..50e6fb7 --- /dev/null +++ b/src/MatroskaBatchFlow.Core/Services/TrackIntent.cs @@ -0,0 +1,230 @@ +using System.ComponentModel; +using MatroskaBatchFlow.Core.Enums; +using MatroskaBatchFlow.Core.Models; +using static MatroskaBatchFlow.Core.Models.MediaInfoResult.MediaInfo; + +namespace MatroskaBatchFlow.Core.Services; + +/// +/// Represents the user's intent for modifying a specific track across all files in the batch. +/// +/// The scanned track information from MediaInfo. Must not be null. +public sealed class TrackIntent(TrackInfo trackInfo) : INotifyPropertyChanged +{ + private TrackType _type; + private int _index; + private string _name = string.Empty; + private MatroskaLanguageOption _language = MatroskaLanguageOption.Undetermined; + private bool _default; + private bool _forced; + private bool _enabled = true; + private bool _shouldModifyName; + private bool _shouldModifyLanguage; + private bool _shouldModifyDefaultFlag; + private bool _shouldModifyForcedFlag; + private bool _shouldModifyEnabledFlag; + + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// The raw track information as returned by MediaInfo for this track position. + /// + public TrackInfo ScannedTrackInfo { get; init; } = trackInfo ?? throw new ArgumentNullException(nameof(trackInfo)); + + /// + /// The type of this track (Audio, Video, Text). + /// + public TrackType Type + { + get => _type; + set + { + if (_type != value) + { + _type = value; + OnPropertyChanged(nameof(Type)); + } + } + } + + /// + /// Zero-based index of this track within its type. + /// + public int Index + { + get => _index; + set + { + if (_index != value) + { + _index = value; + OnPropertyChanged(nameof(Index)); + } + } + } + + /// + /// The track name to apply when is enabled. + /// + public string Name + { + get => _name; + set + { + if (_name != value) + { + _name = value; + OnPropertyChanged(nameof(Name)); + } + } + } + + /// + /// The track language to apply when is enabled. + /// + public MatroskaLanguageOption Language + { + get => _language; + set + { + if (!EqualityComparer.Default.Equals(_language, value)) + { + _language = value; + OnPropertyChanged(nameof(Language)); + } + } + } + + /// + /// The default flag to apply when is enabled. + /// + public bool Default + { + get => _default; + set + { + if (_default != value) + { + _default = value; + OnPropertyChanged(nameof(Default)); + } + } + } + + /// + /// The forced flag to apply when is enabled. + /// + public bool Forced + { + get => _forced; + set + { + if (_forced != value) + { + _forced = value; + OnPropertyChanged(nameof(Forced)); + } + } + } + + /// + /// The enabled flag to apply when is enabled. + /// + public bool Enabled + { + get => _enabled; + set + { + if (_enabled != value) + { + _enabled = value; + OnPropertyChanged(nameof(Enabled)); + } + } + } + + /// + /// Whether the track name should be modified during batch processing. + /// + public bool ShouldModifyName + { + get => _shouldModifyName; + set + { + if (_shouldModifyName != value) + { + _shouldModifyName = value; + OnPropertyChanged(nameof(ShouldModifyName)); + } + } + } + + /// + /// Whether the track language should be modified during batch processing. + /// + public bool ShouldModifyLanguage + { + get => _shouldModifyLanguage; + set + { + if (_shouldModifyLanguage != value) + { + _shouldModifyLanguage = value; + OnPropertyChanged(nameof(ShouldModifyLanguage)); + } + } + } + + /// + /// Whether the default flag should be modified during batch processing. + /// + public bool ShouldModifyDefaultFlag + { + get => _shouldModifyDefaultFlag; + set + { + if (_shouldModifyDefaultFlag != value) + { + _shouldModifyDefaultFlag = value; + OnPropertyChanged(nameof(ShouldModifyDefaultFlag)); + } + } + } + + /// + /// Whether the forced flag should be modified during batch processing. + /// + public bool ShouldModifyForcedFlag + { + get => _shouldModifyForcedFlag; + set + { + if (_shouldModifyForcedFlag != value) + { + _shouldModifyForcedFlag = value; + OnPropertyChanged(nameof(ShouldModifyForcedFlag)); + } + } + } + + /// + /// Whether the enabled flag should be modified during batch processing. + /// + public bool ShouldModifyEnabledFlag + { + get => _shouldModifyEnabledFlag; + set + { + if (_shouldModifyEnabledFlag != value) + { + _shouldModifyEnabledFlag = value; + OnPropertyChanged(nameof(ShouldModifyEnabledFlag)); + } + } + } + + private void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/src/MatroskaBatchFlow.Core/Services/TrackConfigurationFactory.cs b/src/MatroskaBatchFlow.Core/Services/TrackIntentFactory.cs similarity index 55% rename from src/MatroskaBatchFlow.Core/Services/TrackConfigurationFactory.cs rename to src/MatroskaBatchFlow.Core/Services/TrackIntentFactory.cs index 977cf42..ea04625 100644 --- a/src/MatroskaBatchFlow.Core/Services/TrackConfigurationFactory.cs +++ b/src/MatroskaBatchFlow.Core/Services/TrackIntentFactory.cs @@ -1,30 +1,28 @@ using MatroskaBatchFlow.Core.Enums; -using MatroskaBatchFlow.Core.Models; +using static MatroskaBatchFlow.Core.Models.MediaInfoResult.MediaInfo; namespace MatroskaBatchFlow.Core.Services; /// -/// Creates track configurations from scanned track information. +/// Creates track intents from scanned track information. /// /// The language provider for resolving track language codes. -public class TrackConfigurationFactory(ILanguageProvider languageProvider) : ITrackConfigurationFactory +public class TrackIntentFactory(ILanguageProvider languageProvider) : ITrackIntentFactory { /// - public TrackConfiguration Create( - MediaInfoResult.MediaInfo.TrackInfo scannedTrackInfo, - TrackType trackType, - int index) + public TrackIntent Create(TrackInfo scannedTrackInfo, TrackType trackType, int index) { ArgumentNullException.ThrowIfNull(scannedTrackInfo); - return new TrackConfiguration(scannedTrackInfo) + return new TrackIntent(scannedTrackInfo) { Type = trackType, Index = index, Name = scannedTrackInfo.Title ?? string.Empty, Language = languageProvider.Resolve(scannedTrackInfo.Language), Default = scannedTrackInfo.Default, - Forced = scannedTrackInfo.Forced + Forced = scannedTrackInfo.Forced, + Enabled = true, }; } -} +} \ No newline at end of file diff --git a/src/MatroskaBatchFlow.Uno/Extensions/ServiceCollectionExtensions.cs b/src/MatroskaBatchFlow.Uno/Extensions/ServiceCollectionExtensions.cs index 203d465..bc99fde 100644 --- a/src/MatroskaBatchFlow.Uno/Extensions/ServiceCollectionExtensions.cs +++ b/src/MatroskaBatchFlow.Uno/Extensions/ServiceCollectionExtensions.cs @@ -50,7 +50,7 @@ public IServiceCollection AddCoreServices(LoggingLevelSwitch levelSwitch, ILoggi // Register application services services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -96,7 +96,6 @@ public IServiceCollection AddFileValidationRules() public IServiceCollection AddFileProcessingRules() { services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/MatroskaBatchFlow.Uno/Presentation/AudioPage.xaml b/src/MatroskaBatchFlow.Uno/Presentation/AudioPage.xaml index 72ebfe2..baaa795 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/AudioPage.xaml +++ b/src/MatroskaBatchFlow.Uno/Presentation/AudioPage.xaml @@ -43,7 +43,7 @@ IsEnabled="{x:Bind ViewModel.AudioTracks.Count, Converter={StaticResource GreaterThanZeroConverter}, Mode=OneWay}" PlaceholderText="{x:Bind ViewModel.AudioTracks, Converter={StaticResource EmptyStateToTextConverter}, ConverterParameter='No tracks|Select track'}"> - + diff --git a/src/MatroskaBatchFlow.Uno/Presentation/AudioPage.xaml.cs b/src/MatroskaBatchFlow.Uno/Presentation/AudioPage.xaml.cs index f862a56..a354f97 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/AudioPage.xaml.cs +++ b/src/MatroskaBatchFlow.Uno/Presentation/AudioPage.xaml.cs @@ -31,7 +31,7 @@ private void TrackAvailabilityText_Loaded(object sender, RoutedEventArgs e) private void TrackAvailabilityDot_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) { - if (sender is not Ellipse ellipse || ellipse.DataContext is not TrackConfiguration track) + if (sender is not Ellipse ellipse || ellipse.DataContext is not TrackIntent track) return; int trackIndex = track.Index; diff --git a/src/MatroskaBatchFlow.Uno/Presentation/AudioViewModel.cs b/src/MatroskaBatchFlow.Uno/Presentation/AudioViewModel.cs index 721fc07..69afb24 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/AudioViewModel.cs +++ b/src/MatroskaBatchFlow.Uno/Presentation/AudioViewModel.cs @@ -9,7 +9,7 @@ namespace MatroskaBatchFlow.Uno.Presentation; public partial class AudioViewModel : TrackViewModelBase { - public ObservableCollection AudioTracks + public ObservableCollection AudioTracks { get => _tracks; set @@ -39,7 +39,7 @@ public AudioViewModel(ILogger logger, ILanguageProvider language } /// - protected override IList GetTracks() => AudioTracks; + protected override IList GetTracks() => AudioTracks; /// protected override TrackType GetTrackType() => TrackType.Audio; @@ -78,16 +78,16 @@ private void OnFileListChanged(object? sender, NotifyCollectionChangedEventArgs /// performed and the items affected. private void OnBatchConfigurationAudioTracksChanged(object? sender, NotifyCollectionChangedEventArgs eventArgs) { - void Subscribe(IEnumerable? items) => + void Subscribe(IEnumerable? items) => items?.ToList().ForEach(t => t.PropertyChanged += OnTrackPropertyChanged); - void Unsubscribe(IEnumerable? items) => + void Unsubscribe(IEnumerable? items) => items?.ToList().ForEach(t => t.PropertyChanged -= OnTrackPropertyChanged); if (eventArgs.NewItems != null) - Subscribe(eventArgs.NewItems.Cast()); + Subscribe(eventArgs.NewItems.Cast()); if (eventArgs.OldItems != null) - Unsubscribe(eventArgs.OldItems.Cast()); + Unsubscribe(eventArgs.OldItems.Cast()); if (eventArgs.Action == NotifyCollectionChangedAction.Reset) { diff --git a/src/MatroskaBatchFlow.Uno/Presentation/Controls/TrackSettingsControl.xaml.cs b/src/MatroskaBatchFlow.Uno/Presentation/Controls/TrackSettingsControl.xaml.cs index 969b516..969b658 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/Controls/TrackSettingsControl.xaml.cs +++ b/src/MatroskaBatchFlow.Uno/Presentation/Controls/TrackSettingsControl.xaml.cs @@ -12,7 +12,7 @@ public TrackSettingsControl() public static readonly DependencyProperty SelectedTrackProperty = DependencyProperty.Register( nameof(SelectedTrack), - typeof(TrackConfiguration), + typeof(TrackIntent), typeof(TrackSettingsControl), new PropertyMetadata(null)); @@ -124,9 +124,9 @@ public string TrackName set => SetValue(TrackNameProperty, value); } - public TrackConfiguration SelectedTrack + public TrackIntent SelectedTrack { - get => (TrackConfiguration)GetValue(SelectedTrackProperty); + get => (TrackIntent)GetValue(SelectedTrackProperty); set => SetValue(SelectedTrackProperty, value); } diff --git a/src/MatroskaBatchFlow.Uno/Presentation/SubtitlePage.xaml b/src/MatroskaBatchFlow.Uno/Presentation/SubtitlePage.xaml index 90fb784..5ed17ad 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/SubtitlePage.xaml +++ b/src/MatroskaBatchFlow.Uno/Presentation/SubtitlePage.xaml @@ -43,7 +43,7 @@ IsEnabled="{x:Bind ViewModel.SubtitleTracks.Count, Converter={StaticResource GreaterThanZeroConverter}, Mode=OneWay}" PlaceholderText="{x:Bind ViewModel.SubtitleTracks, Converter={StaticResource EmptyStateToTextConverter}, ConverterParameter='No tracks|Select track'}"> - + diff --git a/src/MatroskaBatchFlow.Uno/Presentation/SubtitlePage.xaml.cs b/src/MatroskaBatchFlow.Uno/Presentation/SubtitlePage.xaml.cs index 4fa7ae0..b4010dd 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/SubtitlePage.xaml.cs +++ b/src/MatroskaBatchFlow.Uno/Presentation/SubtitlePage.xaml.cs @@ -31,7 +31,7 @@ private void TrackAvailabilityText_Loaded(object sender, RoutedEventArgs e) private void TrackAvailabilityDot_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) { - if (sender is not Ellipse ellipse || ellipse.DataContext is not TrackConfiguration track) + if (sender is not Ellipse ellipse || ellipse.DataContext is not TrackIntent track) return; int trackIndex = track.Index; diff --git a/src/MatroskaBatchFlow.Uno/Presentation/SubtitleViewModel.cs b/src/MatroskaBatchFlow.Uno/Presentation/SubtitleViewModel.cs index bce831b..99c9d3f 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/SubtitleViewModel.cs +++ b/src/MatroskaBatchFlow.Uno/Presentation/SubtitleViewModel.cs @@ -9,7 +9,7 @@ namespace MatroskaBatchFlow.Uno.Presentation; public partial class SubtitleViewModel : TrackViewModelBase { - public ObservableCollection SubtitleTracks + public ObservableCollection SubtitleTracks { get => _tracks; set @@ -39,7 +39,7 @@ public SubtitleViewModel(ILogger logger, ILanguageProvider la } /// - protected override IList GetTracks() => SubtitleTracks; + protected override IList GetTracks() => SubtitleTracks; /// protected override TrackType GetTrackType() => TrackType.Text; @@ -79,16 +79,16 @@ private void OnFileListChanged(object? sender, NotifyCollectionChangedEventArgs /// items. private void OnBatchConfigurationSubtitleTracksChanged(object? sender, NotifyCollectionChangedEventArgs eventArgs) { - void Subscribe(IEnumerable? items) => + void Subscribe(IEnumerable? items) => items?.ToList().ForEach(t => t.PropertyChanged += OnTrackPropertyChanged); - void Unsubscribe(IEnumerable? items) => + void Unsubscribe(IEnumerable? items) => items?.ToList().ForEach(t => t.PropertyChanged -= OnTrackPropertyChanged); if (eventArgs.NewItems != null) - Subscribe(eventArgs.NewItems.Cast()); + Subscribe(eventArgs.NewItems.Cast()); if (eventArgs.OldItems != null) - Unsubscribe(eventArgs.OldItems.Cast()); + Unsubscribe(eventArgs.OldItems.Cast()); // Handle the Reset action, which indicates that the entire collection has been replaced or cleared. if (eventArgs.Action == NotifyCollectionChangedAction.Reset) diff --git a/src/MatroskaBatchFlow.Uno/Presentation/TrackViewModelBase.cs b/src/MatroskaBatchFlow.Uno/Presentation/TrackViewModelBase.cs index 6bdf566..b2abab7 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/TrackViewModelBase.cs +++ b/src/MatroskaBatchFlow.Uno/Presentation/TrackViewModelBase.cs @@ -10,7 +10,7 @@ public abstract partial class TrackViewModelBase : ObservableObject { private readonly ILogger _logger; protected bool _suppressBatchConfigUpdate = false; - protected ObservableCollection _tracks = []; + protected ObservableCollection _tracks = []; protected ImmutableList _languages; @@ -27,14 +27,14 @@ public ImmutableList Languages } } - private TrackConfiguration? _selectedTrack; + private TrackIntent? _selectedTrack; - public TrackConfiguration? SelectedTrack + public TrackIntent? SelectedTrack { get => _selectedTrack; set { - if (!EqualityComparer.Default.Equals(_selectedTrack, value)) + if (!ReferenceEquals(_selectedTrack, value)) { _selectedTrack = value; OnPropertyChanged(nameof(SelectedTrack)); @@ -54,7 +54,7 @@ public bool IsDefaultTrack { _isDefaultTrack = value; OnPropertyChanged(nameof(IsDefaultTrack)); - UpdateBatchConfigTrackProperty(tc => tc.Default = value, ftv => ftv.Default = value); + UpdateTrackIntentProperty(intent => intent.Default = value); } } } @@ -70,7 +70,7 @@ public bool IsDefaultFlagModificationEnabled { _isDefaultFlagModificationEnabled = value; OnPropertyChanged(nameof(IsDefaultFlagModificationEnabled)); - UpdateGlobalModificationFlag(tc => tc.ShouldModifyDefaultFlag = value); + UpdateTrackIntentProperty(intent => intent.ShouldModifyDefaultFlag = value); } } } @@ -86,7 +86,7 @@ public bool IsEnabledTrack { _isEnabledTrack = value; OnPropertyChanged(nameof(IsEnabledTrack)); - UpdateBatchConfigTrackProperty(tc => tc.Enabled = value, ftv => ftv.Enabled = value); + UpdateTrackIntentProperty(intent => intent.Enabled = value); } } } @@ -102,7 +102,7 @@ public bool IsEnabledFlagModificationEnabled { _isEnabledFlagModificationEnabled = value; OnPropertyChanged(nameof(IsEnabledFlagModificationEnabled)); - UpdateGlobalModificationFlag(tc => tc.ShouldModifyEnabledFlag = value); + UpdateTrackIntentProperty(intent => intent.ShouldModifyEnabledFlag = value); } } } @@ -118,7 +118,7 @@ public bool IsForcedTrack { _isForcedTrack = value; OnPropertyChanged(nameof(IsForcedTrack)); - UpdateBatchConfigTrackProperty(tc => tc.Forced = value, ftv => ftv.Forced = value); + UpdateTrackIntentProperty(intent => intent.Forced = value); } } } @@ -134,7 +134,7 @@ public bool IsForcedFlagModificationEnabled { _isForcedFlagModificationEnabled = value; OnPropertyChanged(nameof(IsForcedFlagModificationEnabled)); - UpdateGlobalModificationFlag(tc => tc.ShouldModifyForcedFlag = value); + UpdateTrackIntentProperty(intent => intent.ShouldModifyForcedFlag = value); } } } @@ -150,7 +150,7 @@ public string TrackName { _trackName = value; OnPropertyChanged(nameof(TrackName)); - UpdateBatchConfigTrackProperty(tc => tc.Name = value, ftv => ftv.Name = value); + UpdateTrackIntentProperty(intent => intent.Name = value); } } } @@ -166,7 +166,7 @@ public bool IsTrackNameModificationEnabled { _isTrackNameModificationEnabled = value; OnPropertyChanged(nameof(IsTrackNameModificationEnabled)); - UpdateGlobalModificationFlag(tc => tc.ShouldModifyName = value); + UpdateTrackIntentProperty(intent => intent.ShouldModifyName = value); } } } @@ -191,7 +191,7 @@ public MatroskaLanguageOption? SelectedLanguage } MatroskaLanguageOption language = value ?? MatroskaLanguageOption.Undetermined; - UpdateBatchConfigTrackProperty(tc => tc.Language = language, ftv => ftv.Language = language); + UpdateTrackIntentProperty(intent => intent.Language = language); } } } @@ -207,7 +207,7 @@ public bool IsSelectedLanguageModificationEnabled { _isSelectedLanguageModificationEnabled = value; OnPropertyChanged(nameof(IsSelectedLanguageModificationEnabled)); - UpdateGlobalModificationFlag(tc => tc.ShouldModifyLanguage = value); + UpdateTrackIntentProperty(intent => intent.ShouldModifyLanguage = value); } } } @@ -289,11 +289,11 @@ public string GetTrackAvailabilityText(int trackIndex) } /// - /// Retrieves a collection of track configurations. + /// Retrieves a collection of track intents. /// - /// A list of objects representing the available tracks. + /// A list of objects representing the available tracks. /// If no tracks are available, an empty list is returned. - protected abstract IList GetTracks(); + protected abstract IList GetTracks(); /// /// Sets up event handlers for monitoring changes in the batch configuration and its specific tracks collection. @@ -309,12 +309,13 @@ public string GetTrackAvailabilityText(int trackIndex) protected abstract void OnBatchConfigurationChanged(object? sender, PropertyChangedEventArgs eventArgs); /// - /// Handles property change notifications for the selected track and updates corresponding properties. + /// Handles property change notifications from a track intent and re-applies the selected track's properties. /// - /// This method synchronizes the properties of the selected track with the associated UI state. - /// It suppresses batch configuration updates during the synchronization process to prevent potential - /// recursion. - /// The source of the property change event, typically the selected track. + /// + /// The current implementation re-applies all bound properties whenever the selected track changes. + /// This keeps the view state simple and avoids per-property synchronization code. + /// + /// The source of the property change event, typically a . /// The event data containing the name of the property that changed. protected virtual void OnTrackPropertyChanged(object? sender, PropertyChangedEventArgs eventArgs) { @@ -325,111 +326,29 @@ protected virtual void OnTrackPropertyChanged(object? sender, PropertyChangedEve if (!ReferenceEquals(sender, SelectedTrack)) return; - // Suppress batch configuration updates while synchronizing properties to avoid (potential) recursion. + // Re-apply all properties from the selected track. + // Suppress batch config updates to avoid write-back recursion. _suppressBatchConfigUpdate = true; - switch (eventArgs.PropertyName) + try { - case nameof(TrackConfiguration.Name): - TrackName = SelectedTrack.Name; - break; - case nameof(TrackConfiguration.Default): - IsDefaultTrack = SelectedTrack.Default; - break; - case nameof(TrackConfiguration.Forced): - IsForcedTrack = SelectedTrack.Forced; - break; - case nameof(TrackConfiguration.Enabled): - IsEnabledTrack = SelectedTrack.Enabled; - break; - case nameof(TrackConfiguration.Language): - SelectedLanguage = SelectedTrack.Language; - break; - case nameof(TrackConfiguration.ShouldModifyDefaultFlag): - IsDefaultFlagModificationEnabled = SelectedTrack.ShouldModifyDefaultFlag; - break; - case nameof(TrackConfiguration.ShouldModifyEnabledFlag): - IsEnabledFlagModificationEnabled = SelectedTrack.ShouldModifyEnabledFlag; - break; - case nameof(TrackConfiguration.ShouldModifyForcedFlag): - IsForcedFlagModificationEnabled = SelectedTrack.ShouldModifyForcedFlag; - break; - case nameof(TrackConfiguration.ShouldModifyName): - IsTrackNameModificationEnabled = SelectedTrack.ShouldModifyName; - break; - case nameof(TrackConfiguration.ShouldModifyLanguage): - IsSelectedLanguageModificationEnabled = SelectedTrack.ShouldModifyLanguage; - break; + ApplyTrackProperties(SelectedTrack); } - - _suppressBatchConfigUpdate = false; - } - - /// - /// Updates the properties of the currently selected track in the batch configuration using the provided update action. - /// - /// - /// This method applies the provided update actions to both: - /// - /// The global track collection (used for UI display) - triggers PropertyChanged which fires StateChanged - /// All per-file track value collections (used for command generation) - updated silently - /// - /// The global track update will trigger the StateChanged event through its PropertyChanged handler, - /// ensuring the UI and command generation stay synchronized. - /// If updates are suppressed or no track is selected, the method performs no operation. - /// - /// An delegate that defines the update to apply to the global track configuration. - /// An delegate that defines the update to apply to each per-file track value. - protected virtual void UpdateBatchConfigTrackProperty( - Action globalUpdateAction, - Action perFileUpdateAction) - { - // If suppressing updates, do nothing to avoid (potential) recursion. - if (_suppressBatchConfigUpdate) - return; - if (SelectedTrack == null || GetTracks() == null) - return; - - int index = SelectedTrack.Index; - var tracks = GetTracks(); - if (index < 0 || index >= tracks.Count) - return; - - // First, update all per-file value collections silently (without triggering events) - // This keeps per-file configurations in sync with the global track settings used for command generation - var trackType = SelectedTrack.Type; - foreach (var kvp in _batchConfiguration.FileConfigurations) + finally { - var fileConfig = kvp.Value; - var fileTracks = fileConfig.GetTrackListForType(trackType); - - // Only update if this file actually has this track - if (index >= 0 && index < fileTracks.Count) - { - perFileUpdateAction(fileTracks[index]); - } + _suppressBatchConfigUpdate = false; } - - // Finally, apply the update action to the global track configuration - // This will trigger PropertyChanged -> TrackConfiguration_PropertyChanged -> StateChanged - // which updates CanProcessBatch and regenerates commands - globalUpdateAction(tracks[index]); } /// - /// Updates only the global for a modification flag (e.g. ShouldModify*). - /// Per-file track values do not carry modification flags, so only the global track is updated. + /// Updates a property on the selected track intent, guarded by the suppression flag. /// - /// - /// If updates are suppressed or no track is selected, the method performs no operation. - /// - /// An delegate that sets the modification flag on the global track configuration. - protected virtual void UpdateGlobalModificationFlag(Action updateAction) + /// An delegate that updates the intent property. + protected virtual void UpdateTrackIntentProperty(Action updateAction) { - // If suppressing updates, do nothing to avoid (potential) recursion. if (_suppressBatchConfigUpdate) return; - if (SelectedTrack == null || GetTracks() == null) + if (SelectedTrack is null || GetTracks().Count == 0) return; int index = SelectedTrack.Index; @@ -437,16 +356,14 @@ protected virtual void UpdateGlobalModificationFlag(Action u if (index < 0 || index >= tracks.Count) return; - // Apply the update action to the global track configuration only - // This will trigger PropertyChanged -> TrackConfiguration_PropertyChanged -> StateChanged updateAction(tracks[index]); } /// /// Updates the bound view properties when the selected track changes to reflect the state of the newly selected track. /// - /// The newly selected , or if no track is currently selected. - protected virtual void OnSelectedTrackChanged(TrackConfiguration? newSelectedTrack) + /// The newly selected , or if no track is currently selected. + protected virtual void OnSelectedTrackChanged(TrackIntent? newSelectedTrack) { // Raise event to re-calculate IsTrackSelected OnPropertyChanged(nameof(IsTrackSelected)); @@ -455,19 +372,18 @@ protected virtual void OnSelectedTrackChanged(TrackConfiguration? newSelectedTra } /// - /// Updates the track-related properties based on the specified instance. + /// Updates the track-related properties based on the specified instance. /// - /// The instance containing the track properties to apply. If is , all track-related properties are reset to default values. - private void ApplyTrackProperties(TrackConfiguration? track) + /// The instance containing the track properties to apply. If is , all track-related properties are reset to default values. + private void ApplyTrackProperties(TrackIntent? intent) { - // Suppress batch config updates while synchronizing properties to avoid (potential) recursion. + // Suppress batch config updates while synchronizing properties to avoid recursion. _suppressBatchConfigUpdate = true; try { - // TODO: Need a better way to reset properties when no track is provided. - if (track is null) + if (intent is null) { IsDefaultTrack = false; IsEnabledTrack = true; @@ -483,17 +399,17 @@ private void ApplyTrackProperties(TrackConfiguration? track) return; } - // Synchronize properties with the selected track. - IsDefaultTrack = track.Default; - IsEnabledTrack = track.Enabled; - IsForcedTrack = track.Forced; - TrackName = track.Name; - SelectedLanguage = track.Language; - IsDefaultFlagModificationEnabled = track.ShouldModifyDefaultFlag; - IsEnabledFlagModificationEnabled = track.ShouldModifyEnabledFlag; - IsForcedFlagModificationEnabled = track.ShouldModifyForcedFlag; - IsTrackNameModificationEnabled = track.ShouldModifyName; - IsSelectedLanguageModificationEnabled = track.ShouldModifyLanguage; + // Synchronize properties with the selected track intent. + IsDefaultTrack = intent.Default; + IsEnabledTrack = intent.Enabled; + IsForcedTrack = intent.Forced; + TrackName = intent.Name; + SelectedLanguage = intent.Language; + IsDefaultFlagModificationEnabled = intent.ShouldModifyDefaultFlag; + IsEnabledFlagModificationEnabled = intent.ShouldModifyEnabledFlag; + IsForcedFlagModificationEnabled = intent.ShouldModifyForcedFlag; + IsTrackNameModificationEnabled = intent.ShouldModifyName; + IsSelectedLanguageModificationEnabled = intent.ShouldModifyLanguage; } finally { diff --git a/src/MatroskaBatchFlow.Uno/Presentation/VideoPage.xaml b/src/MatroskaBatchFlow.Uno/Presentation/VideoPage.xaml index 6912dc9..0346832 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/VideoPage.xaml +++ b/src/MatroskaBatchFlow.Uno/Presentation/VideoPage.xaml @@ -43,7 +43,7 @@ IsEnabled="{x:Bind ViewModel.VideoTracks.Count, Converter={StaticResource GreaterThanZeroConverter}, Mode=OneWay}" PlaceholderText="{x:Bind ViewModel.VideoTracks, Converter={StaticResource EmptyStateToTextConverter}, ConverterParameter='No tracks|Select track'}"> - + diff --git a/src/MatroskaBatchFlow.Uno/Presentation/VideoPage.xaml.cs b/src/MatroskaBatchFlow.Uno/Presentation/VideoPage.xaml.cs index aeb8699..d6248b7 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/VideoPage.xaml.cs +++ b/src/MatroskaBatchFlow.Uno/Presentation/VideoPage.xaml.cs @@ -31,7 +31,7 @@ private void TrackAvailabilityText_Loaded(object sender, RoutedEventArgs e) private void TrackAvailabilityDot_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) { - if (sender is not Ellipse ellipse || ellipse.DataContext is not TrackConfiguration track) + if (sender is not Ellipse ellipse || ellipse.DataContext is not TrackIntent track) return; int trackIndex = track.Index; diff --git a/src/MatroskaBatchFlow.Uno/Presentation/VideoViewModel.cs b/src/MatroskaBatchFlow.Uno/Presentation/VideoViewModel.cs index ebe4a16..4021e32 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/VideoViewModel.cs +++ b/src/MatroskaBatchFlow.Uno/Presentation/VideoViewModel.cs @@ -9,7 +9,7 @@ namespace MatroskaBatchFlow.Uno.Presentation; public partial class VideoViewModel : TrackViewModelBase { - public ObservableCollection VideoTracks + public ObservableCollection VideoTracks { get => _tracks; set @@ -39,7 +39,7 @@ public VideoViewModel(ILogger logger, ILanguageProvider language } /// - protected override IList GetTracks() => VideoTracks; + protected override IList GetTracks() => VideoTracks; /// protected override TrackType GetTrackType() => TrackType.Video; @@ -79,16 +79,16 @@ private void OnFileListChanged(object? sender, NotifyCollectionChangedEventArgs /// items. private void OnBatchConfigurationVideoTracksChanged(object? sender, NotifyCollectionChangedEventArgs eventArgs) { - void Subscribe(IEnumerable? items) => + void Subscribe(IEnumerable? items) => items?.ToList().ForEach(t => t.PropertyChanged += OnTrackPropertyChanged); - void Unsubscribe(IEnumerable? items) => + void Unsubscribe(IEnumerable? items) => items?.ToList().ForEach(t => t.PropertyChanged -= OnTrackPropertyChanged); if (eventArgs.NewItems != null) - Subscribe(eventArgs.NewItems.Cast()); + Subscribe(eventArgs.NewItems.Cast()); if (eventArgs.OldItems != null) - Unsubscribe(eventArgs.OldItems.Cast()); + Unsubscribe(eventArgs.OldItems.Cast()); // Handle the Reset action, which indicates that the entire collection has been replaced or cleared. if (eventArgs.Action == NotifyCollectionChangedAction.Reset) diff --git a/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs b/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs index 01c7441..21b14b0 100644 --- a/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs +++ b/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs @@ -44,8 +44,8 @@ await runner.RunAsync( filterDuplicatesStage, scanFilesStage, refreshStaleMetadataStage, - initTrackConfigStage, addFilesStage, + initTrackConfigStage, validateStage ], context); } diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Models/FileTrackConfigurationTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Models/FileTrackConfigurationTests.cs deleted file mode 100644 index d4c8cce..0000000 --- a/tests/MatroskaBatchFlow.Core.UnitTests/Models/FileTrackConfigurationTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using MatroskaBatchFlow.Core.Enums; -using MatroskaBatchFlow.Core.Models; - -namespace MatroskaBatchFlow.Core.UnitTests.Models; - -/// -/// Contains unit tests for the FileTrackConfiguration model. -/// -public class FileTrackConfigurationTests -{ - [Fact] - public void FileTrackConfiguration_InitializesWithEmptyCollections() - { - // Act - var config = new FileTrackConfiguration(); - - // Assert - Assert.NotNull(config.AudioTracks); - Assert.NotNull(config.VideoTracks); - Assert.NotNull(config.SubtitleTracks); - Assert.Empty(config.AudioTracks); - Assert.Empty(config.VideoTracks); - Assert.Empty(config.SubtitleTracks); - } - - [Fact] - public void FileTrackConfiguration_FilePathCanBeSet() - { - // Act - var config = new FileTrackConfiguration - { - FilePath = "test.mkv" - }; - - // Assert - Assert.Equal("test.mkv", config.FilePath); - } - - [Theory] - [InlineData(TrackType.Audio)] - [InlineData(TrackType.Video)] - [InlineData(TrackType.Text)] - public void GetTrackListForType_ReturnsCorrectCollection(TrackType trackType) - { - // Arrange - var config = new FileTrackConfiguration(); - var expectedList = trackType switch - { - TrackType.Audio => config.AudioTracks, - TrackType.Video => config.VideoTracks, - TrackType.Text => config.SubtitleTracks, - _ => throw new ArgumentException("Invalid track type") - }; - - // Act - var result = config.GetTrackListForType(trackType); - - // Assert - Assert.Same(expectedList, result); - } - - [Fact] - public void GetTrackListForType_ReturnsEmptyForGeneralType() - { - // Arrange - var config = new FileTrackConfiguration(); - - // Act - var result = config.GetTrackListForType(TrackType.General); - - // Assert - Assert.NotNull(result); - Assert.Empty(result); - } - - [Fact] - public void GetTrackListForType_ReturnsCorrectCollection_AudioTracksAreModifiable() - { - // Arrange - var config = new FileTrackConfiguration(); - var audioList = config.GetTrackListForType(TrackType.Audio); - - // Act - Verify we can modify the returned list - var initialCount = audioList.Count; - - // Assert - Assert.Same(config.AudioTracks, audioList); - Assert.Equal(0, initialCount); // Initially empty - } -} diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Models/ScannedFileInfoTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Models/ScannedFileInfoTests.cs new file mode 100644 index 0000000..9117b01 --- /dev/null +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Models/ScannedFileInfoTests.cs @@ -0,0 +1,33 @@ +using MatroskaBatchFlow.Core.Enums; +using MatroskaBatchFlow.Core.Models; +using MatroskaBatchFlow.Core.UnitTests.Builders; + +namespace MatroskaBatchFlow.Core.UnitTests.Models; + +public class ScannedFileInfoTests +{ + [Fact] + public void GetTracks_ReturnsTracksOrderedByStreamKindId() + { + // Arrange + var mediaInfoResult = new MediaInfoResultBuilder() + .WithCreatingLibrary() + .AddTrack(new TrackInfoBuilder().WithType(TrackType.Audio).WithStreamKindID(2).Build()) + .AddTrack(new TrackInfoBuilder().WithType(TrackType.Video).WithStreamKindID(0).Build()) + .AddTrack(new TrackInfoBuilder().WithType(TrackType.Audio).WithStreamKindID(0).Build()) + .AddTrack(new TrackInfoBuilder().WithType(TrackType.Audio).WithStreamKindID(1).Build()) + .Build(); + + var scannedFile = new ScannedFileInfo(mediaInfoResult, "file.mkv"); + + // Act + var audioTracks = scannedFile.GetTracks(TrackType.Audio); + + // Assert + Assert.Collection( + audioTracks, + track => Assert.Equal(0, track.StreamKindID), + track => Assert.Equal(1, track.StreamKindID), + track => Assert.Equal(2, track.StreamKindID)); + } +} \ No newline at end of file diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Services/BatchConfigurationTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Services/BatchConfigurationTests.cs index 956ee09..1e10c13 100644 --- a/tests/MatroskaBatchFlow.Core.UnitTests/Services/BatchConfigurationTests.cs +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Services/BatchConfigurationTests.cs @@ -45,9 +45,9 @@ public void Clear_WhenCalled_ResetsAllPropertiesAndTrackCollections() { DirectoryPath = "C:\\media", Title = "TestTitle", - AudioTracks = [new(audioTrackInfo) { Name = "A" }], - VideoTracks = [new(videoTrackInfo) { Name = "V" }], - SubtitleTracks = [new(subtitleTrackInfo) { Name = "S" }] + AudioTracks = [new(audioTrackInfo)], + VideoTracks = [new(videoTrackInfo)], + SubtitleTracks = [new(subtitleTrackInfo)] }; // Act @@ -212,150 +212,4 @@ public void IsFileStale_WhenFileNotMarked_ReturnsFalse() // Act & Assert Assert.False(config.IsFileStale(scannedFile.Id)); } - - [Fact] - public void MigrateFileConfiguration_WhenConfigurationExists_TransfersToNewId() - { - // Arrange - var config = new BatchConfiguration(_comparer, _logger); - var oldFileId = Guid.NewGuid(); - var newFileId = Guid.NewGuid(); - - // Manually create a file configuration (simulating what initializer does) - var fileConfig = new FileTrackConfiguration - { - FilePath = "test.mkv" - }; - config.FileConfigurations.Add(oldFileId, fileConfig); - - // Act - config.MigrateFileConfiguration(oldFileId, newFileId); - - // Assert - new ID should have configuration - Assert.True(config.FileConfigurations.ContainsKey(newFileId)); - Assert.Same(fileConfig, config.FileConfigurations[newFileId]); - - // Assert - old ID should be removed - Assert.False(config.FileConfigurations.ContainsKey(oldFileId)); - } - - [Fact] - public void MigrateFileConfiguration_WhenOldConfigurationDoesNotExist_DoesNothing() - { - // Arrange - var config = new BatchConfiguration(_comparer, _logger); - var oldFileId = Guid.NewGuid(); - var newFileId = Guid.NewGuid(); - - // Act - should not throw - config.MigrateFileConfiguration(oldFileId, newFileId); - - // Assert - new ID should not have configuration - Assert.False(config.FileConfigurations.ContainsKey(newFileId)); - } - - [Fact] - public void MigrateFileConfiguration_PreservesAllTrackTypes() - { - // Arrange - var config = new BatchConfiguration(_comparer, _logger); - var oldFileId = Guid.NewGuid(); - var newFileId = Guid.NewGuid(); - - // Create file configuration with all track types - var audioTrack = new FileTrackValues - { - ScannedTrackInfo = new MediaInfoResultBuilder() - .AddTrackOfType(TrackType.Audio) - .Build().Media.Track.First(t => t.Type == TrackType.Audio), - Type = TrackType.Audio, - Index = 0, - Default = false, - Forced = false, - Enabled = false - }; - var videoTrack = new FileTrackValues - { - ScannedTrackInfo = new MediaInfoResultBuilder() - .AddTrackOfType(TrackType.Video) - .Build().Media.Track.First(t => t.Type == TrackType.Video), - Type = TrackType.Video, - Index = 0, - Default = false, - Forced = false, - Enabled = false - }; - var subtitleTrack = new FileTrackValues - { - ScannedTrackInfo = new MediaInfoResultBuilder() - .AddTrackOfType(TrackType.Text) - .Build().Media.Track.First(t => t.Type == TrackType.Text), - Type = TrackType.Text, - Index = 0, - Default = false, - Forced = false, - Enabled = false - }; - - var fileConfig = new FileTrackConfiguration - { - FilePath = "test.mkv", - AudioTracks = [audioTrack], - VideoTracks = [videoTrack], - SubtitleTracks = [subtitleTrack] - }; - config.FileConfigurations.Add(oldFileId, fileConfig); - - // Act - config.MigrateFileConfiguration(oldFileId, newFileId); - - // Assert - all track types should be preserved - var migratedConfig = config.FileConfigurations[newFileId]; - Assert.Single(migratedConfig.AudioTracks); - Assert.Single(migratedConfig.VideoTracks); - Assert.Single(migratedConfig.SubtitleTracks); - Assert.Same(audioTrack, migratedConfig.AudioTracks[0]); - Assert.Same(videoTrack, migratedConfig.VideoTracks[0]); - Assert.Same(subtitleTrack, migratedConfig.SubtitleTracks[0]); - } - - [Fact] - public void MigrateFileConfiguration_PreservesUserModifications() - { - // Arrange - var config = new BatchConfiguration(_comparer, _logger); - var oldFileId = Guid.NewGuid(); - var newFileId = Guid.NewGuid(); - - // Create audio track with user-modified values - var audioTrack = new FileTrackValues - { - ScannedTrackInfo = new MediaInfoResultBuilder() - .AddTrackOfType(TrackType.Audio) - .Build().Media.Track.First(t => t.Type == TrackType.Audio), - Type = TrackType.Audio, - Index = 0, - Name = "Modified Track Name", - Default = true, - Forced = false, - Enabled = false - }; - - var fileConfig = new FileTrackConfiguration - { - FilePath = "test.mkv", - AudioTracks = [audioTrack] - }; - config.FileConfigurations.Add(oldFileId, fileConfig); - - // Act - config.MigrateFileConfiguration(oldFileId, newFileId); - - // Assert - user-modified values should be preserved after migration - var migratedConfig = config.FileConfigurations[newFileId]; - var migratedTrack = migratedConfig.AudioTracks[0]; - - Assert.Equal("Modified Track Name", migratedTrack.Name); - Assert.True(migratedTrack.Default); - } } diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Services/BatchTrackConfigurationInitializerTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Services/BatchTrackConfigurationInitializerTests.cs index b81fbe7..6059cc0 100644 --- a/tests/MatroskaBatchFlow.Core.UnitTests/Services/BatchTrackConfigurationInitializerTests.cs +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Services/BatchTrackConfigurationInitializerTests.cs @@ -10,8 +10,8 @@ namespace MatroskaBatchFlow.Core.UnitTests.Services; /// -/// Contains unit tests for the BatchTrackConfigurationInitializer class, verifying correct creation of -/// per-file track configurations based on scanned file information. +/// Contains unit tests for the BatchTrackConfigurationInitializer class, verifying correct expansion of +/// global track intent collections based on scanned file information. /// public class BatchTrackConfigurationInitializerTests { @@ -23,21 +23,18 @@ private static ILanguageProvider CreateMockLanguageProvider() return mockLanguageProvider; } - private static (IBatchConfiguration mockConfig, - Dictionary fileConfigs, - ObservableCollection audioTracks, - ObservableCollection videoTracks, - ObservableCollection subtitleTracks) CreateMockConfig() + private static (IBatchConfiguration mockConfig, + ObservableCollection audioTracks, + ObservableCollection videoTracks, + ObservableCollection subtitleTracks) CreateMockConfig() { var mockConfig = Substitute.For(); - var fileConfigs = new Dictionary(); - var audioTracks = new ObservableCollection(); - var videoTracks = new ObservableCollection(); - var subtitleTracks = new ObservableCollection(); + var audioTracks = new ObservableCollection(); + var videoTracks = new ObservableCollection(); + var subtitleTracks = new ObservableCollection(); var mockComparer = Substitute.For(); var fileList = new UniqueObservableCollection(mockComparer); - - mockConfig.FileConfigurations.Returns(fileConfigs); + mockConfig.AudioTracks.Returns(audioTracks); mockConfig.VideoTracks.Returns(videoTracks); mockConfig.SubtitleTracks.Returns(subtitleTracks); @@ -45,24 +42,24 @@ private static (IBatchConfiguration mockConfig, mockConfig.GetTrackListForType(TrackType.Audio).Returns(audioTracks); mockConfig.GetTrackListForType(TrackType.Video).Returns(videoTracks); mockConfig.GetTrackListForType(TrackType.Text).Returns(subtitleTracks); - - return (mockConfig, fileConfigs, audioTracks, videoTracks, subtitleTracks); + + return (mockConfig, audioTracks, videoTracks, subtitleTracks); } private static BatchTrackConfigurationInitializer CreateInitializer( - IBatchConfiguration batchConfig, + IBatchConfiguration batchConfig, ILanguageProvider? languageProvider = null) { languageProvider ??= CreateMockLanguageProvider(); - var trackConfigFactory = new TrackConfigurationFactory(languageProvider); - return new BatchTrackConfigurationInitializer(batchConfig, trackConfigFactory, languageProvider); + var trackIntentFactory = new TrackIntentFactory(languageProvider); + return new BatchTrackConfigurationInitializer(batchConfig, trackIntentFactory); } [Fact] - public void Initialize_CreatesPerFileConfiguration() + public void Initialize_PopulatesGlobalTracks() { // Arrange - var (mockConfig, fileConfigs, audioTracks, _, _) = CreateMockConfig(); + var (mockConfig, audioTracks, _, _) = CreateMockConfig(); var initializer = CreateInitializer(mockConfig); var mediaInfoResult = new MediaInfoResultBuilder() @@ -77,11 +74,7 @@ public void Initialize_CreatesPerFileConfiguration() // Act initializer.Initialize(scannedFile, TrackType.Audio); - // Assert - Verify per-file configuration was created - Assert.True(fileConfigs.ContainsKey(scannedFile.Id)); - Assert.Equal(3, fileConfigs[scannedFile.Id].AudioTracks.Count); - - // Verify global tracks were populated (first file) + // Assert - Global tracks should be populated Assert.Equal(3, audioTracks.Count); } @@ -89,7 +82,7 @@ public void Initialize_CreatesPerFileConfiguration() public void Initialize_PopulatesMultipleTrackTypes() { // Arrange - var (mockConfig, fileConfigs, audioTracks, videoTracks, subtitleTracks) = CreateMockConfig(); + var (mockConfig, audioTracks, videoTracks, subtitleTracks) = CreateMockConfig(); var initializer = CreateInitializer(mockConfig); var mediaInfoResult = new MediaInfoResultBuilder() @@ -106,17 +99,11 @@ public void Initialize_PopulatesMultipleTrackTypes() // Act initializer.Initialize(scannedFile, TrackType.Audio, TrackType.Video, TrackType.Text); - // Assert - var fileConfig = fileConfigs[scannedFile.Id]; - Assert.Equal(2, fileConfig.AudioTracks.Count); - Assert.Single(fileConfig.VideoTracks); - Assert.Equal(2, fileConfig.SubtitleTracks.Count); - - // Verify ScannedFileInfo has correct track counts + // Assert - Verify ScannedFileInfo has correct track counts Assert.Equal(2, scannedFile.AudioTrackCount); Assert.Equal(1, scannedFile.VideoTrackCount); Assert.Equal(2, scannedFile.SubtitleTrackCount); - + // Verify global tracks populated Assert.Equal(2, audioTracks.Count); Assert.Single(videoTracks); @@ -127,7 +114,7 @@ public void Initialize_PopulatesMultipleTrackTypes() public void Initialize_UpdatesGlobalTracksToMaximumCount() { // Arrange - var (mockConfig, fileConfigs, audioTracks, _, _) = CreateMockConfig(); + var (mockConfig, audioTracks, _, _) = CreateMockConfig(); var initializer = CreateInitializer(mockConfig); // First file - 2 audio tracks @@ -157,17 +144,13 @@ public void Initialize_UpdatesGlobalTracksToMaximumCount() // Assert - Global tracks should now be 3 (maximum across all files) Assert.Equal(3, audioTracks.Count); - - // Per-file configurations should be correct - Assert.Equal(2, fileConfigs[firstFile.Id].AudioTracks.Count); - Assert.Equal(3, fileConfigs[secondFile.Id].AudioTracks.Count); } [Fact] public void Initialize_HandlesEmptyTrackTypes() { // Arrange - var (mockConfig, fileConfigs, _, _, _) = CreateMockConfig(); + var (mockConfig, audioTracks, _, _) = CreateMockConfig(); var initializer = CreateInitializer(mockConfig); var mediaInfoResult = new MediaInfoResultBuilder() @@ -180,28 +163,28 @@ public void Initialize_HandlesEmptyTrackTypes() initializer.Initialize(scannedFile); // Assert - Nothing should be created (method returns early when trackTypes is empty) - Assert.False(fileConfigs.ContainsKey(scannedFile.Id)); + Assert.Empty(audioTracks); } [Fact] public void Initialize_NullScannedFile_DoesNothing() { // Arrange - var (mockConfig, fileConfigs, _, _, _) = CreateMockConfig(); + var (mockConfig, audioTracks, _, _) = CreateMockConfig(); var initializer = CreateInitializer(mockConfig); // Act initializer.Initialize(null!, TrackType.Audio); - // Assert - No entries created - Assert.Empty(fileConfigs); + // Assert - No tracks created + Assert.Empty(audioTracks); } [Fact] public void Initialize_NullResult_DoesNothing() { // Arrange - var (mockConfig, fileConfigs, _, _, _) = CreateMockConfig(); + var (mockConfig, audioTracks, _, _) = CreateMockConfig(); var initializer = CreateInitializer(mockConfig); var scannedFile = new ScannedFileInfo(null!, "file.mkv"); @@ -209,15 +192,15 @@ public void Initialize_NullResult_DoesNothing() // Act initializer.Initialize(scannedFile, TrackType.Audio); - // Assert - No entries created - Assert.Empty(fileConfigs); + // Assert - No tracks created + Assert.Empty(audioTracks); } [Fact] - public void Initialize_EmptyMediaInfo_CreatesConfigWithoutTracks() + public void Initialize_EmptyMediaInfo_DoesNotAddTracks() { // Arrange - var (mockConfig, fileConfigs, _, _, _) = CreateMockConfig(); + var (mockConfig, audioTracks, _, _) = CreateMockConfig(); var initializer = CreateInitializer(mockConfig); // Create a MediaInfoResult with creating library but no tracks @@ -229,17 +212,14 @@ public void Initialize_EmptyMediaInfo_CreatesConfigWithoutTracks() // Assert - ScannedFileInfo should have 0 audio tracks Assert.Equal(0, scannedFile.AudioTrackCount); - - // FileConfiguration should be created even with no tracks - Assert.True(fileConfigs.ContainsKey(scannedFile.Id)); - Assert.Empty(fileConfigs[scannedFile.Id].AudioTracks); + Assert.Empty(audioTracks); } [Fact] - public void Initialize_PerFileTracksAreFileTrackValuesWithScannedData() + public void Initialize_GlobalTracksHaveCorrectScannedTrackInfo() { // Arrange - var (mockConfig, fileConfigs, _, _, _) = CreateMockConfig(); + var (mockConfig, audioTracks, _, _) = CreateMockConfig(); var initializer = CreateInitializer(mockConfig); var mediaInfoResult = new MediaInfoResultBuilder() @@ -253,45 +233,40 @@ public void Initialize_PerFileTracksAreFileTrackValuesWithScannedData() // Act initializer.Initialize(scannedFile, TrackType.Audio); - // Assert - per-file tracks are FileTrackValues initialized from the scan - var fileConfig = fileConfigs[scannedFile.Id]; - Assert.Equal(2, fileConfig.AudioTracks.Count); + // Assert - global tracks have correct properties + Assert.Equal(2, audioTracks.Count); - Assert.Equal(TrackType.Audio, fileConfig.AudioTracks[0].Type); - Assert.Equal(0, fileConfig.AudioTracks[0].Index); - Assert.NotNull(fileConfig.AudioTracks[0].ScannedTrackInfo); + Assert.Equal(TrackType.Audio, audioTracks[0].Type); + Assert.Equal(0, audioTracks[0].Index); + Assert.NotNull(audioTracks[0].ScannedTrackInfo); - Assert.Equal(TrackType.Audio, fileConfig.AudioTracks[1].Type); - Assert.Equal(1, fileConfig.AudioTracks[1].Index); - Assert.NotNull(fileConfig.AudioTracks[1].ScannedTrackInfo); + Assert.Equal(TrackType.Audio, audioTracks[1].Type); + Assert.Equal(1, audioTracks[1].Index); + Assert.NotNull(audioTracks[1].ScannedTrackInfo); } [Fact] - public void Initialize_FilesAddedAfterUserConfigure_ReceiveFileTrackValues() + public void Initialize_DoesNotShrinkGlobalTracksWhenFewerTracksScanned() { - // Regression test for issue #85: files added after processing were skipped because - // per-file TrackConfiguration objects always had ShouldModify* = false. - // Now per-file tracks are FileTrackValues with no ShouldModify* fields, - // so the argument generator reads ShouldModify* from global tracks for every file. // Arrange - var (mockConfig, fileConfigs, audioTracks, _, _) = CreateMockConfig(); + var (mockConfig, audioTracks, _, _) = CreateMockConfig(); var initializer = CreateInitializer(mockConfig); + // First file - 3 audio tracks var firstFile = new ScannedFileInfo( new MediaInfoResultBuilder() .WithCreatingLibrary() .AddTrackOfType(TrackType.Audio) + .AddTrackOfType(TrackType.Audio) + .AddTrackOfType(TrackType.Audio) .Build(), "file1.mkv" ); mockConfig.FileList.Add(firstFile); initializer.Initialize(firstFile, TrackType.Audio); + Assert.Equal(3, audioTracks.Count); - // Simulate user enabling modifications on the global track - audioTracks[0].ShouldModifyLanguage = true; - audioTracks[0].ShouldModifyName = true; - - // Add a second file after modifications were configured (this was the bug scenario) + // Second file - only 1 audio track var secondFile = new ScannedFileInfo( new MediaInfoResultBuilder() .WithCreatingLibrary() @@ -300,19 +275,9 @@ public void Initialize_FilesAddedAfterUserConfigure_ReceiveFileTrackValues() "file2.mkv" ); mockConfig.FileList.Add(secondFile); - - // Act initializer.Initialize(secondFile, TrackType.Audio); - // Assert - second file receives FileTrackValues (not TrackConfiguration with ShouldModify* = false) - Assert.True(fileConfigs.ContainsKey(secondFile.Id)); - var secondFileConfig = fileConfigs[secondFile.Id]; - Assert.Single(secondFileConfig.AudioTracks); - Assert.Equal(TrackType.Audio, secondFileConfig.AudioTracks[0].Type); - Assert.Equal(0, secondFileConfig.AudioTracks[0].Index); - - // Global tracks still hold the ShouldModify* flags set by the user - Assert.True(audioTracks[0].ShouldModifyLanguage); - Assert.True(audioTracks[0].ShouldModifyName); + // Assert - Global tracks should still be 3 (never shrinks) + Assert.Equal(3, audioTracks.Count); } } diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileProcessing/Track/TrackLanguageRuleTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileProcessing/Track/TrackLanguageRuleTests.cs new file mode 100644 index 0000000..8e5fef7 --- /dev/null +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileProcessing/Track/TrackLanguageRuleTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Immutable; +using MatroskaBatchFlow.Core.Enums; +using MatroskaBatchFlow.Core.Models; +using MatroskaBatchFlow.Core.Services; +using MatroskaBatchFlow.Core.Services.FileProcessing.Track; +using MatroskaBatchFlow.Core.UnitTests.Builders; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace MatroskaBatchFlow.Core.UnitTests.Services.FileProcessing.Track; + +public class TrackLanguageRuleTests +{ + [Fact] + public void Apply_WhenAliasesRepresentSameLanguage_UsesNormalizedMajority() + { + // Arrange + var english = new MatroskaLanguageOption("English", "en", "eng", "eng", "eng"); + var japanese = new MatroskaLanguageOption("Japanese", "ja", "jpn", "jpn", "jpn"); + var batchConfig = CreateBatchConfiguration(); + var languageProvider = CreateLanguageProvider(english, japanese); + + var japaneseFile = CreateScannedFile("file1.mkv", "jpn"); + var englishBibliographicFile = CreateScannedFile("file2.mkv", "eng"); + var englishTwoLetterFile = CreateScannedFile("file3.mkv", "en"); + + AddAndInitializeTracks(batchConfig, languageProvider, japaneseFile, englishBibliographicFile, englishTwoLetterFile); + + var sut = new TrackLanguageRule(languageProvider); + + // Act + sut.Apply(japaneseFile, batchConfig); + + // Assert + Assert.Same(english, batchConfig.AudioTracks[0].Language); + } + + [Fact] + public void Apply_WhenNoLanguageHasMajority_ReturnsUndetermined() + { + // Arrange + var english = new MatroskaLanguageOption("English", "en", "eng", "eng", "eng"); + var japanese = new MatroskaLanguageOption("Japanese", "ja", "jpn", "jpn", "jpn"); + var batchConfig = CreateBatchConfiguration(); + var languageProvider = CreateLanguageProvider(english, japanese); + + var englishFile = CreateScannedFile("file1.mkv", "eng"); + var japaneseFile = CreateScannedFile("file2.mkv", "jpn"); + + AddAndInitializeTracks(batchConfig, languageProvider, englishFile, japaneseFile); + + var sut = new TrackLanguageRule(languageProvider); + + // Act + sut.Apply(englishFile, batchConfig); + + // Assert + Assert.Same(MatroskaLanguageOption.Undetermined, batchConfig.AudioTracks[0].Language); + } + + private static BatchConfiguration CreateBatchConfiguration() + { + var platformService = Substitute.For(); + platformService.IsWindows().Returns(true); + + var comparer = new ScannedFileInfoPathComparer(platformService); + var logger = Substitute.For>(); + + return new BatchConfiguration(comparer, logger); + } + + private static ILanguageProvider CreateLanguageProvider(MatroskaLanguageOption english, MatroskaLanguageOption japanese) + { + var languageProvider = Substitute.For(); + languageProvider.Languages.Returns(ImmutableList.Create(MatroskaLanguageOption.Undetermined, english, japanese)); + languageProvider.Resolve(Arg.Any()).Returns(callInfo => + { + var code = callInfo.Arg(); + if (string.IsNullOrWhiteSpace(code)) + { + return MatroskaLanguageOption.Undetermined; + } + + return code.Trim().ToLowerInvariant() switch + { + "en" or "eng" => english, + "ja" or "jpn" => japanese, + _ => MatroskaLanguageOption.Undetermined + }; + }); + + return languageProvider; + } + + private static void AddAndInitializeTracks(BatchConfiguration batchConfig, ILanguageProvider languageProvider, params ScannedFileInfo[] files) + { + var initializer = new BatchTrackConfigurationInitializer(batchConfig, new TrackIntentFactory(languageProvider)); + + foreach (var file in files) + { + batchConfig.FileList.Add(file); + initializer.Initialize(file, TrackType.Audio); + } + } + + private static ScannedFileInfo CreateScannedFile(string path, string languageCode) + { + var mediaInfoResult = new MediaInfoResultBuilder() + .WithCreatingLibrary() + .AddTrack(new TrackInfoBuilder() + .WithType(TrackType.Audio) + .WithStreamKindID(0) + .WithLanguage(languageCode) + .Build()) + .Build(); + + return new ScannedFileInfo(mediaInfoResult, path); + } +} \ No newline at end of file diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Services/MkvPropeditArgumentsGeneratorTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Services/MkvPropeditArgumentsGeneratorTests.cs new file mode 100644 index 0000000..f7a7e43 --- /dev/null +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Services/MkvPropeditArgumentsGeneratorTests.cs @@ -0,0 +1,138 @@ +using System.Collections.Immutable; +using MatroskaBatchFlow.Core.Enums; +using MatroskaBatchFlow.Core.Models; +using MatroskaBatchFlow.Core.Services; +using MatroskaBatchFlow.Core.UnitTests.Builders; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace MatroskaBatchFlow.Core.UnitTests.Services; + +public class MkvPropeditArgumentsGeneratorTests +{ + [Fact] + public void BuildBatchArguments_WhenNoPropertiesAreEnabled_ReturnsEmpty() + { + // Arrange + var batchConfig = CreateBatchConfiguration(); + var file = CreateScannedFile( + "C:\\media\\episode-01.mkv", + new TrackInfoBuilder().WithType(TrackType.Audio).WithStreamKindID(0).Build()); + + AddAndInitializeTracks(batchConfig, file); + + var sut = new MkvPropeditArgumentsGenerator(Substitute.For>()); + + // Act + var commands = sut.BuildBatchArguments(batchConfig); + + // Assert + Assert.Empty(commands); + } + + [Fact] + public void BuildBatchArguments_WhenSubtitleNameIsEnabled_GeneratesCommandOnlyForFilesWithThatTrack() + { + // Arrange + var batchConfig = CreateBatchConfiguration(); + + var fileWithSubtitle = CreateScannedFile( + "C:\\media\\with-subtitle.mkv", + new TrackInfoBuilder().WithType(TrackType.Text).WithStreamKindID(0).WithTitle("Original").Build()); + + var fileWithoutSubtitle = CreateScannedFile( + "C:\\media\\without-subtitle.mkv", + new TrackInfoBuilder().WithType(TrackType.Audio).WithStreamKindID(0).Build()); + + AddAndInitializeTracks(batchConfig, fileWithSubtitle, fileWithoutSubtitle); + + batchConfig.SubtitleTracks[0].ShouldModifyName = true; + batchConfig.SubtitleTracks[0].Name = "Renamed Subtitle"; + + var sut = new MkvPropeditArgumentsGenerator(Substitute.For>()); + + // Act + var commands = sut.BuildBatchArguments(batchConfig); + + // Assert + Assert.Single(commands); + Assert.Contains("with-subtitle.mkv", commands[0], StringComparison.Ordinal); + Assert.Contains("--edit track:s1", commands[0], StringComparison.Ordinal); + Assert.Contains("name=\"Renamed Subtitle\"", commands[0], StringComparison.Ordinal); + } + + [Fact] + public void BuildBatchArguments_WhenContainerAndTrackSettingsEnabled_IncludesAllExpectedDirectArguments() + { + // Arrange + var batchConfig = CreateBatchConfiguration(); + + var file = CreateScannedFile( + "C:\\media\\movie.mkv", + new TrackInfoBuilder().WithType(TrackType.Audio).WithStreamKindID(0).Build()); + + AddAndInitializeTracks(batchConfig, file); + + batchConfig.ShouldModifyTitle = true; + batchConfig.Title = "New Container Title"; + batchConfig.ShouldModifyTrackStatisticsTags = true; + batchConfig.AddTrackStatisticsTags = true; + batchConfig.DeleteTrackStatisticsTags = true; + + batchConfig.AudioTracks[0].ShouldModifyDefaultFlag = true; + batchConfig.AudioTracks[0].Default = true; + + var sut = new MkvPropeditArgumentsGenerator(Substitute.For>()); + + // Act + var commands = sut.BuildBatchArguments(batchConfig); + + // Assert + Assert.Single(commands); + Assert.Contains("--edit info", commands[0], StringComparison.Ordinal); + Assert.Contains("title=\"New Container Title\"", commands[0], StringComparison.Ordinal); + Assert.Contains("--add-track-statistics-tags", commands[0], StringComparison.Ordinal); + Assert.Contains("--delete-track-statistics-tags", commands[0], StringComparison.Ordinal); + Assert.Contains("--edit track:a1", commands[0], StringComparison.Ordinal); + Assert.Contains("flag-default=1", commands[0], StringComparison.Ordinal); + } + + private static BatchConfiguration CreateBatchConfiguration() + { + var platformService = Substitute.For(); + platformService.IsWindows().Returns(true); + + var comparer = new ScannedFileInfoPathComparer(platformService); + var logger = Substitute.For>(); + + return new BatchConfiguration(comparer, logger); + } + + private static void AddAndInitializeTracks(BatchConfiguration batchConfig, params ScannedFileInfo[] files) + { + var languageProvider = Substitute.For(); + languageProvider.Languages.Returns(ImmutableList.Empty); + languageProvider.Resolve(Arg.Any()).Returns(MatroskaLanguageOption.Undetermined); + + var initializer = new BatchTrackConfigurationInitializer( + batchConfig, + new TrackIntentFactory(languageProvider)); + + foreach (var file in files) + { + batchConfig.FileList.Add(file); + initializer.Initialize(file, TrackType.Audio, TrackType.Video, TrackType.Text); + } + } + + private static ScannedFileInfo CreateScannedFile(string path, params MediaInfoResult.MediaInfo.TrackInfo[] tracks) + { + var builder = new MediaInfoResultBuilder().WithCreatingLibrary(); + foreach (var track in tracks) + { + builder.AddTrack(track); + } + + return new ScannedFileInfo(builder.Build(), path); + } +} diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Services/Pipeline/RefreshStaleMetadataStageTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Services/Pipeline/RefreshStaleMetadataStageTests.cs new file mode 100644 index 0000000..5da6316 --- /dev/null +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Services/Pipeline/RefreshStaleMetadataStageTests.cs @@ -0,0 +1,152 @@ +using MatroskaBatchFlow.Core.Enums; +using MatroskaBatchFlow.Core.Models; +using MatroskaBatchFlow.Core.Services; +using MatroskaBatchFlow.Core.Services.Pipeline; +using MatroskaBatchFlow.Core.UnitTests.Builders; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace MatroskaBatchFlow.Core.UnitTests.Services.Pipeline; + +public class RefreshStaleMetadataStageTests +{ + [Fact] + public async Task ExecuteAsync_WhenNoFilesAreStale_DoesNotScan() + { + // Arrange + var fileScanner = Substitute.For(); + var batchConfig = CreateBatchConfiguration(); + var pathComparer = CreatePathComparer(); + var logger = Substitute.For>(); + + batchConfig.FileList.Add(CreateScannedFile("C:\\media\\episode-01.mkv", 1)); + + var stage = new RefreshStaleMetadataStage(fileScanner, batchConfig, pathComparer, logger); + + // Act + await stage.ExecuteAsync(new PipelineContext(), progress: null, CancellationToken.None); + + // Assert + await fileScanner.DidNotReceiveWithAnyArgs().ScanAsync(default!, default); + } + + [Fact] + public async Task ExecuteAsync_WhenFreshScanExists_ReplacesFileAndClearsStaleFlag() + { + // Arrange + var fileScanner = Substitute.For(); + var batchConfig = CreateBatchConfiguration(); + var pathComparer = CreatePathComparer(); + var logger = Substitute.For>(); + + var staleFile = CreateScannedFile("C:\\media\\episode-01.mkv", 1); + var freshScan = CreateScannedFile("C:\\media\\episode-01.mkv", 2); + + batchConfig.FileList.Add(staleFile); + batchConfig.MarkFileAsStale(staleFile.Id); + + fileScanner.ScanAsync(Arg.Any(), Arg.Any>()) + .Returns(new[] { freshScan }); + + var stage = new RefreshStaleMetadataStage(fileScanner, batchConfig, pathComparer, logger); + + // Act + await stage.ExecuteAsync(new PipelineContext(), progress: null, CancellationToken.None); + + // Assert + Assert.False(batchConfig.IsFileStale(staleFile.Id)); + Assert.Empty(batchConfig.GetStaleFiles()); + + var onlyFile = Assert.Single(batchConfig.FileList); + Assert.Equal(freshScan.Id, onlyFile.Id); + Assert.Equal(2, onlyFile.AudioTrackCount); + } + + [Fact] + public async Task ExecuteAsync_WhenFreshScanMissing_ClearsStaleFlagAndKeepsOriginalFile() + { + // Arrange + var fileScanner = Substitute.For(); + var batchConfig = CreateBatchConfiguration(); + var pathComparer = CreatePathComparer(); + var logger = Substitute.For>(); + + var staleFile = CreateScannedFile("C:\\media\\episode-01.mkv", 1); + + batchConfig.FileList.Add(staleFile); + batchConfig.MarkFileAsStale(staleFile.Id); + + fileScanner.ScanAsync(Arg.Any(), Arg.Any>()) + .Returns(Array.Empty()); + + var stage = new RefreshStaleMetadataStage(fileScanner, batchConfig, pathComparer, logger); + + // Act + await stage.ExecuteAsync(new PipelineContext(), progress: null, CancellationToken.None); + + // Assert + Assert.False(batchConfig.IsFileStale(staleFile.Id)); + + var onlyFile = Assert.Single(batchConfig.FileList); + Assert.Equal(staleFile.Id, onlyFile.Id); + } + + [Fact] + public async Task ExecuteAsync_WhenBatchRescanThrows_ClearsAllStaleFlagsWithoutThrowing() + { + // Arrange + var fileScanner = Substitute.For(); + var batchConfig = CreateBatchConfiguration(); + var pathComparer = CreatePathComparer(); + var logger = Substitute.For>(); + + var staleFile1 = CreateScannedFile("C:\\media\\episode-01.mkv", 1); + var staleFile2 = CreateScannedFile("C:\\media\\episode-02.mkv", 1); + + batchConfig.FileList.Add(staleFile1); + batchConfig.FileList.Add(staleFile2); + batchConfig.MarkFileAsStale(staleFile1.Id); + batchConfig.MarkFileAsStale(staleFile2.Id); + + fileScanner.ScanAsync(Arg.Any(), Arg.Any>()) + .Returns(_ => Task.FromException>(new IOException("Scan failed"))); + + var stage = new RefreshStaleMetadataStage(fileScanner, batchConfig, pathComparer, logger); + + // Act + var exception = await Record.ExceptionAsync(() => + stage.ExecuteAsync(new PipelineContext(), progress: null, CancellationToken.None)); + + // Assert + Assert.Null(exception); + Assert.False(batchConfig.IsFileStale(staleFile1.Id)); + Assert.False(batchConfig.IsFileStale(staleFile2.Id)); + Assert.Equal(2, batchConfig.FileList.Count); + } + + private static BatchConfiguration CreateBatchConfiguration() + { + var pathComparer = CreatePathComparer(); + var logger = Substitute.For>(); + return new BatchConfiguration(pathComparer, logger); + } + + private static IScannedFileInfoPathComparer CreatePathComparer() + { + var platformService = Substitute.For(); + platformService.IsWindows().Returns(true); + return new ScannedFileInfoPathComparer(platformService); + } + + private static ScannedFileInfo CreateScannedFile(string path, int audioTrackCount) + { + var builder = new MediaInfoResultBuilder().WithCreatingLibrary(); + + for (var i = 0; i < audioTrackCount; i++) + { + builder.AddTrackOfType(TrackType.Audio, i); + } + + return new ScannedFileInfo(builder.Build(), path); + } +} diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Services/Processing/FileProcessingOrchestratorTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Services/Processing/FileProcessingOrchestratorTests.cs index c014f3d..4dd541e 100644 --- a/tests/MatroskaBatchFlow.Core.UnitTests/Services/Processing/FileProcessingOrchestratorTests.cs +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Services/Processing/FileProcessingOrchestratorTests.cs @@ -27,7 +27,7 @@ public async Task ProcessFileAsync_ReturnsReport_WhenAlreadyRunning() var report = CreateFileReport(ProcessingStatus.Running); // Act - var result = await orchestrator.ProcessFileAsync(report); + var result = await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert Assert.Equal(ProcessingStatus.Running, result.Status); @@ -61,7 +61,7 @@ public async Task ProcessFileAsync_SetsRunningStatus_WhenProcessingStarts() .Returns(string.Empty); // Act - await orchestrator.ProcessFileAsync(report); + await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert Assert.NotNull(report.StartedAt); @@ -77,7 +77,7 @@ public async Task ProcessFileAsync_SkipsFile_WhenNoModificationsRequested() .Returns(string.Empty); // Act - var result = await orchestrator.ProcessFileAsync(report); + var result = await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert Assert.Equal(ProcessingStatus.Skipped, result.Status); @@ -94,7 +94,7 @@ public async Task ProcessFileAsync_SkipsFile_WhenArgumentsAreWhitespace() .Returns(" "); // Act - var result = await orchestrator.ProcessFileAsync(report); + var result = await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert Assert.Equal(ProcessingStatus.Skipped, result.Status); @@ -113,7 +113,7 @@ public async Task ProcessFileAsync_ExecutesMkvToolExecutor_WhenArgumentsProvided .Returns(CreateSuccessResult()); // Act - await orchestrator.ProcessFileAsync(report); + await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert await _mkvToolExecutor.Received(1).ExecuteAsync(args, Arg.Any()); @@ -131,7 +131,7 @@ public async Task ProcessFileAsync_SetsSucceededStatus_WhenExecutionSucceeds() .Returns(CreateSuccessResult()); // Act - var result = await orchestrator.ProcessFileAsync(report); + var result = await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert Assert.Equal(ProcessingStatus.Succeeded, result.Status); @@ -149,7 +149,7 @@ public async Task ProcessFileAsync_SetsSucceededWithWarningsStatus_WhenWarningsE .Returns(CreateWarningResult()); // Act - var result = await orchestrator.ProcessFileAsync(report); + var result = await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert Assert.Equal(ProcessingStatus.SucceededWithWarnings, result.Status); @@ -168,7 +168,7 @@ public async Task ProcessFileAsync_SetsFailedStatus_WhenExecutionFails() .Returns(CreateFailureResult()); // Act - var result = await orchestrator.ProcessFileAsync(report); + var result = await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert Assert.Equal(ProcessingStatus.Failed, result.Status); @@ -213,7 +213,7 @@ public async Task ProcessFileAsync_CapturesException_WhenExecutionThrows() .Returns(_ => throw new InvalidOperationException("Test error")); // Act - var result = await orchestrator.ProcessFileAsync(report); + var result = await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert Assert.Equal(ProcessingStatus.Failed, result.Status); @@ -240,7 +240,7 @@ public async Task ProcessFileAsync_SetsExecutedCommand_WhenExecuted() }); // Act - var result = await orchestrator.ProcessFileAsync(report); + var result = await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert Assert.Contains(executablePath, result.ExecutedCommand); @@ -263,7 +263,7 @@ public async Task ProcessFileAsync_SetsDuration_AfterExecution() }); // Act - var result = await orchestrator.ProcessFileAsync(report); + var result = await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result.Duration); @@ -282,7 +282,7 @@ public async Task ProcessFileAsync_SetsStartedAtAndFinishedAt() .Returns(CreateSuccessResult()); // Act - var result = await orchestrator.ProcessFileAsync(report); + var result = await orchestrator.ProcessFileAsync(report, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result.StartedAt); @@ -308,7 +308,7 @@ public async Task ProcessAllAsync_ProcessesAllFiles() .Returns(CreateSuccessResult()); // Act - var results = await orchestrator.ProcessAllAsync(files); + var results = await orchestrator.ProcessAllAsync(files, TestContext.Current.CancellationToken); // Assert Assert.Equal(3, results.Count); @@ -328,7 +328,7 @@ public async Task ProcessAllAsync_EnrollsFilesInActiveBatch() .Returns(CreateSuccessResult()); // Act - await orchestrator.ProcessAllAsync(files); + await orchestrator.ProcessAllAsync(files, TestContext.Current.CancellationToken); // Assert Assert.Single(activeBatch.FileReports); @@ -377,7 +377,7 @@ public async Task ProcessAllAsync_ReturnsEmptyList_WhenNoFilesProvided() _batchReportStore.ActiveBatch.Returns(activeBatch); // Act - var results = await orchestrator.ProcessAllAsync(Array.Empty()); + var results = await orchestrator.ProcessAllAsync(Array.Empty(), TestContext.Current.CancellationToken); // Assert Assert.Empty(results); @@ -404,7 +404,7 @@ public async Task ProcessAllAsync_ProcessesNewFilesEvenIfSamePathExists() .Returns(string.Empty); // Act - Pass file2 which has a different report ID - var results = await orchestrator.ProcessAllAsync(new[] { file2 }); + var results = await orchestrator.ProcessAllAsync(new[] { file2 }, TestContext.Current.CancellationToken); // Assert - Should create a new report since it's a different FileProcessingReport instance Assert.Single(results); diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Services/TrackConfigurationFactoryTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Services/TrackIntentFactoryTests.cs similarity index 84% rename from tests/MatroskaBatchFlow.Core.UnitTests/Services/TrackConfigurationFactoryTests.cs rename to tests/MatroskaBatchFlow.Core.UnitTests/Services/TrackIntentFactoryTests.cs index c0274f3..3dd27de 100644 --- a/tests/MatroskaBatchFlow.Core.UnitTests/Services/TrackConfigurationFactoryTests.cs +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Services/TrackIntentFactoryTests.cs @@ -7,14 +7,14 @@ namespace MatroskaBatchFlow.Core.UnitTests.Services; /// -/// Contains unit tests for the TrackConfigurationFactory class, verifying correct creation of -/// TrackConfiguration objects from scanned track information. +/// Contains unit tests for the TrackIntentFactory class, verifying correct creation of +/// TrackIntent objects from scanned track information. /// -public class TrackConfigurationFactoryTests +public class TrackIntentFactoryTests { private readonly ILanguageProvider _mockLanguageProvider = Substitute.For(); - public TrackConfigurationFactoryTests() + public TrackIntentFactoryTests() { // Default: any unmatched code resolves to Undetermined. _mockLanguageProvider.Resolve(Arg.Any()).Returns(MatroskaLanguageOption.Undetermined); @@ -24,7 +24,7 @@ public TrackConfigurationFactoryTests() public void Create_SetsBasicProperties() { // Arrange - var factory = new TrackConfigurationFactory(_mockLanguageProvider); + var factory = new TrackIntentFactory(_mockLanguageProvider); var trackInfo = new TrackInfoBuilder() .WithType(TrackType.Audio) .WithTitle("English Commentary") @@ -49,7 +49,7 @@ public void Create_SetsBasicProperties() public void Create_SetsEmptyNameWhenTitleIsNull() { // Arrange - var factory = new TrackConfigurationFactory(_mockLanguageProvider); + var factory = new TrackIntentFactory(_mockLanguageProvider); var trackInfo = new TrackInfoBuilder() .WithType(TrackType.Video) .WithStreamKindID(0) @@ -73,7 +73,7 @@ public void Create_DelegatesToResolveAndUsesResult() iso639_2_t: "eng", iso639_3: "eng"); _mockLanguageProvider.Resolve("eng").Returns(englishLanguage); - var factory = new TrackConfigurationFactory(_mockLanguageProvider); + var factory = new TrackIntentFactory(_mockLanguageProvider); var trackInfo = new TrackInfoBuilder() .WithType(TrackType.Audio) .WithLanguage("eng") @@ -98,7 +98,7 @@ public void Create_PassesLanguageCodeToResolve() iso639_2_t: "jpn", iso639_3: "jpn"); _mockLanguageProvider.Resolve("ja").Returns(japaneseLanguage); - var factory = new TrackConfigurationFactory(_mockLanguageProvider); + var factory = new TrackIntentFactory(_mockLanguageProvider); var trackInfo = new TrackInfoBuilder() .WithType(TrackType.Audio) .WithLanguage("ja") @@ -116,7 +116,7 @@ public void Create_PassesLanguageCodeToResolve() public void Create_ReturnsUndeterminedForUnknownLanguage() { // Arrange - var factory = new TrackConfigurationFactory(_mockLanguageProvider); + var factory = new TrackIntentFactory(_mockLanguageProvider); var trackInfo = new TrackInfoBuilder() .WithType(TrackType.Audio) .WithLanguage("xyz") @@ -134,7 +134,7 @@ public void Create_ReturnsUndeterminedForUnknownLanguage() public void Create_ReturnsUndeterminedForEmptyLanguage() { // Arrange - var factory = new TrackConfigurationFactory(_mockLanguageProvider); + var factory = new TrackIntentFactory(_mockLanguageProvider); var trackInfo = new TrackInfoBuilder() .WithType(TrackType.Audio) .WithLanguage(string.Empty) @@ -152,7 +152,7 @@ public void Create_ReturnsUndeterminedForEmptyLanguage() public void Create_ReturnsUndeterminedForWhitespaceLanguage() { // Arrange - var factory = new TrackConfigurationFactory(_mockLanguageProvider); + var factory = new TrackIntentFactory(_mockLanguageProvider); var trackInfo = new TrackInfoBuilder() .WithType(TrackType.Audio) .WithLanguage(" ") @@ -170,7 +170,7 @@ public void Create_ReturnsUndeterminedForWhitespaceLanguage() public void Create_ThrowsForNullTrackInfo() { // Arrange - var factory = new TrackConfigurationFactory(_mockLanguageProvider); + var factory = new TrackIntentFactory(_mockLanguageProvider); // Act & Assert Assert.Throws(() => factory.Create(null!, TrackType.Audio, 0)); diff --git a/tests/MatroskaBatchFlow.Uno.IntegrationTests/TrackModificationIntegrationTests.cs b/tests/MatroskaBatchFlow.Uno.IntegrationTests/TrackModificationIntegrationTests.cs index f180cf2..8cb96a9 100644 --- a/tests/MatroskaBatchFlow.Uno.IntegrationTests/TrackModificationIntegrationTests.cs +++ b/tests/MatroskaBatchFlow.Uno.IntegrationTests/TrackModificationIntegrationTests.cs @@ -60,9 +60,9 @@ public async Task IntegrationTest_EditingTrackOnlyInSecondFile_TriggersStateChan batchConfig.FileList.Add(file1); batchConfig.FileList.Add(file2); - // Initialize per-file configurations - var trackConfigFactory = new TrackConfigurationFactory(mockLanguageProvider); - var initializer = new BatchTrackConfigurationInitializer(batchConfig, trackConfigFactory, mockLanguageProvider); + // Initialize global track intents + var trackIntentFactory = new TrackIntentFactory(mockLanguageProvider); + var initializer = new BatchTrackConfigurationInitializer(batchConfig, trackIntentFactory); initializer.Initialize(file1, TrackType.Text); initializer.Initialize(file2, TrackType.Text); @@ -93,20 +93,13 @@ public async Task IntegrationTest_EditingTrackOnlyInSecondFile_TriggersStateChan await tcs.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); Assert.True(tcs.Task.IsCompleted, "StateChanged should fire when updating track name"); - // Verify global configuration was updated + // Verify global configuration was updated via TrackIntent properties Assert.Equal("Track17Modified", batchConfig.SubtitleTracks[16].Name); Assert.True(batchConfig.SubtitleTracks[16].ShouldModifyName); - // Verify per-file configurations - var file1Config = batchConfig.FileConfigurations[file1.Id]; - Assert.Single(file1Config.SubtitleTracks); - Assert.NotEqual("Track17Modified", file1Config.SubtitleTracks[0].Name); - - var file2Config = batchConfig.FileConfigurations[file2.Id]; - Assert.Equal(17, file2Config.SubtitleTracks.Count); - Assert.Equal("Track17Modified", file2Config.SubtitleTracks[16].Name); - // Verify command generation works correctly + // File1 has only 1 subtitle track, so track index 16 doesn't apply — no commands for file1 + // File2 has 17 subtitle tracks, so the modification applies to track 17 var argumentsGenerator = new MkvPropeditArgumentsGenerator(Substitute.For>()); var commands = argumentsGenerator.BuildBatchArguments(batchConfig); diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/AudioViewModelTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/AudioViewModelTests.cs index 0fc4f09..6880f3d 100644 --- a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/AudioViewModelTests.cs +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/AudioViewModelTests.cs @@ -27,9 +27,8 @@ public AudioViewModelTests() _logger = Substitute.For>(); _languageProvider.Languages.Returns([]); - _batchConfiguration.AudioTracks.Returns(new ObservableCollection()); + _batchConfiguration.AudioTracks.Returns(new ObservableCollection()); _batchConfiguration.FileList.Returns(new UniqueObservableCollection(Substitute.For())); - _batchConfiguration.FileConfigurations.Returns(new Dictionary()); } [Fact] @@ -42,10 +41,8 @@ public void Constructor_InitializesAudioTracksFromBatchConfiguration() .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Audio); - var audioTracks = new ObservableCollection - { - new TrackConfiguration(trackInfo) { Type = TrackType.Audio, Index = 0, Name = "Track 1" } - }; + var intent = new TrackIntent(trackInfo) { Type = TrackType.Audio, Index = 0, Name = "Track 1" }; + var audioTracks = new ObservableCollection { intent }; _batchConfiguration.AudioTracks.Returns(audioTracks); // Act @@ -66,10 +63,8 @@ public void Constructor_SetsSelectedTrackToFirstTrack() .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Audio); - var audioTracks = new ObservableCollection - { - new TrackConfiguration(trackInfo) { Type = TrackType.Audio, Index = 0, Name = "Track 1" } - }; + var intent = new TrackIntent(trackInfo) { Type = TrackType.Audio, Index = 0, Name = "Track 1" }; + var audioTracks = new ObservableCollection { intent }; _batchConfiguration.AudioTracks.Returns(audioTracks); // Act @@ -145,7 +140,7 @@ public void OnFileListChanged_UpdatesIsFileListPopulated() public void OnBatchConfigurationAudioTracksChanged_UpdatesAudioTracks() { // Arrange - var audioTracks = new ObservableCollection(); + var audioTracks = new ObservableCollection(); _batchConfiguration.AudioTracks.Returns(audioTracks); var viewModel = new AudioViewModel(_logger, _languageProvider, _batchConfiguration, _uiPreferences); @@ -156,7 +151,7 @@ public void OnBatchConfigurationAudioTracksChanged_UpdatesAudioTracks() .AddTrackOfType(TrackType.Audio) .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Audio); - var newTrack = new TrackConfiguration(trackInfo) { Type = TrackType.Audio, Index = 0, Name = "New Track" }; + var newTrack = new TrackIntent(trackInfo) { Type = TrackType.Audio, Index = 0, Name = "New Track" }; // Act audioTracks.Add(newTrack); @@ -169,7 +164,7 @@ public void OnBatchConfigurationAudioTracksChanged_UpdatesAudioTracks() public void OnBatchConfigurationChanged_UpdatesAudioTracksWhenAudioTracksPropertyChanges() { // Arrange - var initialTracks = new ObservableCollection(); + var initialTracks = new ObservableCollection(); _batchConfiguration.AudioTracks.Returns(initialTracks); var viewModel = new AudioViewModel(_logger, _languageProvider, _batchConfiguration, _uiPreferences); @@ -180,10 +175,8 @@ public void OnBatchConfigurationChanged_UpdatesAudioTracksWhenAudioTracksPropert .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Audio); - var newTracks = new ObservableCollection - { - new TrackConfiguration(trackInfo) { Type = TrackType.Audio, Index = 0, Name = "Track 1" } - }; + var intent = new TrackIntent(trackInfo) { Type = TrackType.Audio, Index = 0, Name = "Track 1" }; + var newTracks = new ObservableCollection { intent }; // Act _batchConfiguration.AudioTracks.Returns(newTracks); @@ -199,7 +192,7 @@ public void OnBatchConfigurationChanged_UpdatesAudioTracksWhenAudioTracksPropert public void OnBatchConfigurationChanged_DoesNotUpdateWhenOtherPropertiesChange() { // Arrange - var audioTracks = new ObservableCollection(); + var audioTracks = new ObservableCollection(); _batchConfiguration.AudioTracks.Returns(audioTracks); var viewModel = new AudioViewModel(_logger, _languageProvider, _batchConfiguration, _uiPreferences); diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/SubtitleViewModelTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/SubtitleViewModelTests.cs index ec7effd..1a27f09 100644 --- a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/SubtitleViewModelTests.cs +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/SubtitleViewModelTests.cs @@ -27,9 +27,8 @@ public SubtitleViewModelTests() _logger = Substitute.For>(); _languageProvider.Languages.Returns([]); - _batchConfiguration.SubtitleTracks.Returns(new ObservableCollection()); + _batchConfiguration.SubtitleTracks.Returns(new ObservableCollection()); _batchConfiguration.FileList.Returns(new UniqueObservableCollection(Substitute.For())); - _batchConfiguration.FileConfigurations.Returns(new Dictionary()); } [Fact] @@ -42,10 +41,8 @@ public void Constructor_InitializesSubtitleTracksFromBatchConfiguration() .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Text); - var subtitleTracks = new ObservableCollection - { - new TrackConfiguration(trackInfo) { Type = TrackType.Text, Index = 0, Name = "Track 1" } - }; + var intent = new TrackIntent(trackInfo) { Type = TrackType.Text, Index = 0, Name = "Track 1" }; + var subtitleTracks = new ObservableCollection { intent }; _batchConfiguration.SubtitleTracks.Returns(subtitleTracks); // Act @@ -66,10 +63,8 @@ public void Constructor_SetsSelectedTrackToFirstTrack() .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Text); - var subtitleTracks = new ObservableCollection - { - new TrackConfiguration(trackInfo) { Type = TrackType.Text, Index = 0, Name = "Track 1" } - }; + var intent = new TrackIntent(trackInfo) { Type = TrackType.Text, Index = 0, Name = "Track 1" }; + var subtitleTracks = new ObservableCollection { intent }; _batchConfiguration.SubtitleTracks.Returns(subtitleTracks); // Act @@ -145,7 +140,7 @@ public void OnFileListChanged_UpdatesIsFileListPopulated() public void OnBatchConfigurationSubtitleTracksChanged_UpdatesSubtitleTracks() { // Arrange - var subtitleTracks = new ObservableCollection(); + var subtitleTracks = new ObservableCollection(); _batchConfiguration.SubtitleTracks.Returns(subtitleTracks); var viewModel = new SubtitleViewModel(_logger, _languageProvider, _batchConfiguration, _uiPreferences); @@ -156,7 +151,7 @@ public void OnBatchConfigurationSubtitleTracksChanged_UpdatesSubtitleTracks() .AddTrackOfType(TrackType.Text) .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Text); - var newTrack = new TrackConfiguration(trackInfo) { Type = TrackType.Text, Index = 0, Name = "New Track" }; + var newTrack = new TrackIntent(trackInfo) { Type = TrackType.Text, Index = 0, Name = "New Track" }; // Act subtitleTracks.Add(newTrack); @@ -169,7 +164,7 @@ public void OnBatchConfigurationSubtitleTracksChanged_UpdatesSubtitleTracks() public void OnBatchConfigurationChanged_UpdatesSubtitleTracksWhenSubtitleTracksPropertyChanges() { // Arrange - var initialTracks = new ObservableCollection(); + var initialTracks = new ObservableCollection(); _batchConfiguration.SubtitleTracks.Returns(initialTracks); var viewModel = new SubtitleViewModel(_logger, _languageProvider, _batchConfiguration, _uiPreferences); @@ -180,10 +175,8 @@ public void OnBatchConfigurationChanged_UpdatesSubtitleTracksWhenSubtitleTracksP .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Text); - var newTracks = new ObservableCollection - { - new TrackConfiguration(trackInfo) { Type = TrackType.Text, Index = 0, Name = "Track 1" } - }; + var intent = new TrackIntent(trackInfo) { Type = TrackType.Text, Index = 0, Name = "Track 1" }; + var newTracks = new ObservableCollection { intent }; // Act _batchConfiguration.SubtitleTracks.Returns(newTracks); @@ -199,7 +192,7 @@ public void OnBatchConfigurationChanged_UpdatesSubtitleTracksWhenSubtitleTracksP public void OnBatchConfigurationChanged_DoesNotUpdateWhenOtherPropertiesChange() { // Arrange - var subtitleTracks = new ObservableCollection(); + var subtitleTracks = new ObservableCollection(); _batchConfiguration.SubtitleTracks.Returns(subtitleTracks); var viewModel = new SubtitleViewModel(_logger, _languageProvider, _batchConfiguration, _uiPreferences); diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/TrackViewModelBaseTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/TrackViewModelBaseTests.cs index 07b55a3..84a659b 100644 --- a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/TrackViewModelBaseTests.cs +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/TrackViewModelBaseTests.cs @@ -12,12 +12,9 @@ namespace MatroskaBatchFlow.Uno.UnitTests.Presentation; /// -/// Contains unit tests for the TrackViewModelBase class, verifying correct behavior of track property updates in batch -/// configurations. +/// Contains unit tests for the TrackViewModelBase class, verifying correct behavior of track property updates +/// against TrackIntent properties. /// -/// These tests ensure that changes to track properties in the view model are properly propagated to both -/// global and per-file configurations. The class uses a test-specific implementation of TrackViewModelBase to -/// facilitate testing scenarios. public class TrackViewModelBaseTests { private readonly ILogger _mockLogger = Substitute.For(); @@ -27,16 +24,16 @@ public class TrackViewModelBaseTests /// private class TestTrackViewModel : TrackViewModelBase { - private readonly IList _testTracks; + private readonly IList _testTracks; - public TestTrackViewModel(ILogger logger, ILanguageProvider languageProvider, IBatchConfiguration batchConfiguration, IUIPreferencesService uiPreferences, IList tracks) + public TestTrackViewModel(ILogger logger, ILanguageProvider languageProvider, IBatchConfiguration batchConfiguration, IUIPreferencesService uiPreferences, IList tracks) : base(logger, languageProvider, batchConfiguration, uiPreferences) { _testTracks = tracks; SetupEventHandlers(); } - protected override IList GetTracks() => _testTracks; + protected override IList GetTracks() => _testTracks; protected override TrackType GetTrackType() => TrackType.Text; @@ -52,114 +49,32 @@ protected override void OnBatchConfigurationChanged(object? sender, System.Compo } [Fact] - public void UpdateBatchConfigTrackProperty_UpdatesGlobalAndPerFileConfigurations() + public void TrackName_WhenChanged_UpdatesTrackIntentName() { // Arrange var mockLanguageProvider = Substitute.For(); var mockBatchConfig = Substitute.For(); var mockUIPreferences = Substitute.For(); - // Create test track info using builder var mediaInfoResult = new MediaInfoResultBuilder() .WithCreatingLibrary() .AddTrackOfType(TrackType.Text) .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Text); - // Set up global track collection - var globalTracks = new List - { - new TrackConfiguration(trackInfo) { Type = TrackType.Text, Index = 0, Name = "Original" } - }; - - // Set up per-file configurations - var file1 = new ScannedFileInfo(mediaInfoResult, "file1.mkv"); - var file2 = new ScannedFileInfo(mediaInfoResult, "file2.mkv"); - - var file1Config = new FileTrackConfiguration { FilePath = file1.Path }; - file1Config.SubtitleTracks.Add(new FileTrackValues { ScannedTrackInfo = trackInfo, Type = TrackType.Text, Index = 0, Name = "Original", Default = false, Forced = false, Enabled = false }); - - var file2Config = new FileTrackConfiguration { FilePath = file2.Path }; - file2Config.SubtitleTracks.Add(new FileTrackValues { ScannedTrackInfo = trackInfo, Type = TrackType.Text, Index = 0, Name = "Original", Default = false, Forced = false, Enabled = false }); - - var fileConfigurations = new Dictionary - { - { file1.Id, file1Config }, - { file2.Id, file2Config } - }; - - mockBatchConfig.FileConfigurations.Returns(fileConfigurations); - - // Create view model - var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, globalTracks); - - // Set the selected track - viewModel.SelectedTrack = globalTracks[0]; - - // Act - enable modification and update the track name - viewModel.IsTrackNameModificationEnabled = true; - viewModel.TrackName = "Updated Name"; - - // Assert - verify global track was updated - Assert.Equal("Updated Name", globalTracks[0].Name); - Assert.True(globalTracks[0].ShouldModifyName); - - // Assert - verify per-file configurations were also updated - Assert.Equal("Updated Name", file1Config.SubtitleTracks[0].Name); - - Assert.Equal("Updated Name", file2Config.SubtitleTracks[0].Name); - } - - [Fact] - public void UpdateBatchConfigTrackProperty_SkipsFilesWithoutTrack() - { - // Arrange - var mockLanguageProvider = Substitute.For(); - var mockBatchConfig = Substitute.For(); - var mockUIPreferences = Substitute.For(); - - var mediaInfoResult = new MediaInfoResultBuilder() - .WithCreatingLibrary() - .AddTrackOfType(TrackType.Text) - .Build(); - var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Text); - - // Global has track 0 - var globalTracks = new List - { - new TrackConfiguration(trackInfo) { Type = TrackType.Text, Index = 0, Name = "Original" } - }; - - // File1 has track 0 - var file1 = new ScannedFileInfo(mediaInfoResult, "file1.mkv"); - var file1Config = new FileTrackConfiguration { FilePath = file1.Path }; - file1Config.SubtitleTracks.Add(new FileTrackValues { ScannedTrackInfo = trackInfo, Type = TrackType.Text, Index = 0, Name = "Original", Default = false, Forced = false, Enabled = false }); - - // File2 has NO tracks (different track count) - var file2 = new ScannedFileInfo(mediaInfoResult, "file2.mkv"); - var file2Config = new FileTrackConfiguration { FilePath = file2.Path }; - // Intentionally empty subtitle tracks list - - var fileConfigurations = new Dictionary - { - { file1.Id, file1Config }, - { file2.Id, file2Config } - }; - - mockBatchConfig.FileConfigurations.Returns(fileConfigurations); + var intent = new TrackIntent(trackInfo) { Type = TrackType.Text, Index = 0, Name = "Original" }; + var globalTracks = new List { intent }; var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, globalTracks); viewModel.SelectedTrack = globalTracks[0]; - // Act - enable modification and update track name + // Act viewModel.IsTrackNameModificationEnabled = true; viewModel.TrackName = "Updated Name"; - // Assert - file1 should be updated - Assert.Equal("Updated Name", file1Config.SubtitleTracks[0].Name); - - // Assert - file2 should not crash (it has no tracks to update) - Assert.Empty(file2Config.SubtitleTracks); + // Assert + Assert.Equal("Updated Name", intent.Name); + Assert.True(intent.ShouldModifyName); } [Fact] @@ -176,12 +91,14 @@ public void SelectedTrack_WhenSetToNull_ResetsAllPropertiesToDefault() .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Text); - var globalTracks = new List + var intent = new TrackIntent(trackInfo) { - new TrackConfiguration(trackInfo) { Type = TrackType.Text, Index = 0, Name = "Test Track", Default = true } + Type = TrackType.Text, + Index = 0, + Name = "Test Track", + Default = true, }; - - mockBatchConfig.FileConfigurations.Returns(new Dictionary()); + var globalTracks = new List { intent }; var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, globalTracks); viewModel.SelectedTrack = globalTracks[0]; @@ -216,12 +133,8 @@ public void IsTrackSelected_ReturnsTrueWhenTrackIsSelected() .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Text); - var globalTracks = new List - { - new TrackConfiguration(trackInfo) { Type = TrackType.Text, Index = 0 } - }; - - mockBatchConfig.FileConfigurations.Returns(new Dictionary()); + var intent = new TrackIntent(trackInfo) { Type = TrackType.Text, Index = 0 }; + var globalTracks = new List { intent }; var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, globalTracks); @@ -240,13 +153,7 @@ public void IsTrackSelected_ReturnsFalseWhenNoTrackIsSelected() var mockBatchConfig = Substitute.For(); var mockUIPreferences = Substitute.For(); - var mediaInfoResult = new MediaInfoResultBuilder() - .WithCreatingLibrary() - .AddTrackOfType(TrackType.Text) - .Build(); - - var globalTracks = new List(); - mockBatchConfig.FileConfigurations.Returns(new Dictionary()); + var globalTracks = new List(); var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, globalTracks); @@ -255,7 +162,7 @@ public void IsTrackSelected_ReturnsFalseWhenNoTrackIsSelected() } [Fact] - public void IsDefaultTrack_UpdatesBatchConfiguration_WhenChanged() + public void IsDefaultTrack_WhenChanged_UpdatesTrackIntentDefaultFlag() { // Arrange var mockLanguageProvider = Substitute.For(); @@ -268,21 +175,8 @@ public void IsDefaultTrack_UpdatesBatchConfiguration_WhenChanged() .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Text); - var globalTracks = new List - { - new TrackConfiguration(trackInfo) { Type = TrackType.Text, Index = 0, Default = false } - }; - - var file1 = new ScannedFileInfo(mediaInfoResult, "file1.mkv"); - var file1Config = new FileTrackConfiguration { FilePath = file1.Path }; - file1Config.SubtitleTracks.Add(new FileTrackValues { ScannedTrackInfo = trackInfo, Type = TrackType.Text, Index = 0, Default = false, Forced = false, Enabled = false }); - - var fileConfigurations = new Dictionary - { - { file1.Id, file1Config } - }; - - mockBatchConfig.FileConfigurations.Returns(fileConfigurations); + var intent = new TrackIntent(trackInfo) { Type = TrackType.Text, Index = 0, Default = false }; + var globalTracks = new List { intent }; var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, globalTracks); viewModel.SelectedTrack = globalTracks[0]; @@ -291,12 +185,11 @@ public void IsDefaultTrack_UpdatesBatchConfiguration_WhenChanged() viewModel.IsDefaultTrack = true; // Assert - Assert.True(globalTracks[0].Default); - Assert.True(file1Config.SubtitleTracks[0].Default); + Assert.True(intent.Default); } [Fact] - public void IsForcedTrack_UpdatesBatchConfiguration_WhenChanged() + public void IsForcedTrack_WhenChanged_UpdatesTrackIntentForcedFlag() { // Arrange var mockLanguageProvider = Substitute.For(); @@ -309,21 +202,8 @@ public void IsForcedTrack_UpdatesBatchConfiguration_WhenChanged() .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Text); - var globalTracks = new List - { - new TrackConfiguration(trackInfo) { Type = TrackType.Text, Index = 0, Forced = false } - }; - - var file1 = new ScannedFileInfo(mediaInfoResult, "file1.mkv"); - var file1Config = new FileTrackConfiguration { FilePath = file1.Path }; - file1Config.SubtitleTracks.Add(new FileTrackValues { ScannedTrackInfo = trackInfo, Type = TrackType.Text, Index = 0, Default = false, Forced = false, Enabled = false }); - - var fileConfigurations = new Dictionary - { - { file1.Id, file1Config } - }; - - mockBatchConfig.FileConfigurations.Returns(fileConfigurations); + var intent = new TrackIntent(trackInfo) { Type = TrackType.Text, Index = 0, Forced = false }; + var globalTracks = new List { intent }; var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, globalTracks); viewModel.SelectedTrack = globalTracks[0]; @@ -332,12 +212,11 @@ public void IsForcedTrack_UpdatesBatchConfiguration_WhenChanged() viewModel.IsForcedTrack = true; // Assert - Assert.True(globalTracks[0].Forced); - Assert.True(file1Config.SubtitleTracks[0].Forced); + Assert.True(intent.Forced); } [Fact] - public void SelectedLanguage_UpdatesBatchConfiguration_WhenChanged() + public void SelectedLanguage_WhenChanged_UpdatesTrackIntentLanguage() { // Arrange var mockLanguageProvider = Substitute.For(); @@ -350,21 +229,13 @@ public void SelectedLanguage_UpdatesBatchConfiguration_WhenChanged() .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Text); - var globalTracks = new List + var intent = new TrackIntent(trackInfo) { - new TrackConfiguration(trackInfo) { Type = TrackType.Text, Index = 0, Language = MatroskaLanguageOption.Undetermined } + Type = TrackType.Text, + Index = 0, + Language = MatroskaLanguageOption.Undetermined, }; - - var file1 = new ScannedFileInfo(mediaInfoResult, "file1.mkv"); - var file1Config = new FileTrackConfiguration { FilePath = file1.Path }; - file1Config.SubtitleTracks.Add(new FileTrackValues { ScannedTrackInfo = trackInfo, Type = TrackType.Text, Index = 0, Language = MatroskaLanguageOption.Undetermined, Default = false, Forced = false, Enabled = false }); - - var fileConfigurations = new Dictionary - { - { file1.Id, file1Config } - }; - - mockBatchConfig.FileConfigurations.Returns(fileConfigurations); + var globalTracks = new List { intent }; var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, globalTracks); viewModel.SelectedTrack = globalTracks[0]; @@ -375,8 +246,7 @@ public void SelectedLanguage_UpdatesBatchConfiguration_WhenChanged() viewModel.SelectedLanguage = newLanguage; // Assert - Assert.Same(newLanguage, globalTracks[0].Language); - Assert.Same(newLanguage, file1Config.SubtitleTracks[0].Language); + Assert.Same(newLanguage, intent.Language); } [Fact] @@ -406,9 +276,8 @@ public void GetTrackAvailabilityCount_ReturnsCorrectCount() fileList.Add(file2); mockBatchConfig.FileList.Returns(fileList); - mockBatchConfig.FileConfigurations.Returns(new Dictionary()); - var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, new List()); + var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, new List()); // Act int count0 = viewModel.GetTrackAvailabilityCount(0); @@ -438,9 +307,8 @@ public void GetTrackAvailabilityText_ReturnsFormattedString() fileList.Add(file1); mockBatchConfig.FileList.Returns(fileList); - mockBatchConfig.FileConfigurations.Returns(new Dictionary()); - var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, new List()); + var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, new List()); // Act string result = viewModel.GetTrackAvailabilityText(0); @@ -459,9 +327,8 @@ public void TotalFileCount_ReturnsCorrectCount() var fileList = new UniqueObservableCollection(Substitute.For()); mockBatchConfig.FileList.Returns(fileList); - mockBatchConfig.FileConfigurations.Returns(new Dictionary()); - var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, new List()); + var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, new List()); // Act & Assert Assert.Equal(0, viewModel.TotalFileCount); @@ -476,10 +343,9 @@ public void ShowTrackAvailabilityText_ReflectsUIPreferencesValue() var mockUIPreferences = Substitute.For(); mockUIPreferences.ShowTrackAvailabilityText.Returns(true); - mockBatchConfig.FileConfigurations.Returns(new Dictionary()); // Act - var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, new List()); + var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, new List()); // Assert Assert.True(viewModel.ShowTrackAvailabilityText); @@ -494,9 +360,8 @@ public void UIPreferences_PropertyChanged_UpdatesShowTrackAvailabilityText() var mockUIPreferences = Substitute.For(); mockUIPreferences.ShowTrackAvailabilityText.Returns(false); - mockBatchConfig.FileConfigurations.Returns(new Dictionary()); - var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, new List()); + var viewModel = new TestTrackViewModel(_mockLogger, mockLanguageProvider, mockBatchConfig, mockUIPreferences, new List()); Assert.False(viewModel.ShowTrackAvailabilityText); // Act diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/VideoViewModelTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/VideoViewModelTests.cs index 70eba8f..472ff92 100644 --- a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/VideoViewModelTests.cs +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/VideoViewModelTests.cs @@ -27,9 +27,8 @@ public VideoViewModelTests() _logger = Substitute.For>(); _languageProvider.Languages.Returns([]); - _batchConfiguration.VideoTracks.Returns(new ObservableCollection()); + _batchConfiguration.VideoTracks.Returns(new ObservableCollection()); _batchConfiguration.FileList.Returns(new UniqueObservableCollection(Substitute.For())); - _batchConfiguration.FileConfigurations.Returns(new Dictionary()); } [Fact] @@ -42,10 +41,8 @@ public void Constructor_InitializesVideoTracksFromBatchConfiguration() .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Video); - var videoTracks = new ObservableCollection - { - new TrackConfiguration(trackInfo) { Type = TrackType.Video, Index = 0, Name = "Track 1" } - }; + var intent = new TrackIntent(trackInfo) { Type = TrackType.Video, Index = 0, Name = "Track 1" }; + var videoTracks = new ObservableCollection { intent }; _batchConfiguration.VideoTracks.Returns(videoTracks); // Act @@ -66,10 +63,8 @@ public void Constructor_SetsSelectedTrackToFirstTrack() .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Video); - var videoTracks = new ObservableCollection - { - new TrackConfiguration(trackInfo) { Type = TrackType.Video, Index = 0, Name = "Track 1" } - }; + var intent = new TrackIntent(trackInfo) { Type = TrackType.Video, Index = 0, Name = "Track 1" }; + var videoTracks = new ObservableCollection { intent }; _batchConfiguration.VideoTracks.Returns(videoTracks); // Act @@ -145,7 +140,7 @@ public void OnFileListChanged_UpdatesIsFileListPopulated() public void OnBatchConfigurationVideoTracksChanged_UpdatesVideoTracks() { // Arrange - var videoTracks = new ObservableCollection(); + var videoTracks = new ObservableCollection(); _batchConfiguration.VideoTracks.Returns(videoTracks); var viewModel = new VideoViewModel(_logger, _languageProvider, _batchConfiguration, _uiPreferences); @@ -156,7 +151,7 @@ public void OnBatchConfigurationVideoTracksChanged_UpdatesVideoTracks() .AddTrackOfType(TrackType.Video) .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Video); - var newTrack = new TrackConfiguration(trackInfo) { Type = TrackType.Video, Index = 0, Name = "New Track" }; + var newTrack = new TrackIntent(trackInfo) { Type = TrackType.Video, Index = 0, Name = "New Track" }; // Act videoTracks.Add(newTrack); @@ -169,7 +164,7 @@ public void OnBatchConfigurationVideoTracksChanged_UpdatesVideoTracks() public void OnBatchConfigurationChanged_UpdatesVideoTracksWhenVideoTracksPropertyChanges() { // Arrange - var initialTracks = new ObservableCollection(); + var initialTracks = new ObservableCollection(); _batchConfiguration.VideoTracks.Returns(initialTracks); var viewModel = new VideoViewModel(_logger, _languageProvider, _batchConfiguration, _uiPreferences); @@ -180,10 +175,8 @@ public void OnBatchConfigurationChanged_UpdatesVideoTracksWhenVideoTracksPropert .Build(); var trackInfo = mediaInfoResult.Media.Track.First(t => t.Type == TrackType.Video); - var newTracks = new ObservableCollection - { - new TrackConfiguration(trackInfo) { Type = TrackType.Video, Index = 0, Name = "Track 1" } - }; + var intent = new TrackIntent(trackInfo) { Type = TrackType.Video, Index = 0, Name = "Track 1" }; + var newTracks = new ObservableCollection { intent }; // Act _batchConfiguration.VideoTracks.Returns(newTracks); @@ -199,7 +192,7 @@ public void OnBatchConfigurationChanged_UpdatesVideoTracksWhenVideoTracksPropert public void OnBatchConfigurationChanged_DoesNotUpdateWhenOtherPropertiesChange() { // Arrange - var videoTracks = new ObservableCollection(); + var videoTracks = new ObservableCollection(); _batchConfiguration.VideoTracks.Returns(videoTracks); var viewModel = new VideoViewModel(_logger, _languageProvider, _batchConfiguration, _uiPreferences); diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs index 731c563..ebbbfbc 100644 --- a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs @@ -2,6 +2,7 @@ using MatroskaBatchFlow.Core.Models; using MatroskaBatchFlow.Core.Services; using MatroskaBatchFlow.Core.Services.FileProcessing; +using MatroskaBatchFlow.Core.Services.FileProcessing.Track; using MatroskaBatchFlow.Core.Services.FileValidation; using MatroskaBatchFlow.Core.Services.Pipeline; using MatroskaBatchFlow.Core.UnitTests.Builders; @@ -94,8 +95,8 @@ await _pipelineRunner.RunAsync( Assert.Same(_filterDuplicatesStage, capturedStages[0]); Assert.Same(_scanFilesStage, capturedStages[1]); Assert.Same(_refreshStaleMetadataStage, capturedStages[2]); - Assert.Same(_initTrackConfigStage, capturedStages[3]); - Assert.Same(_addFilesStage, capturedStages[4]); + Assert.Same(_addFilesStage, capturedStages[3]); + Assert.Same(_initTrackConfigStage, capturedStages[4]); Assert.Same(_validateStage, capturedStages[5]); } finally @@ -104,6 +105,108 @@ await _pipelineRunner.RunAsync( } } + [Fact] + public async Task ImportFilesAsync_AddsFilesBeforeApplyingTrackConfiguration() + { + IReadOnlyList? capturedStages = null; + await _pipelineRunner.RunAsync( + Arg.Do>(s => capturedStages = s), + Arg.Any(), + Arg.Any()); + + var testFile = Path.GetTempFileName(); + try + { + var orchestrator = CreateOrchestrator(); + await orchestrator.ImportFilesAsync([new FileInfo(testFile)]); + + Assert.NotNull(capturedStages); + + var addFilesIndex = capturedStages.IndexOf(_addFilesStage); + var initializeIndex = capturedStages.IndexOf(_initTrackConfigStage); + + Assert.True(addFilesIndex >= 0, "AddFilesToBatchStage should be part of the import pipeline."); + Assert.True(initializeIndex >= 0, "InitializeTrackConfigStage should be part of the import pipeline."); + Assert.True( + addFilesIndex < initializeIndex, + "Imported files must be added to the batch before track initialization runs so aggregate defaults can see the full batch."); + } + finally + { + File.Delete(testFile); + } + } + + [Fact] + public async Task ImportFilesAsync_WhenImportedFilesDisagreeOnDefaultFlag_UsesAllImportedFilesForDerivedDefault() + { + var platformService = Substitute.For(); + platformService.IsWindows().Returns(true); + + var pathComparer = new ScannedFileInfoPathComparer(platformService); + var batchConfig = new BatchConfiguration(pathComparer, Substitute.For>()); + var fileListAdapter = new FileListAdapter(batchConfig, Substitute.For>()); + + var fileScanner = Substitute.For(); + var languageProvider = Substitute.For(); + languageProvider.Resolve(Arg.Any()).Returns(MatroskaLanguageOption.Undetermined); + + var validationStateService = Substitute.For(); + validationStateService.RevalidateAsync().Returns(Task.CompletedTask); + + var firstPath = Path.GetTempFileName(); + var secondPath = Path.GetTempFileName(); + + try + { + var firstScannedFile = CreateScannedFile(firstPath, audioDefault: true); + var secondScannedFile = CreateScannedFile(secondPath, audioDefault: false); + + var scannedFilesByPath = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [firstPath] = firstScannedFile, + [secondPath] = secondScannedFile, + }; + + fileScanner.ScanAsync(Arg.Any(), Arg.Any>()) + .Returns(callInfo => + { + var files = callInfo.Arg(); + return files.Select(file => scannedFilesByPath[file.FullName]).ToList(); + }); + + var orchestrator = new BatchOperationOrchestrator( + new ExecutingPipelineRunner(), + new FilterDuplicateFilesStage(batchConfig, platformService, Substitute.For>()), + new ScanFilesStage(fileScanner, Substitute.For>()), + new RefreshStaleMetadataStage(fileScanner, batchConfig, pathComparer, Substitute.For>()), + new InitializeTrackConfigStage( + new BatchTrackConfigurationInitializer(batchConfig, new TrackIntentFactory(languageProvider)), + new FileProcessingEngine([new TrackDefaultRule()]), + batchConfig), + new AddFilesToBatchStage(fileListAdapter), + new RemoveFilesFromBatchStage(fileListAdapter), + new ValidateStage(validationStateService), + Substitute.For>()); + + await orchestrator.ImportFilesAsync([new FileInfo(firstPath), new FileInfo(secondPath)]); + + Assert.Equal(2, batchConfig.FileList.Count); + + var audioTrack = Assert.Single(batchConfig.AudioTracks); + + Assert.True(firstScannedFile.GetTracks(TrackType.Audio)[0].Default); + Assert.False(secondScannedFile.GetTracks(TrackType.Audio)[0].Default); + Assert.False(audioTrack.Default); + } + finally + { + File.Delete(firstPath); + File.Delete(secondPath); + fileListAdapter.Dispose(); + } + } + [Fact] public async Task ImportFilesAsync_SetsInputFilesInContext() { @@ -217,11 +320,34 @@ private BatchOperationOrchestrator CreateOrchestrator() _logger); } - private static ScannedFileInfo CreateScannedFile(string path) + private sealed class ExecutingPipelineRunner : IPipelineRunner + { + public async Task RunAsync(IReadOnlyList stages, PipelineContext context, CancellationToken ct = default) + { + foreach (var stage in stages) + { + ct.ThrowIfCancellationRequested(); + + if (context.IsAborted) + { + break; + } + + await stage.ExecuteAsync(context, progress: null, ct); + } + } + } + + private static ScannedFileInfo CreateScannedFile(string path, bool audioDefault = false) { var builder = new MediaInfoResultBuilder() .AddTrackOfType(TrackType.Video) - .AddTrackOfType(TrackType.Audio); + .AddTrack(new TrackInfoBuilder() + .WithType(TrackType.Audio) + .WithStreamKindID(0) + .WithDefault(audioDefault) + .Build()); + return new ScannedFileInfo(builder.Build(), path); } } From 7a1d202ed395a1d0bf40182795303b371c8a5e8c Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Sat, 2 May 2026 16:53:35 +0200 Subject: [PATCH 2/2] style: fix newline at end of file in multiple source and test files --- src/MatroskaBatchFlow.Core/Services/ITrackIntentFactory.cs | 2 +- src/MatroskaBatchFlow.Core/Services/TrackIntent.cs | 2 +- src/MatroskaBatchFlow.Core/Services/TrackIntentFactory.cs | 2 +- .../Models/ScannedFileInfoTests.cs | 2 +- .../Services/FileProcessing/Track/TrackLanguageRuleTests.cs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/MatroskaBatchFlow.Core/Services/ITrackIntentFactory.cs b/src/MatroskaBatchFlow.Core/Services/ITrackIntentFactory.cs index 1008956..70f6bcd 100644 --- a/src/MatroskaBatchFlow.Core/Services/ITrackIntentFactory.cs +++ b/src/MatroskaBatchFlow.Core/Services/ITrackIntentFactory.cs @@ -24,4 +24,4 @@ TrackIntent Create( MediaInfoResult.MediaInfo.TrackInfo scannedTrackInfo, TrackType trackType, int index); -} \ No newline at end of file +} diff --git a/src/MatroskaBatchFlow.Core/Services/TrackIntent.cs b/src/MatroskaBatchFlow.Core/Services/TrackIntent.cs index 50e6fb7..f44ddac 100644 --- a/src/MatroskaBatchFlow.Core/Services/TrackIntent.cs +++ b/src/MatroskaBatchFlow.Core/Services/TrackIntent.cs @@ -227,4 +227,4 @@ private void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } -} \ No newline at end of file +} diff --git a/src/MatroskaBatchFlow.Core/Services/TrackIntentFactory.cs b/src/MatroskaBatchFlow.Core/Services/TrackIntentFactory.cs index ea04625..47da281 100644 --- a/src/MatroskaBatchFlow.Core/Services/TrackIntentFactory.cs +++ b/src/MatroskaBatchFlow.Core/Services/TrackIntentFactory.cs @@ -25,4 +25,4 @@ public TrackIntent Create(TrackInfo scannedTrackInfo, TrackType trackType, int i Enabled = true, }; } -} \ No newline at end of file +} diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Models/ScannedFileInfoTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Models/ScannedFileInfoTests.cs index 9117b01..5259f8c 100644 --- a/tests/MatroskaBatchFlow.Core.UnitTests/Models/ScannedFileInfoTests.cs +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Models/ScannedFileInfoTests.cs @@ -30,4 +30,4 @@ public void GetTracks_ReturnsTracksOrderedByStreamKindId() track => Assert.Equal(1, track.StreamKindID), track => Assert.Equal(2, track.StreamKindID)); } -} \ No newline at end of file +} diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileProcessing/Track/TrackLanguageRuleTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileProcessing/Track/TrackLanguageRuleTests.cs index 8e5fef7..a7ede63 100644 --- a/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileProcessing/Track/TrackLanguageRuleTests.cs +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileProcessing/Track/TrackLanguageRuleTests.cs @@ -116,4 +116,4 @@ private static ScannedFileInfo CreateScannedFile(string path, string languageCod return new ScannedFileInfo(mediaInfoResult, path); } -} \ No newline at end of file +}