# QSV native (VA-API) decode Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** On the QSV acceleration path, decode with the error-tolerant VA-API decoder while still encoding with QSV — Jellyfin's default hybrid — gated by a new per-profile boolean `QsvPreferNativeDecoder` defaulting to on.

**Architecture:** Add one boolean to `FFmpegProfile`, thread it into `FFmpegState` (as a trailing defaulted record parameter so no existing constructor call breaks), and consume it in the QSV pipeline: `QsvHardwareAccelerationOption` flips the decode global options from `-hwaccel qsv` to `-hwaccel vaapi` while keeping the existing VA-API→QSV derived-device chain, and `QsvPipelineBuilder` selects the generic VA-API decoder. The device-derivation plumbing (`-init_hw_device vaapi=va:… / qsv=hw@va`) already exists — this points the decoder at the tolerant device. All other layers (Application records, REST DTOs, response model, SPA screen) mechanically carry the new field.

**Tech Stack:** C# / .NET 10, EF Core (SQLite + MySQL dual-provider migrations), MediatR CQRS, LanguageExt, `ErsatzTV.FFmpeg` step-based pipeline, NUnit + Shouldly, ChicoryTV React SPA (Vite/TS, generated OpenAPI types).

## Global Constraints

- **Field name (verbatim):** `QsvPreferNativeDecoder` (C#), `qsvPreferNativeDecoder` (TS/JSON).
- **Default: ON.** New column has store default `true`; existing rows backfill to `true` (set via `HasDefaultValue(true)` in EF config so `add-migration.sh` emits `defaultValue: true` and keeps snapshots consistent). Domain `New()` sets `= true`. SPA `defaultDraft` sets `true`.
- **The domain property is `bool?` (nullable), NOT `bool`** — this is the repo's `DeinterlaceVideo` pattern (`FFmpegProfile.cs:38`, `FFmpegProfileConfiguration.cs:17`). Reason (EF Core gotcha): with a non-nullable `bool` + a store default, EF treats the CLR-default `false` as "not set" and omits it from INSERT, so a user creating a profile with the box **unchecked** would silently persist `true` — defeating the OFF escape hatch. `bool?` makes an explicit `false` distinct from the unset sentinel. Coerce **null-means-ON** everywhere the effective value is read: use `!= false` (null→true, true→true, false→false), NEVER `== true` (which would read null as OFF).
- **REST request DTOs use `bool? QsvPreferNativeDecoder = null`** (additive-compat): `/api/v1` is additive-only with real machine clients; an old client omitting the key must NOT flip the feature off. `ToCommand()` passes `QsvPreferNativeDecoder ?? true` so omission preserves default-ON, explicit `false` is honored. The MediatR command + ViewModel stay plain `bool` (resolved values).
- **QSV-only:** the flag has effect only when acceleration is QSV; ignored everywhere else. UI row renders only when `hardwareAcceleration === 'Qsv'`.
- **UI copy (verbatim):** label `Prefer native decoder`; help `Decode with the more error-tolerant VA-API decoder instead of QSV, while still encoding with QSV. Recommended on Intel — handles imperfect streams and is required for Dolby Vision.`
- **`FFmpegState` change is a trailing defaulted parameter** `bool QsvPreferNativeDecoder = false` — appended after `IsTroubleshooting` so all existing `new FFmpegState(...)` callers (tests, `Concat`, error-loop sites) keep compiling with `false`.
- **Positional peer records:** append the new field at the END of each record and each positional mapping (do not insert mid-list) to avoid shifting existing positions.
- **Dual-provider migration:** `scripts/add-migration.sh Add_FFmpegProfile_QsvPreferNativeDecoder` (Sqlite + MySql + both snapshots). CI `migrations` job enforces model-drift + apply.
- **BOM check before every push touching `.cs`:** `for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done` — strip any hit; then `bash -c 'mapfile -t files < <(git diff --name-only --diff-filter=ACM origin/main...HEAD -- "*.cs"); dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"'`.
- **Docs updated in the SAME PR** (decisions.md + field ref + OpenAPI regen).
- **Verification bar:** arg-level NUnit assertions are the automated net. True Intel-QSV hardware validation is deferred to the reporter's i7-10700K post-deploy and stated as such in the PR — NOT claimed as a passing live-E2E. Never set `ETV_UPDATE_GOLDENS`.
- **Work in the worktree** `/Users/timothy/ersatztv/.claude/worktrees/498-qsv-native-decode` (branch `feat/498-qsv-native-decode`, off `origin/main`). Commit hooks: use `git -c core.hooksPath=/dev/null` + manual verification per repo lore.

---

### Task 1: Domain field + EF default + dual-provider migration

**Files:**
- Modify: `ErsatzTV.Core/Domain/FFmpegProfile.cs` (add property near `QsvExtraHardwareFrames` L16; set default in `New()` L65)
- Modify: `ErsatzTV.Infrastructure/Data/Configurations/FFmpegProfileConfiguration.cs` (add `HasDefaultValue(true)`)
- Create (generated): `ErsatzTV.Infrastructure.Sqlite/Migrations/*_Add_FFmpegProfile_QsvPreferNativeDecoder.cs`, `ErsatzTV.Infrastructure.MySql/Migrations/*_Add_FFmpegProfile_QsvPreferNativeDecoder.cs`, + both `TvContextModelSnapshot.cs`

**Interfaces:**
- Produces: `FFmpegProfile.QsvPreferNativeDecoder` (`bool?`, null-means-ON) — consumed by Tasks 4, 6, 7.

- [ ] **Step 1: Add the domain property.** In `ErsatzTV.Core/Domain/FFmpegProfile.cs`, after line 16 (`public int? QsvExtraHardwareFrames { get; set; }`) add (nullable — see Global Constraints):

```csharp
    public bool? QsvPreferNativeDecoder { get; set; }
```

- [ ] **Step 2: Default it on for new profiles.** In `FFmpegProfile.New()` (after `QsvExtraHardwareFrames = 64,` at L65) add:

```csharp
            QsvPreferNativeDecoder = true,
```

- [ ] **Step 3: Set the store default (backfills existing rows to true).** In `FFmpegProfileConfiguration.Configure`, after the `NormalizeColors` block (L26) add:

```csharp
        builder.Property(p => p.QsvPreferNativeDecoder)
            .HasDefaultValue(true);
```

- [ ] **Step 4: Build the model so migrations see the new property.**

Run: `dotnet build ErsatzTV.Infrastructure.Sqlite/ErsatzTV.Infrastructure.Sqlite.csproj`
Expected: `Build succeeded`, `0 Error(s)`.

- [ ] **Step 5: Generate the dual-provider migration.**

Run: `ETV_GITEA_BASICAUTH= scripts/add-migration.sh Add_FFmpegProfile_QsvPreferNativeDecoder`
(If the script requires no env, just `scripts/add-migration.sh Add_FFmpegProfile_QsvPreferNativeDecoder`.)
Expected: new migration files under both `ErsatzTV.Infrastructure.Sqlite/Migrations` and `ErsatzTV.Infrastructure.MySql/Migrations`, each with an `AddColumn<bool>("QsvPreferNativeDecoder", "FFmpegProfile", … defaultValue: true)`, and both `TvContextModelSnapshot.cs` updated.

- [ ] **Step 6: Verify the generated column default.** Open both migration `Up()` methods and confirm `defaultValue: true` is present on the `AddColumn`. If EF emitted `nullable: false` without `defaultValue`, hand-edit to add `defaultValue: true` in BOTH migrations (this is what backfills existing QSV profiles to the hybrid).

- [ ] **Step 7: Confirm no model drift.**

Run: `dotnet build ErsatzTV.Infrastructure.Sqlite/ErsatzTV.Infrastructure.Sqlite.csproj && dotnet build ErsatzTV.Infrastructure.MySql/ErsatzTV.Infrastructure.MySql.csproj`
Expected: both `Build succeeded`.

- [ ] **Step 8: Commit.**

```bash
git add ErsatzTV.Core/Domain/FFmpegProfile.cs ErsatzTV.Infrastructure/Data/Configurations/FFmpegProfileConfiguration.cs ErsatzTV.Infrastructure.Sqlite/Migrations ErsatzTV.Infrastructure.MySql/Migrations
git -c core.hooksPath=/dev/null commit -m "feat(498): add QsvPreferNativeDecoder domain field + migration (default on)"
```

---

### Task 2: `FFmpegState` field (trailing defaulted parameter)

**Files:**
- Modify: `ErsatzTV.FFmpeg/FFmpegState.cs:30` (add trailing param); `Concat` factory unaffected (defaults to false).
- Test: build-only (record change).

**Interfaces:**
- Produces: `FFmpegState.QsvPreferNativeDecoder` (bool, default false) — consumed by Tasks 4, 6.

- [ ] **Step 1: Add the trailing defaulted parameter.** In `ErsatzTV.FFmpeg/FFmpegState.cs`, change line 30 from:

```csharp
    bool IsTroubleshooting)
```

to:

```csharp
    bool IsTroubleshooting,
    bool QsvPreferNativeDecoder = false)
```

- [ ] **Step 2: Build to confirm no existing caller breaks.**

Run: `dotnet build ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj`
Expected: `Build succeeded`, `0 Error(s)` (the `Concat` factory and all positional callers keep compiling — the new param defaults to false).

- [ ] **Step 3: Commit.**

```bash
git add ErsatzTV.FFmpeg/FFmpegState.cs
git -c core.hooksPath=/dev/null commit -m "feat(498): thread QsvPreferNativeDecoder through FFmpegState"
```

---

### Task 3: `QsvHardwareAccelerationOption` decode-flag flip (the linchpin — fully unit-tested)

**Files:**
- Modify: `ErsatzTV.FFmpeg/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOption.cs`
- Test: `ErsatzTV.FFmpeg.Tests/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOptionTests.cs`

**Interfaces:**
- Consumes: nothing new.
- Produces: `new QsvHardwareAccelerationOption(Option<string> device, FFmpegCapability decodeCapability, bool preferNativeDecoder = false)` — a 3rd optional ctor arg. Consumed by Task 4.

- [ ] **Step 1: Write the failing test.** Append to `QsvHardwareAccelerationOptionTests.cs` (before the closing brace). NOTE: the native path emits `-hwaccel vaapi` **without** `-hwaccel_output_format vaapi` — omitting the output format makes ffmpeg download the VA-API-decoded frames to system memory, which routes them through the QSV builder's proven `format=nv12,hwupload,vpp_qsv` filter branch (see Task 4 design note). Emitting `-hwaccel_output_format vaapi` would keep frames as VA-API surfaces and break the QSV filter graph.

```csharp
    [Test]
    public void GlobalOptions_WithHardwareDecode_AndPreferNative_ShouldUseVaapiDecodeToSoftware()
    {
        var option = new QsvHardwareAccelerationOption("/dev/dri/renderD128", FFmpegCapability.Hardware, preferNativeDecoder: true);

        option.GlobalOptions.ShouldBe(
        [
            "-hwaccel", "vaapi",
            "-init_hw_device", "vaapi=va:/dev/dri/renderD128",
            "-init_hw_device", "qsv=hw@va",
            "-filter_hw_device", "hw"
        ]);
        // must NOT keep frames on the GPU as VA-API surfaces
        option.GlobalOptions.ShouldNotContain("-hwaccel_output_format");
    }
```

Hardware-verify note (not blocking the arg test): if on the Intel host ffmpeg fails to bind `-hwaccel vaapi` to the `va` device, add `-hwaccel_device va` after `-hwaccel vaapi` in the native branch. The device is already created by `-init_hw_device vaapi=va:<dev>`.

- [ ] **Step 2: Run the test to verify it fails.**

Run: `dotnet test ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj --filter FullyQualifiedName~QsvHardwareAccelerationOptionTests.GlobalOptions_WithHardwareDecode_AndPreferNative_ShouldUseVaapiDecode`
Expected: FAIL — the ctor has no `preferNativeDecoder` param (compile error) or the arg shape differs.

- [ ] **Step 3: Implement the flip.** Replace the body of `QsvHardwareAccelerationOption.cs` with:

```csharp
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Format;

namespace ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration;

public class QsvHardwareAccelerationOption(
    Option<string> device,
    FFmpegCapability decodeCapability,
    bool preferNativeDecoder = false) : GlobalOption
{
    // TODO: read this from ffmpeg output
    private readonly List<string> _supportedFFmpegFormats = new()
    {
        FFmpegFormat.NV12,
        FFmpegFormat.P010LE
    };

    public override string[] GlobalOptions
    {
        get
        {
            var result = new List<string>();

            if (decodeCapability is FFmpegCapability.Hardware)
            {
                // native path: decode with the error-tolerant VA-API decoder and let ffmpeg
                // download frames to system memory (no -hwaccel_output_format), so the QSV
                // filter graph's software->hwupload branch bridges them to the QSV encoder.
                // default path: decode (and keep frames) on QSV.
                result.AddRange(
                    preferNativeDecoder
                        ? ["-hwaccel", "vaapi"]
                        : ["-hwaccel", "qsv", "-hwaccel_output_format", "qsv"]);
            }

            var deviceConfigured = false;
            foreach (string qsvDevice in device)
            {
                if (!string.IsNullOrWhiteSpace(qsvDevice))
                {
                    result.AddRange(
                    [
                        "-init_hw_device", $"vaapi=va:{qsvDevice}",
                        "-init_hw_device", "qsv=hw@va"
                    ]);
                    deviceConfigured = true;
                }
            }

            if (!deviceConfigured)
            {
                result.AddRange(["-init_hw_device", "qsv=hw"]);
            }

            result.AddRange(["-filter_hw_device", "hw"]);

            return result.ToArray();
        }
    }

    // qsv encoders want nv12
    public override FrameState NextState(FrameState currentState)
    {
        FrameState result = currentState;

        foreach (IPixelFormat pixelFormat in currentState.PixelFormat)
        {
            if (_supportedFFmpegFormats.Contains(pixelFormat.FFmpegName))
            {
                return result;
            }

            return result with { PixelFormat = new PixelFormatNv12(pixelFormat.Name) };
        }

        return result with { PixelFormat = new PixelFormatNv12(new PixelFormatUnknown().Name) };
    }
}
```

Note: this preserves both existing tests. `WithDevice…` (Software capability, no `-hwaccel`) still yields the device chain; `WithHardwareDecode` (no device, `preferNativeDecoder` defaults false) still yields `-hwaccel qsv … -init_hw_device qsv=hw -filter_hw_device hw`. The earlier `result.Clear()` idiom is replaced by only adding `-hwaccel` when `decodeCapability is Hardware`, which is behavior-equivalent.

- [ ] **Step 4: Run the full option test class to verify all pass.**

Run: `dotnet test ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj --filter FullyQualifiedName~QsvHardwareAccelerationOptionTests`
Expected: PASS (3 tests: both originals + the new one).

- [ ] **Step 5: Commit.**

```bash
git add ErsatzTV.FFmpeg/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOption.cs ErsatzTV.FFmpeg.Tests/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOptionTests.cs
git -c core.hooksPath=/dev/null commit -m "feat(498): QsvHardwareAccelerationOption emits VA-API decode when preferring native decoder"
```

---

### Task 4: `QsvPipelineBuilder` — VA-API-decode-to-software + pass the flag (arg-level test)

**Files:**
- Create: `ErsatzTV.FFmpeg/Decoder/DecoderVaapiToSoftware.cs`
- Modify: `ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs` (`SetAccelState` L105/L108-116; `SetDecoder` L125-135)
- Test: `ErsatzTV.FFmpeg.Tests/Pipeline/QsvPipelineBuilderTests.cs` (create)

**Interfaces:**
- Consumes: `FFmpegState.QsvPreferNativeDecoder` (`bool?`, Task 2 — coerce with `!= false`); `QsvHardwareAccelerationOption(..., preferNativeDecoder)` (Task 3).
- Produces: `DecoderVaapiToSoftware` (software-output implicit VA-API decoder); a QSV pipeline whose decode side is VA-API-to-software when the flag is set.

**Design note (READ — Fable review corrected the original approach):** The naive approach (set `DecoderHardwareAccelerationMode = Vaapi` with `-hwaccel_output_format vaapi`, reuse `DecoderVaapi`) is **BROKEN**: `DecoderVaapi` outputs *hardware* VA-API surfaces, and `QsvPipelineBuilder.SetScale` unconditionally chooses `ScaleQsvFilter` when the encoder is QSV — which, on hardware frames, emits a **bare `vpp_qsv`** (even for same-resolution content, via the "still need pixel format" branch at `ScaleQsvFilter.cs:41-47`) directly onto VA-API frames. ffmpeg does NOT auto-`hwmap` between hw contexts → the graph fails to configure on ~all content. `DeinterlaceQsvFilter` fails the same way on interlaced content. The `HardwareDownloadFilter` at `QsvPipelineBuilder.cs:204` runs *after* scale/deinterlace, too late to save it.

**Correct approach — produce SOFTWARE frames after VA-API decode.** Emit `-hwaccel vaapi` WITHOUT `-hwaccel_output_format` (Task 3) so ffmpeg downloads decoded frames to system memory, and use a decoder whose `OutputFrameDataLocation` is `Software`. Then every QSV filter takes its proven software branch: `ScaleQsvFilter` → `format=nv12,hwupload=extra_hw_frames=N,vpp_qsv` (`ScaleQsvFilter.cs:83-86`); `DeinterlaceQsvFilter` → `format=nv12,hwupload=…,deinterlace_qsv` (`DeinterlaceQsvFilter.cs:17-18`); `SetTonemap` picks the software `TonemapFilter` because the decoder mode is not `Qsv` (`QsvPipelineBuilder.cs:705`). This is **exactly the graph the production error-loop paths already run** (`FFmpegLibraryProcessService.cs:782/923` — software decode + QSV encode), just with VA-API-accelerated tolerant decode instead of a CPU decoder. Trade-off (state honestly in the PR): a PCIe download/upload round-trip, NOT Jellyfin's zero-copy `hwmap=derive_device=qsv` (that filter does not exist in this codebase and is out of scope). We keep the QSV *encoder* and the tolerant *decoder*, which is the whole point of #498.

- [ ] **Step 1: Create the software-output VA-API decoder.** Create `ErsatzTV.FFmpeg/Decoder/DecoderVaapiToSoftware.cs`:

```csharp
using ErsatzTV.FFmpeg.Format;

namespace ErsatzTV.FFmpeg.Decoder;

// VA-API-accelerated decode that downloads frames to system memory (no
// -hwaccel_output_format). Pairs with `-hwaccel vaapi` from
// QsvHardwareAccelerationOption on the "prefer native decoder" QSV path: the
// error-tolerant VA-API decoder feeds software frames into the QSV builder's
// format=nv12,hwupload,vpp_qsv branch, which re-uploads for the QSV encoder.
public class DecoderVaapiToSoftware : DecoderBase
{
    protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Software;

    public override string Name => "implicit_vaapi";

    // no -c:v (implicit decoder; `-hwaccel vaapi` drives VA-API) and no
    // -hwaccel_output_format (frames download to software)
    public override string[] InputOptions(InputFile inputFile) => [];
}
```

- [ ] **Step 2: Write the failing arg-level tests.** Create `ErsatzTV.FFmpeg.Tests/Pipeline/QsvPipelineBuilderTests.cs`. Model it on `PipelineBuilderBaseTests` (copy the usings, the nested `DefaultFFmpegCapabilities` class, and the 6-arg `PrintCommand` helper from `PipelineBuilderBaseTests.cs:534-565` verbatim). Add helper `BuildQsvH264Pipeline(bool preferNativeDecoder)` that builds a single 8-bit H264 `VideoInputFile` (1920x1080, `PixelFormatYuv420P`, `ScanKind.Progressive`), an `AudioInputFile` (Aac, 2ch), a `FrameState desiredState` targeting `VideoFormat.H264` / `FFmpegFilterMode.Software`, and an `FFmpegState` with `DecoderHardwareAccelerationMode = HardwareAccelerationMode.Qsv`, `EncoderHardwareAccelerationMode = HardwareAccelerationMode.Qsv`, `VaapiDevice = "/dev/dri/renderD128"`, `QsvPreferNativeDecoder: preferNativeDecoder` (pass as the trailing arg). Build with:

```csharp
        var builder = new QsvPipelineBuilder(
            new DefaultFFmpegCapabilities(),        // IFFmpegCapabilities (1st arg)
            new DefaultHardwareCapabilities(),      // IHardwareCapabilities (2nd arg) — CanDecode/CanEncode => Hardware for 8-bit H264
            videoInputFile,
            audioInputFile,
            None,                                    // watermark
            None,                                    // subtitle
            None,                                    // concat
            Option<GraphicsEngineInput>.None,
            "",                                      // reports folder
            "",                                      // fonts folder
            _logger);
        FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
```

(`DefaultHardwareCapabilities` is `ErsatzTV.FFmpeg/Capabilities/DefaultHardwareCapabilities.cs`; add `using ErsatzTV.FFmpeg.Capabilities;`. The 6-arg `PrintCommand(videoInputFile, audioInputFile, None, None, None, result)` returns the joined command string.) Assertions:

```csharp
    [Test]
    public void Qsv_PreferNativeDecoder_Should_Decode_Via_Vaapi_To_Software_Then_Qsv_Encode()
    {
        string command = BuildAndPrint(preferNativeDecoder: true);

        // VA-API decode, frames downloaded to software (NO hwaccel_output_format)
        command.ShouldContain("-hwaccel vaapi");
        command.ShouldNotContain("-hwaccel_output_format");
        command.ShouldNotContain("-hwaccel qsv");
        command.ShouldNotContain("-c:v h264_qsv -"); // no QSV *decoder* input option
        // derived-device chain retained for the QSV encoder
        command.ShouldContain("-init_hw_device vaapi=va:/dev/dri/renderD128");
        command.ShouldContain("-init_hw_device qsv=hw@va");
        // software frames re-uploaded before QSV filters/encoder (proves NO bare vpp_qsv on VA-API frames)
        command.ShouldContain("hwupload=extra_hw_frames");
        // QSV encoder still used
        command.ShouldContain("h264_qsv");
    }

    [Test]
    public void Qsv_Default_Should_Decode_And_Encode_With_Qsv()
    {
        string command = BuildAndPrint(preferNativeDecoder: false);

        command.ShouldContain("-hwaccel qsv");
        command.ShouldContain("-hwaccel_output_format qsv");
        command.ShouldContain("h264_qsv");
        command.ShouldNotContain("-hwaccel vaapi");
    }
```

(where `BuildAndPrint` = `PrintCommand(...)` over `BuildQsvH264Pipeline(...)`.) Optionally add a third test with an interlaced input (`ScanKind.Interlaced` + a context that deinterlaces) asserting the native command contains `hwupload=extra_hw_frames` followed by `deinterlace_qsv` (never a bare `deinterlace_qsv` on VA-API frames) — add it if the `PipelineContext.ShouldDeinterlace` setup is tractable; otherwise note it as a manual hardware check.

- [ ] **Step 3: Run to verify it fails.**

Run: `dotnet test ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj --filter FullyQualifiedName~QsvPipelineBuilderTests`
Expected: FAIL — the native case still emits `-hwaccel qsv`.

- [ ] **Step 4: Pass the flag into the accel option.** In `QsvPipelineBuilder.SetAccelState`, change line 105 from:

```csharp
        pipelineSteps.Add(new QsvHardwareAccelerationOption(ffmpegState.VaapiDevice, decodeCapability));
```

to (coerce the nullable flag with `!= false` — null means ON):

```csharp
        pipelineSteps.Add(new QsvHardwareAccelerationOption(
            ffmpegState.VaapiDevice,
            decodeCapability,
            ffmpegState.QsvPreferNativeDecoder != false));
```

- [ ] **Step 5: Set the decoder mode to VA-API for the native path.** In the same method, change the `return ffmpegState with { … }` block (L108-116) so the decoder mode is `Vaapi` when preferring native decode and decode is hardware-capable:

```csharp
        // disable hw accel if decoder/encoder isn't supported
        return ffmpegState with
        {
            DecoderHardwareAccelerationMode = decodeCapability == FFmpegCapability.Hardware
                ? ffmpegState.QsvPreferNativeDecoder != false
                    ? HardwareAccelerationMode.Vaapi
                    : HardwareAccelerationMode.Qsv
                : HardwareAccelerationMode.None,
            EncoderHardwareAccelerationMode = encodeCapability == FFmpegCapability.Hardware
                ? HardwareAccelerationMode.Qsv
                : HardwareAccelerationMode.None
        };
```

- [ ] **Step 6: Return the software-output VA-API decoder for that mode.** In `QsvPipelineBuilder.SetDecoder`, add a case to the switch (before the `_ =>` fallback at L134):

```csharp
            (HardwareAccelerationMode.Vaapi, _) => new DecoderVaapiToSoftware(),
```

The pixel-format branch at L169-171 (`== Qsv ? … : source`) correctly takes the source-pixel-format branch for the VA-API-decode case — no change needed. `SetTonemap` (L705, `== Qsv`) correctly falls to the software `TonemapFilter` — no change needed.

- [ ] **Step 7: Build.**

Run: `dotnet build ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj`
Expected: `Build succeeded`.

- [ ] **Step 8: Run the new arg tests + the full FFmpeg test suite (regression).**

Run: `dotnet test ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj`
Expected: PASS — both new tests plus all existing pipeline tests. The native test proves frames go VA-API→software→`hwupload`→QSV (no bare `vpp_qsv` on VA-API frames); the default test proves the QSV-decode path is unchanged.

- [ ] **Step 9: Commit.**

```bash
git add ErsatzTV.FFmpeg/Decoder/DecoderVaapiToSoftware.cs ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs ErsatzTV.FFmpeg.Tests/Pipeline/QsvPipelineBuilderTests.cs
git -c core.hooksPath=/dev/null commit -m "feat(498): QSV pipeline decodes via VA-API to software when preferring native decoder"
```

---

### Task 5: Thread the flag from the profile into `FFmpegState`

**Files:**
- Modify: `ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs` (the primary streaming `new FFmpegState(...)` at L565-590)

**Interfaces:**
- Consumes: `channel.FFmpegProfile.QsvPreferNativeDecoder` (Task 1), `FFmpegState`'s trailing param (Task 2).

- [ ] **Step 1: Pass the flag at the main streaming construction site.** In `FFmpegLibraryProcessService.cs`, the `new FFmpegState(...)` starting at L565 currently ends at L590 with `channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);`. Append the new trailing argument. Change:

```csharp
            GetTonemapAlgorithm(playbackSettings),
            channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
```

to:

```csharp
            GetTonemapAlgorithm(playbackSettings),
            channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
            channel.FFmpegProfile.QsvPreferNativeDecoder != false);
```

(`FFmpegState.QsvPreferNativeDecoder` is non-nullable `bool`; the domain property is `bool?`, so coerce with `!= false` — null-means-ON.) Leave the two error-loop sites (L782-785, L923-926) untouched — they default `QsvPreferNativeDecoder` to false, which is correct (their decoder is already software/None).

- [ ] **Step 2: Build the Core project.**

Run: `dotnet build ErsatzTV.Core/ErsatzTV.Core.csproj`
Expected: `Build succeeded`, `0 Error(s)`.

- [ ] **Step 3: Commit.**

```bash
git add ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
git -c core.hooksPath=/dev/null commit -m "feat(498): pass QsvPreferNativeDecoder from profile into FFmpegState"
```

---

### Task 6: Application + REST + response-model layer (positional appends)

**Files:**
- Modify: `ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs:38`
- Modify: `ErsatzTV.Application/FFmpegProfiles/Mapper.cs` (`ProjectToViewModel` L40, `ProjectToFullResponseModel` L83)
- Modify: `ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs:37`, `UpdateFFmpegProfile.cs:38`
- Modify: `ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs` (`new FFmpegProfile { … }` L108), `UpdateFFmpegProfileHandler.cs` (`ApplyUpdateRequest` L104)
- Modify: `ErsatzTV.Core/Api/FFmpegProfiles/FFmpegFullProfileResponseModel.cs:40`
- Modify: `ErsatzTV/Controllers/Api/Requests/CreateFFmpegProfileRequest.cs` (record L37 + `ToCommand()` L71), `UpdateFFmpegProfileRequest.cs` (record L37 + `ToCommand(id)` L71)
- Verify: `ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdForApiHandler.cs` / `GetAllFFmpegProfilesForApiHandler.cs` delegate to `Mapper.ProjectToFullResponseModel` (if so, no edit; if they build the response model inline, append the field there too)
- Test: `ErsatzTV.Application.Tests` — Create/Update handler round-trip if such a test exists; otherwise a focused new test.

**Interfaces:**
- Consumes: `FFmpegProfile.QsvPreferNativeDecoder`, `CreateFFmpegProfile`/`UpdateFFmpegProfile` records.
- Produces: the field on `FFmpegProfileViewModel`, `FFmpegFullProfileResponseModel`, and both request DTOs (JSON key `qsvPreferNativeDecoder`).

- [ ] **Step 1: Append to `FFmpegProfileViewModel`.** Change `FFmpegProfileViewModel.cs:38` from `    bool DeinterlaceVideo);` to:

```csharp
    bool DeinterlaceVideo,
    bool QsvPreferNativeDecoder);
```

- [ ] **Step 2: Append to `CreateFFmpegProfile` and `UpdateFFmpegProfile` records.** In each, change the final `    bool DeinterlaceVideo) : IRequest<…>;` to add the param before the `)`:

`CreateFFmpegProfile.cs`:
```csharp
    bool DeinterlaceVideo,
    bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
```
`UpdateFFmpegProfile.cs`:
```csharp
    bool DeinterlaceVideo,
    bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
```

- [ ] **Step 3: Append to `FFmpegFullProfileResponseModel`.** Change its final `    bool DeinterlaceVideo);` (L40) to:

```csharp
    bool DeinterlaceVideo,
    bool QsvPreferNativeDecoder);
```

- [ ] **Step 4: Map it in `Mapper`.** The ViewModel/response-model fields are plain `bool`; the domain is `bool?` → coerce with `!= false` (null-means-ON, matching the `DeinterlaceVideo == true` pattern but ON-by-default). In `ProjectToViewModel`, change L40 `            profile.DeinterlaceVideo == true);` to:

```csharp
            profile.DeinterlaceVideo == true,
            profile.QsvPreferNativeDecoder != false);
```

In `ProjectToFullResponseModel`, change L83 `            ffmpegProfile.DeinterlaceVideo == true);` to:

```csharp
            ffmpegProfile.DeinterlaceVideo == true,
            ffmpegProfile.QsvPreferNativeDecoder != false);
```

- [ ] **Step 5: Persist it in the Create handler.** In `CreateFFmpegProfileHandler.Validate`, in the `new FFmpegProfile { … }` initializer change L108 `                DeinterlaceVideo = request.DeinterlaceVideo` to:

```csharp
                DeinterlaceVideo = request.DeinterlaceVideo,
                QsvPreferNativeDecoder = request.QsvPreferNativeDecoder
```

- [ ] **Step 6: Persist it in the Update handler.** In `UpdateFFmpegProfileHandler.ApplyUpdateRequest`, after L104 `        p.DeinterlaceVideo = update.DeinterlaceVideo;` add:

```csharp
        p.QsvPreferNativeDecoder = update.QsvPreferNativeDecoder;
```

- [ ] **Step 7: Append to the REST request DTOs + their `ToCommand` (nullable for additive-compat).** The request param is `bool? … = null` (an old client omitting the key must NOT flip the feature off), and `ToCommand` resolves omission to default-ON with `?? true`. In `CreateFFmpegProfileRequest.cs`:

record param — change `    bool DeinterlaceVideo)` (the one followed by `{`) to:
```csharp
    bool DeinterlaceVideo,
    bool? QsvPreferNativeDecoder = null)
```
`ToCommand()` — change the final `            DeinterlaceVideo);` to:
```csharp
            DeinterlaceVideo,
            QsvPreferNativeDecoder ?? true);
```
Apply the identical two edits to `UpdateFFmpegProfileRequest.cs` (its `ToCommand(int id)` ends the same way). NOTE: the MediatR command records (`CreateFFmpegProfile`/`UpdateFFmpegProfile`, Step 2) keep the param as plain `bool` — the `?? true` resolves it here.

- [ ] **Step 8: Verify the API query handlers.** Open `ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdForApiHandler.cs` and `GetAllFFmpegProfilesForApiHandler.cs`. If they call `Mapper.ProjectToFullResponseModel`, Steps 3-4 already cover them. If either builds `new FFmpegFullProfileResponseModel(...)` inline, append `profile.QsvPreferNativeDecoder` as the final positional arg there too.

- [ ] **Step 9: Build the app + application projects.**

Run: `dotnet build ErsatzTV.Application/ErsatzTV.Application.csproj && dotnet build ErsatzTV/ErsatzTV.csproj`
Expected: both `Build succeeded`, `0 Error(s)`. (A positional-arity mismatch anywhere shows here as a compile error — fix by appending the field at that call site.)

- [ ] **Step 10: Handler round-trip test.** If `ErsatzTV.Application.Tests` contains a Create/Update FFmpeg profile handler test, extend it to assert `QsvPreferNativeDecoder` persists (request `true` → stored `true`; request `false` → stored `false`). If none exists, add a minimal NUnit test that sends `CreateFFmpegProfile` with `QsvPreferNativeDecoder: false` through the handler against an in-memory/SQLite `TvContext` and asserts the stored entity's flag is `false` (proves the field is not hardcoded on).

Run: `dotnet test ErsatzTV.Application.Tests/ErsatzTV.Application.Tests.csproj --filter FullyQualifiedName~FFmpegProfile`
Expected: PASS.

- [ ] **Step 11: Commit.**

```bash
git add ErsatzTV.Application/FFmpegProfiles ErsatzTV.Core/Api/FFmpegProfiles ErsatzTV/Controllers/Api/Requests ErsatzTV.Application.Tests
git -c core.hooksPath=/dev/null commit -m "feat(498): carry QsvPreferNativeDecoder through application + REST layer"
```

---

### Task 7: Regenerate OpenAPI + generated SPA types

**Files:**
- Modify (generated): `ErsatzTV/wwwroot` OpenAPI `v1.json`, `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts`

**Interfaces:**
- Produces: `qsvPreferNativeDecoder` on the generated `CreateFFmpegProfileRequest`/`UpdateFFmpegProfileRequest`/`FFmpegFullProfileResponseModel` TS types — consumed by Task 8.

- [ ] **Step 1: Build the app project first (OpenAPI reads compiled metadata).**

Run: `dotnet build ErsatzTV/ErsatzTV.csproj`
Expected: `Build succeeded`.

- [ ] **Step 2: Regenerate the OpenAPI doc + endpoint index.**

Run: `./scripts/update-openapi.sh`
Expected: `v1.json` and `docs/endpoint-index.md` updated; the three FFmpeg-profile schemas now include `qsvPreferNativeDecoder`.

- [ ] **Step 3: Regenerate the TS client types.**

Run (from `web/`): `npm run generate:api`
Expected: `web/src/api/generated/v1.d.ts` now has `qsvPreferNativeDecoder: boolean` on the request + full-response schemas.

- [ ] **Step 4: Guard check.**

Run (from `web/`): `npm run check:api`
Expected: PASS (generated artifacts consistent).

- [ ] **Step 5: Commit.**

```bash
git add -A
git -c core.hooksPath=/dev/null commit -m "chore(498): regenerate OpenAPI + SPA types for QsvPreferNativeDecoder"
```

---

### Task 8: SPA screen — checkbox + draft defaults

**Files:**
- Modify: `web/src/screens/FFmpegProfilesScreen.tsx` (`defaultDraft` ~L192-225, `draftFromProfile` ~L229-262, QSV rows ~L768-783)

**Interfaces:**
- Consumes: generated `Draft` type field `qsvPreferNativeDecoder` (Task 7); the `Row`/`Checkbox` components already imported (L5).

- [ ] **Step 1: Default it on in `defaultDraft`.** Add to the returned object literal (e.g. after `qsvExtraHardwareFrames: 64,`):

```tsx
    qsvPreferNativeDecoder: true,
```

- [ ] **Step 2: Copy it in `draftFromProfile`.** Add to that object literal (after `qsvExtraHardwareFrames: profile.qsvExtraHardwareFrames,`):

```tsx
    qsvPreferNativeDecoder: profile.qsvPreferNativeDecoder,
```

- [ ] **Step 3: Add the QSV-only checkbox row.** Immediately after the closing `)}` of the `{draft.hardwareAcceleration === 'Qsv' ? ( … ) : ( … )}` tonemap/extra-frames ternary block (around L783), add a new independent QSV-only row:

```tsx
            {draft.hardwareAcceleration === 'Qsv' && (
              <Row
                control={200}
                help="Decode with the more error-tolerant VA-API decoder instead of QSV, while still encoding with QSV. Recommended on Intel — handles imperfect streams and is required for Dolby Vision."
                label="Prefer native decoder"
              >
                <Checkbox
                  checked={draft.qsvPreferNativeDecoder}
                  onChange={(qsvPreferNativeDecoder) => set({ qsvPreferNativeDecoder })}
                />
              </Row>
            )}
```

- [ ] **Step 4: Typecheck + build the SPA.**

Run (from `web/`): `npm run build`
Expected: type-checks and builds with no errors (the `draft.qsvPreferNativeDecoder` references resolve against the regenerated type).

- [ ] **Step 5: Commit.**

```bash
git add web/src/screens/FFmpegProfilesScreen.tsx
git -c core.hooksPath=/dev/null commit -m "feat(498): add 'Prefer native decoder' checkbox to QSV FFmpeg profiles"
```

---

### Task 9: Docs (same PR)

**Files:**
- Modify: `docs/decisions.md` (append)
- Modify: `docs/channels.md` OR `docs/domain-model.md` (FFmpeg-profile field ref, wherever profile fields are catalogued)

**Interfaces:** none.

- [ ] **Step 1: Append a decisions.md entry.** Add (append-only, at the end) an entry dated 2026-07-20 recording: the choice of a single QSV-scoped `QsvPreferNativeDecoder` boolean (mirroring Jellyfin's "Prefer OS native … decoders"), default **ON** (native VA-API decode is more tolerant; Dolby Vision needs it; existing QSV rows adopt it via `HasDefaultValue(true)`), and the explicitly-rejected alternative (a general `DecodeHardwareAcceleration` enum column — more configurable than Jellyfin, deferred until a second asymmetric case appears). Reference issue #498 and the design spec `docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md`.

- [ ] **Step 2: Update the FFmpeg-profile field reference.** Add `QsvPreferNativeDecoder` (bool, default true, QSV-only: VA-API decode + QSV encode) to whichever doc lists FFmpeg profile fields.

- [ ] **Step 3: Commit.**

```bash
git add docs/decisions.md docs/channels.md docs/domain-model.md
git -c core.hooksPath=/dev/null commit -m "docs(498): record QsvPreferNativeDecoder decision + field reference"
```

---

### Task 10: Full local verification, BOM/format gate, push, PR

**Files:** none (verification).

- [ ] **Step 1: Full solution build.**

Run: `dotnet build ErsatzTV.sln`
Expected: `Build succeeded`, `0 Error(s)`.

- [ ] **Step 2: Full test pass (the layers this PR touches).**

Run: `dotnet test ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj && dotnet test ErsatzTV.Application.Tests/ErsatzTV.Application.Tests.csproj`
Expected: PASS. (Optionally the full `dotnet test ErsatzTV.sln`.)

- [ ] **Step 3: Migration apply-to-fresh-DB smoke (both providers, per CI `migrations` job).** Follow `docs/ci-cd.md` → Migration integrity, or at minimum confirm the Sqlite migration applies to a fresh DB without error.

- [ ] **Step 4: BOM check on touched `.cs`.**

Run: `for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done`
Expected: no output. Strip any hit.

- [ ] **Step 5: Format-gate the touched set.**

Run: `bash -c 'mapfile -t files < <(git diff --name-only --diff-filter=ACM origin/main...HEAD -- "*.cs"); dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"'`
Expected: exit 0. If it reports changes, run without `--verify-no-changes` to apply, then re-commit.

- [ ] **Step 6: Rebase on origin/main (H11), regenerate artifacts if conflicts.**

Run: `git fetch origin main && git rebase origin/main`
If conflicts hit generated artifacts (`v1.json`, `v1.d.ts`, `endpoint-index.md`): REGENERATE (`./scripts/update-openapi.sh` + `npm run generate:api`) — never hand-resolve.

- [ ] **Step 7: Push once (batch — no mid-review re-pushes).**

Run: `git push -u origin feat/498-qsv-native-decode`

- [ ] **Step 8: Open the PR** with `fixes #498`, arm a CI monitor on the head sha (commit-status endpoint) at PR-open, and state plainly that hardware validation (real Intel-QSV decode) is deferred to the reporter's i7-10700K post-deploy — the automated net is the arg-level tests, not a live-E2E on this class.

- [ ] **Step 9: Independent review.** This diff touches an API write-path handler + a migration → an independent review pass is mandatory (Global Constraints; cross-model preferred, else a cold review-only agent). Fold in fix commits (never amend/force-push). Loop to a clean MERGEABLE verdict; post the `Review-verdict: … @ <head-sha>` comment per the H10 convention.

## Notes for the executor

- **Task 4 is the substantive task.** The correct approach (VA-API decode → *software* frames → existing `hwupload`/`vpp_qsv` branch → QSV encode) is statically sound and reuses the production software-decode+QSV-encode graph; the arg test proves `hwupload` bridges the frames (no bare `vpp_qsv` on VA-API frames). Do NOT emit `-hwaccel_output_format vaapi` and do NOT reuse `DecoderVaapi` (both keep frames on the GPU and break the QSV filter graph — see the Task 4 design note). What's deferred to the Intel host is only driver-level empirics (iHD tolerance on imperfect NAL units, upload bandwidth), not graph construction.
- **The `bool?` nullable + `!= false` coercion is load-bearing (Blocker 2).** A non-nullable `bool` + `HasDefaultValue(true)` would make EF silently store `true` when a user creates a profile with the box unchecked. Keep the domain property `bool?`, read effective value with `!= false` everywhere (Mapper, Task 5), and keep the REST DTO params `bool? = null` with `?? true` (Task 6 Step 7). The Task 6 Step 10 round-trip test (request `false` → stored `false`) is the regression guard for this.
- Every positional-record edit (Task 6) will fail the build loudly at the mismatched call site if a slot is missed — trust the compiler, fix, rebuild.
- Do NOT set `ETV_UPDATE_GOLDENS`.
