From d73d9b0221205017f237b4fbffea5786d1617d2c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 06:35:48 -0700 Subject: [PATCH 1/2] Normalize line endings and final newlines to .editorconfig spec These files drifted from the .editorconfig line-ending rules (LF where CRLF is mandated for .yml/.md/.json) or were missing the mandated final newline. Bring them to spec; no content changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .config/dotnet-tools.json | 2 +- .github/workflows/build-docker-task.yml | 220 +++---- .github/workflows/build-executable-task.yml | 212 +++---- .github/workflows/get-version-task.yml | 112 ++-- .vscode/tasks.json | 606 ++++++++++---------- Docs/ClosedCaptions.md | 372 ++++++------ Docs/CustomOptions.md | 212 +++---- Docs/LanguageMatching.md | 54 +- PlexCleaner.schema.json | 2 +- PlexCleaner/Properties/launchSettings.json | 2 +- Samples/PlexCleaner/PlexCleaner.v1.json | 2 +- Samples/PlexCleaner/PlexCleaner.v2.json | 2 +- Samples/PlexCleaner/PlexCleaner.v3.json | 2 +- Samples/PlexCleaner/PlexCleaner.v4.json | 2 +- 14 files changed, 901 insertions(+), 901 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index e7479741..957373e9 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -24,4 +24,4 @@ "rollForward": false } } -} \ No newline at end of file +} diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 1d6a32ca..3fe7e829 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -1,110 +1,110 @@ -name: Build Docker image task - -# Builds the multi-arch image (linux/amd64,arm64) and, on a main publish, pushes it plus the Docker Hub overview. -# Branch drives config and tags: main => Release/`latest`, else Debug/`develop`, plus the `:SemVer2` tag. Smoke builds -# amd64 only and never pushes; registry buildcache is branch-scoped. The orchestrator passes `branch` explicitly -# (the publisher builds one branch per run - the trigger ref - and smoke passes github.ref_name). -on: - workflow_call: - inputs: - # Push the built image and the Docker Hub overview. - push: - required: false - type: boolean - default: false - # Git ref to check out / version (empty = default checkout ref). - ref: - required: false - type: string - default: '' - # Logical branch: main => Release/`latest`, else Debug/`develop`. Required (see header). - branch: - required: true - type: string - # Smoke: build linux/amd64 only, never push, skip the shared cache-to. Fast PR feedback. - smoke: - required: false - type: boolean - default: false - # Version from the orchestrator's single NBGV run (threaded). This task does not - # re-run NBGV on the detached commit, which could classify the branch differently. - semver2: - required: true - type: string - assembly_version: - required: true - type: string - assembly_file_version: - required: true - type: string - assembly_informational_version: - required: true - type: string - -jobs: - - build-docker: - name: Build Docker image job - runs-on: ubuntu-latest - - steps: - - - name: Checkout step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ inputs.ref }} - - # QEMU only emulates arm64; smoke is amd64-only, so skip it to save setup cost. - - name: Setup QEMU step - if: ${{ !inputs.smoke }} - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - with: - platforms: linux/amd64,linux/arm64 - - - name: Setup Buildx step - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - with: - platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - - # Always login (even on smoke) for higher pull/cache-read rate limits; the credentials are in both the - # Actions and Dependabot secret stores so a Dependabot push CI run can log in too. Forks cannot push here. - - name: Login to Docker Hub step - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - - name: Docker build and push step - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - push: ${{ inputs.push }} - file: ./Docker/Dockerfile - tags: | - docker.io/ptr727/plexcleaner:${{ inputs.branch == 'main' && 'latest' || 'develop' }} - docker.io/ptr727/plexcleaner:${{ inputs.semver2 }} - platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - # Read both branches' caches (near-identical layers), write only this branch's tag and only when pushing. - # mode=max caches the builder publish; ignore-error keeps a cache hiccup from failing a publish. - cache-from: | - type=registry,ref=docker.io/ptr727/plexcleaner:buildcache-main - type=registry,ref=docker.io/ptr727/plexcleaner:buildcache-develop - cache-to: ${{ inputs.push && format('type=registry,ref=docker.io/ptr727/plexcleaner:buildcache-{0},mode=max,ignore-error=true', inputs.branch) || '' }} - build-args: | - LABEL_VERSION=${{ inputs.semver2 }} - BUILD_CONFIGURATION=${{ inputs.branch == 'main' && 'Release' || 'Debug' }} - BUILD_VERSION=${{ inputs.assembly_version }} - BUILD_FILE_VERSION=${{ inputs.assembly_file_version }} - BUILD_ASSEMBLY_VERSION=${{ inputs.assembly_version }} - BUILD_INFORMATION_VERSION=${{ inputs.assembly_informational_version }} - BUILD_PACKAGE_VERSION=${{ inputs.semver2 }} - - # Push the trimmed Docker Hub overview from Docker/README.md, main only (one overview, no per-branch context). - - name: Update Docker Hub description step - if: ${{ inputs.push && inputs.branch == 'main' }} - uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - repository: ptr727/plexcleaner - readme-filepath: ./Docker/README.md +name: Build Docker image task + +# Builds the multi-arch image (linux/amd64,arm64) and, on a main publish, pushes it plus the Docker Hub overview. +# Branch drives config and tags: main => Release/`latest`, else Debug/`develop`, plus the `:SemVer2` tag. Smoke builds +# amd64 only and never pushes; registry buildcache is branch-scoped. The orchestrator passes `branch` explicitly +# (the publisher builds one branch per run - the trigger ref - and smoke passes github.ref_name). +on: + workflow_call: + inputs: + # Push the built image and the Docker Hub overview. + push: + required: false + type: boolean + default: false + # Git ref to check out / version (empty = default checkout ref). + ref: + required: false + type: string + default: '' + # Logical branch: main => Release/`latest`, else Debug/`develop`. Required (see header). + branch: + required: true + type: string + # Smoke: build linux/amd64 only, never push, skip the shared cache-to. Fast PR feedback. + smoke: + required: false + type: boolean + default: false + # Version from the orchestrator's single NBGV run (threaded). This task does not + # re-run NBGV on the detached commit, which could classify the branch differently. + semver2: + required: true + type: string + assembly_version: + required: true + type: string + assembly_file_version: + required: true + type: string + assembly_informational_version: + required: true + type: string + +jobs: + + build-docker: + name: Build Docker image job + runs-on: ubuntu-latest + + steps: + + - name: Checkout step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.ref }} + + # QEMU only emulates arm64; smoke is amd64-only, so skip it to save setup cost. + - name: Setup QEMU step + if: ${{ !inputs.smoke }} + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + with: + platforms: linux/amd64,linux/arm64 + + - name: Setup Buildx step + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + with: + platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} + + # Always login (even on smoke) for higher pull/cache-read rate limits; the credentials are in both the + # Actions and Dependabot secret stores so a Dependabot push CI run can log in too. Forks cannot push here. + - name: Login to Docker Hub step + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Docker build and push step + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + push: ${{ inputs.push }} + file: ./Docker/Dockerfile + tags: | + docker.io/ptr727/plexcleaner:${{ inputs.branch == 'main' && 'latest' || 'develop' }} + docker.io/ptr727/plexcleaner:${{ inputs.semver2 }} + platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} + # Read both branches' caches (near-identical layers), write only this branch's tag and only when pushing. + # mode=max caches the builder publish; ignore-error keeps a cache hiccup from failing a publish. + cache-from: | + type=registry,ref=docker.io/ptr727/plexcleaner:buildcache-main + type=registry,ref=docker.io/ptr727/plexcleaner:buildcache-develop + cache-to: ${{ inputs.push && format('type=registry,ref=docker.io/ptr727/plexcleaner:buildcache-{0},mode=max,ignore-error=true', inputs.branch) || '' }} + build-args: | + LABEL_VERSION=${{ inputs.semver2 }} + BUILD_CONFIGURATION=${{ inputs.branch == 'main' && 'Release' || 'Debug' }} + BUILD_VERSION=${{ inputs.assembly_version }} + BUILD_FILE_VERSION=${{ inputs.assembly_file_version }} + BUILD_ASSEMBLY_VERSION=${{ inputs.assembly_version }} + BUILD_INFORMATION_VERSION=${{ inputs.assembly_informational_version }} + BUILD_PACKAGE_VERSION=${{ inputs.semver2 }} + + # Push the trimmed Docker Hub overview from Docker/README.md, main only (one overview, no per-branch context). + - name: Update Docker Hub description step + if: ${{ inputs.push && inputs.branch == 'main' }} + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + repository: ptr727/plexcleaner + readme-filepath: ./Docker/README.md diff --git a/.github/workflows/build-executable-task.yml b/.github/workflows/build-executable-task.yml index c5d51a9c..424a8f37 100644 --- a/.github/workflows/build-executable-task.yml +++ b/.github/workflows/build-executable-task.yml @@ -1,106 +1,106 @@ -name: Build executable task - -# Build the executable across the runtime matrix; on a non-smoke run, zip all runtimes into PlexCleaner.7z (per-RID -# subfolders) as the release-asset--executable artifact. Smoke builds a 2-runtime subset and skips the zip. -on: - workflow_call: - inputs: - # Git ref to check out / version (empty = default checkout ref). - ref: - required: false - type: string - default: '' - # Logical branch: main => Release, else Debug. Required (see header). - branch: - required: true - type: string - # Smoke: build linux-x64 + win-x64 only (not the full matrix), skip the zip. Fast PR feedback. - smoke: - required: false - type: boolean - default: false - # Version from the orchestrator's single NBGV run (threaded). This task does not - # re-run NBGV on the detached commit, which could classify the branch differently. - semver2: - required: true - type: string - assembly_version: - required: true - type: string - assembly_file_version: - required: true - type: string - assembly_informational_version: - required: true - type: string - -jobs: - - build-executable-matrix: - name: Build executable project matrix job - runs-on: ubuntu-latest - strategy: - matrix: - runtime: ${{ fromJSON(inputs.smoke && '["linux-x64","win-x64"]' || '["win-x64","linux-x64","linux-musl-x64","linux-arm","linux-arm64","osx-x64","osx-arm64"]') }} - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ inputs.ref }} - - - name: Build executable project step - run: | - dotnet publish ./PlexCleaner/PlexCleaner.csproj \ - --runtime ${{ matrix.runtime }} \ - -property:PublishDir=${{ runner.temp }}/publish/${{ matrix.runtime }}/ \ - --configuration ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \ - -property:PublishAot=false \ - -property:Version=${{ inputs.assembly_version }} \ - -property:FileVersion=${{ inputs.assembly_file_version }} \ - -property:AssemblyVersion=${{ inputs.assembly_version }} \ - -property:InformationalVersion=${{ inputs.assembly_informational_version }} \ - -property:PackageVersion=${{ inputs.semver2 }} - - # Smoke just proves each runtime compiles; the per-runtime upload feeds the release zip, so skip it on - # smoke (also keeps the branch out of the artifact name, which a feature branch's `/` would invalidate). - - name: Upload matrix build artifacts step - if: ${{ !inputs.smoke }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: publish-${{ inputs.branch }}-${{ matrix.runtime }} - path: ${{ runner.temp }}/publish - retention-days: 1 - - # Aggregate every runtime into one PlexCleaner.7z (skipped on smoke; the zip is a release concern). The release - # job collects the release-asset--* artifacts by pattern. - upload-build-artifacts: - name: Upload matrix build artifacts job - if: ${{ !inputs.smoke }} - runs-on: ubuntu-latest - needs: [ build-executable-matrix ] - - steps: - - - name: Download matrix build artifacts step - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: publish-${{ inputs.branch }}-* - merge-multiple: true - path: ${{ runner.temp }}/publish - - - name: Zip build output step - run: 7z a -t7z ${{ runner.temp }}/PlexCleaner.7z ${{ runner.temp }}/publish/* - - - name: Upload build artifacts step - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-asset-${{ inputs.branch }}-executable - path: ${{ runner.temp }}/PlexCleaner.7z - retention-days: 1 +name: Build executable task + +# Build the executable across the runtime matrix; on a non-smoke run, zip all runtimes into PlexCleaner.7z (per-RID +# subfolders) as the release-asset--executable artifact. Smoke builds a 2-runtime subset and skips the zip. +on: + workflow_call: + inputs: + # Git ref to check out / version (empty = default checkout ref). + ref: + required: false + type: string + default: '' + # Logical branch: main => Release, else Debug. Required (see header). + branch: + required: true + type: string + # Smoke: build linux-x64 + win-x64 only (not the full matrix), skip the zip. Fast PR feedback. + smoke: + required: false + type: boolean + default: false + # Version from the orchestrator's single NBGV run (threaded). This task does not + # re-run NBGV on the detached commit, which could classify the branch differently. + semver2: + required: true + type: string + assembly_version: + required: true + type: string + assembly_file_version: + required: true + type: string + assembly_informational_version: + required: true + type: string + +jobs: + + build-executable-matrix: + name: Build executable project matrix job + runs-on: ubuntu-latest + strategy: + matrix: + runtime: ${{ fromJSON(inputs.smoke && '["linux-x64","win-x64"]' || '["win-x64","linux-x64","linux-musl-x64","linux-arm","linux-arm64","osx-x64","osx-arm64"]') }} + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.ref }} + + - name: Build executable project step + run: | + dotnet publish ./PlexCleaner/PlexCleaner.csproj \ + --runtime ${{ matrix.runtime }} \ + -property:PublishDir=${{ runner.temp }}/publish/${{ matrix.runtime }}/ \ + --configuration ${{ inputs.branch == 'main' && 'Release' || 'Debug' }} \ + -property:PublishAot=false \ + -property:Version=${{ inputs.assembly_version }} \ + -property:FileVersion=${{ inputs.assembly_file_version }} \ + -property:AssemblyVersion=${{ inputs.assembly_version }} \ + -property:InformationalVersion=${{ inputs.assembly_informational_version }} \ + -property:PackageVersion=${{ inputs.semver2 }} + + # Smoke just proves each runtime compiles; the per-runtime upload feeds the release zip, so skip it on + # smoke (also keeps the branch out of the artifact name, which a feature branch's `/` would invalidate). + - name: Upload matrix build artifacts step + if: ${{ !inputs.smoke }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: publish-${{ inputs.branch }}-${{ matrix.runtime }} + path: ${{ runner.temp }}/publish + retention-days: 1 + + # Aggregate every runtime into one PlexCleaner.7z (skipped on smoke; the zip is a release concern). The release + # job collects the release-asset--* artifacts by pattern. + upload-build-artifacts: + name: Upload matrix build artifacts job + if: ${{ !inputs.smoke }} + runs-on: ubuntu-latest + needs: [ build-executable-matrix ] + + steps: + + - name: Download matrix build artifacts step + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: publish-${{ inputs.branch }}-* + merge-multiple: true + path: ${{ runner.temp }}/publish + + - name: Zip build output step + run: 7z a -t7z ${{ runner.temp }}/PlexCleaner.7z ${{ runner.temp }}/publish/* + + - name: Upload build artifacts step + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-asset-${{ inputs.branch }}-executable + path: ${{ runner.temp }}/PlexCleaner.7z + retention-days: 1 diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index 6c22652b..722259b2 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -1,56 +1,56 @@ -name: Get version information task - -# Run NBGV once and expose the version outputs. The publisher passes its trigger branch as ref so the run -# versions the branch it publishes; github.ref matches that branch (one branch per run), so NBGV classifies it -# correctly without overriding github.ref. -on: - workflow_call: - inputs: - # Git ref to check out / version (empty = caller's default checkout ref). - ref: - required: false - type: string - default: '' - outputs: - SemVer2: - value: ${{ jobs.get-version.outputs.SemVer2 }} - AssemblyVersion: - value: ${{ jobs.get-version.outputs.AssemblyVersion }} - AssemblyFileVersion: - value: ${{ jobs.get-version.outputs.AssemblyFileVersion }} - AssemblyInformationalVersion: - value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }} - # Full SHA NBGV versioned; pins the release tag to the exact built commit, not a moving ref. - GitCommitId: - value: ${{ jobs.get-version.outputs.GitCommitId }} - -jobs: - - get-version: - name: Get version information job - runs-on: ubuntu-latest - outputs: - SemVer2: ${{ steps.nbgv.outputs.SemVer2 }} - AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }} - AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }} - AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }} - GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }} - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ inputs.ref }} - fetch-depth: 0 - - # Float nbgv on `master`, not SHA-pinned: its tag stream lags `master`, so Dependabot - # tag-tracking would only propose downgrades to stale tags (WORKFLOW.md D9.1). - - name: Run Nerdbank.GitVersioning tool step - id: nbgv - uses: dotnet/nbgv@master +name: Get version information task + +# Run NBGV once and expose the version outputs. The publisher passes its trigger branch as ref so the run +# versions the branch it publishes; github.ref matches that branch (one branch per run), so NBGV classifies it +# correctly without overriding github.ref. +on: + workflow_call: + inputs: + # Git ref to check out / version (empty = caller's default checkout ref). + ref: + required: false + type: string + default: '' + outputs: + SemVer2: + value: ${{ jobs.get-version.outputs.SemVer2 }} + AssemblyVersion: + value: ${{ jobs.get-version.outputs.AssemblyVersion }} + AssemblyFileVersion: + value: ${{ jobs.get-version.outputs.AssemblyFileVersion }} + AssemblyInformationalVersion: + value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }} + # Full SHA NBGV versioned; pins the release tag to the exact built commit, not a moving ref. + GitCommitId: + value: ${{ jobs.get-version.outputs.GitCommitId }} + +jobs: + + get-version: + name: Get version information job + runs-on: ubuntu-latest + outputs: + SemVer2: ${{ steps.nbgv.outputs.SemVer2 }} + AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }} + AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }} + AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }} + GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }} + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + # Float nbgv on `master`, not SHA-pinned: its tag stream lags `master`, so Dependabot + # tag-tracking would only propose downgrades to stale tags (WORKFLOW.md D9.1). + - name: Run Nerdbank.GitVersioning tool step + id: nbgv + uses: dotnet/nbgv@master diff --git a/.vscode/tasks.json b/.vscode/tasks.json index daf0a30e..17ead417 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,303 +1,303 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": ".NET Build", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}", - "--verbosity=diagnostic" - ], - "group": "build", - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": ".NET Format", - "type": "process", - "command": "dotnet", - "args": [ - "format", - "style", - "--verify-no-changes", - "--severity=info", - "--verbosity=detailed" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - }, - "dependsOrder": "sequence", - "dependsOn": [ - "CSharpier Format", - ".NET Build" - ] - }, - { - "label": "CSharpier Format", - "type": "process", - "command": "dotnet", - "args": [ - "csharpier", - "format", - "--log-level=debug", - "." - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": ".NET Tool Update", - "type": "process", - "command": "dotnet", - "args": [ - "tool", - "update", - "--all" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Husky.Net Run", - "type": "process", - "command": "dotnet", - "args": [ - "husky", - "run" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": ".NET Outdated Upgrade", - "type": "process", - "command": "dotnet", - "args": [ - "outdated", - "--upgrade:prompt" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Build Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "buildx", - "build", - "--platform=linux/amd64,linux/arm64", - "--file=./Docker/Dockerfile", - "${workspaceFolder}" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Build all Dockerfiles", - "dependsOrder": "parallel", - "dependsOn": [ - "Build Dockerfile" - ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Load Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "buildx", - "build", - "--load", - "--platform=linux/amd64", - "--tag=plexcleaner:ubuntu", - "--file=./Docker/Dockerfile", - "${workspaceFolder}" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Load all Dockerfiles", - "dependsOrder": "parallel", - "dependsOn": [ - "Load Dockerfile" - ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Test Dockerfile", - "type": "shell", - "command": "docker", - "args": [ - "run", - "-it", - "--rm", - "--name=PlexCleaner-Test", - "plexcleaner:ubuntu", - "/Test/Test.sh" - ], - "dependsOrder": "sequence", - "dependsOn": [ - "Load Dockerfile" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Test all Dockerfiles", - "dependsOrder": "parallel", - "dependsOn": [ - "Test Dockerfile" - ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: CSharpier", - "type": "process", - "command": "dotnet", - "args": [ - "csharpier", - "check", - "." - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: Style", - "type": "process", - "command": "dotnet", - "args": [ - "format", - "style", - "--verify-no-changes", - "--severity=info", - "--verbosity=detailed" - ], - "problemMatcher": [ - "$msCompile" - ], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: Markdown", - "type": "shell", - "command": "docker", - "args": [ - "run", - "--rm", - "--volume=${workspaceFolder}:/workdir", - "--workdir=/workdir", - "davidanson/markdownlint-cli2:latest", - "**/*.md" - ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: Spelling", - "type": "shell", - "command": "docker", - "args": [ - "run", - "--rm", - "--volume=${workspaceFolder}:/workdir", - "--workdir=/workdir", - "ghcr.io/streetsidesoftware/cspell:latest", - "--no-progress", - "README.md", - "HISTORY.md" - ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - }, - { - "label": "Lint: All (CI parity)", - "dependsOrder": "sequence", - "dependsOn": [ - "Lint: CSharpier", - "Lint: Style", - "Lint: Markdown", - "Lint: Spelling" - ], - "problemMatcher": [], - "presentation": { - "showReuseMessage": false, - "clear": false - } - } - ] -} +{ + "version": "2.0.0", + "tasks": [ + { + "label": ".NET Build", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}", + "--verbosity=diagnostic" + ], + "group": "build", + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": ".NET Format", + "type": "process", + "command": "dotnet", + "args": [ + "format", + "style", + "--verify-no-changes", + "--severity=info", + "--verbosity=detailed" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + }, + "dependsOrder": "sequence", + "dependsOn": [ + "CSharpier Format", + ".NET Build" + ] + }, + { + "label": "CSharpier Format", + "type": "process", + "command": "dotnet", + "args": [ + "csharpier", + "format", + "--log-level=debug", + "." + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": ".NET Tool Update", + "type": "process", + "command": "dotnet", + "args": [ + "tool", + "update", + "--all" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Husky.Net Run", + "type": "process", + "command": "dotnet", + "args": [ + "husky", + "run" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": ".NET Outdated Upgrade", + "type": "process", + "command": "dotnet", + "args": [ + "outdated", + "--upgrade:prompt" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Build Dockerfile", + "type": "shell", + "command": "docker", + "args": [ + "buildx", + "build", + "--platform=linux/amd64,linux/arm64", + "--file=./Docker/Dockerfile", + "${workspaceFolder}" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Build all Dockerfiles", + "dependsOrder": "parallel", + "dependsOn": [ + "Build Dockerfile" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Load Dockerfile", + "type": "shell", + "command": "docker", + "args": [ + "buildx", + "build", + "--load", + "--platform=linux/amd64", + "--tag=plexcleaner:ubuntu", + "--file=./Docker/Dockerfile", + "${workspaceFolder}" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Load all Dockerfiles", + "dependsOrder": "parallel", + "dependsOn": [ + "Load Dockerfile" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Test Dockerfile", + "type": "shell", + "command": "docker", + "args": [ + "run", + "-it", + "--rm", + "--name=PlexCleaner-Test", + "plexcleaner:ubuntu", + "/Test/Test.sh" + ], + "dependsOrder": "sequence", + "dependsOn": [ + "Load Dockerfile" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Test all Dockerfiles", + "dependsOrder": "parallel", + "dependsOn": [ + "Test Dockerfile" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: CSharpier", + "type": "process", + "command": "dotnet", + "args": [ + "csharpier", + "check", + "." + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Style", + "type": "process", + "command": "dotnet", + "args": [ + "format", + "style", + "--verify-no-changes", + "--severity=info", + "--verbosity=detailed" + ], + "problemMatcher": [ + "$msCompile" + ], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Markdown", + "type": "shell", + "command": "docker", + "args": [ + "run", + "--rm", + "--volume=${workspaceFolder}:/workdir", + "--workdir=/workdir", + "davidanson/markdownlint-cli2:latest", + "**/*.md" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: Spelling", + "type": "shell", + "command": "docker", + "args": [ + "run", + "--rm", + "--volume=${workspaceFolder}:/workdir", + "--workdir=/workdir", + "ghcr.io/streetsidesoftware/cspell:latest", + "--no-progress", + "README.md", + "HISTORY.md" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: All (CI parity)", + "dependsOrder": "sequence", + "dependsOn": [ + "Lint: CSharpier", + "Lint: Style", + "Lint: Markdown", + "Lint: Spelling" + ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + } + ] +} diff --git a/Docs/ClosedCaptions.md b/Docs/ClosedCaptions.md index 3fa9bf17..fe106830 100644 --- a/Docs/ClosedCaptions.md +++ b/Docs/ClosedCaptions.md @@ -1,186 +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. +# 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 index 274d1900..cfa0acab 100644 --- a/Docs/CustomOptions.md +++ b/Docs/CustomOptions.md @@ -1,106 +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. +# 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 index c13e4607..a42f67a8 100644 --- a/Docs/LanguageMatching.md +++ b/Docs/LanguageMatching.md @@ -1,27 +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. +# 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/PlexCleaner.schema.json b/PlexCleaner.schema.json index 6d0af249..b6dfa810 100644 --- a/PlexCleaner.schema.json +++ b/PlexCleaner.schema.json @@ -343,4 +343,4 @@ "title": "PlexCleaner Configuration Schema", "$id": "https://raw.githubusercontent.com/ptr727/PlexCleaner/main/PlexCleaner.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema" -} \ No newline at end of file +} diff --git a/PlexCleaner/Properties/launchSettings.json b/PlexCleaner/Properties/launchSettings.json index d8c55f2e..9706494e 100644 --- a/PlexCleaner/Properties/launchSettings.json +++ b/PlexCleaner/Properties/launchSettings.json @@ -116,4 +116,4 @@ "commandLineArgs": "testmediainfo --settingsfile \"PlexCleaner.json\" --mediafiles \"D:\\Test\"" } } -} \ No newline at end of file +} diff --git a/Samples/PlexCleaner/PlexCleaner.v1.json b/Samples/PlexCleaner/PlexCleaner.v1.json index ee9cc695..a23491f4 100644 --- a/Samples/PlexCleaner/PlexCleaner.v1.json +++ b/Samples/PlexCleaner/PlexCleaner.v1.json @@ -54,4 +54,4 @@ "MaximumBitrate": 100000000, "MinimumFileAge": 0 } -} \ No newline at end of file +} diff --git a/Samples/PlexCleaner/PlexCleaner.v2.json b/Samples/PlexCleaner/PlexCleaner.v2.json index 390b0671..f3c68413 100644 --- a/Samples/PlexCleaner/PlexCleaner.v2.json +++ b/Samples/PlexCleaner/PlexCleaner.v2.json @@ -129,4 +129,4 @@ "MaximumBitrate": 100000000, "MinimumFileAge": 0 } -} \ No newline at end of file +} diff --git a/Samples/PlexCleaner/PlexCleaner.v3.json b/Samples/PlexCleaner/PlexCleaner.v3.json index 9897b0c2..c74da41e 100644 --- a/Samples/PlexCleaner/PlexCleaner.v3.json +++ b/Samples/PlexCleaner/PlexCleaner.v3.json @@ -137,4 +137,4 @@ "RegisterInvalidFiles": false, "MaximumBitrate": 100000000 } -} \ No newline at end of file +} diff --git a/Samples/PlexCleaner/PlexCleaner.v4.json b/Samples/PlexCleaner/PlexCleaner.v4.json index 249e3308..578da00d 100644 --- a/Samples/PlexCleaner/PlexCleaner.v4.json +++ b/Samples/PlexCleaner/PlexCleaner.v4.json @@ -141,4 +141,4 @@ "FileRetryWaitTime": 5, "FileRetryCount": 2 } -} \ No newline at end of file +} From 66e541200f4e08d6c3dc3a0faa7f9290d3dffdff Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 06:36:17 -0700 Subject: [PATCH 2/2] Add Codecov coverage reporting to CI The unit-test job collected no coverage. Emit Cobertura from the existing dotnet test run (coverlet.collector was already referenced) and upload it to Codecov for trending only - never gated: the upload sets fail_ci_if_error false and codecov.yml marks status informational, so a coverage change or Codecov outage cannot block a merge. CODECOV_TOKEN is threaded via secrets: inherit and audited in both the Actions and Dependabot secret stores by configure.sh, since a Dependabot-triggered push runs the validate job too. Docs (WORKFLOW.md, repo-config/README.md) list the new secret; AGENTS.md notes CI CLI linters can run via Docker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build-release-task.yml | 310 ++++++++++++----------- .github/workflows/test-pull-request.yml | 2 + .github/workflows/validate-task.yml | 166 ++++++------ .gitignore | 1 + AGENTS.md | 5 +- WORKFLOW.md | 4 + codecov.yml | 11 + repo-config/README.md | 7 +- repo-config/configure.sh | 13 +- 9 files changed, 276 insertions(+), 243 deletions(-) create mode 100644 codecov.yml diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index b7469983..3de2caaf 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -1,154 +1,156 @@ -name: Build project release task - -# Orchestrate one branch's release: version once (get-version), build the executable 7z and the Docker image, then -# create the GitHub release. github/dockerhub gate the two publish targets; smoke builds everything, publishes nothing. -on: - workflow_call: - inputs: - github: - required: false - type: boolean - default: false - dockerhub: - required: false - type: boolean - default: false - # Git ref to check out / version (empty = default checkout ref). - ref: - required: false - type: string - default: '' - # Logical branch driving config, tags, and prerelease. Required (see header); the publisher passes the run's branch. - branch: - required: true - type: string - # Smoke: reduced, never-published build for fast PR feedback; hard-disables every push below. - smoke: - required: false - type: boolean - default: false - -jobs: - - # Validate the branch being published (unit tests + lint), gating the build/publish below. Skipped on - # smoke: the PR runs its own validate job, so a smoke build does not double-validate. - validate: - name: Validate job - if: ${{ !inputs.smoke }} - uses: ./.github/workflows/validate-task.yml - with: - ref: ${{ inputs.ref }} - - get-version: - name: Get version information job - uses: ./.github/workflows/get-version-task.yml - secrets: inherit - with: - ref: ${{ inputs.ref }} - - # Build only when validation passed (success) or was skipped (smoke); never when it failed. - build-executable: - name: Build executable job - needs: [get-version, validate] - if: ${{ !cancelled() && needs.get-version.result == 'success' && (needs.validate.result == 'success' || needs.validate.result == 'skipped') }} - uses: ./.github/workflows/build-executable-task.yml - secrets: inherit - with: - # Pin to the resolved commit so the artifacts match the release tag, and thread the single NBGV version. - ref: ${{ needs.get-version.outputs.GitCommitId }} - branch: ${{ inputs.branch }} - smoke: ${{ inputs.smoke }} - semver2: ${{ needs.get-version.outputs.SemVer2 }} - assembly_version: ${{ needs.get-version.outputs.AssemblyVersion }} - assembly_file_version: ${{ needs.get-version.outputs.AssemblyFileVersion }} - assembly_informational_version: ${{ needs.get-version.outputs.AssemblyInformationalVersion }} - - build-docker: - name: Build Docker job - needs: [get-version, validate] - if: ${{ !cancelled() && needs.get-version.result == 'success' && (needs.validate.result == 'success' || needs.validate.result == 'skipped') }} - uses: ./.github/workflows/build-docker-task.yml - secrets: inherit - with: - # Pin to the resolved commit so the artifacts match the release tag, and thread the single NBGV version. - ref: ${{ needs.get-version.outputs.GitCommitId }} - branch: ${{ inputs.branch }} - smoke: ${{ inputs.smoke }} - semver2: ${{ needs.get-version.outputs.SemVer2 }} - assembly_version: ${{ needs.get-version.outputs.AssemblyVersion }} - assembly_file_version: ${{ needs.get-version.outputs.AssemblyFileVersion }} - assembly_informational_version: ${{ needs.get-version.outputs.AssemblyInformationalVersion }} - # Push to Docker Hub, never on a smoke build. - push: ${{ inputs.dockerhub && !inputs.smoke }} - - github-release: - name: Publish GitHub release job - # !smoke enforces "smoke never publishes" even if a smoke caller set github: true. - if: ${{ inputs.github && !inputs.smoke }} - runs-on: ubuntu-latest - needs: [get-version, build-executable, build-docker] - - steps: - - # Check out the exact built commit so the uploaded release files match the tag even if the branch advances mid-run. - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ needs.get-version.outputs.GitCommitId }} - - # Backstop (main only): refuse to publish if the public version carries a prerelease '-' (NBGV mis-versioning). - # Strip '+buildmetadata' first; only a '-' in the core segment marks a prerelease. - - name: Verify public release version step - if: ${{ inputs.branch == 'main' }} - env: - SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} - run: | - set -euo pipefail - CORE_AND_PRE="${SEMVER2%%+*}" # drop +buildmetadata; a '-' here is the genuine prerelease separator - if [[ "$CORE_AND_PRE" == *-* ]]; then - echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." - exit 1 - fi - - # Collect the executable build's release-asset--* artifacts (the PlexCleaner.7z) by pattern. - - name: Download release asset artifacts step - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: release-asset-${{ inputs.branch }}-* - merge-multiple: true - path: ./Publish - - # Weekly re-runs may hit an already-released version; skip release-create when the tag exists (no-op republish). - - name: Check for existing release step - id: release-exists - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.get-version.outputs.SemVer2 }} - run: | - set -euo pipefail - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - echo "Release $TAG already exists; workflow_dispatch will refresh it." - else - echo "Release $TAG already exists; skipping release creation (no-op republish)." - fi - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - # target_commitish pins the tag to the exact built commit (GitCommitId), not the moving branch ref. - # fail_on_unmatched_files catches a missing or misnamed PlexCleaner.7z. - - name: Create GitHub release step - if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 - with: - generate_release_notes: true - tag_name: ${{ needs.get-version.outputs.SemVer2 }} - target_commitish: ${{ needs.get-version.outputs.GitCommitId }} - prerelease: ${{ inputs.branch != 'main' }} - fail_on_unmatched_files: true - files: | - LICENSE - README.md - ./Publish/* +name: Build project release task + +# Orchestrate one branch's release: version once (get-version), build the executable 7z and the Docker image, then +# create the GitHub release. github/dockerhub gate the two publish targets; smoke builds everything, publishes nothing. +on: + workflow_call: + inputs: + github: + required: false + type: boolean + default: false + dockerhub: + required: false + type: boolean + default: false + # Git ref to check out / version (empty = default checkout ref). + ref: + required: false + type: string + default: '' + # Logical branch driving config, tags, and prerelease. Required (see header); the publisher passes the run's branch. + branch: + required: true + type: string + # Smoke: reduced, never-published build for fast PR feedback; hard-disables every push below. + smoke: + required: false + type: boolean + default: false + +jobs: + + # Validate the branch being published (unit tests + lint), gating the build/publish below. Skipped on + # smoke: the PR runs its own validate job, so a smoke build does not double-validate. + validate: + name: Validate job + if: ${{ !inputs.smoke }} + uses: ./.github/workflows/validate-task.yml + # Thread CODECOV_TOKEN through so the unit-test job can upload coverage. + secrets: inherit + with: + ref: ${{ inputs.ref }} + + get-version: + name: Get version information job + uses: ./.github/workflows/get-version-task.yml + secrets: inherit + with: + ref: ${{ inputs.ref }} + + # Build only when validation passed (success) or was skipped (smoke); never when it failed. + build-executable: + name: Build executable job + needs: [get-version, validate] + if: ${{ !cancelled() && needs.get-version.result == 'success' && (needs.validate.result == 'success' || needs.validate.result == 'skipped') }} + uses: ./.github/workflows/build-executable-task.yml + secrets: inherit + with: + # Pin to the resolved commit so the artifacts match the release tag, and thread the single NBGV version. + ref: ${{ needs.get-version.outputs.GitCommitId }} + branch: ${{ inputs.branch }} + smoke: ${{ inputs.smoke }} + semver2: ${{ needs.get-version.outputs.SemVer2 }} + assembly_version: ${{ needs.get-version.outputs.AssemblyVersion }} + assembly_file_version: ${{ needs.get-version.outputs.AssemblyFileVersion }} + assembly_informational_version: ${{ needs.get-version.outputs.AssemblyInformationalVersion }} + + build-docker: + name: Build Docker job + needs: [get-version, validate] + if: ${{ !cancelled() && needs.get-version.result == 'success' && (needs.validate.result == 'success' || needs.validate.result == 'skipped') }} + uses: ./.github/workflows/build-docker-task.yml + secrets: inherit + with: + # Pin to the resolved commit so the artifacts match the release tag, and thread the single NBGV version. + ref: ${{ needs.get-version.outputs.GitCommitId }} + branch: ${{ inputs.branch }} + smoke: ${{ inputs.smoke }} + semver2: ${{ needs.get-version.outputs.SemVer2 }} + assembly_version: ${{ needs.get-version.outputs.AssemblyVersion }} + assembly_file_version: ${{ needs.get-version.outputs.AssemblyFileVersion }} + assembly_informational_version: ${{ needs.get-version.outputs.AssemblyInformationalVersion }} + # Push to Docker Hub, never on a smoke build. + push: ${{ inputs.dockerhub && !inputs.smoke }} + + github-release: + name: Publish GitHub release job + # !smoke enforces "smoke never publishes" even if a smoke caller set github: true. + if: ${{ inputs.github && !inputs.smoke }} + runs-on: ubuntu-latest + needs: [get-version, build-executable, build-docker] + + steps: + + # Check out the exact built commit so the uploaded release files match the tag even if the branch advances mid-run. + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.get-version.outputs.GitCommitId }} + + # Backstop (main only): refuse to publish if the public version carries a prerelease '-' (NBGV mis-versioning). + # Strip '+buildmetadata' first; only a '-' in the core segment marks a prerelease. + - name: Verify public release version step + if: ${{ inputs.branch == 'main' }} + env: + SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} + run: | + set -euo pipefail + CORE_AND_PRE="${SEMVER2%%+*}" # drop +buildmetadata; a '-' here is the genuine prerelease separator + if [[ "$CORE_AND_PRE" == *-* ]]; then + echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." + exit 1 + fi + + # Collect the executable build's release-asset--* artifacts (the PlexCleaner.7z) by pattern. + - name: Download release asset artifacts step + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-asset-${{ inputs.branch }}-* + merge-multiple: true + path: ./Publish + + # Weekly re-runs may hit an already-released version; skip release-create when the tag exists (no-op republish). + - name: Check for existing release step + id: release-exists + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.get-version.outputs.SemVer2 }} + run: | + set -euo pipefail + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "Release $TAG already exists; workflow_dispatch will refresh it." + else + echo "Release $TAG already exists; skipping release creation (no-op republish)." + fi + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + # target_commitish pins the tag to the exact built commit (GitCommitId), not the moving branch ref. + # fail_on_unmatched_files catches a missing or misnamed PlexCleaner.7z. + - name: Create GitHub release step + if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + generate_release_notes: true + tag_name: ${{ needs.get-version.outputs.SemVer2 }} + target_commitish: ${{ needs.get-version.outputs.GitCommitId }} + prerelease: ${{ inputs.branch != 'main' }} + fail_on_unmatched_files: true + files: | + LICENSE + README.md + ./Publish/* diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 923b31ca..cedd73a4 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -26,6 +26,8 @@ jobs: name: Validate job if: ${{ !github.event.deleted }} uses: ./.github/workflows/validate-task.yml + # Thread CODECOV_TOKEN through so the unit-test job can upload coverage. + secrets: inherit # Build and pack both targets (executable + Docker) to prove they ship, publishing nothing. Docker smoke is # amd64-only and seeds from the branch-scoped cache, so it stays fast. diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 7f42741c..b52150d5 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -1,78 +1,88 @@ -name: Validate task - -# The single validation gate: unit tests + the full lint set (the editor's checks, enforced in CI). Reused by -# test-pull-request (produces the required check) and the publisher, so the CI and publish gates are identical. -on: - workflow_call: - inputs: - # Git ref to validate (empty = default checkout ref). The publisher passes its trigger branch so the - # publish run validates the branch it publishes. - ref: - required: false - type: string - default: '' - -jobs: - - unit-test: - name: Run unit tests job - runs-on: ubuntu-latest - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ inputs.ref }} - - # Builds with TreatWarningsAsErrors, so analyzer and code-style warnings fail here. - - name: Run unit tests step - run: dotnet test - - # The editor's checks, enforced in CI from the same config files. - lint: - name: Lint job - runs-on: ubuntu-latest - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ inputs.ref }} - - - name: Check C# formatting step - run: | - set -euo pipefail - dotnet tool restore - dotnet csharpier check . - - - name: Check C# style step - run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed - - - name: Lint markdown step - uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23.2.0 - with: - globs: '**/*.md' - - # Spell-check the user-facing docs; word list + exclusions live in cspell.json (shared with the editor). - - name: Spell check step - uses: streetsidesoftware/cspell-action@de2a73e963e7443969755b648a1008f77033c5b2 # v8.4.0 - with: - files: | - README.md - HISTORY.md - incremental_files_only: false - - - name: Lint workflows step - uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 +name: Validate task + +# The single validation gate: unit tests + the full lint set (the editor's checks, enforced in CI). Reused by +# test-pull-request (produces the required check) and the publisher, so the CI and publish gates are identical. +on: + workflow_call: + inputs: + # Git ref to validate (empty = default checkout ref). The publisher passes its trigger branch so the + # publish run validates the branch it publishes. + ref: + required: false + type: string + default: '' + +jobs: + + unit-test: + name: Run unit tests job + runs-on: ubuntu-latest + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.ref }} + + # Builds with TreatWarningsAsErrors, so analyzer and code-style warnings fail here. + # --collect drives coverlet.collector to emit Cobertura XML into ./coverage//. + - name: Run unit tests step + run: dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage + + # Report-only: fail_ci_if_error is false so a Codecov hiccup or an absent token never fails the gate. + - name: Upload coverage to Codecov step + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + directory: ./coverage + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + # The editor's checks, enforced in CI from the same config files. + lint: + name: Lint job + runs-on: ubuntu-latest + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.ref }} + + - name: Check C# formatting step + run: | + set -euo pipefail + dotnet tool restore + dotnet csharpier check . + + - name: Check C# style step + run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed + + - name: Lint markdown step + uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23.2.0 + with: + globs: '**/*.md' + + # Spell-check the user-facing docs; word list + exclusions live in cspell.json (shared with the editor). + - name: Spell check step + uses: streetsidesoftware/cspell-action@de2a73e963e7443969755b648a1008f77033c5b2 # v8.4.0 + with: + files: | + README.md + HISTORY.md + incremental_files_only: false + + - name: Lint workflows step + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 diff --git a/.gitignore b/.gitignore index 4e19244a..21241c2f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .vs .artifacts .codex +coverage/ .DS_Store *.log diff --git a/AGENTS.md b/AGENTS.md index c0fbd3a6..9879ee23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,8 +103,8 @@ Applies to code and workflow (`#`) comments alike. ### Line Endings -- [`.editorconfig`](./.editorconfig) defines the correct ending per file type (CRLF for `.md`, `.cs`, XML/`.csproj`/`.props`, `.yml`/`.yaml`, `.json`, `.cmd`/`.bat`/`.ps1`; LF for `.sh`), and [`.gitattributes`](./.gitattributes) (`* -text`) stops git from normalizing. The `[*.cs]`/ReSharper style block applies because this repo ships .NET. -- **Editing an existing file: preserve its current line endings** - do not reflow them as a side effect of a content change, even if the file is already non-compliant. After any programmatic edit, verify with `git diff --stat` (only changed lines) and `file ` (expected ending). Bring a non-compliant file to its `.editorconfig` ending only as a deliberate, isolated EOL-only change. +- [`.editorconfig`](./.editorconfig) is the single source of truth for line endings: CRLF for `.md`, `.cs`, XML/`.csproj`/`.props`, `.yml`/`.yaml`, `.json`, `.cmd`/`.bat`/`.ps1`; LF for `.sh` and Dockerfiles. The `[*.cs]`/ReSharper style block applies because this repo ships .NET. +- **Always honor the `.editorconfig` ending.** Create a file with its spec ending; when editing a file, bring the whole file to spec (a file-wide EOL fix alongside the content change is expected, not a violation); if you come across a file with the wrong ending, fix it. [`.gitattributes`](./.gitattributes) (`* -text`) governs git's own normalization - it is not a license to leave a file on the wrong ending. Verify with `file ` after writing. ### Quantitative Claims @@ -175,6 +175,7 @@ Anti-pattern: don't keep flipping the code on the same style point. Flip the rul - **Clean-compile tasks.** [`.vscode/tasks.json`](./.vscode/tasks.json) defines the canonical `.NET Build`, `CSharpier Format`, and `.NET Format` tasks (the last chains the first two then `dotnet format style --verify-no-changes`); their names are owned by the `CODESTYLE.md` ".NET" section - do not loosen them. Husky.Net runs the same checks as a local pre-commit hook, and CI's `lint` job is the authoritative backstop. - **Brownfield analyzer relaxations.** `Directory.Build.props` sets strict analysis; because this is a pre-existing console app, a specific set of analyzer rules are relaxed to suggestion in [`.editorconfig`](./.editorconfig), each documented inline. Prefer fixing new violations over adding relaxations. - **Spell check.** The cspell word list and path exclusions live in [`cspell.json`](./cspell.json), the single source shared by the editor and CI. Do not keep a parallel word list in the `.code-workspace` file. +- **Run CI CLI tooling via Docker.** The linters CI uses (actionlint, markdownlint-cli2, shellcheck, cspell, etc.) need not be installed on the host - run them from their official images (e.g. `docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint`) to reproduce a CI check locally before pushing. - **Release notes.** Keep a short summary in [`README.md`](./README.md) and the full history in [`HISTORY.md`](./HISTORY.md); update both when cutting a release. ## Workflow YAML Conventions diff --git a/WORKFLOW.md b/WORKFLOW.md index bf699fcb..1af2aa6e 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -513,6 +513,10 @@ the configuration is part of "operational" (D10; audit 5D). token from. Required in **both** the Actions and Dependabot secret stores (a Dependabot-triggered run gets the Dependabot store, not Actions secrets). The App must be installed on the repo with `contents: write` and `pull_requests: write`. +- `CODECOV_TOKEN` - authenticates the coverage upload to Codecov from the `validate` job's `unit-test` step. + Report-only and non-gating (the upload sets `fail_ci_if_error: false`, so a Codecov outage or an absent token + never fails CI). Required in **both** the Actions and Dependabot secret stores, since a Dependabot-triggered + push runs CI whose `validate` job uploads coverage and that run gets the Dependabot store. - The built-in `GITHUB_TOKEN` needs no setup. **No `PUBLISH_ON_MERGE` variable is used.** **Branch rulesets.** diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..b9a0f3dd --- /dev/null +++ b/codecov.yml @@ -0,0 +1,11 @@ +# Coverage is reported and trended, never gated. informational keeps Codecov's own commit +# statuses advisory (always "pass") so a coverage change can never block a PR merge. +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true +comment: false diff --git a/repo-config/README.md b/repo-config/README.md index 04d66248..08afdd55 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -30,10 +30,11 @@ templates); repository administration config-as-code is the maintainer's, so it ## What it does not store Secret **values** are never readable through the API, so the script only asserts the required secret -**names** exist (`DOCKER_HUB_USERNAME` / `DOCKER_HUB_ACCESS_TOKEN` for the image, and the App credentials -`CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY` for the merge-bot), and *notes* (best-effort) whether a +**names** exist (`DOCKER_HUB_USERNAME` / `DOCKER_HUB_ACCESS_TOKEN` for the image, the App credentials +`CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY` for the merge-bot, and `CODECOV_TOKEN` for the +report-only coverage upload), and *notes* (best-effort) whether a GitHub App is installed - a precise check needs app-level auth, so the App-installation check does not fail the audit. -The Docker Hub and App credentials must be set in **both** the Actions and Dependabot secret stores, since a +The Docker Hub, App, and Codecov credentials must be set in **both** the Actions and Dependabot secret stores, since a Dependabot-triggered run gets the Dependabot store. Set the values in the repository (or organization) secret store directly. There is no NuGet publishing here; the GitHub release uses the built-in `GITHUB_TOKEN`. The Docker Hub access token's validity and push scope are verified by hand, not by this script. diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 76abf987..9363481e 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -15,12 +15,13 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO="${REPO:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}" -# Secrets by store (names only; values are never readable via the API). The Docker Hub credentials and the -# merge-bot App credentials must be set in BOTH stores: a Dependabot-triggered run gets the Dependabot secret -# store, not Actions secrets, and that run's push CI builds the Docker smoke, which logs in to Docker Hub. -# Publishing the GitHub release uses the built-in GITHUB_TOKEN (no secret needed). -REQUIRED_ACTIONS_SECRETS=(DOCKER_HUB_USERNAME DOCKER_HUB_ACCESS_TOKEN CODEGEN_APP_CLIENT_ID CODEGEN_APP_PRIVATE_KEY) -REQUIRED_DEPENDABOT_SECRETS=(DOCKER_HUB_USERNAME DOCKER_HUB_ACCESS_TOKEN CODEGEN_APP_CLIENT_ID CODEGEN_APP_PRIVATE_KEY) +# Secrets by store (names only; values are never readable via the API). The Docker Hub credentials, the +# merge-bot App credentials, and CODECOV_TOKEN must be set in BOTH stores: a Dependabot-triggered run gets the +# Dependabot secret store, not Actions secrets, and that run's push CI builds the Docker smoke (logs in to +# Docker Hub) and runs the validate job (uploads coverage to Codecov). Publishing the GitHub release uses the +# built-in GITHUB_TOKEN (no secret needed). +REQUIRED_ACTIONS_SECRETS=(DOCKER_HUB_USERNAME DOCKER_HUB_ACCESS_TOKEN CODEGEN_APP_CLIENT_ID CODEGEN_APP_PRIVATE_KEY CODECOV_TOKEN) +REQUIRED_DEPENDABOT_SECRETS=(DOCKER_HUB_USERNAME DOCKER_HUB_ACCESS_TOKEN CODEGEN_APP_CLIENT_ID CODEGEN_APP_PRIVATE_KEY CODECOV_TOKEN) REQUIRED_CHECK="Check pull request workflow status job" note() { printf ' %s\n' "$*"; }