diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 757c0efe..1e06f05b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -32,7 +32,7 @@ body: value: | OS Version: E.g. Windows 11 Pro 22H2. Docker Version: Run `docker --version`. - Docker Image: E.g. `latest`, `latest-debian`. + Docker Image: E.g. `latest`, `develop`. `PlexCleaner getversioninfo`: Run `PlexCleaner getversioninfo`. validations: required: true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..5433ae5e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,442 @@ +# PlexCleaner AI Coding Instructions + +## Project Overview + +PlexCleaner is a .NET 10.0 CLI utility that optimizes media files for Direct Play in Plex/Emby/Jellyfin by: + +- Converting containers to MKV format +- Re-encoding incompatible video/audio codecs +- Managing tracks (language tags, duplicates, subtitles) +- Verifying and repairing media integrity +- Removing closed captions and unwanted content +- Monitoring folders for changes and automatically processing new/modified files + +The tool orchestrates external media processing tools (FFmpeg, HandBrake, MkvToolNix, MediaInfo, 7-Zip) via CLI wrappers. + +## Documentation + +User-facing documentation is organized as follows: + +- **[README.md](../README.md)**: Main project documentation, quick start, installation, usage, and FAQ. +- **[Docs/LanguageMatching.md](../Docs/LanguageMatching.md)**: Technical details on IETF/RFC 5646 language tag matching and configuration. +- **[Docs/CustomOptions.md](../Docs/CustomOptions.md)**: FFmpeg and HandBrake custom encoding parameters, hardware acceleration setup, and encoder options. +- **[Docs/ClosedCaptions.md](../Docs/ClosedCaptions.md)**: Detailed technical analysis of EIA-608/CTA-708 closed caption detection methods and tools. +- **[HISTORY.md](../HISTORY.md)**: Release notes and version history. + +## Architecture + +### Command Structure + +PlexCleaner provides multiple commands: + +- **process**: Batch process media files in specified folders +- **monitor**: Watch folders for changes and automatically process modified files +- **verify**: Verify media files using FFmpeg +- **remux**: Re-multiplex media files to MKV +- **reencode**: Re-encode media tracks using HandBrake or FFmpeg +- **deinterlace**: De-interlace media files +- **createsidecar**: Create sidecar files for existing media +- **gettoolinfo**: Display tool version information +- **gettagmap**: Analyze language tags across media files +- **getmediainfo**: Extract and display media properties +- **checkfornewtools**: Check for and download tool updates (Windows only) +- **defaultsettings**: Create default configuration file +- **createschema**: Generate JSON schema for configuration validation +- **removesubtitles**: Remove all subtitle tracks +- **removeclosedcaptions**: Remove embedded EIA-608/CTA-708 closed captions from video streams +- **updatesidecar**: Create or update sidecar files to current schema/tool info +- **getsidecarinfo**: Display sidecar file information +- **testmediainfo**: Test parsing media tool information for non-Matroska containers +- **getversioninfo**: Print application and media tool version information + +### Fluent Builder Pattern for Media Tools + +All media tool command-line construction uses fluent builders (`*Builder.cs`). Never concatenate strings: + +```csharp +// Correct - fluent builder pattern +var command = new FfMpeg.GlobalOptions(args) + .Default() + .Add(customOption); + +// Wrong - string concatenation +string args = "-hide_banner " + option; +``` + +### Process Execution with CliWrap + +All external process execution uses [CliWrap](https://github.com/Tyrrrz/CliWrap) (v3.x): + +- Builders create `ArgumentsBuilder` instances +- Execute via `Cli.Wrap(toolPath).WithArguments(builder)` +- Use `BufferedCommandResult` for output capture +- See `MediaTool.cs` for base execution patterns +- All tool execution supports cancellation via `Program.CancelToken()` + +### Sidecar File System + +Critical performance feature - DO NOT break compatibility: + +- Each `.mkv` gets a `.PlexCleaner` sidecar JSON file +- Contains: processing state, tool versions, media properties, file hash +- Hash: First 64KB + last 64KB of file (not timestamp-based) +- Schema versioned (`SchemaVersion: 5` in `SidecarFileJsonSchema5`, global alias in `GlobalUsing.cs`) +- Processing skips verified files unless sidecar invalidated +- State flags are bitwise: `StatesType` enum with `[Flags]` attribute +- Sidecar operations: `Create()`, `Read()`, `Update()`, `Delete()` + +### Media Tool Abstraction + +- `MediaTool` base class defines tool lifecycle +- Each tool family has: Tool class, Builder class, Info schema +- Tool version info retrieved from CLI output, cached in `Tools.json` +- Windows supports auto-download via `GitHubRelease.cs`; Linux uses system tools +- Tool paths: `ToolsOptions.UseSystem` or `RootPath + ToolFamily/SubFolder/ToolName` +- Tool execution: Base `Execute()` method with cancellation, logging, and error handling +- Version checking: `GetInstalledVersion()`, `GetLatestVersion()` (Windows only) + +### Media Properties and Track Management + +**MediaProps hierarchy:** + +- `MediaProps`: Container for all media information (video, audio, subtitle tracks) +- `TrackProps`: Base class for all track types + - `VideoProps`: Video track properties (format, resolution, codec, HDR, interlacing) + - `AudioProps`: Audio track properties (format, channels, sample rate, codec) + - `SubtitleProps`: Subtitle track properties (format, codec, closed captions) + +**Track properties:** + +- Language tags: ISO 639-2B (`Language`) and RFC 5646/BCP 47 (`LanguageIetf`) +- Flags: Default, Forced, HearingImpaired, VisualImpaired, Descriptions, Original, Commentary +- State: Keep, Remove, ReMux, ReEncode, DeInterlace, SetFlags, SetLanguage, Unsupported +- Title, Format, Codec, Id, Number, Uid + +**Track selection (`SelectMediaProps.cs`):** + +- Separates tracks into Selected/NotSelected categories +- Used for language filtering, duplicate removal, codec selection +- Move operations: `Move(track, toSelected)`, `Move(trackList, toSelected)` +- State assignment: `SetState(selectedState, notSelectedState)` + +### Language Tag Management + +**IETF/RFC 5646 Support:** + +- Uses external package `ptr727.LanguageTags` for language tag parsing and matching +- Tag format: `language-extlang-script-region-variant-extension-privateuse` +- Matching: Left-to-right prefix matching via `LanguageLookup.IsMatch()` +- Conversion: ISO 639-2B ↔ RFC 5646 via `GetIsoFromIetf()`, `GetIetfFromIso()` +- Special tags: `und` (undefined), `zxx` (no linguistic content), `en` (English) + +**Language processing:** + +- MediaInfo reports both ISO 639-2B and IETF tags (if set) +- MkvMerge normalizes to IETF tags when `SetIetfLanguageTags` enabled +- FFprobe uses tag metadata which may differ from track metadata +- Track validation: Checks ISO/IETF consistency, sets error states for mismatches + +### Monitor Mode + +**File system watching:** + +- Uses `FileSystemWatcher` to monitor specified folders +- Monitors: Size, CreationTime, LastWrite, FileName, DirectoryName +- Handles: Changed, Created, Deleted, Renamed events +- Queue-based: Changes added to watch queue with timestamps + +**Processing logic:** + +- Files must "settle" (no changes for `MonitorWaitTime` seconds) before processing +- Files must be readable (not being written) before processing +- Retry logic: `FileRetryCount` attempts with `FileRetryWaitTime` delays +- Cleanup: Deletes empty folders after file removal +- Pre-process: Optional initial scan of all monitored folders on startup + +**Concurrency:** + +- Lock-based queue management (`_watchLock`) +- Periodic processing (1-second poll interval) +- Supports parallel processing when `--parallel` enabled + +### XML and JSON Parsing + +AOT-safe parsers in `MediaInfoXmlParser.cs`: + +- **MediaInfoFromXml()**: Parses specific MediaInfo XML elements into `MediaInfoToolXmlSchema.MediaInfo` + - Manually parses only known elements needed by PlexCleaner (id, format, language, etc.) + - Used by sidecar file system to parse XML output when JSON unavailable + - Avoids XmlSerializer (not AOT-compatible) +- **GenericXmlToJson()**: Converts any XML file to JSON format + - Preserves all elements and attributes (unlike MediaInfoFromXml's selective parsing) + - Handles attributes: prefix with `@` for elements with children, no prefix for leaf elements + - Detects arrays: elements appearing multiple times become JSON arrays + - Two-pass algorithm: collect children to detect arrays, then write JSON + - Uses `XmlReader` and `Utf8JsonWriter` for streaming efficiency + - Special handling for MediaInfo's mixed attribute/text content format (creatingLibrary) +- **MediaInfoXmlToJson()**: Converts parsed MediaInfo XML to MediaInfo JSON schema + - Bridges between XML and JSON schema types + - Maps only known MediaInfo track properties + +Parser design patterns: + +- Forward-only `XmlReader` with depth tracking for streaming +- Recursive `ElementData` tree for generic XML-to-JSON conversion +- Namespace filtering (skip `xmlns`, `xsi` attributes) +- Special handling for MediaInfo's mixed attribute/text content format + +### Extensions Pattern + +**Modern C# 13 extension syntax:** + +- Uses implicit class extensions: `extension(ILogger logger)` +- Provides context-aware helper methods +- Examples: + - `LogAndPropagate()`: Log exception and return false (propagates error) + - `LogAndHandle()`: Log exception and return true (handles error for catch clauses) + - `LogOverrideContext()`: Create scoped logger with LogOverride context + +## Code Conventions + +### Formatting Standards + +- **Code formatter**: CSharpier (`.csharpier.json`) - primary formatter +- **EditorConfig**: `.editorconfig` follows .NET Runtime style guide +- **Pre-commit hooks**: Husky.Net validates style (`dotnet husky run`) +- Line endings: CRLF for Windows files (`.cs`, `.json`, `.yml`), LF for shell scripts +- Charset: UTF-8 without BOM + +### Code Style + +- Target: .NET 10.0 (`net10.0`) +- AOT compilation enabled: `true` in executable projects +- Use C# modern features (records, pattern matching, collection expressions, implicit class extensions) +- Prefer `Debug.Assert()` for internal invariants +- Logging: Serilog with thread IDs (`Log.Information/Warning/Error`) +- Exception handling: Currently uses broad `catch(Exception)` - TODO to specialize +- Global usings: `GlobalUsing.cs` defines project-wide type aliases (`ConfigFileJsonSchema`, `SidecarFileJsonSchema`) + +### Naming and Structure + +- JSON schemas: Generated via `JsonSchema.Net`, suffixed with version (e.g., `SidecarFileJsonSchema5`) +- Builder methods: Return `this` for chaining +- Media props: `*Props.cs` classes (VideoProps, AudioProps, SubtitleProps, TrackProps) +- Options classes: `*Options.cs` for command categories (ProcessOptions, VerifyOptions, ConvertOptions, ToolsOptions) +- Partial classes: Tool families use partial class structure (`*Tool.cs`, `*Builder.cs`) + +### Async and Concurrency + +- Main loop: Uses `WaitForCancel()` polling pattern instead of async/await +- Tool execution: Synchronous wrappers around CliWrap async operations +- Parallel processing: PLINQ with `AsParallel()`, `WithDegreeOfParallelism()` +- Lock-based synchronization: `Lock` instances for collection access +- Cancellation: Global `CancellationTokenSource` accessed via `Program.CancelToken()` + +## Testing + +### Test Framework + +- xUnit v3.x with `AwesomeAssertions` +- Test project: `PlexCleanerTests/` +- Fixture: `PlexCleanerFixture` (assembly-level, sets up defaults and logging) +- Sample media: `Samples/PlexCleaner/` (relative path `../../../../Samples/PlexCleaner`) + +### Test Coverage + +- Command-line parsing: `CommandLineTests.cs` +- Configuration validation: `ConfigFileTests.cs` +- FFmpeg parsing: `FfMpegIdetParsingTests.cs` +- Sidecar functionality: `SidecarFileTests.cs` +- Version parsing: `VersionParsingTests.cs` +- Wildcards: `WildcardTests.cs` +- Filename escaping for filters: `FileNameEscapingTests.cs` + +### Test Execution + +- Task: `"dotnet: .Net Build"` for builds +- Unit tests: `dotnet test` or VS Code test explorer +- Docker tests: Download Matroska test files from GitHub +- CI: Separate workflows for build tests and Docker tests + +## Build and Release + +### Local Development + +```bash +# Build +dotnet build + +# Format code +dotnet csharpier . + +# Verify formatting +dotnet format style --verify-no-changes --severity=info --verbosity=detailed + +# Run tests +dotnet test + +# Pre-commit validation (automatic via Husky) +dotnet husky run +``` + +### GitHub Actions + +- **BuildGitHubRelease.yml**: Multi-runtime matrix build (win, linux, osx × x64/arm/arm64) +- **BuildDockerPush.yml**: Multi-arch Docker builds (linux/amd64, linux/arm64) +- **TestBuildPr.yml** / **TestDockerPr.yml**: PR validation +- Version info: `version.json` with Nerdbank.GitVersioning format +- Branches: `main` (stable releases), `develop` (pre-releases) + +### Docker + +- Multi-stage builds in `Docker/Ubuntu.Rolling.Dockerfile` +- Base image: `ubuntu:rolling` only (no longer publishing Alpine or Debian variants) +- Supported architectures: `linux/amd64`, `linux/arm64` (no longer supporting `linux/arm/v7`) +- Tool installation: Ubuntu package manager (apt) +- Media tool versions match Windows versions for consistent behavior +- Test script: `Docker/Test.sh` validates all commands +- Version extraction: `Docker/Version.sh` captures tool versions for README +- User: Runs as `nonroot` user in containers +- Volumes: `/media` for media files and configuration + +## Common Patterns + +### Command-Line Parsing + +Uses `System.CommandLine` (v2.x): + +- Options defined in `CommandLineOptions.cs` +- Binding via `CommandLineParser.Bind()` +- No `System.CommandLine.NamingConventionBinder` (deprecated) +- Recursive options: Available to all subcommands (`--logfile`, `--logwarning`, `--debug`) +- Command routing: Each command maps to static method in `Program.cs` + +### Parallel Processing + +- `--parallel` flag enables concurrent file processing +- Uses `ProcessDriver.cs` with `AsParallel()` and `WithDegreeOfParallelism()` +- Default thread count: min(CPU/2, 4), configurable via `--threadcount` +- Lock-based collection updates in parallel contexts +- File grouping: Groups by path (excluding extension) to prevent concurrent access to same file + +### File Processing States + +```csharp +[Flags] +enum StatesType { + None, SetLanguage, ReMuxed, ReEncoded, DeInterlaced, + Repaired, RepairFailed, Verified, VerifyFailed, + BitrateExceeded, ClearedTags, FileReNamed, FileDeleted, + FileModified, ClearedCaptions, RemovedAttachments, + SetFlags, RemovedCoverArt +} +``` + +Check states with `HasFlag()`, combine with `|=` + +### Configuration Schema + +- Settings: `PlexCleaner.defaults.json` with inline JSONC comments +- Schema: `PlexCleaner.schema.json` (auto-generated via JsonSchema.Net) +- Validation: JSON Schema.Net with source-generated context +- URL schema reference: `https://raw.githubusercontent.com/ptr727/PlexCleaner/main/PlexCleaner.schema.json` +- Versioned: ConfigFile schemas numbered (ConfigFileJsonSchema4, etc.) +- Defaults: `SetDefaults()` method in each options class +- Verification: `VerifyValues()` method validates configuration + +### Keep-Awake Pattern + +- Prevents system sleep during long operations +- Uses `KeepAwake.cs` with Windows API calls +- Timer-based: Refreshes every 30 seconds +- Cross-platform: No-op on non-Windows systems + +### Cancellation Handling + +- Global token source: `Program.s_cancelSource` +- Console handlers: Ctrl+C, Ctrl+Z, Ctrl+Q +- Keyboard monitoring: Separate task for key press handling +- Tool execution: All CliWrap calls use `Program.CancelToken()` +- Graceful cleanup: Logs cancellation messages, disables file watchers + +## Critical Details + +### DO NOT + +- Break sidecar file compatibility (versioned schema migrations only) +- Use string concatenation for command-line arguments (use builders) +- Modify file timestamps unless `RestoreFileTimestamp` enabled +- Execute media tools without CliWrap abstractions +- Add synchronous operations in parallel processing paths +- Use `XmlSerializer` for AOT compilation (not compatible) +- Break language tag matching logic (IETF/ISO conversion) + +### DO + +- Add tests for media tool parsing changes (see `FfMpegIdetParsingTests.cs`) +- Update `HISTORY.md` for notable changes +- Use `Program.CancelToken()` for cancellation support +- Log with context: filenames, state transitions, tool versions +- Handle cross-platform paths (`Path.Combine`, forward slashes in Docker) +- Use modern C# features (collection expressions, pattern matching, extensions) +- Version schemas when making breaking changes +- Update global using aliases in `GlobalUsing.cs` when changing schema versions + +### Performance Considerations + +- Sidecar files enable fast re-processing (skip verified files) +- `--parallel` most effective with I/O-bound operations (re-mux) +- `--quickscan` limits scan to 3 minutes (trades accuracy for speed) +- `--testsnippets` creates 30s clips for testing +- Docker logging can grow large - configure rotation externally +- Monitor mode: Settle time prevents excessive re-processing + +### Special Cases + +**Closed Captions:** + +- EIA-608/EIA-708 tracks handled specially in `SubtitleProps.HandleClosedCaptions()` +- Parsed as subtitle tracks but removed during processing +- Track IDs formatted as `{VideoId}-CC{Number}` (e.g., `256-CC1`) + +**VOBSUB Subtitles:** + +- Require `MuxingMode` to be set for Plex compatibility +- Missing `MuxingMode` triggers error and removal recommendation + +**Duplicate Tracks:** + +- Language-based grouping with flag preservation +- Preferred audio codec selection via `FindPreferredAudio()` +- Keeps one flagged track per flag type, one non-flagged track + +**Language Mismatches:** + +- ISO 639-2B vs IETF tag validation in `TrackProps.SetLanguage()` +- Tag metadata vs track metadata differences (FFprobe specific) +- Automatic fallback: At least one track kept even if language doesn't match + +## Key Files Reference + +- **Program.cs**: Entry point, command routing, global state, cancellation handling +- **ProcessDriver.cs**: File enumeration, parallel processing orchestration +- **ProcessFile.cs**: Single-file processing logic, track selection algorithms +- **Process.cs**: High-level processing workflow, empty folder deletion +- **SidecarFile.cs**: Sidecar creation, validation, state management, hashing +- **MediaTool.cs**: Base class for tool abstractions, execution patterns +- **MediaProps.cs**: Media container, track aggregation +- **TrackProps.cs**: Base track properties, language handling, flag management +- **VideoProps.cs / AudioProps.cs / SubtitleProps.cs**: Track-specific properties +- **MediaInfoXmlParser.cs**: AOT-safe XML/JSON parsing (MediaInfo output) +- **Monitor.cs**: File system watching, change queue management +- **Convert.cs**: Re-encoding and re-muxing orchestration +- **MkvProcess.cs**: MKV-specific operations (attachment removal, flag setting) +- **Tools.cs**: Tool instances, version verification, update checking +- **Language.cs**: IETF tag matching, language list extraction +- **SelectMediaProps.cs**: Track filtering and selection logic +- **CommandLineOptions.cs**: CLI parsing, option definitions +- **Extensions.cs**: Logger extensions, implicit class extensions +- **GlobalUsing.cs**: Global type aliases for schema versions +- **KeepAwake.cs**: System sleep prevention +- **PlexCleaner.defaults.json**: Canonical configuration reference +- **.editorconfig** / **.csharpier.json**: Code style definitions diff --git a/.github/workflows/BuildDockerPush.yml b/.github/workflows/BuildDockerPush.yml index 267bfe3f..8718a4b8 100644 --- a/.github/workflows/BuildDockerPush.yml +++ b/.github/workflows/BuildDockerPush.yml @@ -27,12 +27,8 @@ jobs: strategy: matrix: include: - - tag: ${{ endsWith(github.ref, 'refs/heads/main') && 'debian' || 'debian-develop' }} - file: debian.ver - - tag: ${{ endsWith(github.ref, 'refs/heads/main') && 'alpine' || 'alpine-develop' }} - file: alpine.ver - - tag: ${{ endsWith(github.ref, 'refs/heads/main') && 'ubuntu' || 'ubuntu-develop' }} - file: ubuntu.ver + - tag: ${{ endsWith(github.ref, 'refs/heads/main') && 'latest' || 'develop' }} + file: latest.ver steps: diff --git a/.github/workflows/BuildDockerTask.yml b/.github/workflows/BuildDockerTask.yml index 1650b99e..010515ef 100644 --- a/.github/workflows/BuildDockerTask.yml +++ b/.github/workflows/BuildDockerTask.yml @@ -23,21 +23,9 @@ jobs: strategy: matrix: include: - - file: ./Docker/Debian.Stable.Dockerfile - platforms: linux/amd64,linux/arm64,linux/arm/v7 - cache-scope: debian - tags: | - docker.io/ptr727/plexcleaner:${{ endsWith(github.ref, 'refs/heads/main') && 'debian' || 'debian-develop' }} - - file: ./Docker/Alpine.Latest.Dockerfile - platforms: linux/amd64,linux/arm64 - cache-scope: alpine - tags: | - docker.io/ptr727/plexcleaner:${{ endsWith(github.ref, 'refs/heads/main') && 'alpine' || 'alpine-develop' }} - file: ./Docker/Ubuntu.Rolling.Dockerfile platforms: linux/amd64,linux/arm64 - cache-scope: ubuntu tags: | - docker.io/ptr727/plexcleaner:${{ endsWith(github.ref, 'refs/heads/main') && 'ubuntu' || 'ubuntu-develop' }} docker.io/ptr727/plexcleaner:${{ endsWith(github.ref, 'refs/heads/main') && 'latest' || 'develop' }} docker.io/ptr727/plexcleaner:${{ needs.get-version.outputs.SemVer2 }} @@ -49,12 +37,12 @@ jobs: - name: Setup QEMU uses: docker/setup-qemu-action@v3 with: - platforms: linux/amd64,linux/arm64,linux/arm/v7 + platforms: linux/amd64,linux/arm64 - name: Setup Buildx uses: docker/setup-buildx-action@v3 with: - platforms: linux/amd64,linux/arm64,linux/arm/v7 + platforms: linux/amd64,linux/arm64 - name: Login to Docker Hub uses: docker/login-action@v3 @@ -67,8 +55,8 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: ${{ matrix.file }} push: ${{ inputs.push }} + file: ${{ matrix.file }} tags: ${{ matrix.tags }} platforms: ${{ matrix.platforms }} build-args: | diff --git a/.github/workflows/BuildGitHubRelease.yml b/.github/workflows/BuildGitHubRelease.yml index d00ea1b2..32b5cdbb 100644 --- a/.github/workflows/BuildGitHubRelease.yml +++ b/.github/workflows/BuildGitHubRelease.yml @@ -29,21 +29,14 @@ jobs: needs: [test-build, get-version] strategy: matrix: - include: - - runtime: win-x64 - - runtime: linux-x64 - - runtime: linux-musl-x64 - - runtime: linux-arm - - runtime: linux-arm64 - - runtime: osx-x64 - - runtime: osx-arm64 + runtime: [ win-x64, linux-x64, linux-musl-x64, linux-arm, linux-arm64, osx-x64, osx-arm64 ] steps: - name: Setup .NET SDK uses: actions/setup-dotnet@v5 with: - dotnet-version: "9" + dotnet-version: "10" - name: Checkout code uses: actions/checkout@v6 @@ -52,9 +45,9 @@ jobs: run: >- dotnet publish ./PlexCleaner/PlexCleaner.csproj --runtime ${{ matrix.runtime }} - --self-contained false --output ${{ runner.temp }}/publish/${{ matrix.runtime }} --configuration ${{ endsWith(github.ref, 'refs/heads/main') && 'Release' || 'Debug' }} + -property:PublishAot=false -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} -property:AssemblyVersion=${{ needs.get-version.outputs.AssemblyVersion }} diff --git a/.github/workflows/GetVersionTask.yml b/.github/workflows/GetVersionTask.yml index d749bb93..55b4ffdd 100644 --- a/.github/workflows/GetVersionTask.yml +++ b/.github/workflows/GetVersionTask.yml @@ -29,7 +29,7 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v5 with: - dotnet-version: "9" + dotnet-version: "10" - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/TestBuildTask.yml b/.github/workflows/TestBuildTask.yml index 26f7c816..fec28633 100644 --- a/.github/workflows/TestBuildTask.yml +++ b/.github/workflows/TestBuildTask.yml @@ -15,7 +15,7 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v5 with: - dotnet-version: "9" + dotnet-version: "10" - name: Checkout code uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index d83e0a86..8ae884af 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ .idea .vs +.DS_Store *.user diff --git a/.husky/task-runner.json b/.husky/task-runner.json index eb389c75..009e6b3a 100644 --- a/.husky/task-runner.json +++ b/.husky/task-runner.json @@ -22,8 +22,7 @@ "style", "--verify-no-changes", "--severity=info", - "--verbosity=detailed", - "--exclude-diagnostics=IDE0055" + "--verbosity=detailed" ], "include": [ "**/*.cs" diff --git a/.vscode/launch.json b/.vscode/launch.json index a4c4f6e6..079badb8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,11 +11,11 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "--help", ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "console": "internalConsole", "stopAtEntry": false }, @@ -24,12 +24,12 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "process", "--help", ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "console": "internalConsole", "stopAtEntry": false }, @@ -38,11 +38,11 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "--version", ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "console": "internalConsole", "stopAtEntry": false }, @@ -51,12 +51,12 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "getversioninfo", "--settingsfile=PlexCleaner.json", ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "console": "internalConsole", "stopAtEntry": false }, @@ -65,12 +65,12 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "defaultsettings", - "--settingsfile=PlexCleaner.json", + "--settingsfile=PlexCleaner.defaults.json", ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "console": "internalConsole", "stopAtEntry": false }, @@ -79,16 +79,15 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "process", "--logfile=PlexCleaner_Single.log", "--settingsfile=PlexCleaner.json", "--resultsfile=Results_Single.json", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "stopAtEntry": false, "enableStepFiltering": false, "justMyCode": false @@ -98,17 +97,16 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "process", "--logfile=PlexCleaner_Single_Quickscan.log", "--settingsfile=PlexCleaner.json", "--resultsfile=Results_Single_Quickscan.json", "--quickscan", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -119,7 +117,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "process", "--logfile=PlexCleaner_Single_Quickscan.log", @@ -127,10 +125,9 @@ "--resultsfile=Results_Single_Quickscan.json", "--testsnippets", "--quickscan", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -141,7 +138,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "process", "--logfile=PlexCleaner_Parallel_TestSnippets_Quickscan.log", @@ -151,10 +148,9 @@ "--parallel", "--testsnippets", "--quickscan", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -165,16 +161,15 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "monitor", "--logfile=PlexCleaner.log", "--settingsfile=PlexCleaner.json", "--preprocess", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -185,16 +180,15 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "remux", "--logfile=PlexCleaner.log", "--settingsfile=PlexCleaner.json", "--quickscan", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -205,16 +199,15 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "reencode", "--logfile=PlexCleaner.log", "--settingsfile=PlexCleaner.json", "--quickscan", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -225,16 +218,15 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "deinterlace", "--logfile=PlexCleaner.log", "--settingsfile=PlexCleaner.json", "--quickscan", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -245,16 +237,15 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "removeclosedcaptions", "--logfile=PlexCleaner.log", "--settingsfile=PlexCleaner.json", "--quickscan", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -265,15 +256,14 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "removesubtitles", "--logfile=PlexCleaner.log", "--settingsfile=PlexCleaner.json", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -284,16 +274,15 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "verify", "--logfile=PlexCleaner.log", "--settingsfile=PlexCleaner.json", "--quickscan", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -304,14 +293,13 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "gettagmap", "--settingsfile=PlexCleaner.json", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -322,14 +310,13 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "getmediainfo", "--settingsfile=PlexCleaner.json", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -340,15 +327,14 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "testmediainfo", "--logfile=PlexCleaner.log", "--settingsfile=PlexCleaner.json", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -359,14 +345,13 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "gettoolinfo", "--settingsfile=PlexCleaner.json", - "--mediafiles", - "D:\\Test" + "--mediafiles=D:\\Test" ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -377,12 +362,79 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0/PlexCleaner.dll", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", "args": [ "checkfornewtools", "--settingsfile=PlexCleaner.json", ], - "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", + "externalConsole": false, + "stopAtEntry": false, + "enableStepFiltering": false, + "justMyCode": false + }, + { + "name": "Create Sidecar", + "type": "coreclr", + "request": "launch", + "preLaunchTask": ".Net Build", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", + "args": [ + "createsidecar", + "--settingsfile=PlexCleaner.json", + "--mediafiles=D:\\Test" + ], + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", + "externalConsole": false, + "stopAtEntry": false, + "enableStepFiltering": false, + "justMyCode": false + }, + { + "name": "Update Sidecar", + "type": "coreclr", + "request": "launch", + "preLaunchTask": ".Net Build", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", + "args": [ + "updatesidecar", + "--settingsfile=PlexCleaner.json", + "--mediafiles=D:\\Test" + ], + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", + "externalConsole": false, + "stopAtEntry": false, + "enableStepFiltering": false, + "justMyCode": false + }, + { + "name": "Get Sidecar Info", + "type": "coreclr", + "request": "launch", + "preLaunchTask": ".Net Build", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", + "args": [ + "getsidecarinfo", + "--settingsfile=PlexCleaner.json", + "--mediafiles=D:\\Test" + ], + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", + "externalConsole": false, + "stopAtEntry": false, + "enableStepFiltering": false, + "justMyCode": false + }, + { + "name": "Create Schema", + "type": "coreclr", + "request": "launch", + "preLaunchTask": ".Net Build", + "program": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0/PlexCleaner.dll", + "args": [ + "createschema", + "--schemafile=PlexCleaner.schema.json" + ], + "cwd": "${workspaceFolder}/PlexCleaner/bin/Debug/net10.0", "externalConsole": false, "stopAtEntry": false, "enableStepFiltering": false, @@ -393,9 +445,9 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/Sandbox/bin/Debug/net9.0/Sandbox.dll", + "program": "${workspaceFolder}/Sandbox/bin/Debug/net10.0/Sandbox.dll", "args": [], - "cwd": "${workspaceFolder}/Sandbox/bin/Debug/net9.0", + "cwd": "${workspaceFolder}/Sandbox/bin/Debug/net10.0", "stopAtEntry": false, "enableStepFiltering": false, "justMyCode": false diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 58661e7a..957454dc 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,8 +3,13 @@ "tasks": [ { "label": ".Net Build", - "type": "dotnet", - "task": "build", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}", + "--verbosity=diagnostic" + ], "group": "build", "problemMatcher": [ "$msCompile" @@ -23,8 +28,7 @@ "style", "--verify-no-changes", "--severity=info", - "--verbosity=detailed", - "--exclude-diagnostics=IDE0055" + "--verbosity=detailed" ], "problemMatcher": [ "$msCompile" @@ -34,6 +38,7 @@ "clear": false }, "dependsOn": [ + "CSharpier Format", ".Net Build" ] }, @@ -126,92 +131,11 @@ "clear": false } }, - { - "label": "Build Alpine.Edge Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "buildx", - "build", - "--platform=linux/amd64,linux/arm64", - "--file=./Docker/Alpine.Edge.Dockerfile", - "${workspaceFolder}" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Build Alpine.Latest Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "buildx", - "build", - "--platform=linux/amd64,linux/arm64", - "--file=./Docker/Alpine.Latest.Dockerfile", - "${workspaceFolder}" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Build Debian.Stable Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "buildx", - "build", - "--platform=linux/amd64,linux/arm64,linux/arm/v7", - "--file=./Docker/Debian.Stable.Dockerfile", - "${workspaceFolder}" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Build Debian.Testing Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "buildx", - "build", - "--platform=linux/amd64,linux/arm64,linux/arm/v7", - "--file=./Docker/Debian.Testing.Dockerfile", - "${workspaceFolder}" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, { "label": "Build all Dockerfiles", "dependsOrder": "parallel", "dependsOn": [ "Build Ubuntu.Rolling Dockerfile", - "Build Ubuntu.Devel Dockerfile", - "Build Alpine.Edge Dockerfile", - "Build Alpine.Latest Dockerfile", - "Build Debian.Stable Dockerfile", - "Build Debian.Testing Dockerfile" ], "problemMatcher": [], "presentation": { @@ -261,100 +185,11 @@ "clear": false } }, - { - "label": "Load Alpine.Edge Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "buildx", - "build", - "--load", - "--platform=linux/amd64", - "--tag=plexcleaner:alpine-edge", - "--file=./Docker/Alpine.Edge.Dockerfile", - "${workspaceFolder}" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Load Alpine.Latest Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "buildx", - "build", - "--load", - "--platform=linux/amd64", - "--tag=plexcleaner:alpine", - "--file=./Docker/Alpine.Latest.Dockerfile", - "${workspaceFolder}" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Load Debian.Stable Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "buildx", - "build", - "--load", - "--platform=linux/amd64", - "--tag=plexcleaner:debian", - "--file=./Docker/Debian.Stable.Dockerfile", - "${workspaceFolder}" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Load Debian.Testing Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "buildx", - "build", - "--load", - "--platform=linux/amd64", - "--tag=plexcleaner:debian-testing", - "--file=./Docker/Debian.Testing.Dockerfile", - "${workspaceFolder}" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, { "label": "Load all Dockerfiles", "dependsOrder": "parallel", "dependsOn": [ "Load Ubuntu.Rolling Dockerfile", - "Load Ubuntu.Devel Dockerfile", - "Load Alpine.Edge Dockerfile", - "Load Alpine.Latest Dockerfile", - "Load Debian.Stable Dockerfile", - "Load Debian.Testing Dockerfile" ], "problemMatcher": [], "presentation": { @@ -410,112 +245,11 @@ "clear": false } }, - { - "label": "Test Alpine.Edge Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "run", - "-it", - "--rm", - "--name=PlexCleaner-Test-Alpine.Edge", - "plexcleaner:alpine-edge", - "/Test/Test.sh" - ], - "dependsOrder": "sequence", - "dependsOn": [ - "Load Alpine.Edge Dockerfile" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Test Alpine.Latest Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "run", - "-it", - "--rm", - "--name=PlexCleaner-Test-Alpine.Latest", - "plexcleaner:alpine", - "/Test/Test.sh" - ], - "dependsOrder": "sequence", - "dependsOn": [ - "Load Alpine.Latest Dockerfile" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Test Debian.Stable Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "run", - "-it", - "--rm", - "--name=PlexCleaner-Test-Debian.Stable", - "plexcleaner:debian", - "/Test/Test.sh" - ], - "dependsOrder": "sequence", - "dependsOn": [ - "Load Debian.Stable Dockerfile" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Test Debian.Testing Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "run", - "-it", - "--rm", - "--name=PlexCleaner-Test-Debian.Testing", - "plexcleaner:debian-testing", - "/Test/Test.sh" - ], - "dependsOrder": "sequence", - "dependsOn": [ - "Load Debian.Testing Dockerfile" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, { "label": "Test all Dockerfiles", "dependsOrder": "parallel", "dependsOn": [ "Test Ubuntu.Rolling Dockerfile", - "Test Ubuntu.Devel Dockerfile", - "Test Alpine.Edge Dockerfile", - "Test Alpine.Latest Dockerfile", - "Test Debian.Stable Dockerfile", - "Test Debian.Testing Dockerfile" ], "problemMatcher": [], "presentation": { diff --git a/Docker/Alpine.Edge.Dockerfile b/Docker/Alpine.Edge.Dockerfile deleted file mode 100644 index f3ab970c..00000000 --- a/Docker/Alpine.Edge.Dockerfile +++ /dev/null @@ -1,134 +0,0 @@ -# Description: Alpine development release -# Based on: alpine:edge -# .NET install: Alpine repository -# Platforms: linux/amd64, linux/arm64 -# Tag: ptr727/plexcleaner:alpine-edge - -# Docker build debugging: -# --progress=plain -# --no-cache - -# Test image in shell: -# docker run -it --rm --pull always --name Testing alpine:edge /bin/sh -# docker run -it --rm --pull always --name Testing ptr727/plexcleaner:alpine-edge /bin/sh - -# Build Dockerfile -# docker buildx create --name plexcleaner --use -# docker buildx build --platform linux/amd64,linux/arm64 --file ./Docker/Alpine.Edge.Dockerfile . - -# Test linux/amd64 target -# docker buildx build --load --platform linux/amd64 --tag plexcleaner:alpine-edge --file ./Docker/Alpine.Edge.Dockerfile . -# docker run -it --rm --name PlexCleaner-Test plexcleaner:alpine-edge /bin/sh - - -# Builder layer -FROM --platform=$BUILDPLATFORM alpine:edge AS builder - -# Layer workdir -WORKDIR /Builder - -# Build platform args -ARG TARGETPLATFORM \ - TARGETARCH \ - BUILDPLATFORM - -# PlexCleaner build attribute configuration -ARG BUILD_CONFIGURATION="Debug" \ - BUILD_VERSION="1.0.0.0" \ - BUILD_FILE_VERSION="1.0.0.0" \ - BUILD_ASSEMBLY_VERSION="1.0.0.0" \ - BUILD_INFORMATION_VERSION="1.0.0.0" \ - BUILD_PACKAGE_VERSION="1.0.0.0" - -# Upgrade -RUN apk update \ - && apk upgrade - -# Install .NET SDK -# https://pkgs.alpinelinux.org/package/edge/community/x86_64/dotnet9-sdk -RUN apk add --no-cache dotnet9-sdk - -# Copy source and unit tests -COPY ./Samples/. ./Samples/. -COPY ./PlexCleanerTests/. ./PlexCleanerTests/. -COPY ./PlexCleaner/. ./PlexCleaner/. - -# Unit Test -COPY ./Docker/UnitTest.sh ./ -RUN chmod ug=rwx,o=rx ./UnitTest.sh -RUN ./UnitTest.sh - -# Build -COPY ./Docker/Build.sh ./ -RUN chmod ug=rwx,o=rx ./Build.sh -RUN ./Build.sh - - -# Final layer -FROM alpine:edge AS final - -# Image label -ARG LABEL_VERSION="1.0.0.0" -LABEL name="PlexCleaner" \ - version=${LABEL_VERSION} \ - description="Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc." \ - maintainer="Pieter Viljoen " - -# Enable .NET globalization, set default locale to en_US.UTF-8, and timezone to UTC -# https://github.com/dotnet/dotnet-docker/blob/main/samples/dotnetapp/Dockerfile.alpine-icu -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \ - LANG=en_US.UTF-8 \ - LANGUAGE=en_US:en \ - LC_ALL=en_US.UTF-8 \ - TZ=Etc/UTC - -# Upgrade -RUN apk update \ - && apk upgrade - -# Install dependencies -RUN apk add --no-cache \ - icu-data-full \ - icu-libs \ - p7zip \ - tzdata \ - wget - -# Install .NET Runtime -# https://pkgs.alpinelinux.org/package/edge/community/x86_64/dotnet9-runtime -RUN apk add --no-cache dotnet9-runtime - -# Install media tools -# https://pkgs.alpinelinux.org/package/edge/community/x86_64/ffmpeg -# https://pkgs.alpinelinux.org/package/edge/community/x86_64/mediainfo -# https://pkgs.alpinelinux.org/package/edge/community/x86_64/mkvtoolnix -# https://pkgs.alpinelinux.org/package/edge/community/x86_64/handbrake -RUN apk add --no-cache \ - ffmpeg\ - handbrake \ - mediainfo \ - mkvtoolnix - -# Copy PlexCleaner from builder layer -COPY --from=builder /Builder/Publish/PlexCleaner/. /PlexCleaner - -# Copy test script -COPY /Docker/Test.sh /Test/ -RUN chmod -R ug=rwx,o=rx /Test - -# Install debug tools -COPY ./Docker/InstallDebugTools.sh ./ -RUN chmod ug=rwx,o=rx ./InstallDebugTools.sh \ - && ./InstallDebugTools.sh \ - && rm -rf ./InstallDebugTools.sh - -# Copy version script -COPY /Docker/Version.sh /PlexCleaner/ -RUN chmod ug=rwx,o=rx /PlexCleaner/Version.sh - -# Print version information -ARG TARGETPLATFORM \ - BUILDPLATFORM -RUN if [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ - /PlexCleaner/Version.sh; \ - fi diff --git a/Docker/Alpine.Latest.Dockerfile b/Docker/Alpine.Latest.Dockerfile deleted file mode 100644 index 6156a53c..00000000 --- a/Docker/Alpine.Latest.Dockerfile +++ /dev/null @@ -1,134 +0,0 @@ -# Description: Alpine latest release -# Based on: alpine:latest -# .NET install: Alpine repository -# Platforms: linux/amd64, linux/arm64 -# Tag: ptr727/plexcleaner:alpine - -# Docker build debugging: -# --progress=plain -# --no-cache - -# Test image in shell: -# docker run -it --rm --pull always --name Testing alpine:latest /bin/sh -# docker run -it --rm --pull always --name Testing ptr727/plexcleaner:alpine /bin/sh - -# Build Dockerfile -# docker buildx create --name plexcleaner --use -# docker buildx build --platform linux/amd64,linux/arm64 --file ./Docker/Alpine.Latest.Dockerfile . - -# Test linux/amd64 target -# docker buildx build --load --platform linux/amd64 --tag plexcleaner:alpine --file ./Docker/Alpine.Latest.Dockerfile . -# docker run -it --rm --name PlexCleaner-Test plexcleaner:alpine /bin/sh - - -# Builder layer -FROM --platform=$BUILDPLATFORM alpine:latest AS builder - -# Layer workdir -WORKDIR /Builder - -# Build platform args -ARG TARGETPLATFORM \ - TARGETARCH \ - BUILDPLATFORM - -# PlexCleaner build attribute configuration -ARG BUILD_CONFIGURATION="Debug" \ - BUILD_VERSION="1.0.0.0" \ - BUILD_FILE_VERSION="1.0.0.0" \ - BUILD_ASSEMBLY_VERSION="1.0.0.0" \ - BUILD_INFORMATION_VERSION="1.0.0.0" \ - BUILD_PACKAGE_VERSION="1.0.0.0" - -# Upgrade -RUN apk update \ - && apk upgrade - -# Install .NET SDK -# https://pkgs.alpinelinux.org/package/v3.21/community/x86_64/dotnet9-sdk -RUN apk add --no-cache dotnet9-sdk - -# Copy source and unit tests -COPY ./Samples/. ./Samples/. -COPY ./PlexCleanerTests/. ./PlexCleanerTests/. -COPY ./PlexCleaner/. ./PlexCleaner/. - -# Unit Test -COPY ./Docker/UnitTest.sh ./ -RUN chmod ug=rwx,o=rx ./UnitTest.sh -RUN ./UnitTest.sh - -# Build -COPY ./Docker/Build.sh ./ -RUN chmod ug=rwx,o=rx ./Build.sh -RUN ./Build.sh - - -# Final layer -FROM alpine:latest AS final - -# Image label -ARG LABEL_VERSION="1.0.0.0" -LABEL name="PlexCleaner" \ - version=${LABEL_VERSION} \ - description="Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc." \ - maintainer="Pieter Viljoen " - -# Enable .NET globalization, set default locale to en_US.UTF-8, and timezone to UTC -# https://github.com/dotnet/dotnet-docker/blob/main/samples/dotnetapp/Dockerfile.alpine-icu -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \ - LANG=en_US.UTF-8 \ - LANGUAGE=en_US:en \ - LC_ALL=en_US.UTF-8 \ - TZ=Etc/UTC - -# Upgrade -RUN apk update \ - && apk upgrade - -# Install dependencies -RUN apk add --no-cache \ - icu-data-full \ - icu-libs \ - p7zip \ - tzdata \ - wget - -# Install .NET Runtime -# https://pkgs.alpinelinux.org/package/v3.21/community/x86_64/dotnet9-runtime -RUN apk add --no-cache dotnet9-runtime - -# Install media tools -# https://pkgs.alpinelinux.org/package/v3.21/community/x86_64/ffmpeg -# https://pkgs.alpinelinux.org/package/v3.21/community/x86_64/mediainfo -# https://pkgs.alpinelinux.org/package/v3.21/community/x86_64/mkvtoolnix -# https://pkgs.alpinelinux.org/package/v3.21/community/x86_64/handbrake -RUN apk add --no-cache \ - ffmpeg\ - handbrake \ - mediainfo \ - mkvtoolnix - -# Copy PlexCleaner from builder layer -COPY --from=builder /Builder/Publish/PlexCleaner/. /PlexCleaner - -# Copy test script -COPY /Docker/Test.sh /Test/ -RUN chmod -R ug=rwx,o=rx /Test - -# Install debug tools -COPY ./Docker/InstallDebugTools.sh ./ -RUN chmod ug=rwx,o=rx ./InstallDebugTools.sh \ - && ./InstallDebugTools.sh \ - && rm -rf ./InstallDebugTools.sh - -# Copy version script -COPY /Docker/Version.sh /PlexCleaner/ -RUN chmod ug=rwx,o=rx /PlexCleaner/Version.sh - -# Print version information -ARG TARGETPLATFORM \ - BUILDPLATFORM -RUN if [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ - /PlexCleaner/Version.sh; \ - fi diff --git a/Docker/Build.sh b/Docker/Build.sh index 0f631697..1d43903e 100644 --- a/Docker/Build.sh +++ b/Docker/Build.sh @@ -9,9 +9,9 @@ set -e # Build release and debug builds dotnet publish ./PlexCleaner/PlexCleaner.csproj \ --arch $TARGETARCH \ - --self-contained false \ --output ./Build/Release \ --configuration release \ + -property:PublishAot=false \ -property:Version=$BUILD_VERSION \ -property:FileVersion=$BUILD_FILE_VERSION \ -property:AssemblyVersion=$BUILD_ASSEMBLY_VERSION \ @@ -20,9 +20,9 @@ dotnet publish ./PlexCleaner/PlexCleaner.csproj \ dotnet publish ./PlexCleaner/PlexCleaner.csproj \ --arch $TARGETARCH \ - --self-contained false \ --output ./Build/Debug \ --configuration debug \ + -property:PublishAot=false \ -property:Version=$BUILD_VERSION \ -property:FileVersion=$BUILD_FILE_VERSION \ -property:AssemblyVersion=$BUILD_ASSEMBLY_VERSION \ diff --git a/Docker/Debian.Stable.Dockerfile b/Docker/Debian.Stable.Dockerfile deleted file mode 100644 index 7b053abb..00000000 --- a/Docker/Debian.Stable.Dockerfile +++ /dev/null @@ -1,192 +0,0 @@ -# Description: Debian latest release -# Based on: debian:stable-slim -# .NET install: Install script -# Platforms: linux/amd64, linux/arm64, linux/arm/v7 -# Tag: ptr727/plexcleaner:debian - -# Docker build debugging: -# --progress=plain -# --no-cache - -# Test image in shell: -# docker run -it --rm --pull always --name Testing debian:stable-slim /bin/bash -# docker run -it --rm --pull always --name Testing ptr727/plexcleaner:debian /bin/bash -# export DEBIAN_FRONTEND=noninteractive - -# Build Dockerfile -# docker buildx create --name "plexcleaner" --use -# docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --file ./Docker/Debian.Stable.Dockerfile . - -# Test linux/amd64 target -# docker buildx build --load --platform linux/amd64 --tag plexcleaner:debian --file ./Docker/Debian.Stable.Dockerfile . -# docker run -it --rm --name PlexCleaner-Test plexcleaner:debian /bin/bash - - -# Builder layer -FROM --platform=$BUILDPLATFORM debian:stable-slim AS builder - -# Layer workdir -WORKDIR /Builder - -# Build platform args -ARG TARGETPLATFORM \ - TARGETARCH \ - BUILDPLATFORM - -# PlexCleaner build attribute configuration -ARG BUILD_CONFIGURATION="Debug" \ - BUILD_VERSION="1.0.0.0" \ - BUILD_FILE_VERSION="1.0.0.0" \ - BUILD_ASSEMBLY_VERSION="1.0.0.0" \ - BUILD_INFORMATION_VERSION="1.0.0.0" \ - BUILD_PACKAGE_VERSION="1.0.0.0" - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -# Upgrade -RUN apt update \ - && apt upgrade -y - -# Install dependencies -RUN apt install -y --no-install-recommends \ - ca-certificates \ - lsb-release \ - wget - -# Install .NET SDK -# https://learn.microsoft.com/en-us/dotnet/core/install/linux-scripted-manual -# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script -# https://github.com/dotnet/core/blob/main/release-notes/9.0/os-packages.md -# https://github.com/dotnet/dotnet-docker/blob/main/src/sdk/9.0/bookworm-slim/amd64/Dockerfile -# https://github.com/dotnet/dotnet-docker/blob/main/src/runtime-deps/9.0/bookworm-slim/amd64/Dockerfile -RUN apt install -y --no-install-recommends \ - ca-certificates \ - curl \ - libc6 \ - libgcc-s1 \ - libicu76 \ - libssl3t64 \ - libstdc++6 \ - tzdata \ - wget \ - && wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ - && chmod ug=rwx,o=rx dotnet-install.sh \ - && ./dotnet-install.sh --install-dir /usr/local/bin/dotnet --channel 9.0 \ - && rm dotnet-install.sh -ENV DOTNET_ROOT=/usr/local/bin/dotnet \ - PATH=$PATH:/usr/local/bin/dotnet:/usr/local/bin/dotnet/tools \ - DOTNET_USE_POLLING_FILE_WATCHER=true \ - DOTNET_RUNNING_IN_CONTAINER=true - -# Copy source and unit tests -COPY ./Samples/. ./Samples/. -COPY ./PlexCleanerTests/. ./PlexCleanerTests/. -COPY ./PlexCleaner/. ./PlexCleaner/. - -# Unit Test -COPY ./Docker/UnitTest.sh ./ -RUN chmod ug=rwx,o=rx ./UnitTest.sh -RUN ./UnitTest.sh - -# Build -COPY ./Docker/Build.sh ./ -RUN chmod ug=rwx,o=rx ./Build.sh -RUN ./Build.sh - - -# Final layer -FROM debian:stable-slim AS final - -# Image label -ARG LABEL_VERSION="1.0.0.0" -LABEL name="PlexCleaner" \ - version=${LABEL_VERSION} \ - description="Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc." \ - maintainer="Pieter Viljoen " - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -# Upgrade -RUN apt update \ - && apt upgrade -y - -# Install dependencies -RUN apt install -y --no-install-recommends \ - ca-certificates \ - locales \ - locales-all \ - lsb-release \ - p7zip-full \ - tzdata \ - wget \ - && locale-gen --no-purge en_US en_US.UTF-8 - -# Set locale to UTF-8 after running locale-gen -# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md -ENV TZ=Etc/UTC \ - LANG=en_US.UTF-8 \ - LANGUAGE=en_US:en \ - LC_ALL=en_US.UTF-8 - -# Install .NET runtime -# Keep dependencies in sync with SDK install step -RUN apt install -y --no-install-recommends \ - ca-certificates \ - curl \ - libc6 \ - libgcc-s1 \ - libicu76 \ - libssl3t64 \ - libstdc++6 \ - tzdata \ - wget \ -&& wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ - && chmod ug=rwx,o=rx dotnet-install.sh \ - && ./dotnet-install.sh --install-dir /usr/local/bin/dotnet --runtime dotnet --channel 9.0 \ - && rm dotnet-install.sh -ENV DOTNET_ROOT=/usr/local/bin/dotnet \ - PATH=$PATH:/usr/local/bin/dotnet:/usr/local/bin/dotnet/tools \ - DOTNET_USE_POLLING_FILE_WATCHER=true \ - DOTNET_RUNNING_IN_CONTAINER=true - -# Install media tools -# https://tracker.debian.org/pkg/ffmpeg -# https://tracker.debian.org/pkg/handbrake -# https://tracker.debian.org/pkg/mediainfo -# https://tracker.debian.org/pkg/mkvtoolnix -RUN apt install -y --no-install-recommends \ - ffmpeg \ - handbrake-cli \ - mediainfo \ - mkvtoolnix - -# Cleanup -RUN apt autoremove -y \ - && apt clean \ - && rm -rf /var/lib/apt/lists/* - -# Copy PlexCleaner from builder layer -COPY --from=builder /Builder/Publish/PlexCleaner/. /PlexCleaner - -# Copy test script -COPY /Docker/Test.sh /Test/ -RUN chmod -R ug=rwx,o=rx /Test - -# Install debug tools -COPY ./Docker/InstallDebugTools.sh ./ -RUN chmod ug=rwx,o=rx ./InstallDebugTools.sh \ - && ./InstallDebugTools.sh \ - && rm -rf ./InstallDebugTools.sh - -# Copy version script -COPY /Docker/Version.sh /PlexCleaner/ -RUN chmod ug=rwx,o=rx /PlexCleaner/Version.sh - -# Print version information -ARG TARGETPLATFORM \ - BUILDPLATFORM -RUN if [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ - /PlexCleaner/Version.sh; \ - fi diff --git a/Docker/Debian.Testing.Dockerfile b/Docker/Debian.Testing.Dockerfile deleted file mode 100644 index c430cda8..00000000 --- a/Docker/Debian.Testing.Dockerfile +++ /dev/null @@ -1,192 +0,0 @@ -# Description: Debian development release -# Based on: debian:testing-slim -# .NET install: Install script -# Platforms: linux/amd64, linux/arm64, linux/arm/v7 -# Tag: ptr727/plexcleaner:debian-testing - -# Docker build debugging: -# --progress=plain -# --no-cache - -# Test image in shell: -# docker run -it --rm --pull always --name Testing debian:testing-slim /bin/bash -# docker run -it --rm --pull always --name Testing ptr727/plexcleaner:debian-testing /bin/bash -# export DEBIAN_FRONTEND=noninteractive - -# Build Dockerfile -# docker buildx create --name "plexcleaner" --use -# docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --file ./Docker/Debian.Testing.Dockerfile . - -# Test linux/amd64 target -# docker buildx build --load --platform linux/amd64 --tag plexcleaner:debian-testing --file ./Docker/Debian.Testing.Dockerfile . -# docker run -it --rm --name PlexCleaner-Test plexcleaner:debian-testing /bin/bash - - -# Builder layer -FROM --platform=$BUILDPLATFORM debian:testing-slim AS builder - -# Layer workdir -WORKDIR /Builder - -# Build platform args -ARG TARGETPLATFORM \ - TARGETARCH \ - BUILDPLATFORM - -# PlexCleaner build attribute configuration -ARG BUILD_CONFIGURATION="Debug" \ - BUILD_VERSION="1.0.0.0" \ - BUILD_FILE_VERSION="1.0.0.0" \ - BUILD_ASSEMBLY_VERSION="1.0.0.0" \ - BUILD_INFORMATION_VERSION="1.0.0.0" \ - BUILD_PACKAGE_VERSION="1.0.0.0" - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -# Upgrade -RUN apt update \ - && apt upgrade -y - -# Install dependencies -RUN apt install -y --no-install-recommends \ - ca-certificates \ - lsb-release \ - wget - -# Install .NET SDK -# https://learn.microsoft.com/en-us/dotnet/core/install/linux-scripted-manual -# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script -# https://github.com/dotnet/core/blob/main/release-notes/9.0/os-packages.md -# https://github.com/dotnet/dotnet-docker/blob/main/src/sdk/10.0/trixie-slim/amd64/Dockerfile -# https://github.com/dotnet/dotnet-docker/blob/main/src/runtime-deps/10.0/trixie-slim/amd64/Dockerfile -RUN apt install -y --no-install-recommends \ - ca-certificates \ - curl \ - libc6 \ - libgcc-s1 \ - libicu76 \ - libssl3t64 \ - libstdc++6 \ - tzdata \ - wget \ - && wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ - && chmod ug=rwx,o=rx dotnet-install.sh \ - && ./dotnet-install.sh --install-dir /usr/local/bin/dotnet --channel 9.0 \ - && rm dotnet-install.sh -ENV DOTNET_ROOT=/usr/local/bin/dotnet \ - PATH=$PATH:/usr/local/bin/dotnet:/usr/local/bin/dotnet/tools \ - DOTNET_USE_POLLING_FILE_WATCHER=true \ - DOTNET_RUNNING_IN_CONTAINER=true - -# Copy source and unit tests -COPY ./Samples/. ./Samples/. -COPY ./PlexCleanerTests/. ./PlexCleanerTests/. -COPY ./PlexCleaner/. ./PlexCleaner/. - -# Unit Test -COPY ./Docker/UnitTest.sh ./ -RUN chmod ug=rwx,o=rx ./UnitTest.sh -RUN ./UnitTest.sh - -# Build -COPY ./Docker/Build.sh ./ -RUN chmod ug=rwx,o=rx ./Build.sh -RUN ./Build.sh - - -# Final layer -FROM debian:testing-slim AS final - -# Image label -ARG LABEL_VERSION="1.0.0.0" -LABEL name="PlexCleaner" \ - version=${LABEL_VERSION} \ - description="Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc." \ - maintainer="Pieter Viljoen " - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -# Upgrade -RUN apt update \ - && apt upgrade -y - -# Install dependencies -RUN apt install -y --no-install-recommends \ - ca-certificates \ - locales \ - locales-all \ - lsb-release \ - p7zip-full \ - tzdata \ - wget \ - && locale-gen --no-purge en_US en_US.UTF-8 - -# Set locale to UTF-8 after running locale-gen -# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md -ENV TZ=Etc/UTC \ - LANG=en_US.UTF-8 \ - LANGUAGE=en_US:en \ - LC_ALL=en_US.UTF-8 - -# Install .NET runtime -# Keep dependencies in sync with SDK install step -RUN apt install -y --no-install-recommends \ - ca-certificates \ - curl \ - libc6 \ - libgcc-s1 \ - libicu76 \ - libssl3t64 \ - libstdc++6 \ - tzdata \ - wget \ - && wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ - && chmod ug=rwx,o=rx dotnet-install.sh \ - && ./dotnet-install.sh --install-dir /usr/local/bin/dotnet --runtime dotnet --channel 9.0 \ - && rm dotnet-install.sh -ENV DOTNET_ROOT=/usr/local/bin/dotnet \ - PATH=$PATH:/usr/local/bin/dotnet:/usr/local/bin/dotnet/tools \ - DOTNET_USE_POLLING_FILE_WATCHER=true \ - DOTNET_RUNNING_IN_CONTAINER=true - -# Install media tools -# https://tracker.debian.org/pkg/ffmpeg -# https://tracker.debian.org/pkg/handbrake -# https://tracker.debian.org/pkg/mediainfo -# https://tracker.debian.org/pkg/mkvtoolnix -RUN apt install -y --no-install-recommends \ - ffmpeg \ - handbrake-cli \ - mediainfo \ - mkvtoolnix - -# Cleanup -RUN apt autoremove -y \ - && apt clean \ - && rm -rf /var/lib/apt/lists/* - -# Copy PlexCleaner from builder layer -COPY --from=builder /Builder/Publish/PlexCleaner/. /PlexCleaner - -# Copy test script -COPY /Docker/Test.sh /Test/ -RUN chmod -R ug=rwx,o=rx /Test - -# Install debug tools -COPY ./Docker/InstallDebugTools.sh ./ -RUN chmod ug=rwx,o=rx ./InstallDebugTools.sh \ - && ./InstallDebugTools.sh \ - && rm -rf ./InstallDebugTools.sh - -# Copy version script -COPY /Docker/Version.sh /PlexCleaner/ -RUN chmod ug=rwx,o=rx /PlexCleaner/Version.sh - -# Print version information -ARG TARGETPLATFORM \ - BUILDPLATFORM -RUN if [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ - /PlexCleaner/Version.sh; \ - fi diff --git a/Docker/README.m4 b/Docker/README.m4 index 923ddf3c..4956ea1f 100644 --- a/Docker/README.m4 +++ b/Docker/README.m4 @@ -15,42 +15,21 @@ Binary releases are published on [GitHub Releases](https://github.com/ptr727/Ple Docker images are published on [Docker Hub](https://hub.docker.com/r/ptr727/plexcleaner).\ Images are updated weekly with the latest upstream updates. -## Docker Builds and Tags +## Usage -- `latest`: Alias for `ubuntu`. -- `develop`: Alias for `ubuntu-develop`. -- `ubuntu`: Based on [Ubuntu Rolling](https://releases.ubuntu.com/) `ubuntu:rolling` latest stable release base image. - - Multi-architecture image supporting `linux/amd64` and `linux/arm64` builds. -- `alpine`: Based on [Alpine Latest](https://alpinelinux.org/releases/) `alpine:latest` latest stable release base image. - - Multi-architecture image supporting `linux/amd64` and `linux/arm64` builds. -- `debian`: Based on [Debian Stable](https://www.debian.org/releases/) `debian:stable-slim` latest stable release base image. - - Multi-architecture image supporting `linux/amd64`, `linux/arm64`, and `linux/arm/v7` builds. -- `*-develop` : Builds from the pre-release [develop branch](https://github.com/ptr727/PlexCleaner/tree/develop). - -## Platform Support - -| Tag | `linux/amd64` | `linux/arm64` | `linux/arm/v7` | Size | -| --- | --- | --- | --- | --- | -| `ubuntu` | ☑ | ☑ | ☐ | ~350MB | -| `alpine` | ☑ | ☑ | ☐ | ~156MB | -| `debian` | ☑ | ☑ | ☑ | ~330MB | - -## Media Tool Versions - -### `ptr727/plexcleaner:ubuntu` +Refer to the [project](https://github.com/ptr727/PlexCleaner) page. -```text -include({{ubuntu.ver}}) -``` - -### `ptr727/plexcleaner:debian` +## Docker Tags -```text -include({{debian.ver}}) -``` +- `latest`: + - Based on [Ubuntu Rolling](https://releases.ubuntu.com/) `ubuntu:rolling` latest stable release base image. + - Multi-architecture image supporting `linux/amd64` and `linux/arm64` builds. + - Builds from the release [main branch](https://github.com/ptr727/PlexCleaner/tree/main). +- `develop`: + - Builds from the pre-release [develop branch](https://github.com/ptr727/PlexCleaner/tree/develop). -### `ptr727/plexcleaner:alpine` +## Image Information ```text -include({{alpine.ver}}) +include({{latest.ver}}) ``` diff --git a/Docker/Test.sh b/Docker/Test.sh index 4a1c885a..e7ee2bd6 100644 --- a/Docker/Test.sh +++ b/Docker/Test.sh @@ -66,7 +66,9 @@ $PlexCleanerApp getversioninfo --settingsfile $SettingsFile # Run process command first $PlexCleanerApp process --settingsfile $SettingsFile --logfile $TestPath/PlexCleaner.log --logwarning --mediafiles $MediaPath --testsnippets --resultsfile $TestPath/Results.json -# Info commands +# Info/Testing commands +$PlexCleanerApp verify --settingsfile $SettingsFile --mediafiles $MediaPath --quickscan +$PlexCleanerApp testmediainfo --settingsfile $SettingsFile --mediafiles $MediaPath $PlexCleanerApp updatesidecar --settingsfile $SettingsFile --mediafiles $MediaPath $PlexCleanerApp getsidecarinfo --settingsfile $SettingsFile --mediafiles $MediaPath $PlexCleanerApp gettagmap --settingsfile $SettingsFile --mediafiles $MediaPath @@ -77,10 +79,11 @@ $PlexCleanerApp gettoolinfo --settingsfile $SettingsFile --mediafiles $MediaPath $PlexCleanerApp createsidecar --settingsfile $SettingsFile --mediafiles $MediaPath # Processing commands -$PlexCleanerApp remux --settingsfile $SettingsFile --mediafiles $MediaPath --testsnippets -$PlexCleanerApp reencode --settingsfile $SettingsFile --mediafiles $MediaPath --testsnippets -$PlexCleanerApp deinterlace --settingsfile $SettingsFile --mediafiles $MediaPath --testsnippets +$PlexCleanerApp remux --settingsfile $SettingsFile --mediafiles $MediaPath +$PlexCleanerApp reencode --settingsfile $SettingsFile --mediafiles $MediaPath +$PlexCleanerApp deinterlace --settingsfile $SettingsFile --mediafiles $MediaPath $PlexCleanerApp removesubtitles --settingsfile $SettingsFile --mediafiles $MediaPath +$PlexCleanerApp removeclosedcaptions --settingsfile $SettingsFile --mediafiles $MediaPath --quickscan # Not readily testable # $PlexCleanerApp monitor --settingsfile $SettingsFile --mediafiles $MediaPath diff --git a/Docker/Ubuntu.Devel.Dockerfile b/Docker/Ubuntu.Devel.Dockerfile deleted file mode 100644 index 84f64162..00000000 --- a/Docker/Ubuntu.Devel.Dockerfile +++ /dev/null @@ -1,147 +0,0 @@ -# Description: Ubuntu development release -# Based on: ubuntu:devel -# .NET install: Ubuntu repository -# Platforms: linux/amd64, linux/arm64 -# Tag: ptr727/plexcleaner:ubuntu-devel - -# Docker build debugging: -# --progress=plain -# --no-cache - -# Test image in shell: -# docker run -it --rm --pull always --name Testing ubuntu:devel /bin/bash -# docker run -it --rm --pull always --name Testing ptr727/plexcleaner:ubuntu-devel /bin/bash -# export DEBIAN_FRONTEND=noninteractive - -# Build Dockerfile -# docker buildx create --name "plexcleaner" --use -# docker buildx build --platform linux/amd64,linux/arm64 --file ./Docker/Ubuntu.Devel.Dockerfile . - -# Test linux/amd64 target -# docker buildx build --load --platform linux/amd64 --tag plexcleaner:ubuntu-devel --file ./Docker/Ubuntu.Devel.Dockerfile . -# docker run -it --rm --name PlexCleaner-Test plexcleaner:ubuntu-devel /bin/bash - - -# Builder layer -FROM --platform=$BUILDPLATFORM ubuntu:devel AS builder - -# Layer workdir -WORKDIR /Builder - -# Build platform args -ARG TARGETPLATFORM \ - TARGETARCH \ - BUILDPLATFORM - -# PlexCleaner build attribute configuration -ARG BUILD_CONFIGURATION="Debug" \ - BUILD_VERSION="1.0.0.0" \ - BUILD_FILE_VERSION="1.0.0.0" \ - BUILD_ASSEMBLY_VERSION="1.0.0.0" \ - BUILD_INFORMATION_VERSION="1.0.0.0" \ - BUILD_PACKAGE_VERSION="1.0.0.0" - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -# Upgrade -RUN apt update \ - && apt upgrade -y - -# Install .NET SDK -# https://packages.ubuntu.com/plucky/dotnet-sdk-9.0 -RUN apt install -y --no-install-recommends dotnet-sdk-9.0 - -# Copy source and unit tests -COPY ./Samples/. ./Samples/. -COPY ./PlexCleanerTests/. ./PlexCleanerTests/. -COPY ./PlexCleaner/. ./PlexCleaner/. - -# Unit Test -COPY ./Docker/UnitTest.sh ./ -RUN chmod ug=rwx,o=rx ./UnitTest.sh -RUN ./UnitTest.sh - -# Build -COPY ./Docker/Build.sh ./ -RUN chmod ug=rwx,o=rx ./Build.sh -RUN ./Build.sh - - -# Final layer -FROM ubuntu:devel AS final - -# Image label -ARG LABEL_VERSION="1.0.0.0" -LABEL name="PlexCleaner" \ - version=${LABEL_VERSION} \ - description="Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc." \ - maintainer="Pieter Viljoen " - -# Prevent EULA and confirmation prompts in installers -ENV DEBIAN_FRONTEND=noninteractive - -# Upgrade -RUN apt update \ - && apt upgrade -y - -# Install dependencies -RUN apt install -y --no-install-recommends \ - ca-certificates \ - locales \ - locales-all \ - p7zip-full \ - tzdata \ - wget \ - && locale-gen --no-purge en_US en_US.UTF-8 - -# Set locale to UTF-8 after running locale-gen -# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md -ENV TZ=Etc/UTC \ - LANG=en_US.UTF-8 \ - LANGUAGE=en_US:en \ - LC_ALL=en_US.UTF-8 - -# Install .NET Runtime -# https://packages.ubuntu.com/plucky/dotnet-runtime-9.0 -RUN apt install -y --no-install-recommends dotnet-runtime-9.0 - -# Install media tools -# https://packages.ubuntu.com/plucky/ffmpeg -# https://packages.ubuntu.com/plucky/handbrake-cli -# https://packages.ubuntu.com/plucky/mediainfo -# https://packages.ubuntu.com/plucky/mkvtoolnix -RUN apt install -y --no-install-recommends \ - ffmpeg \ - handbrake-cli \ - mediainfo \ - mkvtoolnix - -# Cleanup -RUN apt autoremove -y \ - && apt clean \ - && rm -rf /var/lib/apt/lists/* - -# Copy PlexCleaner from builder layer -COPY --from=builder /Builder/Publish/PlexCleaner/. /PlexCleaner - -# Copy test script -COPY /Docker/Test.sh /Test/ -RUN chmod -R ug=rwx,o=rx /Test - -# Install debug tools -COPY ./Docker/InstallDebugTools.sh ./ -RUN chmod ug=rwx,o=rx ./InstallDebugTools.sh \ - && ./InstallDebugTools.sh \ - && rm -rf ./InstallDebugTools.sh - -# Copy version script -COPY /Docker/Version.sh /PlexCleaner/ -RUN chmod ug=rwx,o=rx /PlexCleaner/Version.sh - -# Print version information -ARG TARGETPLATFORM \ - BUILDPLATFORM -RUN if [ "$BUILDPLATFORM" = "$TARGETPLATFORM" ]; then \ - /PlexCleaner/Version.sh; \ - fi diff --git a/Docker/Ubuntu.Rolling.Dockerfile b/Docker/Ubuntu.Rolling.Dockerfile index 1b882927..ebfb559a 100644 --- a/Docker/Ubuntu.Rolling.Dockerfile +++ b/Docker/Ubuntu.Rolling.Dockerfile @@ -49,8 +49,9 @@ RUN apt update \ && apt upgrade -y # Install .NET SDK -# https://packages.ubuntu.com/oracular/dotnet-sdk-9.0 -RUN apt install -y --no-install-recommends dotnet-sdk-9.0 +# https://documentation.ubuntu.com/ubuntu-for-developers/howto/dotnet-setup +# https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install +RUN apt install -y --install-suggests dotnet-sdk-10.0 # Copy source and unit tests COPY ./Samples/. ./Samples/. @@ -58,13 +59,11 @@ COPY ./PlexCleanerTests/. ./PlexCleanerTests/. COPY ./PlexCleaner/. ./PlexCleaner/. # Unit Test -COPY ./Docker/UnitTest.sh ./ -RUN chmod ug=rwx,o=rx ./UnitTest.sh +COPY --chmod=ug=rwx,o=rx ./Docker/UnitTest.sh ./ RUN ./UnitTest.sh # Build -COPY ./Docker/Build.sh ./ -RUN chmod ug=rwx,o=rx ./Build.sh +COPY --chmod=ug=rwx,o=rx ./Docker/Build.sh ./ RUN ./Build.sh @@ -103,14 +102,13 @@ ENV TZ=Etc/UTC \ LC_ALL=en_US.UTF-8 # Install .NET Runtime -# https://packages.ubuntu.com/oracular/dotnet-runtime-9.0 -RUN apt install -y --no-install-recommends dotnet-runtime-9.0 +RUN apt install -y --install-suggests dotnet-runtime-10.0 # Install media tools -# https://packages.ubuntu.com/oracular/ffmpeg -# https://packages.ubuntu.com/oracular/handbrake-cli -# https://packages.ubuntu.com/oracular/mediainfo -# https://packages.ubuntu.com/oracular/mkvtoolnix +# https://packages.ubuntu.com/questing/ffmpeg +# https://packages.ubuntu.com/questing/handbrake-cli +# https://packages.ubuntu.com/questing/mediainfo +# https://packages.ubuntu.com/questing/mkvtoolnix RUN apt install -y --no-install-recommends \ ffmpeg \ handbrake-cli \ @@ -126,18 +124,15 @@ RUN apt autoremove -y \ COPY --from=builder /Builder/Publish/PlexCleaner/. /PlexCleaner # Copy test script -COPY /Docker/Test.sh /Test/ -RUN chmod -R ug=rwx,o=rx /Test +COPY --chmod=ug=rwx,o=rx ./Docker/Test.sh /Test/ # Install debug tools -COPY ./Docker/InstallDebugTools.sh ./ -RUN chmod ug=rwx,o=rx ./InstallDebugTools.sh \ - && ./InstallDebugTools.sh \ +COPY --chmod=ug=rwx,o=rx ./Docker/InstallDebugTools.sh ./ +RUN ./InstallDebugTools.sh \ && rm -rf ./InstallDebugTools.sh # Copy version script -COPY /Docker/Version.sh /PlexCleaner/ -RUN chmod ug=rwx,o=rx /PlexCleaner/Version.sh +COPY --chmod=ug=rwx,o=rx ./Docker/Version.sh /PlexCleaner/ # Print version information ARG TARGETPLATFORM \ diff --git a/Docs/ClosedCaptions.md b/Docs/ClosedCaptions.md new file mode 100644 index 00000000..3fa9bf17 --- /dev/null +++ b/Docs/ClosedCaptions.md @@ -0,0 +1,186 @@ +# EIA-608 and CTA-708 Closed Captions + +[EIA-608](https://en.wikipedia.org/wiki/EIA-608) and [CTA-708](https://en.wikipedia.org/wiki/CTA-708) subtitles, commonly referred to as Closed Captions (CC), are typically used for broadcast television. + +> **ℹ️ TL;DR**: Closed captions (CC) are subtitles embedded in the video stream (not separate tracks). They can cause issues with some players that always display them or cannot disable them. PlexCleaner detects and removes them using the `RemoveClosedCaptions` option with the FFprobe `subcc` filter (most reliable method). Detection requires scanning the entire file (~10-30% of playback time, faster with `--quickscan`). Removal uses FFmpeg's `filter_units` filter without re-encoding. + +## Understanding Closed Captions + +Media containers typically contain separate discrete subtitle tracks, but closed captions can be encoded into the primary video stream. + +Removal of closed captions may be desirable for various reasons, including undesirable content, or players that always burn in closed captions during playback. + +Unlike normal subtitle tracks, detection and removal of closed captions are non-trivial. + +## Technical Details + +> **ℹ️ Note**: I have no expertise in video engineering; the following information was gathered by research and experimentation. + +The currently implemented method of closed caption detection uses [FFprobe and the `subcc` filter](#ffprobe-subcc) to detect closed caption frames in the video stream. + +> **ℹ️ Note**: The `subcc` filter does not support partial file analysis. When the `quickscan` option is enabled, a small file snippet is first created and used for analysis, reducing processing times. + +The [FFmpeg `filter_units` filter](#ffmpeg-filter_units) is used for closed caption removal. + +## Closed Caption Detection + +### FFprobe + +FFprobe used to identify closed caption presence in normal console output, but [does not support](https://github.com/ptr727/PlexCleaner/issues/94) closed caption reporting when using `-print_format json`, and recently [removed reporting](https://github.com/ptr727/PlexCleaner/issues/497) of closed caption presence completely, prompting research into alternatives. + +E.g. `ffprobe filename` + +```text +Stream #0:0(eng): Video: h264 (High), yuv420p(tv, bt709, progressive), 1920x1080, Closed Captions, SAR 1:1 DAR 16:9, 29.97 fps, 29.97 tbr, 1k tbn (default) +``` + +### MediaInfo + +MediaInfo supports closed caption detection, but only for [some container types](https://github.com/MediaArea/MediaInfoLib/issues/2264) (e.g. TS and DV), and [only scans](https://github.com/MediaArea/MediaInfoLib/issues/1881) the first 30s of the video looking for video frames containing closed captions. + +E.g. `mediainfo --Output=JSON filename` + +MediaInfo does [not support](https://github.com/MediaArea/MediaInfoLib/issues/1881#issuecomment-2816754336) general input piping (e.g. MKV -> FFmpeg -> TS -> MediaInfo), and requires a temporary TS file to be created on disk and used as standard input. + +In my testing I found that remuxing 30s of video from MKV to TS did produce reliable results. + +E.g. + +```json +{ + "@type": "Text", + "ID": "256-1", + "Format": "EIA-708", + "MuxingMode": "A/53 / DTVCC Transport", +}, +``` + +### CCExtractor + +[CCExtractor](https://ccextractor.org/) supports closed caption detection using `-out=report`. + +E.g. `ccextractor -12 -out=report filename` + +In my testing I found using MKV containers directly as input produced unreliable results, either no output generated or false negatives. + +CCExtractor does support input piping, but I found it to be unreliable with broken pipes, and requires a temporary TS file to be created on disk and used as standard input. + +Even in TS format on disk, it is very sensitive to stream anomalies, e.g. `Error: Broken AVC stream - forbidden_zero_bit not zero ...`, making it unreliable. + +E.g. + +```text +EIA-608: Yes +CEA-708: Yes +``` + +## FFprobe `readeia608` + +FFmpeg [`readeia608` filter](https://ffmpeg.org/ffmpeg-filters.html#readeia608) can be used in FFprobe to report EIA-608 frame information. + +E.g. + +```shell +ffprobe -loglevel error -f lavfi -i "movie=filename,readeia608" -show_entries frame=best_effort_timestamp_time,duration_time:frame_tags=lavfi.readeia608.0.line,lavfi.readeia608.0.cc,lavfi.readeia608.1.line,lavfi.readeia608.1.cc -print_format json +``` + +The `movie=filename[out0+subcc]` convention requires [special escaping](https://superuser.com/questions/1893137/how-to-quote-a-file-name-containing-single-quotes-in-ffmpeg-ffprobe-movie-filena) of the filename to not interfere with commandline or filter graph parsing. + +In my testing I found only one [IMX sample](https://archive.org/details/vitc_eia608_sample) that produced the expected results, making it unreliable. + +E.g. + +```json +{ + "best_effort_timestamp_time": "0.000000", + "duration_time": "0.033367", + "tags": { + "lavfi.readeia608.1.cc": "0x8504", + "lavfi.readeia608.0.cc": "0x8080", + "lavfi.readeia608.0.line": "28", + "lavfi.readeia608.1.line": "29" + }, +} +``` + +### FFprobe `subcc` + +FFmpeg [`subcc` filter](https://www.ffmpeg.org/ffmpeg-devices.html#Options-10) can be used in FFprobe to create subtitle streams from the closed captions embedded in video streams. + +E.g. + +```shell +ffprobe -loglevel error -select_streams s:0 -f lavfi -i "movie=filename[out0+subcc]" -show_packets -print_format json +``` + +E.g. + +```shell +ffmpeg -abort_on empty_output -y -f lavfi -i "movie=filename[out0+subcc]" -map 0:s -c:s srt outfilename +``` + +The `ffmpeg -t` and `ffprobe -read_intervals` options limiting scan time does [not work](https://superuser.com/questions/1893673/how-to-time-limit-the-input-stream-duration-when-using-movie-filenameout0subcc) on the input stream when using the `subcc` filter, and scanning the entire file can take a very long time. + +In my testing I found the results to be reliable across a wide variety of files. + +E.g. + +```json +{ + "codec_type": "subtitle", + "stream_index": 1, + "pts_time": "0.000000", + "dts_time": "0.000000", + "size": "60", + "pos": "5690", + "flags": "K__" +}, +``` + +```text +9 +00:00:35,568 --> 00:00:38,004 +{\an7}No going back now. +``` + +### FFprobe `analyze_frames` + +FFprobe [recently added](https://github.com/FFmpeg/FFmpeg/commit/90af8e07b02e690a9fe60aab02a8bccd2cbf3f01) the `analyze_frames` [option](https://ffmpeg.org/ffprobe.html#toc-Main-options) that reports on the presence of closed captions in video streams. + +As of writing this functionality has not yet been released, but is only in nightly builds. + +E.g. + +```shell +ffprobe -loglevel error -show_streams -analyze_frames -read_intervals %180 filename -print_format json +``` + +```json +{ + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "coded_width": 1920, + "coded_height": 1088, + "closed_captions": 1, + "film_grain": 0, +} +``` + +The FFprobe `analyze_frames` method of detection will be implemented when broadly supported. + +## Closed Caption Removal + +### FFmpeg `filter_units` + +FFmpeg [`filter_units` filter](https://ffmpeg.org/ffmpeg-bitstream-filters.html#filter_005funits) can be used to [remove closed captions](https://stackoverflow.com/questions/48177694/removing-eia-608-closed-captions-from-h-264-without-reencode) from video streams. + +E.g. + +```shell +ffmpeg -loglevel error -i [in-filename] -c copy -map 0 -bsf:v filter_units=remove_types=6 [out-filename] +``` + +Closed captions SEI unit for H264 is `6`, `39` for H265, and `178` for MPEG2. + +> **ℹ️ Note**: [Wiki](https://trac.ffmpeg.org/wiki/HowToExtractAndRemoveClosedCaptions) and [issue](https://trac.ffmpeg.org/ticket/5283); as of writing HDR10+ metadata may be lost when removing closed captions from H265 content. diff --git a/Docs/CustomOptions.md b/Docs/CustomOptions.md new file mode 100644 index 00000000..274d1900 --- /dev/null +++ b/Docs/CustomOptions.md @@ -0,0 +1,106 @@ +# Custom FFmpeg and HandBrake CLI Parameters + +Custom encoding settings for FFmpeg and Handbrake. + +## Understanding Custom Settings + +The `ConvertOptions:FfMpegOptions` and `ConvertOptions:HandBrakeOptions` settings allow custom CLI parameters for media processing. This is useful for: + +- Hardware-accelerated encoding (GPU encoding via NVENC, QuickSync, etc.). +- Custom quality/speed tradeoffs (CRF values, presets). +- Alternative codecs (AV1, VP9, etc.). + +> **ℹ️ Note**: Hardware encoding options are operating system, hardware, and tool version specific.\ +Refer to the Jellyfin hardware acceleration [docs](https://jellyfin.org/docs/general/administration/hardware-acceleration/) for hints on usage. +The example configurations are from documentation and minimal testing with Intel QuickSync on Windows only, please discuss and post working configurations in [GitHub Discussions](https://github.com/ptr727/PlexCleaner/discussions). + +## FFmpeg Options + +See the [FFmpeg documentation](https://ffmpeg.org/ffmpeg.html) for complete commandline option details. + +The typical FFmpeg commandline is: + +```text +ffmpeg [global_options] {[input_file_options] -i input_url} ... {[output_file_options] output_url} +``` + +E.g.: + +```shell +ffmpeg -analyzeduration 2147483647 -probesize 2147483647 -i /media/foo.mkv -max_muxing_queue_size 1024 -abort_on empty_output -hide_banner -nostats -map 0 -c:v libx265 -crf 26 -preset medium -c:a ac3 -c:s copy -f matroska /media/bar.mkv +``` + +Settings allows for custom configuration of: + +- `FfMpegOptions:Global`: Custom hardware global options, e.g. `-hwaccel cuda -hwaccel_output_format cuda` +- `FfMpegOptions:Video`: Video encoder options following the `-c:v` parameter, e.g. `libx264 -crf 22 -preset medium` +- `FfMpegOptions:Audio`: Audio encoder options following the `-c:a` parameter, e.g. `ac3` + +Get encoder options: + +- List hardware acceleration methods: `ffmpeg -hwaccels` +- List supported encoders: `ffmpeg -encoders` +- List options supported by an encoder: `ffmpeg -h encoder=libsvtav1` + +Example video encoder options: + +- [H.264](https://trac.ffmpeg.org/wiki/Encode/H.264): `libx264 -crf 22 -preset medium` +- [H.265](https://trac.ffmpeg.org/wiki/Encode/H.265): `libx265 -crf 26 -preset medium` +- [AV1](https://trac.ffmpeg.org/wiki/Encode/AV1): `libsvtav1 -crf 30 -preset 5` + +Example hardware assisted video encoding options: + +- NVidia NVENC: + - See [FFmpeg NVENC](https://trac.ffmpeg.org/wiki/HWAccelIntro#CUDANVENCNVDEC) documentation. + - View NVENC encoder options: `ffmpeg -h encoder=h264_nvenc` + - `FfMpegOptions:Global`: `-hwaccel cuda -hwaccel_output_format cuda` + - `FfMpegOptions:Video`: `h264_nvenc -preset medium` +- Intel QuickSync: + - See [FFmpeg QuickSync](https://trac.ffmpeg.org/wiki/Hardware/QuickSync) documentation. + - View QuickSync encoder options: `ffmpeg -h encoder=h264_qsv` + - `FfMpegOptions:Global`: `-hwaccel qsv -hwaccel_output_format qsv` + - `FfMpegOptions:Video`: `h264_qsv -preset medium` + +## HandBrake Options + +See the [HandBrake documentation](https://handbrake.fr/docs/en/latest/cli/command-line-reference.html) for complete commandline option details. + +The typical HandBrake commandline is: + +```text +HandBrakeCLI [options] -i -o +``` + +E.g. + +```shell +HandBrakeCLI --input /media/foo.mkv --output /media/bar.mkv --format av_mkv --encoder x265 --quality 26 --encoder-preset medium --comb-detect --decomb --all-audio --aencoder copy --audio-fallback ac3 +``` + +Settings allows for custom configuration of: + +- `HandBrakeOptions:Video`: Video encoder options following the `--encode` parameter, e.g. `x264 --quality 22 --encoder-preset medium` +- `HandBrakeOptions:Audio`: Audio encoder options following the `--aencode` parameter, e.g. `copy --audio-fallback ac3` + +Get encoder options: + +- List all supported encoders: `HandBrakeCLI --help` +- List presets supported by an encoder: `HandBrakeCLI --encoder-preset-list svt_av1` + +Example video encoder options: + +- H.264: `x264 --quality 22 --encoder-preset medium` +- H.265: `x265 --quality 26 --encoder-preset medium` +- AV1: `svt_av1 --quality 30 --encoder-preset 5` + +Example hardware assisted video encoding options: + +- NVidia NVENC: + - See [HandBrake NVENC](https://handbrake.fr/docs/en/latest/technical/video-nvenc.html) documentation. + - `HandBrakeOptions:Video`: `nvenc_h264 --encoder-preset medium` +- Intel QuickSync: + - See [HandBrake QuickSync](https://handbrake.fr/docs/en/latest/technical/video-qsv.html) documentation. + - `HandBrakeOptions:Video`: `qsv_h264 --encoder-preset balanced` + +> **ℹ️ Note**: HandBrake is primarily used for video deinterlacing, and only as backup encoder when FFmpeg fails.\ +The default `HandBrakeOptions:Audio` configuration is set to `copy --audio-fallback ac3` that will copy all supported audio tracks as is, and only encode to `ac3` if the audio codec is not natively supported. diff --git a/Docs/LanguageMatching.md b/Docs/LanguageMatching.md new file mode 100644 index 00000000..c13e4607 --- /dev/null +++ b/Docs/LanguageMatching.md @@ -0,0 +1,27 @@ +# IETF Language Matching + +Language tag matching supports [IETF / RFC 5646 / BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) tag formats as implemented by [MkvMerge](https://codeberg.org/mbunkus/mkvtoolnix/wiki/Languages-in-Matroska-and-MKVToolNix). + +## Understanding Language Matching + +Tags are in the form of `language-extlang-script-region-variant-extension-privateuse`, and matching happens left to right (most specific to least specific). + +Examples: + +- `pt` matches: `pt` Portuguese, `pt-BR` Brazilian Portuguese, `pt-PT` European Portuguese. +- `pt-BR` matches: only `pt-BR` Brazilian Portuguese. +- `zh` matches: `zh` Chinese, `zh-Hans` simplified Chinese, `zh-Hant` traditional Chinese, and other variants. +- `zh-Hans` matches: only `zh-Hans` simplified Chinese. + +## Technical Details + +During processing the absence of IETF language tags will be treated as a track warning, and an RFC 5646 IETF language will be temporarily assigned based on the ISO639-2B tag.\ +If `ProcessOptions.SetIetfLanguageTags` is enabled MkvMerge will be used to remux the file using the `--normalize-language-ietf extlang` option, see the [MkvMerge documentation](https://mkvtoolnix.download/doc/mkvmerge.html) for more details. + +Normalized tags will be expanded for matching.\ +E.g. `cmn-Hant` will be expanded to `zh-cmn-Hant` allowing matching with `zh`. + +## References + +- See the [W3C Language tags in HTML and XML](https://www.w3.org/International/articles/language-tags/) and [BCP47 language subtag lookup](https://r12a.github.io/app-subtags/) for technical details. +- Language tag matching is implemented using the [LanguageTags](https://github.com/ptr727/LanguageTags) library. diff --git a/HISTORY.md b/HISTORY.md index c191bf50..560ffb91 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -4,18 +4,49 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. ## Release History +- Version 3.15: + - This is primarily a code refactoring release. + - Updated from .NET 9 to .NET 10. + - Added [Nullable types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/nullable-value-types) support. + - Added [Native AOT](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot) support. + - Replaced `JsonSchemaBuilder.FromType()` with `GetJsonSchemaAsNode()` as `FromType()` is [not AOT compatible](https://github.com/json-everything/json-everything/issues/975). + - Replaced `JsonSerializer.Deserialize()` with `JsonSerializer.Deserialize(JsonSerializerContext)` for generating [AOT compatible](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.serialization.jsonserializercontext) JSON serialization code. + - Replaced `MethodBase.GetCurrentMethod()?.Name` with `[System.Runtime.CompilerServices.CallerMemberName]` to generate the caller function name during compilation. + - AOT cross compilation is [not supported](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/cross-compile) by the CI/CD pipeline and single file native AOT binaries can be [manually built](./README.md#aot) if needed. + - Changed MediaInfo output from `--Output=XML` using XML to `--Output=JSON` using JSON. + - Attempts to use `Microsoft.XmlSerializer.Generator` and generate AOT compatible XML parsing was [unsuccessful](https://stackoverflow.com/questions/79858800/statically-generated-xml-parsing-code-using-microsoft-xmlserializer-generator), while JSON `JsonSerializerContext` is AOT compatible. + - Parsing the existing XML schema is done with custom AOT compatible XML parser created for the MediaInfo XML content. + - SidecarFile schema changed from v4 to v5 to account for XML to JSON content change. + - Schema will automatically be upgraded and convert XML to JSON equivalent on reading. + - Using [`ArrayPool.Shared.Rent()`](https://learn.microsoft.com/en-us/dotnet/api/system.buffers.arraypool-1) vs. `new byte[]` to improve memory pressure during sidecar hash calculations. + - Removed `MonitorOptions` from the config file schema, default values do not need to be changed. + - ⚠️ Standardized on only using the Ubuntu [rolling](https://releases.ubuntu.com/) docker base image. + - No longer publishing Debian or Alpine based docker images, or images supporting `linux/arm/v7`. + - The media tool versions published with the rolling release are typically current, and matches the versions available on Windows, offering a consistent experience, and requires less testing due to changes in behavior between versions. +- Version 3.14: + - Switch to using [CliWrap][cliwrap-link] for commandline tool process execution. + - Remove dependency on [deprecated](https://github.com/dotnet/command-line-api/issues/2576) `System.CommandLine.NamingConventionBinder` by directly using commandline options binding. + - Converted media tool commandline creation to using fluent builder pattern. + - Converted FFprobe JSON packet parsing to using streaming per-packet processing using [Utf8JsonAsyncStreamReader][utf8jsonasync-link] vs. read everything into memory and then process. + - Switched editorconfig `charset` from `utf-8-bom` to `utf-8` as some tools and PR merge in GitHub always write files without the BOM. + - Improved closed caption detection in MediaInfo, e.g. discrete detection of separate `SCTE 128` tracks vs. `A/53` embedded video tracks. + - Improved media tool parsing resiliency when parsing non-Matroska containers, i.e. added `testmediainfo` command to attempt parsing media files. + - Add [Husky.Net](https://alirezanet.github.io/Husky.Net) for pre-commit hook code style validation. + - General refactoring. +- Version 3.13: + - Escape additional filename characters for use with `ffprobe movie=filename[out0+subcc]` command. Fixes [#524](https://github.com/ptr727/PlexCleaner/issues/524). - Version 3:12: - Update to .NET 9.0. - - Dropping Ubuntu docker `arm/v7` support as .NET for ARM32 is no longer published in the Ubuntu repository. + - ⚠️ Dropping Ubuntu docker `arm/v7` support as .NET for ARM32 is no longer published in the Ubuntu repository. - Switching Debian docker builds to install .NET using install script as the Microsoft repository now only supports x64 builds. (Ubuntu and Alpine still installing .NET using the distribution repository.) - Updated code style [`.editorconfig`](./.editorconfig) to closely follow the Visual Studio and .NET Runtime defaults. - Set [CSharpier](https://csharpier.com/) as default C# code formatter. - - Removed docker [`UbuntuDevel.Dockerfile`](./Docker/Ubuntu.Devel.Dockerfile), [`AlpineEdge.Dockerfile`](./Docker/Alpine.Edge.Dockerfile), and [`DebianTesting.Dockerfile`](./Docker/Debian.Testing.Dockerfile) builds from CI as theses OS pre-release / Beta builds were prone to intermittent build failures. If "bleeding edge" media tools are required local builds can be done using the Dockerfile. + - ⚠️ Removed docker [`UbuntuDevel.Dockerfile`](./Docker/Ubuntu.Devel.Dockerfile), [`AlpineEdge.Dockerfile`](./Docker/Alpine.Edge.Dockerfile), and [`DebianTesting.Dockerfile`](./Docker/Debian.Testing.Dockerfile) builds from CI as theses OS pre-release / Beta builds were prone to intermittent build failures. If "bleeding edge" media tools are required local builds can be done using the Dockerfile. - Updated 7-Zip version number parsing to account for newly [observed](./PlexCleanerTests/VersionParsingTests.cs) variants. - EIA-608 and CTA-708 closed caption detection was reworked due to FFmpeg [removing](https://code.ffmpeg.org/FFmpeg/FFmpeg/commit/19c95ecbff84eebca254d200c941ce07868ee707) easy detection using FFprobe. - See the [EIA-608 and CTA-708 Closed Captions](./README.md#eia-608-and-cta-708-closed-captions) section for details. - Refactored the logic used to determine if a video stream should be considered to contain closed captions. - - Note that detection may have been broken since the release of FFmpeg v7, it is possible that media files may be in the `Verified` state with closed captions being undetected, run the `removeclosedcaptions` command to re-detect and remove closed captions. + - Detection may have been broken since the release of FFmpeg v7, it is possible that media files may be in the `Verified` state with closed captions being undetected, run the `removeclosedcaptions` command to re-detect and remove closed captions. - Interlace and Telecine detection is complicated and this implementation using track flags and `idet` is naive and may not be reliable, changed `DeInterlace` to default to `false`. - Re-added `parallel` and `threadcount` option to `monitor` command, fixes [#498](https://github.com/ptr727/PlexCleaner/issues/498). - Added conditional checks for `ReMux` to warn when disabled and media must be modified for processing logic to work as intended, e.g. removing extra video streams, removing cover art, etc. @@ -40,7 +71,7 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. - No longer pre-installing VS Debug Tools in docker builds, replaced with [`DebugTools.sh`](./Docker//DebugTools.sh) script that can be used to install [VS Debug Tools](https://learn.microsoft.com/en-us/visualstudio/debugger/remote-debugging-dotnet-core-linux-with-ssh) and [.NET Diagnostic Tools](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/tools-overview) if required. - Version 3.8: - Added Alpine Stable and Edge, Debian Stable and Testing, and Ubuntu Rolling and Devel docker builds. - - Removed ArchLinux docker build, only supported x64 and media tool versions were often lagging. + - ⚠️ Removed ArchLinux docker build, only supported x64 and media tool versions were often lagging. - No longer using MCR base images with .NET pre-installed, support for new linux distribution versions were often lagging. - Alpine Stable builds are still [disabled](https://github.com/ptr727/PlexCleaner/issues/344), waiting for Alpine 3.20 to be released, ETA 1 June 2024. - Rob Savoury [announced][savoury-link] that due to a lack of funding Ubuntu Noble 24.04 LTS will not get PPA support. @@ -60,7 +91,7 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. - Changed JSON serialization from `Newtonsoft.Json` [to](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/migrate-from-newtonsoft) .NET native `Text.Json`. - Changed JSON schema generation from `Newtonsoft.Json.Schema` [to][jsonschema-link] `JsonSchema.Net.Generation`. - Fixed issue with old settings schemas not upgrading as expected, and updated associated unit tests to help catch this next time. - - Disabling Alpine Edge builds, Handbrake is [failing](https://gitlab.alpinelinux.org/alpine/aports/-/issues/15979) to install, again. + - ⚠️ Disabling Alpine Edge builds, Handbrake is [failing](https://gitlab.alpinelinux.org/alpine/aports/-/issues/15979) to install, again. - Will re-enable Alpine builds if Alpine 3.20 and Handbrake is stable. - Version 3.6: - Disabling Alpine 3.19 release builds and switching to Alpine Edge. @@ -78,7 +109,7 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. - Updating the tool itself is still a manual process. - Alternatively subscribe to GitHub [Release Notifications][github-release-notification]. - Added `verify` command option to verify media streams in files. - - Note that only media stream validation is performed, track-, bitrate-, and HDR verification is only performed as part of the `process` command. + - Only media stream validation is performed, track-, bitrate-, and HDR verification is only performed as part of the `process` command. - The `verify` command is useful when testing or selecting from multiple available media sources. - Version 3.3: - Download Windows FfMpeg builds from [GyanD FfMpeg GitHub mirror](https://github.com/GyanD/codexffmpeg), may help with [#214](https://github.com/ptr727/PlexCleaner/issues/214). @@ -104,7 +135,7 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. - On v3 schema upgrade old `ConvertOptions` settings will be upgrade to equivalent settings. - Added support for [IETF / RFC 5646 / BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) language tag formats. - See the [Language Matching](./README.md#language-matching) section usage for details. - - IETF language tags allows for greater flexibility in Matroska player [language matching](https://gitlab.com/mbunkus/mkvtoolnix/-/wikis/Languages-in-Matroska-and-MKVToolNix). + - IETF language tags allows for greater flexibility in Matroska player [language matching](https://codeberg.org/mbunkus/mkvtoolnix/wiki/Languages-in-Matroska-and-MKVToolNix). - E.g. `pt-BR` for Brazilian Portuguese vs. `por` for Portuguese. - E.g. `zh-Hans` for simplified Chinese vs. `chi` for Chinese. - Update `ProcessOptions:DefaultLanguage` and `ProcessOptions:KeepLanguages` from ISO 639-2B to RFC 5646 format, e.g. `eng` to `en`. @@ -141,8 +172,8 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. - Changed the process exit code to return `1` vs. `-1` in case of error, more conformant with standard exit codes, `0` remains success. - Settings JSON schema updated from v2 to v3 to account for new and modified settings. - Older settings schemas will automatically be upgraded with compatible settings to v3 on first run. - - *Breaking Change* Removed the `reprocess` commandline option, logic was very complex with limited value, use `reverify` instead. - - *Breaking Change* Refactored commandline arguments to only add relevant options to commands that use them vs. adding global options to all commands. + - ⚠️ Removed the `reprocess` commandline option, logic was very complex with limited value, use `reverify` instead. + - ⚠️ Refactored commandline arguments to only add relevant options to commands that use them vs. adding global options to all commands. - Maintaining commandline backwards compatibility was [complicated](https://github.com/dotnet/command-line-api/issues/2023), and the change is unfortunately a breaking change. - The following global options have been removed and added to their respective commands: - `--settingsfile` used by several commands. @@ -281,11 +312,11 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. - Support for H.265 encoding added. - All file metadata, titles, tags, and track names are now deleted during media file cleanup. - Windows systems will be kept awake during processing. - - Schema version numbers were added to JSON config files, breaking backwards compatibility. + - ⚠️ Schema version numbers were added to JSON config files, breaking backwards compatibility. - Sidecar JSON will be invalid and recreated, including re-verifying that can be very time consuming. - Tools JSON will be invalid and `checkfortools` should be used to update tools. - Tool version numbers are now using the short version number, allowing for Sidecar compatibility between Windows and Linux. - - Processing of the same media can be mixed between Windows, Linux, and Docker, note that the paths in the `FileIgnoreList` setting are platform specific. + - Processing of the same media can be mixed between Windows, Linux, and Docker, but the paths in the `FileIgnoreList` setting are platform specific. - New options were added to the JSON config file. - `ConvertOptions:EnableH265Encoder`: Enable H.265 encoding vs. H.264. - `ToolsOptions:UseSystem`: Use tools from the system path vs. from the Tools folder, this is the default on Linux. @@ -294,7 +325,9 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. - File logging and console output is now done using structured Serilog logging. - Basic console and file logging options are used, configuration from JSON is not currently supported. +[cliwrap-link]: https://github.com/Tyrrrz/CliWrap [docker-link]: https://hub.docker.com/r/ptr727/plexcleaner -[savoury-link]: https://launchpad.net/~savoury1 [github-release-notification]: https://docs.github.com/en/account-and-profile/managing-subscriptions-and-notifications-on-github/managing-subscriptions-for-activity-on-github/viewing-your-subscriptions [jsonschema-link]: https://json-everything.net/json-schema/ +[savoury-link]: https://launchpad.net/~savoury1 +[utf8jsonasync-link]: https://github.com/gragra33/Utf8JsonAsyncStreamReader diff --git a/PlexCleaner.code-workspace b/PlexCleaner.code-workspace index 50ef1733..8eb52a44 100644 --- a/PlexCleaner.code-workspace +++ b/PlexCleaner.code-workspace @@ -159,6 +159,7 @@ "Pieter", "Plex", "plexcleaner", + "PLINQ", "preprocess", "printmediainfo", "printsidecar", @@ -236,7 +237,7 @@ "yaml.schemas": { "https://json.schemastore.org/github-issue-config.json": "file:/.github/ISSUE_TEMPLATE/config.yml" }, - "dotnet.defaultSolution": "PlexCleaner.sln", + "dotnet.defaultSolution": "PlexCleaner.slnx", "files.trimTrailingWhitespace": true, "[markdown]": { "files.trimTrailingWhitespace": false @@ -256,7 +257,8 @@ "dotnet.formatting.organizeImportsOnFormat": true, "csharp.debug.symbolOptions.searchNuGetOrgSymbolServer": true, "csharp.debug.symbolOptions.searchMicrosoftSymbolServer": true, - "files.encoding": "utf8" + "files.encoding": "utf8", + "markdown.extension.toc.levels": "2..3" }, "extensions": { "recommendations": [ diff --git a/PlexCleaner.defaults.json b/PlexCleaner.defaults.json index b130c16b..8971fc2a 100644 --- a/PlexCleaner.defaults.json +++ b/PlexCleaner.defaults.json @@ -18,6 +18,7 @@ "*.jpg", "*.nfo", "*.partial~", + "*.png", "*.sample.*", "*.sample", "*.smi", @@ -40,6 +41,8 @@ ".m2ts", ".m4v", ".mp4", + ".mpg", + ".mov", ".ts", ".vob", ".wmv" @@ -175,13 +178,5 @@ "RegisterInvalidFiles": false, // Warn when bitrate in bits per second is exceeded "MaximumBitrate": 100000000 - }, - "MonitorOptions": { - // Time to wait in seconds after detecting a file change - "MonitorWaitTime": 60, - // Time to wait in seconds between file retry operations - "FileRetryWaitTime": 5, - // Maximum number of file retry operations - "FileRetryCount": 2 } } diff --git a/PlexCleaner.schema.json b/PlexCleaner.schema.json index a101cfc2..6d0af249 100644 --- a/PlexCleaner.schema.json +++ b/PlexCleaner.schema.json @@ -1,95 +1,99 @@ { - "type": "object", + "type": [ + "object", + "null" + ], "properties": { - "ConvertOptions": { - "type": "object", - "properties": { - "FfMpegOptions": { - "type": "object", - "properties": { - "Audio": { - "type": "string" - }, - "Global": { - "type": "string" - }, - "Video": { - "type": "string" - } - } - }, - "HandBrakeOptions": { - "type": "object", - "properties": { - "Audio": { - "type": "string" - }, - "Video": { - "type": "string" - } - } - } - } + "$schema": { + "type": [ + "string", + "null" + ] + }, + "SchemaVersion": { + "type": [ + "string", + "integer" + ], + "pattern": "^-?(?:0|[1-9]\\d*)$" }, - "MonitorOptions": { + "ToolsOptions": { "type": "object", "properties": { - "FileRetryCount": { - "type": "integer" + "UseSystem": { + "type": "boolean" + }, + "RootPath": { + "type": "string" }, - "FileRetryWaitTime": { - "type": "integer" + "RootRelative": { + "type": "boolean" }, - "MonitorWaitTime": { - "type": "integer" + "AutoUpdate": { + "type": "boolean" } - } + }, + "required": [ + "UseSystem", + "RootPath", + "RootRelative", + "AutoUpdate" + ] }, "ProcessOptions": { "type": "object", "properties": { - "DefaultLanguage": { - "type": "string" + "FileIgnoreMasks": { + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } }, - "DeInterlace": { + "KeepOriginalLanguage": { "type": "boolean" }, - "DeleteEmptyFolders": { + "RemoveClosedCaptions": { "type": "boolean" }, - "DeleteUnwantedExtensions": { + "SetIetfLanguageTags": { "type": "boolean" }, - "FileIgnoreList": { - "$ref": "#/$defs/hashSetOfString" - }, - "FileIgnoreMasks": { - "$ref": "#/$defs/hashSetOfString" - }, - "KeepLanguages": { - "$ref": "#/$defs/hashSetOfString" - }, - "KeepOriginalLanguage": { + "SetTrackFlags": { "type": "boolean" }, - "PreferredAudioFormats": { - "$ref": "#/$defs/hashSetOfString" - }, - "ReEncode": { - "type": "boolean" + "KeepExtensions": { + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } }, - "ReEncodeAudioFormats": { - "$ref": "#/$defs/hashSetOfString" + "ReMuxExtensions": { + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } }, "ReEncodeVideo": { "type": "array", "items": { - "type": "object", + "type": [ + "object", + "null" + ], "properties": { - "Codec": { + "Format": { "type": "string" }, - "Format": { + "Codec": { "type": "string" }, "Profile": { @@ -98,70 +102,180 @@ } } }, - "RemoveClosedCaptions": { - "type": "boolean" + "ReEncodeAudioFormats": { + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } }, - "RemoveDuplicateTracks": { - "type": "boolean" + "KeepLanguages": { + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } }, - "RemoveTags": { + "PreferredAudioFormats": { + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + }, + "ReEncodeVideoFormats": { + "type": "string" + }, + "ReEncodeVideoCodecs": { + "type": "string" + }, + "ReEncodeVideoProfiles": { + "type": "string" + }, + "DeleteEmptyFolders": { "type": "boolean" }, - "RemoveUnwantedLanguageTracks": { + "DeleteUnwantedExtensions": { "type": "boolean" }, "ReMux": { "type": "boolean" }, - "ReMuxExtensions": { - "$ref": "#/$defs/hashSetOfString" + "DeInterlace": { + "type": "boolean" }, - "RestoreFileTimestamp": { + "ReEncode": { "type": "boolean" }, - "SetIetfLanguageTags": { + "SetUnknownLanguage": { "type": "boolean" }, - "SetTrackFlags": { + "DefaultLanguage": { + "type": "string" + }, + "RemoveUnwantedLanguageTracks": { "type": "boolean" }, - "SetUnknownLanguage": { + "RemoveDuplicateTracks": { "type": "boolean" }, - "SidecarUpdateOnToolChange": { + "RemoveTags": { "type": "boolean" }, "UseSidecarFiles": { "type": "boolean" }, + "SidecarUpdateOnToolChange": { + "type": "boolean" + }, "Verify": { "type": "boolean" + }, + "RestoreFileTimestamp": { + "type": "boolean" + }, + "FileIgnoreList": { + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } } - } - }, - "$schema": { - "type": "string", - "readOnly": true - }, - "SchemaVersion": { - "type": "integer" + }, + "required": [ + "FileIgnoreMasks", + "KeepOriginalLanguage", + "RemoveClosedCaptions", + "SetIetfLanguageTags", + "SetTrackFlags", + "ReMuxExtensions", + "ReEncodeVideo", + "ReEncodeAudioFormats", + "KeepLanguages", + "PreferredAudioFormats", + "DeleteEmptyFolders", + "DeleteUnwantedExtensions", + "ReMux", + "DeInterlace", + "ReEncode", + "SetUnknownLanguage", + "DefaultLanguage", + "RemoveUnwantedLanguageTracks", + "RemoveDuplicateTracks", + "RemoveTags", + "UseSidecarFiles", + "SidecarUpdateOnToolChange", + "Verify", + "RestoreFileTimestamp", + "FileIgnoreList" + ] }, - "ToolsOptions": { + "ConvertOptions": { "type": "object", "properties": { - "AutoUpdate": { - "type": "boolean" + "FfMpegOptions": { + "type": "object", + "properties": { + "Video": { + "type": "string" + }, + "Audio": { + "type": "string" + }, + "Global": { + "type": "string" + }, + "Output": { + "type": "string" + } + }, + "required": [ + "Video", + "Audio", + "Global" + ] }, - "RootPath": { - "type": "string" + "HandBrakeOptions": { + "type": "object", + "properties": { + "Video": { + "type": "string" + }, + "Audio": { + "type": "string" + } + }, + "required": [ + "Video", + "Audio" + ] }, - "RootRelative": { + "EnableH265Encoder": { "type": "boolean" }, - "UseSystem": { - "type": "boolean" + "VideoEncodeQuality": { + "type": [ + "string", + "integer" + ], + "pattern": "^-?(?:0|[1-9]\\d*)$" + }, + "AudioEncodeCodec": { + "type": "string" } - } + }, + "required": [ + "FfMpegOptions", + "HandBrakeOptions" + ] }, "VerifyOptions": { "type": "object", @@ -172,23 +286,60 @@ "DeleteInvalidFiles": { "type": "boolean" }, - "MaximumBitrate": { - "type": "integer" - }, "RegisterInvalidFiles": { "type": "boolean" + }, + "MinimumDuration": { + "type": [ + "string", + "integer" + ], + "pattern": "^-?(?:0|[1-9]\\d*)$" + }, + "VerifyDuration": { + "type": [ + "string", + "integer" + ], + "pattern": "^-?(?:0|[1-9]\\d*)$" + }, + "IdetDuration": { + "type": [ + "string", + "integer" + ], + "pattern": "^-?(?:0|[1-9]\\d*)$" + }, + "MaximumBitrate": { + "type": [ + "string", + "integer" + ], + "pattern": "^-?(?:0|[1-9]\\d*)$" + }, + "MinimumFileAge": { + "type": [ + "string", + "integer" + ], + "pattern": "^-?(?:0|[1-9]\\d*)$" } - } - } - }, - "$defs": { - "hashSetOfString": { - "type": "array", - "items": { - "type": "string" - } + }, + "required": [ + "AutoRepair", + "DeleteInvalidFiles", + "RegisterInvalidFiles", + "MaximumBitrate" + ] } }, + "required": [ + "SchemaVersion", + "ToolsOptions", + "ProcessOptions", + "ConvertOptions", + "VerifyOptions" + ], "title": "PlexCleaner Configuration Schema", "$id": "https://raw.githubusercontent.com/ptr727/PlexCleaner/main/PlexCleaner.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema" diff --git a/PlexCleaner.sln b/PlexCleaner.sln deleted file mode 100644 index 18520c4a..00000000 --- a/PlexCleaner.sln +++ /dev/null @@ -1,86 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.31903.286 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlexCleaner", "PlexCleaner\PlexCleaner.csproj", "{1A686E6C-DD3F-4D71-A5F1-DCEE8F872A2F}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{D8F3D73D-A71B-4B79-982F-8491CCFB893A}" - ProjectSection(SolutionItems) = preProject - .dockerignore = .dockerignore - .editorconfig = .editorconfig - .gitattributes = .gitattributes - .gitignore = .gitignore - HISTORY.md = HISTORY.md - LICENSE = LICENSE - PlexCleaner.defaults.json = PlexCleaner.defaults.json - PlexCleaner.schema.json = PlexCleaner.schema.json - README.md = README.md - version.json = version.json - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docker", "Docker", "{C560F57F-962E-4C71-8F27-939D06813302}" - ProjectSection(SolutionItems) = preProject - Docker\Alpine.Edge.Dockerfile = Docker\Alpine.Edge.Dockerfile - Docker\Alpine.Latest.Dockerfile = Docker\Alpine.Latest.Dockerfile - Docker\Build.sh = Docker\Build.sh - Docker\Debian.Stable.Dockerfile = Docker\Debian.Stable.Dockerfile - Docker\Debian.Testing.Dockerfile = Docker\Debian.Testing.Dockerfile - Docker\DebugTools.sh = Docker\DebugTools.sh - Docker\README.m4 = Docker\README.m4 - Docker\Test.sh = Docker\Test.sh - Docker\Ubuntu.Devel.Dockerfile = Docker\Ubuntu.Devel.Dockerfile - Docker\Ubuntu.Rolling.Dockerfile = Docker\Ubuntu.Rolling.Dockerfile - Docker\UnitTest.sh = Docker\UnitTest.sh - Docker\Version.sh = Docker\Version.sh - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Actions", "Actions", "{CA5B5CC1-86DF-46DB-8AEB-12CDE10FB785}" - ProjectSection(SolutionItems) = preProject - .github\workflows\BuildDockerPush.yml = .github\workflows\BuildDockerPush.yml - .github\workflows\BuildGitHubRelease.yml = .github\workflows\BuildGitHubRelease.yml - .github\dependabot.yml = .github\dependabot.yml - .github\workflows\DependabotAutoMerge.yml = .github\workflows\DependabotAutoMerge.yml - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlexCleanerTests", "PlexCleanerTests\PlexCleanerTests.csproj", "{D6124D3D-CC4F-448F-BF57-C1D7E2FAC226}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sandbox", "Sandbox\Sandbox.csproj", "{134B6EC9-E20F-4CDA-B079-755BEC15E8C5}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Issues", "Issues", "{03A7B3AB-49DA-481B-AF3D-CF3D16BBFD0E}" - ProjectSection(SolutionItems) = preProject - .github\ISSUE_TEMPLATE\bug_report.yml = .github\ISSUE_TEMPLATE\bug_report.yml - .github\ISSUE_TEMPLATE\config.yml = .github\ISSUE_TEMPLATE\config.yml - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {1A686E6C-DD3F-4D71-A5F1-DCEE8F872A2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1A686E6C-DD3F-4D71-A5F1-DCEE8F872A2F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1A686E6C-DD3F-4D71-A5F1-DCEE8F872A2F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1A686E6C-DD3F-4D71-A5F1-DCEE8F872A2F}.Release|Any CPU.Build.0 = Release|Any CPU - {D6124D3D-CC4F-448F-BF57-C1D7E2FAC226}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D6124D3D-CC4F-448F-BF57-C1D7E2FAC226}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D6124D3D-CC4F-448F-BF57-C1D7E2FAC226}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D6124D3D-CC4F-448F-BF57-C1D7E2FAC226}.Release|Any CPU.Build.0 = Release|Any CPU - {134B6EC9-E20F-4CDA-B079-755BEC15E8C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {134B6EC9-E20F-4CDA-B079-755BEC15E8C5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {134B6EC9-E20F-4CDA-B079-755BEC15E8C5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {134B6EC9-E20F-4CDA-B079-755BEC15E8C5}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {C560F57F-962E-4C71-8F27-939D06813302} = {D8F3D73D-A71B-4B79-982F-8491CCFB893A} - {CA5B5CC1-86DF-46DB-8AEB-12CDE10FB785} = {D8F3D73D-A71B-4B79-982F-8491CCFB893A} - {03A7B3AB-49DA-481B-AF3D-CF3D16BBFD0E} = {D8F3D73D-A71B-4B79-982F-8491CCFB893A} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {A127C929-B096-4BFD-8BAD-56A3860AEB94} - EndGlobalSection -EndGlobal diff --git a/PlexCleaner.slnx b/PlexCleaner.slnx new file mode 100644 index 00000000..8ab77210 --- /dev/null +++ b/PlexCleaner.slnx @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PlexCleaner/AssemblyVersion.cs b/PlexCleaner/AssemblyVersion.cs index 3d3404dd..29e4e56e 100644 --- a/PlexCleaner/AssemblyVersion.cs +++ b/PlexCleaner/AssemblyVersion.cs @@ -1,5 +1,3 @@ -using System; -using System.IO; using System.Reflection; using System.Runtime.InteropServices; @@ -22,31 +20,28 @@ public static string GetBuildType() return build; } - public static string GetName() => GetAssembly().GetName().Name; + public static string GetName() => GetAssembly().GetName().Name ?? string.Empty; public static string GetInformationalVersion() => // E.g. 1.2.3+abc123.abc123 GetAssembly() .GetCustomAttribute() - ?.InformationalVersion; + ?.InformationalVersion + ?? string.Empty; public static string GetFileVersion() => // E.g. 1.2.3.4 - GetAssembly().GetCustomAttribute()?.Version; + GetAssembly().GetCustomAttribute()?.Version + ?? string.Empty; public static string GetReleaseVersion() => // E.g. 1.2.3 part of 1.2.3+abc123.abc123 // Use major.minor.build from informational version GetInformationalVersion().Split('+', '-')[0]; - public static DateTime GetBuildDate() => - // Use assembly modified time as build date - // https://stackoverflow.com/questions/1600962/displaying-the-build-date - File.GetLastWriteTime(GetAssembly().Location).ToLocalTime(); - private static Assembly GetAssembly() { - Assembly assembly = Assembly.GetEntryAssembly(); + Assembly? assembly = Assembly.GetEntryAssembly(); assembly ??= Assembly.GetExecutingAssembly(); return assembly; } diff --git a/PlexCleaner/AudioProps.cs b/PlexCleaner/AudioProps.cs index 050fb7b9..2e8b0aea 100644 --- a/PlexCleaner/AudioProps.cs +++ b/PlexCleaner/AudioProps.cs @@ -15,5 +15,5 @@ public class AudioProps(MediaProps mediaProps) : TrackProps(TrackType.Audio, med // Required // Format = track.Format; // Codec = track.CodecId; - public override bool Create(MediaInfoToolXmlSchema.Track track) => base.Create(track); + public override bool Create(MediaInfoToolJsonSchema.Track track) => base.Create(track); } diff --git a/PlexCleaner/CommandLineOptions.cs b/PlexCleaner/CommandLineOptions.cs index 6c75d7a7..9c8ddc32 100644 --- a/PlexCleaner/CommandLineOptions.cs +++ b/PlexCleaner/CommandLineOptions.cs @@ -28,8 +28,8 @@ public class CommandLineParser { public CommandLineParser(string[] args) { - _root = CreateRootCommand(); - Result = _root.Parse(args); + Root = CreateRootCommand(); + Result = Root.Parse(args); } public ParseResult Result { get; init; } @@ -41,7 +41,7 @@ symbolResult is OptionResult optionResult ); private static readonly List s_cliBypassList = ["--help", "--version"]; - private RootCommand _root { get; init; } + private RootCommand Root { get; init; } private class CommandHandler(Func action) : SynchronousCommandLineAction { @@ -135,12 +135,9 @@ private class CommandHandler(Func action) : SynchronousCommand private RootCommand CreateRootCommand() { - // TODO: https://github.com/dotnet/command-line-api/issues/2597 -#pragma warning disable IDE0028 // Simplify collection initialization RootCommand rootCommand = new( "Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc." ); -#pragma warning restore IDE0028 // Simplify collection initialization // Global options rootCommand.Options.Add(_logFileOption); diff --git a/PlexCleaner/ConfigFileJsonSchema.cs b/PlexCleaner/ConfigFileJsonSchema.cs index ef0145f3..b32406bd 100644 --- a/PlexCleaner/ConfigFileJsonSchema.cs +++ b/PlexCleaner/ConfigFileJsonSchema.cs @@ -10,10 +10,9 @@ using System; using System.IO; using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using Json.Schema; -using Json.Schema.Generation; using Serilog; namespace PlexCleaner; @@ -27,7 +26,12 @@ public record ConfigFileJsonSchemaBase [JsonPropertyName("$schema")] [JsonPropertyOrder(-3)] - public string Schema { get; } = SchemaUri; + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1822:Mark members as static", + Justification = "Schema must be written as instance property for JSON serialization." + )] + public string Schema => SchemaUri; [JsonRequired] [JsonPropertyOrder(-2)] @@ -37,7 +41,7 @@ public record ConfigFileJsonSchemaBase // v1 public record ConfigFileJsonSchema1 : ConfigFileJsonSchemaBase { - protected const int Version = 1; + public const int Version = 1; [JsonRequired] [JsonPropertyOrder(1)] @@ -45,29 +49,27 @@ public record ConfigFileJsonSchema1 : ConfigFileJsonSchemaBase // v2 : Replaced with ProcessOptions2 [Obsolete("Replaced with ProcessOptions2 in v2.")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public ProcessOptions1 ProcessOptions { get; set; } = new(); // v3 : Replaced with ConvertOptions2 [Obsolete("Replaced with ConvertOptions2 in v3.")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public ConvertOptions1 ConvertOptions { get; set; } = new(); // v3 : Replaced with VerifyOptions2 [Obsolete("Replaced with VerifyOptions2 in v3.")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public VerifyOptions1 VerifyOptions { get; set; } = new(); - // TODO: Remove, never customized - [JsonRequired] - [JsonPropertyOrder(5)] - public MonitorOptions1 MonitorOptions { get; set; } = new(); + // v4 : Removed + // public MonitorOptions1 MonitorOptions { get; set; } = new(); } // v2 public record ConfigFileJsonSchema2 : ConfigFileJsonSchema1 { - protected new const int Version = 2; + public new const int Version = 2; public ConfigFileJsonSchema2() { } @@ -77,14 +79,14 @@ public ConfigFileJsonSchema2(ConfigFileJsonSchema1 configFileJsonSchema1) // v2 : Added // v3 : Replaced with ProcessOptions3 [Obsolete("Replaced with ProcessOptions3 in v3.")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public new ProcessOptions2 ProcessOptions { get; set; } = new(); } // v3 public record ConfigFileJsonSchema3 : ConfigFileJsonSchema2 { - protected new const int Version = 3; + public new const int Version = 3; public ConfigFileJsonSchema3() { } @@ -97,13 +99,13 @@ public ConfigFileJsonSchema3(ConfigFileJsonSchema2 configFileJsonSchema2) // v3 : Added // v4 : Replaced with ProcessOptions4 [Obsolete("Replaced with ProcessOptions4 in v4.")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public new ProcessOptions3 ProcessOptions { get; set; } = new(); // v3 : Added // v4 : Replaced with ConvertOptions3 [Obsolete("Replaced with ConvertOptions3 in v4.")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public new ConvertOptions2 ConvertOptions { get; set; } = new(); // v3 : Added @@ -117,25 +119,8 @@ public record ConfigFileJsonSchema4 : ConfigFileJsonSchema3 { public new const int Version = 4; - public static readonly JsonSerializerOptions JsonReadOptions = new() - { - AllowTrailingCommas = true, - IncludeFields = true, - NumberHandling = JsonNumberHandling.AllowReadingFromString, - PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, - ReadCommentHandling = JsonCommentHandling.Skip, - }; - - public static readonly JsonSerializerOptions JsonWriteOptions = new() - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - IncludeFields = true, - TypeInfoResolver = new DefaultJsonTypeInfoResolver().WithAddedModifier( - ExcludeObsoletePropertiesModifier - ), - WriteIndented = true, - NewLine = "\r\n", - }; + [JsonIgnore] + public int DeserializedVersion { get; private set; } = Version; public ConfigFileJsonSchema4() { } @@ -160,13 +145,6 @@ public ConfigFileJsonSchema4(ConfigFileJsonSchema3 configFileJsonSchema3) private void Upgrade(int version) { - // v4: - // ToolsOptions1 - // ProcessOptions4 - // ConvertOptions3 - // VerifyOptions2 - // MonitorOptions1 - // v1 if (version == ConfigFileJsonSchema1.Version) { @@ -220,7 +198,16 @@ private void Upgrade(int version) #pragma warning restore CS0618 // Type or member is obsolete } - // v4 + // v4: + // ToolsOptions1 + // ProcessOptions4 + // ConvertOptions3 + // VerifyOptions2 + // MonitorOptions1 + + // Set schema version to current and save original version + SchemaVersion = Version; + DeserializedVersion = version; } public void SetDefaults() @@ -228,7 +215,6 @@ public void SetDefaults() ToolsOptions.SetDefaults(); ConvertOptions.SetDefaults(); ProcessOptions.SetDefaults(); - MonitorOptions.SetDefaults(); VerifyOptions.SetDefaults(); } @@ -236,7 +222,6 @@ public bool VerifyValues() => ToolsOptions.VerifyValues() && ConvertOptions.VerifyValues() && ProcessOptions.VerifyValues() - && MonitorOptions.VerifyValues() && VerifyOptions.VerifyValues(); public static void WriteDefaultsToFile(string path) @@ -251,6 +236,22 @@ public static void WriteDefaultsToFile(string path) public static ConfigFileJsonSchema FromFile(string path) => FromJson(File.ReadAllText(path)); + public static ConfigFileJsonSchema OpenAndUpgrade(string path) + { + ConfigFileJsonSchema configJson = FromFile(path); + if (configJson.DeserializedVersion != Version) + { + Log.Warning( + "Writing ConfigFileJsonSchema upgraded from version {LoadedVersion} to {CurrentVersion}, {FileName}", + configJson.DeserializedVersion, + Version, + path + ); + ToFile(path, configJson); + } + return configJson; + } + public static void ToFile(string path, ConfigFileJsonSchema json) { // Set the schema version to the current version @@ -260,79 +261,97 @@ public static void ToFile(string path, ConfigFileJsonSchema json) File.WriteAllText(path, ToJson(json)); } + // Write using custom serializer to ignore null or empty strings private static string ToJson(ConfigFileJsonSchema json) => - JsonSerializer.Serialize(json, JsonWriteOptions); + JsonSerialization.SerializeIgnoreEmptyStrings(json, ConfigFileJsonContext.Default); + // Will throw on failure to deserialize private static ConfigFileJsonSchema FromJson(string json) { // Deserialize the base class to get the schema version ConfigFileJsonSchemaBase configFileJsonSchemaBase = - JsonSerializer.Deserialize(json, JsonReadOptions); - if (configFileJsonSchemaBase == null) - { - return null; - } + JsonSerializer.Deserialize(json, ConfigFileJsonContext.Default.ConfigFileJsonSchemaBase) + ?? throw new JsonException("Failed to deserialize ConfigFileJsonSchemaBase"); if (configFileJsonSchemaBase.SchemaVersion != Version) { Log.Warning( - "Converting ConfigFileJsonSchema from {JsonSchemaVersion} to {CurrentSchemaVersion}", + "Converting ConfigFileJsonSchema version from {JsonSchemaVersion} to {CurrentSchemaVersion}", configFileJsonSchemaBase.SchemaVersion, Version ); } // Deserialize the correct version - return configFileJsonSchemaBase.SchemaVersion switch + switch (configFileJsonSchemaBase.SchemaVersion) { - ConfigFileJsonSchema1.Version => new ConfigFileJsonSchema( - JsonSerializer.Deserialize(json, JsonReadOptions) - ), - ConfigFileJsonSchema2.Version => new ConfigFileJsonSchema( - JsonSerializer.Deserialize(json, JsonReadOptions) - ), - ConfigFileJsonSchema3.Version => new ConfigFileJsonSchema( - JsonSerializer.Deserialize(json, JsonReadOptions) - ), - Version => JsonSerializer.Deserialize(json, JsonReadOptions), - _ => throw new NotImplementedException(), - }; + case ConfigFileJsonSchema1.Version: + ConfigFileJsonSchema1 configFileJsonSchema1 = + JsonSerializer.Deserialize( + json, + ConfigFileJsonContext.Default.ConfigFileJsonSchema1 + ) ?? throw new JsonException("Failed to deserialize ConfigFileJsonSchema1"); + return new ConfigFileJsonSchema(configFileJsonSchema1); + case ConfigFileJsonSchema2.Version: + ConfigFileJsonSchema2 configFileJsonSchema2 = + JsonSerializer.Deserialize( + json, + ConfigFileJsonContext.Default.ConfigFileJsonSchema2 + ) ?? throw new JsonException("Failed to deserialize ConfigFileJsonSchema2"); + return new ConfigFileJsonSchema(configFileJsonSchema2); + case ConfigFileJsonSchema3.Version: + ConfigFileJsonSchema3 configFileJsonSchema3 = + JsonSerializer.Deserialize( + json, + ConfigFileJsonContext.Default.ConfigFileJsonSchema3 + ) ?? throw new JsonException("Failed to deserialize ConfigFileJsonSchema3"); + return new ConfigFileJsonSchema(configFileJsonSchema3); + case Version: + ConfigFileJsonSchema configFileJsonSchema4 = + JsonSerializer.Deserialize( + json, + ConfigFileJsonContext.Default.ConfigFileJsonSchema4 + ) ?? throw new JsonException("Failed to deserialize ConfigFileJsonSchema4"); + return configFileJsonSchema4; + default: + throw new NotSupportedException( + $"Unsupported schema version: {configFileJsonSchemaBase.SchemaVersion}" + ); + } } public static void WriteSchemaToFile(string path) { // Create JSON schema - const string schemaVersion = "https://json-schema.org/draft/2020-12/schema"; - JsonSchema schemaBuilder = new JsonSchemaBuilder() - .FromType( - new SchemaGeneratorConfiguration { PropertyOrder = PropertyOrder.ByName } - ) - .Title("PlexCleaner Configuration Schema") - .Id(new Uri(SchemaUri)) - .Schema(new Uri(schemaVersion)) - .Build(); - string jsonSchema = JsonSerializer.Serialize(schemaBuilder, JsonWriteOptions); - - // Write to file - File.WriteAllText(path, jsonSchema); - } + JsonNode schemaNode = ConfigFileJsonContext.Default.Options.GetJsonSchemaAsNode( + typeof(ConfigFileJsonSchema) + ); - private static void ExcludeObsoletePropertiesModifier(JsonTypeInfo typeInfo) - { - // Only process objects - if (typeInfo.Kind != JsonTypeInfoKind.Object) - { - return; - } + // Add decorators + JsonObject schemaObject = schemaNode.AsObject(); + _ = schemaObject.TryAdd("title", "PlexCleaner Configuration Schema"); + _ = schemaObject.TryAdd("$id", SchemaUri); + _ = schemaObject.TryAdd("$schema", "https://json-schema.org/draft/2020-12/schema"); - // Iterate over all properties - foreach (JsonPropertyInfo property in typeInfo.Properties) - { - // Do not serialize [Obsolete] items - if (property.AttributeProvider?.IsDefined(typeof(ObsoleteAttribute), true) == true) - { - property.ShouldSerialize = (_, _) => false; - } - } + // Write to file + string schemaJson = schemaObject.ToJsonString(ConfigFileJsonContext.Default.Options); + File.WriteAllText(path, schemaJson); } } + +[JsonSourceGenerationOptions( + AllowTrailingCommas = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + ReadCommentHandling = JsonCommentHandling.Skip, + WriteIndented = true, + NewLine = "\r\n" +)] +[JsonSerializable(typeof(ConfigFileJsonSchemaBase))] +[JsonSerializable(typeof(ConfigFileJsonSchema1))] +[JsonSerializable(typeof(ConfigFileJsonSchema2))] +[JsonSerializable(typeof(ConfigFileJsonSchema3))] +[JsonSerializable(typeof(ConfigFileJsonSchema))] +internal partial class ConfigFileJsonContext : JsonSerializerContext; diff --git a/PlexCleaner/Convert.cs b/PlexCleaner/Convert.cs index 78116bb4..70b7a3b2 100644 --- a/PlexCleaner/Convert.cs +++ b/PlexCleaner/Convert.cs @@ -119,7 +119,7 @@ public static bool ReMuxToMkv(string inputName, out string outputName) public static bool ReMuxToMkv( string inputName, - SelectMediaProps selectMediaProps, + SelectMediaProps? selectMediaProps, out string outputName ) { diff --git a/PlexCleaner/ConvertOptions.cs b/PlexCleaner/ConvertOptions.cs index bec9dea9..1788d63f 100644 --- a/PlexCleaner/ConvertOptions.cs +++ b/PlexCleaner/ConvertOptions.cs @@ -1,6 +1,5 @@ using System; using System.Text.Json.Serialization; -using Json.Schema.Generation; using Serilog; namespace PlexCleaner; @@ -9,29 +8,29 @@ namespace PlexCleaner; public record HandBrakeOptions { [JsonRequired] - public string Video { get; set; } = ""; + public string Video { get; set; } = string.Empty; [JsonRequired] - public string Audio { get; set; } = ""; + public string Audio { get; set; } = string.Empty; } // v2 : Added public record FfMpegOptions { [JsonRequired] - public string Video { get; set; } = ""; + public string Video { get; set; } = string.Empty; [JsonRequired] - public string Audio { get; set; } = ""; + public string Audio { get; set; } = string.Empty; // v3 : Value no longer needs defaults [JsonRequired] - public string Global { get; set; } = ""; + public string Global { get; set; } = string.Empty; // v3 : Removed [Obsolete("Removed in v3")] - [JsonExclude] - public string Output { get; set; } = ""; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string Output { get; set; } = string.Empty; } // v1 @@ -41,16 +40,16 @@ public record ConvertOptions1 // v2 : Replaced with FfMpegOptions and HandBrakeOptions [Obsolete("Replaced in v2 with FfMpegOptions and HandBrakeOptions")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public bool EnableH265Encoder { get; set; } [Obsolete("Replaced in v2 with FfMpegOptions and HandBrakeOptions")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public int VideoEncodeQuality { get; set; } [Obsolete("Replaced in v2 with FfMpegOptions and HandBrakeOptions")] - [JsonExclude] - public string AudioEncodeCodec { get; set; } = ""; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string AudioEncodeCodec { get; set; } = string.Empty; } // v2 @@ -122,7 +121,7 @@ private void Upgrade(int version) // Obsolete #pragma warning disable CS0618 // Type or member is obsolete - convertOptions2.FfMpegOptions.Output = ""; + convertOptions2.FfMpegOptions.Output = string.Empty; #pragma warning restore CS0618 // Type or member is obsolete // Remove old default global options @@ -138,7 +137,7 @@ public void SetDefaults() { FfMpegOptions.Video = FfMpeg.DefaultVideoOptions; FfMpegOptions.Audio = FfMpeg.DefaultAudioOptions; - FfMpegOptions.Global = ""; + FfMpegOptions.Global = string.Empty; HandBrakeOptions.Video = HandBrake.DefaultVideoOptions; HandBrakeOptions.Audio = HandBrake.DefaultAudioOptions; diff --git a/PlexCleaner/Extensions.cs b/PlexCleaner/Extensions.cs index 3facd054..44fa5a87 100644 --- a/PlexCleaner/Extensions.cs +++ b/PlexCleaner/Extensions.cs @@ -5,20 +5,28 @@ namespace PlexCleaner; public static class Extensions { - public static bool LogAndPropagate(this ILogger logger, Exception exception, string function) + extension(ILogger logger) { - logger.Error(exception, "{Function}", function); - return false; - } + public bool LogAndPropagate( + Exception exception, + [System.Runtime.CompilerServices.CallerMemberName] string function = "unknown" + ) + { + logger.Error(exception, "{Function}", function); + return false; + } - public static bool LogAndHandle(this ILogger logger, Exception exception, string function) - { - logger.Error(exception, "{Function}", function); - return true; - } + public bool LogAndHandle( + Exception exception, + [System.Runtime.CompilerServices.CallerMemberName] string function = "unknown" + ) + { + logger.Error(exception, "{Function}", function); + return true; + } - public static ILogger LogOverrideContext(this ILogger logger) => - logger.ForContext(); + public ILogger LogOverrideContext() => logger.ForContext(); + } public class LogOverride; } diff --git a/PlexCleaner/FfMpegBuilder.cs b/PlexCleaner/FfMpegBuilder.cs index f3b3162b..b95deed0 100644 --- a/PlexCleaner/FfMpegBuilder.cs +++ b/PlexCleaner/FfMpegBuilder.cs @@ -17,10 +17,8 @@ public partial class FfMpeg { public class GlobalOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public GlobalOptions Default() => - LogLevelError().HideBanner().NoStats().AbortOnEmptyOutput(); + NoStdIn().LogLevelError().HideBanner().NoStats().AbortOnEmptyOutput(); public GlobalOptions LogLevel() => Add("-loglevel"); @@ -40,6 +38,8 @@ public GlobalOptions Default() => public GlobalOptions AbortOnEmptyOutput() => AbortOn("empty_output"); + public GlobalOptions NoStdIn() => Add("-nostdin"); + public GlobalOptions Add(string option) => Add(option, false); public GlobalOptions Add(string option, bool escape) @@ -48,15 +48,13 @@ public GlobalOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } public class InputOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - // https://trac.ffmpeg.org/ticket/2622 // Error with some PGS subtitles // [matroska,webm @ 000001d77fb61ca0] Could not find codec parameters for stream 2 (Subtitle: hdmv_pgs_subtitle): unspecified size @@ -115,15 +113,13 @@ public InputOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } public class OutputOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - // https://trac.ffmpeg.org/ticket/6375 // Too many packets buffered for output stream 0:1 // Set max_muxing_queue_size to large value to work around issue @@ -214,7 +210,7 @@ public OutputOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } diff --git a/PlexCleaner/FfMpegIdetInfo.cs b/PlexCleaner/FfMpegIdetInfo.cs index c78431ac..280deac6 100644 --- a/PlexCleaner/FfMpegIdetInfo.cs +++ b/PlexCleaner/FfMpegIdetInfo.cs @@ -66,7 +66,7 @@ public partial class FfMpegIdetInfo public bool IsInterlaced(out double percentage) => MultiFrame.IsInterlaced(out percentage) || SingleFrame.IsInterlaced(out percentage); - public static bool GetIdetInfo(string fileName, out FfMpegIdetInfo idetInfo, out string error) + public static bool GetIdetInfo(string fileName, out FfMpegIdetInfo? idetInfo, out string error) { // Get idet output from ffmpeg idetInfo = null; diff --git a/PlexCleaner/FfMpegTool.cs b/PlexCleaner/FfMpegTool.cs index a250d366..989f8eba 100644 --- a/PlexCleaner/FfMpegTool.cs +++ b/PlexCleaner/FfMpegTool.cs @@ -4,7 +4,6 @@ using System.Globalization; using System.IO; using System.Linq; -using System.Reflection; using System.Text; using System.Text.RegularExpressions; using CliWrap; @@ -113,8 +112,7 @@ protected override bool GetLatestVersionWindows(out MediaToolInfo mediaToolInfo) mediaToolInfo.FileName ); } - catch (Exception e) - when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } @@ -138,7 +136,10 @@ public override bool Update(string updateFile) // Delete the tool destination directory string toolPath = GetToolFolder(); - Directory.Delete(toolPath, true); + if (Directory.Exists(toolPath)) + { + Directory.Delete(toolPath, true); + } // Build the versioned out folder from the downloaded filename // E.g. ffmpeg-3.4-win64-static.zip to .\Tools\FFmpeg\ffmpeg-3.4-win64-static @@ -146,7 +147,6 @@ public override bool Update(string updateFile) // Rename the extract folder to the tool folder // E.g. ffmpeg-3.4-win64-static to .\Tools\FFMpeg - Directory.Delete(toolPath, true); Directory.Move(extractPath, toolPath); return true; @@ -265,7 +265,7 @@ out string outputMap public bool ConvertToMkv( string inputName, - SelectMediaProps selectMediaProps, + SelectMediaProps? selectMediaProps, string outputName, out string error ) diff --git a/PlexCleaner/FfMpegToolJsonSchema.cs b/PlexCleaner/FfMpegToolJsonSchema.cs index 85574322..95e494f9 100644 --- a/PlexCleaner/FfMpegToolJsonSchema.cs +++ b/PlexCleaner/FfMpegToolJsonSchema.cs @@ -2,15 +2,9 @@ using System.Text.Json; using System.Text.Json.Serialization; -// Convert JSON file to C# using app.quicktype.io -// Set language, framework, namespace, list - // No JSON schema, use XML schema // https://github.com/FFmpeg/FFmpeg/blob/master/doc/ffprobe.xsd -// Convert array[] to List<> -// Remove per item NullValueHandling = NullValueHandling.Ignore and add to Converter settings - // Use ffprobe example output: // ffprobe -loglevel quiet -show_streams -print_format json file.mkv @@ -26,14 +20,16 @@ public class FfProbe [JsonPropertyName("format")] public FormatInfo Format { get; } = new(); + // Will throw on failure to deserialize public static FfProbe FromJson(string json) => - JsonSerializer.Deserialize(json, ConfigFileJsonSchema.JsonReadOptions); + JsonSerializer.Deserialize(json, FfMpegToolJsonContext.Default.FfProbe) + ?? throw new JsonException("Failed to deserialize FfProbe"); } public class FormatInfo { [JsonPropertyName("format_name")] - public string FormatName { get; set; } = ""; + public string FormatName { get; set; } = string.Empty; [JsonPropertyName("duration")] public double Duration { get; set; } @@ -48,27 +44,26 @@ public class Track public long Index { get; set; } [JsonPropertyName("codec_name")] - public string CodecName { get; set; } = ""; + public string CodecName { get; set; } = string.Empty; [JsonPropertyName("codec_long_name")] - public string CodecLongName { get; set; } = ""; + public string CodecLongName { get; set; } = string.Empty; [JsonPropertyName("profile")] - public string Profile { get; set; } = ""; + public string Profile { get; set; } = string.Empty; [JsonPropertyName("codec_type")] - public string CodecType { get; set; } = ""; + public string CodecType { get; set; } = string.Empty; [JsonPropertyName("codec_tag_string")] - public string CodecTagString { get; set; } = ""; + public string CodecTagString { get; set; } = string.Empty; [JsonPropertyName("level")] public int Level { get; set; } [JsonPropertyName("field_order")] - public string FieldOrder { get; set; } = ""; + public string FieldOrder { get; set; } = string.Empty; - // XSD says it is a Boolean, examples use an int [JsonPropertyName("closed_captions")] public int ClosedCaptions { get; set; } @@ -84,23 +79,44 @@ public class Disposition [JsonPropertyName("default")] public int Default { get; set; } + [JsonIgnore] + public bool IsDefault => Default != 0; + [JsonPropertyName("forced")] public int Forced { get; set; } + [JsonIgnore] + public bool IsForced => Forced != 0; + [JsonPropertyName("original")] public int Original { get; set; } + [JsonIgnore] + public bool IsOriginal => Original != 0; + [JsonPropertyName("comment")] public int Comment { get; set; } + [JsonIgnore] + public bool IsCommentary => Comment != 0; + [JsonPropertyName("hearing_impaired")] public int HearingImpaired { get; set; } + [JsonIgnore] + public bool IsHearingImpaired => HearingImpaired != 0; + [JsonPropertyName("visual_impaired")] public int VisualImpaired { get; set; } + [JsonIgnore] + public bool IsVisualImpaired => VisualImpaired != 0; + [JsonPropertyName("descriptions")] public int Descriptions { get; set; } + + [JsonIgnore] + public bool IsDescriptions => Descriptions != 0; } public class PacketInfo @@ -112,7 +128,7 @@ public class PacketInfo public class Packet { [JsonPropertyName("codec_type")] - public string CodecType { get; set; } = ""; + public string CodecType { get; set; } = string.Empty; [JsonPropertyName("stream_index")] public long StreamIndex { get; set; } = -1; @@ -130,3 +146,14 @@ public class Packet public long Size { get; set; } = -1; } } + +[JsonSourceGenerationOptions( + AllowTrailingCommas = true, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + ReadCommentHandling = JsonCommentHandling.Skip +)] +[JsonSerializable(typeof(FfMpegToolJsonSchema.FfProbe))] +[JsonSerializable(typeof(FfMpegToolJsonSchema.Packet))] +internal partial class FfMpegToolJsonContext : JsonSerializerContext; diff --git a/PlexCleaner/FfProbeBuilder.cs b/PlexCleaner/FfProbeBuilder.cs index 7af2cd8c..ae8077e5 100644 --- a/PlexCleaner/FfProbeBuilder.cs +++ b/PlexCleaner/FfProbeBuilder.cs @@ -24,8 +24,6 @@ public static string EscapeMovieFileName(string fileName) => public class GlobalOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public GlobalOptions Default() => AnalyzeDuration("2G").ProbeSize("2G"); public GlobalOptions LogLevel() => Add("-loglevel"); @@ -54,7 +52,7 @@ public GlobalOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } @@ -62,8 +60,6 @@ public GlobalOptions Add(string option, bool escape) // TODO: Rename to input or output options public class FfProbeOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public FfProbeOptions OutputFormat() => Add("-output_format"); public FfProbeOptions OutputFormat(string option) => OutputFormat().Add(option); @@ -125,7 +121,7 @@ public FfProbeOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } diff --git a/PlexCleaner/FfProbeTool.cs b/PlexCleaner/FfProbeTool.cs index d586345f..b8d7e76b 100644 --- a/PlexCleaner/FfProbeTool.cs +++ b/PlexCleaner/FfProbeTool.cs @@ -3,9 +3,9 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Reflection; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.Json.Stream; using System.Threading; using System.Threading.Tasks; @@ -94,7 +94,7 @@ out string error if ( jsonStreamReader.TokenType == JsonTokenType.PropertyName && jsonStreamReader - .GetString() + .GetString()! .Equals("packets", StringComparison.OrdinalIgnoreCase) ) { @@ -131,14 +131,13 @@ out string error // Send packet to delegate // A false returns means delegate does not want any more packets - if ( - !await packetFunc( - await jsonStreamReader.DeserializeAsync( - ConfigFileJsonSchema.JsonReadOptions, - cancellationToken - ) - ) - ) + FfMpegToolJsonSchema.Packet? packet = + await jsonStreamReader.DeserializeAsync( + JsonReadOptions, + cancellationToken + ); + + if (packet == null || !await packetFunc(packet)) { // Done break; @@ -184,8 +183,7 @@ out string error ); return (false, string.Empty); } - catch (Exception e) - when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return (false, string.Empty); } @@ -196,7 +194,10 @@ public bool GetSubCcPackets( Func packetFunc ) { - // Quickscan + // TODO: Switch to ffprobe and analyze_frames (when available in the shipping version). + // `ffprobe -i FILE -show_entries stream=closed_captions -select_streams v:0 -analyze_frames -read_intervals %X` + + // Quickscan is not supported with subcc filter // -t and read_intervals do not work with the subcc filter // https://superuser.com/questions/1893673/how-to-time-limit-the-input-stream-duration-when-using-movie-filenameout0subcc // ReMux using FFmpeg to a snippet file then scan the snippet file @@ -297,7 +298,7 @@ public bool GetBitratePackets( public bool GetMediaProps(string fileName, out MediaProps mediaProps) { - mediaProps = null; + mediaProps = null!; return GetMediaPropsJson(fileName, out string json) && GetMediaPropsFromJson(json, fileName, out mediaProps); } @@ -363,7 +364,6 @@ out MediaProps mediaProps { // Deserialize FfMpegToolJsonSchema.FfProbe ffProbe = FfMpegToolJsonSchema.FfProbe.FromJson(json); - ArgumentNullException.ThrowIfNull(ffProbe); if (ffProbe.Tracks.Count == 0) { Log.Error( @@ -429,8 +429,7 @@ out MediaProps mediaProps // TODO: Chapters // TODO: Attachments } - catch (Exception e) - when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } @@ -460,4 +459,14 @@ private static bool HasUnwantedTags(Dictionary tags) => s_undesirableTags.Any(tag => tag.Equals(key, StringComparison.OrdinalIgnoreCase)) ); } + + public static readonly JsonSerializerOptions JsonReadOptions = new() + { + AllowTrailingCommas = true, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + ReadCommentHandling = JsonCommentHandling.Skip, + TypeInfoResolver = FfMpegToolJsonContext.Default, + }; } diff --git a/PlexCleaner/GitHubRelease.cs b/PlexCleaner/GitHubRelease.cs index f9e7f86b..ba80efda 100644 --- a/PlexCleaner/GitHubRelease.cs +++ b/PlexCleaner/GitHubRelease.cs @@ -4,6 +4,7 @@ namespace PlexCleaner; +// TODO: Convert to JSON Schema class public class GitHubRelease { // Can throw HTTP exceptions @@ -17,9 +18,9 @@ public static string GetLatestRelease(string repo) Debug.Assert(json != null); // Parse latest version from "tag_name" - JsonNode releases = JsonNode.Parse(json); + JsonNode? releases = JsonNode.Parse(json); Debug.Assert(releases != null); - JsonNode versionTag = releases["tag_name"]; + JsonNode? versionTag = releases["tag_name"]; Debug.Assert(versionTag != null); return versionTag.ToString(); } diff --git a/PlexCleaner/GlobalUsing.cs b/PlexCleaner/GlobalUsing.cs index 1186f8af..d9c83552 100644 --- a/PlexCleaner/GlobalUsing.cs +++ b/PlexCleaner/GlobalUsing.cs @@ -1,9 +1,2 @@ -// TODO: info IDE0005: Using directive is unnecessary. -// https://github.com/dotnet/roslyn/discussions/78254 - -#pragma warning disable IDE0005 // Using directive is unnecessary. -// Current schema version is v4 global using ConfigFileJsonSchema = PlexCleaner.ConfigFileJsonSchema4; -// Current schema version is v4 -global using SidecarFileJsonSchema = PlexCleaner.SidecarFileJsonSchema4; -#pragma warning restore IDE0005 // Using directive is unnecessary. +global using SidecarFileJsonSchema = PlexCleaner.SidecarFileJsonSchema5; diff --git a/PlexCleaner/HandBrakeBuilder.cs b/PlexCleaner/HandBrakeBuilder.cs index 6b814a4f..7230fda4 100644 --- a/PlexCleaner/HandBrakeBuilder.cs +++ b/PlexCleaner/HandBrakeBuilder.cs @@ -8,8 +8,6 @@ public partial class HandBrake { public class GlobalOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - // TODO: Consolidate public GlobalOptions Default() => this; @@ -21,15 +19,13 @@ public GlobalOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } public class InputOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public InputOptions Input() => Add("--input"); public InputOptions InputFile(string option) => Input().Add($"\"{option}\""); @@ -60,15 +56,13 @@ public InputOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } public class OutputOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public OutputOptions Output() => Add("--output"); public OutputOptions OutputFile(string option) => Output().Add($"\"{option}\""); @@ -116,7 +110,7 @@ public OutputOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } diff --git a/PlexCleaner/HandBrakeTool.cs b/PlexCleaner/HandBrakeTool.cs index b6b5c282..ae62e4f8 100644 --- a/PlexCleaner/HandBrakeTool.cs +++ b/PlexCleaner/HandBrakeTool.cs @@ -1,7 +1,6 @@ using System; using System.Diagnostics; using System.IO; -using System.Reflection; using System.Text.RegularExpressions; using CliWrap; using CliWrap.Buffered; @@ -84,8 +83,7 @@ protected override bool GetLatestVersionWindows(out MediaToolInfo mediaToolInfo) mediaToolInfo.FileName ); } - catch (Exception e) - when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } diff --git a/PlexCleaner/JsonSerialization.cs b/PlexCleaner/JsonSerialization.cs new file mode 100644 index 00000000..8b6ae494 --- /dev/null +++ b/PlexCleaner/JsonSerialization.cs @@ -0,0 +1,68 @@ +using System; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace PlexCleaner; + +internal static class JsonSerialization +{ + private static readonly ConditionalWeakTable< + JsonSerializerContext, + JsonSerializerOptions + > s_writeOptions = []; + + public static string SerializeIgnoreEmptyStrings(T value, JsonSerializerContext context) + { + JsonSerializerOptions options = s_writeOptions.GetValue(context, CreateWriteOptions); + JsonTypeInfo typeInfo = + options.GetTypeInfo(typeof(T)) as JsonTypeInfo + ?? throw new InvalidOperationException( + $"Json type info not found for {typeof(T).FullName}" + ); + return JsonSerializer.Serialize(value, typeInfo); + } + + private static JsonSerializerOptions CreateWriteOptions(JsonSerializerContext context) + { + JsonSerializerOptions options = new(context.Options) + { + TypeInfoResolver = new IgnoreEmptyStringTypeInfoResolver(context), + }; + return options; + } + + private sealed class IgnoreEmptyStringTypeInfoResolver(IJsonTypeInfoResolver inner) + : IJsonTypeInfoResolver + { + public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) + { + JsonTypeInfo? typeInfo = inner.GetTypeInfo(type, options); + if (typeInfo?.Kind != JsonTypeInfoKind.Object) + { + return typeInfo; + } + + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + if (property.PropertyType != typeof(string)) + { + continue; + } + + if (property.IsRequired) + { + continue; + } + + Func? existing = property.ShouldSerialize; + property.ShouldSerialize = (obj, value) => + !string.IsNullOrEmpty(value as string) + && (existing?.Invoke(obj, value) ?? true); + } + + return typeInfo; + } + } +} diff --git a/PlexCleaner/KeepAwake.cs b/PlexCleaner/KeepAwake.cs index 7b28b950..6b4d1d6c 100644 --- a/PlexCleaner/KeepAwake.cs +++ b/PlexCleaner/KeepAwake.cs @@ -26,7 +26,7 @@ public static void AllowSleep() } } - public static void OnTimedEvent(object sender, ElapsedEventArgs e) => PreventSleep(); + public static void OnTimedEvent(object? sender, ElapsedEventArgs e) => PreventSleep(); [LibraryImport("kernel32.dll")] private static partial ExecutionState SetThreadExecutionState(ExecutionState esFlags); @@ -34,9 +34,10 @@ public static void AllowSleep() [Flags] private enum ExecutionState : uint { - EsAwayModeRequired = 0x00000040, + // EsAwayModeRequired = 0x00000040, EsContinuous = 0x80000000, - EsDisplayRequired = 0x00000002, + + // EsDisplayRequired = 0x00000002, EsSystemRequired = 0x00000001, } } diff --git a/PlexCleaner/Mario.ico b/PlexCleaner/Mario.ico deleted file mode 100644 index a652b66d..00000000 Binary files a/PlexCleaner/Mario.ico and /dev/null differ diff --git a/PlexCleaner/MediaInfoBuilder.cs b/PlexCleaner/MediaInfoBuilder.cs index 83a77283..8b00b698 100644 --- a/PlexCleaner/MediaInfoBuilder.cs +++ b/PlexCleaner/MediaInfoBuilder.cs @@ -8,8 +8,6 @@ public partial class MediaInfo { public class GlobalOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - // TODO: Consolidate public GlobalOptions Default() => this; @@ -21,7 +19,7 @@ public GlobalOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } @@ -29,8 +27,6 @@ public GlobalOptions Add(string option, bool escape) // TODO: Rename input or output public class MediaInfoOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public MediaInfoOptions OutputFormat(string option) => Add($"--Output={option}"); public MediaInfoOptions OutputFormatXml() => OutputFormat("XML"); @@ -47,7 +43,7 @@ public MediaInfoOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } diff --git a/PlexCleaner/MediaInfoTool.cs b/PlexCleaner/MediaInfoTool.cs index 982519ee..fb02e3f9 100644 --- a/PlexCleaner/MediaInfoTool.cs +++ b/PlexCleaner/MediaInfoTool.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.Globalization; using System.IO; -using System.Reflection; using System.Text.RegularExpressions; using CliWrap; using CliWrap.Buffered; @@ -86,8 +85,7 @@ protected override bool GetLatestVersionWindows(out MediaToolInfo mediaToolInfo) mediaToolInfo.Url = $"https://mediaarea.net/download/binary/mediainfo/{mediaToolInfo.Version}/{mediaToolInfo.FileName}"; } - catch (Exception e) - when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } @@ -96,19 +94,18 @@ protected override bool GetLatestVersionWindows(out MediaToolInfo mediaToolInfo) public bool GetMediaProps(string fileName, out MediaProps mediaProps) { - // TODO: Switch to JSON version - mediaProps = null; - return GetMediaPropsXml(fileName, out string xml) - && GetMediaPropsFromXml(xml, fileName, out mediaProps); + mediaProps = null!; + return GetMediaPropsJson(fileName, out string json) + && GetMediaPropsFromJson(json, fileName, out mediaProps); } - public bool GetMediaPropsXml(string fileName, out string xml) + public bool GetMediaPropsJson(string fileName, out string json) { // Build command line - xml = string.Empty; + json = string.Empty; Command command = GetBuilder() .GlobalOptions(options => options.Default()) - .MediaInfoOptions(options => options.OutputFormatXml().InputFile(fileName)) + .MediaInfoOptions(options => options.OutputFormatJson().InputFile(fileName)) .Build(); // Execute command @@ -137,46 +134,42 @@ public bool GetMediaPropsXml(string fileName, out string xml) Log.Warning("{ToolType} : {Warning}", GetToolType(), result.StandardError.Trim()); } - // Get XML output - xml = result.StandardOutput; + // Get JSON output + json = result.StandardOutput; // TODO: No error is returned when the file does not exist // https://sourceforge.net/p/mediainfo/bugs/1052/ - // Empty XML files are around 86 bytes + // Empty files are around 86 bytes // Match size check with ProcessSidecarFile() - return xml.Length >= 100; + return json.Length >= 100; } - public static bool GetMediaPropsFromXml( - string xml, + public static bool GetMediaPropsFromJson( + string json, string fileName, out MediaProps mediaProps ) { - // Populate the MediaInfo object from the XML string + // Populate the MediaInfo object from the JSON string mediaProps = new MediaProps(ToolType.MediaInfo, fileName); try { // Deserialize - MediaInfoToolXmlSchema.MediaInfo xmlInfo = MediaInfoToolXmlSchema.MediaInfo.FromXml( - xml - ); - ArgumentNullException.ThrowIfNull(xmlInfo); - MediaInfoToolXmlSchema.MediaElement xmlMedia = xmlInfo.Media; - ArgumentNullException.ThrowIfNull(xmlMedia); - if (xmlMedia.Tracks.Count == 0) + MediaInfoToolJsonSchema.MediaInfo jsonInfo = + MediaInfoToolJsonSchema.MediaInfo.FromJson(json); + if (jsonInfo.Media.Tracks.Count == 0) { Log.Error( "{ToolType} : Container not supported : Tracks: {Tracks} : {FileName}", mediaProps.Parser, - xmlMedia.Tracks.Count, + jsonInfo.Media.Tracks.Count, fileName ); return false; } // Tracks - foreach (MediaInfoToolXmlSchema.Track track in xmlMedia.Tracks) + foreach (MediaInfoToolJsonSchema.Track track in jsonInfo.Media.Tracks) { // Process by track type switch (track.Type.ToLowerInvariant()) @@ -233,8 +226,7 @@ out MediaProps mediaProps // TODO: Chapters // TODO: Attachments } - catch (Exception e) - when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } diff --git a/PlexCleaner/MediaInfoToolJsonSchema.cs b/PlexCleaner/MediaInfoToolJsonSchema.cs index dc32837e..cd7eb780 100644 --- a/PlexCleaner/MediaInfoToolJsonSchema.cs +++ b/PlexCleaner/MediaInfoToolJsonSchema.cs @@ -1,9 +1,8 @@ +using System; using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; -// Convert JSON file to C# using app.quicktype.io -// Set language, framework, namespace, list - // No JSON schema. use XML schema // https://github.com/MediaArea/MediaAreaXml/blob/master/mediainfo.xsd // https://mediaarea.net/en/MediaInfo/Support/Tags @@ -11,47 +10,99 @@ // Use MediaInfo example output: // mediainfo --Output=JSON file.mkv -// TODO: Evaluate JSON support availability in older versions and switch from XML to JSON - -// TODO: Add MediaInfo namespace - namespace PlexCleaner; public class MediaInfoToolJsonSchema { - [JsonPropertyName("media")] - public Media Media { get; } = new(); -} + public class MediaInfo + { + [JsonPropertyName("media")] + public Media Media { get; } = new(); -public class Media -{ - [JsonPropertyName("track")] - public List Tracks { get; } = []; -} + // Will throw on failure to deserialize + public static MediaInfo FromJson(string json) => + JsonSerializer.Deserialize(json, MediaInfoToolJsonContext.Default.MediaInfo) + ?? throw new JsonException("Failed to deserialize MediaInfo"); + } -public class Track -{ - [JsonPropertyName("@type")] - public string Type { get; set; } = ""; + public class Media + { + [JsonPropertyName("track")] + public List Tracks { get; } = []; + } + + public class Track + { + [JsonPropertyName("@type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("Format")] + public string Format { get; set; } = string.Empty; + + [JsonPropertyName("Format_Profile")] + public string FormatProfile { get; set; } = string.Empty; - [JsonPropertyName("Format")] - public string Format { get; set; } = ""; + [JsonPropertyName("Format_Level")] + public string FormatLevel { get; set; } = string.Empty; - [JsonPropertyName("Format_Profile")] - public string FormatProfile { get; set; } = ""; + [JsonPropertyName("HDR_Format")] + public string HdrFormat { get; set; } = string.Empty; - [JsonPropertyName("CodecID")] - public string CodecId { get; set; } = ""; + [JsonPropertyName("CodecID")] + public string CodecId { get; set; } = string.Empty; - [JsonPropertyName("ID")] - public string Id { get; set; } = ""; + [JsonPropertyName("ID")] + public string Id { get; set; } = string.Empty; - [JsonPropertyName("UniqueID")] - public string UniqueId { get; set; } = ""; + [JsonPropertyName("UniqueID")] + public string UniqueId { get; set; } = string.Empty; - [JsonPropertyName("Format_Level")] - public string FormatLevel { get; set; } = ""; + [JsonPropertyName("Duration")] + public string Duration { get; set; } = string.Empty; - [JsonPropertyName("MuxingMode")] - public string MuxingMode { get; set; } = ""; + [JsonPropertyName("Language")] + public string Language { get; set; } = string.Empty; + + [JsonPropertyName("Default")] + public string Default { get; set; } = string.Empty; + + [JsonIgnore] + public bool IsDefault => StringToBool(Default); + + [JsonPropertyName("Forced")] + public string Forced { get; set; } = string.Empty; + + [JsonIgnore] + public bool IsForced => StringToBool(Forced); + + [JsonPropertyName("MuxingMode")] + public string MuxingMode { get; set; } = string.Empty; + + [JsonPropertyName("StreamOrder")] + public string StreamOrder { get; set; } = string.Empty; + + [JsonPropertyName("ScanType")] + public string ScanType { get; set; } = string.Empty; + + [JsonPropertyName("Title")] + public string Title { get; set; } = string.Empty; + + private static bool StringToBool(string value) => + !string.IsNullOrEmpty(value) + && ( + value.Equals("yes", StringComparison.OrdinalIgnoreCase) + || value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("1", StringComparison.OrdinalIgnoreCase) + ); + } } + +[JsonSourceGenerationOptions( + AllowTrailingCommas = true, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + ReadCommentHandling = JsonCommentHandling.Skip +)] +[JsonSerializable(typeof(MediaInfoToolJsonSchema.MediaInfo))] +internal partial class MediaInfoToolJsonContext : JsonSerializerContext; diff --git a/PlexCleaner/MediaInfoToolXmlSchema.cs b/PlexCleaner/MediaInfoToolXmlSchema.cs index 354a3406..65c6bea5 100644 --- a/PlexCleaner/MediaInfoToolXmlSchema.cs +++ b/PlexCleaner/MediaInfoToolXmlSchema.cs @@ -1,108 +1,96 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Xml; using System.Xml.Serialization; // https://github.com/MediaArea/MediaAreaXml/blob/master/mediainfo.xsd // https://mediaarea.net/en/MediaInfo/Support/Tags -// Convert XML to C# using http://xmltocsharp.azurewebsites.net/ -// Do not use XSD, https://mediaarea.net/mediainfo/mediainfo.xsd - // Use mediainfo example output: // mediainfo --Output=XML file.mkv -// Replace the namespace with Namespace="https://mediaarea.net/mediainfo" -// Add FromXml() method - namespace PlexCleaner; public class MediaInfoToolXmlSchema { - [XmlRoot(ElementName = "track", Namespace = "https://mediaarea.net/mediainfo")] - public class Track + [XmlRoot("MediaInfo", Namespace = "https://mediaarea.net/mediainfo", IsNullable = false)] + public class MediaInfo { - [XmlAttribute(AttributeName = "type")] - public string Type { get; set; } = ""; + [XmlElement("media", IsNullable = false)] + public Media Media { get; set; } = new(); - [XmlElement(ElementName = "ID", Namespace = "https://mediaarea.net/mediainfo")] - public string Id { get; set; } = ""; + public static MediaInfo FromXml(string xml) => MediaInfoXmlParser.MediaInfoFromXml(xml); - [XmlElement(ElementName = "UniqueID", Namespace = "https://mediaarea.net/mediainfo")] - public string UniqueId { get; set; } = ""; + public static bool StringToBool(string value) => + !string.IsNullOrEmpty(value) + && ( + value.Equals("yes", StringComparison.OrdinalIgnoreCase) + || value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("1", StringComparison.OrdinalIgnoreCase) + ); + } - [XmlElement(ElementName = "Duration", Namespace = "https://mediaarea.net/mediainfo")] - public string Duration { get; set; } = ""; + [XmlRoot("media")] + public class Media + { + [XmlElement("track")] + public List Tracks { get; set; } = []; + } - [XmlElement(ElementName = "Format", Namespace = "https://mediaarea.net/mediainfo")] - public string Format { get; set; } = ""; + [XmlRoot("track")] + public class Track + { + [XmlAttribute("type")] + public string Type { get; set; } = string.Empty; - [XmlElement(ElementName = "Format_Profile", Namespace = "https://mediaarea.net/mediainfo")] - public string FormatProfile { get; set; } = ""; + [XmlElement("ID")] + public string Id { get; set; } = string.Empty; - [XmlElement(ElementName = "Format_Level", Namespace = "https://mediaarea.net/mediainfo")] - public string FormatLevel { get; set; } = ""; + [XmlElement("UniqueID")] + public string UniqueId { get; set; } = string.Empty; - [XmlElement(ElementName = "HDR_Format", Namespace = "https://mediaarea.net/mediainfo")] - public string HdrFormat { get; set; } = ""; + [XmlElement("Duration")] + public string Duration { get; set; } = string.Empty; - [XmlElement(ElementName = "CodecID", Namespace = "https://mediaarea.net/mediainfo")] - public string CodecId { get; set; } = ""; + [XmlElement("Format")] + public string Format { get; set; } = string.Empty; - [XmlElement(ElementName = "Language", Namespace = "https://mediaarea.net/mediainfo")] - public string Language { get; set; } = ""; + [XmlElement("Format_Profile")] + public string FormatProfile { get; set; } = string.Empty; - [XmlElement(ElementName = "Default", Namespace = "https://mediaarea.net/mediainfo")] - public string DefaultString { get; set; } = ""; + [XmlElement("Format_Level")] + public string FormatLevel { get; set; } = string.Empty; - public bool Default => MediaInfo.StringToBool(DefaultString); + [XmlElement("HDR_Format")] + public string HdrFormat { get; set; } = string.Empty; - [XmlElement(ElementName = "Forced", Namespace = "https://mediaarea.net/mediainfo")] - public string ForcedString { get; set; } = ""; + [XmlElement("CodecID")] + public string CodecId { get; set; } = string.Empty; - public bool Forced => MediaInfo.StringToBool(ForcedString); + [XmlElement("Language")] + public string Language { get; set; } = string.Empty; - [XmlElement(ElementName = "MuxingMode", Namespace = "https://mediaarea.net/mediainfo")] - public string MuxingMode { get; set; } = ""; + [XmlElement("Default")] + public string Default { get; set; } = string.Empty; - [XmlElement(ElementName = "StreamOrder", Namespace = "https://mediaarea.net/mediainfo")] - public string StreamOrder { get; set; } = ""; + [XmlIgnore] + public bool IsDefault => MediaInfo.StringToBool(Default); - [XmlElement(ElementName = "ScanType", Namespace = "https://mediaarea.net/mediainfo")] - public string ScanType { get; set; } = ""; + [XmlElement("Forced")] + public string Forced { get; set; } = string.Empty; - [XmlElement(ElementName = "Title", Namespace = "https://mediaarea.net/mediainfo")] - public string Title { get; set; } = ""; - } + [XmlIgnore] + public bool IsForced => MediaInfo.StringToBool(Forced); - [XmlRoot(ElementName = "media", Namespace = "https://mediaarea.net/mediainfo")] - public class MediaElement - { - [XmlElement(ElementName = "track", Namespace = "https://mediaarea.net/mediainfo")] - public List Tracks { get; } = []; - } + [XmlElement("MuxingMode")] + public string MuxingMode { get; set; } = string.Empty; - [XmlRoot(ElementName = "MediaInfo", Namespace = "https://mediaarea.net/mediainfo")] - public class MediaInfo - { - [XmlElement(ElementName = "media", Namespace = "https://mediaarea.net/mediainfo")] - public MediaElement Media { get; set; } = new(); + [XmlElement("StreamOrder")] + public string StreamOrder { get; set; } = string.Empty; - public static MediaInfo FromXml(string xml) - { - XmlSerializer xmlSerializer = new(typeof(MediaInfo)); - using TextReader textReader = new StringReader(xml); - using XmlReader xmlReader = XmlReader.Create(textReader); - return xmlSerializer.Deserialize(xmlReader) as MediaInfo; - } + [XmlElement("ScanType")] + public string ScanType { get; set; } = string.Empty; - public static bool StringToBool(string value) => - value != null - && ( - value.Equals("yes", StringComparison.OrdinalIgnoreCase) - || value.Equals("true", StringComparison.OrdinalIgnoreCase) - || value.Equals("1", StringComparison.OrdinalIgnoreCase) - ); + [XmlElement("Title")] + public string Title { get; set; } = string.Empty; } } diff --git a/PlexCleaner/MediaInfoXmlParser.cs b/PlexCleaner/MediaInfoXmlParser.cs new file mode 100644 index 00000000..28da558c --- /dev/null +++ b/PlexCleaner/MediaInfoXmlParser.cs @@ -0,0 +1,439 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Xml; + +// Standard XML serializer is not AOT compatible +// https://stackoverflow.com/questions/79858800/statically-generated-xml-parsing-code-using-microsoft-xmlserializer-generator +// https://github.com/dotnet/runtime/issues/106580 + +namespace PlexCleaner; + +public static class MediaInfoXmlParser +{ + // Parses known MediaInfo XML elements and converts to MediaInfo object + public static MediaInfoToolXmlSchema.MediaInfo MediaInfoFromXml(string xml) + { + MediaInfoToolXmlSchema.MediaInfo mediaInfo = new(); + using StringReader stringReader = new(xml); + XmlReaderSettings settings = new() + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + IgnoreWhitespace = true, + IgnoreComments = true, + }; + + using XmlReader reader = XmlReader.Create(stringReader, settings); + while (reader.Read()) + { + if ( + reader.NodeType == XmlNodeType.Element + && reader.LocalName.Equals("mediainfo", StringComparison.OrdinalIgnoreCase) + ) + { + ParseMediaInfo(reader, mediaInfo); + break; + } + } + + return mediaInfo; + } + + private static void ParseMediaInfo(XmlReader reader, MediaInfoToolXmlSchema.MediaInfo mediaInfo) + { + int mediaInfoDepth = reader.Depth; + while (reader.Read() && reader.Depth > mediaInfoDepth) + { + if ( + reader.NodeType == XmlNodeType.Element + && reader.LocalName.Equals("media", StringComparison.OrdinalIgnoreCase) + ) + { + ParseMedia(reader, mediaInfo.Media); + } + } + } + + private static void ParseMedia(XmlReader reader, MediaInfoToolXmlSchema.Media media) + { + int mediaDepth = reader.Depth; + while (reader.Read() && reader.Depth > mediaDepth) + { + if ( + reader.NodeType == XmlNodeType.Element + && reader.LocalName.Equals("track", StringComparison.OrdinalIgnoreCase) + ) + { + MediaInfoToolXmlSchema.Track track = ParseTrack(reader); + media.Tracks.Add(track); + } + } + } + + private static MediaInfoToolXmlSchema.Track ParseTrack(XmlReader reader) + { + MediaInfoToolXmlSchema.Track track = new(); + if (reader.HasAttributes) + { + track.Type = reader.GetAttribute("type") ?? string.Empty; + } + + int trackDepth = reader.Depth; + while (reader.Read() && reader.Depth > trackDepth) + { + if (reader.NodeType == XmlNodeType.Element) + { + string elementName = reader.LocalName; + if (reader.Read() && reader.NodeType == XmlNodeType.Text) + { + switch (elementName.ToLowerInvariant()) + { + case "id": + track.Id = reader.Value; + break; + case "uniqueid": + track.UniqueId = reader.Value; + break; + case "duration": + track.Duration = reader.Value; + break; + case "format": + track.Format = reader.Value; + break; + case "format_profile": + track.FormatProfile = reader.Value; + break; + case "format_level": + track.FormatLevel = reader.Value; + break; + case "hdr_format": + track.HdrFormat = reader.Value; + break; + case "codecid": + track.CodecId = reader.Value; + break; + case "language": + track.Language = reader.Value; + break; + case "default": + track.Default = reader.Value; + break; + case "forced": + track.Forced = reader.Value; + break; + case "muxingmode": + track.MuxingMode = reader.Value; + break; + case "streamorder": + track.StreamOrder = reader.Value; + break; + case "scantype": + track.ScanType = reader.Value; + break; + case "title": + track.Title = reader.Value; + break; + default: + break; + } + } + } + } + + return track; + } + + // Parses all MediaInfo XML elements and converts to JSON + public static string GenericXmlToJson(string xml) + { + XmlReaderSettings xmlSettings = new() + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + IgnoreWhitespace = true, + IgnoreComments = true, + }; + using StringReader xmlStringReader = new(xml); + using XmlReader reader = XmlReader.Create(xmlStringReader, xmlSettings); + + JsonWriterOptions jsonOptions = new() + { + Indented = true, + IndentSize = 4, + // Allow e.g. ' without escaping to \u0027 + // "mkvmerge v93.0 ('Goblu') 64-bit" + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + using MemoryStream jsonStream = new(); + using Utf8JsonWriter jsonWriter = new(jsonStream, jsonOptions); + + // Read until we find the root element + while (reader.Read() && reader.NodeType != XmlNodeType.Element) + { + // Skip until root element + } + + // Parse the root element's children + WriteRootElementChildren(reader, jsonWriter); + + // Flush stream and write output + jsonWriter.Flush(); + return Encoding.UTF8.GetString(jsonStream.ToArray()); + } + + private static void WriteRootElementChildren(XmlReader reader, Utf8JsonWriter writer) + { + if (reader.NodeType != XmlNodeType.Element) + { + return; + } + + bool isEmpty = reader.IsEmptyElement; + int elementDepth = reader.Depth; + + writer.WriteStartObject(); + + if (!isEmpty) + { + // First pass: collect all children to detect arrays + Dictionary> childrenByName = new( + StringComparer.OrdinalIgnoreCase + ); + + while (reader.Read() && reader.Depth > elementDepth) + { + if (reader.Depth == elementDepth + 1 && reader.NodeType == XmlNodeType.Element) + { + ElementData elementData = ReadElementData(reader); + if (!childrenByName.TryGetValue(elementData.Name, out List? value)) + { + value = []; + childrenByName[elementData.Name] = value; + } + value.Add(elementData); + } + } + + // Second pass: write the JSON + foreach (KeyValuePair> kvp in childrenByName) + { + writer.WritePropertyName(kvp.Key); + + bool isArray = kvp.Value.Count > 1; + if (isArray) + { + writer.WriteStartArray(); + } + + foreach (ElementData element in kvp.Value) + { + WriteElementAsJson(element, writer); + } + + if (isArray) + { + writer.WriteEndArray(); + } + } + } + + writer.WriteEndObject(); + } + + private static ElementData ReadElementData(XmlReader reader) + { + ElementData data = new() { Name = reader.LocalName }; + + bool isEmpty = reader.IsEmptyElement; + int elementDepth = reader.Depth; + + // Read attributes + if (reader.HasAttributes) + { + for (int i = 0; i < reader.AttributeCount; i++) + { + reader.MoveToAttribute(i); + if ( + !reader.Name.Equals("xmlns", StringComparison.OrdinalIgnoreCase) + && !reader.Name.StartsWith("xmlns:", StringComparison.OrdinalIgnoreCase) + && !reader.Name.StartsWith("xsi:", StringComparison.OrdinalIgnoreCase) + ) + { + data.Attributes[reader.LocalName] = reader.Value; + } + } + _ = reader.MoveToElement(); + } + + if (!isEmpty) + { + // Read child elements + while (reader.Read() && reader.Depth > elementDepth) + { + if (reader.Depth == elementDepth + 1) + { + switch (reader.NodeType) + { + case XmlNodeType.Element: + { + ElementData childData = ReadElementData(reader); + if ( + !data.Children.TryGetValue( + childData.Name, + out List? value + ) + ) + { + value = []; + data.Children[childData.Name] = value; + } + value.Add(childData); + break; + } + case XmlNodeType.Text or XmlNodeType.CDATA: + data.TextContent = reader.Value; + break; + case XmlNodeType.None: + case XmlNodeType.Attribute: + case XmlNodeType.EntityReference: + case XmlNodeType.Entity: + case XmlNodeType.ProcessingInstruction: + case XmlNodeType.Comment: + case XmlNodeType.Document: + case XmlNodeType.DocumentType: + case XmlNodeType.DocumentFragment: + case XmlNodeType.Notation: + case XmlNodeType.Whitespace: + case XmlNodeType.SignificantWhitespace: + case XmlNodeType.EndElement: + case XmlNodeType.EndEntity: + case XmlNodeType.XmlDeclaration: + default: + break; + } + } + } + } + + return data; + } + + private static void WriteElementAsJson(ElementData element, Utf8JsonWriter writer) + { + writer.WriteStartObject(); + + // Write attributes + // If element has children, use @ prefix for attributes + // If element has only text content and attributes, don't use @ prefix + bool hasChildren = element.Children.Count > 0; + foreach (KeyValuePair attr in element.Attributes) + { + writer.WriteString(hasChildren ? "@" + attr.Key : attr.Key, attr.Value); + } + + // Write text content if present and no children + // The text becomes the "name" property for elements like + if (!string.IsNullOrEmpty(element.TextContent) && !hasChildren) + { + writer.WriteString("name", element.TextContent); + } + + // Write child elements as properties + foreach ((string propertyName, List children) in element.Children) + { + bool isArray = children.Count > 1; + + // Check if all children have only text content (no attributes, no nested children) + bool allSimpleText = children.All(c => + c.Attributes.Count == 0 && c.Children.Count == 0 + ); + + if (isArray) + { + writer.WritePropertyName(propertyName); + writer.WriteStartArray(); + + foreach (ElementData child in children) + { + if (allSimpleText && !string.IsNullOrEmpty(child.TextContent)) + { + writer.WriteStringValue(child.TextContent); + } + else + { + WriteElementAsJson(child, writer); + } + } + + writer.WriteEndArray(); + } + else + { + ElementData child = children[0]; + if (allSimpleText && !string.IsNullOrEmpty(child.TextContent)) + { + // Simple text element + writer.WriteString(propertyName, child.TextContent); + } + else + { + // Complex element with attributes or children + writer.WritePropertyName(propertyName); + WriteElementAsJson(child, writer); + } + } + } + + writer.WriteEndObject(); + } + + private class ElementData + { + public string Name { get; set; } = string.Empty; + public Dictionary Attributes { get; } = []; + public Dictionary> Children { get; } = []; + public string TextContent { get; set; } = string.Empty; + } + + // Parses known MediaInfo XML elements and converts to JSON + public static string MediaInfoXmlToJson(string mediaInfoXml) + { + // Serialize from XML + MediaInfoToolXmlSchema.MediaInfo xmlMediaInfo = MediaInfoToolXmlSchema.MediaInfo.FromXml( + mediaInfoXml + ); + + // Copy to JSON schema + MediaInfoToolJsonSchema.MediaInfo jsonMediaInfo = new(); + foreach (MediaInfoToolXmlSchema.Track xmlTrack in xmlMediaInfo.Media.Tracks) + { + MediaInfoToolJsonSchema.Track jsonTrack = new() + { + Type = xmlTrack.Type, + Id = xmlTrack.Id, + UniqueId = xmlTrack.UniqueId, + Duration = xmlTrack.Duration, + Format = xmlTrack.Format, + FormatProfile = xmlTrack.FormatProfile, + FormatLevel = xmlTrack.FormatLevel, + HdrFormat = xmlTrack.HdrFormat, + CodecId = xmlTrack.CodecId, + Language = xmlTrack.Language, + Default = xmlTrack.Default, + Forced = xmlTrack.Forced, + MuxingMode = xmlTrack.MuxingMode, + StreamOrder = xmlTrack.StreamOrder, + ScanType = xmlTrack.ScanType, + Title = xmlTrack.Title, + }; + jsonMediaInfo.Media.Tracks.Add(jsonTrack); + } + + // Serialize to JSON + return JsonSerializer.Serialize(jsonMediaInfo, MediaInfoToolJsonContext.Default.MediaInfo); + } +} diff --git a/PlexCleaner/MediaProps.cs b/PlexCleaner/MediaProps.cs index 046eecfb..d2efc004 100644 --- a/PlexCleaner/MediaProps.cs +++ b/PlexCleaner/MediaProps.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using Serilog; @@ -39,7 +38,7 @@ public class MediaProps(MediaTool.ToolType parser, string fileName) || Subtitle.Any(item => item.State == TrackProps.StateType.Unsupported); public TimeSpan Duration { get; set; } - public string Container { get; set; } + public string Container { get; set; } = string.Empty; public int Attachments { get; set; } public int Chapters { get; set; } @@ -107,14 +106,14 @@ public static bool GetMediaProps( out MediaProps mediaInfo ) { - mkvMerge = null; - mediaInfo = null; + mkvMerge = null!; + mediaInfo = null!; return GetMediaProps(fileInfo, MediaTool.ToolType.FfProbe, out ffProbe) && GetMediaProps(fileInfo, MediaTool.ToolType.MkvMerge, out mkvMerge) && GetMediaProps(fileInfo, MediaTool.ToolType.MediaInfo, out mediaInfo); } - [SuppressMessage("Style", "IDE0072:Add missing cases")] + // Will throw on failure public static bool GetMediaProps( FileInfo fileInfo, MediaTool.ToolType parser, @@ -135,7 +134,13 @@ out mediaProps fileInfo.FullName, out mediaProps ), - _ => throw new NotImplementedException(), + MediaTool.ToolType.None => throw new NotImplementedException(), + MediaTool.ToolType.FfMpeg => throw new NotImplementedException(), + MediaTool.ToolType.HandBrake => throw new NotImplementedException(), + MediaTool.ToolType.MkvPropEdit => throw new NotImplementedException(), + MediaTool.ToolType.SevenZip => throw new NotImplementedException(), + MediaTool.ToolType.MkvExtract => throw new NotImplementedException(), + _ => throw new NotSupportedException($"Unsupported parser type: {parser}"), }; public void RemoveCoverArt() @@ -180,7 +185,8 @@ public List MatchMediaInfoToMkvMerge(List mediaInfoTrack List matchedTrackList = []; mediaInfoTrackList.ForEach(mediaInfoItem => matchedTrackList.Add( - mkvMergeTrackList.Find(mkvMergeItem => mkvMergeItem.Number == mediaInfoItem.Number) + // First() will throw if not found + mkvMergeTrackList.First(mkvMergeItem => mkvMergeItem.Number == mediaInfoItem.Number) ) ); @@ -210,7 +216,7 @@ public bool VerifyTrackOrder(MediaProps mediaProps) foreach (TrackProps thisItem in thisTrackList) { // Find the matching item by matroska header number - TrackProps thatItem = thatTrackList.Find(item => item.Number == thisItem.Number); + TrackProps? thatItem = thatTrackList.Find(item => item.Number == thisItem.Number); if (thatItem == null) { return false; diff --git a/PlexCleaner/MediaTool.cs b/PlexCleaner/MediaTool.cs index bd8bc0d6..94b0a1ce 100644 --- a/PlexCleaner/MediaTool.cs +++ b/PlexCleaner/MediaTool.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Threading; @@ -42,7 +41,8 @@ public enum ToolType // The tool info must be set during initialization // Version information is used in the sidecar tool logic - public MediaToolInfo Info { get; set; } + // TODO: Improve nullable logic + public MediaToolInfo Info { get; set; } = null!; public abstract ToolFamily GetToolFamily(); public abstract ToolType GetToolType(); @@ -58,14 +58,17 @@ public enum ToolType protected abstract bool GetLatestVersionWindows(out MediaToolInfo mediaToolInfo); // Tool subfolder, e.g. /x64, /bin - protected virtual string GetSubFolder() => ""; + protected virtual string GetSubFolder() => string.Empty; // Tools can override the default behavior as needed public virtual bool Update(string updateFile) { // Make sure the tool folder exists and is empty string toolPath = GetToolFolder(); - Directory.Delete(toolPath, true); + if (Directory.Exists(toolPath)) + { + Directory.Delete(toolPath, true); + } _ = Directory.CreateDirectory(toolPath); // Extract the update file @@ -103,7 +106,7 @@ protected string GetLatestGitHubRelease(string repo) public bool Execute(Command command, out CommandResult commandResult) { - commandResult = null; + commandResult = null!; int processId = -1; try { @@ -131,7 +134,7 @@ public bool Execute(Command command, out CommandResult commandResult) ); return false; } - catch (Exception e) when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } @@ -147,7 +150,7 @@ public bool Execute( out BufferedCommandResult bufferedCommandResult ) { - bufferedCommandResult = null; + bufferedCommandResult = null!; int processId = -1; try { @@ -193,7 +196,7 @@ out BufferedCommandResult bufferedCommandResult ); return false; } - catch (Exception e) when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } diff --git a/PlexCleaner/MediaToolInfo.cs b/PlexCleaner/MediaToolInfo.cs index 450307c2..0dfbe21c 100644 --- a/PlexCleaner/MediaToolInfo.cs +++ b/PlexCleaner/MediaToolInfo.cs @@ -15,11 +15,11 @@ public MediaToolInfo(MediaTool mediaTool) public MediaTool.ToolType ToolType { get; set; } public MediaTool.ToolFamily ToolFamily { get; set; } - public string FileName { get; set; } + public string FileName { get; set; } = string.Empty; public DateTime ModifiedTime { get; set; } public long Size { get; set; } - public string Url { get; set; } - public string Version { get; set; } + public string Url { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; public void WriteLine(string prefix) => Log.Information( diff --git a/PlexCleaner/MkvMergeBuilder.cs b/PlexCleaner/MkvMergeBuilder.cs index f3d88b1f..322667ed 100644 --- a/PlexCleaner/MkvMergeBuilder.cs +++ b/PlexCleaner/MkvMergeBuilder.cs @@ -10,8 +10,6 @@ public partial class MkvMerge { public class GlobalOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public GlobalOptions Default() => DisableTrackStatisticsTags().NormalizeLanguageIetfExtended(); @@ -33,15 +31,13 @@ public GlobalOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } public class InputOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public InputOptions Default() => NoGlobalTags().NoTrackTags().NoAttachments().NoButtons(); public InputOptions Identify(string option) => Add("--identify").Add($"\"{option}\""); @@ -100,15 +96,13 @@ public InputOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } public class OutputOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public OutputOptions IdentificationFormat(string option) => Add("--identification-format").Add(option); @@ -143,7 +137,7 @@ public OutputOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } diff --git a/PlexCleaner/MkvMergeTool.cs b/PlexCleaner/MkvMergeTool.cs index f91b43ac..01e835d9 100644 --- a/PlexCleaner/MkvMergeTool.cs +++ b/PlexCleaner/MkvMergeTool.cs @@ -1,8 +1,6 @@ using System; using System.Diagnostics; using System.IO; -using System.IO.Compression; -using System.Reflection; using System.Text.RegularExpressions; using CliWrap; using CliWrap.Buffered; @@ -72,27 +70,23 @@ protected override bool GetLatestVersionWindows(out MediaToolInfo mediaToolInfo) try { // Download latest release file - const string uri = "https://mkvtoolnix.download/latest-release.xml.gz"; + // https://mkvtoolnix.download/latest-release.json + const string uri = "https://mkvtoolnix.download/latest-release.json"; Log.Information( "{Tool} : Reading latest version from : {Uri}", GetToolFamily(), uri ); - Stream releaseStream = Program - .GetHttpClient() - .GetStreamAsync(uri) - .GetAwaiter() - .GetResult(); - - // Get XML from Gzip - using GZipStream gzStream = new(releaseStream, CompressionMode.Decompress); - using StreamReader sr = new(gzStream); - string xml = sr.ReadToEnd(); - - // Get the version number from XML - MkvToolXmlSchema.MkvToolnixReleases mkvtools = - MkvToolXmlSchema.MkvToolnixReleases.FromXml(xml); - mediaToolInfo.Version = mkvtools.LatestSource.Version; + string json = Program.GetHttpClient().GetStringAsync(uri).GetAwaiter().GetResult(); + Debug.Assert(json != null); + + // Get the version number from JSON + MkvToolJsonSchema.LatestRelease latestRelease = + MkvToolJsonSchema.LatestRelease.FromJson(json); + Debug.Assert( + !string.IsNullOrEmpty(latestRelease.MkvToolnixReleases.LatestSource.Version) + ); + mediaToolInfo.Version = latestRelease.MkvToolnixReleases.LatestSource.Version; // Create download URL and the output fileName using the version number // E.g. https://mkvtoolnix.download/windows/releases/18.0.0/mkvtoolnix-64-bit-18.0.0.7z @@ -100,8 +94,7 @@ protected override bool GetLatestVersionWindows(out MediaToolInfo mediaToolInfo) mediaToolInfo.Url = $"https://mkvtoolnix.download/windows/releases/{mediaToolInfo.Version}/{mediaToolInfo.FileName}"; } - catch (Exception e) - when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } @@ -110,7 +103,7 @@ protected override bool GetLatestVersionWindows(out MediaToolInfo mediaToolInfo) public bool GetMediaProps(string fileName, out MediaProps mediaProps) { - mediaProps = null; + mediaProps = null!; return GetMediaPropsJson(fileName, out string json) && GetMediaPropsFromJson(json, fileName, out mediaProps); } @@ -175,7 +168,6 @@ out MediaProps mediaProps { // Deserialize MkvToolJsonSchema.MkvMerge mkvMerge = MkvToolJsonSchema.MkvMerge.FromJson(json); - ArgumentNullException.ThrowIfNull(mkvMerge); if (!mkvMerge.Container.Supported || mkvMerge.Tracks.Count == 0) { Log.Error( @@ -254,8 +246,7 @@ out MediaProps mediaProps mkvMerge.Container.Properties.Duration / 1000000.0 ); } - catch (Exception e) - when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } @@ -380,5 +371,11 @@ out string error RegexOptions.IgnoreCase | RegexOptions.Multiline )] public static partial Regex InstalledVersionRegex(); + + [GeneratedRegex( + @".*?(?[\d.]+)", + RegexOptions.IgnoreCase | RegexOptions.Multiline + )] + public static partial Regex LatestVersionRegex(); } } diff --git a/PlexCleaner/MkvPropEditBuilder.cs b/PlexCleaner/MkvPropEditBuilder.cs index 7e5780b5..4efa4660 100644 --- a/PlexCleaner/MkvPropEditBuilder.cs +++ b/PlexCleaner/MkvPropEditBuilder.cs @@ -26,8 +26,6 @@ public static string GetTrackFlag(TrackProps.FlagsType flagType) => public class GlobalOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public GlobalOptions Default() => NormalizeLanguageIetfExtended(); public GlobalOptions NormalizeLanguageIetf(string option) => @@ -43,15 +41,13 @@ public GlobalOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } public class InputOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public InputOptions Default() => DeleteTrackStatisticsTags(); public InputOptions InputFile(string option) => Add($"\"{option}\""); @@ -75,7 +71,7 @@ public class InputOptions(ArgumentsBuilder argumentsBuilder) public InputOptions DeleteAttachment(int option) => DeleteAttachment().Add($"{option + 1}"); // Set the language property not the language-ietf property - // https://gitlab.com/mbunkus/mkvtoolnix/-/wikis/Languages-in-Matroska-and-MKVToolNix#mkvpropedit + // https://codeberg.org/mbunkus/mkvtoolnix/wiki/Languages-in-Matroska-and-MKVToolNix#mkvpropedit public InputOptions Language(string option) => Add($"language={option}"); public InputOptions SetLanguage(string option) => Set().Language(option); @@ -99,7 +95,7 @@ public InputOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } diff --git a/PlexCleaner/MkvToolJsonSchema.cs b/PlexCleaner/MkvToolJsonSchema.cs index d174ab77..67ca1803 100644 --- a/PlexCleaner/MkvToolJsonSchema.cs +++ b/PlexCleaner/MkvToolJsonSchema.cs @@ -2,18 +2,12 @@ using System.Text.Json; using System.Text.Json.Serialization; -// Convert JSON file to C# using app.quicktype.io -// Set language, framework, namespace, list - -// JSON Schema: https://gitlab.com/mbunkus/mkvtoolnix/-/blob/main/doc/json-schema/mkvmerge-identification-output-schema-v17.json +// https://gitlab.com/mbunkus/mkvtoolnix/-/blob/main/doc/json-schema/mkvmerge-identification-output-schema-v17.json +// https://mkvtoolnix.download/latest-release.json // Use mkvmerge example output: // mkvmerge --identify file.mkv --identification-format json -// Convert array[] to List<> -// Change uid long to UInt64 -// Remove per item NullValueHandling = NullValueHandling.Ignore and add to Converter settings - namespace PlexCleaner; public class MkvToolJsonSchema @@ -38,8 +32,10 @@ public class MkvMerge [JsonPropertyName("chapters")] public List Chapters { get; } = []; + // Will throw on failure to deserialize public static MkvMerge FromJson(string json) => - JsonSerializer.Deserialize(json, ConfigFileJsonSchema.JsonReadOptions); + JsonSerializer.Deserialize(json, MkvToolJsonContext.Default.MkvMerge) + ?? throw new JsonException("Failed to deserialize MkvMerge"); } public class Container @@ -48,7 +44,7 @@ public class Container public ContainerProperties Properties { get; } = new(); [JsonPropertyName("type")] - public string Type { get; set; } = ""; + public string Type { get; set; } = string.Empty; [JsonPropertyName("recognized")] public bool Recognized { get; set; } @@ -63,7 +59,7 @@ public class ContainerProperties public long Duration { get; set; } [JsonPropertyName("title")] - public string Title { get; set; } = ""; + public string Title { get; set; } = string.Empty; } public class GlobalTag @@ -72,7 +68,6 @@ public class GlobalTag public int NumEntries { get; set; } } - // TODO: Only used to for presence, do we need contents? public class TrackTag { [JsonPropertyName("num_entries")] @@ -85,7 +80,7 @@ public class TrackTag public class Track { [JsonPropertyName("codec")] - public string Codec { get; set; } = ""; + public string Codec { get; set; } = string.Empty; [JsonPropertyName("id")] public long Id { get; set; } @@ -94,25 +89,25 @@ public class Track public TrackProperties Properties { get; } = new(); [JsonPropertyName("type")] - public string Type { get; set; } = ""; + public string Type { get; set; } = string.Empty; } public class TrackProperties { [JsonPropertyName("codec_id")] - public string CodecId { get; set; } = ""; + public string CodecId { get; set; } = string.Empty; [JsonPropertyName("language")] - public string Language { get; set; } = ""; + public string Language { get; set; } = string.Empty; [JsonPropertyName("language_ietf")] - public string LanguageIetf { get; set; } + public string LanguageIetf { get; set; } = string.Empty; [JsonPropertyName("forced_track")] public bool Forced { get; set; } [JsonPropertyName("tag_language")] - public string TagLanguage { get; set; } = ""; + public string TagLanguage { get; set; } = string.Empty; [JsonPropertyName("uid")] public ulong Uid { get; set; } @@ -121,7 +116,7 @@ public class TrackProperties public long Number { get; set; } [JsonPropertyName("track_name")] - public string TrackName { get; set; } = ""; + public string TrackName { get; set; } = string.Empty; [JsonPropertyName("default_track")] public bool DefaultTrack { get; set; } @@ -142,20 +137,52 @@ public class TrackProperties public bool TextDescriptions { get; set; } } - // TODO: Only used to for presence, do we need contents? public class Attachment { [JsonPropertyName("content_type")] - public string ContentType { get; set; } = ""; + public string ContentType { get; set; } = string.Empty; [JsonPropertyName("id")] public int Id { get; set; } } - // TODO: Only used to for presence, do we need contents? public class Chapter { [JsonPropertyName("type")] - public string Type { get; set; } = ""; + public string Type { get; set; } = string.Empty; + } + + public class LatestRelease + { + [JsonPropertyName("mkvtoolnix-releases")] + public MkvtoolnixReleases MkvToolnixReleases { get; } = new(); + + // Will throw on failure to deserialize + public static LatestRelease FromJson(string json) => + JsonSerializer.Deserialize(json, MkvToolJsonContext.Default.LatestRelease) + ?? throw new JsonException("Failed to deserialize LatestRelease"); + } + + public class MkvtoolnixReleases + { + [JsonPropertyName("latest-source")] + public LatestSource LatestSource { get; } = new(); + } + + public class LatestSource + { + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; } } + +[JsonSourceGenerationOptions( + AllowTrailingCommas = true, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + ReadCommentHandling = JsonCommentHandling.Skip +)] +[JsonSerializable(typeof(MkvToolJsonSchema.MkvMerge))] +[JsonSerializable(typeof(MkvToolJsonSchema.LatestRelease))] +internal partial class MkvToolJsonContext : JsonSerializerContext; diff --git a/PlexCleaner/MkvToolXmlSchema.cs b/PlexCleaner/MkvToolXmlSchema.cs deleted file mode 100644 index 6a206267..00000000 --- a/PlexCleaner/MkvToolXmlSchema.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.IO; -using System.Xml; -using System.Xml.Serialization; - -// Convert XML to C# using http://xmltocsharp.azurewebsites.net/ -// https://mkvtoolnix.download/latest-release.xml - -namespace PlexCleaner; - -public class MkvToolXmlSchema -{ - [XmlRoot(ElementName = "latest-source")] - public class LatestSource - { - [XmlElement(ElementName = "version")] - public string Version { get; set; } - } - - [XmlRoot(ElementName = "mkvtoolnix-releases")] - public class MkvToolnixReleases - { - [XmlElement(ElementName = "latest-source")] - public LatestSource LatestSource { get; set; } - - public static MkvToolnixReleases FromXml(string xml) - { - XmlSerializer xmlSerializer = new(typeof(MkvToolnixReleases)); - using TextReader textReader = new StringReader(xml); - using XmlReader xmlReader = XmlReader.Create(textReader); - return xmlSerializer.Deserialize(xmlReader) as MkvToolnixReleases; - } - } -} diff --git a/PlexCleaner/Monitor.cs b/PlexCleaner/Monitor.cs index 76953b46..96500cd1 100644 --- a/PlexCleaner/Monitor.cs +++ b/PlexCleaner/Monitor.cs @@ -25,6 +25,8 @@ private static void LogMonitorMessage() public bool MonitorFolders(List folders) { + const int MonitorWaitTime = 60; + LogMonitorMessage(); // Trim quotes around input paths @@ -74,10 +76,7 @@ public bool MonitorFolders(List folders) foreach (string folder in folders) { Log.Information("Adding folder to processing queue : {Folder}", folder); - _watchFolders.Add( - folder, - DateTime.UtcNow.AddSeconds(-Program.Config.MonitorOptions.MonitorWaitTime) - ); + _watchFolders.Add(folder, DateTime.UtcNow.AddSeconds(-MonitorWaitTime)); } } } @@ -94,9 +93,7 @@ public bool MonitorFolders(List folders) if (_watchFolders.Count != 0) { // Evaluate all folders in the watch list - DateTime settleTime = DateTime.UtcNow.AddSeconds( - -Program.Config.MonitorOptions.MonitorWaitTime - ); + DateTime settleTime = DateTime.UtcNow.AddSeconds(-MonitorWaitTime); foreach ((string folder, DateTime timeStamp) in _watchFolders) { // Settled down, i.e. not modified in last wait time @@ -238,7 +235,8 @@ private void OnChanged(FileSystemEventArgs e) case WatcherChangeTypes.Renamed: case WatcherChangeTypes.All: default: - throw new NotImplementedException(); + // Ignore + break; } } @@ -264,7 +262,8 @@ private void OnRenamed(RenamedEventArgs e) case WatcherChangeTypes.Changed: case WatcherChangeTypes.All: default: - throw new NotImplementedException(); + // Ignore + break; } } @@ -278,7 +277,7 @@ private static void OnError(ErrorEventArgs e) private void OnChanged(string pathname) { // File - string folderName = null; + string folderName = string.Empty; if (File.Exists(pathname)) { // Get the file details @@ -287,7 +286,7 @@ private void OnChanged(string pathname) // Ignore sidecar and temp files if (!ProcessFile.IsTempFile(fileInfo) && !SidecarFile.IsSidecarFile(fileInfo)) { - folderName = fileInfo.DirectoryName; + folderName = fileInfo.DirectoryName ?? string.Empty; } } // Or directory diff --git a/PlexCleaner/MonitorOptions.cs b/PlexCleaner/MonitorOptions.cs deleted file mode 100644 index 9ba7c288..00000000 --- a/PlexCleaner/MonitorOptions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Text.Json.Serialization; - -namespace PlexCleaner; - -public record MonitorOptions1 -{ - protected const int Version = 1; - - [JsonRequired] - public int MonitorWaitTime { get; set; } - - [JsonRequired] - public int FileRetryWaitTime { get; set; } - - [JsonRequired] - public int FileRetryCount { get; set; } - - public void SetDefaults() - { - MonitorWaitTime = 60; - FileRetryWaitTime = 5; - FileRetryCount = 2; - } - -#pragma warning disable CA1822 // Mark members as static - public bool VerifyValues() => - // Nothing to do - true; -#pragma warning restore CA1822 // Mark members as static -} diff --git a/PlexCleaner/PlexCleaner.csproj b/PlexCleaner/PlexCleaner.csproj index c6f6e5a8..79800b59 100644 --- a/PlexCleaner/PlexCleaner.csproj +++ b/PlexCleaner/PlexCleaner.csproj @@ -1,8 +1,8 @@ - + True latest - Mario.ico + PlexCleaner.ico PlexCleaner 1.1.1.0 Pieter Viljoen @@ -11,14 +11,17 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. Linux true + true true true 1.1.1.0 true en + enable Exe ptr727.PlexCleaner MIT + true https://github.com/ptr727/PlexCleaner README.md 1.1.1.0-prerelease @@ -27,9 +30,13 @@ PlexCleaner PlexCleaner.Program snupkg - net9.0 + net10.0 1.1.1.0-prerelease + + true + $(NoWarn);1591 + True @@ -38,11 +45,13 @@ - - - - - + + + @@ -54,4 +63,19 @@ + + + + diff --git a/PlexCleaner/PlexCleaner.ico b/PlexCleaner/PlexCleaner.ico new file mode 100644 index 00000000..4cb2dccf Binary files /dev/null and b/PlexCleaner/PlexCleaner.ico differ diff --git a/PlexCleaner/Process.cs b/PlexCleaner/Process.cs index 523ec04b..ffe5ac58 100644 --- a/PlexCleaner/Process.cs +++ b/PlexCleaner/Process.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Reflection; using System.Threading; using Serilog; @@ -42,7 +41,7 @@ out string processName ignored = false; state = SidecarFile.StatesType.None; processName = fileName; - ProcessFile processFile = null; + ProcessFile? processFile = null; bool result; // Process in jump loop @@ -365,7 +364,7 @@ public static bool DeleteEmptyFolders(IEnumerable folderList) // Cancelled fatalError = true; } - catch (Exception e) when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { // Error fatalError = true; diff --git a/PlexCleaner/ProcessDriver.cs b/PlexCleaner/ProcessDriver.cs index 3dc9f4a1..01702b81 100644 --- a/PlexCleaner/ProcessDriver.cs +++ b/PlexCleaner/ProcessDriver.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Reflection; using System.Threading; using Serilog; @@ -32,7 +31,7 @@ out List fileList List localFileList = []; try { - // No need for concurrent collections, number of items are small, and added in bulk, just lock when adding results + // Lock when adding results Lock listLock = new(); // Process each input in parallel @@ -86,7 +85,7 @@ out List fileList // Cancelled error = true; } - catch (Exception e) when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { // Error error = true; @@ -217,7 +216,7 @@ Func taskFunc // Cancelled error = true; } - catch (Exception e) when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { // Error error = true; @@ -346,7 +345,6 @@ public static bool TestMediaInfo(List fileList) => { // Process MKV files or files in the Remux list FileInfo fileInfo = new(fileName); - if ( !SidecarFile.IsMkvFile(fileName) && !Program.Config.ProcessOptions.ReMuxExtensions.Contains( @@ -453,7 +451,7 @@ public static bool GetToolInfo(List fileList) => { // Get media tool information if ( - !Tools.MediaInfo.GetMediaPropsXml(fileName, out string mediaInfoXml) + !Tools.MediaInfo.GetMediaPropsJson(fileName, out string mediaInfoXml) || !Tools.MkvMerge.GetMediaPropsJson(fileName, out string mkvMergeJson) || !Tools.FfProbe.GetMediaPropsJson(fileName, out string ffProbeJson) ) diff --git a/PlexCleaner/ProcessFile.cs b/PlexCleaner/ProcessFile.cs index 7125c588..5ca65245 100644 --- a/PlexCleaner/ProcessFile.cs +++ b/PlexCleaner/ProcessFile.cs @@ -32,9 +32,10 @@ public ProcessFile(string mediaFile) _sidecarFile = new SidecarFile(FileInfo); } - public MediaProps FfProbeProps { get; private set; } - public MediaProps MkvMergeProps { get; private set; } - public MediaProps MediaInfoProps { get; private set; } + // TODO: Improve nullable logic + public MediaProps FfProbeProps { get; private set; } = null!; + public MediaProps MkvMergeProps { get; private set; } = null!; + public MediaProps MediaInfoProps { get; private set; } = null!; public SidecarFile.StatesType State => _sidecarFile.State; public FileInfo FileInfo { get; private set; } @@ -125,7 +126,7 @@ public bool MakeExtensionLowercase(ref bool modified) return Refresh(lowerName); } - public bool IsWriteable() => FileInfo.Exists && !FileInfo.IsReadOnly; + public bool IsWriteable() => FileInfo is { Exists: true, IsReadOnly: false }; public bool IsSidecarAvailable() => _sidecarFile.Exists(); @@ -610,7 +611,8 @@ public bool RemoveCoverArtMkvMerge(ref bool modified) // Selected is Keep // NotSelected is Remove SelectMediaProps selectMediaProps = new(MkvMergeProps, true); - selectMediaProps.Move(MkvMergeProps.Video.Find(item => item.CoverArt), false); + // First() will throw if not found + selectMediaProps.Move(MkvMergeProps.Video.First(item => item.CoverArt), false); // There must be something left to keep Debug.Assert(selectMediaProps.Selected.Count > 0); @@ -751,7 +753,7 @@ public bool RemoveDuplicateTracks(ref bool modified) return Refresh(outputName); } - private bool FindInterlacedTracks(bool conditional, out VideoProps videoProps) + private bool FindInterlacedTracks(bool conditional, out VideoProps? videoProps) { // Return false on error // Set videoProps if interlaced @@ -789,7 +791,7 @@ private bool FindInterlacedTracks(bool conditional, out VideoProps videoProps) } // Count the frame types using the idet filter, expensive - if (!GetIdetInfo(out FfMpegIdetInfo idetInfo)) + if (!GetIdetInfo(out FfMpegIdetInfo? idetInfo) || idetInfo == null) { // Error return false; @@ -815,7 +817,7 @@ private bool FindInterlacedTracks(bool conditional, out VideoProps videoProps) return true; } - private bool FindClosedCaptionTracks(bool conditional, out VideoProps videoProps) + private bool FindClosedCaptionTracks(bool conditional, out VideoProps? videoProps) { // Return false on error // Set videoProps if contains closed captions @@ -892,7 +894,7 @@ public bool DeInterlace(bool conditional, ref bool modified) } // Do we have any interlaced video - if (!FindInterlacedTracks(conditional, out VideoProps videoProps)) + if (!FindInterlacedTracks(conditional, out VideoProps? videoProps)) { // Error return false; @@ -1046,7 +1048,7 @@ public bool RemoveClosedCaptions(bool conditional, ref bool modified) } // Do we have any closed captions - if (!FindClosedCaptionTracks(conditional, out VideoProps videoProps)) + if (!FindClosedCaptionTracks(conditional, out VideoProps? videoProps)) { // Error return false; @@ -1490,7 +1492,7 @@ private bool VerifyBitrate() // Calculate bitrate Log.Information("Calculating bitrate info : {FileName}", FileInfo.Name); - if (!GetBitrateInfo(out BitrateInfo bitrateInfo)) + if (!GetBitrateInfo(out BitrateInfo? bitrateInfo) || bitrateInfo == null) { // Error Log.Error("Failed to calculate bitrate info : {FileName}", FileInfo.Name); @@ -1943,14 +1945,14 @@ public bool TestMediaProps() return true; } - public bool GetBitrateInfo(out BitrateInfo bitrateInfo) + public bool GetBitrateInfo(out BitrateInfo? bitrateInfo) { // Use the default track, else the first track - VideoProps videoProps = FfProbeProps.Video.Find(item => + VideoProps? videoProps = FfProbeProps.Video.Find(item => item.Flags.HasFlag(TrackProps.FlagsType.Default) ); videoProps ??= FfProbeProps.Video.FirstOrDefault(); - AudioProps audioProps = FfProbeProps.Audio.Find(item => + AudioProps? audioProps = FfProbeProps.Audio.Find(item => item.Flags.HasFlag(TrackProps.FlagsType.Default) ); audioProps ??= FfProbeProps.Audio.FirstOrDefault(); @@ -1983,11 +1985,14 @@ public bool GetBitrateInfo(out BitrateInfo bitrateInfo) return true; } - private bool GetIdetInfo(out FfMpegIdetInfo idetInfo) + private bool GetIdetInfo(out FfMpegIdetInfo? idetInfo) { // Count the frame types using the idet filter Log.Information("Counting interlaced frames : {FileName}", FileInfo.Name); - if (!FfMpegIdetInfo.GetIdetInfo(FileInfo.FullName, out idetInfo, out string error)) + if ( + !FfMpegIdetInfo.GetIdetInfo(FileInfo.FullName, out idetInfo, out string error) + || idetInfo == null + ) { // Cancel requested if (Program.IsCancelledError()) @@ -2091,16 +2096,17 @@ public SelectMediaProps FindDuplicateTracks() List languageList = Language.GetLanguageList(trackList); // Map each language to its corresponding track list - List> tracksByLanguage = [.. languageList - .Select(language => + List> tracksByLanguage = + [ + .. languageList.Select(language => trackList.FindAll(item => language.Equals(item.LanguageIetf, StringComparison.OrdinalIgnoreCase) ) - )]; + ), + ]; foreach (List trackLanguageList in tracksByLanguage) { - // If multiple audio tracks exist for this language, keep the preferred audio codec track List audioTrackList = trackLanguageList.FindAll(item => item.GetType() == typeof(AudioProps) @@ -2234,10 +2240,12 @@ private static AudioProps FindPreferredAudio(IEnumerable trackInfoLi } // Iterate through the preferred codecs in order and return on first match - AudioProps audioProps = Program.Config.ProcessOptions.PreferredAudioFormats - .Select(format => audioPropsList.Find(item => - item.Format.Equals(format, StringComparison.OrdinalIgnoreCase) - )) + AudioProps? audioProps = Program + .Config.ProcessOptions.PreferredAudioFormats.Select(format => + audioPropsList.Find(item => + item.Format.Equals(format, StringComparison.OrdinalIgnoreCase) + ) + ) .FirstOrDefault(props => props != null); if (audioProps != null) { diff --git a/PlexCleaner/ProcessOptions.cs b/PlexCleaner/ProcessOptions.cs index 056b0989..3ff07c9b 100644 --- a/PlexCleaner/ProcessOptions.cs +++ b/PlexCleaner/ProcessOptions.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Text.Json.Serialization; using System.Text.RegularExpressions; -using Json.Schema.Generation; using Serilog; namespace PlexCleaner; @@ -11,9 +10,9 @@ namespace PlexCleaner; // v2 : Added public record VideoFormat { - public string Format { get; set; } - public string Codec { get; set; } - public string Profile { get; set; } + public string Format { get; set; } = string.Empty; + public string Codec { get; set; } = string.Empty; + public string Profile { get; set; } = string.Empty; } // v1 @@ -24,50 +23,50 @@ public record ProcessOptions1 // v2 : Removed // v1 -> v2 : CSV -> List [Obsolete("Removed in v2")] - [JsonExclude] - public string ReEncodeVideoFormats { get; set; } = ""; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string ReEncodeVideoFormats { get; set; } = string.Empty; // v2 : Removed // v1 -> v2 : CSV -> List [Obsolete("Removed in v2")] - [JsonExclude] - public string ReEncodeVideoCodecs { get; set; } = ""; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string ReEncodeVideoCodecs { get; set; } = string.Empty; // v2 : Removed // v1 -> v2 : CSV -> List [Obsolete("Removed in v2")] - [JsonExclude] - public string ReEncodeVideoProfiles { get; set; } = ""; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string ReEncodeVideoProfiles { get; set; } = string.Empty; // v2 : Removed // v1 -> v2 : CSV -> HashSet [Obsolete("Removed in v2")] - [JsonExclude] - public string ReEncodeAudioFormats { get; set; } = ""; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string ReEncodeAudioFormats { get; set; } = string.Empty; // v2 : Removed // v1 -> v2 : CSV -> HashSet [Obsolete("Removed in v2")] - [JsonExclude] - public string ReMuxExtensions { get; set; } = ""; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string ReMuxExtensions { get; set; } = string.Empty; // v2 : Removed // v1 -> v2 : CSV -> HashSet [Obsolete("Removed in v2")] - [JsonExclude] - public string KeepExtensions { get; set; } = ""; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string KeepExtensions { get; set; } = string.Empty; // v2 : Removed // v1 -> v2 : CSV -> HashSet [Obsolete("Removed in v2")] - [JsonExclude] - public string KeepLanguages { get; set; } = ""; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string KeepLanguages { get; set; } = string.Empty; // v2 : Removed // v1 -> v2 : CSV -> HashSet [Obsolete("Removed in v2")] - [JsonExclude] - public string PreferredAudioFormats { get; set; } = ""; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string PreferredAudioFormats { get; set; } = string.Empty; [JsonRequired] public bool DeleteEmptyFolders { get; set; } @@ -90,7 +89,7 @@ public record ProcessOptions1 // v3 : Changed ISO 639-2 to RFC 5646 language tags [JsonRequired] - public string DefaultLanguage { get; set; } = ""; + public string DefaultLanguage { get; set; } = string.Empty; [JsonRequired] public bool RemoveUnwantedLanguageTracks { get; set; } @@ -131,7 +130,7 @@ public ProcessOptions2(ProcessOptions1 processOptions1) // v1 -> v2 : CSV -> HashSet // v3 -> v4 : Replaced by FileIgnoreMasks [Obsolete("Replaced in v4 with FileIgnoreMasks")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public new HashSet KeepExtensions { get; set; } = new(StringComparer.OrdinalIgnoreCase); // v2 : Added @@ -249,27 +248,27 @@ protected void Upgrade(int version) if (!string.IsNullOrEmpty(processOptions1.KeepExtensions)) { KeepExtensions.UnionWith(processOptions1.KeepExtensions.Split(',')); - processOptions1.KeepExtensions = null; + processOptions1.KeepExtensions = string.Empty; } if (!string.IsNullOrEmpty(processOptions1.ReMuxExtensions)) { ReMuxExtensions.UnionWith(processOptions1.ReMuxExtensions.Split(',')); - processOptions1.ReMuxExtensions = null; + processOptions1.ReMuxExtensions = string.Empty; } if (!string.IsNullOrEmpty(processOptions1.ReEncodeAudioFormats)) { ReEncodeAudioFormats.UnionWith(processOptions1.ReEncodeAudioFormats.Split(',')); - processOptions1.ReEncodeAudioFormats = null; + processOptions1.ReEncodeAudioFormats = string.Empty; } if (!string.IsNullOrEmpty(processOptions1.KeepLanguages)) { KeepLanguages.UnionWith(processOptions1.KeepLanguages.Split(',')); - processOptions1.KeepLanguages = null; + processOptions1.KeepLanguages = string.Empty; } if (!string.IsNullOrEmpty(processOptions1.PreferredAudioFormats)) { PreferredAudioFormats.UnionWith(processOptions1.PreferredAudioFormats.Split(',')); - processOptions1.PreferredAudioFormats = null; + processOptions1.PreferredAudioFormats = string.Empty; } // v1 -> v2 : Convert CSV to List @@ -300,23 +299,23 @@ protected void Upgrade(int version) // Convert the * as wildcard to a null as any match if (videoFormat.Codec.Equals("*", StringComparison.OrdinalIgnoreCase)) { - videoFormat.Codec = null; + videoFormat.Codec = string.Empty; } if (videoFormat.Format.Equals("*", StringComparison.OrdinalIgnoreCase)) { - videoFormat.Format = null; + videoFormat.Format = string.Empty; } if (videoFormat.Profile.Equals("*", StringComparison.OrdinalIgnoreCase)) { - videoFormat.Profile = null; + videoFormat.Profile = string.Empty; } ReEncodeVideo.Add(videoFormat); } } - processOptions1.ReEncodeVideoCodecs = null; - processOptions1.ReEncodeVideoFormats = null; - processOptions1.ReEncodeVideoProfiles = null; + processOptions1.ReEncodeVideoCodecs = string.Empty; + processOptions1.ReEncodeVideoFormats = string.Empty; + processOptions1.ReEncodeVideoProfiles = string.Empty; } // v2 @@ -326,12 +325,11 @@ protected void Upgrade(int version) // ProcessOptions2 processOptions2 = this; // v2 -> v3 : Convert ISO 639-2 to RFC 5646 language tags - DefaultLanguage = Language.Lookup.GetIetfFromIso(DefaultLanguage) ?? Language.English; + // TODO: Filter out lookups that fail + DefaultLanguage = Language.Lookup.GetIetfFromIso(DefaultLanguage); List oldList = [.. KeepLanguages]; KeepLanguages.Clear(); - oldList.ForEach(item => - KeepLanguages.Add(Language.Lookup.GetIetfFromIso(item) ?? Language.English) - ); + oldList.ForEach(item => KeepLanguages.Add(Language.Lookup.GetIetfFromIso(item))); // v2 -> v3 : Defaults KeepOriginalLanguage = true; @@ -361,7 +359,7 @@ protected void Upgrade(int version) public void SetDefaults() { - DefaultLanguage = "en"; + DefaultLanguage = Language.English; DeInterlace = false; DeleteEmptyFolders = true; DeleteUnwantedExtensions = true; diff --git a/PlexCleaner/ProcessResultJsonSchema.cs b/PlexCleaner/ProcessResultJsonSchema.cs index e31080e0..57591306 100644 --- a/PlexCleaner/ProcessResultJsonSchema.cs +++ b/PlexCleaner/ProcessResultJsonSchema.cs @@ -41,38 +41,37 @@ public static void ToFile(string path, ProcessResultJsonSchema json) => File.WriteAllText(path, ToJson(json)); private static string ToJson(ProcessResultJsonSchema tools) => - JsonSerializer.Serialize(tools, ConfigFileJsonSchema.JsonWriteOptions); + JsonSerializer.Serialize(tools, ProcessResultJsonContext.Default.ProcessResultJsonSchema); + // Will throw on failure to deserialize public static ProcessResultJsonSchema FromJson(string json) => - JsonSerializer.Deserialize( - json, - ConfigFileJsonSchema.JsonReadOptions - ); + JsonSerializer.Deserialize(json, ProcessResultJsonContext.Default.ProcessResultJsonSchema) + ?? throw new JsonException("Failed to deserialize ProcessResultJsonSchema"); public class ToolVersion { - [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonConverter(typeof(JsonStringEnumConverter))] public MediaTool.ToolFamily Tool { get; set; } - public string Version { get; set; } + public string Version { get; set; } = string.Empty; } public class Version { - public string Application { get; set; } - public string Runtime { get; set; } - public string OS { get; set; } + public string Application { get; set; } = string.Empty; + public string Runtime { get; set; } = string.Empty; + public string OS { get; set; } = string.Empty; public List Tools { get; } = []; } public class ProcessResult { public bool Result { get; set; } - public string OriginalFileName { get; set; } - public string NewFileName { get; set; } + public string OriginalFileName { get; set; } = string.Empty; + public string NewFileName { get; set; } = string.Empty; public bool Modified { get; set; } - [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonConverter(typeof(JsonStringEnumConverter))] public SidecarFile.StatesType State { get; set; } } @@ -90,3 +89,16 @@ public class Result public List Results { get; } = []; } } + +[JsonSourceGenerationOptions( + AllowTrailingCommas = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + ReadCommentHandling = JsonCommentHandling.Skip, + WriteIndented = true, + NewLine = "\r\n" +)] +[JsonSerializable(typeof(ProcessResultJsonSchema))] +internal partial class ProcessResultJsonContext : JsonSerializerContext; diff --git a/PlexCleaner/Program.cs b/PlexCleaner/Program.cs index 387056cd..6e9145bf 100644 --- a/PlexCleaner/Program.cs +++ b/PlexCleaner/Program.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Net.Http; using System.Net.Http.Headers; -using System.Reflection; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -15,37 +14,31 @@ using Serilog.Debugging; using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; -using Timer = System.Timers.Timer; namespace PlexCleaner; -// TODO: Specialize all catch(Exception) to catch specific expected exceptions only -// TODO: Adopt async where it makes sense - public static class Program { private static readonly CancellationTokenSource s_cancelSource = new(); - private static HttpClient s_httpClient; + private static readonly Lazy s_httpClient = new(CreateHttpClient); public static readonly TimeSpan SnippetTimeSpan = TimeSpan.FromSeconds(30); public static readonly TimeSpan QuickScanTimeSpan = TimeSpan.FromMinutes(3); - public static CommandLineOptions Options { get; set; } - public static ConfigFileJsonSchema Config { get; set; } + public static CommandLineOptions Options { get; set; } = null!; + public static ConfigFileJsonSchema Config { get; set; } = null!; + + public static HttpClient GetHttpClient() => s_httpClient.Value; - public static HttpClient GetHttpClient() + private static HttpClient CreateHttpClient() { - if (s_httpClient != null) - { - return s_httpClient; - } - s_httpClient = new() { Timeout = TimeSpan.FromSeconds(120) }; - s_httpClient.DefaultRequestHeaders.UserAgent.Add( + HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(120) }; + httpClient.DefaultRequestHeaders.UserAgent.Add( new ProductInfoHeaderValue( - Assembly.GetExecutingAssembly().GetName().Name, - Assembly.GetExecutingAssembly().GetName().Version.ToString() + AssemblyVersion.GetName(), + AssemblyVersion.GetInformationalVersion() ) ); - return s_httpClient; + return httpClient; } private static int MakeExitCode(ExitCode exitCode) => (int)exitCode; @@ -84,7 +77,7 @@ private static int Main(string[] args) // Setup CreateLogger(); Console.CancelKeyPress += CancelEventHandler; - Task consoleKeyTask = null; + Task? consoleKeyTask = null; if (!Console.IsInputRedirected) { consoleKeyTask = Task.Run(KeyPressHandler); @@ -92,7 +85,7 @@ private static int Main(string[] args) // Keep the system from going to sleep KeepAwake.PreventSleep(); - using Timer keepAwakeTimer = new(30 * 1000); + using System.Timers.Timer keepAwakeTimer = new(30 * 1000); keepAwakeTimer.Elapsed += KeepAwake.OnTimedEvent; keepAwakeTimer.AutoReset = true; keepAwakeTimer.Start(); @@ -141,7 +134,7 @@ public static void VerifyLatestVersion() ); } } - catch (Exception e) when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { // Nothing to do } @@ -179,7 +172,7 @@ keyInfo.Key is ConsoleKey.Q or ConsoleKey.Z } } - private static void CancelEventHandler(object sender, ConsoleCancelEventArgs eventArgs) + private static void CancelEventHandler(object? sender, ConsoleCancelEventArgs eventArgs) { Log.Warning("Cancel event triggered : {EventType}", eventArgs.SpecialKey); @@ -220,7 +213,7 @@ private static void CreateLogger() LoggerConfiguration loggerConfiguration = new LoggerConfiguration() .MinimumLevel.Is(Options.LogWarning ? LogEventLevel.Warning : LogEventLevel.Information) // Set minimum to Verbose for LogOverride context - .MinimumLevel.Override(typeof(Extensions.LogOverride).FullName, LogEventLevel.Verbose) + .MinimumLevel.Override(typeof(Extensions.LogOverride).FullName!, LogEventLevel.Verbose) .Enrich.WithThreadId() .WriteTo.Console( theme: AnsiConsoleTheme.Code, @@ -455,7 +448,7 @@ public static int GetVersionInfoCommand() return MakeExitCode(ExitCode.Success); } - private static bool Create(bool verifyTools) + private static bool LoadSettings() { // Load config settings from JSON Log.Information("Loading settings from : {SettingsFile}", Options.SettingsFile); @@ -464,40 +457,40 @@ private static bool Create(bool verifyTools) Log.Error("Settings file not found : {SettingsFile}", Options.SettingsFile); return false; } - ConfigFileJsonSchema config = ConfigFileJsonSchema.FromFile(Options.SettingsFile); - if (config == null) - { - Log.Error("Failed to load settings : {FileName}", Options.SettingsFile); - return false; - } - // Compare the schema version - if (config.SchemaVersion != ConfigFileJsonSchema.Version) + try { - Log.Warning( - "Loaded old settings schema version : {LoadedVersion} != {CurrentVersion}, {FileName}", - config.SchemaVersion, - ConfigFileJsonSchema.Version, - Options.SettingsFile - ); + // Load the settings file + ConfigFileJsonSchema config = ConfigFileJsonSchema.OpenAndUpgrade(Options.SettingsFile); - // Upgrade the file schema - Log.Information("Writing upgraded settings file : {FileName}", Options.SettingsFile); - ConfigFileJsonSchema.ToFile(Options.SettingsFile, config); - } + // Verify the settings + if (!config.VerifyValues()) + { + Log.Error( + "Settings file contains incorrect or missing values : {FileName}", + Options.SettingsFile + ); + return false; + } - // Verify the settings - if (!config.VerifyValues()) + // Set the static config + Config = config; + } + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { - Log.Error( - "Settings file contains incorrect or missing values : {FileName}", - Options.SettingsFile - ); + Log.Error("Error opening settings file : {FileName}", Options.SettingsFile); return false; } + return true; + } - // Set the static config - Config = config; + private static bool Create(bool verifyTools) + { + // Load config settings from JSON + if (!LoadSettings()) + { + return false; + } // Log runtime information Log.Logger.LogOverrideContext() @@ -511,8 +504,6 @@ private static bool Create(bool verifyTools) ); Log.Logger.LogOverrideContext() .Information("OS Version : {OsDescription}", RuntimeInformation.OSDescription); - Log.Logger.LogOverrideContext() - .Information("Build Date : {BuildDate}", AssemblyVersion.GetBuildDate().ToLocalTime()); // Warn if a newer version has been released VerifyLatestVersion(); diff --git a/PlexCleaner/SevenZipBuilder.cs b/PlexCleaner/SevenZipBuilder.cs index 8304ac03..6e273a3c 100644 --- a/PlexCleaner/SevenZipBuilder.cs +++ b/PlexCleaner/SevenZipBuilder.cs @@ -8,8 +8,6 @@ public partial class SevenZip { public class GlobalOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public GlobalOptions Add(string option) => Add(option, false); public GlobalOptions Add(string option, bool escape) @@ -18,15 +16,13 @@ public GlobalOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } public class InputOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public InputOptions InputFile(string option) => Add($"\"{option}\""); public InputOptions Add(string option) => Add(option, false); @@ -37,15 +33,13 @@ public InputOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } public class OutputOptions(ArgumentsBuilder argumentsBuilder) { - private readonly ArgumentsBuilder _argumentsBuilder = argumentsBuilder; - public OutputOptions OutputFolder(string option) => Add($"-o\"{option}\""); public OutputOptions Add(string option) => Add(option, false); @@ -56,7 +50,7 @@ public OutputOptions Add(string option, bool escape) { return this; } - _ = _argumentsBuilder.Add(option, escape); + _ = argumentsBuilder.Add(option, escape); return this; } } @@ -94,7 +88,7 @@ public class Builder(string targetFilePath) public IInputOptions GlobalOptions(Action globalOptions) { - globalOptions(new(_argumentsBuilder)); + globalOptions(new GlobalOptions(_argumentsBuilder)); return this; } diff --git a/PlexCleaner/SevenZipTool.cs b/PlexCleaner/SevenZipTool.cs index 6ebc7e05..2ef2653e 100644 --- a/PlexCleaner/SevenZipTool.cs +++ b/PlexCleaner/SevenZipTool.cs @@ -1,7 +1,6 @@ using System; using System.Diagnostics; using System.IO; -using System.Reflection; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using CliWrap; @@ -25,7 +24,7 @@ public partial class Tool : MediaTool protected override string GetToolNameLinux() => "7z"; protected override string GetSubFolder() => - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "x64" : ""; + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "x64" : string.Empty; public IGlobalOptions GetBuilder() => Builder.Create(GetToolPath()); @@ -84,8 +83,7 @@ protected override bool GetLatestVersionWindows(out MediaToolInfo mediaToolInfo) mediaToolInfo.FileName ); } - catch (Exception e) - when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } @@ -112,11 +110,13 @@ public override bool Update(string updateFile) // Delete the tool destination directory string toolPath = GetToolFolder(); - Directory.Delete(toolPath, true); + if (Directory.Exists(toolPath)) + { + Directory.Delete(toolPath, true); + } // Rename the folder // E.g. 7z1805-extra to .\Tools\7Zip - Directory.Delete(toolPath, true); Directory.Move(extractPath, toolPath); return true; @@ -186,11 +186,13 @@ public bool BootstrapDownload() // Delete the tool destination directory string toolPath = GetToolFolder(); - Directory.Delete(toolPath, true); + if (Directory.Exists(toolPath)) + { + Directory.Delete(toolPath, true); + } // Rename the folder // E.g. 7z1805-extra to .\Tools\7Zip - Directory.Delete(toolPath, true); Directory.Move(extractPath, toolPath); return true; diff --git a/PlexCleaner/SidecarFile.cs b/PlexCleaner/SidecarFile.cs index 769f8af8..78df1aa2 100644 --- a/PlexCleaner/SidecarFile.cs +++ b/PlexCleaner/SidecarFile.cs @@ -1,7 +1,7 @@ using System; +using System.Buffers; using System.Diagnostics; using System.IO; -using System.Reflection; using System.Security.Cryptography; using InsaneGenius.Utilities; using Serilog; @@ -39,25 +39,36 @@ public enum StatesType private readonly FileInfo _mediaFileInfo; private readonly FileInfo _sidecarFileInfo; - private string _ffProbeJson; - private string _mediaInfoXml; - private string _mkvMergeJson; + private string _ffProbeJson = string.Empty; + private string _mediaInfoJson = string.Empty; + private string _mkvMergeJson = string.Empty; - private SidecarFileJsonSchema _sidecarJson; + private SidecarFileJsonSchema? _sidecarJson; public SidecarFile(FileInfo mediaFileInfo) { _mediaFileInfo = mediaFileInfo; _sidecarFileInfo = new FileInfo(GetSidecarName(_mediaFileInfo)); + _sidecarJson = null; + + FfProbeProps = null!; + MkvMergeProps = null!; + MediaInfoProps = null!; } public SidecarFile(string mediaFileName) { _mediaFileInfo = new FileInfo(mediaFileName); _sidecarFileInfo = new FileInfo(GetSidecarName(_mediaFileInfo)); + _sidecarJson = null; + + FfProbeProps = null!; + MkvMergeProps = null!; + MediaInfoProps = null!; } + // TODO: Improve nullable handling public MediaProps FfProbeProps { get; private set; } public MediaProps MkvMergeProps { get; private set; } public MediaProps MediaInfoProps { get; private set; } @@ -238,42 +249,42 @@ public bool Open(bool modified = false) return true; } - private bool IsMediaAndToolsCurrent(bool log) - { - // Follow all steps to log all mismatches, do not jump out early + private bool IsMediaAndToolsCurrent(bool log) => + IsMediaCurrent(log) + && (IsToolsCurrent(log) || Program.Config.ProcessOptions.SidecarUpdateOnToolChange); - // Verify the media file matches the json info - bool mismatch = !IsMediaCurrent(log); + private bool IsStateCurrent() => State == _sidecarJson?.State; - // Verify the tools matches the json info - // Ignore changes if SidecarUpdateOnToolChange is not set - if (!IsToolsCurrent(log) && Program.Config.ProcessOptions.SidecarUpdateOnToolChange) - { - mismatch = true; - } - - return !mismatch; - } - - private bool IsStateCurrent() => State == _sidecarJson.State; - - public bool IsWriteable() => _sidecarFileInfo.Exists && !_sidecarFileInfo.IsReadOnly; + public bool IsWriteable() => _sidecarFileInfo is { Exists: true, IsReadOnly: false }; public bool Exists() => _sidecarFileInfo.Exists; private bool GetMediaPropsFromJson() { + Debug.Assert(_sidecarJson != null); + Log.Information("Reading media info from sidecar : {FileName}", _sidecarFileInfo.Name); // Decompress the tool data - _mediaInfoXml = StringCompression.Decompress(_sidecarJson.MediaInfoData); + _mediaInfoJson = StringCompression.Decompress(_sidecarJson.MediaInfoData); _mkvMergeJson = StringCompression.Decompress(_sidecarJson.MkvMergeData); _ffProbeJson = StringCompression.Decompress(_sidecarJson.FfProbeData); + // Must have data to deserialize + if ( + string.IsNullOrEmpty(_mediaInfoJson) + || string.IsNullOrEmpty(_mkvMergeJson) + || string.IsNullOrEmpty(_ffProbeJson) + ) + { + Log.Error("Media info tool data is missing : {FileName}", _sidecarFileInfo.Name); + return false; + } + // Deserialize the tool data if ( - !MediaInfo.Tool.GetMediaPropsFromXml( - _mediaInfoXml, + !MediaInfo.Tool.GetMediaPropsFromJson( + _mediaInfoJson, _mediaFileInfo.Name, out MediaProps mediaInfoProps ) @@ -308,13 +319,18 @@ private bool IsMediaCurrent(bool log) { // Refresh file info _mediaFileInfo.Refresh(); + Debug.Assert(_sidecarJson != null); + Debug.Assert(_mediaFileInfo != null); + Debug.Assert(_sidecarFileInfo != null); // Compare media attributes - bool mismatch = false; + bool current = true; + + // Compare last write time if (_mediaFileInfo.LastWriteTimeUtc != _sidecarJson.MediaLastWriteTimeUtc) { // Ignore LastWriteTimeUtc, it is unreliable over SMB - // mismatch = true; + // current = false; if (log) { Log.Warning( @@ -325,9 +341,11 @@ private bool IsMediaCurrent(bool log) ); } } + + // Compare size if (_mediaFileInfo.Length != _sidecarJson.MediaLength) { - mismatch = true; + current = false; if (log) { Log.Warning( @@ -338,11 +356,12 @@ private bool IsMediaCurrent(bool log) ); } } + + // Compare hash string hash = ComputeHash(); - Debug.Assert(!string.IsNullOrEmpty(hash)); if (!string.Equals(hash, _sidecarJson.MediaHash, StringComparison.OrdinalIgnoreCase)) { - mismatch = true; + current = false; if (log) { Log.Warning( @@ -354,13 +373,19 @@ private bool IsMediaCurrent(bool log) } } - return !mismatch; + return current; } private bool IsToolsCurrent(bool log) { + Debug.Assert(_sidecarJson != null); + Debug.Assert(_mediaFileInfo != null); + Debug.Assert(_sidecarFileInfo != null); + // Compare tool versions - bool mismatch = false; + bool current = true; + + // FfProbe if ( !_sidecarJson.FfProbeToolVersion.Equals( Tools.FfProbe.Info.Version, @@ -368,7 +393,7 @@ private bool IsToolsCurrent(bool log) ) ) { - mismatch = true; + current = false; if (log) { Log.Warning( @@ -379,6 +404,8 @@ private bool IsToolsCurrent(bool log) ); } } + + // MkvMerge if ( !_sidecarJson.MkvMergeToolVersion.Equals( Tools.MkvMerge.Info.Version, @@ -386,7 +413,7 @@ private bool IsToolsCurrent(bool log) ) ) { - mismatch = true; + current = false; if (log) { Log.Warning( @@ -397,6 +424,8 @@ private bool IsToolsCurrent(bool log) ); } } + + // MediaInfo if ( !_sidecarJson.MediaInfoToolVersion.Equals( Tools.MediaInfo.Info.Version, @@ -404,7 +433,7 @@ private bool IsToolsCurrent(bool log) ) ) { - mismatch = true; + current = false; if (log) { Log.Warning( @@ -416,43 +445,36 @@ private bool IsToolsCurrent(bool log) } } - return !mismatch; + return current; } private bool ReadJson() { try { - // Get json file - string json = File.ReadAllText(_sidecarFileInfo.FullName); - - // Create the object from json - _sidecarJson = SidecarFileJsonSchema.FromJson(json); - if (_sidecarJson == null) - { - Log.Error("Failed to read JSON from file : {FileName}", _sidecarFileInfo.Name); - return false; - } + // Create the object from the sidecar file + _sidecarJson = SidecarFileJsonSchema.OpenAndUpgrade(_sidecarFileInfo.FullName); } - catch (Exception e) when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { + Log.Error("Failed to read JSON from file : {FileName}", _sidecarFileInfo.Name); return false; } + Debug.Assert(_sidecarJson != null); return true; } private bool WriteJson() { + Debug.Assert(_sidecarJson != null); try { - // Get json from object - string json = SidecarFileJsonSchema.ToJson(_sidecarJson); - - // Write the text to the sidecar file - File.WriteAllText(_sidecarFileInfo.FullName, json); + // Write the object to the sidecar file + SidecarFileJsonSchema.ToFile(_sidecarFileInfo.FullName, _sidecarJson); } - catch (Exception e) when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { + Log.Error("Failed to write JSON to file : {FileName}", _sidecarFileInfo.Name); return false; } return true; @@ -460,18 +482,14 @@ private bool WriteJson() private bool SetJsonInfo() { - // Create the sidecar json object + // Create or update the sidecar JSON object _sidecarJson ??= new SidecarFileJsonSchema(); - // Schema version - _sidecarJson.SchemaVersion = SidecarFileJsonSchema.Version; - // Media file info _mediaFileInfo.Refresh(); _sidecarJson.MediaLastWriteTimeUtc = _mediaFileInfo.LastWriteTimeUtc; _sidecarJson.MediaLength = _mediaFileInfo.Length; _sidecarJson.MediaHash = ComputeHash(); - Debug.Assert(!string.IsNullOrEmpty(_sidecarJson.MediaHash)); // Tool version info _sidecarJson.FfProbeToolVersion = Tools.FfProbe.Info.Version; @@ -481,7 +499,7 @@ private bool SetJsonInfo() // Compressed tool info _sidecarJson.FfProbeData = StringCompression.Compress(_ffProbeJson); _sidecarJson.MkvMergeData = StringCompression.Compress(_mkvMergeJson); - _sidecarJson.MediaInfoData = StringCompression.Compress(_mediaInfoXml); + _sidecarJson.MediaInfoData = StringCompression.Compress(_mediaInfoJson); // State _sidecarJson.State = State; @@ -495,7 +513,7 @@ private bool GetToolInfo() // Read the tool data text if ( - !Tools.MediaInfo.GetMediaPropsXml(_mediaFileInfo.FullName, out _mediaInfoXml) + !Tools.MediaInfo.GetMediaPropsJson(_mediaFileInfo.FullName, out _mediaInfoJson) || !Tools.MkvMerge.GetMediaPropsJson(_mediaFileInfo.FullName, out _mkvMergeJson) || !Tools.FfProbe.GetMediaPropsJson(_mediaFileInfo.FullName, out _ffProbeJson) ) @@ -506,8 +524,8 @@ private bool GetToolInfo() // Deserialize the tool data if ( - !MediaInfo.Tool.GetMediaPropsFromXml( - _mediaInfoXml, + !MediaInfo.Tool.GetMediaPropsFromJson( + _mediaInfoJson, _mediaFileInfo.Name, out MediaProps mediaInfoProps ) @@ -542,13 +560,12 @@ out MediaProps ffProbeProps private string ComputeHash() { + // Rent buffer from shared pool + int hashSize = 2 * HashWindowLength; + byte[] hashBuffer = ArrayPool.Shared.Rent(hashSize); try { - // TODO: Reuse this object or the buffer without breaking multithreading - // Allocate buffer to hold data to be hashed - byte[] hashBuffer = new byte[2 * HashWindowLength]; - - // Open file + // Open file for shared reading using FileStream fileStream = _mediaFileInfo.Open( FileMode.Open, FileAccess.Read, @@ -556,17 +573,15 @@ private string ComputeHash() ); // Small files read entire file, big files read front and back - if (_mediaFileInfo.Length <= hashBuffer.Length) + if (_mediaFileInfo.Length <= hashSize) { - // Read the entire file, buffer is already zeroed + // Read the entire file + hashSize = (int)_mediaFileInfo.Length; _ = fileStream.Seek(0, SeekOrigin.Begin); - if ( - fileStream.Read(hashBuffer, 0, (int)_mediaFileInfo.Length) - != _mediaFileInfo.Length - ) + if (fileStream.Read(hashBuffer, 0, hashSize) != _mediaFileInfo.Length) { Log.Error("Error reading from media file : {FileName}", _mediaFileInfo.Name); - return null; + return string.Empty; } } else @@ -576,7 +591,7 @@ private string ComputeHash() if (fileStream.Read(hashBuffer, 0, HashWindowLength) != HashWindowLength) { Log.Error("Error reading from media file : {FileName}", _mediaFileInfo.Name); - return null; + return string.Empty; } // Read the end of the file @@ -587,19 +602,21 @@ private string ComputeHash() ) { Log.Error("Error reading from media file : {FileName}", _mediaFileInfo.Name); - return null; + return string.Empty; } } - - // Close stream fileStream.Close(); // Calculate the hash and convert to string - return System.Convert.ToBase64String(SHA256.HashData(hashBuffer)); + return System.Convert.ToBase64String(SHA256.HashData(hashBuffer.AsSpan(0, hashSize))); + } + catch (Exception e) when (Log.Logger.LogAndHandle(e)) + { + return string.Empty; } - catch (Exception e) when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + finally { - return null; + ArrayPool.Shared.Return(hashBuffer); } } @@ -633,8 +650,13 @@ public static string GetMkvName(FileInfo sidecarFileInfo) => public void WriteLine() { + Debug.Assert(_sidecarJson != null); + Debug.Assert(_mediaInfoJson != null); + Debug.Assert(_mkvMergeJson != null); + Debug.Assert(_ffProbeJson != null); + Log.Information("State: {State}", State); - Log.Information("MediaInfoXml: {MediaInfoXml}", _mediaInfoXml); + Log.Information("MediaInfoJson: {MediaInfoJson}", _mediaInfoJson); Log.Information("MkvMergeJson: {MkvMergeJson}", _mkvMergeJson); Log.Information("FfProbeJson: {FfProbeJson}", _ffProbeJson); Log.Information("SchemaVersion: {SchemaVersion}", _sidecarJson.SchemaVersion); diff --git a/PlexCleaner/SidecarFileJsonSchema.cs b/PlexCleaner/SidecarFileJsonSchema.cs index cd108655..1855edb5 100644 --- a/PlexCleaner/SidecarFileJsonSchema.cs +++ b/PlexCleaner/SidecarFileJsonSchema.cs @@ -1,9 +1,10 @@ // See ConfigFileJsonSchema.cs for schema update steps using System; +using System.IO; using System.Text.Json; using System.Text.Json.Serialization; -using Json.Schema.Generation; +using InsaneGenius.Utilities; using Serilog; namespace PlexCleaner; @@ -18,22 +19,22 @@ public record SidecarFileJsonSchemaBase // v1 public record SidecarFileJsonSchema1 : SidecarFileJsonSchemaBase { - protected const int Version = 1; + public const int Version = 1; // v3 : Removed [Obsolete("Removed in v3")] - [JsonExclude] - public string FfMpegToolVersion { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string FfMpegToolVersion { get; set; } = string.Empty; // v3 : Removed [Obsolete("Removed in v3")] - [JsonExclude] - public string MkvToolVersion { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string MkvToolVersion { get; set; } = string.Empty; // v2 : Removed [Obsolete("Removed in v2")] - [JsonExclude] - public string FfIdetInfoData { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string FfIdetInfoData { get; set; } = string.Empty; [JsonRequired] public DateTime MediaLastWriteTimeUtc { get; set; } @@ -43,24 +44,24 @@ public record SidecarFileJsonSchema1 : SidecarFileJsonSchemaBase [JsonRequired] [JsonPropertyName("FfProbeInfoData")] - public string FfProbeData { get; set; } + public string FfProbeData { get; set; } = string.Empty; [JsonRequired] [JsonPropertyName("MkvMergeInfoData")] - public string MkvMergeData { get; set; } + public string MkvMergeData { get; set; } = string.Empty; [JsonRequired] - public string MediaInfoToolVersion { get; set; } + public string MediaInfoToolVersion { get; set; } = string.Empty; [JsonRequired] [JsonPropertyName("MediaInfoData")] - public string MediaInfoData { get; set; } + public string MediaInfoData { get; set; } = string.Empty; } // v2 public record SidecarFileJsonSchema2 : SidecarFileJsonSchema1 { - protected new const int Version = 2; + public new const int Version = 2; public SidecarFileJsonSchema2() { } @@ -70,14 +71,14 @@ public SidecarFileJsonSchema2(SidecarFileJsonSchema1 sidecarFileJsonSchema1) // v2 : Added // v4 : Removed [Obsolete("Removed in v4")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public bool Verified { get; set; } } // v3 public record SidecarFileJsonSchema3 : SidecarFileJsonSchema2 { - protected new const int Version = 3; + public new const int Version = 3; public SidecarFileJsonSchema3() { } @@ -89,11 +90,11 @@ public SidecarFileJsonSchema3(SidecarFileJsonSchema2 sidecarFileJsonSchema2) // v3 : Added [JsonRequired] - public string FfProbeToolVersion { get; set; } + public string FfProbeToolVersion { get; set; } = string.Empty; // v3 : Added [JsonRequired] - public string MkvMergeToolVersion { get; set; } + public string MkvMergeToolVersion { get; set; } = string.Empty; } // v4 @@ -104,13 +105,13 @@ public record SidecarFileJsonSchema4 : SidecarFileJsonSchema3 public SidecarFileJsonSchema4() { } public SidecarFileJsonSchema4(SidecarFileJsonSchema1 sidecarFileJsonSchema1) - : base(sidecarFileJsonSchema1) => Upgrade(SidecarFileJsonSchema1.Version); + : base(sidecarFileJsonSchema1) { } public SidecarFileJsonSchema4(SidecarFileJsonSchema2 sidecarFileJsonSchema2) - : base(sidecarFileJsonSchema2) => Upgrade(SidecarFileJsonSchema2.Version); + : base(sidecarFileJsonSchema2) { } public SidecarFileJsonSchema4(SidecarFileJsonSchema3 sidecarFileJsonSchema3) - : base(sidecarFileJsonSchema3) => Upgrade(SidecarFileJsonSchema3.Version); + : base(sidecarFileJsonSchema3) { } // v4 : Added [JsonRequired] @@ -118,7 +119,33 @@ public SidecarFileJsonSchema4(SidecarFileJsonSchema3 sidecarFileJsonSchema3) // v4 : Added [JsonRequired] - public string MediaHash { get; set; } + public string MediaHash { get; set; } = string.Empty; +} + +// v5 +public record SidecarFileJsonSchema5 : SidecarFileJsonSchema4 +{ + public new const int Version = 5; + + [JsonIgnore] + public int DeserializedVersion { get; private set; } = Version; + + public SidecarFileJsonSchema5() { } + + public SidecarFileJsonSchema5(SidecarFileJsonSchema1 sidecarFileJsonSchema1) + : base(sidecarFileJsonSchema1) => Upgrade(SidecarFileJsonSchema1.Version); + + public SidecarFileJsonSchema5(SidecarFileJsonSchema2 sidecarFileJsonSchema2) + : base(sidecarFileJsonSchema2) => Upgrade(SidecarFileJsonSchema2.Version); + + public SidecarFileJsonSchema5(SidecarFileJsonSchema3 sidecarFileJsonSchema3) + : base(sidecarFileJsonSchema3) => Upgrade(SidecarFileJsonSchema3.Version); + + public SidecarFileJsonSchema5(SidecarFileJsonSchema4 sidecarFileJsonSchema4) + : base(sidecarFileJsonSchema4) => Upgrade(SidecarFileJsonSchema4.Version); + + // v5: Changed MediaInfo from XML to JSON + // No schema change private void Upgrade(int version) { @@ -131,7 +158,7 @@ private void Upgrade(int version) // Defaults State = SidecarFile.StatesType.None; - MediaHash = ""; + MediaHash = string.Empty; } // v2 @@ -146,7 +173,7 @@ private void Upgrade(int version) // Defaults State = SidecarFile.StatesType.None; - MediaHash = ""; + MediaHash = string.Empty; } // v3 @@ -161,64 +188,143 @@ private void Upgrade(int version) : SidecarFile.StatesType.None; // Defaults - MediaHash = ""; + MediaHash = string.Empty; } -#pragma warning restore CS0618 // Type or member is obsolete // v4 + if (version <= SidecarFileJsonSchema4.Version) + { + // Get v4 schema + SidecarFileJsonSchema4 sidecarFileJsonSchema4 = this; + + // v5: Changed MediaInfo schema from XML to JSON + // Convert MediaInfo XML attributes to JSON + string decompressedXml = StringCompression.Decompress( + sidecarFileJsonSchema4.MediaInfoData + ); + string jsonData = MediaInfoXmlParser.GenericXmlToJson(decompressedXml); + sidecarFileJsonSchema4.MediaInfoData = StringCompression.Compress(jsonData); + } +#pragma warning restore CS0618 // Type or member is obsolete + + // v5 + + // Set schema version to current and save original version + SchemaVersion = Version; + DeserializedVersion = version; + } + + public static SidecarFileJsonSchema FromFile(string path) => FromJson(File.ReadAllText(path)); + + public static SidecarFileJsonSchema OpenAndUpgrade(string path) + { + SidecarFileJsonSchema sidecarJson = FromFile(path); + if (sidecarJson.DeserializedVersion != Version) + { + Log.Warning( + "Writing SidecarFileJsonSchema upgraded from version {LoadedVersion} to {CurrentVersion}, {FileName}", + sidecarJson.DeserializedVersion, + Version, + path + ); + ToFile(path, sidecarJson); + } + return sidecarJson; + } + + public static void ToFile(string path, SidecarFileJsonSchema json) + { + // Set the schema version to the current version + json.SchemaVersion = Version; + + // Write JSON to file + File.WriteAllText(path, ToJson(json)); } - public static string ToJson(SidecarFileJsonSchema json) => - JsonSerializer.Serialize(json, ConfigFileJsonSchema.JsonWriteOptions); + private static string ToJson(SidecarFileJsonSchema json) => + JsonSerializer.Serialize(json, SidecarFileJsonContext.Default.SidecarFileJsonSchema5); - public static SidecarFileJsonSchema FromJson(string json) + // Will throw on failure to deserialize + private static SidecarFileJsonSchema FromJson(string json) { // Deserialize the base class to get the schema version SidecarFileJsonSchemaBase sidecarFileJsonSchemaBase = - JsonSerializer.Deserialize( + JsonSerializer.Deserialize( json, - ConfigFileJsonSchema.JsonReadOptions - ); - if (sidecarFileJsonSchemaBase == null) - { - return null; - } + SidecarFileJsonContext.Default.SidecarFileJsonSchemaBase + ) ?? throw new JsonException("Failed to deserialize SidecarFileJsonSchemaBase"); if (sidecarFileJsonSchemaBase.SchemaVersion != Version) { Log.Warning( - "Converting SidecarFileJsonSchema from {JsonSchemaVersion} to {CurrentSchemaVersion}", + "Converting SidecarFileJsonSchema version from {JsonSchemaVersion} to {CurrentSchemaVersion}", sidecarFileJsonSchemaBase.SchemaVersion, Version ); } // Deserialize the correct version - return sidecarFileJsonSchemaBase.SchemaVersion switch + switch (sidecarFileJsonSchemaBase.SchemaVersion) { - SidecarFileJsonSchema1.Version => new SidecarFileJsonSchema( - JsonSerializer.Deserialize( - json, - ConfigFileJsonSchema.JsonReadOptions - ) - ), - SidecarFileJsonSchema2.Version => new SidecarFileJsonSchema( - JsonSerializer.Deserialize( - json, - ConfigFileJsonSchema.JsonReadOptions - ) - ), - SidecarFileJsonSchema3.Version => new SidecarFileJsonSchema( - JsonSerializer.Deserialize( - json, - ConfigFileJsonSchema.JsonReadOptions - ) - ), - Version => JsonSerializer.Deserialize( - json, - ConfigFileJsonSchema.JsonReadOptions - ), - _ => throw new NotImplementedException(), - }; + case SidecarFileJsonSchema1.Version: + SidecarFileJsonSchema1 sidecarFileJsonSchema1 = + JsonSerializer.Deserialize( + json, + SidecarFileJsonContext.Default.SidecarFileJsonSchema1 + ) ?? throw new JsonException("Failed to deserialize SidecarFileJsonSchema1"); + return new SidecarFileJsonSchema(sidecarFileJsonSchema1); + + case SidecarFileJsonSchema2.Version: + SidecarFileJsonSchema2 sidecarFileJsonSchema2 = + JsonSerializer.Deserialize( + json, + SidecarFileJsonContext.Default.SidecarFileJsonSchema2 + ) ?? throw new JsonException("Failed to deserialize SidecarFileJsonSchema2"); + return new SidecarFileJsonSchema(sidecarFileJsonSchema2); + case SidecarFileJsonSchema3.Version: + SidecarFileJsonSchema3 sidecarFileJsonSchema3 = + JsonSerializer.Deserialize( + json, + SidecarFileJsonContext.Default.SidecarFileJsonSchema3 + ) ?? throw new JsonException("Failed to deserialize SidecarFileJsonSchema3"); + return new SidecarFileJsonSchema(sidecarFileJsonSchema3); + + case SidecarFileJsonSchema4.Version: + SidecarFileJsonSchema4 sidecarFileJsonSchema4 = + JsonSerializer.Deserialize( + json, + SidecarFileJsonContext.Default.SidecarFileJsonSchema4 + ) ?? throw new JsonException("Failed to deserialize SidecarFileJsonSchema4"); + return new SidecarFileJsonSchema(sidecarFileJsonSchema4); + case Version: + SidecarFileJsonSchema sidecarFileJsonSchema = + JsonSerializer.Deserialize( + json, + SidecarFileJsonContext.Default.SidecarFileJsonSchema5 + ) ?? throw new JsonException("Failed to deserialize SidecarFileJsonSchema5"); + return sidecarFileJsonSchema; + default: + throw new NotSupportedException( + $"Unsupported schema version: {sidecarFileJsonSchemaBase.SchemaVersion}" + ); + } } } + +[JsonSourceGenerationOptions( + AllowTrailingCommas = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + ReadCommentHandling = JsonCommentHandling.Skip, + WriteIndented = true, + NewLine = "\r\n" +)] +[JsonSerializable(typeof(SidecarFileJsonSchemaBase))] +[JsonSerializable(typeof(SidecarFileJsonSchema1))] +[JsonSerializable(typeof(SidecarFileJsonSchema2))] +[JsonSerializable(typeof(SidecarFileJsonSchema3))] +[JsonSerializable(typeof(SidecarFileJsonSchema4))] +[JsonSerializable(typeof(SidecarFileJsonSchema))] +internal partial class SidecarFileJsonContext : JsonSerializerContext; diff --git a/PlexCleaner/SubtitleProps.cs b/PlexCleaner/SubtitleProps.cs index 7acee477..bdcd61e0 100644 --- a/PlexCleaner/SubtitleProps.cs +++ b/PlexCleaner/SubtitleProps.cs @@ -19,7 +19,7 @@ public class SubtitleProps(MediaProps mediaProps) : TrackProps(TrackType.Subtitl // Required // Format = track.Format; // Codec = track.CodecId; - public override bool Create(MediaInfoToolXmlSchema.Track track) + public override bool Create(MediaInfoToolJsonSchema.Track track) { // Handle closed captions if (!HandleClosedCaptions(track)) @@ -55,7 +55,7 @@ public override bool Create(MediaInfoToolXmlSchema.Track track) return true; } - private bool HandleClosedCaptions(MediaInfoToolXmlSchema.Track track) + private bool HandleClosedCaptions(MediaInfoToolJsonSchema.Track track) { // Handle closed caption tracks presented as subtitle tracks // return false to abort normal processing diff --git a/PlexCleaner/TagMap.cs b/PlexCleaner/TagMap.cs index a61a78a1..09725d14 100644 --- a/PlexCleaner/TagMap.cs +++ b/PlexCleaner/TagMap.cs @@ -2,11 +2,11 @@ namespace PlexCleaner; public class TagMap { - public string Primary { get; set; } + public string Primary { get; set; } = string.Empty; public MediaTool.ToolType PrimaryTool { get; set; } - public string Secondary { get; set; } + public string Secondary { get; set; } = string.Empty; public MediaTool.ToolType SecondaryTool { get; set; } - public string Tertiary { get; set; } + public string Tertiary { get; set; } = string.Empty; public MediaTool.ToolType TertiaryTool { get; set; } public int Count { get; set; } } diff --git a/PlexCleaner/TagMapSet.cs b/PlexCleaner/TagMapSet.cs index d5fb43c7..e966b57e 100644 --- a/PlexCleaner/TagMapSet.cs +++ b/PlexCleaner/TagMapSet.cs @@ -50,7 +50,7 @@ Dictionary dictionary { // Look for an existing entry string key = prime.ElementAt(i).Format; - if (dictionary.TryGetValue(key, out TagMap tagmap)) + if (dictionary.TryGetValue(key, out TagMap? tagmap)) { // Increment the usage count tagmap.Count++; diff --git a/PlexCleaner/ToolInfoJsonSchema.cs b/PlexCleaner/ToolInfoJsonSchema.cs index 494c7dc2..fbfaf694 100644 --- a/PlexCleaner/ToolInfoJsonSchema.cs +++ b/PlexCleaner/ToolInfoJsonSchema.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.ComponentModel; using System.IO; -using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using Serilog; @@ -22,8 +21,8 @@ public class ToolInfoJsonSchema public List Tools { get; } = []; - public MediaToolInfo GetToolInfo(MediaTool mediaTool) => - Tools.FirstOrDefault(t => t.ToolFamily == mediaTool.GetToolFamily()); + public MediaToolInfo? GetToolInfo(MediaTool mediaTool) => + Tools.Find(tool => tool.ToolFamily == mediaTool.GetToolFamily()); public static ToolInfoJsonSchema FromFile(string path) => FromJson(File.ReadAllText(path)); @@ -31,10 +30,12 @@ public static void ToFile(string path, ToolInfoJsonSchema json) => File.WriteAllText(path, ToJson(json)); private static string ToJson(ToolInfoJsonSchema tools) => - JsonSerializer.Serialize(tools, ConfigFileJsonSchema.JsonWriteOptions); + JsonSerializer.Serialize(tools, ToolInfoJsonContext.Default.ToolInfoJsonSchema); + // Will throw on failure to deserialize public static ToolInfoJsonSchema FromJson(string json) => - JsonSerializer.Deserialize(json, ConfigFileJsonSchema.JsonReadOptions); + JsonSerializer.Deserialize(json, ToolInfoJsonContext.Default.ToolInfoJsonSchema) + ?? throw new JsonException("Failed to deserialize ToolInfoJsonSchema"); public static bool Upgrade(ToolInfoJsonSchema json) { @@ -55,3 +56,16 @@ public static bool Upgrade(ToolInfoJsonSchema json) return false; } } + +[JsonSourceGenerationOptions( + AllowTrailingCommas = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + ReadCommentHandling = JsonCommentHandling.Skip, + WriteIndented = true, + NewLine = "\r\n" +)] +[JsonSerializable(typeof(ToolInfoJsonSchema))] +internal partial class ToolInfoJsonContext : JsonSerializerContext; diff --git a/PlexCleaner/Tools.cs b/PlexCleaner/Tools.cs index debb9229..7ef087b8 100644 --- a/PlexCleaner/Tools.cs +++ b/PlexCleaner/Tools.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.IO; using System.Net.Http; -using System.Reflection; using System.Runtime.InteropServices; using System.Threading.Tasks; using InsaneGenius.Utilities; @@ -121,10 +120,10 @@ private static bool VerifyFolderTools() foreach (MediaTool mediaTool in GetToolList()) { // Lookup using the tool family - MediaToolInfo mediaToolInfo = toolInfoJson.GetToolInfo(mediaTool); + MediaToolInfo? mediaToolInfo = toolInfoJson.GetToolInfo(mediaTool); if (mediaToolInfo == null) { - Log.Error("{Tool} not found in Tools.json", mediaTool.GetToolFamily()); + Log.Error("{Tool} not registered", mediaTool.GetToolType()); return false; } @@ -145,7 +144,7 @@ private static bool VerifyFolderTools() mediaTool.Info = mediaToolInfo; } } - catch (Exception e) when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } @@ -157,7 +156,7 @@ public static string GetToolsRoot() // System tools if (Program.Config.ToolsOptions.UseSystem) { - return ""; + return string.Empty; } // Process relative or absolute tools path @@ -167,11 +166,10 @@ public static string GetToolsRoot() return Program.Config.ToolsOptions.RootPath; } - // Get the assembly directory - string toolsRoot = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location); - // Create the root from the relative directory - return Path.GetFullPath(Path.Combine(toolsRoot!, Program.Config.ToolsOptions.RootPath)); + return Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, Program.Config.ToolsOptions.RootPath) + ); } public static string CombineToolPath(string fileName) => @@ -186,13 +184,13 @@ public static bool CheckForNewTools() { // Keep in sync with VerifyFolderTools() - // Checking for new tools are not supported on Linux - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + // Checking for new tools is only supported on Windows + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Log.Warning("Checking for new tools are not supported on Linux"); + Log.Warning("Checking for new tools is only supported on Windows"); if (Program.Config.ToolsOptions.AutoUpdate) { - Log.Warning("Set 'ToolsOptions:AutoUpdate' to 'false' on Linux"); + Log.Warning("Set 'ToolsOptions:AutoUpdate' to 'false' on non-Windows platforms"); Program.Config.ToolsOptions.AutoUpdate = false; } @@ -222,7 +220,7 @@ public static bool CheckForNewTools() { // Read the current tool versions from the JSON file string toolsFile = GetToolsJsonPath(); - ToolInfoJsonSchema toolInfoJson = null; + ToolInfoJsonSchema? toolInfoJson = null; if (File.Exists(toolsFile)) { // Deserialize and compare the schema version @@ -238,10 +236,13 @@ public static bool CheckForNewTools() ); if (!ToolInfoJsonSchema.Upgrade(toolInfoJson)) { + // Failed to upgrade schema toolInfoJson = null; } } } + + // Create new schema if not deserialized or upgraded toolInfoJson ??= new ToolInfoJsonSchema(); // Set the last check time @@ -276,7 +277,7 @@ public static bool CheckForNewTools() } // Lookup in JSON file using the tool family - MediaToolInfo jsonToolInfo = toolInfoJson.GetToolInfo(mediaTool); + MediaToolInfo? jsonToolInfo = toolInfoJson.GetToolInfo(mediaTool); bool updateRequired; if (jsonToolInfo == null) { @@ -331,7 +332,7 @@ public static bool CheckForNewTools() // Write updated JSON to file ToolInfoJsonSchema.ToFile(toolsFile, toolInfoJson); } - catch (Exception e) when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (Log.Logger.LogAndHandle(e)) { return false; } @@ -349,12 +350,11 @@ public static bool GetUrlInfo(MediaToolInfo mediaToolInfo) .GetResult() .EnsureSuccessStatusCode(); - mediaToolInfo.Size = (long)httpResponse.Content.Headers.ContentLength; - mediaToolInfo.ModifiedTime = (DateTime) - httpResponse.Content.Headers.LastModified?.DateTime; + mediaToolInfo.Size = httpResponse.Content.Headers.ContentLength ?? 0; + mediaToolInfo.ModifiedTime = + httpResponse.Content.Headers.LastModified?.DateTime ?? DateTime.MinValue; } - catch (HttpRequestException e) - when (Log.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (HttpRequestException e) when (Log.Logger.LogAndHandle(e)) { return false; } @@ -363,9 +363,12 @@ public static bool GetUrlInfo(MediaToolInfo mediaToolInfo) public static async Task DownloadFileAsync(Uri uri, string fileName) { - await using Stream httpStream = await Program.GetHttpClient().GetStreamAsync(uri); + await using Stream httpStream = await Program + .GetHttpClient() + .GetStreamAsync(uri) + .ConfigureAwait(false); await using FileStream fileStream = File.OpenWrite(fileName); - await httpStream.CopyToAsync(fileStream); + await httpStream.CopyToAsync(fileStream).ConfigureAwait(false); } public static bool DownloadFile(Uri uri, string fileName) @@ -374,8 +377,7 @@ public static bool DownloadFile(Uri uri, string fileName) { DownloadFileAsync(uri, fileName).GetAwaiter().GetResult(); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } diff --git a/PlexCleaner/ToolsOptions.cs b/PlexCleaner/ToolsOptions.cs index c333ad16..6c16c273 100644 --- a/PlexCleaner/ToolsOptions.cs +++ b/PlexCleaner/ToolsOptions.cs @@ -13,7 +13,7 @@ public record ToolsOptions1 public bool UseSystem { get; set; } [JsonRequired] - public string RootPath { get; set; } = ""; + public string RootPath { get; set; } = string.Empty; [JsonRequired] public bool RootRelative { get; set; } @@ -24,20 +24,11 @@ public record ToolsOptions1 public void SetDefaults() { // Set defaults based on OS - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - UseSystem = false; - RootPath = @".\Tools\"; - RootRelative = true; - AutoUpdate = true; - } - else - { - UseSystem = true; - RootPath = ""; - RootRelative = false; - AutoUpdate = false; - } + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + UseSystem = !isWindows; + AutoUpdate = isWindows; + RootPath = @".\Tools\"; + RootRelative = true; } public bool VerifyValues() diff --git a/PlexCleaner/TrackProps.cs b/PlexCleaner/TrackProps.cs index 8c235f45..6839fe8b 100644 --- a/PlexCleaner/TrackProps.cs +++ b/PlexCleaner/TrackProps.cs @@ -78,7 +78,10 @@ public virtual bool Create(MkvToolJsonSchema.Track track) Debug.Assert(Parent.Parser == MediaTool.ToolType.MkvMerge); // Fixup non-MKV container formats - if (!Parent.IsContainerMkv() && (string.IsNullOrEmpty(track.Codec) || string.IsNullOrEmpty(track.Properties.CodecId))) + if ( + !Parent.IsContainerMkv() + && (string.IsNullOrEmpty(track.Codec) || string.IsNullOrEmpty(track.Properties.CodecId)) + ) { if (string.IsNullOrEmpty(track.Codec)) { @@ -357,7 +360,10 @@ public virtual bool Create(FfMpegToolJsonSchema.Track track) Debug.Assert(Parent.Parser == MediaTool.ToolType.FfProbe); // Fixup non-MKV container formats - if (!Parent.IsContainerMkv() && (string.IsNullOrEmpty(track.CodecName) || string.IsNullOrEmpty(track.CodecLongName))) + if ( + !Parent.IsContainerMkv() + && (string.IsNullOrEmpty(track.CodecName) || string.IsNullOrEmpty(track.CodecLongName)) + ) { if (string.IsNullOrEmpty(track.CodecName)) { @@ -440,31 +446,31 @@ public virtual bool Create(FfMpegToolJsonSchema.Track track) } // Flags - if (track.Disposition.Default != 0) + if (track.Disposition.IsDefault) { Flags |= FlagsType.Default; } - if (track.Disposition.Forced != 0) + if (track.Disposition.IsForced) { Flags |= FlagsType.Forced; } - if (track.Disposition.Original != 0) + if (track.Disposition.IsOriginal) { Flags |= FlagsType.Original; } - if (track.Disposition.Comment != 0) + if (track.Disposition.IsCommentary) { Flags |= FlagsType.Commentary; } - if (track.Disposition.HearingImpaired != 0) + if (track.Disposition.IsHearingImpaired) { Flags |= FlagsType.HearingImpaired; } - if (track.Disposition.VisualImpaired != 0) + if (track.Disposition.IsVisualImpaired) { Flags |= FlagsType.VisualImpaired; } - if (track.Disposition.Descriptions != 0) + if (track.Disposition.IsDescriptions) { Flags |= FlagsType.Descriptions; } @@ -475,7 +481,8 @@ public virtual bool Create(FfMpegToolJsonSchema.Track track) .Tags.FirstOrDefault(item => item.Key.Equals("title", StringComparison.OrdinalIgnoreCase) ) - .Value ?? ""; + .Value + ?? string.Empty; // Language Language = @@ -483,7 +490,8 @@ public virtual bool Create(FfMpegToolJsonSchema.Track track) .Tags.FirstOrDefault(item => item.Key.Equals("language", StringComparison.OrdinalIgnoreCase) ) - .Value ?? ""; + .Value + ?? string.Empty; // TODO: FfProbe uses the tag language value instead of the track language // Some files show MediaInfo and MkvMerge say language is "eng", FfProbe says language is "und" @@ -527,7 +535,7 @@ public virtual bool Create(FfMpegToolJsonSchema.Track track) // Required // Format = track.Format; // Codec = track.CodecId; - public virtual bool Create(MediaInfoToolXmlSchema.Track track) + public virtual bool Create(MediaInfoToolJsonSchema.Track track) { Debug.Assert(Parent.Parser == MediaTool.ToolType.MediaInfo); @@ -538,7 +546,10 @@ public virtual bool Create(MediaInfoToolXmlSchema.Track track) } // Fixup non-MKV container formats - if (!Parent.IsContainerMkv() && (string.IsNullOrEmpty(track.Format) || string.IsNullOrEmpty(track.CodecId))) + if ( + !Parent.IsContainerMkv() + && (string.IsNullOrEmpty(track.Format) || string.IsNullOrEmpty(track.CodecId)) + ) { if (string.IsNullOrEmpty(track.Format)) { @@ -597,11 +608,11 @@ public virtual bool Create(MediaInfoToolXmlSchema.Track track) // HearingImpaired // Descriptions - if (track.Default) + if (track.IsDefault) { Flags |= FlagsType.Default; } - if (track.Forced) + if (track.IsForced) { Flags |= FlagsType.Forced; } @@ -650,7 +661,7 @@ public virtual bool Create(MediaInfoToolXmlSchema.Track track) return true; } - private bool HandleSubTracks(MediaInfoToolXmlSchema.Track track) + private bool HandleSubTracks(MediaInfoToolJsonSchema.Track track) { // StreamOrder maps to Id // Id maps to Number @@ -663,7 +674,7 @@ private bool HandleSubTracks(MediaInfoToolXmlSchema.Track track) { // Ignoring sub-track Log.Warning( - "{Parser} : {Type} : Ignoring sub-track : Id: {Id}, Number: {Id}, Container: {Container} : {FileName}", + "{Parser} : {Type} : Ignoring sub-track : Id: {Id}, Number: {Number}, Container: {Container} : {FileName}", Parent.Parser, Type, track.StreamOrder, diff --git a/PlexCleaner/VerifyOptions.cs b/PlexCleaner/VerifyOptions.cs index 18fa7bf4..0b898a8b 100644 --- a/PlexCleaner/VerifyOptions.cs +++ b/PlexCleaner/VerifyOptions.cs @@ -1,7 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; -using Json.Schema.Generation; namespace PlexCleaner; @@ -21,17 +20,17 @@ public record VerifyOptions1 // v2 : Removed [Obsolete("Removed in v2")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public int MinimumDuration { get; set; } // v2 : Removed [Obsolete("Removed in v2")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public int VerifyDuration { get; set; } // v2 : Removed [Obsolete("Removed in v2")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public int IdetDuration { get; set; } [JsonRequired] @@ -39,7 +38,7 @@ public record VerifyOptions1 // v2 : Removed [Obsolete("Removed in v2")] - [JsonExclude] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] public int MinimumFileAge { get; set; } } diff --git a/PlexCleaner/VideoProps.cs b/PlexCleaner/VideoProps.cs index 2528cd14..3eddde5a 100644 --- a/PlexCleaner/VideoProps.cs +++ b/PlexCleaner/VideoProps.cs @@ -120,7 +120,7 @@ public override bool Create(FfMpegToolJsonSchema.Track track) // Required // Format = track.Format; // Codec = track.CodecId; - public override bool Create(MediaInfoToolXmlSchema.Track track) + public override bool Create(MediaInfoToolJsonSchema.Track track) { // Call base if (!base.Create(track)) diff --git a/PlexCleanerTests/CommandLineTests.cs b/PlexCleanerTests/CommandLineTests.cs index 8f28b7d7..4a215a0e 100644 --- a/PlexCleanerTests/CommandLineTests.cs +++ b/PlexCleanerTests/CommandLineTests.cs @@ -337,6 +337,54 @@ public void Parse_Commandline_RemoveSubtitles(params string[] args) _ = options.MediaFiles[0].Should().Be("/data/foo"); } + [Theory] + [InlineData( + "removeclosedcaptions", + "--settingsfile=settings.json", + "--mediafiles=/data/foo", + "--parallel", + "--threadcount=4", + "--quickscan" + )] + public void Parse_Commandline_RemoveClosedCaptions_Full(params string[] args) + { + CommandLineParser parser = new(args); + _ = parser.Result.Errors.Should().BeEmpty(); + _ = parser.Result.CommandResult.Command.Name.Should().Be("removeclosedcaptions"); + + CommandLineOptions options = parser.Bind(); + _ = options.Should().NotBeNull(); + _ = options.SettingsFile.Should().Be("settings.json"); + _ = options.MediaFiles.Count.Should().Be(1); + _ = options.MediaFiles[0].Should().Be("/data/foo"); + _ = options.Parallel.Should().BeTrue(); + _ = options.ThreadCount.Should().Be(4); + _ = options.QuickScan.Should().BeTrue(); + } + + [Theory] + [InlineData( + "testmediainfo", + "--settingsfile=settings.json", + "--mediafiles=/data/foo", + "--parallel", + "--threadcount=2" + )] + public void Parse_Commandline_TestMediaInfo(params string[] args) + { + CommandLineParser parser = new(args); + _ = parser.Result.Errors.Should().BeEmpty(); + _ = parser.Result.CommandResult.Command.Name.Should().Be("testmediainfo"); + + CommandLineOptions options = parser.Bind(); + _ = options.Should().NotBeNull(); + _ = options.SettingsFile.Should().Be("settings.json"); + _ = options.MediaFiles.Count.Should().Be(1); + _ = options.MediaFiles[0].Should().Be("/data/foo"); + _ = options.Parallel.Should().BeTrue(); + _ = options.ThreadCount.Should().Be(2); + } + [Theory] [InlineData("--help")] [InlineData("--version")] @@ -365,7 +413,7 @@ public void Parse_Commandline_Help(params string[] args) } [Theory] - [InlineData()] + [InlineData] [InlineData("--foo")] [InlineData("foo")] [InlineData("defaultsettings", "--settingsfile=settings.json", "--foo")] diff --git a/PlexCleanerTests/ConfigFileTests.cs b/PlexCleanerTests/ConfigFileTests.cs index 0a6f919b..d8f11c70 100644 --- a/PlexCleanerTests/ConfigFileTests.cs +++ b/PlexCleanerTests/ConfigFileTests.cs @@ -1,25 +1,30 @@ using PlexCleaner; using Xunit; +using ConfigFileJsonSchema = PlexCleaner.ConfigFileJsonSchema4; namespace PlexCleanerTests; -public class ConfigFileTests(PlexCleanerFixture fixture) +public class ConfigFileTests(PlexCleanerFixture assemblyFixture) : SamplesFixture { - private readonly PlexCleanerFixture _fixture = fixture; - [Theory] - [InlineData("PlexCleaner.v1.json")] - [InlineData("PlexCleaner.v2.json")] - [InlineData("PlexCleaner.v3.json")] - [InlineData("PlexCleaner.v4.json")] - public void Open_Old_Schemas_Opens(string fileName) + [InlineData("PlexCleaner.v1.json", ConfigFileJsonSchema1.Version)] + [InlineData("PlexCleaner.v2.json", ConfigFileJsonSchema2.Version)] + [InlineData("PlexCleaner.v3.json", ConfigFileJsonSchema3.Version)] + [InlineData("PlexCleaner.v4.json", ConfigFileJsonSchema.Version)] + public void Open_Old_Schema_Open(string fileName, int expectedDeserializedVersion) { // Deserialize ConfigFileJsonSchema configFileJsonSchema = ConfigFileJsonSchema.FromFile( - _fixture.GetSampleFilePath(fileName) + assemblyFixture.GetSampleFilePath(fileName) ); Assert.NotNull(configFileJsonSchema); + // Verify schema was upgraded to current version + Assert.Equal(ConfigFileJsonSchema.Version, configFileJsonSchema.SchemaVersion); + + // Verify DeserializedVersion reflects the original version + Assert.Equal(expectedDeserializedVersion, configFileJsonSchema.DeserializedVersion); + // Test for expected config values Assert.Equal(@".\Tools\", configFileJsonSchema.ToolsOptions.RootPath); Assert.Equal(@".\Tools\", configFileJsonSchema.ToolsOptions.RootPath); @@ -44,6 +49,35 @@ public void Open_Old_Schemas_Opens(string fileName) configFileJsonSchema.ProcessOptions.FileIgnoreList ); Assert.Equal(100000000, configFileJsonSchema.VerifyOptions.MaximumBitrate); - Assert.Equal(60, configFileJsonSchema.MonitorOptions.MonitorWaitTime); + } + + [Theory] + [InlineData("PlexCleaner.v1.json", ConfigFileJsonSchema1.Version)] + [InlineData("PlexCleaner.v2.json", ConfigFileJsonSchema2.Version)] + [InlineData("PlexCleaner.v3.json", ConfigFileJsonSchema3.Version)] + [InlineData("PlexCleaner.v4.json", ConfigFileJsonSchema.Version)] + public void Open_Old_Schema_Upgrade(string fileName, int expectedDeserializedVersion) + { + // Load config file schema and upgrade on disk + ConfigFileJsonSchema configSchema = ConfigFileJsonSchema.OpenAndUpgrade( + GetSampleFilePath(fileName) + ); + Assert.NotNull(configSchema); + + // Verify schema was upgraded to current version + Assert.Equal(ConfigFileJsonSchema.Version, configSchema.SchemaVersion); + + // Verify DeserializedVersion reflects the original version + Assert.Equal(expectedDeserializedVersion, configSchema.DeserializedVersion); + + // Re-open the file to verify it was saved in current version + configSchema = ConfigFileJsonSchema.FromFile(GetSampleFilePath(fileName)); + Assert.NotNull(configSchema); + + // Verify schema was upgraded to current version + Assert.Equal(ConfigFileJsonSchema.Version, configSchema.SchemaVersion); + + // Verify DeserializedVersion reflects the current version + Assert.Equal(ConfigFileJsonSchema.Version, configSchema.DeserializedVersion); } } diff --git a/PlexCleanerTests/PlexCleanerFixture.cs b/PlexCleanerTests/PlexCleanerFixture.cs index 76faa2b3..21de8476 100644 --- a/PlexCleanerTests/PlexCleanerFixture.cs +++ b/PlexCleanerTests/PlexCleanerFixture.cs @@ -11,18 +11,27 @@ using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; using Xunit; +using ConfigFileJsonSchema = PlexCleaner.ConfigFileJsonSchema4; -// Create instance once per assembly [assembly: AssemblyFixture(typeof(PlexCleanerFixture))] namespace PlexCleanerTests; +// One instance for all tests in the assembly public class PlexCleanerFixture : IDisposable { - // Relative path to Samples - private const string SamplesDirectory = "../../../../Samples/PlexCleaner"; + internal static string GetSamplesAbsoluteDirectory() + { + // Relative path to the Samples directory from the assembly output directory + const string samplesDirectory = "../../../../Samples/PlexCleaner"; - private readonly string _samplesDirectory; + // Get absolute path + Assembly entryAssembly = Assembly.GetEntryAssembly(); + Debug.Assert(entryAssembly != null); + string assemblyDirectory = Path.GetDirectoryName(entryAssembly.Location); + Debug.Assert(!string.IsNullOrEmpty(assemblyDirectory)); + return Path.GetFullPath(Path.Combine(assemblyDirectory, samplesDirectory)); + } public PlexCleanerFixture() { @@ -45,11 +54,7 @@ public PlexCleanerFixture() LogOptions.Logger = Log.Logger; // Get the Samples directory - Assembly entryAssembly = Assembly.GetEntryAssembly(); - Debug.Assert(entryAssembly != null); - string assemblyDirectory = Path.GetDirectoryName(entryAssembly.Location); - Debug.Assert(!string.IsNullOrEmpty(assemblyDirectory)); - _samplesDirectory = Path.GetFullPath(Path.Combine(assemblyDirectory, SamplesDirectory)); + GetSamplesDirectory = GetSamplesAbsoluteDirectory(); } public void Dispose() @@ -58,6 +63,98 @@ public void Dispose() Log.CloseAndFlush(); } + /// + /// Gets the path to a sample file in the read-only samples directory. + /// Use this only when files will not be modified. + /// public string GetSampleFilePath(string fileName) => - Path.GetFullPath(Path.Combine(_samplesDirectory, fileName)); + Path.GetFullPath(Path.Combine(GetSamplesDirectory, fileName)); + + public string GetSamplesDirectory { get; } +} + +// One instance per test and copy of samples in temp directory +public class SamplesFixture : IDisposable +{ + public SamplesFixture() + { + GetSamplesDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + CopySamples(); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + try + { + if (Directory.Exists(GetSamplesDirectory)) + { + Directory.Delete(GetSamplesDirectory, recursive: true); + } + } + catch (Exception ex) + { + Log.Warning( + "Failed to delete temp samples directory {TempDirectory}: {Exception}", + GetSamplesDirectory, + ex + ); + } + } + + private void CopySamples() + { + string sourceSamplesDirectory = PlexCleanerFixture.GetSamplesAbsoluteDirectory(); + _ = Directory.CreateDirectory(GetSamplesDirectory); + try + { + foreach ( + string sourceFilePath in Directory.EnumerateFiles( + sourceSamplesDirectory, + "*", + SearchOption.AllDirectories + ) + ) + { + string relativePath = Path.GetRelativePath(sourceSamplesDirectory, sourceFilePath); + string destFilePath = Path.Combine(GetSamplesDirectory, relativePath); + string destDirPath = Path.GetDirectoryName(destFilePath); + + if (!string.IsNullOrEmpty(destDirPath) && !Directory.Exists(destDirPath)) + { + _ = Directory.CreateDirectory(destDirPath); + } + + File.Copy(sourceFilePath, destFilePath, overwrite: true); + } + } + catch (Exception ex) + { + try + { + if (Directory.Exists(GetSamplesDirectory)) + { + Directory.Delete(GetSamplesDirectory, recursive: true); + } + } + catch (Exception cleanupEx) + { + Log.Warning( + "Failed to cleanup temp directory during exception handling: {Exception}", + cleanupEx + ); + } + + Log.Error("Failed to copy samples to temp directory: {Exception}", ex); + throw; + } + } + + /// + /// Gets the path to a sample file in the temp samples directory. + /// + public string GetSampleFilePath(string fileName) => + Path.GetFullPath(Path.Combine(GetSamplesDirectory, fileName)); + + public string GetSamplesDirectory { get; } } diff --git a/PlexCleanerTests/PlexCleanerTests.csproj b/PlexCleanerTests/PlexCleanerTests.csproj index 53c161b6..3811a18e 100644 --- a/PlexCleanerTests/PlexCleanerTests.csproj +++ b/PlexCleanerTests/PlexCleanerTests.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 @@ -17,6 +17,5 @@ - diff --git a/PlexCleanerTests/SidecarFileTests.cs b/PlexCleanerTests/SidecarFileTests.cs index a4f1143e..0e458e75 100644 --- a/PlexCleanerTests/SidecarFileTests.cs +++ b/PlexCleanerTests/SidecarFileTests.cs @@ -1,25 +1,74 @@ using PlexCleaner; using Xunit; +using SidecarFileJsonSchema = PlexCleaner.SidecarFileJsonSchema5; namespace PlexCleanerTests; -public class SidecarFileTests(PlexCleanerFixture fixture) +public class SidecarFileTests : SamplesFixture { - private readonly PlexCleanerFixture _fixture = fixture; + [Theory] + [InlineData("Sidecar.v1.PlexCleaner", SidecarFileJsonSchema1.Version)] + [InlineData("Sidecar.v2.PlexCleaner", SidecarFileJsonSchema2.Version)] + [InlineData("Sidecar.v3.PlexCleaner", SidecarFileJsonSchema3.Version)] + [InlineData("Sidecar.v4.PlexCleaner", SidecarFileJsonSchema4.Version)] + [InlineData("Sidecar.v5.PlexCleaner", SidecarFileJsonSchema.Version)] + public void Open_Old_Schema_Open(string fileName, int expectedDeserializedVersion) + { + // Load sidecar file schema directly + SidecarFileJsonSchema sidecarSchema = SidecarFileJsonSchema.FromFile( + GetSampleFilePath(fileName) + ); + Assert.NotNull(sidecarSchema); + + // Verify schema was upgraded to current version + Assert.Equal(SidecarFileJsonSchema.Version, sidecarSchema.SchemaVersion); + + // Verify DeserializedVersion reflects the original version + Assert.Equal(expectedDeserializedVersion, sidecarSchema.DeserializedVersion); + } + + [Theory] + [InlineData("Sidecar.v1.PlexCleaner", SidecarFileJsonSchema1.Version)] + [InlineData("Sidecar.v2.PlexCleaner", SidecarFileJsonSchema2.Version)] + [InlineData("Sidecar.v3.PlexCleaner", SidecarFileJsonSchema3.Version)] + [InlineData("Sidecar.v4.PlexCleaner", SidecarFileJsonSchema4.Version)] + [InlineData("Sidecar.v5.PlexCleaner", SidecarFileJsonSchema.Version)] + public void Open_Old_Schema_Upgrade(string fileName, int expectedDeserializedVersion) + { + // Load sidecar file schema and upgrade on disk + SidecarFileJsonSchema sidecarSchema = SidecarFileJsonSchema.OpenAndUpgrade( + GetSampleFilePath(fileName) + ); + Assert.NotNull(sidecarSchema); + + // Verify schema was upgraded to current version + Assert.Equal(SidecarFileJsonSchema.Version, sidecarSchema.SchemaVersion); + + // Verify DeserializedVersion reflects the original version + Assert.Equal(expectedDeserializedVersion, sidecarSchema.DeserializedVersion); + + // Re-open the file to verify it was saved in current version + sidecarSchema = SidecarFileJsonSchema.FromFile(GetSampleFilePath(fileName)); + Assert.NotNull(sidecarSchema); + + // Verify schema was upgraded to current version + Assert.Equal(SidecarFileJsonSchema.Version, sidecarSchema.SchemaVersion); + + // Verify DeserializedVersion reflects the current version + Assert.Equal(SidecarFileJsonSchema.Version, sidecarSchema.DeserializedVersion); + } [Theory] [InlineData("Sidecar.v1.mkv")] [InlineData("Sidecar.v2.mkv")] [InlineData("Sidecar.v3.mkv")] [InlineData("Sidecar.v4.mkv")] - public void Open_Old_Schema_Open(string fileName) + [InlineData("Sidecar.v5.mkv")] + public void Open_Old_File_Open(string fileName) { - SidecarFile sidecarFile = new(_fixture.GetSampleFilePath(fileName)); - // Read the JSON file but do not verify the MKV media attributes - // TODO: Use media files that match the JSON, currently dummy files + SidecarFile sidecarFile = new(GetSampleFilePath(fileName)); Assert.True(sidecarFile.Read(out _, false)); - // Test for expected config values Assert.True(sidecarFile.FfProbeProps.Audio.Count > 0); Assert.True(sidecarFile.FfProbeProps.Audio.Count > 0); Assert.Equal(MediaTool.ToolType.FfProbe, sidecarFile.FfProbeProps.Parser); diff --git a/README.md b/README.md index 46bc7d1f..0d65b4ad 100644 --- a/README.md +++ b/README.md @@ -2,238 +2,340 @@ Utility to optimize media files for Direct Play in Plex, Emby, Jellyfin, etc. -## Build +## Build and Distribution -Code and Pipeline is on [GitHub][github-link].\ -Binary releases are published on [GitHub Releases][releases-link].\ -Docker images are published on [Docker Hub][docker-link]. +- **Source Code**: [GitHub][github-link] - Full source code, issues, and CI/CD pipelines. +- **Binary Releases**: [GitHub Releases][releases-link] - Pre-compiled executables for Windows, Linux, and macOS. +- **Docker Images**: [Docker Hub][docker-link] - Container images with all tools pre-installed. -## Status +### Build Status [![Release Status][release-status-shield]][actions-link]\ [![Docker Status][docker-status-shield]][actions-link]\ [![Last Commit][last-commit-shield]][commit-link]\ [![Last Build][last-build-shield]][actions-link] -## Releases +### Releases [![GitHub Release][release-version-shield]][releases-link]\ [![GitHub Pre-Release][pre-release-version-shield]][releases-link]\ [![Docker Latest][docker-latest-version-shield]][docker-link]\ [![Docker Develop][docker-develop-version-shield]][docker-link] -## Release Notes - -- Version 3:14: - - Switch to using [CliWrap](https://github.com/Tyrrrz/CliWrap) for commandline tool process execution. - - Remove dependency on [deprecated](https://github.com/dotnet/command-line-api/issues/2576) `System.CommandLine.NamingConventionBinder` by directly using commandline options binding. - - Converted media tool commandline creation to using fluent builder pattern. - - Converted FFprobe JSON packet parsing to using streaming per-packet processing using [Utf8JsonAsyncStreamReader][utf8jsonasync-link] vs. read everything into memory and then process. - - Switched editorconfig `charset` from `utf-8-bom` to `utf-8` as some tools and PR merge in GitHub always write files without the BOM. - - Improved closed caption detection in MediaInfo, e.g. discrete detection of separate `SCTE 128` tracks vs. `A/53` embedded video tracks. - - Improved media tool parsing resiliency when parsing non-Matroska containers, i.e. added `testmediainfo` command to attempt parsing media files. - - Add [Husky.Net](https://alirezanet.github.io/Husky.Net) for pre-commit hook code style validation. - - General refactoring. -- Version 3.13: - - Escape additional filename characters for use with `ffprobe movie=filename[out0+subcc]` command. Fixes [#524](https://github.com/ptr727/PlexCleaner/issues/524). -- See [Release History](./HISTORY.md) for older Release Notes. +### Release Notes + +**Version: 3.15**: + +**Summary:** + +- Updated from .NET 9 to .NET 10. +- Refactored code to support Nullable types and Native AOT. +- Changed MediaInfo output from XML to JSON for AOT compatibility. +- Documentation structure update. + +> **⚠️ Docker Breaking Changes:** +> +> - Only `ubuntu:rolling` images are published (Alpine and Debian discontinued). +> - Only `linux/amd64` and `linux/arm64` architectures supported (`linux/arm/v7` discontinued). +> - Update compose files: Use `docker.io/ptr727/plexcleaner:latest` (Based on `ubuntu:rolling`). + +See [Release History](./HISTORY.md) for complete release notes and older versions. + +## Getting Started + +Get started with PlexCleaner in three easy steps using Docker (recommended): + +> **⚠️ Important**: PlexCleaner modifies media files in place. Always maintain backups of your media library before processing. Consider testing on sample files first. +> +> **ℹ️ Note**: Replace `/data/media` with your actual host media directory path. All examples map the host directory to `/media` inside the container. + +```shell +# 1. Create a default settings file +docker run --rm --volume /data/media:/media:rw docker.io/ptr727/plexcleaner \ + /PlexCleaner/PlexCleaner defaultsettings --settingsfile /media/PlexCleaner/PlexCleaner.json + +# 2. Edit /data/media/PlexCleaner/PlexCleaner.json to suit your needs + +# 3. Process your media files +docker run --rm --volume /data/media:/media:rw docker.io/ptr727/plexcleaner \ + /PlexCleaner/PlexCleaner --logfile /media/PlexCleaner/PlexCleaner.log \ + process --settingsfile /media/PlexCleaner/PlexCleaner.json \ + --mediafiles /media/Movies --mediafiles /media/Series +``` + +See [Installation](#installation) for detailed setup instructions and other platforms. + +## Table of Contents + +- [Build and Distribution](#build-and-distribution) + - [Build Status](#build-status) + - [Releases](#releases) + - [Release Notes](#release-notes) +- [Getting Started](#getting-started) +- [Table of Contents](#table-of-contents) +- [Questions or Issues](#questions-or-issues) +- [Use Cases](#use-cases) +- [Performance Considerations](#performance-considerations) +- [Installation](#installation) + - [Docker](#docker) + - [Windows](#windows) + - [Linux](#linux) + - [macOS](#macos) + - [AOT](#aot) +- [Configuration](#configuration) + - [Default Settings](#default-settings) + - [Common Configuration Examples](#common-configuration-examples) + - [IETF Language Matching](#ietf-language-matching) + - [EIA-608 and CTA-708 Closed Captions](#eia-608-and-cta-708-closed-captions) + - [Custom FFmpeg and Handbrake Encoding Settings](#custom-ffmpeg-and-handbrake-encoding-settings) +- [Usage](#usage) + - [Common Commands Quick Reference](#common-commands-quick-reference) + - [Global Options](#global-options) + - [Process Command](#process-command) + - [Monitor Command](#monitor-command) + - [Other Commands](#other-commands) +- [Testing](#testing) + - [Unit Testing](#unit-testing) + - [Docker Testing](#docker-testing) + - [Regression Testing](#regression-testing) +- [Development Tooling](#development-tooling) +- [Feature Ideas](#feature-ideas) +- [3rd Party Tools](#3rd-party-tools) +- [Sample Media Files](#sample-media-files) +- [License](#license) ## Questions or Issues -- Use the [Discussions][discussions-link] forum for general questions. -- Refer to the [Issues][issues-link] tracker for known problems. -- Report bugs in the [Issues][issues-link] tracker. +**For General Questions:** + +- Use the [Discussions][discussions-link] forum for general questions, feature requests, and sharing working configurations. + +**For Bug Reports:** + +- Ask in the [Discussions][discussions-link] forum if you are not sure if it is a bug. +- Check the [Issues][issues-link] tracker for known problems first. +- When reporting a new bug, please include: + - PlexCleaner version (`PlexCleaner --version`). + - Operating system and architecture (Windows/Linux/Docker, x64/arm64). + - Media tool versions (`PlexCleaner gettoolinfo`). + - Complete command line and relevant configuration settings. + - Full log output with `--debug` flag enabled. + - Sample media file information (`PlexCleaner getmediainfo --mediafiles `). + - Steps to reproduce the issue. ## Use Cases -The objective of PlexCleaner is to modify media content such that it will always Direct Play in [Plex](https://support.plex.tv/articles/200250387-streaming-media-direct-play-and-direct-stream/), [Emby](https://support.emby.media/support/solutions/articles/44001920144-direct-play-vs-direct-streaming-vs-transcoding), [Jellyfin](https://jellyfin.org/docs/plugin-api/MediaBrowser.Model.Session.PlayMethod.html), etc. - -Below are examples of issues that can be resolved using the primary `process` command: - -- Container file formats other than MKV are not supported by all platforms, re-multiplex to MKV. -- Licensing for some codecs like MPEG-2 prevents hardware decoding, re-encode to H.264. -- Some video codecs like MPEG-4 or VC-1 cause playback issues, re-encode to H.264. -- Some H.264 video profiles like `Constrained Baseline@30` cause playback issues, re-encode to H.264 `High@40`. -- On some displays interlaced video cause playback issues, deinterlace using HandBrake and the `--comb-detect --decomb` options. -- Some audio codecs like Vorbis or WMAPro are not supported by the client platform, re-encode to AC3. -- Some subtitle tracks like VOBsub cause hangs when the `MuxingMode` attribute is not set, re-multiplex to set the correct `MuxingMode`. -- Automatic audio and subtitle track selection requires the track language to be set, set the language for unknown tracks. -- Duplicate audio or subtitle tracks of the same language cause issues with player track selection, delete duplicate tracks, and keep the best quality audio tracks. -- Corrupt media streams cause playback issues, verify stream integrity, and try to automatically repair by re-encoding. -- Some WiFi or 100Mbps Ethernet connected devices with small read buffers hang when playing high bitrate content, warn when media bitrate exceeds the network bitrate. -- Dolby Vision is only supported on DV capable displays, warn when the HDR profile is `Dolby Vision` (profile 5) vs. `Dolby Vision / SMPTE ST 2086` (profile 7) that supports DV and HDR10/HDR10+ displays. -- EIA-608 and CTA-708 closed captions (CC) embedded in video streams can't be disabled or managed from the player, remove embedded closed captions from video streams. -- See the [`process` command](#process-command) for more details. +> **ℹ️ TL;DR**: *Direct Play* means your media server (Plex/Emby/Jellyfin) sends the file directly to your player without transcoding on the server or the client. This saves server CPU, reduces power consumption, preserves quality, and enables playback on low-power devices. The **objective of PlexCleaner** is to *modify media content* such that it will always Direct Play in [Plex](https://support.plex.tv/articles/200250387-streaming-media-direct-play-and-direct-stream/), [Emby](https://support.emby.media/support/solutions/articles/44001920144-direct-play-vs-direct-streaming-vs-transcoding), [Jellyfin](https://jellyfin.org/docs/plugin-api/MediaBrowser.Model.Session.PlayMethod.html), etc. + +Common examples of issues resolved by the `process` command: + +**Container & Codec Issues:** + +- Non-MKV containers → Re-multiplex to MKV (player compatibility). +- MPEG-2 video → Re-encode to H.264 (licensing prevents hardware decoding). +- MPEG-4 or VC-1 video → Re-encode to H.264 (playback issues). +- H.264 `Constrained Baseline@30` → Re-encode to H.264 `High@40` (playback issues). +- Vorbis or WMAPro audio → Re-encode to AC3 (platform compatibility). + +**Track Management:** + +- Missing language tags → Set language for unknown tracks (enables automatic selection). +- Duplicate audio/subtitle tracks → Remove duplicates, keep best quality. +- VOBsub subtitles without `MuxingMode` → Re-multiplex to set correct attribute (prevents hangs). + +**Video Quality:** + +- Interlaced video → Deinterlace using HandBrake `--comb-detect --decomb`. +- Embedded closed captions (EIA-608/CTA-708) → Remove from video streams (can't be managed by player). +- Dolby Vision profile 5 → Warn when not profile 7 (DV/HDR10 compatibility). + +**Performance & Integrity:** + +- Corrupt media streams → Verify integrity and attempt automatic repair. +- High bitrate content → Warn when exceeding network capacity (WiFi/100Mbps Ethernet). + +See the [`process` command](#process-command) for detailed workflow and the [Common Configuration Examples](#common-configuration-examples) for quick setup examples. ## Performance Considerations +PlexCleaner is optimized for processing large media libraries efficiently. Key performance features and tips: + +> **⚡ Performance Tips:** +> +> - **Large libraries**: Use `--parallel` to process multiple files concurrently. +> - **Testing**: Combine `--testsnippets` and `--quickscan` for faster test iterations. +> - **Network storage**: Process files locally when possible to avoid network bottlenecks. +> - **Docker logging**: Configure [log rotation](https://docs.docker.com/config/containers/logging/configure/) to prevent large log files. +> - **Thread count**: Default is half of CPU cores (max 4); adjust with `--threadcount` if needed. + +**Sidecar Files:** + - To improve processing performance of large media collections, the media file attributes and processing state is cached in sidecar files. (`filename.mkv` -> `filename.PlexCleaner`) - Sidecar files allow re-processing of the same files to be very fast as the state will be read from the sidecar vs. re-computed from the media file. - The sidecar maintains a hash of small parts of the media file (timestamps are unreliable), and the media file will be reprocessed when a change in the media file is detected. + +**Processing Operations:** + - Re-multiplexing is an IO intensive operation and re-encoding is a CPU intensive operation. - Parallel processing, using the `--parallel` option, is useful when a single instance of FFmpeg or HandBrake does not saturate all the available CPU resources. - Processing can be interrupted using `Ctrl-Break`, if using sidecar files restarting will skip previously verified files. + +**Docker Considerations:** + - Processing very large media collections on docker may result in a very large docker log file, set appropriate [docker logging](https://docs.docker.com/config/containers/logging/configure/) options. ## Installation -[Docker](#docker) builds are the easiest and most up to date way to run, and can be used on any platform that supports `linux/amd64`, `linux/arm64`, or `linux/arm/v7` architectures. -Alternatively, install directly on [Windows](#windows), [Linux](#linux), or [MacOS](#macos) following the provided instructions. +Choose an installation method based on your platform and requirements: + +- **[Docker](#docker)** (Recommended): Easiest and most up-to-date option. + - ✅ All tools pre-installed and automatically updated. + - ✅ Consistent experience across platforms. + - ✅ Supports `linux/amd64` and `linux/arm64` architectures. + - Best for: Linux, NAS devices, servers, cross-platform deployments. + +- **[Windows](#windows)**: Native installation with automatic tool updates. + - ✅ Automatic tool downloads and updates via `checkfornewtools` command. + - ✅ Or use `winget` for system-wide tool installation. + - Best for: Windows desktops and servers. + +- **[Linux](#linux)**: Manual installation. + - ⚠️ Requires manual tool installation via package manager. + - ⚠️ No automatic tool updates. + - Best for: Users who prefer native binaries over Docker. + +- **[macOS](#macos)**: Limited support. + - ⚠️ Binaries built but not tested in CI. + - ⚠️ Manual tool installation required. ### Docker - Builds are published on [Docker Hub][plexcleaner-hub-link]. -- See the [Docker README][docker-link] for full distribution details and current media tool versions. - - `ptr727/plexcleaner:latest` is an alias for the `ubuntu` tag. - - `ptr727/plexcleaner:ubuntu` is based on [Ubuntu][ubuntu-hub-link] (`ubuntu:rolling`). - - `ptr727/plexcleaner:alpine` is based on [Alpine][alpine-docker-link] (`alpine:latest`). - - `ptr727/plexcleaner:debian` is based on [Debian][debian-hub-link] (`debian:stable-slim`). +- See the [Docker README][docker-link] for current distribution and media tool versions. +- `ptr727/plexcleaner:latest` is based on [Ubuntu][ubuntu-hub-link] (`ubuntu:rolling`) built from the `main` branch. +- `ptr727/plexcleaner:develop` is based on [Ubuntu][ubuntu-hub-link] (`ubuntu:rolling`) built from the `develop` branch. - Images are updated weekly with the latest upstream updates. - The container has all the prerequisite 3rd party tools pre-installed. -- Map your host volumes, and make sure the user has permission to access and modify media files. -- The container is intended to be used in interactive mode, for long running operations run in a `screen` session. -- See examples below for instructions on getting started. -Example, run in an interactive shell: +**Path Mapping Convention**: All examples use `/data/media` as the host path mapped to `/media` inside the container. Replace `/data/media` with your actual host media location. + +#### Docker Compose (Recommended for Monitor Mode) + +For continuous monitoring of media folders, use Docker Compose. + +```yaml +services: + + plexcleaner: + image: docker.io/ptr727/plexcleaner:latest # Use :develop for pre-release builds + container_name: PlexCleaner + restart: unless-stopped + user: 1000:100 # Change to match your nonroot:users + command: + - /PlexCleaner/PlexCleaner + - monitor # Monitor command + - --settingsfile=/media/PlexCleaner/PlexCleaner.json # Path inside container + - --logfile=/media/PlexCleaner/PlexCleaner.log # Path inside container + - --preprocess # Process all existing files on startup + - --mediafiles=/media/Series # Add multiple --mediafiles for each folder to monitor + - --mediafiles=/media/Movies # Paths inside container (mapped from host volumes) + environment: + - TZ=America/Los_Angeles # Set your timezone + volumes: + - /data/media:/media # Map host path /data/media to container /media (read/write) +``` + +#### Docker Run Examples + +For a simple one-time process operation, see the [Getting Started](#getting-started) example. + +**Setup Permissions** (if running as non-root user): ```shell -# The host "/data/media" directory is mapped to the container "/media" directory -# Replace the volume mappings to suit your needs +# Create nonroot user and set media directory permissions +sudo adduser --no-create-home --shell /bin/false --disabled-password --system --group users nonroot +sudo chown -R nonroot:users /data/media +sudo chmod -R ug=rwx,o=rx /data/media +``` -# If running docker as a non-root user make sure the media file permissions allow writing for the executing user -# adduser --no-create-home --shell /bin/false --disabled-password --system --group users nonroot -# sudo chown -R nonroot:users /data/media -# sudo chmod -R ug=rwx,o=rx /data/media -# docker run --user nonroot:users +**Interactive Shell Access:** -# Run the bash shell in an interactive session +```shell +# Replace /data/media with your actual media directory +# Replace the 1001:100 with your nonroot:users docker run \ - -it \ - --rm \ - --pull always \ + -it --rm --pull always \ --name PlexCleaner \ + --user 1001:100 \ --volume /data/media:/media:rw \ docker.io/ptr727/plexcleaner \ /bin/bash -# Create default settings file -# Edit the settings file to suit your needs -/PlexCleaner/PlexCleaner \ - defaultsettings \ - --settingsfile /media/PlexCleaner/PlexCleaner.json - -# Process media files -/PlexCleaner/PlexCleaner \ - --logfile /media/PlexCleaner/PlexCleaner.log \ - process \ - --settingsfile /media/PlexCleaner/PlexCleaner.json \ - --mediafiles /media/Movies \ - --mediafiles /media/Series - -# Exit the interactive session +# Inside the container, run PlexCleaner commands (see Quick Start) exit ``` -Example, run `monitor` command in a screen session: +**Monitor Command in Screen Session:** ```shell -# Start a new screen session +# Start or attach to screen session screen -# Or attach to the existing screen session -# screen -rd +# Or: screen -rd -# Run the monitor command in an interactive session -docker run \ - -it \ - --rm \ +# Run monitor (adjust paths as needed) +docker run -it --rm --pull always \ --log-driver json-file --log-opt max-size=10m \ - --pull always \ - --name PlexCleaner \ - --env TZ=America/Los_Angeles \ + --name PlexCleaner --env TZ=America/Los_Angeles \ --volume /data/media:/media:rw \ docker.io/ptr727/plexcleaner \ - /PlexCleaner/PlexCleaner \ - --logfile /media/PlexCleaner/PlexCleaner.log \ - --logwarning \ - monitor \ - --settingsfile /media/PlexCleaner/PlexCleaner.json \ - --parallel \ - --mediafiles /media/Movies \ - --mediafiles /media/Series + /PlexCleaner/PlexCleaner --logfile /media/PlexCleaner/PlexCleaner.log --logwarning \ + monitor --settingsfile /media/PlexCleaner/PlexCleaner.json --parallel \ + --mediafiles /media/Movies --mediafiles /media/Series ``` -Example, run `process` command: +**Process Command:** -```shell -# Run the process command -docker run \ - --rm \ - --pull always \ - --name PlexCleaner \ - --env TZ=America/Los_Angeles \ - --volume /data/media:/media:rw \ - docker.io/ptr727/plexcleaner \ - /PlexCleaner/PlexCleaner \ - --logfile /media/PlexCleaner/PlexCleaner.log \ - --logwarning \ - process \ - --settingsfile /media/PlexCleaner/PlexCleaner.json \ - --mediafiles /media/Movies \ - --mediafiles /media/Series -``` +For one-time processing, see the [Getting Started](#getting-started) example or use similar syntax as above, replacing `monitor` with `process`. -Example, run `monitor` command as a docker compose stack: +### Windows -```yaml -services: +**Prerequisites:** - plexcleaner: - image: docker.io/ptr727/plexcleaner:latest - container_name: PlexCleaner - restart: unless-stopped - user: nonroot:users - command: - - /PlexCleaner/PlexCleaner - - monitor - - --settingsfile=/media/PlexCleaner/PlexCleaner.json - - --logfile=/media/PlexCleaner/PlexCleaner.log - - --preprocess - - --mediafiles=/media/Series - - --mediafiles=/media/Movies - environment: - - TZ=America/Los_Angeles - volumes: - - /data/media:/media -``` +- For pre-compiled binaries: Install [.NET Runtime](https://docs.microsoft.com/en-us/dotnet/core/install/windows) (smaller, runtime only). +- For compiling from source: Install [.NET SDK](https://dotnet.microsoft.com/download) (includes build tools). -### Windows +**Installation Steps:** -- Install the [.NET Runtime](https://docs.microsoft.com/en-us/dotnet/core/install/windows). -- Download [PlexCleaner](https://github.com/ptr727/PlexCleaner/releases/latest) and extract the pre-compiled binaries. -- Or compile from [code](https://github.com/ptr727/PlexCleaner.git) using [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [VSCode](https://code.visualstudio.com/download) or the [.NET SDK](https://dotnet.microsoft.com/download). -- Create a default JSON settings file using the `defaultsettings` command: - - `PlexCleaner defaultsettings --settingsfile PlexCleaner.json` - - Modify the settings to suit your needs. -- Install the required 3rd party tools: - - Using the `checkfornewtools` to install tools locally: - - `PlexCleaner checkfornewtools --settingsfile PlexCleaner.json` - - The default `Tools` folder will be created in the same folder as the `PlexCleaner` binary file. - - The tool version information will be stored in `Tools\Tools.json`. - - Keep the 3rd party tools updated by periodically running the `checkfornewtools` command, or update tools on every run by setting `ToolsOptions:AutoUpdate` to `true`. - - Using `winget` to install tools system wide: - - Note, run from an elevated shell e.g. using [`gsudo`](https://github.com/gerardog/gsudo), else [symlinks will not be created](https://github.com/microsoft/winget-cli/issues/3437). - - `winget install --id=Gyan.FFmpeg --exact`. - - `winget install --id=MediaArea.MediaInfo --exact`. - - `winget install --id=HandBrake.HandBrake.CLI --exact`. - - `winget install --id=MoritzBunkus.MKVToolNix --exact --installer-type portable`. - - Set `ToolsOptions:UseSystem` to `true` and `ToolsOptions:AutoUpdate` to `false`. - - Manually downloaded and extracted locally: - - [FfMpeg Full](https://github.com/GyanD/codexffmpeg/releases), e.g. `ffmpeg-6.0-full.7z`: `\Tools\FfMpeg` - - [HandBrake CLI x64](https://github.com/HandBrake/HandBrake/releases), e.g. `HandBrakeCLI-1.6.1-win-x86_64.zip`: `\Tools\HandBrake` - - [MediaInfo CLI x64](https://mediaarea.net/en/MediaInfo/Download/Windows), e.g. `MediaInfo_CLI_23.07_Windows_x64.zip`: `\Tools\MediaInfo` - - [MkvToolNix Portable x64](https://mkvtoolnix.download/downloads.html#windows), e.g. `mkvtoolnix-64-bit-79.0.7z`: `\Tools\MkvToolNix` - - [7-Zip Extra](https://www.7-zip.org/download.html), e.g. `7z2301-extra.7z`: `\Tools\SevenZip` - - Set `ToolsOptions:UseSystem` to `false` and `ToolsOptions:AutoUpdate` to `false`. +1. Download [PlexCleaner](https://github.com/ptr727/PlexCleaner/releases/latest) and extract the pre-compiled binaries. + - Or compile from [code](https://github.com/ptr727/PlexCleaner.git) using [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [VSCode](https://code.visualstudio.com/download) with the .NET SDK. + +2. Create a default JSON settings file using the `defaultsettings` command: + - `PlexCleaner defaultsettings --settingsfile PlexCleaner.json` + - Modify the settings to suit your needs. + +3. Install the required 3rd party tools (choose one method): + + **Option A: Automatic download (Recommended)** + - `PlexCleaner checkfornewtools --settingsfile PlexCleaner.json` + - The default `Tools` folder will be created in the same folder as the `PlexCleaner` binary file. + - The tool version information will be stored in `Tools\Tools.json`. + - Keep the 3rd party tools updated by periodically running the `checkfornewtools` command, or update tools on every run by setting `ToolsOptions:AutoUpdate` to `true`. + + **Option B: System-wide installation via winget** + - Run from an elevated shell e.g. using [`gsudo`](https://github.com/gerardog/gsudo), else [symlinks will not be created](https://github.com/microsoft/winget-cli/issues/3437). + - `winget install --id=Gyan.FFmpeg --exact` + - `winget install --id=MediaArea.MediaInfo --exact` + - `winget install --id=HandBrake.HandBrake.CLI --exact` + - `winget install --id=MoritzBunkus.MKVToolNix --exact --installer-type portable` + - Set `ToolsOptions:UseSystem` to `true` and `ToolsOptions:AutoUpdate` to `false`. + + **Option C: Manual download** + - [FfMpeg Full](https://github.com/GyanD/codexffmpeg/releases), e.g. `ffmpeg-6.0-full.7z`: `\Tools\FfMpeg` + - [HandBrake CLI x64](https://github.com/HandBrake/HandBrake/releases), e.g. `HandBrakeCLI-1.6.1-win-x86_64.zip`: `\Tools\HandBrake` + - [MediaInfo CLI x64](https://mediaarea.net/en/MediaInfo/Download/Windows), e.g. `MediaInfo_CLI_23.07_Windows_x64.zip`: `\Tools\MediaInfo` + - [MkvToolNix Portable x64](https://mkvtoolnix.download/downloads.html#windows), e.g. `mkvtoolnix-64-bit-79.0.7z`: `\Tools\MkvToolNix` + - [7-Zip Extra](https://www.7-zip.org/download.html), e.g. `7z2301-extra.7z`: `\Tools\SevenZip` + - Set `ToolsOptions:UseSystem` to `false` and `ToolsOptions:AutoUpdate` to `false`. ### Linux @@ -249,93 +351,189 @@ services: - macOS x64 and Arm64 binaries are built as part of [Releases](https://github.com/ptr727/PlexCleaner/releases/latest), but are not tested during CI. +### AOT + +Ahead-of-time compiled self-contained binaries do not require any .NET runtime components to be installed.\ +AOT builds are [platform specific](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot), require a platform native compiler, and are created using [`dotnet publish`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-publish). + +> **ℹ️ Note**: AOT binaries are not published in CI/CD due to being platform specific, and cross compilation of AOT binaries are not supported. + +```shell +# Install .NET SDK and native code compiler +apt install dotnet-sdk-10.0 clang zlib1g-dev + +# Clone repository (main or develop branch) +git clone -b develop https://github.com/ptr727/PlexCleaner.git ./PlexCleanerAOT +cd ./PlexCleanerAOT + +# Publish standalone release build executable +dotnet publish ./PlexCleaner/PlexCleaner.csproj \ + --output ./PublishAOT \ + --configuration release \ + -property:PublishAot=true +``` + ## Configuration +### Default Settings + Create a default JSON configuration file by running: -`PlexCleaner defaultsettings --settingsfile PlexCleaner.json` -Refer to the commented default JSON [settings file](./PlexCleaner.defaults.json) for usage. +```shell +PlexCleaner defaultsettings --settingsfile PlexCleaner.json +``` -### Custom FFmpeg and HandBrake CLI Parameters +> **⚠️ Important**: The default settings file must be edited to match your requirements before processing media files. +> **Required Changes**: +> +> - Verify language settings: +> - `ProcessOptions.DefaultLanguage` +> - `ProcessOptions:KeepLanguages` +> - `ProcessOptions.SetUnknownLanguage` +> - `ProcessOptions.RemoveUnwantedLanguageTracks` +> - Verify codec settings: +> - `ProcessOptions.ReEncodeVideo` +> - `ProcessOptions.ReEncodeAudioFormats` +> - Verify encoding settings: +> - `ConvertOptions.FfMpegOptions` +> - `ConvertOptions.HandBrakeOptions` +> - Verify processing settings: +> - `ProcessOptions.Verify` +> - `ProcessOptions.ReMux` +> - `ProcessOptions.ReEncode` +> - `ProcessOptions.DeInterlace` +> - `ProcessOptions.DeleteUnwantedExtensions` +> - `ProcessOptions.RemoveTags` +> - `ProcessOptions.RemoveDuplicateTracks` +> - `ProcessOptions.RemoveClosedCaptions` +> - Verify verification settings: +> - `VerifyOptions.AutoRepair` +> - `VerifyOptions.DeleteInvalidFiles` +> - `VerifyOptions.RegisterInvalidFiles` + +Refer to the commented default JSON [settings file](./PlexCleaner.defaults.json) for detailed configuration options and explanations. + +### Common Configuration Examples + +Quick configuration examples for common use cases. Edit your `PlexCleaner.json` file: + +**Keep Only English and Spanish Audio and Subtitles:** -The `ConvertOptions:FfMpegOptions` and `ConvertOptions:HandBrakeOptions` settings allows for custom CLI parameters to be used during processing. +```json +"ProcessOptions": { + "KeepLanguages": ["en", "es"], + "RemoveUnwantedLanguageTracks": true +} +``` -Note that hardware assisted encoding options are operating system, hardware, and tool version specific.\ -Refer to the Jellyfin hardware acceleration [docs](https://jellyfin.org/docs/general/administration/hardware-acceleration/) for hints on usage. -The example configurations are from documentation and minimal testing with Intel QuickSync on Windows only, please discuss and post working configurations in [Discussions][discussions-link]. +**Re-encode MPEG-2 and VC1 Video to H.264:** -#### FFmpeg Options +```json +"ProcessOptions": { + "ReEncode": true, + "ReEncodeVideo": [ + { + "Format": "mpeg2video" + }, + { + "Format": "vc1" + } + ] +}, +"ConvertOptions": { + "FfMpegOptions": { + "Video": "libx264 -crf 22 -preset medium" + } +} +``` -See the [FFmpeg documentation](https://ffmpeg.org/ffmpeg.html) for complete commandline option details.\ -The typical FFmpeg commandline is `ffmpeg [global_options] {[input_file_options] -i input_url} ... {[output_file_options] output_url}`. -E.g. `ffmpeg "-analyzeduration 2147483647 -probesize 2147483647 -i "/media/foo.mkv" -max_muxing_queue_size 1024 -abort_on empty_output -hide_banner -nostats -map 0 -c:v libx265 -crf 26 -preset medium -c:a ac3 -c:s copy -f matroska "/media/bar.mkv"` +**Re-encode Vorbis and WMAPro Audio to AC3:** -Settings allows for custom configuration of: +```json +"ProcessOptions": { + "ReEncode": true +}, +"ConvertOptions": { + "ReEncodeAudioFormats": ["vorbis", "wmapro"], + "FfMpegOptions": { + "Audio": "ac3" + } +} +``` -- `FfMpegOptions:Global`: Custom hardware global options, e.g. `-hwaccel cuda -hwaccel_output_format cuda` -- `FfMpegOptions:Video`: Video encoder options following the `-c:v` parameter, e.g. `libx264 -crf 22 -preset medium` -- `FfMpegOptions:Audio`: Audio encoder options following the `-c:a` parameter, e.g. `ac3` +**Remove Duplicate Audio Tracks and Keep the Best Quality:** -Get encoder options: +```json +"ProcessOptions": { + "RemoveDuplicateTracks": true, + "PreferredAudioFormats": [ + "ac-3", + "dts-hd high resolution audio", + "dts-hd master audio", + "dts", + "e-ac-3", + "truehd atmos", + "truehd" + ] +} +``` -- List hardware acceleration methods: `ffmpeg -hwaccels` -- List supported encoders: `ffmpeg -encoders` -- List options supported by an encoder: `ffmpeg -h encoder=libsvtav1` +**Verify Media Integrity and Auto-Repair:** -Example video encoder options: +```json +"ProcessOptions": { + "Verify": true, + "AutoRepair": true, + "DeleteInvalidFiles": false, + "RegisterInvalidFiles": true +} +``` -- [H.264](https://trac.ffmpeg.org/wiki/Encode/H.264): `libx264 -crf 22 -preset medium` -- [H.265](https://trac.ffmpeg.org/wiki/Encode/H.265): `libx265 -crf 26 -preset medium` -- [AV1](https://trac.ffmpeg.org/wiki/Encode/AV1): `libsvtav1 -crf 30 -preset 5` +### IETF Language Matching -Example hardware assisted video encoding options: +> **ℹ️ TL;DR**: Language tag matching supports [IETF / RFC 5646 / BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) tag formats as implemented by [MkvMerge](https://codeberg.org/mbunkus/mkvtoolnix/wiki/Languages-in-Matroska-and-MKVToolNix). -- NVidia NVENC: - - See [FFmpeg NVENC](https://trac.ffmpeg.org/wiki/HWAccelIntro#CUDANVENCNVDEC) documentation. - - View NVENC encoder options: `ffmpeg -h encoder=h264_nvenc` - - `FfMpegOptions:Global`: `-hwaccel cuda -hwaccel_output_format cuda` - - `FfMpegOptions:Video`: `h264_nvenc -preset medium` -- Intel QuickSync: - - See [FFmpeg QuickSync](https://trac.ffmpeg.org/wiki/Hardware/QuickSync) documentation. - - View QuickSync encoder options: `ffmpeg -h encoder=h264_qsv` - - `FfMpegOptions:Global`: `-hwaccel qsv -hwaccel_output_format qsv` - - `FfMpegOptions:Video`: `h264_qsv -preset medium` +**Common Use Cases:** -#### HandBrake Options +- **Keep only English**: Set `ProcessOptions:KeepLanguages` to `["en"]`. +- **Keep English and Spanish**: Set `ProcessOptions:KeepLanguages` to `["en", "es"]`. +- **Keep all Portuguese variants**: Use `"pt"` to match `pt`, `pt-BR` (Brazilian), `pt-PT` (European), etc. +- **Keep only Brazilian Portuguese**: Use `"pt-BR"` to match specifically Brazilian Portuguese. +- **Set IETF Language Tags if not present**: Set `ProcessOptions.SetIetfLanguageTags` to `true`. -See the [HandBrake documentation](https://handbrake.fr/docs/en/latest/cli/command-line-reference.html) for complete commandline option details. -The typical HandBrake commandline is `HandBrakeCLI [options] -i -o `. -E.g. `HandBrakeCLI --input "/media/foo.mkv" --output "/media/bar.mkv" --format av_mkv --encoder x265 --quality 26 --encoder-preset medium --comb-detect --decomb --all-audio --aencoder copy --audio-fallback ac3` +Refer to [Docs/LanguageMatching.md](./Docs/LanguageMatching.md) for technical details on language tag matching, including examples, normalization, and configuration options. -Settings allows for custom configuration of: +### EIA-608 and CTA-708 Closed Captions -- `HandBrakeOptions:Video`: Video encoder options following the `--encode` parameter, e.g. `x264 --quality 22 --encoder-preset medium` -- `HandBrakeOptions:Audio`: Audio encoder options following the `--aencode` parameter, e.g. `copy --audio-fallback ac3` +> **ℹ️ TL;DR**: Closed captions (CC) are subtitles embedded in the video stream (not separate tracks). They can cause issues with some players that always display them or cannot disable them. PlexCleaner can detect and remove them using the `RemoveClosedCaptions` option. -Get encoder options: +Refer to [Docs/ClosedCaptions.md](./Docs/ClosedCaptions.md) for technical details on detection and removal methods. -- List all supported encoders: `HandBrakeCLI --help` -- List presets supported by an encoder: `HandBrakeCLI --encoder-preset-list svt_av1` +### Custom FFmpeg and Handbrake Encoding Settings -Example video encoder options: +> **ℹ️ Note**: The default encoding settings work well for most users and provide good compatibility with Plex/Emby/Jellyfin. Only customize these settings if you have specific requirements (e.g., hardware encoding, different quality targets, or specific codec preferences). -- H.264: `x264 --quality 22 --encoder-preset medium` -- H.265: `x265 --quality 26 --encoder-preset medium` -- AV1: `svt_av1 --quality 30 --encoder-preset 5` +Refer to [Docs/CustomOptions.md](./Docs/CustomOptions.md) for hardware acceleration setup, encoder options, and real-world examples. -Example hardware assisted video encoding options: +## Usage -- NVidia NVENC: - - See [HandBrake NVENC](https://handbrake.fr/docs/en/latest/technical/video-nvenc.html) documentation. - - `HandBrakeOptions:Video`: `nvenc_h264 --encoder-preset medium` -- Intel QuickSync: - - See [HandBrake QuickSync](https://handbrake.fr/docs/en/latest/technical/video-qsv.html) documentation. - - `HandBrakeOptions:Video`: `qsv_h264 --encoder-preset balanced` +### Common Commands Quick Reference -Note that HandBrake is primarily used for video deinterlacing, and only as backup encoder when FFmpeg fails.\ -The default `HandBrakeOptions:Audio` configuration is set to `copy --audio-fallback ac3` that will copy all supported audio tracks as is, and only encode to `ac3` if the audio codec is not natively supported. +| Command | Purpose | When to Use | +| ------- | ------- | ----------- | +| `defaultsettings` | Create default configuration file | First time setup | +| `process` | Batch process media files | One-time processing of media library | +| `monitor` | Watch folders and auto-process changes | Continuous monitoring of active media folders | +| `verify` | Verify media integrity without processing | Test media files or check for corruption | +| `remux` | Re-multiplex to MKV without re-encoding | Fix container issues, faster than full processing | +| `reencode` | Re-encode video/audio tracks | Fix codec compatibility issues | +| `deinterlace` | Remove interlacing artifacts | Fix interlaced video playback issues | +| `removeclosedcaptions` | Remove embedded closed captions | Remove unwanted CC from video streams | +| `checkfornewtools` | Download/update media tools (Windows only) | Keep tools up-to-date | -## Usage +See detailed command documentation below for all options and usage examples. + +--- Use the `PlexCleaner --help` commandline option to get a list of commands and options.\ To get help for a specific command run `PlexCleaner --help`. @@ -416,43 +614,71 @@ Options: --debug Wait for debugger to attach ``` -The `process` command will process the media content using options as defined in the settings file and the optional commandline arguments: +The `process` command will process the media content using options as defined in the settings file and the optional commandline arguments. + +Refer to [PlexCleaner.defaults.json](PlexCleaner.defaults.json) for complete configuration details, or see [Common Configuration Examples](#common-configuration-examples) for quick setup examples. + +**Processing Workflow (in order):** + +**1. File Management:** + +- Delete unwanted files based on patterns. + - `FileIgnoreMasks`, `ReMuxExtensions`, `DeleteUnwantedExtensions` + +**2. Container Operations:** -- Refer to [PlexCleaner.defaults.json](PlexCleaner.defaults.json) for configuration details. -- Delete unwanted files. - - `FileIgnoreMasks`, `ReMuxExtensions`, `DeleteUnwantedExtensions`. - Re-multiplex non-MKV containers to MKV format. - - `ReMuxExtensions`, `ReMux`. -- Remove all tags, titles, thumbnails, cover art, and attachments from the media file. - - `RemoveTags`. + - `ReMuxExtensions`, `ReMux` +- Remove all tags, titles, thumbnails, cover art, and attachments. + - `RemoveTags` + +**3. Track Language and Metadata:** + - Set IETF language tags and Matroska special track flags if missing. - - `SetIetfLanguageTags`. + - `SetIetfLanguageTags` - Set Matroska special track flags based on track titles. - - `SetTrackFlags`. + - `SetTrackFlags` - Set the default language for any track with an undefined language. - - `SetUnknownLanguage`, `DefaultLanguage`. + - `SetUnknownLanguage`, `DefaultLanguage` + +**4. Track Selection:** + - Remove tracks with unwanted languages. - `KeepLanguages`, `KeepOriginalLanguage`, `RemoveUnwantedLanguageTracks` -- Remove duplicate tracks, where duplicates are tracks of the same type and language. - - `RemoveDuplicateTracks`, `PreferredAudioFormats`. -- Re-multiplex the media file if required to fix inconsistencies. - - `ReMux`. +- Remove duplicate tracks (same type and language, keep best quality). + - `RemoveDuplicateTracks`, `PreferredAudioFormats` + +**5. Video Processing:** + - De-interlace the video stream if interlaced. - - `DeInterlace`. -- Remove EIA-608 and CTA-708 closed captions from video stream if present. - - `RemoveClosedCaptions`. -- Re-encode video and audio based on specified codecs and formats. - - `ReEncodeVideo`, `ReEncodeAudioFormats`, `ConvertOptions`, `ReEncode`. + - `DeInterlace` +- Remove EIA-608 and CTA-708 closed captions from video stream. + - `RemoveClosedCaptions` +- Re-encode video based on specified codecs and formats. + - `ReEncodeVideo`, `ReEncodeVideoFormats`, `ConvertOptions` + +**6. Audio Processing:** + +- Re-encode audio based on specified formats. + - `ReEncode`, `ReEncodeAudioFormats`, `ConvertOptions` + +**7. Integrity and Verification:** + +- Re-multiplex the media file if required to fix inconsistencies. + - `ReMux` - Verify the media container and stream integrity. - - `MaximumBitrate`, `Verify`. -- If verification fails attempt repair. - - `AutoRepair`. -- If verification after repair fails delete or mark file to be ignored. - - `DeleteInvalidFiles`, `RegisterInvalidFiles`. + - `MaximumBitrate`, `Verify` +- If verification fails, attempt automatic repair. + - `AutoRepair` +- If repair fails, delete or mark file to be ignored. + - `DeleteInvalidFiles`, `RegisterInvalidFiles` + +**8. Finalization:** + - Restore modified timestamp of modified files to original timestamp. - - See `RestoreFileTimestamp`. -- Delete empty folders. - - `DeleteEmptyFolders`. + - `RestoreFileTimestamp` +- Delete empty folders after processing. + - `DeleteEmptyFolders` Options: @@ -473,7 +699,7 @@ Options: - `--threadcount`: - Concurrent file processing thread count when the `--parallel` option is enabled. - The default thread count is the largest of 1/2 number of logical processors or 4. - - Note that media tools internally use multiple threads. + - Media tools internally use multiple threads. - `--quickscan`: - Limits the time duration (3min) when scanning media files, applies to: - Stream verification. @@ -523,22 +749,35 @@ Options: The `monitor` command will watch the specified folders for file changes, and periodically run the `process` command on the changed folders: - All the referenced directories will be watched for changes, and any changes will be added to a queue to be periodically processed. -- Note that the [FileSystemWatcher](https://docs.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher) used to monitor for changes may not always work as expected when changes are made via virtual or network filesystem, e.g. NFS or SMB backed volumes may not detect changes made directly to the underlying ZFS filesystem, while running directly on ZFS will work fine. +- The [FileSystemWatcher](https://docs.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher) used to monitor for changes may not always work as expected when changes are made via virtual or network filesystem, e.g. NFS or SMB backed volumes may not detect changes made directly to the underlying ZFS filesystem, while running directly on ZFS will work fine. Options: - Most of the `process` command options apply. - `--preprocess`: - On startup process all existing media files while watching for new changes. -- Advanced options are configured in [`MonitorOptions`](PlexCleaner.defaults.json). ### Other Commands +Additional commands for specific tasks, organized by category: + +**Configuration:** + - `defaultsettings`: - Create JSON configuration file using default settings. +- `createschema`: + - Write JSON settings schema to file for validation. + +**Tool Management:** + - `checkfornewtools`: - Check for new tool versions and download if newer. - Only supported on Windows. +- `gettoolinfo`: + - Print media tool information and versions. + +**File Operations:** + - `remux`: - Conditionally re-multiplex media files. - Re-multiplex non-MKV containers in the `ReMuxExtensions` list to MKV container format. @@ -557,6 +796,9 @@ Options: - Remove closed captions from video stream. - Useful when media players cannot disable EIA-608 and CTA-708 embedded in the video stream, or content is undesirable. - Same logic as used in the `process` command. + +**Information and Debugging:** + - `verify`: - Verify media container and stream integrity. - Same logic as used in the `process` command. @@ -573,162 +815,38 @@ Options: - Print media file attribute mappings. - Useful to show how different media tools interprets the same attributes. - `getmediainfo`: - - Print media file information. -- `gettoolinfo`: - - Print media tool information. -- `createschema`: - - Write JSON settings schema to file. - -## IETF Language Matching - -Language tag matching supports [IETF / RFC 5646 / BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) tag formats as implemented by [MkvMerge](https://gitlab.com/mbunkus/mkvtoolnix/-/wikis/Languages-in-Matroska-and-MKVToolNix).\ -During processing the absence of IETF language tags will treated as a track warning, and an RFC 5646 IETF language will be temporarily assigned based on the ISO639-2B tag.\ -If `ProcessOptions.SetIetfLanguageTags` is enabled MkvMerge will be used to remux the file using the `--normalize-language-ietf extlang` option, see the [MkvMerge docs](https://mkvtoolnix.download/doc/mkvpropedit.html) for more details. - -Tags are in the form of `language-extlang-script-region-variant-extension-privateuse`, and matching happens left to right.\ -E.g. `pt` will match `pt` Portuguese, or `pt-BR` Brazilian Portuguese, or `pt-PT` European Portuguese.\ -E.g. `pt-BR` will only match only `pt-BR` Brazilian Portuguese.\ -E.g. `zh` will match `zh` Chinese, or `zh-Hans` simplified Chinese, or `zh-Hant` for traditional Chinese, and other variants.\ -E.g. `zh-Hans` will only match `zh-Hans` simplified Chinese. - -Normalized tags will be expanded for matching.\ -E.g. `cmn-Hant` will be expanded to `zh-cmn-Hant` allowing matching with `zh`. - -See the [W3C Language tags in HTML and XML](https://www.w3.org/International/articles/language-tags/) and [BCP47 language subtag lookup](https://r12a.github.io/app-subtags/) for more details. - -## EIA-608 and CTA-708 Closed Captions + - Print media file information and track details. -[EIA-608](https://en.wikipedia.org/wiki/EIA-608) and [CTA-708](https://en.wikipedia.org/wiki/CTA-708) subtitles, commonly referred to as Closed Captions (CC), are typically used for broadcast television.\ -Media containers typically contain separate discrete subtitle tracks, but closed captions can be encoded into the primary video stream. - -Removal of closed captions may be desirable for various reasons, including undesirable content, or players that always burn in closed captions during playback.\ -Unlike normal subtitle tracks, detection and removal of closed captions are non-trivial.\ -Note I have no expertise in video engineering, and the following information was gathered by research and experimentation. - -FFprobe [never supported](https://github.com/ptr727/PlexCleaner/issues/94) closed caption reporting when using `-print_format json`, and recently [removed reporting](https://github.com/ptr727/PlexCleaner/issues/497) of closed caption presence completely, prompting research into alternatives.\ -E.g. - -```text -Stream #0:0(eng): Video: h264 (High), yuv420p(tv, bt709, progressive), 1920x1080, Closed Captions, SAR 1:1 DAR 16:9, 29.97 fps, 29.97 tbr, 1k tbn (default) -``` - -MediaInfo supports closed caption detection, but only for [some container types](https://github.com/MediaArea/MediaInfoLib/issues/2264) (e.g. TS and DV), and [only scans](https://github.com/MediaArea/MediaInfoLib/issues/1881) the first 30s of the video looking for video frames containing closed captions.\ -E.g. `mediainfo --Output=JSON filename`\ -MediaInfo does [not support](https://github.com/MediaArea/MediaInfoLib/issues/1881#issuecomment-2816754336) general input piping (e.g. MKV -> FFmpeg -> TS -> MediaInfo), and requires a temporary TS file to be created on disk and used as standard input.\ -In my testing I found that remuxing 30s of video from MKV to TS did produce reliable results.\ -E.g. - -```json -{ - "@type": "Text", - "ID": "256-1", - "Format": "EIA-708", - "MuxingMode": "A/53 / DTVCC Transport", -}, -``` - -[CCExtractor](https://ccextractor.org/) supports closed caption detection using `-out=report`.\ -E.g. `ccextractor -12 -out=report filename`\ -In my testing I found using MKV containers directly as input produced unreliable results, either no output generated or false negatives.\ -CCExtractor does support input piping, but I found it to be unreliable with broken pipes, and requires a temporary TS file to be created on disk and used as standard input.\ -Even in TS format on disk, it is very sensitive to stream anomalies, e.g. `Error: Broken AVC stream - forbidden_zero_bit not zero ...`, making it unreliable.\ -E.g. - -```text -EIA-608: Yes -CEA-708: Yes -``` - -FFmpeg [`readeia608` filter](https://ffmpeg.org/ffmpeg-filters.html#readeia608) can be used in FFprobe to report EIA-608 frame information.\ -E.g. `ffprobe -loglevel error -f lavfi -i "movie=filename,readeia608" -show_entries frame=best_effort_timestamp_time,duration_time:frame_tags=lavfi.readeia608.0.line,lavfi.readeia608.0.cc,lavfi.readeia608.1.line,lavfi.readeia608.1.cc -print_format json`\ -Note the `movie=filename[out0+subcc]` convention requires [special escaping](https://superuser.com/questions/1893137/how-to-quote-a-file-name-containing-single-quotes-in-ffmpeg-ffprobe-movie-filena) of the filename to not interfere with commandline or filter graph parsing.\ -In my testing I found only one [IMX sample](https://archive.org/details/vitc_eia608_sample) that produced the expected results, making it unreliable.\ -E.g. - -```json -{ - "best_effort_timestamp_time": "0.000000", - "duration_time": "0.033367", - "tags": { - "lavfi.readeia608.1.cc": "0x8504", - "lavfi.readeia608.0.cc": "0x8080", - "lavfi.readeia608.0.line": "28", - "lavfi.readeia608.1.line": "29" - }, -} -``` - -FFmpeg [`subcc` filter](https://www.ffmpeg.org/ffmpeg-devices.html#Options-10) can be used to create subtitle streams from the closed captions in video streams.\ -E.g. `ffprobe -loglevel error -select_streams s:0 -f lavfi -i "movie=filename[out0+subcc]" -show_packets -print_format json`\ -E.g. `ffmpeg -abort_on empty_output -y -f lavfi -i "movie=filename[out0+subcc]" -map 0:s -c:s srt outfilename`\ -Note that `ffmpeg -t` and `ffprobe -read_intervals` options limiting scan time does [not work](https://superuser.com/questions/1893673/how-to-time-limit-the-input-stream-duration-when-using-movie-filenameout0subcc) on the input stream when using the `subcc` filter, and scanning the entire file can take a very long time.\ -In my testing I found the results to be reliable.\ -E.g. - -```json -{ - "codec_type": "subtitle", - "stream_index": 1, - "pts_time": "0.000000", - "dts_time": "0.000000", - "size": "60", - "pos": "5690", - "flags": "K__" -}, -``` - -```text -9 -00:00:35,568 --> 00:00:38,004 -{\an7}No going back now. -``` - -FFprobe [recently added](https://github.com/FFmpeg/FFmpeg/commit/90af8e07b02e690a9fe60aab02a8bccd2cbf3f01) the `analyze_frames` [option](https://ffmpeg.org/ffprobe.html#toc-Main-options) that reports on the presence of closed captions in video streams.\ -As of writing this functionality has not yet been released, but is only in nightly builds.\ -E.g. `ffprobe -loglevel error -show_streams -analyze_frames -read_intervals %180 filename -print_format json` +## Testing -```json -{ - "index": 0, - "codec_name": "h264", - "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", - "coded_width": 1920, - "coded_height": 1088, - "closed_captions": 1, - "film_grain": 0, -} -``` +PlexCleaner includes multiple testing approaches for different scenarios: -The currently implemented method of closed caption detection uses FFprobe and the `subcc` filter to detect closed caption frames, but requires scanning of the entire file as there are no options to limit the scan duration when using the `subcc` filter.\ -If the `quickscan` options is enabled a small file snippet is first created, and the snippet is used for analysis reducing processing times. +- **Unit Tests**: Fast automated tests for code logic (no media files required). +- **Docker Tests**: Validate container functionality with sample media files. +- **Regression Tests**: Compare processing results across versions using real media files. -FFmpeg [`filter_units` filter](https://ffmpeg.org/ffmpeg-bitstream-filters.html#filter_005funits) can be used to [remove closed captions](https://stackoverflow.com/questions/48177694/removing-eia-608-closed-captions-from-h-264-without-reencode) from video streams.\ -E.g. `ffmpeg -loglevel error -i \"{fileInfo.FullName}\" -c copy -map 0 -bsf:v filter_units=remove_types=6 \"{outInfo.FullName}\"`\ -Closed captions SEI unit for H264 is `6`, `39` for H265, and `178` for MPEG2.\ -[Note](https://trac.ffmpeg.org/wiki/HowToExtractAndRemoveClosedCaptions) and [note](https://trac.ffmpeg.org/ticket/5283) that as of writing HDR10+ metadata may be lost when removing closed captions from H265 content. +The majority of development and debugging time is spent figuring out how to deal with media file and media processing tool specifics affecting playback. -## Testing - -The majority of development and debugging time is spent figuring out how to deal with media file and media processing tool specifics affecting playback.\ For repetitive test tasks pre-configured on-demand tests are included in VSCode [`tasks.json`](./.vscode/tasks.json) and [`launch.json`](./.vscode/launch.json), and VisualStudio [`launchSettings.json`](./PlexCleaner/Properties/launchSettings.json).\ Several of the tests reference system local paths containing media files, so you may need to make path changes to match your environment. ### Unit Testing -Unit tests are included for static tests that do not require the use of media files. +Unit tests validate core functionality without requiring media files. Run locally or in CI/CD pipelines. ```console -dotnet build -dotnet format --verify-no-changes --severity=info --verbosity=detailed dotnet test ``` ### Docker Testing -the [`Test.sh`](./Docker/Test.sh) test script is included in the docker build and can be used to test basic functionality from inside the container.\ +The [`Test.sh`](./Docker/Test.sh) script validates basic container functionality. It downloads sample files if no external media path is provided. + +The [`Test.sh`](./Docker/Test.sh) test script is included in the docker build and can be used to test basic functionality from inside the container. + If an external media path is not specified the test will download and use the [Matroska test files](https://github.com/ietf-wg-cellar/matroska-test-files/archive/refs/heads/master.zip). -```console +```shell docker run \ -it --rm \ --name PlexCleaner-Test \ @@ -738,6 +856,8 @@ docker run \ ### Regression Testing +Regression testing ensures consistent behavior across versions by comparing processing results on the same media files. + The behavior of the tool is very dependent on the media files being tested, and the following process can facilitate regressions testing, assuring that the process results between versions remain consistent. - Maintain a collection of troublesome media files that resulted in functional changes. @@ -795,59 +915,47 @@ RunContainer () { --resultsfile=$ConfigPath/Results-$Tag.json } -# Test release containers -RunContainer docker.io/ptr727/plexcleaner ubuntu -RunContainer docker.io/ptr727/plexcleaner debian -RunContainer docker.io/ptr727/plexcleaner alpine - -# Test pre-release containers -RunContainer docker.io/ptr727/plexcleaner ubuntu-develop -RunContainer docker.io/ptr727/plexcleaner debian-develop -RunContainer docker.io/ptr727/plexcleaner alpine-develop +# Test containers +RunContainer docker.io/ptr727/plexcleaner latest +RunContainer docker.io/ptr727/plexcleaner develop ``` ## Development Tooling -### Fresh Install - -```shell -winget install Microsoft.DotNet.SDK.10 -winget install Microsoft.VisualStudioCode -winget install nektos.act -``` +Install development tools using winget (Windows): ```shell -dotnet new tool-manifest -dotnet tool install csharpier -dotnet tool install husky -dotnet tool install dotnet-outdated-tool -dotnet husky install -dotnet husky add pre-commit -c "dotnet husky run" +winget install Microsoft.DotNet.SDK.10 # .NET 10 SDK +winget install Microsoft.VisualStudioCode # Visual Studio Code +winget install Microsoft.VisualStudio.Community # Visual Studio ``` -### Update Dependencies +Update .NET development tools: ```shell -winget upgrade Microsoft.DotNet.SDK.10 -winget upgrade Microsoft.VisualStudioCode -winget upgrade nektos.act -dotnet tool update --all -dotnet outdated --upgrade:prompt +dotnet tool restore # Restore tools from manifest +dotnet tool update --all # Update all .NET tools +dotnet husky install # Reinstall git hooks +dotnet outdated --upgrade:prompt # Interactive dependency updates ``` ## Feature Ideas -- Cleanup chapters, e.g. chapter markers that exceed the media play time. -- Cleanup NFO files, e.g. verify schema, verify image URL's. -- Cleanup text based subtitle files, e.g. convert file encoding to UTF8. -- Process external subtitle files, e.g. merge or extract. +Have a feature request or idea? Please check [GitHub Discussions][discussions-link] and [Issues][issues-link] first to see if it's already been proposed. If not, feel free to start a new discussion! + +Some ideas being considered: + +- Cleanup chapters (e.g. chapter markers that exceed media play time). +- Cleanup NFO files (e.g. verify schema, verify image URLs). +- Cleanup text-based subtitle files (e.g. convert file encoding to UTF8). +- Process external subtitle files (e.g. merge or extract). ## 3rd Party Tools - [7-Zip](https://www.7-zip.org/) - [AwesomeAssertions](https://awesomeassertions.org/) - [Bring Your Own Badge](https://github.com/marketplace/actions/bring-your-own-badge) -- [CliWrap](https://github.com/Tyrrrz/CliWrap) +- [CliWrap][cliwrap-link] - [Docker Hub Description](https://github.com/marketplace/actions/docker-hub-description) - [Docker Run Action](https://github.com/marketplace/actions/docker-run-action) - [dotnet-outdated](https://github.com/dotnet-outdated/dotnet-outdated) @@ -859,11 +967,10 @@ dotnet outdated --upgrade:prompt - [Husky.Net](https://alirezanet.github.io/Husky.Net/) - [ISO 639-2 language tags](https://www.loc.gov/standards/iso639-2/langhome.html) - [ISO 639-3 language tags](https://iso639-3.sil.org/) -- [JsonSchema.Net.Generation][jsonschema-link] +- [JSON2CSharp][json2csharp-link] - [MediaInfo](https://mediaarea.net/en-us/MediaInfo/) - [MKVToolNix](https://mkvtoolnix.download/) - [Nerdbank.GitVersioning](https://github.com/marketplace/actions/nerdbank-gitversioning) -- [quicktype](https://quicktype.io/) - [regex101.com](https://regex101.com/) - [RFC 5646 language tags](https://www.rfc-editor.org/rfc/rfc5646.html) - [Serilog](https://serilog.net/) @@ -885,9 +992,8 @@ Licensed under the [MIT License][license-link]\ ![GitHub License][license-shield] [actions-link]: https://github.com/ptr727/PlexCleaner/actions -[alpine-docker-link]: https://hub.docker.com/_/alpine +[cliwrap-link]: https://github.com/Tyrrrz/CliWrap [commit-link]: https://github.com/ptr727/PlexCleaner/commits/main -[debian-hub-link]: https://hub.docker.com/_/debian [discussions-link]: https://github.com/ptr727/PlexCleaner/discussions [docker-develop-version-shield]: https://img.shields.io/docker/v/ptr727/plexcleaner/develop?label=Docker%20Develop&logo=docker&color=orange [docker-latest-version-shield]: https://img.shields.io/docker/v/ptr727/plexcleaner/latest?label=Docker%20Latest&logo=docker @@ -896,7 +1002,7 @@ Licensed under the [MIT License][license-link]\ [github-link]: https://github.com/ptr727/PlexCleaner [plexcleaner-hub-link]: https://hub.docker.com/r/ptr727/plexcleaner [issues-link]: https://github.com/ptr727/PlexCleaner/issues -[jsonschema-link]: https://json-everything.net/json-schema/ +[json2csharp-link]: https://json2csharp.com [last-build-shield]: https://byob.yarr.is/ptr727/PlexCleaner/lastbuild [last-commit-shield]: https://img.shields.io/github/last-commit/ptr727/PlexCleaner?logo=github&label=Last%20Commit [license-link]: ./LICENSE diff --git a/Samples/PlexCleaner/Sidecar.v5.PlexCleaner b/Samples/PlexCleaner/Sidecar.v5.PlexCleaner new file mode 100644 index 00000000..da428068 --- /dev/null +++ b/Samples/PlexCleaner/Sidecar.v5.PlexCleaner @@ -0,0 +1,14 @@ +{ + "SchemaVersion": 5, + "State": 64, + "MediaHash": "Xy91x5zUTc8affbR5Q2VR0jV1UIlrFhsbvcaxcrmuqw=", + "FfProbeToolVersion": "8.0.1", + "MkvMergeToolVersion": "96.0", + "Verified": false, + "MediaLastWriteTimeUtc": "2025-04-24T04:25:13.0991687Z", + "MediaLength": 2230039, + "FfProbeInfoData": "7Vddb6Q2FH2PlP9g8bRVNzOGMB/hbZRW7UMjVeqqlbpZWRd8ASvGRrYhma7y3yvDzCQzwCRP\u002B7I7mgfgHB\u002B4H5xrvl5eEEJIYJ1BqGyQkM/9Ff/7\u002BnLYkYTi\u002BBQkhH48ATLNMWMKKgwSEpTRMg7GKVKr4sD7fRYtYzInm79vyZzc/fnrb1fx8UkNxpGQDsRqo3MhexFRlBM3c9u6o7SCo57iQMGsM0IVnvmZfjn8pxd4Jn2ilA4f7FFwVwYJWUWDHJUoitIFCYnD5Zg0Z2cW94RzEiVYlrLcQIW\u002BiuEpbqGqJTKwNWaOGXBC\u002BzjCJBwEwYWtJWwH3HiRRMthLcQTyyv/VMG2aeOI1gOKxBZlkJDrYVyl0RUwqTN/E\u002BVFJOZuoJALlJxpw9F4Tm10YdBa0eKAajAfTYCwDNrMr3amGS5TIJlEVbiSWfFf1zfDJjZ9gn1KOkYUU0rnIaXDJEJbvJ/sRIUsBdvxQs8ZtpZ1YByrnQ\u002BOjoNepmvOmW/OEY1UOMtqNMzAI\u002Bs7wvPXAyI\u002BOQMcHOyTcb0e6xJtxa5sJ1bRMzCHRrqRYvRwk46E0kHaiEIokFN4pqsKlZuC5daIzE6hD2BAP\u002BAUnGuTIZ9CSwRvFkxUNQgzzWuFbUC\u002BScskgmKY55iNFrYjgXOQlchZLbIpji89Z65sqlSBkJNaSivGBRboprUyqH1VJzU42syI85wKXdc/0xo1Kn6mhtYJ6fMHxWSlqkY6IWHbeQI9JjwPXjEo7ESfSlBF098nQFUEJ0ovp69Fp2ZjeH42AmTvGY2bzS35sOEtqAw52TRcaHKruVDFT\u002BeG4R\u002B3b4xC8ErfZhTuBs5uNOTS1VOUvUHG6zGdrASlsOvo5QTGJGx1091nMQunXe/geAP/FEo4AZLVwH2Sxyin3k/n9B2mP8b6Vm4/MPEw/mHiP0z8ezfx/vDLTtvP\u002BwrcsWrgDXXvxb8k9/ef0Lr7\u002B78ExwzMrF3Mqof29fsWqJS9fEJFJ0i3Xe0hOrqIFUY39QDvH\u002B0wFCpwRtsH\u002BPiIaRWMEI9GyN2OTebkH0zvjvhvG0jAG3PYkq/C2XKxOGXst8lRdE3p9c0Rloruu2G3713frBZHcG10isxm2nS2RI\u002BiHi9ygMpPnW77L0WKaSVJS2er2Yr8TKRI98nxF9cjQyAz2MVziDqi9OaKLq7C1ScaJfFNcr3c5eHfV82z65bny4vny4v/AQ==", + "MkvMergeInfoData": "1VRNb9s4EL0HyH8geOkuNnFIifq8GdnPQ7EFGuSwTUFQ1EgmTFJainKTFv3vC0l2LDluN3tcHSx43sxw9PjefLm8QAgL74XcGLC\u002Bwzn68PFqjMqNaD24RaixXigLDufoyxBBCLeuacF5Bd0xOE/l/qkFnCOaXD2DpfDAdSOFxjnCASHZNYmuaXxHs5xleRhfkyQnBJ9U9F4u8pM7Ekz5f81Teye8aizOUULjKCLjc8RVx1vX7FSpbM29MtB5Ydpheu96OOaZ/nHIEG2rlTx0xFoVUBiNdmSVrBL0E9KqMMK7ptuKIZiu6GyWDuqBVt6rcihOszArojjNgoSJUNIsqCAMIYA0LUgqw1nl82C8k0KPBJ5\u002ByCen/JkJzXZnwNWAdsGKrgj64c3aNn4DDr3TQgK6a9CvQus3P6KiV9qjxqJ1XyOaoYCQBNEwZyQnCZ7O\u002Bbo/DzuQTW3VZyiXTOGub9vG\u002BRfx/b3jt3t2xoZTOwzONQtlVUoDt8KMFT/nDw930PmHh/eqBCncahetzHY30YNr3RRCcy/qeQdVgvWq2hPBq8YZ4fkOXDfxEkzMYe\u002BE3J4Wj8ExMA0/13EJo\u002BrW97c3v6\u002BCmN28fffLb9espXN9jvc7u5vzrjj045Mc7vnQit388f7Pm/X97bHdc17r1G5Qfim8GCoIjRkhFKoKKCE0iZPpv5AhA0kKFkZir5OkHH5pEou0onFQhikhA8TiFKCQBfnOcRps7Tc4R2E6TyqhEr32fOYwRhOShmF4Lm0k9dRWA666VosnXioDdridgSOcBOSR0Rify\u002Byt8gt\u002BBwlZUWgov3lK1Tg5gyuhuwWuha17UY\u002BCA1vjcxhX4KspYYEbZZXpzXF7nA5ne8OVLeGRg/VuUsGCStubYlyjdB5thdyCV59HBJsWasZbSvhOldAsJmjVI\u002BhXMDgtnjgJgiyLk/CAHFw9s\u002Bl0xtL052ywvj2VPX2F7EVfqobLjbAW9ADGVy/QTphWDwutcvB3D1Y\u002BDQJL5zvvxEBrvhjnm7ahtCBMRBEDVkwuIITRJI6zmL3GBpT9iw0CGo7Pf7HB/07A5KyAg5dyoxFhAc1C9h25jVd\u002BkNvw2m/iT8JZZffL\u002BfLi6\u002BXFPw==", + "MediaInfoToolVersion": "25.10", + "MediaInfoData": "rVZtb6M4EP5eqf/B8pfu6hJiA4GAhLRZ0u5WSnerptvT6XqHDEwSq4CJgVyyq/73k03e296b7lPwzDwz4\u002BGZJ/w4P8OJBFbzYjbmsWRyjf0fuGA5YB/fQMrZdTEVYx7jDl6CrLgosI/NvkEJ7uBGZtjH87ouK7/Xy1U4k8CMAureDoyfO\u002BdnWDtV7g8SptjHI//x8R6q\u002BvFxwlNImDSWfSN/WuIOriVLnrD/6w/8oV6XqpFPUIBkGe7gbwVfNHA9wj6mA9Mm1sDtu7btOKZnm5ZtE9N1CKW25xLTsVzXsbCq/sBTEKFoiloBtWnYpPzEdMUzuFzVUGyuqdtRdiFzpsJuWC1F9cQOrNHDbir2LsmEf1dtm6ZFiOVp86iRrG7jXGo4/b62fl2qe2UfeX3Hag2xB57b\u002Bq4ky2FrtgzPdfbmXd8uHWjrdTWpJbCcxZmK/wUqbb4sEpFCGo02aQjxuqTfpS4ipm97vuWgb/fhru8oVFw4jDedLqFdYiLq\u002BTb1CTVck7yNicYiYdkJku6Re9SNSPmUH5Xqd4ndNW1EbN/s\u002B9QyPO\u002Bk1BHosFYLtZBJ9tCjAQzLMuPJ9g3kT8sc5AzQ0jSoQdC7i2Eh6jlIdJuxBNC9QFcsyy7eo7jhWY1EgYbNDFEPmYS4iFq\u002BTXzivlUh\u002BtLuz7bMm3F77vx/fezWGGc8hjjP0JIYruGin1DG43zDYGUcGBQ/d/ZbppcEd3DLpK8yBYl93M6x3Tj9eLCCjmuanue41vGaDB/Cww25lWLKNS0/89n80DOGJag3eIiPJlArOaqicPhxGB6Q\u002BdR/B1O9DJWan44IRQqJ7uwhurm9/GT3ridfe9t2Xl/Bn3laz5Vpw87PwGdzdQubtgs3YXmZQRqdBm7tLwC3fAXZsCohUVvNhZqcQUiLGvGqzNj61O1a9HjpFdfVyMKru79VAx3/pcm166VnBOrKdNvB6wISikzISckSLR/fHlrjXIqcTZq4UnflxUzd0jf9NtFHXo\u002Bg1DNpc0wSVty3XLqVYiahqviyZf8IMqY4SfaDUJZoIhqpS4aiqBkvQL5B5pXp2KiLEiEBOQRJj/ZvELWJ7TB4DbLdQYV71b/fvX\u002Bec0s9BWIxSwKKekjCVP\u002BmEGcieQqoT3yCeogVLFtXEJCV5ZMVpRbqoRyCOaxQD1VNnEPQRz0Uy1QEKj7nK0gjlU2fIJKsmEFAHdRDiX4RUQ66Ui0hy3il4warQZrU2pwscm1KgaXfRQGBSTuU7tGLMhLTaQW1jqrnElhaBSp9ITfAhOesbovkMZu2rcRTvWWBqZ6jci1ZztPWE7GUlW31OIo5a3tKuYRNT3\u002BUEtK4DeA5aPcTrHlRB2Z/f4hyXgSmGkeVQAFJUwc2eVdKeK8GnASJnKp7yGlgOoaCyQQWwUWcNTIss9Xv72h3EYq8fH\u002BBemiRiLwM1MqpQ6lS080jWwV91cuirGooAxv1EC8jLQsBNWwVVca7s6Vf4yIwfZVMM2LMilnDZopZUGx4PGVNVh/rVAIp9vEXcaSv\u002Bovjhb62e69FqxWxw0\u002BcPrFN6ln2ib4Oj/R1mKZc6RrLroDVjdSCOA5PFHEYDYdh1/wLJQznrCggU2jn0HArKp1fOa6kKGofjVGI7jpIfbqpw10Hja8uDzFjthaN6jVUbjSu0F21i5lo5axuQWot0uJktnfULl7MNkpnD7ZysXVshcuyLc\u002B2DxRti3CMweYDShFCa5Aotmo6FlW1/i\u002BCpP8bo5egf82H357Pz57Pz87P/gQ=" +} \ No newline at end of file diff --git a/Samples/PlexCleaner/Sidecar.v5.mkv b/Samples/PlexCleaner/Sidecar.v5.mkv new file mode 100644 index 00000000..25d491f2 Binary files /dev/null and b/Samples/PlexCleaner/Sidecar.v5.mkv differ diff --git a/Sandbox/Program.cs b/Sandbox/Program.cs index d7b8adbe..7f81e2b5 100644 --- a/Sandbox/Program.cs +++ b/Sandbox/Program.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -12,6 +11,7 @@ using Serilog.Debugging; using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; +using ConfigFileJsonSchema = PlexCleaner.ConfigFileJsonSchema4; namespace Sandbox; @@ -28,19 +28,9 @@ public class Program { private const string JsonConfigFile = "Sandbox.json"; - private static readonly JsonSerializerOptions s_jsonReadOptions = new() - { - AllowTrailingCommas = true, - IncludeFields = true, - NumberHandling = JsonNumberHandling.AllowReadingFromString, - PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, - ReadCommentHandling = JsonCommentHandling.Skip, - PropertyNameCaseInsensitive = true, - }; - private readonly Dictionary _settings; - private Program(Dictionary settings) => _settings = settings; + protected Program(Dictionary settings) => _settings = settings; public static async Task Main(string[] args) { @@ -70,15 +60,15 @@ public static async Task Main(string[] args) if (GetSettingsFilePath(JsonConfigFile) is { } settingsPath) { await using FileStream jsonStream = File.OpenRead(settingsPath); - settings = JsonSerializer.Deserialize>( + settings = JsonSerializer.Deserialize( jsonStream, - s_jsonReadOptions + ConfigJsonContext.Default.DictionaryStringJsonElement ); Log.Information("Settings loaded : {FilePath}", settingsPath); } // Derive from Program and implement Sandbox() - Program program = new(settings); + TestSomething program = new(settings); int ret = await program.Sandbox(args); // Done @@ -90,8 +80,11 @@ public static async Task Main(string[] args) public static void SetRuntimeOptions() { - FileEx.Options.RetryCount = PlexCleaner.Program.Config.MonitorOptions.FileRetryCount; - FileEx.Options.RetryWaitTime = PlexCleaner.Program.Config.MonitorOptions.FileRetryWaitTime; + const int FileRetryWaitTime = 5; + const int FileRetryCount = 2; + + FileEx.Options.RetryCount = FileRetryCount; + FileEx.Options.RetryWaitTime = FileRetryWaitTime; PlexCleaner.Program.Options.ThreadCount = PlexCleaner.Program.Options.Parallel ? PlexCleaner.Program.Options.ThreadCount == 0 @@ -107,13 +100,7 @@ public static string GetSettingsFilePath(string fileName) if (!File.Exists(settingsPath)) { // Try to load settings file from assembly directory - if ( - Assembly.GetEntryAssembly() is { } entryAssembly - && Path.GetDirectoryName(entryAssembly.Location) is { } assemblyDirectory - ) - { - settingsPath = Path.GetFullPath(Path.Combine(assemblyDirectory, fileName)); - } + settingsPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, fileName)); } if (!File.Exists(settingsPath)) { @@ -128,10 +115,29 @@ public static string GetSettingsFilePath(string fileName) public Dictionary GetSettingsDictionary(string key) => new( - GetSettingsObject(key)?.Deserialize>() ?? [], + GetSettingsObject(key)?.Deserialize(ConfigJsonContext.Default.DictionaryStringString) + ?? [], StringComparer.OrdinalIgnoreCase ); - - public T GetSettings(string key) - where T : class => GetSettingsObject(key)?.Deserialize(); } + +[JsonSourceGenerationOptions( + AllowTrailingCommas = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + ReadCommentHandling = JsonCommentHandling.Skip, + WriteIndented = true, + NewLine = "\r\n", + PropertyNameCaseInsensitive = true +)] +[JsonSerializable( + typeof(Dictionary), + TypeInfoPropertyName = "DictionaryStringJsonElement" +)] +[JsonSerializable( + typeof(Dictionary), + TypeInfoPropertyName = "DictionaryStringString" +)] +internal sealed partial class ConfigJsonContext : JsonSerializerContext; diff --git a/Sandbox/Sandbox.csproj b/Sandbox/Sandbox.csproj index 878f7bef..ba45deed 100644 --- a/Sandbox/Sandbox.csproj +++ b/Sandbox/Sandbox.csproj @@ -1,13 +1,13 @@ Exe - net9.0 + net10.0 + false - diff --git a/Sandbox/TestSomething.cs b/Sandbox/TestSomething.cs new file mode 100644 index 00000000..736e70b0 --- /dev/null +++ b/Sandbox/TestSomething.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Sandbox; + +internal sealed class TestSomething(Dictionary settings) : Program(settings) +{ + protected override Task Sandbox(string[] args) => Task.FromResult(0); +} diff --git a/version.json b/version.json index b614d736..286b9f36 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "3.14", + "version": "3.15", "publicReleaseRefSpec": [ "^refs/heads/main$" ],