From 63937fb60207f78d44604c62b1d88919cd26b5d9 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 17 Sep 2025 10:56:46 -0600 Subject: [PATCH 01/10] Implement low bitrate HDR logic for video encoding - Add HDR requirement to low bitrate detection: only HDR videos < 5 Mbps skip encoding - Update video state machine to automatically mark qualifying videos as encoded - Add comprehensive test coverage for HDR/bitrate combinations - Update valid state transitions to allow direct needs_analysis -> encoded This ensures only genuinely efficient HDR content skips the expensive encoding process while maintaining quality for non-HDR content. --- lib/reencodarr/media/video_state_machine.ex | 25 +- .../media/video_state_machine_test.exs | 455 +++++++++++++++++- 2 files changed, 468 insertions(+), 12 deletions(-) diff --git a/lib/reencodarr/media/video_state_machine.ex b/lib/reencodarr/media/video_state_machine.ex index 7baa80ba..8baec982 100644 --- a/lib/reencodarr/media/video_state_machine.ex +++ b/lib/reencodarr/media/video_state_machine.ex @@ -30,8 +30,8 @@ defmodule Reencodarr.Media.VideoStateMachine do # Valid state transitions - only these transitions are allowed @valid_transitions %{ - needs_analysis: [:analyzed, :crf_searched, :failed], - analyzed: [:crf_searching, :crf_searched, :failed], + needs_analysis: [:analyzed, :crf_searched, :encoded, :failed], + analyzed: [:crf_searching, :crf_searched, :encoded, :failed], # Can go back to analyzed if CRF search is cancelled crf_searching: [:crf_searched, :failed, :analyzed], # Can restart CRF search if needed @@ -99,8 +99,17 @@ defmodule Reencodarr.Media.VideoStateMachine do end def transition_to_analyzed(%Video{} = video, attrs \\ %{}) do - # Don't add any extra validation flags - let the changeset validation handle requirements - transition(video, :analyzed, attrs) + # Check if video has low bitrate (less than 10 Mbps) and should be marked as encoded + if low_bitrate?(video) do + Logger.debug( + "Video #{video.path} has low bitrate (#{video.bitrate} kbps), marking as encoded" + ) + + transition(video, :encoded, attrs) + else + # Don't add any extra validation flags - let the changeset validation handle requirements + transition(video, :analyzed, attrs) + end end def transition_to_crf_searching(%Video{} = video, attrs \\ %{}) do @@ -408,4 +417,12 @@ defmodule Reencodarr.Media.VideoStateMachine do end # Private helper functions + + # Check if video has low bitrate (less than 5 Mbps = 5,000,000 bps) AND is HDR and should skip encoding + defp low_bitrate?(%Video{bitrate: bitrate, hdr: hdr}) + when is_integer(bitrate) and not is_nil(hdr) do + bitrate < 5_000_000 + end + + defp low_bitrate?(_video), do: false end diff --git a/test/reencodarr/media/video_state_machine_test.exs b/test/reencodarr/media/video_state_machine_test.exs index 3c203202..adf2a0e4 100644 --- a/test/reencodarr/media/video_state_machine_test.exs +++ b/test/reencodarr/media/video_state_machine_test.exs @@ -5,12 +5,13 @@ defmodule Reencodarr.Media.VideoStateMachineTest do describe "transition_to_analyzed/2" do test "can transition to analyzed state without duration" do - # Create a video without duration + # Create a video without duration but with HIGH bitrate to avoid low bitrate logic {:ok, video} = Fixtures.video_fixture(%{ path: "/test/no_duration_video.mkv", size: 1_000_000_000, - bitrate: 5000, + # High bitrate to ensure normal transition to analyzed + bitrate: 15_000, width: 1920, height: 1080, video_codecs: ["h264"], @@ -34,12 +35,13 @@ defmodule Reencodarr.Media.VideoStateMachineTest do end test "can transition to analyzed state with valid duration" do - # Create a video with duration + # Create a video with duration and HIGH bitrate {:ok, video} = Fixtures.video_fixture(%{ path: "/test/with_duration_video.mkv", size: 1_000_000_000, - bitrate: 5000, + # High bitrate to ensure normal transition to analyzed + bitrate: 15_000, width: 1920, height: 1080, duration: 7200.0, @@ -63,12 +65,13 @@ defmodule Reencodarr.Media.VideoStateMachineTest do end test "rejects invalid duration when present" do - # Create a video + # Create a video with HIGH bitrate to avoid low bitrate logic {:ok, video} = Fixtures.video_fixture(%{ path: "/test/invalid_duration_video.mkv", size: 1_000_000_000, - bitrate: 5000, + # High bitrate to ensure normal transition to analyzed + bitrate: 15_000, width: 1920, height: 1080, video_codecs: ["h264"], @@ -87,12 +90,13 @@ defmodule Reencodarr.Media.VideoStateMachineTest do end test "rejects zero duration when present" do - # Create a video + # Create a video with HIGH bitrate to avoid low bitrate logic {:ok, video} = Fixtures.video_fixture(%{ path: "/test/zero_duration_video.mkv", size: 1_000_000_000, - bitrate: 5000, + # High bitrate to ensure normal transition to analyzed + bitrate: 15_000, width: 1920, height: 1080, video_codecs: ["h264"], @@ -141,4 +145,439 @@ defmodule Reencodarr.Media.VideoStateMachineTest do "Should have errors for required fields, got: #{inspect(changeset.errors)}" end end + + describe "low bitrate logic in transition_to_analyzed/2" do + test "transitions low bitrate video directly to encoded state" do + # Create a video with low bitrate (< 5,000,000 bps = 5 Mbps) AND HDR + {:ok, video} = + Fixtures.video_fixture(%{ + path: "/test/low_bitrate_video.mkv", + size: 1_000_000_000, + # 3 Mbps - below 5 Mbps threshold + bitrate: 3_000_000, + # HDR content + hdr: "HDR10", + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"], + max_audio_channels: 2, + atmos: false, + state: :needs_analysis + }) + + # Attempt to transition to analyzed state + {:ok, changeset} = VideoStateMachine.transition_to_analyzed(video) + + # Should transition to encoded instead of analyzed + assert changeset.changes.state == :encoded + + # Apply the changeset + {:ok, updated_video} = Repo.update(changeset) + assert updated_video.state == :encoded + end + + test "transitions high bitrate video to analyzed state normally" do + # Create a video with high bitrate (>= 5,000,000 bps = 5 Mbps) or non-HDR + {:ok, video} = + Fixtures.video_fixture(%{ + path: "/test/high_bitrate_video.mkv", + size: 1_000_000_000, + # 15 Mbps - above 5 Mbps threshold + bitrate: 15_000_000, + # Even with HDR, high bitrate should go to analyzed + hdr: "HDR10", + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"], + max_audio_channels: 2, + atmos: false, + state: :needs_analysis + }) + + # Attempt to transition to analyzed state + {:ok, changeset} = VideoStateMachine.transition_to_analyzed(video) + + # Should transition to analyzed normally + assert changeset.changes.state == :analyzed + + # Apply the changeset + {:ok, updated_video} = Repo.update(changeset) + assert updated_video.state == :analyzed + end + + test "transitions video with nil bitrate to analyzed state normally" do + # Create a video with nil bitrate + {:ok, video} = + Fixtures.video_fixture(%{ + path: "/test/nil_bitrate_video.mkv", + size: 1_000_000_000, + bitrate: nil, + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"], + max_audio_channels: 2, + atmos: false, + state: :needs_analysis + }) + + # This should fail validation, but not due to low_bitrate? logic + {:ok, changeset} = VideoStateMachine.transition_to_analyzed(video) + + # Should attempt to transition to analyzed (low_bitrate? returns false for nil) + assert changeset.changes.state == :analyzed + end + + test "treats exactly 5,000 kbps as high bitrate" do + # Create a video with exactly 5,000,000 bps bitrate (5 Mbps) and HDR + {:ok, video} = + Fixtures.video_fixture(%{ + path: "/test/threshold_bitrate_video.mkv", + size: 1_000_000_000, + # Exactly 5 Mbps - should not be considered low (>= threshold) + bitrate: 5_000_000, + # HDR content + hdr: "HDR10", + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"], + max_audio_channels: 2, + atmos: false, + state: :needs_analysis + }) + + # Attempt to transition to analyzed state + {:ok, changeset} = VideoStateMachine.transition_to_analyzed(video) + + # Should transition to analyzed (not low bitrate) + assert changeset.changes.state == :analyzed + + # Apply the changeset + {:ok, updated_video} = Repo.update(changeset) + assert updated_video.state == :analyzed + end + + test "transitions low bitrate non-HDR video to analyzed state (HDR required)" do + # Create a video with low bitrate but no HDR - should not be considered low bitrate + {:ok, video} = + Fixtures.video_fixture(%{ + path: "/test/low_bitrate_no_hdr_video.mkv", + size: 1_000_000_000, + # 3 Mbps - below 5 Mbps threshold but no HDR + bitrate: 3_000_000, + # No HDR content + hdr: nil, + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"], + max_audio_channels: 2, + atmos: false, + state: :needs_analysis + }) + + # Attempt to transition to analyzed state + {:ok, changeset} = VideoStateMachine.transition_to_analyzed(video) + + # Should transition to analyzed (HDR required for low bitrate logic) + assert changeset.changes.state == :analyzed + + # Apply the changeset + {:ok, updated_video} = Repo.update(changeset) + assert updated_video.state == :analyzed + end + + test "transitions low bitrate nil HDR video to analyzed state" do + # Create a video with low bitrate but nil HDR - should not be considered low bitrate + {:ok, video} = + Fixtures.video_fixture(%{ + path: "/test/low_bitrate_nil_hdr_video.mkv", + size: 1_000_000_000, + # 3 Mbps - below 5 Mbps threshold but nil HDR + bitrate: 3_000_000, + # Nil HDR content + hdr: nil, + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"], + max_audio_channels: 2, + atmos: false, + state: :needs_analysis + }) + + # Attempt to transition to analyzed state + {:ok, changeset} = VideoStateMachine.transition_to_analyzed(video) + + # Should transition to analyzed (HDR must be true for low bitrate logic) + assert changeset.changes.state == :analyzed + + # Apply the changeset + {:ok, updated_video} = Repo.update(changeset) + assert updated_video.state == :analyzed + end + end + + describe "valid_transitions/1" do + test "returns valid transitions for each state" do + assert VideoStateMachine.valid_transitions(:needs_analysis) == [ + :analyzed, + :crf_searched, + :encoded, + :failed + ] + + assert VideoStateMachine.valid_transitions(:analyzed) == [ + :crf_searching, + :crf_searched, + :encoded, + :failed + ] + + assert VideoStateMachine.valid_transitions(:crf_searching) == [ + :crf_searched, + :failed, + :analyzed + ] + + assert VideoStateMachine.valid_transitions(:crf_searched) == [ + :encoding, + :failed, + :crf_searching + ] + + assert VideoStateMachine.valid_transitions(:encoding) == [:encoded, :failed, :crf_searched] + assert VideoStateMachine.valid_transitions(:encoded) == [:failed] + + assert VideoStateMachine.valid_transitions(:failed) == [ + :needs_analysis, + :analyzed, + :crf_searching, + :crf_searched, + :encoding + ] + end + end + + describe "valid_transition?/2" do + test "validates valid state transitions" do + assert VideoStateMachine.valid_transition?(:needs_analysis, :analyzed) + assert VideoStateMachine.valid_transition?(:analyzed, :crf_searching) + assert VideoStateMachine.valid_transition?(:crf_searched, :encoding) + assert VideoStateMachine.valid_transition?(:encoding, :encoded) + end + + test "rejects invalid state transitions" do + refute VideoStateMachine.valid_transition?(:needs_analysis, :encoding) + refute VideoStateMachine.valid_transition?(:encoded, :analyzed) + refute VideoStateMachine.valid_transition?(:crf_searching, :encoding) + end + + test "handles invalid states" do + refute VideoStateMachine.valid_transition?(:invalid_state, :analyzed) + refute VideoStateMachine.valid_transition?(:analyzed, :invalid_state) + end + end + + describe "next_expected_state/1" do + test "returns correct next state for needs_analysis video with complete analysis" do + {:ok, video} = + Fixtures.video_fixture(%{ + state: :needs_analysis, + # High bitrate to avoid low bitrate logic + bitrate: 15_000, + width: 1920, + height: 1080, + # Required for analysis_complete? + duration: 7200.0, + video_codecs: ["h264"], + audio_codecs: ["aac"] + }) + + assert VideoStateMachine.next_expected_state(video) == :analyzed + end + + test "returns needs_analysis for incomplete analysis" do + {:ok, video} = + Fixtures.video_fixture(%{ + state: :needs_analysis, + # Missing required field + bitrate: nil, + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"] + }) + + assert VideoStateMachine.next_expected_state(video) == :needs_analysis + end + + test "returns correct next states for other states" do + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + assert VideoStateMachine.next_expected_state(video) == :crf_searching + + {:ok, video} = Fixtures.video_fixture(%{state: :crf_searched}) + assert VideoStateMachine.next_expected_state(video) == :encoding + + {:ok, video} = Fixtures.video_fixture(%{state: :encoding}) + assert VideoStateMachine.next_expected_state(video) == :encoded + + {:ok, video} = Fixtures.video_fixture(%{state: :encoded}) + assert VideoStateMachine.next_expected_state(video) == :encoded + + {:ok, video} = Fixtures.video_fixture(%{state: :failed}) + assert VideoStateMachine.next_expected_state(video) == :failed + end + + test "handles crf_searching state with complete search" do + # This would need a video with VMAF data to test properly + {:ok, video} = Fixtures.video_fixture(%{state: :crf_searching}) + + # Without VMAF data, should stay in crf_searching + assert VideoStateMachine.next_expected_state(video) == :crf_searching + end + end + + describe "mark_as_reencoded/1" do + test "transitions video to encoded state from needs_analysis" do + {:ok, video} = + Fixtures.video_fixture(%{ + state: :needs_analysis, + bitrate: 5000, + width: 1920, + height: 1080 + }) + + {:ok, updated_video} = VideoStateMachine.mark_as_reencoded(video) + assert updated_video.state == :encoded + end + + test "transitions video to encoded state from analyzed" do + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + + {:ok, updated_video} = VideoStateMachine.mark_as_reencoded(video) + assert updated_video.state == :encoded + end + + test "handles invalid transitions gracefully" do + # Create a video that might not be able to transition to encoded + {:ok, video} = Fixtures.video_fixture(%{state: :failed}) + + # Should either succeed or return an error, but not crash + result = VideoStateMachine.mark_as_reencoded(video) + + case result do + {:ok, _updated_video} -> assert true + {:error, _changeset} -> assert true + end + end + end + + describe "mark_as_analyzed/1" do + test "uses low bitrate logic correctly" do + # Test that mark_as_analyzed uses the transition_to_analyzed logic + {:ok, low_bitrate_video} = + Fixtures.video_fixture(%{ + state: :needs_analysis, + # Low bitrate (3 Mbps) + bitrate: 3_000_000, + # HDR content (required) + hdr: "HDR10", + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"], + max_audio_channels: 2 + }) + + {:ok, updated_video} = VideoStateMachine.mark_as_analyzed(low_bitrate_video) + # Should skip to encoded + assert updated_video.state == :encoded + end + + test "transitions high bitrate video to analyzed" do + {:ok, high_bitrate_video} = + Fixtures.video_fixture(%{ + state: :needs_analysis, + # High bitrate (15 Mbps) + bitrate: 15_000_000, + # Even with HDR, high bitrate should go to analyzed + hdr: "HDR10", + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"], + max_audio_channels: 2 + }) + + {:ok, updated_video} = VideoStateMachine.mark_as_analyzed(high_bitrate_video) + # Should go to analyzed normally + assert updated_video.state == :analyzed + end + + test "transitions low bitrate non-HDR video to analyzed (HDR required)" do + {:ok, low_bitrate_no_hdr_video} = + Fixtures.video_fixture(%{ + state: :needs_analysis, + # Low bitrate (3 Mbps) but no HDR + bitrate: 3_000_000, + # No HDR content + hdr: nil, + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"], + max_audio_channels: 2 + }) + + {:ok, updated_video} = VideoStateMachine.mark_as_analyzed(low_bitrate_no_hdr_video) + # Should go to analyzed (HDR required) + assert updated_video.state == :analyzed + end + end + + describe "transition/3" do + test "prevents invalid state transitions" do + {:ok, video} = Fixtures.video_fixture(%{state: :needs_analysis}) + + # Try invalid transition + result = VideoStateMachine.transition(video, :encoding) + + # Should return error tuple with message + assert {:error, _message} = result + end + + test "allows valid state transitions" do + {:ok, video} = + Fixtures.video_fixture(%{ + state: :needs_analysis, + # High bitrate to avoid low bitrate logic + bitrate: 15_000, + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"], + max_audio_channels: 2 + }) + + # Try valid transition + {:ok, changeset} = VideoStateMachine.transition(video, :analyzed) + + assert changeset.valid? + assert changeset.changes.state == :analyzed + end + + test "applies additional attributes during transition" do + {:ok, video} = Fixtures.video_fixture(%{state: :needs_analysis}) + + {:ok, changeset} = VideoStateMachine.transition(video, :failed, %{bitrate: 12_345}) + + assert changeset.valid? + assert changeset.changes.state == :failed + assert changeset.changes.bitrate == 12_345 + end + end end From 6451942e80ceaf7639a5229e9a2af01707c1c7b4 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 17 Sep 2025 11:07:19 -0600 Subject: [PATCH 02/10] Fix log output to display bitrate in megabits - Convert bitrate from bps to Mbps for clearer log messages - Update log message to accurately reflect HDR requirement - Format decimal places for better readability --- lib/reencodarr/media/video_state_machine.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/reencodarr/media/video_state_machine.ex b/lib/reencodarr/media/video_state_machine.ex index 8baec982..644a53d0 100644 --- a/lib/reencodarr/media/video_state_machine.ex +++ b/lib/reencodarr/media/video_state_machine.ex @@ -99,10 +99,12 @@ defmodule Reencodarr.Media.VideoStateMachine do end def transition_to_analyzed(%Video{} = video, attrs \\ %{}) do - # Check if video has low bitrate (less than 10 Mbps) and should be marked as encoded + # Check if video has low bitrate and HDR content, should be marked as encoded if low_bitrate?(video) do + bitrate_mbps = video.bitrate / 1_000_000 + Logger.debug( - "Video #{video.path} has low bitrate (#{video.bitrate} kbps), marking as encoded" + "Video #{video.path} has low bitrate (#{:erlang.float_to_binary(bitrate_mbps, decimals: 1)} Mbps) and HDR, marking as encoded" ) transition(video, :encoded, attrs) From 1d65e4ad35002090b038e60cb81af9cdc4920e4c Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 17 Sep 2025 11:27:11 -0600 Subject: [PATCH 03/10] Optimize GitHub Actions workflow with comprehensive caching - Add build cache for _build directory with proper cache keys - Cache ab-av1 binary to avoid repeated downloads and compilation - Cache apt packages to speed up tool installation - Update to actions/cache@v4 for better performance - Add mediainfo to installed tools for completeness - Separate deps compilation from project compilation for better caching - Add code quality checks (format, credo) to CI pipeline - Use more specific cache keys based on mix.lock and source files --- .github/workflows/elixir.yml | 68 +++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index a917c77c..56bd6423 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -1,8 +1,3 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - name: Elixir CI on: @@ -22,28 +17,63 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Elixir uses: erlef/setup-beam@61e01a43a562a89bfc54c7f9a378ff67b03e4a21 # v1.16.0 with: elixir-version: '1.18.3' # [Required] Define the Elixir version otp-version: '27.3.4' # [Required] Define the Erlang/OTP version - - name: Restore dependencies cache - uses: actions/cache@v3 + + - name: Cache apt packages + uses: actions/cache@v4 with: - path: deps - key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-mix- - - name: Install dependencies - run: mix deps.get + path: /var/cache/apt/archives + key: ${{ runner.os }}-apt-${{ hashFiles('.github/workflows/elixir.yml') }} + restore-keys: ${{ runner.os }}-apt- + + - name: Cache ab-av1 binary + id: cache-ab-av1 + uses: actions/cache@v4 + with: + path: /usr/local/bin/ab-av1 + key: ab-av1-v0.10.1 + - name: Install additional tools run: | sudo apt-get update - sudo apt-get install -y fd-find ffmpeg zstd + sudo apt-get install -y fd-find ffmpeg zstd mediainfo + + - name: Install ab-av1 + if: steps.cache-ab-av1.outputs.cache-hit != 'true' + run: | # Install ab-av1 from GitHub releases wget -O /tmp/ab-av1.tar.zst https://github.com/alexheretic/ab-av1/releases/download/v0.10.1/ab-av1-v0.10.1-x86_64-unknown-linux-musl.tar.zst cd /tmp && zstd -d ab-av1.tar.zst && tar -xf ab-av1.tar sudo mv ab-av1 /usr/local/bin/ab-av1 sudo chmod +x /usr/local/bin/ab-av1 + + - name: Restore dependencies cache + uses: actions/cache@v4 + with: + path: deps + key: ${{ runner.os }}-mix-deps-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix-deps- + + - name: Restore build cache + uses: actions/cache@v4 + with: + path: _build + key: ${{ runner.os }}-mix-build-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/*.ex') }}-${{ hashFiles('**/*.exs') }} + restore-keys: | + ${{ runner.os }}-mix-build-${{ hashFiles('**/mix.lock') }}- + ${{ runner.os }}-mix-build- + + - name: Install dependencies + run: mix deps.get + + - name: Compile dependencies + run: mix deps.compile + - name: Set up PostgreSQL uses: harmon758/postgresql-action@v1 with: @@ -51,13 +81,25 @@ jobs: postgresql db: reencodarr_test postgresql user: postgres postgresql password: postgres + - name: Wait for PostgreSQL run: | for i in {1..30}; do pg_isready -h localhost -p 5432 -U postgres && break sleep 1 done + - name: Set MIX_ENV to test run: echo "MIX_ENV=test" >> $GITHUB_ENV + + - name: Compile project + run: mix compile --warnings-as-errors + + - name: Check formatting + run: mix format --check-formatted + + - name: Run Credo + run: mix credo --strict + - name: Run tests run: mix test From b8ca10d9b8d7219878c18a080c9ba822d65ade40 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 17 Sep 2025 11:29:13 -0600 Subject: [PATCH 04/10] Fix division by zero error in low bitrate detection - Add bitrate > 0 guard to low_bitrate?/1 function to prevent division by zero - Add comprehensive test for zero bitrate HDR videos - Ensure zero bitrate videos are properly handled and validated - Test confirms zero bitrate fails validation as expected (invalid bitrate) --- lib/reencodarr/media/video_state_machine.ex | 2 +- .../media/video_state_machine_test.exs | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/reencodarr/media/video_state_machine.ex b/lib/reencodarr/media/video_state_machine.ex index 644a53d0..afd4c8eb 100644 --- a/lib/reencodarr/media/video_state_machine.ex +++ b/lib/reencodarr/media/video_state_machine.ex @@ -422,7 +422,7 @@ defmodule Reencodarr.Media.VideoStateMachine do # Check if video has low bitrate (less than 5 Mbps = 5,000,000 bps) AND is HDR and should skip encoding defp low_bitrate?(%Video{bitrate: bitrate, hdr: hdr}) - when is_integer(bitrate) and not is_nil(hdr) do + when is_integer(bitrate) and bitrate > 0 and not is_nil(hdr) do bitrate < 5_000_000 end diff --git a/test/reencodarr/media/video_state_machine_test.exs b/test/reencodarr/media/video_state_machine_test.exs index adf2a0e4..cf6ef765 100644 --- a/test/reencodarr/media/video_state_machine_test.exs +++ b/test/reencodarr/media/video_state_machine_test.exs @@ -319,6 +319,36 @@ defmodule Reencodarr.Media.VideoStateMachineTest do {:ok, updated_video} = Repo.update(changeset) assert updated_video.state == :analyzed end + + test "transitions zero bitrate HDR video to analyzed state (prevents division by zero)" do + # Create a video with zero bitrate and HDR - should not be considered low bitrate + {:ok, video} = + Fixtures.video_fixture(%{ + path: "/test/zero_bitrate_hdr_video.mkv", + size: 1_000_000_000, + # Zero bitrate - should not cause division by zero + bitrate: 0, + # HDR content + hdr: "HDR10", + width: 1920, + height: 1080, + video_codecs: ["h264"], + audio_codecs: ["aac"], + max_audio_channels: 2, + atmos: false, + state: :needs_analysis + }) + + # Attempt to transition to analyzed state + {:ok, changeset} = VideoStateMachine.transition_to_analyzed(video) + + # Should transition to analyzed (zero bitrate is invalid, not low bitrate) + assert changeset.changes.state == :analyzed + + # Apply the changeset - this should fail validation due to zero bitrate + {:error, failed_changeset} = Repo.update(changeset) + assert failed_changeset.errors[:bitrate] != nil + end end describe "valid_transitions/1" do From 9ecc3e0d61a14d835c7c8c21fc7f1004c69684cf Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 17 Sep 2025 11:30:35 -0600 Subject: [PATCH 05/10] Update Nix flake dependencies - Update nixpkgs to latest version for improved package availability --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 744ae973..28293a93 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1756397973, - "narHash": "sha256-v//jhxG9Xy68r0izjgq0eqZomCkMfTR0MzXREZr3794=", + "lastModified": 1758127371, + "narHash": "sha256-0VkGTLwl6E/MS+Yx0Eku2iwdTzgEsYxvjtjlMLq9Ekg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e393b89f3363c210fd36c1772f854e537026b396", + "rev": "d48aa7a2404f69bc748a86da230baa6eb308eeb6", "type": "github" }, "original": { From 83d9c6d5a7de1c27517ff4ef5743e84ecd901dec Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 17 Sep 2025 11:32:21 -0600 Subject: [PATCH 06/10] Fix apt cache permissions by excluding lock and partial files - Exclude /var/cache/apt/archives/partial and lock files from cache - These files have permission restrictions that prevent caching - Maintains apt package caching while avoiding permission errors --- .github/workflows/elixir.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 56bd6423..6c3ef073 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -27,7 +27,10 @@ jobs: - name: Cache apt packages uses: actions/cache@v4 with: - path: /var/cache/apt/archives + path: | + /var/cache/apt/archives + !/var/cache/apt/archives/partial + !/var/cache/apt/archives/lock key: ${{ runner.os }}-apt-${{ hashFiles('.github/workflows/elixir.yml') }} restore-keys: ${{ runner.os }}-apt- From dc9161875dd17acaee14077e865a88ca3a2192d3 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 17 Sep 2025 11:43:24 -0600 Subject: [PATCH 07/10] Restore apt caching with proper user-writable directory - Cache apt packages to ~/apt-cache (user-writable directory) - Configure apt to use custom cache directory via APT_CONFIG - Avoids permission issues with system directories - Maintains performance benefits of package caching --- .github/workflows/elixir.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 6c3ef073..5ccdd300 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -27,10 +27,7 @@ jobs: - name: Cache apt packages uses: actions/cache@v4 with: - path: | - /var/cache/apt/archives - !/var/cache/apt/archives/partial - !/var/cache/apt/archives/lock + path: ~/apt-cache key: ${{ runner.os }}-apt-${{ hashFiles('.github/workflows/elixir.yml') }} restore-keys: ${{ runner.os }}-apt- @@ -43,6 +40,12 @@ jobs: - name: Install additional tools run: | + # Create local apt cache directory and configure apt to use it + mkdir -p ~/apt-cache + export APT_CONFIG_DIR=$(mktemp -d) + echo 'Dir::Cache::Archives "'"$HOME/apt-cache"'";' > "$APT_CONFIG_DIR/99cache" + sudo APT_CONFIG="$APT_CONFIG_DIR/99cache" apt-get update + sudo APT_CONFIG="$APT_CONFIG_DIR/99cache" apt-get install -y fd-find ffmpeg zstd mediainfo sudo apt-get update sudo apt-get install -y fd-find ffmpeg zstd mediainfo From ff7274795e124670e21139feba8927af28881e22 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 17 Sep 2025 11:49:13 -0600 Subject: [PATCH 08/10] Optimize apt installation by skipping man-db triggers - Skip installation of man pages and documentation to speed up package install - Add DEBIAN_FRONTEND=noninteractive to avoid interactive prompts - Use --no-install-recommends to reduce package bloat - Remove duplicate apt-get commands that were accidentally added - Should significantly reduce CI build time for package installation --- .github/workflows/elixir.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 5ccdd300..f4f48f15 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -44,10 +44,13 @@ jobs: mkdir -p ~/apt-cache export APT_CONFIG_DIR=$(mktemp -d) echo 'Dir::Cache::Archives "'"$HOME/apt-cache"'";' > "$APT_CONFIG_DIR/99cache" + # Skip man-db triggers to speed up installation + sudo mkdir -p /etc/dpkg/dpkg.cfg.d + echo 'path-exclude=/usr/share/man/*' | sudo tee /etc/dpkg/dpkg.cfg.d/01_nodoc > /dev/null + echo 'path-exclude=/usr/share/doc/*' | sudo tee -a /etc/dpkg/dpkg.cfg.d/01_nodoc > /dev/null + export DEBIAN_FRONTEND=noninteractive sudo APT_CONFIG="$APT_CONFIG_DIR/99cache" apt-get update - sudo APT_CONFIG="$APT_CONFIG_DIR/99cache" apt-get install -y fd-find ffmpeg zstd mediainfo - sudo apt-get update - sudo apt-get install -y fd-find ffmpeg zstd mediainfo + sudo APT_CONFIG="$APT_CONFIG_DIR/99cache" apt-get install -y --no-install-recommends fd-find ffmpeg zstd mediainfo - name: Install ab-av1 if: steps.cache-ab-av1.outputs.cache-hit != 'true' From e94f0223abd3284c8c29014618b4d36b7957d2e9 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 17 Sep 2025 11:52:53 -0600 Subject: [PATCH 09/10] Replace manual apt caching with dedicated cache-apt-pkgs-action - Use awalsh128/cache-apt-pkgs-action@v1 for reliable apt package caching - Removes complex manual cache directory and permission handling - Eliminates need for custom APT_CONFIG and DEBIAN_FRONTEND setup - Action handles all optimization and caching automatically - Should resolve previous apt caching failures and improve CI reliability --- .github/workflows/elixir.yml | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index f4f48f15..effb21d9 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -24,12 +24,11 @@ jobs: elixir-version: '1.18.3' # [Required] Define the Elixir version otp-version: '27.3.4' # [Required] Define the Erlang/OTP version - - name: Cache apt packages - uses: actions/cache@v4 + - name: Install and cache apt packages + uses: awalsh128/cache-apt-pkgs-action@v1 with: - path: ~/apt-cache - key: ${{ runner.os }}-apt-${{ hashFiles('.github/workflows/elixir.yml') }} - restore-keys: ${{ runner.os }}-apt- + packages: fd-find ffmpeg zstd mediainfo + version: 1.0 - name: Cache ab-av1 binary id: cache-ab-av1 @@ -38,20 +37,6 @@ jobs: path: /usr/local/bin/ab-av1 key: ab-av1-v0.10.1 - - name: Install additional tools - run: | - # Create local apt cache directory and configure apt to use it - mkdir -p ~/apt-cache - export APT_CONFIG_DIR=$(mktemp -d) - echo 'Dir::Cache::Archives "'"$HOME/apt-cache"'";' > "$APT_CONFIG_DIR/99cache" - # Skip man-db triggers to speed up installation - sudo mkdir -p /etc/dpkg/dpkg.cfg.d - echo 'path-exclude=/usr/share/man/*' | sudo tee /etc/dpkg/dpkg.cfg.d/01_nodoc > /dev/null - echo 'path-exclude=/usr/share/doc/*' | sudo tee -a /etc/dpkg/dpkg.cfg.d/01_nodoc > /dev/null - export DEBIAN_FRONTEND=noninteractive - sudo APT_CONFIG="$APT_CONFIG_DIR/99cache" apt-get update - sudo APT_CONFIG="$APT_CONFIG_DIR/99cache" apt-get install -y --no-install-recommends fd-find ffmpeg zstd mediainfo - - name: Install ab-av1 if: steps.cache-ab-av1.outputs.cache-hit != 'true' run: | From e870f6b4eb2eddc0458404ccc8f52a12f8703f1e Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 17 Sep 2025 11:59:05 -0600 Subject: [PATCH 10/10] Remove PostgreSQL setup from CI workflow - Remove PostgreSQL installation and setup steps since migrated to SQLite - Remove PostgreSQL wait/ready check that was adding unnecessary delay - SQLite databases are created automatically when tests run - Should further reduce CI build time and complexity --- .github/workflows/elixir.yml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index effb21d9..a7bcf279 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -68,21 +68,6 @@ jobs: - name: Compile dependencies run: mix deps.compile - - name: Set up PostgreSQL - uses: harmon758/postgresql-action@v1 - with: - postgresql version: '15' - postgresql db: reencodarr_test - postgresql user: postgres - postgresql password: postgres - - - name: Wait for PostgreSQL - run: | - for i in {1..30}; do - pg_isready -h localhost -p 5432 -U postgres && break - sleep 1 - done - - name: Set MIX_ENV to test run: echo "MIX_ENV=test" >> $GITHUB_ENV