diff --git a/.gitignore b/.gitignore index b7a7ba40..b134fb86 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ config/dev.overrides.exs .expert/ .envrc + +.serena/cache/ diff --git a/.serena/memories/conventions.md b/.serena/memories/conventions.md new file mode 100644 index 00000000..6e8f8a22 --- /dev/null +++ b/.serena/memories/conventions.md @@ -0,0 +1,8 @@ +# Conventions + +- Use existing state-machine helpers for video state transitions when available. +- Treat `:encoded` videos as protected during sync; do not reset encoded state or discard chosen VMAF/original-size data. +- `Reencodarr.AbAv1.OutputParser` is the shared parser for ab-av1 output; keep encode and CRF-search parsing aligned there. +- `Reencodarr.Rules.build_args/4` is the central encode/CRF-search argument builder. +- Service clients live under `lib/reencodarr/services/` and follow existing CarReq retry/fuse patterns. +- Paginated list LiveViews use URL params plus `ReencodarrWeb.Live.FlopList`; list APIs return `{items, %Flop.Meta{}}`. \ No newline at end of file diff --git a/.serena/memories/core.md b/.serena/memories/core.md new file mode 100644 index 00000000..7200d02d --- /dev/null +++ b/.serena/memories/core.md @@ -0,0 +1,8 @@ +# Core + +- Phoenix app for Reencodarr media transcoding; web modules under `lib/reencodarr_web`, domain modules under `lib/reencodarr`. +- SQLite via `Reencodarr.Repo`; keep DB tuning centralized in `config/config.exs`. +- Broadway/local worker supervisors are disabled in tests via `config :reencodarr, env: :test` and `Application.worker_children/0`. +- Existing app invariants and live-debug commands live in `AGENTS.md`; prefer repo evidence, logs, diagnostics, and tests over guessing. + +Related memories: commands in `mem:suggested_commands`, done gates in `mem:task_completion`, style in `mem:conventions`, stack in `mem:tech_stack`. \ No newline at end of file diff --git a/.serena/memories/memory_maintenance.md b/.serena/memories/memory_maintenance.md new file mode 100644 index 00000000..6f84514d --- /dev/null +++ b/.serena/memories/memory_maintenance.md @@ -0,0 +1,33 @@ +# Memory Maintenance + +## Discovery Model + +- Core principle: progressive discovery through references, building a graph of memories. +- Initially, agents are provided with the list of all memories (names only). +- Agents should read `mem:core` as the top-level entry point (graph root). + This memory should contain references to other memories covering major project domains. + The referenced memories shall, in turn, shall contain references to even more specific memories, and so on. + The depth of the graph shall depend on the project complexity. +- Use topics/folders to group related memories in order to make the content structure explicit. + Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc. +- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`. + The surrounding text should clearly indicate when to read the memory/which content to expect. + The text should provide more precise guidance than the memory name alone, + i.e. avoid a reference like "frontend debugging: `mem:frontend/debugging` and instead make clear which aspects of frontend debugging are covered. +- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory. + +## Style + +Dense agent notes, not prose docs. Prefer invariants, terse bullets. +Avoid obvious context, rationale, and examples unless they prevent likely mistakes. +Keep guidance durable and generalizable, not task-local. + +## Add/update threshold + +Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future. +Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon. + +## Maintenance Actions + +- Renaming memories: References are updated automatically if handled via Serena's memory rename tool. +- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report. \ No newline at end of file diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md new file mode 100644 index 00000000..2aa67f2a --- /dev/null +++ b/.serena/memories/suggested_commands.md @@ -0,0 +1,9 @@ +# Suggested Commands + +- `nix develop -c mix setup` installs deps, creates/migrates DB, and builds assets. +- `nix develop -c mix test` runs the full test suite. +- `nix develop -c mix test path/to/test.exs` runs focused tests. +- `nix develop -c mix compile --warnings-as-errors` catches compile regressions. +- `nix develop -c mix credo --strict` runs lint. +- `nix develop -c mix format` formats; `nix develop -c mix format --check-formatted` verifies formatting. +- `bin/rpc 'Reencodarr.Diagnostics.status()'` and sibling diagnostics helpers inspect the running system. \ No newline at end of file diff --git a/.serena/memories/task_completion.md b/.serena/memories/task_completion.md new file mode 100644 index 00000000..389c47a5 --- /dev/null +++ b/.serena/memories/task_completion.md @@ -0,0 +1,6 @@ +# Task Completion + +- Prefer focused tests while iterating, then run broader gates when practical. +- Standard finish gates: `nix develop -c mix test`, `nix develop -c mix compile --warnings-as-errors`, `nix develop -c mix credo --strict`, and `nix develop -c mix format --check-formatted`. +- Run `nix develop -c mix format` before committing Elixir changes if formatting may have changed. +- For live runtime/debug claims, verify with `bin/rpc` diagnostics rather than inferring from source. \ No newline at end of file diff --git a/.serena/memories/tech_stack.md b/.serena/memories/tech_stack.md new file mode 100644 index 00000000..2e4a39be --- /dev/null +++ b/.serena/memories/tech_stack.md @@ -0,0 +1,8 @@ +# Tech Stack + +- Elixir `>= 1.20.0 and < 1.21.0` Phoenix app. +- Phoenix `~> 1.8.8`, LiveView `~> 1.2.5`, Bandit `~> 1.12`. +- SQLite through `ecto_sqlite3`/`exqlite`; Ecto SQL `~> 3.14`. +- Uses Nix dev shell for repo commands: `nix develop -c ...`. +- Assets use Tailwind package `~> 0.5.1` and esbuild `~> 0.10` via Mix aliases. +- Test helpers include ExUnit, ConnCase/DataCase, StreamData, Floki/LazyHTML, and meck where existing tests mock external commands. \ No newline at end of file diff --git a/config/config.exs b/config/config.exs index ab9602bb..d5d7ba30 100644 --- a/config/config.exs +++ b/config/config.exs @@ -12,7 +12,15 @@ config :reencodarr, generators: [timestamp_type: :utc_datetime], env: config_env(), dashboard_stats_query_timeout_ms: 500, - dashboard_queue_query_timeout_ms: 5_000 + dashboard_queue_query_timeout_ms: 5_000, + distributed_worker_enabled: true, + worker_chunk_size_bytes: 134_217_728, + worker_transfer_window: 1, + worker_retry_limit: 3, + worker_transfer_timeout_ms: 60_000, + worker_max_concurrent_transfers: 1, + worker_transfer_base_url: nil, + worker_transfer_token: nil # Shared database configuration - SQLite performance tuning config :reencodarr, Reencodarr.Repo, diff --git a/config/runtime.exs b/config/runtime.exs index a59b4375..ad8ce38f 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -20,6 +20,62 @@ if System.get_env("PHX_SERVER") do config :reencodarr, ReencodarrWeb.Endpoint, server: true end +parse_bool_env = fn + "false" -> false + "0" -> false + "no" -> false + _ -> true +end + +parse_int_env = fn name, default -> + case System.get_env(name) do + nil -> default + value -> String.to_integer(value) + end +end + +derive_token = fn env_name, label -> + System.get_env(env_name) + |> case do + nil -> + case System.get_env("SECRET_KEY_BASE") do + nil -> + nil + + secret_key_base -> + :crypto.mac(:hmac, :sha256, secret_key_base, label) + |> Base.url_encode64(padding: false) + end + + token -> + token + end +end + +worker_token = derive_token.("REENCODARR_WORKER_TOKEN", "ab-av1-worker-token") + +worker_transfer_token = + derive_token.("REENCODARR_WORKER_TRANSFER_TOKEN", "ab-av1-worker-transfer-token") + +if worker_token do + config :reencodarr, :worker_token, worker_token +end + +if worker_transfer_token do + config :reencodarr, :worker_transfer_token, worker_transfer_token +end + +config :reencodarr, + distributed_worker_enabled: + parse_bool_env.(System.get_env("REENCODARR_DISTRIBUTED_WORKERS", "true")), + worker_chunk_size_bytes: parse_int_env.("REENCODARR_WORKER_CHUNK_SIZE_BYTES", 134_217_728), + worker_transfer_window: parse_int_env.("REENCODARR_WORKER_TRANSFER_WINDOW", 1), + worker_retry_limit: parse_int_env.("REENCODARR_WORKER_RETRY_LIMIT", 3), + worker_transfer_timeout_ms: parse_int_env.("REENCODARR_WORKER_TRANSFER_TIMEOUT_MS", 60_000), + worker_max_concurrent_transfers: + parse_int_env.("REENCODARR_WORKER_MAX_CONCURRENT_TRANSFERS", 1), + worker_transfer_base_url: System.get_env("REENCODARR_WORKER_TRANSFER_BASE_URL") + if config_env() == :prod do # Parse CHECK_ORIGIN env var: "false" disables, otherwise comma-separated origin list parse_check_origin = fn diff --git a/docs/distributed_worker_protocol.md b/docs/distributed_worker_protocol.md new file mode 100644 index 00000000..7dc874ae --- /dev/null +++ b/docs/distributed_worker_protocol.md @@ -0,0 +1,126 @@ +# Distributed CRF-search Websocket Protocol + +This document describes the current Reencodarr websocket contract for distributed +`ab-av1` CRF-search workers. + +## Versioning + +- Protocol version: `1` +- Transport: Phoenix websocket channel `workers:crf_search` +- Worker authentication: token-based socket connect, then per-worker channel join + +## Connection Flow + +1. Client connects to the websocket with the shared worker token. +2. Client joins `workers:crf_search`. +3. Client sends `announce`. +4. Server registers the worker session and returns `accepted: true`. +5. Client requests work with `pull_work` or `request_work`. +6. Server assigns work or returns `no_work`. + +## Announce + +Client payload: + +- `worker_id` +- `protocol_version` +- `version` +- `capabilities` + +Server accepts protocol version `1` and rejects unsupported versions. + +## Job Assignment + +Server reply: + +- `status: "job_assigned"` +- `job_id` +- `video_id` +- `source_name` +- `size_bytes` +- `chunk_size_bytes` +- `target_vmaf` + +The default chunk size is `134_217_728` bytes (128 MiB). + +## Media Transfer + +After assignment, the server streams the source file on the same websocket. + +### `transfer_started` + +Fields: + +- `status: "transfer_started"` +- `video_id` +- `transfer_id` +- `source_name` +- `size_bytes` +- `chunk_size_bytes` +- `total_bytes` +- `total_chunks` + +### `transfer_chunk` + +`transfer_chunk` is a binary Phoenix channel payload. It is not JSON and does +not base64-encode media bytes. + +Frame layout: + +- `magic`: 4 bytes, `RAV1` +- `version`: 1 byte, currently `1` +- `type`: 1 byte, currently `1` for transfer chunk +- `transfer_id_size`: 16-bit unsigned integer +- `video_id`: 64-bit unsigned integer +- `chunk_index`: 64-bit unsigned integer +- `total_chunks`: 64-bit unsigned integer +- `bytes_sent`: 64-bit unsigned integer +- `total_bytes`: 64-bit unsigned integer +- `crc32`: 32-bit unsigned integer +- `transfer_id`: `transfer_id_size` bytes +- `data`: raw chunk bytes + +Notes: + +- `chunk_index` starts at `0` and increments monotonically. +- `crc32` is computed from the raw chunk bytes. +- `data` is the raw file data for that chunk. + +### `transfer_complete` + +Fields: + +- `status: "transfer_complete"` +- `video_id` +- `transfer_id` +- `total_bytes` +- `total_chunks` + +### `transfer_failed` + +Fields: + +- `status: "transfer_failed"` +- `video_id` +- `transfer_id` +- `reason` + +Transfer failures are recorded as file-access failures on the server side. + +## Worker Events + +The channel also accepts these client-to-server events: + +- `heartbeat` +- `transfer_progress` +- `crf_search_progress` +- `crf_search_result` +- `crf_search_completed` +- `video_failed` + +## Terminal State Rules + +- `crf_search_completed` with `ok` marks the video searched and clears the active assignment. +- `crf_search_completed` with `cancelled` or `shutdown` returns the video to `analyzed`. +- `video_failed` records a structured failure and clears the active assignment. +- Disconnecting a worker with active CRF-search work requeues the video. diff --git a/lib/reencodarr/ab_av1/crf_search.ex b/lib/reencodarr/ab_av1/crf_search.ex index bf903fcc..90058213 100644 --- a/lib/reencodarr/ab_av1/crf_search.ex +++ b/lib/reencodarr/ab_av1/crf_search.ex @@ -1174,7 +1174,7 @@ defmodule Reencodarr.AbAv1.CrfSearch do "--input", video.path, "--min-vmaf", - Integer.to_string(vmaf_percent), + to_string(vmaf_percent), "--min-crf", to_string(min_crf), "--max-crf", diff --git a/lib/reencodarr/ab_av1/worker_config.ex b/lib/reencodarr/ab_av1/worker_config.ex new file mode 100644 index 00000000..f004c665 --- /dev/null +++ b/lib/reencodarr/ab_av1/worker_config.ex @@ -0,0 +1,63 @@ +defmodule Reencodarr.AbAv1.WorkerConfig do + @moduledoc """ + Runtime configuration for distributed ab-av1 workers. + """ + + @default_chunk_size_bytes 134_217_728 + + @spec enabled?() :: boolean() + def enabled? do + Application.get_env(:reencodarr, :distributed_worker_enabled, false) + end + + @spec enable() :: :ok + def enable do + Application.put_env(:reencodarr, :distributed_worker_enabled, true) + :ok + end + + @spec disable() :: :ok + def disable do + Application.put_env(:reencodarr, :distributed_worker_enabled, false) + :ok + end + + @spec chunk_size_bytes() :: pos_integer() + def chunk_size_bytes do + Application.get_env(:reencodarr, :worker_chunk_size_bytes, @default_chunk_size_bytes) + end + + @spec transfer_window() :: pos_integer() + def transfer_window do + Application.get_env(:reencodarr, :worker_transfer_window, 1) + end + + @spec retry_limit() :: non_neg_integer() + def retry_limit do + Application.get_env(:reencodarr, :worker_retry_limit, 3) + end + + @spec transfer_timeout_ms() :: pos_integer() + def transfer_timeout_ms do + Application.get_env(:reencodarr, :worker_transfer_timeout_ms, 60_000) + end + + @spec max_concurrent_transfers() :: pos_integer() + def max_concurrent_transfers do + Application.get_env(:reencodarr, :worker_max_concurrent_transfers, 1) + end + + @spec transfer_base_url() :: String.t() | nil + def transfer_base_url do + case Application.get_env(:reencodarr, :worker_transfer_base_url) do + url when is_binary(url) and url != "" -> String.trim_trailing(url, "/") + _ -> nil + end + end + + @spec transfer_token() :: String.t() | nil + def transfer_token do + Application.get_env(:reencodarr, :worker_transfer_token) || + Application.get_env(:reencodarr, :worker_token) + end +end diff --git a/lib/reencodarr/ab_av1/worker_job_state_machine.ex b/lib/reencodarr/ab_av1/worker_job_state_machine.ex new file mode 100644 index 00000000..ecdf3721 --- /dev/null +++ b/lib/reencodarr/ab_av1/worker_job_state_machine.ex @@ -0,0 +1,122 @@ +defmodule Reencodarr.AbAv1.WorkerJobStateMachine do + @moduledoc """ + State machine for one connected worker's current job phase. + """ + + require Logger + + @type phase :: :idle | :receiving_input | :input_ready | :crf_searching + + @valid_phases [:idle, :receiving_input, :input_ready, :crf_searching] + + @valid_transitions %{ + idle: [:receiving_input, :crf_searching], + receiving_input: [:receiving_input, :input_ready, :crf_searching, :idle], + input_ready: [:receiving_input, :crf_searching, :idle], + crf_searching: [:crf_searching, :receiving_input, :input_ready, :idle] + } + + @spec valid_phases() :: [phase()] + def valid_phases, do: @valid_phases + + @spec valid_transition?(phase(), phase()) :: boolean() + def valid_transition?(from_phase, to_phase) + when from_phase in @valid_phases and to_phase in @valid_phases do + to_phase == from_phase or to_phase in Map.fetch!(@valid_transitions, from_phase) + end + + def valid_transition?(_from_phase, _to_phase), do: false + + def transition(session, to_phase, attrs \\ %{}) + + @spec transition(map(), phase(), map()) :: {:ok, map()} | {:error, :invalid_worker_phase} + def transition(session, to_phase, attrs) when to_phase in @valid_phases do + from_phase = Map.get(session, :phase, :idle) + + if valid_transition?(from_phase, to_phase) do + {:ok, session |> Map.merge(attrs) |> Map.put(:phase, to_phase)} + else + Logger.warning("Invalid worker job transition from #{from_phase} to #{to_phase}") + {:error, :invalid_worker_phase} + end + end + + def transition(_session, _to_phase, _attrs), do: {:error, :invalid_worker_phase} + + @spec assign_video(map(), integer(), phase()) :: {:ok, map()} | {:error, :invalid_worker_phase} + def assign_video(session, video_id, phase) when is_integer(video_id) do + with :ok <- ensure_same_active_video(session, video_id) do + transition(session, phase, %{ + active_video_id: video_id, + transfer_progress: nil, + crf_search_progress: nil + }) + end + end + + @spec clear_video(map()) :: {:ok, map()} + def clear_video(session) do + {:ok, + %{session | active_video_id: nil, transfer_progress: nil, crf_search_progress: nil} + |> Map.put(:phase, :idle)} + end + + @spec set_transfer_progress(map(), map()) :: {:ok, map()} | {:error, :invalid_worker_phase} + def set_transfer_progress(session, progress), do: record_transfer_progress(session, progress) + + @spec record_transfer_progress(map(), map()) :: {:ok, map()} | {:error, :invalid_worker_phase} + def record_transfer_progress(%{phase: phase} = session, progress) + when phase in [:idle, :receiving_input, :input_ready, :crf_searching] do + with {:ok, video_id} <- progress_video_id(session, progress) do + phase = if transfer_complete?(progress), do: :input_ready, else: :receiving_input + + transition(session, phase, %{ + active_video_id: video_id, + transfer_progress: progress, + crf_search_progress: nil + }) + end + end + + def record_transfer_progress(_session, _progress), do: {:error, :invalid_worker_phase} + + @spec finish_transfer(map()) :: {:ok, map()} | {:error, :invalid_worker_phase} + def finish_transfer(%{active_video_id: nil} = session), do: clear_video(session) + + def finish_transfer(session) do + transition(session, :input_ready) + end + + @spec set_crf_search_progress(map(), map()) :: {:ok, map()} | {:error, :invalid_worker_phase} + def set_crf_search_progress(session, progress) do + with {:ok, video_id} <- progress_video_id(session, progress) do + transition(session, :crf_searching, %{ + active_video_id: video_id, + transfer_progress: nil, + crf_search_progress: progress + }) + end + end + + defp ensure_same_active_video(%{active_video_id: nil}, _video_id), do: :ok + defp ensure_same_active_video(%{active_video_id: video_id}, video_id), do: :ok + defp ensure_same_active_video(_session, _video_id), do: {:error, :invalid_worker_phase} + + defp progress_video_id(session, progress) do + progress_video_id = Map.get(progress, :video_id) + + case {session.active_video_id, progress_video_id} do + {video_id, video_id} when is_integer(video_id) -> {:ok, video_id} + {nil, video_id} when is_integer(video_id) -> {:ok, video_id} + {video_id, nil} when is_integer(video_id) -> {:ok, video_id} + _ -> {:error, :invalid_worker_phase} + end + end + + defp transfer_complete?(%{bytes_sent: bytes_sent, total_bytes: total_bytes}) + when is_integer(bytes_sent) and is_integer(total_bytes) and total_bytes > 0, + do: bytes_sent >= total_bytes + + defp transfer_complete?(%{percent: percent}) when is_number(percent), do: percent >= 100 + defp transfer_complete?(_progress), do: false +end diff --git a/lib/reencodarr/ab_av1/worker_protocol.ex b/lib/reencodarr/ab_av1/worker_protocol.ex new file mode 100644 index 00000000..4c5a288d --- /dev/null +++ b/lib/reencodarr/ab_av1/worker_protocol.ex @@ -0,0 +1,869 @@ +defmodule Reencodarr.AbAv1.WorkerProtocol do + @moduledoc """ + Server-side helpers for the ab-av1 worker websocket protocol. + """ + + alias Reencodarr.AbAv1.CrfSearch + alias Reencodarr.AbAv1.WorkerConfig + alias Reencodarr.Media.Video + + @crf_search_topic "workers:crf_search" + @supported_protocol_versions [1] + @transfer_chunk_magic "RAV1" + @transfer_chunk_frame_version 1 + @transfer_chunk_frame_type 1 + + defmodule Announcement do + @moduledoc false + + @enforce_keys [:worker_id, :protocol_version, :version, :capabilities] + defstruct [:worker_id, :protocol_version, :version, :capabilities] + + @type t :: %__MODULE__{ + worker_id: String.t(), + protocol_version: pos_integer(), + version: String.t(), + capabilities: map() + } + end + + defmodule TransferProgress do + @moduledoc false + + @enforce_keys [:video_id, :transfer_id, :percent, :bytes_sent, :total_bytes] + defstruct [ + :job_id, + :video_id, + :transfer_id, + :filename, + :percent, + :bytes_sent, + :total_bytes, + :bytes_per_second, + :eta, + :chunk_index, + :total_chunks + ] + + @type t :: %__MODULE__{ + job_id: String.t() | nil, + video_id: pos_integer(), + transfer_id: String.t(), + filename: String.t() | nil, + percent: number(), + bytes_sent: non_neg_integer(), + total_bytes: non_neg_integer(), + bytes_per_second: non_neg_integer() | nil, + eta: non_neg_integer() | nil, + chunk_index: non_neg_integer() | nil, + total_chunks: non_neg_integer() | nil + } + end + + defmodule CrfSearchProgress do + @moduledoc false + + @enforce_keys [:video_id, :percent] + defstruct [:video_id, :percent, :filename, :eta, :fps, :crf, :sample_num, :total_samples] + + @type t :: %__MODULE__{ + video_id: pos_integer(), + percent: number(), + filename: String.t() | nil, + eta: non_neg_integer() | nil, + fps: number() | nil, + crf: number() | nil, + sample_num: pos_integer() | nil, + total_samples: pos_integer() | nil + } + end + + defmodule CrfSearchResult do + @moduledoc false + + @enforce_keys [:video_id, :results] + defstruct [:video_id, :results] + + @type t :: %__MODULE__{ + video_id: pos_integer(), + results: [map()] + } + end + + defmodule FailureReport do + @moduledoc false + + @enforce_keys [:video_id, :stage, :category, :message] + defstruct [ + :video_id, + :stage, + :category, + :message, + :code, + :context, + :retriable, + :stderr_excerpt + ] + + @type t :: %__MODULE__{ + video_id: pos_integer(), + stage: atom(), + category: atom(), + message: String.t(), + code: String.t() | nil, + context: map(), + retriable: boolean() | nil, + stderr_excerpt: String.t() | nil + } + end + + defmodule Completion do + @moduledoc false + + @enforce_keys [:video_id, :result] + defstruct [:video_id, :result, :chosen_crf, results: []] + + @type t :: %__MODULE__{ + video_id: pos_integer(), + result: :ok | :cancelled | :shutdown | :failed | {:error, term()}, + chosen_crf: number() | nil, + results: [map()] + } + end + + @type video_id :: pos_integer() + @type percentage :: number() + @type crf_result :: %{ + required(:crf) => number(), + required(:score) => number(), + required(:percent) => number(), + optional(:size) => String.t() | nil, + optional(:time) => non_neg_integer() | nil, + optional(:params) => [String.t()], + optional(:target) => integer() | nil, + optional(:chosen) => boolean() + } + @type completion_result :: :ok | :cancelled | :shutdown | :failed | {:error, term()} + + @spec crf_search_topic() :: String.t() + def crf_search_topic, do: @crf_search_topic + + @spec supported_protocol_versions() :: [pos_integer()] + def supported_protocol_versions, do: @supported_protocol_versions + + @spec chunk_size_bytes() :: pos_integer() + def chunk_size_bytes, do: WorkerConfig.chunk_size_bytes() + + @spec valid_topic?(String.t()) :: boolean() + def valid_topic?(@crf_search_topic), do: true + def valid_topic?(_topic), do: false + + @spec supported_protocol_version?(integer()) :: boolean() + def supported_protocol_version?(protocol_version), + do: protocol_version in @supported_protocol_versions + + @spec parse_announcement(map()) :: {:ok, Announcement.t()} | {:error, :invalid_announcement} + def parse_announcement(%{ + "worker_id" => worker_id, + "protocol_version" => protocol_version, + "version" => version, + "capabilities" => capabilities + }) + when is_binary(worker_id) and is_integer(protocol_version) and is_binary(version) and + is_map(capabilities) do + {:ok, + %Announcement{ + worker_id: worker_id, + protocol_version: protocol_version, + version: version, + capabilities: capabilities + }} + end + + def parse_announcement(_payload), do: {:error, :invalid_announcement} + + @spec parse_transfer_progress(map()) :: {:ok, TransferProgress.t()} | {:error, atom()} + def parse_transfer_progress(payload) when is_map(payload) do + with {:ok, video_id} <- + required_integer(payload, [:video_id, "video_id"], :invalid_transfer_progress), + {:ok, transfer_id} <- + required_string( + payload, + [:transfer_id, "transfer_id", :job_id, "job_id"], + :invalid_transfer_progress + ), + {:ok, percent} <- + required_number(payload, [:percent, "percent"], :invalid_transfer_progress) do + {:ok, + %TransferProgress{ + job_id: optional_string(payload, [:job_id, "job_id"]), + video_id: video_id, + transfer_id: transfer_id, + filename: optional_string(payload, [:filename, "filename"]), + percent: percent, + bytes_sent: + optional_integer(payload, [ + :bytes_sent, + "bytes_sent", + :received_bytes, + "received_bytes", + :transferred_bytes, + "transferred_bytes" + ]) || 0, + total_bytes: + optional_integer(payload, [ + :total_bytes, + "total_bytes", + :expected_bytes, + "expected_bytes" + ]) || 0, + bytes_per_second: + optional_integer(payload, [ + :bytes_per_second, + "bytes_per_second" + ]), + eta: optional_integer(payload, [:eta, "eta"]), + chunk_index: optional_integer(payload, [:chunk_index, "chunk_index"]), + total_chunks: optional_integer(payload, [:total_chunks, "total_chunks"]) + }} + end + end + + def parse_transfer_progress(_payload), do: {:error, :invalid_transfer_progress} + + @spec parse_crf_search_progress(map()) :: {:ok, CrfSearchProgress.t()} | {:error, atom()} + def parse_crf_search_progress(payload) when is_map(payload) do + with {:ok, video_id} <- + required_integer(payload, [:video_id, "video_id"], :invalid_crf_search_progress), + {:ok, percent} <- + required_number(payload, [:percent, "percent"], :invalid_crf_search_progress) do + {:ok, + %CrfSearchProgress{ + video_id: video_id, + percent: percent, + filename: optional_string(payload, [:filename, "filename"]), + eta: optional_integer(payload, [:eta, "eta"]), + fps: optional_number(payload, [:fps, "fps"]), + crf: optional_number(payload, [:crf, "crf"]), + sample_num: optional_integer(payload, [:sample_num, "sample_num"]), + total_samples: optional_integer(payload, [:total_samples, "total_samples"]) + }} + end + end + + def parse_crf_search_progress(_payload), do: {:error, :invalid_crf_search_progress} + + @spec parse_crf_search_result(map()) :: {:ok, CrfSearchResult.t()} | {:error, atom()} + def parse_crf_search_result(payload) when is_map(payload) do + with {:ok, video_id} <- + required_integer(payload, [:video_id, "video_id"], :invalid_crf_search_result), + {:ok, results} <- parse_result_batch(payload) do + {:ok, %CrfSearchResult{video_id: video_id, results: results}} + end + end + + def parse_crf_search_result(_payload), do: {:error, :invalid_crf_search_result} + + @spec parse_failure_report(map()) :: {:ok, FailureReport.t()} | {:error, atom()} + def parse_failure_report(payload) when is_map(payload) do + with {:ok, video_id} <- + required_integer(payload, [:video_id, "video_id"], :invalid_failure_report), + {:ok, stage} <- parse_failure_stage(payload), + {:ok, category} <- parse_failure_category(payload), + {:ok, message} <- + required_string(payload, [:message, "message"], :invalid_failure_report) do + {:ok, + %FailureReport{ + video_id: video_id, + stage: stage, + category: category, + message: message, + code: optional_string(payload, [:code, "code"]), + context: optional_map(payload, [:context, "context"]) || %{}, + retriable: optional_boolean(payload, [:retriable, "retriable"]), + stderr_excerpt: optional_string(payload, [:stderr_excerpt, "stderr_excerpt"]) + }} + end + end + + def parse_failure_report(_payload), do: {:error, :invalid_failure_report} + + @spec parse_completion(map()) :: {:ok, Completion.t()} | {:error, atom()} + def parse_completion(payload) when is_map(payload) do + with {:ok, video_id} <- + required_integer(payload, [:video_id, "video_id"], :invalid_completion_result), + {:ok, result} <- parse_completion_result(payload) do + {:ok, + %Completion{ + video_id: video_id, + result: result, + chosen_crf: optional_number(payload, [:chosen_crf, "chosen_crf"]), + results: parse_optional_completion_results(payload) + }} + end + end + + def parse_completion(_payload), do: {:error, :invalid_completion_result} + + @spec accepted(pos_integer()) :: map() + def accepted(protocol_version), do: %{accepted: true, protocol_version: protocol_version} + + @spec transfer_started( + Video.t(), + String.t(), + pos_integer(), + non_neg_integer(), + non_neg_integer() + ) :: + map() + def transfer_started( + %Video{id: video_id, path: path, size: size}, + transfer_id, + chunk_size_bytes, + total_bytes, + total_chunks + ) + when is_integer(video_id) and is_binary(path) do + %{ + status: "transfer_started", + video_id: video_id, + transfer_id: transfer_id, + source_name: Path.basename(path), + size_bytes: size || 0, + chunk_size_bytes: chunk_size_bytes, + total_bytes: total_bytes, + total_chunks: total_chunks + } + end + + @spec transfer_chunk( + Video.t(), + String.t(), + non_neg_integer(), + non_neg_integer(), + non_neg_integer(), + non_neg_integer(), + binary() + ) :: {:binary, binary()} + def transfer_chunk( + %Video{id: video_id}, + transfer_id, + chunk_index, + total_chunks, + bytes_sent, + total_bytes, + chunk + ) + when is_integer(video_id) and is_binary(transfer_id) and is_integer(chunk_index) and + is_integer(total_chunks) and is_integer(bytes_sent) and is_integer(total_bytes) and + is_binary(chunk) do + {:binary, + transfer_chunk_frame( + video_id, + transfer_id, + chunk_index, + total_chunks, + bytes_sent, + total_bytes, + chunk + )} + end + + @spec parse_transfer_chunk_frame(binary()) :: {:ok, map()} | {:error, atom()} + def parse_transfer_chunk_frame( + <<@transfer_chunk_magic, @transfer_chunk_frame_version, @transfer_chunk_frame_type, + transfer_id_size::16, video_id::64, chunk_index::64, total_chunks::64, bytes_sent::64, + total_bytes::64, crc32::32, transfer_id::binary-size(transfer_id_size), data::binary>> + ) do + if :erlang.crc32(data) == crc32 do + {:ok, + %{ + video_id: video_id, + transfer_id: transfer_id, + chunk_index: chunk_index, + total_chunks: total_chunks, + bytes_sent: bytes_sent, + total_bytes: total_bytes, + crc32: crc32, + data: data + }} + else + {:error, :crc_mismatch} + end + end + + def parse_transfer_chunk_frame( + <<@transfer_chunk_magic, @transfer_chunk_frame_version, _frame_type, _rest::binary>> + ), + do: {:error, :unsupported_transfer_chunk_frame_type} + + def parse_transfer_chunk_frame(<<@transfer_chunk_magic, _version, _rest::binary>>), + do: {:error, :unsupported_transfer_chunk_frame_version} + + def parse_transfer_chunk_frame(_frame), do: {:error, :invalid_transfer_chunk_frame} + + defp transfer_chunk_frame( + video_id, + transfer_id, + chunk_index, + total_chunks, + bytes_sent, + total_bytes, + chunk + ) do + transfer_id_size = byte_size(transfer_id) + crc32 = :erlang.crc32(chunk) + + << + @transfer_chunk_magic, + @transfer_chunk_frame_version, + @transfer_chunk_frame_type, + transfer_id_size::16, + video_id::64, + chunk_index::64, + total_chunks::64, + bytes_sent::64, + total_bytes::64, + crc32::32, + transfer_id::binary, + chunk::binary + >> + end + + @spec transfer_complete(Video.t(), String.t(), non_neg_integer(), non_neg_integer()) :: map() + def transfer_complete(%Video{id: video_id}, transfer_id, total_bytes, total_chunks) + when is_integer(video_id) and is_binary(transfer_id) do + %{ + status: "transfer_complete", + video_id: video_id, + transfer_id: transfer_id, + total_bytes: total_bytes, + total_chunks: total_chunks + } + end + + @spec transfer_failed(Video.t(), String.t(), String.t()) :: map() + def transfer_failed(%Video{id: video_id}, transfer_id, reason) + when is_integer(video_id) and is_binary(transfer_id) and is_binary(reason) do + %{ + status: "transfer_failed", + video_id: video_id, + transfer_id: transfer_id, + reason: reason + } + end + + @spec event_ack(String.t()) :: map() + def event_ack(event_name), do: %{accepted: true, event: event_name} + + @spec no_work() :: map() + def no_work, do: %{status: "no_work"} + + @spec work_assigned(Video.t(), number()) :: map() + def work_assigned(%Video{id: video_id, path: path, size: size} = video, target_vmaf) + when is_integer(video_id) and is_binary(path) do + video + |> maybe_put_transfer(%{ + status: "job_assigned", + job_id: Integer.to_string(video_id), + video_id: video_id, + source_name: Path.basename(path), + size_bytes: size || 0, + chunk_size_bytes: chunk_size_bytes(), + target_vmaf: target_vmaf, + crf_search_args: CrfSearch.build_crf_search_args(video, target_vmaf) + }) + end + + def work_in_progress(%Video{id: video_id, path: path, size: size} = video, target_vmaf) + when is_integer(video_id) and is_binary(path) do + video + |> maybe_put_transfer(%{ + status: "job_in_progress", + job_id: Integer.to_string(video_id), + video_id: video_id, + source_name: Path.basename(path), + size_bytes: size || 0, + target_vmaf: target_vmaf, + crf_search_args: CrfSearch.build_crf_search_args(video, target_vmaf) + }) + end + + defp maybe_put_transfer(%Video{id: video_id}, payload) do + with base_url when is_binary(base_url) <- WorkerConfig.transfer_base_url(), + token when is_binary(token) <- WorkerConfig.transfer_token() do + Map.put(payload, :transfer, %{ + url: "#{base_url}/workers/files/#{video_id}", + auth: %{ + scheme: "bearer", + header: "authorization", + value: "Bearer #{token}" + } + }) + else + _ -> payload + end + end + + @spec heartbeat_ack(DateTime.t()) :: map() + def heartbeat_ack(last_seen_at), + do: %{accepted: true, last_seen_at: DateTime.to_iso8601(last_seen_at)} + + @spec parse_resource_usage(map()) :: map() | nil + def parse_resource_usage(payload) when is_map(payload) do + [ + cpu_percent: optional_number(payload, [:cpu_percent, "cpu_percent"]), + memory_bytes: + optional_integer(payload, [ + :memory_bytes, + "memory_bytes", + :memory_rss_bytes, + "memory_rss_bytes" + ]), + memory_total_bytes: + optional_integer(payload, [ + :memory_total_bytes, + "memory_total_bytes", + :total_memory_bytes, + "total_memory_bytes" + ]), + disk_free_bytes: + optional_integer(payload, [ + :disk_free_bytes, + "disk_free_bytes", + :free_disk_bytes, + "free_disk_bytes" + ]), + disk_total_bytes: + optional_integer(payload, [ + :disk_total_bytes, + "disk_total_bytes", + :total_disk_bytes, + "total_disk_bytes" + ]) + ] + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + |> case do + usage when usage == %{} -> nil + usage -> usage + end + end + + def parse_resource_usage(_payload), do: nil + + @spec error( + :duplicate_worker_id + | :invalid_announcement + | :invalid_session_attrs + | :invalid_transfer_progress + | :invalid_crf_search_progress + | :invalid_crf_search_result + | :invalid_failure_report + | :invalid_completion_result + | :unsupported_protocol_version + | :unsupported_event + | :unknown_worker_session + | :unauthorized + ) :: map() + def error(:duplicate_worker_id), do: %{reason: "duplicate_worker_id"} + + def error(:invalid_announcement), do: %{reason: "invalid_announcement"} + def error(:invalid_session_attrs), do: %{reason: "invalid_session_attrs"} + def error(:invalid_transfer_progress), do: %{reason: "invalid_transfer_progress"} + def error(:invalid_crf_search_progress), do: %{reason: "invalid_crf_search_progress"} + def error(:invalid_crf_search_result), do: %{reason: "invalid_crf_search_result"} + def error(:invalid_failure_report), do: %{reason: "invalid_failure_report"} + def error(:invalid_completion_result), do: %{reason: "invalid_completion_result"} + + def error(:unsupported_protocol_version) do + %{ + reason: "unsupported_protocol_version", + supported_protocol_versions: @supported_protocol_versions + } + end + + def error(:unsupported_event), do: %{reason: "unsupported_event"} + def error(:unknown_worker_session), do: %{reason: "unknown_worker_session"} + def error(:unauthorized), do: %{reason: "unauthorized"} + + defp parse_single_crf_result(payload) when is_map(payload) do + with {:ok, crf} <- required_number(payload, [:crf, "crf"], :invalid_crf_search_result), + {:ok, score} <- + required_number( + payload, + [:score, "score", :vmaf_score, "vmaf_score"], + :invalid_crf_search_result + ), + {:ok, percent} <- + required_number( + payload, + [ + :percent, + "percent", + :vmaf_percentile, + "vmaf_percentile", + :encode_percent, + "encode_percent" + ], + :invalid_crf_search_result + ) do + {:ok, + %{ + crf: crf, + score: score, + percent: percent, + size: optional_size(payload), + time: optional_time(payload), + params: optional_params(payload), + target: optional_integer(payload, [:target, "target"]), + chosen: optional_boolean(payload, [:chosen, "chosen"]) + }} + end + end + + defp parse_single_crf_result(_payload), do: {:error, :invalid_crf_search_result} + + defp parse_result_batch(payload) do + case fetch_any(payload, [:results, "results"]) do + nil -> + parse_single_crf_result(payload) + |> wrap_single_result() + + results when is_list(results) -> + parse_result_list(results) + + _ -> + {:error, :invalid_crf_search_result} + end + end + + defp parse_optional_completion_results(payload) do + case parse_result_batch(payload) do + {:ok, results} -> results + {:error, _reason} -> [] + end + end + + defp parse_result_list(results) do + results + |> Enum.reduce_while({:ok, []}, fn result, {:ok, acc} -> + case parse_single_crf_result(result) do + {:ok, parsed_result} -> {:cont, {:ok, [parsed_result | acc]}} + {:error, _} = error -> {:halt, error} + end + end) + |> case do + {:ok, parsed_results} -> {:ok, Enum.reverse(parsed_results)} + {:error, _} = error -> error + end + end + + defp wrap_single_result({:ok, result}), do: {:ok, [result]} + defp wrap_single_result({:error, _} = error), do: error + + defp parse_completion_result(payload) do + case Map.get(payload, :result, Map.get(payload, "result")) do + value when value in [:ok, "ok"] -> {:ok, :ok} + value when value in [:cancelled, :canceled, "cancelled", "canceled"] -> {:ok, :cancelled} + value when value in [:shutdown, "shutdown"] -> {:ok, :shutdown} + value when value in [:failed, "failed"] -> {:ok, :failed} + {:error, _} = error -> {:ok, error} + %{"error" => reason} -> {:ok, {:error, reason}} + %{error: reason} -> {:ok, {:error, reason}} + _ -> {:error, :invalid_completion_result} + end + end + + defp parse_failure_stage(payload) do + case normalize_stage(Map.get(payload, :stage, Map.get(payload, "stage"))) do + {:ok, stage} -> {:ok, stage} + :error -> {:error, :invalid_failure_report} + end + end + + defp parse_failure_category(payload) do + case normalize_category(Map.get(payload, :category, Map.get(payload, "category"))) do + {:ok, category} -> {:ok, category} + :error -> {:error, :invalid_failure_report} + end + end + + defp normalize_stage(stage) when stage in [:analysis, :crf_search, :encoding, :post_process], + do: {:ok, stage} + + defp normalize_stage("analysis"), do: {:ok, :analysis} + defp normalize_stage("crf_search"), do: {:ok, :crf_search} + defp normalize_stage("encoding"), do: {:ok, :encoding} + defp normalize_stage("post_process"), do: {:ok, :post_process} + defp normalize_stage(_), do: :error + + defp normalize_category(category) + when category in [ + :file_access, + :mediainfo_parsing, + :validation, + :vmaf_calculation, + :crf_optimization, + :size_limits, + :preset_retry, + :process_failure, + :resource_exhaustion, + :codec_issues, + :timeout, + :file_operations, + :sync_integration, + :cleanup, + :configuration, + :system_environment, + :unknown + ], + do: {:ok, category} + + defp normalize_category("file_access"), do: {:ok, :file_access} + defp normalize_category("mediainfo_parsing"), do: {:ok, :mediainfo_parsing} + defp normalize_category("validation"), do: {:ok, :validation} + defp normalize_category("vmaf_calculation"), do: {:ok, :vmaf_calculation} + defp normalize_category("crf_optimization"), do: {:ok, :crf_optimization} + defp normalize_category("size_limits"), do: {:ok, :size_limits} + defp normalize_category("preset_retry"), do: {:ok, :preset_retry} + defp normalize_category("process_failure"), do: {:ok, :process_failure} + defp normalize_category("resource_exhaustion"), do: {:ok, :resource_exhaustion} + defp normalize_category("codec_issues"), do: {:ok, :codec_issues} + defp normalize_category("timeout"), do: {:ok, :timeout} + defp normalize_category("file_operations"), do: {:ok, :file_operations} + defp normalize_category("sync_integration"), do: {:ok, :sync_integration} + defp normalize_category("cleanup"), do: {:ok, :cleanup} + defp normalize_category("configuration"), do: {:ok, :configuration} + defp normalize_category("system_environment"), do: {:ok, :system_environment} + defp normalize_category("unknown"), do: {:ok, :unknown} + defp normalize_category(_), do: :error + + defp required_integer(payload, keys, error) do + case fetch_any(payload, keys) do + value when is_integer(value) and value > 0 -> {:ok, value} + _ -> {:error, error} + end + end + + defp required_number(payload, keys, error) do + case fetch_any(payload, keys) do + value when is_number(value) -> {:ok, value} + _ -> {:error, error} + end + end + + defp required_string(payload, keys, error) do + case fetch_any(payload, keys) do + value when is_binary(value) and value != "" -> {:ok, value} + _ -> {:error, error} + end + end + + defp optional_string(payload, keys) do + case fetch_any(payload, keys) do + value when is_binary(value) -> value + _ -> nil + end + end + + defp optional_number(payload, keys) do + case fetch_any(payload, keys) do + value when is_number(value) -> value + _ -> nil + end + end + + defp optional_integer(payload, keys) do + case fetch_any(payload, keys) do + value when is_integer(value) and value >= 0 -> value + _ -> nil + end + end + + defp optional_boolean(payload, keys) do + case fetch_any(payload, keys) do + value when is_boolean(value) -> value + _ -> nil + end + end + + defp optional_map(payload, keys) do + case fetch_any(payload, keys) do + value when is_map(value) -> value + _ -> nil + end + end + + defp optional_params(payload) do + case fetch_any(payload, [:params, "params"]) do + value when is_list(value) -> Enum.map(value, &to_string/1) + value when is_binary(value) -> [value] + _ -> [] + end + end + + defp optional_size(payload) do + case fetch_any(payload, [:size, "size"]) do + value when is_binary(value) -> value + value when is_number(value) -> format_size(value, payload) + _ -> optional_predicted_size(payload) + end + end + + defp format_size(size, payload) do + case optional_size_unit(payload) do + nil -> "#{size}" + unit -> "#{size} #{unit}" + end + end + + defp optional_predicted_size(payload) do + case optional_number(payload, [ + :predicted_size, + "predicted_size", + :predicted_encode_size, + "predicted_encode_size" + ]) do + nil -> nil + size -> format_size(size, payload) + end + end + + defp optional_size_unit(payload) do + optional_string(payload, [:size_unit, "size_unit"]) || + optional_string(payload, [:unit, "unit"]) + end + + defp optional_time(payload) do + case fetch_any(payload, [:time, "time"]) do + value when is_integer(value) and value >= 0 -> + value + + value when is_number(value) -> + round(value) + + _ -> + case {optional_number(payload, [ + :time_taken, + "time_taken", + :predicted_encode_time_secs, + "predicted_encode_time_secs" + ]), optional_string(payload, [:time_unit, "time_unit"])} do + {nil, _} -> nil + {time_taken, unit} when unit in [nil, ""] -> round(time_taken) + {time_taken, unit} -> round(Reencodarr.Core.Time.to_seconds(time_taken, unit)) + end + end + end + + defp fetch_any(payload, keys) do + Enum.find_value(keys, fn key -> + if Map.has_key?(payload, key), do: {:ok, Map.fetch!(payload, key)} + end) + |> case do + {:ok, value} -> value + nil -> nil + end + end +end diff --git a/lib/reencodarr/ab_av1/worker_sessions.ex b/lib/reencodarr/ab_av1/worker_sessions.ex new file mode 100644 index 00000000..b50411fe --- /dev/null +++ b/lib/reencodarr/ab_av1/worker_sessions.ex @@ -0,0 +1,555 @@ +defmodule Reencodarr.AbAv1.WorkerSessions do + @moduledoc """ + Tracks connected ab-av1 worker websocket sessions. + """ + + use GenServer + + alias Reencodarr.AbAv1.WorkerJobStateMachine + alias Reencodarr.Dashboard.Events + alias Reencodarr.Media + alias Reencodarr.Media.VideoStateMachine + + @by_server_table :reencodarr_worker_sessions_by_server + @by_client_table :reencodarr_worker_sessions_by_client + + @type session :: %{ + server_worker_id: String.t(), + client_worker_id: String.t(), + version: String.t(), + protocol_version: pos_integer(), + capabilities: map(), + phase: :idle | :receiving_input | :input_ready | :crf_searching, + active_video_id: integer() | nil, + transfer_progress: map() | nil, + crf_search_progress: map() | nil, + resource_usage: map() | nil, + connected_at: DateTime.t(), + last_seen_at: DateTime.t() + } + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def register(attrs) do + GenServer.call(__MODULE__, {:register, attrs}) + end + + def touch(server_worker_id, resource_usage \\ nil) do + GenServer.call(__MODULE__, {:touch, server_worker_id, resource_usage}) + end + + def unregister(server_worker_id) do + GenServer.call(__MODULE__, {:unregister, server_worker_id}) + end + + def assign_video(server_worker_id, video_id, phase \\ :crf_searching) + when is_integer(video_id) and phase in [:receiving_input, :input_ready, :crf_searching] do + GenServer.call(__MODULE__, {:assign_video, server_worker_id, video_id, phase}) + end + + def clear_video(server_worker_id) do + GenServer.call(__MODULE__, {:clear_video, server_worker_id}) + end + + def set_transfer_progress(server_worker_id, progress) when is_map(progress) do + record_transfer_progress(server_worker_id, progress) + end + + def record_transfer_progress(server_worker_id, progress) when is_map(progress) do + GenServer.call(__MODULE__, {:record_transfer_progress, server_worker_id, progress}) + end + + def finish_transfer(server_worker_id) do + GenServer.call(__MODULE__, {:finish_transfer, server_worker_id}) + end + + def set_crf_search_progress(server_worker_id, progress) when is_map(progress) do + GenServer.call(__MODULE__, {:set_crf_search_progress, server_worker_id, progress}) + end + + def cancel(server_worker_id) do + GenServer.call(__MODULE__, {:cancel, server_worker_id}) + end + + def drain do + GenServer.call(__MODULE__, :drain) + end + + def get(server_worker_id) do + GenServer.call(__MODULE__, {:get, server_worker_id}) + end + + def expire_stale(timeout_seconds) when is_integer(timeout_seconds) and timeout_seconds >= 0 do + GenServer.call(__MODULE__, {:expire_stale, timeout_seconds}) + end + + def list do + GenServer.call(__MODULE__, :list) + end + + def reset do + GenServer.call(__MODULE__, :reset) + end + + @impl GenServer + def init(_opts) do + :ets.new(@by_server_table, [:named_table, :set, :private]) + :ets.new(@by_client_table, [:named_table, :set, :private]) + schedule_expire_stale() + {:ok, :ok} + end + + @impl GenServer + def handle_call({:register, attrs}, _from, state) do + now = now() + + with {:ok, server_worker_id} <- required_attr(attrs, :server_worker_id), + {:ok, client_worker_id} <- required_attr(attrs, :client_worker_id), + {:ok, version} <- required_attr(attrs, :version), + {:ok, protocol_version} <- required_attr(attrs, :protocol_version), + {:ok, capabilities} <- required_attr(attrs, :capabilities) do + register_session( + server_worker_id, + client_worker_id, + version, + protocol_version, + capabilities, + now, + state + ) + else + :error -> + {:reply, {:error, :invalid_session_attrs}, state} + end + end + + def handle_call({:touch, server_worker_id, resource_usage}, _from, state) do + update_session_reply(server_worker_id, state, fn session -> + %{session | last_seen_at: now(), resource_usage: resource_usage || session.resource_usage} + end) + end + + def handle_call({:unregister, server_worker_id}, _from, state) do + :ok = drop_session(server_worker_id) + broadcast_sessions() + {:reply, :ok, state} + end + + def handle_call({:assign_video, server_worker_id, video_id, phase}, _from, state) do + update_session_reply(server_worker_id, state, fn session -> + WorkerJobStateMachine.assign_video(session, video_id, phase) + end) + end + + def handle_call({:clear_video, server_worker_id}, _from, state) do + update_session_reply(server_worker_id, state, fn session -> + WorkerJobStateMachine.clear_video(session) + end) + end + + def handle_call({:record_transfer_progress, server_worker_id, progress}, _from, state) do + update_session_reply(server_worker_id, state, fn session -> + with {:ok, session} <- WorkerJobStateMachine.record_transfer_progress(session, progress) do + {:ok, %{session | last_seen_at: now()}} + end + end) + end + + def handle_call({:finish_transfer, server_worker_id}, _from, state) do + update_session_reply(server_worker_id, state, fn session -> + with {:ok, session} <- WorkerJobStateMachine.finish_transfer(session) do + {:ok, %{session | last_seen_at: now()}} + end + end) + end + + def handle_call({:set_crf_search_progress, server_worker_id, progress}, _from, state) do + update_session_reply(server_worker_id, state, fn session -> + progress = merge_crf_search_progress(session.crf_search_progress, progress) + + with {:ok, session} <- WorkerJobStateMachine.set_crf_search_progress(session, progress) do + {:ok, %{session | last_seen_at: now()}} + end + end) + end + + def handle_call({:cancel, server_worker_id}, _from, state) do + case lookup_session(server_worker_id) do + {:ok, session} -> + requeue_active_video(session) + :ok = drop_session(server_worker_id) + broadcast_sessions() + {:reply, {:ok, session}, state} + + :error -> + {:reply, {:error, :unknown_worker_session}, state} + end + end + + def handle_call({:get, server_worker_id}, _from, state) do + {:reply, session_or_nil(lookup_session(server_worker_id)), state} + end + + def handle_call({:expire_stale, timeout_seconds}, _from, state) do + {expired_sessions, _} = expire_stale_sessions(timeout_seconds) + + if expired_sessions != [] do + broadcast_sessions() + end + + {:reply, {:ok, expired_sessions}, state} + end + + def handle_call(:list, _from, state) do + sessions = list_sessions() + {:reply, sessions, state} + end + + def handle_call(:reset, _from, state) do + :ok = reset_tables() + broadcast_sessions() + {:reply, :ok, state} + end + + def handle_call(:drain, _from, state) do + drained_sessions = + list_sessions() + |> Enum.filter(&(&1.active_video_id != nil)) + |> Enum.map(fn session -> + requeue_active_video(session) + :ok = drop_session(session.server_worker_id) + session + end) + + if drained_sessions != [] do + broadcast_sessions() + end + + {:reply, {:ok, drained_sessions}, state} + end + + @impl GenServer + def handle_info(:expire_stale, state) do + {expired_sessions, _} = expire_stale_sessions(timeout_seconds()) + + if expired_sessions != [] do + broadcast_sessions() + end + + schedule_expire_stale() + {:noreply, state} + end + + defp build_session( + server_worker_id, + client_worker_id, + version, + protocol_version, + capabilities, + now + ) do + %{ + server_worker_id: server_worker_id, + client_worker_id: client_worker_id, + version: version, + protocol_version: protocol_version, + capabilities: capabilities, + phase: :idle, + active_video_id: nil, + transfer_progress: nil, + crf_search_progress: nil, + resource_usage: nil, + connected_at: now, + last_seen_at: now + } + end + + defp merge_crf_search_progress( + %{video_id: video_id} = previous, + %{video_id: video_id} = progress + ) do + Enum.reduce([:crf, :sample_num, :total_samples], progress, fn key, merged -> + case Map.get(merged, key) do + nil -> Map.put(merged, key, Map.get(previous, key)) + _value -> merged + end + end) + end + + defp merge_crf_search_progress(_previous, progress), do: progress + + defp register_session( + server_worker_id, + client_worker_id, + version, + protocol_version, + capabilities, + now, + state + ) do + case lookup_session(server_worker_id) do + :error -> + case lookup_client(client_worker_id) do + nil -> + session = + build_session( + server_worker_id, + client_worker_id, + version, + protocol_version, + capabilities, + now + ) + + :ok = put_session(session) + {:reply, {:ok, session}, state} + + existing_server_worker_id -> + replace_client_session( + existing_server_worker_id, + server_worker_id, + client_worker_id, + version, + protocol_version, + capabilities, + now, + state + ) + end + + {:ok, %{client_worker_id: ^client_worker_id, connected_at: connected_at}} -> + session = + build_session( + server_worker_id, + client_worker_id, + version, + protocol_version, + capabilities, + now + ) + |> Map.put(:connected_at, connected_at) + + :ok = put_session(session) + {:reply, {:ok, session}, state} + + {:ok, _existing_session} -> + {:reply, {:error, :duplicate_worker_id}, state} + end + end + + defp replace_client_session( + existing_server_worker_id, + server_worker_id, + client_worker_id, + version, + protocol_version, + capabilities, + now, + state + ) do + case lookup_session(existing_server_worker_id) do + {:ok, existing_session} -> + active_video_id = resumable_active_video_id(existing_session.active_video_id) + + phase = + if active_video_id do + existing_session + |> Map.get(:phase, :crf_searching) + |> resumable_phase() + else + :idle + end + + session = + build_session( + server_worker_id, + client_worker_id, + version, + protocol_version, + capabilities, + now + ) + |> Map.put(:connected_at, existing_session.connected_at) + |> Map.put(:active_video_id, active_video_id) + |> Map.put(:phase, phase) + |> Map.put( + :transfer_progress, + if(active_video_id && phase in [:receiving_input, :input_ready], + do: existing_session.transfer_progress + ) + ) + |> Map.put( + :crf_search_progress, + if(active_video_id && phase == :crf_searching, + do: existing_session.crf_search_progress + ) + ) + |> Map.put(:resource_usage, existing_session.resource_usage) + + :ok = drop_session(existing_server_worker_id) + :ok = put_session(session) + {:reply, {:ok, session}, state} + + :error -> + {:reply, {:error, :duplicate_worker_id}, state} + end + end + + defp required_attr(attrs, key), do: Map.fetch(attrs, key) + + defp resumable_phase(phase) when phase in [:receiving_input, :input_ready, :crf_searching], + do: phase + + defp resumable_phase(_phase), do: :crf_searching + + defp update_session_reply(server_worker_id, state, update_fun) do + case lookup_session(server_worker_id) do + {:ok, session} -> + case update_fun.(session) do + {:ok, updated_session} -> + :ok = put_session(updated_session) + {:reply, {:ok, updated_session}, state} + + {:error, reason} -> + {:reply, {:error, reason}, state} + + updated_session -> + :ok = put_session(updated_session) + {:reply, {:ok, updated_session}, state} + end + + :error -> + {:reply, {:error, :unknown_worker_session}, state} + end + end + + defp broadcast_sessions do + Events.broadcast_event(:worker_sessions_updated, %{sessions: list_sessions()}) + end + + defp requeue_active_video(%{active_video_id: nil}), do: :ok + + defp requeue_active_video(%{active_video_id: video_id}) do + case Media.get_video(video_id) do + %Media.Video{state: :crf_searching} = video -> + _ = VideoStateMachine.mark_as_analyzed(video) + :ok + + _ -> + :ok + end + end + + defp preserve_dispatched_active_video(%{active_video_id: nil}), do: :ok + + defp preserve_dispatched_active_video(%{active_video_id: video_id}) do + case Media.get_video(video_id) do + %Media.Video{state: :crf_searching, crf_search_worker_id: dispatch_id} + when is_binary(dispatch_id) -> + :ok + + %Media.Video{state: :crf_searching} = video -> + _ = VideoStateMachine.mark_as_analyzed(video) + :ok + + _ -> + :ok + end + end + + defp resumable_active_video_id(nil), do: nil + + defp resumable_active_video_id(video_id) do + case Media.get_video(video_id) do + %Media.Video{state: :crf_searching} -> video_id + _ -> nil + end + end + + defp put_session(session) do + :ok = drop_session(session.server_worker_id) + true = :ets.insert(@by_server_table, {session.server_worker_id, session}) + true = :ets.insert(@by_client_table, {session.client_worker_id, session.server_worker_id}) + broadcast_sessions() + :ok + end + + defp drop_session(server_worker_id) do + case lookup_session(server_worker_id) do + {:ok, session} -> + true = :ets.delete(@by_server_table, server_worker_id) + true = :ets.delete(@by_client_table, session.client_worker_id) + :ok + + :error -> + :ok + end + end + + defp expire_stale_sessions(timeout_seconds) do + now = now() + + expired_sessions = + @by_server_table + |> :ets.tab2list() + |> Enum.map(fn {_server_worker_id, session} -> session end) + |> Enum.filter(fn session -> + DateTime.diff(now, session.last_seen_at, :second) >= timeout_seconds + end) + + Enum.each(expired_sessions, fn session -> + preserve_dispatched_active_video(session) + :ok = drop_session(session.server_worker_id) + end) + + {expired_sessions, :ok} + end + + defp schedule_expire_stale do + Process.send_after(self(), :expire_stale, sweep_interval_ms()) + end + + defp reset_tables do + true = :ets.delete_all_objects(@by_server_table) + true = :ets.delete_all_objects(@by_client_table) + :ok + end + + defp list_sessions do + @by_server_table + |> :ets.tab2list() + |> Enum.map(fn {_server_worker_id, session} -> session end) + |> Enum.sort_by(& &1.client_worker_id) + end + + defp lookup_session(server_worker_id) do + case :ets.lookup(@by_server_table, server_worker_id) do + [{^server_worker_id, session}] -> {:ok, session} + [] -> :error + end + end + + defp lookup_client(client_worker_id) do + case :ets.lookup(@by_client_table, client_worker_id) do + [{^client_worker_id, server_worker_id}] -> server_worker_id + [] -> nil + end + end + + defp session_or_nil({:ok, session}), do: session + defp session_or_nil(:error), do: nil + + defp timeout_seconds do + Application.get_env(:reencodarr, :worker_session_timeout_seconds, 120) + end + + defp sweep_interval_ms do + Application.get_env(:reencodarr, :worker_session_sweep_interval_ms, 30_000) + end + + defp now do + DateTime.utc_now() |> DateTime.truncate(:second) + end +end diff --git a/lib/reencodarr/application.ex b/lib/reencodarr/application.ex index eade7a9f..329cf63f 100644 --- a/lib/reencodarr/application.ex +++ b/lib/reencodarr/application.ex @@ -109,6 +109,7 @@ defmodule Reencodarr.Application do {Phoenix.PubSub, name: Reencodarr.PubSub}, # Start the Finch HTTP client for outbound API requests. {Finch, name: Reencodarr.Finch}, + Reencodarr.AbAv1.WorkerSessions, # Start to serve requests, typically the last entry ReencodarrWeb.Endpoint, # DynamicSupervisor for port-holder processes (AbAv1.Encoder, AbAv1.CrfSearcher). diff --git a/lib/reencodarr/crf_searcher/broadway/producer.ex b/lib/reencodarr/crf_searcher/broadway/producer.ex index a0fdfe9d..805db2a4 100644 --- a/lib/reencodarr/crf_searcher/broadway/producer.ex +++ b/lib/reencodarr/crf_searcher/broadway/producer.ex @@ -63,10 +63,12 @@ defmodule Reencodarr.CrfSearcher.Broadway.Producer do suspended? = ProcessControl.suspended?(:crf_searcher) videos = - if status == :available and not suspended? do - Media.get_videos_for_crf_search(1) + with :available <- status, + false <- suspended?, + video when not is_nil(video) <- Media.claim_next_video_for_crf_search() do + [video] else - [] + _ -> [] end remaining_demand = demand - length(videos) diff --git a/lib/reencodarr/diagnostics.ex b/lib/reencodarr/diagnostics.ex index b5919213..3b1fb912 100644 --- a/lib/reencodarr/diagnostics.ex +++ b/lib/reencodarr/diagnostics.ex @@ -8,7 +8,7 @@ defmodule Reencodarr.Diagnostics do import Ecto.Query - alias Reencodarr.AbAv1.{CrfSearch, Encode} + alias Reencodarr.AbAv1.{CrfSearch, Encode, WorkerSessions} alias Reencodarr.Analyzer alias Reencodarr.Analyzer.MediaInfoCache alias Reencodarr.Core.Time @@ -306,6 +306,14 @@ defmodule Reencodarr.Diagnostics do encode_state = safe_get_state(Reencodarr.AbAv1.Encode, 2000) health_state = safe_get_state(Reencodarr.Encoder.HealthCheck, 2000) cache_stats = safe_call(fn -> MediaInfoCache.get_stats() end) + worker_sessions = safe_call(fn -> WorkerSessions.list() end) + + worker_sessions_section = + case worker_sessions do + {:error, _} -> " Unavailable" + sessions when is_list(sessions) -> format_worker_sessions(sessions) + _ -> " Unknown state" + end """ #{section("Live Process State")} @@ -319,6 +327,9 @@ defmodule Reencodarr.Diagnostics do Health Check: #{format_health_check_state(health_state)} + Worker Sessions: + #{worker_sessions_section} + MediaInfo Cache: #{format_cache_stats(cache_stats)} """ @@ -654,6 +665,38 @@ defmodule Reencodarr.Diagnostics do defp format_health_check_state(_), do: " Unknown state" + defp format_worker_sessions([]), do: " none" + + defp format_worker_sessions(worker_sessions) when is_list(worker_sessions) do + Enum.map_join(worker_sessions, "\n", fn session -> + active_video_id = session.active_video_id || "none" + video_state = worker_session_video_state(session.active_video_id) + transfer_progress = format_worker_progress(session.transfer_progress, "transfer") + crf_search_progress = format_worker_progress(session.crf_search_progress, "crf") + + " #{session.client_worker_id} protocol=#{session.protocol_version} version=#{session.version} video=#{active_video_id} phase=#{session.phase} video_state=#{video_state} transfer=#{transfer_progress} crf=#{crf_search_progress} capabilities=#{inspect(session.capabilities)} last_seen=#{DateTime.to_iso8601(session.last_seen_at)}" + end) + end + + defp worker_session_video_state(nil), do: "none" + + defp worker_session_video_state(video_id) do + case Reencodarr.Media.get_video(video_id) do + %Video{state: state} -> Atom.to_string(state) + nil -> "missing" + end + end + + defp format_worker_progress(nil, _label), do: "none" + + defp format_worker_progress(progress, label) when is_map(progress) do + percent = Map.get(progress, :percent, Map.get(progress, "percent")) + bytes_sent = Map.get(progress, :bytes_sent, Map.get(progress, "bytes_sent")) + total_bytes = Map.get(progress, :total_bytes, Map.get(progress, "total_bytes")) + + "#{label}(percent=#{percent || "?"}, bytes_sent=#{bytes_sent || "?"}, total_bytes=#{total_bytes || "?"})" + end + defp video_active_in_state?({:error, _}, _video_id), do: false defp video_active_in_state?(state, video_id) when is_map(state) do diff --git a/lib/reencodarr/media.ex b/lib/reencodarr/media.ex index 0ccdc7ea..b755d0c5 100644 --- a/lib/reencodarr/media.ex +++ b/lib/reencodarr/media.ex @@ -158,6 +158,10 @@ defmodule Reencodarr.Media do VideoQueries.videos_for_crf_search(limit) end + def claim_next_video_for_crf_search do + VideoQueries.claim_next_video_for_crf_search() + end + def count_videos_for_crf_search do VideoQueries.count_videos_for_crf_search() end @@ -308,6 +312,31 @@ defmodule Reencodarr.Media do def mark_as_crf_searching(%Video{} = video), do: VideoStateMachine.mark_as_crf_searching(video) + def mark_as_worker_crf_searching(%Video{id: video_id}, worker_id) + when is_integer(video_id) and is_binary(worker_id) do + write( + fn -> + from(v in Video, where: v.id == ^video_id and v.state == :crf_searching) + |> Repo.update_all(set: [crf_search_worker_id: worker_id, updated_at: DateTime.utc_now()]) + end, + label: :media_mark_as_worker_crf_searching + ) + |> case do + {1, _} -> {:ok, Repo.get(Video, video_id)} + _ -> {:error, :not_crf_searching} + end + end + + def get_worker_crf_searching_video(worker_id) when is_binary(worker_id) do + Repo.one( + from(v in Video, + where: v.state == :crf_searching and v.crf_search_worker_id == ^worker_id, + order_by: [desc: v.updated_at], + limit: 1 + ) + ) + end + def mark_as_encoding(%Video{} = video), do: VideoStateMachine.mark_as_encoding(video) def mark_as_reencoded(%Video{} = video), do: VideoStateMachine.mark_as_reencoded(video) @@ -1380,7 +1409,7 @@ defmodule Reencodarr.Media do def reset_orphaned_crf_searching do exclude_id = CrfSearch.current_video_id() - from(v in Video, where: v.state == :crf_searching) + from(v in Video, where: v.state == :crf_searching and is_nil(v.crf_search_worker_id)) |> maybe_exclude_video(exclude_id) |> reset_videos("orphaned crf_searching videos → analyzed") end diff --git a/lib/reencodarr/media/chart_queries.ex b/lib/reencodarr/media/chart_queries.ex index 961a2583..8fcd7c5e 100644 --- a/lib/reencodarr/media/chart_queries.ex +++ b/lib/reencodarr/media/chart_queries.ex @@ -1,8 +1,6 @@ defmodule Reencodarr.Media.ChartQueries do @moduledoc "Database queries for dashboard chart data." - import Ecto.Query - alias Reencodarr.Media.Video alias Reencodarr.Repo @vmaf_bins [ @@ -133,22 +131,27 @@ defmodule Reencodarr.Media.ChartQueries do @doc "Get primary codec distribution across all videos (top 8)." def codec_distribution do - from(v in Video, - where: not is_nil(v.video_codecs) and v.video_codecs != [], - select: v.video_codecs - ) - |> Repo.all() - |> Enum.reduce(%{}, fn codecs, acc -> - codec = - case codecs do - [first | _] -> normalize_codec(first) - _ -> "unknown" - end + case Repo.query( + """ + SELECT json_extract(video_codecs, '$[0]') AS codec, COUNT(*) + FROM videos + WHERE video_codecs IS NOT NULL + AND json_array_length(video_codecs) > 0 + GROUP BY codec + """, + [] + ) do + {:ok, %{rows: rows}} -> + rows + |> Enum.reduce(%{}, fn [codec, count], acc -> + Map.update(acc, normalize_codec(codec), count, &(&1 + count)) + end) + |> Enum.sort_by(fn {_, count} -> -count end) + |> Enum.take(8) - Map.update(acc, codec, 1, &(&1 + 1)) - end) - |> Enum.sort_by(fn {_, count} -> -count end) - |> Enum.take(8) + {:error, error} -> + raise error + end end defp normalize_codec(codec) when is_binary(codec) do diff --git a/lib/reencodarr/media/video.ex b/lib/reencodarr/media/video.ex index 964e332e..f806d3c9 100644 --- a/lib/reencodarr/media/video.ex +++ b/lib/reencodarr/media/video.ex @@ -40,6 +40,7 @@ defmodule Reencodarr.Media.Video do atmos: boolean() | nil, service_id: String.t() | nil, service_type: service_type() | nil, + crf_search_worker_id: String.t() | nil, mediainfo: map() | nil, inserted_at: DateTime.t() | nil, updated_at: DateTime.t() | nil @@ -78,6 +79,7 @@ defmodule Reencodarr.Media.Video do :library_id, :service_id, :service_type, + :crf_search_worker_id, :duration, :mediainfo, :chosen_vmaf_id, @@ -134,6 +136,7 @@ defmodule Reencodarr.Media.Video do field :title, :string field :service_id, :string field :service_type, Ecto.Enum, values: @service_types + field :crf_search_worker_id, :string field :mediainfo, :map # Year information from Sonarr/Radarr APIs diff --git a/lib/reencodarr/media/video_queries.ex b/lib/reencodarr/media/video_queries.ex index 02efefb0..d5f17e8e 100644 --- a/lib/reencodarr/media/video_queries.ex +++ b/lib/reencodarr/media/video_queries.ex @@ -18,16 +18,12 @@ defmodule Reencodarr.Media.VideoQueries do """ @spec videos_for_crf_search(integer(), keyword()) :: [Video.t()] def videos_for_crf_search(limit \\ 10, opts \\ []) do - # Simplified query - just check state, let Rules filter codecs at encode time - Repo.all( - from(v in Video, - where: v.state == :analyzed, - order_by: [desc: v.priority, desc: v.bitrate, desc: v.size, asc: v.updated_at], - limit: ^limit, - select: v - ), - opts - ) + ids = crf_search_queue_candidate_ids(limit, opts) + + Video + |> where([v], v.id in ^ids) + |> Repo.all(opts) + |> sort_by_ids(ids) end @doc """ @@ -46,6 +42,38 @@ defmodule Reencodarr.Media.VideoQueries do ) end + @doc """ + Atomically claims the next video ready for CRF search. + + Returns `nil` when the queue is empty. The claim transitions the selected + video from `:analyzed` to `:crf_searching` so only one worker can receive it. + """ + @spec claim_next_video_for_crf_search(keyword()) :: Video.t() | nil + def claim_next_video_for_crf_search(opts \\ []) do + DbWriter.transaction( + fn -> + claim_next_video_for_crf_search_in_tx(opts) + end, + label: :video_queries_claim_next_video_for_crf_search + ) + |> case do + {:ok, {:claimed, video, old_snapshot}} -> + Reencodarr.Media.broadcast_video_mutation( + :update, + old_snapshot, + Reencodarr.Media.fetch_dashboard_video_snapshot_by_id(video.id) + ) + + video + + {:ok, :none} -> + nil + + {:error, reason} -> + raise "Failed to claim video for CRF search: #{inspect(reason)}" + end + end + @doc """ Gets videos needing analysis (state: needs_analysis). @@ -156,6 +184,31 @@ defmodule Reencodarr.Media.VideoQueries do end) end + defp claim_next_video_for_crf_search_in_tx(opts) do + claim_next_video_for_crf_search_in_tx(crf_search_queue_candidate_ids(10, opts), opts) + end + + defp claim_next_video_for_crf_search_in_tx([], _opts), do: :none + + defp claim_next_video_for_crf_search_in_tx([video_id | rest], opts) do + old_snapshot = Reencodarr.Media.fetch_dashboard_video_snapshot_by_id(video_id) + + {updated_count, updated_rows} = + from(v in Video, + where: v.id == ^video_id and v.state == :analyzed, + select: v + ) + |> Repo.update_all([set: [state: :crf_searching, updated_at: DateTime.utc_now()]], opts) + + case {updated_count, updated_rows} do + {1, [video]} -> + {:claimed, video, old_snapshot} + + _ -> + claim_next_video_for_crf_search_in_tx(rest, opts) + end + end + @doc """ Counts the total number of videos needing analysis. """ @@ -175,15 +228,7 @@ defmodule Reencodarr.Media.VideoQueries do """ @spec videos_for_crf_search_preview(integer(), keyword()) :: [map()] def videos_for_crf_search_preview(limit \\ 10, opts \\ []) do - Repo.all( - from(v in Video, - where: v.state == :analyzed, - order_by: [desc: v.priority, desc: v.bitrate, desc: v.size, asc: v.updated_at], - limit: ^limit, - select: %{id: v.id, path: v.path} - ), - opts - ) + crf_search_queue_preview_rows(limit, opts) end @doc """ @@ -239,4 +284,32 @@ defmodule Reencodarr.Media.VideoQueries do opts ) end + + defp crf_search_queue_candidate_ids(limit, opts) do + crf_search_queue_rows("id", limit, opts) + |> Enum.map(fn [id] -> id end) + end + + defp crf_search_queue_preview_rows(limit, opts) do + crf_search_queue_rows("id, path", limit, opts) + |> Enum.map(fn [id, path] -> %{id: id, path: path} end) + end + + defp crf_search_queue_rows(select, limit, opts) do + sql = """ + SELECT #{select} + FROM videos INDEXED BY videos_crf_search_queue_index + WHERE state = 'analyzed' + ORDER BY priority DESC, bitrate DESC, size DESC, updated_at ASC + LIMIT ? + """ + + %{rows: rows} = Repo.query!(sql, [limit], opts) + rows + end + + defp sort_by_ids(videos, ids) do + videos_by_id = Map.new(videos, &{&1.id, &1}) + Enum.map(ids, &Map.fetch!(videos_by_id, &1)) + end end diff --git a/lib/reencodarr/media/video_state_machine.ex b/lib/reencodarr/media/video_state_machine.ex index f5499dd3..32e74e3d 100644 --- a/lib/reencodarr/media/video_state_machine.ex +++ b/lib/reencodarr/media/video_state_machine.ex @@ -92,12 +92,18 @@ defmodule Reencodarr.Media.VideoStateMachine do video |> change(attrs) |> put_change(:state, to_state) + |> maybe_clear_crf_search_worker_id(to_state) |> validate_state_transition(from_state, to_state) {:ok, changeset} end end + defp maybe_clear_crf_search_worker_id(changeset, :crf_searching), do: changeset + + defp maybe_clear_crf_search_worker_id(changeset, _to_state), + do: put_change(changeset, :crf_search_worker_id, nil) + @doc """ Transitions a video to a new state with automatic state-specific validations. """ diff --git a/lib/reencodarr_web/channels/worker_channel.ex b/lib/reencodarr_web/channels/worker_channel.ex new file mode 100644 index 00000000..e34b6880 --- /dev/null +++ b/lib/reencodarr_web/channels/worker_channel.ex @@ -0,0 +1,1145 @@ +defmodule ReencodarrWeb.WorkerChannel do + @moduledoc """ + Phoenix channel for long-lived ab-av1 CRF-search workers. + """ + + use ReencodarrWeb, :channel + + alias Reencodarr.AbAv1.{CrfSearch, WorkerConfig, WorkerProtocol, WorkerSessions} + alias Reencodarr.AbAv1.WorkerProtocol.Announcement + alias Reencodarr.Dashboard.Events + alias Reencodarr.Media + + @crf_search_topic WorkerProtocol.crf_search_topic() + @worker_control_topic_prefix "worker_controls:" + + def worker_control_topic(worker_id), do: @worker_control_topic_prefix <> worker_id + + @impl true + def join(@crf_search_topic, _payload, %{assigns: %{worker_id: worker_id}} = socket) do + Phoenix.PubSub.subscribe(Reencodarr.PubSub, worker_control_topic(worker_id)) + {:ok, %{worker_id: worker_id}, socket} + end + + def join(_topic, _payload, _socket), do: {:error, WorkerProtocol.error(:unauthorized)} + + @impl true + def handle_in("announce", payload, %{assigns: %{worker_id: server_worker_id}} = socket) do + with {:ok, + %Announcement{ + worker_id: client_worker_id, + protocol_version: protocol_version, + version: version, + capabilities: capabilities + }} <- WorkerProtocol.parse_announcement(payload), + :ok <- ensure_supported_protocol_version(protocol_version), + {:ok, session} <- + WorkerSessions.register(%{ + server_worker_id: server_worker_id, + client_worker_id: client_worker_id, + protocol_version: protocol_version, + version: version, + capabilities: capabilities + }) do + socket = + socket + |> assign(:client_worker_id, client_worker_id) + |> assign(:client_version, version) + |> assign(:protocol_version, protocol_version) + |> assign(:capabilities, capabilities) + |> attach_announced_work(client_worker_id, session) + + {:reply, {:ok, WorkerProtocol.accepted(protocol_version)}, socket} + else + {:error, reason} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + + def handle_in("heartbeat", payload, %{assigns: %{worker_id: worker_id}} = socket) do + case WorkerSessions.touch(worker_id, WorkerProtocol.parse_resource_usage(payload)) do + {:ok, session} -> + {:reply, {:ok, WorkerProtocol.heartbeat_ack(session.last_seen_at)}, socket} + + {:error, reason} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + + def handle_in("request_work", payload, socket), do: handle_work_request(payload, socket) + + def handle_in("pull_work", payload, socket), do: handle_work_request(payload, socket) + + def handle_in("transfer_progress", payload, socket), + do: handle_transfer_progress(payload, socket) + + def handle_in("crf_search_progress", payload, socket), + do: handle_crf_search_progress(payload, socket) + + def handle_in("crf_search_result", payload, socket), + do: handle_crf_search_result(payload, socket) + + def handle_in("crf_search_completed", payload, socket), + do: handle_crf_search_completed(payload, socket) + + def handle_in("video_failed", payload, socket), + do: handle_video_failed(payload, socket) + + def handle_in(_event, _payload, socket) do + {:reply, {:error, WorkerProtocol.error(:unsupported_event)}, socket} + end + + @impl true + def handle_info(:stream_transfer_chunk, %{assigns: %{transfer_io_device: nil}} = socket) do + case open_transfer_stream(socket) do + {:ok, socket} -> + push_transfer_start(socket) + + {:error, socket} -> + {:noreply, socket} + end + end + + def handle_info({:worker_control, action}, socket) do + push(socket, "control", %{ + action: Atom.to_string(action), + video_id: socket.assigns[:current_video_id] + }) + + {:noreply, socket} + end + + def handle_info(:stream_transfer_chunk, %{assigns: %{transfer_io_device: io_device}} = socket) + when not is_nil(io_device) do + read_transfer_chunk(socket, io_device) + end + + def handle_info(:complete_transfer, %{assigns: %{transfer_io_device: io_device}} = socket) + when not is_nil(io_device) do + case Media.get_video(socket.assigns.current_video_id) do + %Media.Video{} = video -> + {:noreply, complete_transfer(socket, video, io_device)} + + nil -> + close_transfer_stream(io_device) + {:noreply, clear_assigned_video(socket.assigns.worker_id, socket)} + end + end + + defp ensure_supported_protocol_version(protocol_version) do + if WorkerProtocol.supported_protocol_version?(protocol_version) do + :ok + else + {:error, :unsupported_protocol_version} + end + end + + @impl true + def terminate(reason, %{assigns: %{worker_id: worker_id}} = socket) do + if !shutdown_reason?(reason) do + maybe_requeue_active_video(socket) + end + + WorkerSessions.unregister(worker_id) + :ok + end + + defp handle_work_request(payload, %{assigns: %{worker_id: worker_id}} = socket) do + _ = WorkerSessions.touch(worker_id) + + request_mode = work_request_mode(payload) + + case socket.assigns[:current_video_id] do + nil -> + resume_or_claim_work(worker_id, socket, request_mode) + + video_id -> + handle_active_video_work_request(worker_id, socket, video_id, request_mode) + end + end + + defp handle_active_video_work_request(worker_id, socket, video_id, request_mode) do + case Media.get_video(video_id) do + %Media.Video{state: :crf_searching} = video -> + request_mode = + case WorkerSessions.get(worker_id) do + %{active_video_id: ^video_id} = session -> + maybe_resume_mode(session, request_mode) + + _ -> + request_mode + end + + reply_for_active_work(worker_id, socket, video, request_mode) + + %Media.Video{} -> + socket = clear_assigned_video(worker_id, socket) + resume_or_claim_work(worker_id, socket, request_mode) + + nil -> + {:reply, {:error, WorkerProtocol.error(:unknown_worker_session)}, socket} + end + end + + defp resume_or_claim_work(worker_id, socket, request_mode) do + case resume_session_work(worker_id, socket, request_mode) do + {:reply, reply, socket} -> + {:reply, reply, socket} + + :none -> + case resume_dispatched_work(worker_id, socket, request_mode) do + {:reply, reply, socket} -> {:reply, reply, socket} + :none -> claim_work(worker_id, socket) + end + end + end + + defp resume_session_work(worker_id, socket, request_mode) do + case WorkerSessions.get(worker_id) do + %{active_video_id: video_id} = session when is_integer(video_id) -> + with {:ok, socket} <- ensure_resumable_active_video(socket, video_id), + %Media.Video{} = video <- Media.get_video(video_id) do + session + |> maybe_resume_mode(request_mode) + |> then(&reply_for_active_work(worker_id, socket, video, &1)) + else + _ -> :none + end + + _session -> + :none + end + end + + defp maybe_resume_mode( + %{phase: :receiving_input, transfer_progress: transfer_progress}, + request_mode + ) do + if should_resend_transfer?(transfer_progress), + do: :resend_input, + else: request_mode + end + + defp maybe_resume_mode(%{phase: :crf_searching}, :resend_input), do: :resend_input + defp maybe_resume_mode(%{phase: :crf_searching}, _request_mode), do: :resume_only + defp maybe_resume_mode(%{phase: :input_ready}, :resend_input), do: :resend_input + defp maybe_resume_mode(%{phase: :input_ready}, _request_mode), do: :resume_only + defp maybe_resume_mode(_session, request_mode), do: request_mode + + defp should_resend_transfer?(nil), do: true + + defp should_resend_transfer?(%{bytes_sent: bytes_sent, total_bytes: total_bytes}) + when is_integer(bytes_sent) and is_integer(total_bytes) and total_bytes > 0 do + bytes_sent < total_bytes + end + + defp should_resend_transfer?(%{percent: percent}) when is_number(percent), + do: percent < 100.0 + + defp should_resend_transfer?(%{chunk_index: chunk_index, total_chunks: total_chunks}) + when is_integer(chunk_index) and is_integer(total_chunks) and total_chunks > 0 do + chunk_index < total_chunks + end + + defp should_resend_transfer?(_), do: true + + defp attach_announced_work(socket, _client_worker_id, %{active_video_id: video_id}) + when is_integer(video_id) do + attach_active_video(socket, video_id) + end + + defp attach_announced_work(socket, _client_worker_id, _session), do: socket + + defp attach_active_video(socket, video_id) do + case Media.get_video(video_id) do + %Media.Video{} = video -> + socket + |> assign(:current_video_id, video.id) + |> assign(:current_vmaf_target, Reencodarr.Rules.vmaf_target(video)) + + nil -> + socket + end + end + + defp resume_dispatched_work(worker_id, socket, request_mode) do + case Media.get_worker_crf_searching_video(worker_dispatch_id(socket)) do + %Media.Video{} = video -> + resume_dispatched_work(worker_id, socket, video, request_mode) + + nil -> + :none + end + end + + defp resume_dispatched_work(worker_id, socket, video, :resend_input) do + with {:ok, socket} <- ensure_resumable_active_video(socket, video.id, :receiving_input) do + reply_for_active_work(worker_id, socket, video, :resend_input) + end + end + + defp resume_dispatched_work(worker_id, socket, video, :resume_only) do + with {:ok, socket} <- ensure_resumable_active_video(socket, video.id, :crf_searching) do + reply_for_active_work(worker_id, socket, video, :resume_only) + end + end + + defp handle_transfer_progress(payload, %{assigns: %{worker_id: worker_id}} = socket) do + with {:ok, progress} <- WorkerProtocol.parse_transfer_progress(payload), + :ok <- ensure_active_video(socket, progress.video_id) do + {progress, socket} = fill_transfer_rate(socket, progress) + + _ = WorkerSessions.record_transfer_progress(worker_id, progress) + Events.broadcast_event(:transfer_progress, Map.put(progress, :worker_id, worker_id)) + + socket = maybe_send_next_transfer_chunk(socket, progress) + + {:reply, {:ok, WorkerProtocol.event_ack("transfer_progress")}, socket} + else + {:error, reason} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + + defp handle_crf_search_progress(payload, socket) do + with {:ok, progress} <- WorkerProtocol.parse_crf_search_progress(payload), + {:ok, socket} <- ensure_resumable_active_video(socket, progress.video_id) do + _ = WorkerSessions.set_crf_search_progress(socket.assigns.worker_id, progress) + Events.broadcast_event(:crf_search_progress, progress) + {:reply, {:ok, WorkerProtocol.event_ack("crf_search_progress")}, socket} + else + {:error, reason} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + + defp handle_crf_search_result(payload, socket) do + case WorkerProtocol.parse_crf_search_result(payload) do + {:ok, result} -> + handle_parsed_crf_search_result(socket, result) + + {:error, reason} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + + defp handle_crf_search_completed(payload, %{assigns: %{worker_id: worker_id}} = socket) do + case WorkerProtocol.parse_completion(payload) do + {:ok, completion} -> + handle_parsed_crf_search_completed(worker_id, socket, completion) + + {:error, reason} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + + defp handle_video_failed(payload, %{assigns: %{worker_id: worker_id}} = socket) do + with {:ok, failure} <- WorkerProtocol.parse_failure_report(payload), + {:ok, socket} <- ensure_resumable_active_video(socket, failure.video_id) do + video = Media.get_video(failure.video_id) + + if is_nil(video) do + {:reply, {:error, WorkerProtocol.error(:unknown_worker_session)}, socket} + else + _ = + Media.record_video_failure(video, failure.stage, failure.category, + code: failure.code, + message: failure.message, + context: Map.put(failure.context, :stderr_excerpt, failure.stderr_excerpt) + ) + + socket = clear_assigned_video(worker_id, socket) + Events.broadcast_event(:video_failed, failure) + {:reply, {:ok, WorkerProtocol.event_ack("video_failed")}, socket} + end + else + {:error, reason} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + + defp claim_work(worker_id, socket) do + case Media.claim_next_video_for_crf_search() do + %Media.Video{} = video -> + assign_claimed_work(worker_id, socket, video) + + nil -> + {:reply, {:ok, WorkerProtocol.no_work()}, socket} + end + end + + defp assign_claimed_work(worker_id, socket, video) do + target_vmaf = Reencodarr.Rules.vmaf_target(video) + + with {:ok, _video} <- Media.mark_as_worker_crf_searching(video, worker_dispatch_id(socket)), + {:ok, _session} <- WorkerSessions.assign_video(worker_id, video.id, :receiving_input) do + socket = + prepare_transfer(socket, video, target_vmaf, stream?: websocket_transfer_on_assign?()) + + {:reply, {:ok, WorkerProtocol.work_assigned(video, target_vmaf)}, socket} + else + {:error, reason} -> + _ = Media.mark_as_analyzed(video) + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + + defp reply_for_active_work(worker_id, socket, video, :resend_input) do + target_vmaf = socket.assigns[:current_vmaf_target] || Reencodarr.Rules.vmaf_target(video) + + case WorkerSessions.assign_video(worker_id, video.id, :receiving_input) do + {:ok, _session} -> + socket = + prepare_transfer(socket, video, target_vmaf, stream?: true, fail_if_missing?: true) + + {:reply, {:ok, WorkerProtocol.work_assigned(video, target_vmaf)}, socket} + + {:error, reason} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + + defp reply_for_active_work(_worker_id, socket, video, :resume_only) do + resume_active_work(socket, video) + end + + defp resume_active_work(socket, video) do + {:reply, {:ok, WorkerProtocol.work_in_progress(video, socket.assigns[:current_vmaf_target])}, + socket} + end + + defp prepare_transfer(socket, video, target_vmaf, opts) do + transfer_id = Integer.to_string(video.id) + total_bytes = video.size || 0 + chunk_size_bytes = WorkerProtocol.chunk_size_bytes() + total_chunks = total_chunks(total_bytes, chunk_size_bytes) + + socket = + socket + |> assign(:current_video_id, video.id) + |> assign(:current_vmaf_target, target_vmaf) + |> assign(:transfer_io_device, nil) + |> assign(:transfer_path, video.path) + |> assign(:transfer_id, transfer_id) + |> assign(:transfer_chunk_size_bytes, chunk_size_bytes) + |> assign(:transfer_total_bytes, total_bytes) + |> assign(:transfer_total_chunks, total_chunks) + |> assign(:transfer_bytes_sent, 0) + |> assign(:transfer_chunk_index, 0) + |> assign(:transfer_waiting_for_ack, false) + |> assign(:transfer_started_sent, nil) + |> assign(:transfer_complete_pending, nil) + |> assign(:transfer_last_progress_at, nil) + |> assign(:transfer_last_progress_bytes, nil) + + if Keyword.get(opts, :stream?, true) and + (File.exists?(video.path) or Keyword.get(opts, :fail_if_missing?, false)) do + send(self(), :stream_transfer_chunk) + end + + socket + end + + defp websocket_transfer_on_assign?, + do: is_nil(WorkerConfig.transfer_base_url()) + + defp work_request_mode(payload) when is_map(payload) do + if Enum.any?( + [ + "input_missing", + :input_missing, + "needs_input", + :needs_input, + "request_transfer", + :request_transfer, + "resend_input", + :resend_input + ], + &truthy?(Map.get(payload, &1)) + ) do + :resend_input + else + :resume_only + end + end + + defp work_request_mode(_payload), do: :resume_only + + defp truthy?(true), do: true + defp truthy?("true"), do: true + defp truthy?("1"), do: true + defp truthy?(1), do: true + defp truthy?(_value), do: false + + defp maybe_requeue_active_video(socket) do + case socket.assigns[:current_video_id] do + nil -> + :ok + + video_id -> + case Media.get_video(video_id) do + %Media.Video{state: :crf_searching, crf_search_worker_id: dispatch_id} + when is_binary(dispatch_id) -> + :ok + + %Media.Video{state: :crf_searching} = video -> + _ = Media.mark_as_analyzed(video) + :ok + + _ -> + :ok + end + end + end + + defp handle_valid_crf_search_result(socket, %{video_id: video_id, results: results}) do + case Media.get_video(video_id) do + nil -> + {:reply, {:error, WorkerProtocol.error(:unknown_worker_session)}, socket} + + video -> + mark_crf_search_active(socket.assigns.worker_id, video_id) + + case persist_crf_results(video, results) do + {:ok, persisted_results} -> + reply_after_persisted_crf_results(socket, video, persisted_results) + + {:error, reason} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + end + + defp reply_after_persisted_crf_results(socket, video, persisted_results) do + if Enum.any?(persisted_results, &Map.get(&1, :chosen, false)) do + finalize_chosen_result_from_report( + socket.assigns.worker_id, + socket, + video, + persisted_results + ) + else + {:reply, {:ok, WorkerProtocol.event_ack("crf_search_result")}, socket} + end + end + + defp handle_parsed_crf_search_result(%{assigns: %{worker_id: worker_id}} = socket, result) do + case Media.get_video(result.video_id) do + %Media.Video{state: :crf_searched, chosen_vmaf_id: chosen_vmaf_id} + when not is_nil(chosen_vmaf_id) -> + {:reply, {:ok, WorkerProtocol.event_ack("crf_search_result")}, + clear_assigned_video(worker_id, socket)} + + _ -> + case ensure_result_video(socket, result.video_id) do + {:ok, socket} -> + handle_valid_crf_search_result(socket, result) + + {:error, reason} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + end + + defp handle_valid_crf_search_completion(worker_id, socket, completion) do + case Media.get_video(completion.video_id) do + nil -> + {:reply, {:error, WorkerProtocol.error(:unknown_worker_session)}, socket} + + %Media.Video{state: :crf_searched, chosen_vmaf_id: chosen_vmaf_id} + when not is_nil(chosen_vmaf_id) -> + {:reply, {:ok, WorkerProtocol.event_ack("crf_search_completed")}, + clear_assigned_video(worker_id, socket)} + + video -> + mark_crf_search_active(worker_id, completion.video_id) + + case apply_completion_result( + worker_id, + socket, + video, + completion.result, + completion.chosen_crf, + completion.results + ) do + {:ok, socket} -> + Events.broadcast_event(:crf_search_completed, %{ + video_id: completion.video_id, + result: completion.result, + chosen_crf: completion.chosen_crf + }) + + {:reply, {:ok, WorkerProtocol.event_ack("crf_search_completed")}, socket} + + {:error, reason, socket} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + end + + defp handle_parsed_crf_search_completed(worker_id, socket, completion) do + case Media.get_video(completion.video_id) do + %Media.Video{state: :crf_searched, chosen_vmaf_id: chosen_vmaf_id} + when not is_nil(chosen_vmaf_id) -> + {:reply, {:ok, WorkerProtocol.event_ack("crf_search_completed")}, + clear_assigned_video(worker_id, socket)} + + _ -> + case ensure_result_video(socket, completion.video_id) do + {:ok, socket} -> + handle_valid_crf_search_completion(worker_id, socket, completion) + + {:error, reason} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + end + + defp apply_completion_result(worker_id, socket, video, :ok, chosen_crf, results) do + case persist_crf_results(video, results) do + {:ok, persisted_results} -> + chosen_crf = chosen_crf || chosen_crf_from_results(persisted_results) + finish_successful_crf_search(worker_id, socket, video, chosen_crf) + + {:error, reason} -> + fail_invalid_successful_crf_search(worker_id, socket, video, reason) + end + end + + defp apply_completion_result(worker_id, socket, video, :cancelled, _chosen_crf, _results) do + finish_cancelled_crf_search(worker_id, socket, video, :cancelled) + end + + defp apply_completion_result(worker_id, socket, video, :shutdown, _chosen_crf, _results) do + finish_cancelled_crf_search(worker_id, socket, video, :shutdown) + end + + defp apply_completion_result(worker_id, socket, video, :failed, _chosen_crf, _results) do + record_completion_failure(worker_id, socket, video, "failed") + end + + defp apply_completion_result(worker_id, socket, video, {:error, reason}, _chosen_crf, _results) do + record_completion_failure(worker_id, socket, video, inspect(reason)) + end + + defp record_completion_failure(worker_id, socket, video, code) do + _ = + Media.record_video_failure(video, :crf_search, :crf_optimization, + code: code, + message: "CRF search failed" + ) + + _ = Media.mark_as_failed(video) + {:ok, clear_assigned_video(worker_id, socket)} + end + + defp ensure_active_video(socket, video_id) do + case socket.assigns[:current_video_id] do + nil -> {:error, :unknown_worker_session} + ^video_id -> :ok + _other -> {:error, :unknown_worker_session} + end + end + + defp ensure_resumable_active_video(socket, video_id, phase \\ :crf_searching) do + case socket.assigns[:current_video_id] do + ^video_id -> + {:ok, socket} + + nil -> + resume_active_video(socket, video_id, phase) + + _other -> + {:error, :unknown_worker_session} + end + end + + defp ensure_result_video(socket, video_id) do + case ensure_resumable_active_video(socket, video_id) do + {:ok, socket} -> + {:ok, socket} + + {:error, :unknown_worker_session} -> + allow_dispatched_video_result(socket, video_id) + end + end + + defp allow_dispatched_video_result(socket, video_id) do + case Media.get_video(video_id) do + %Media.Video{state: :crf_searching, crf_search_worker_id: dispatch_id} = video + when is_binary(dispatch_id) -> + {:ok, + socket + |> assign(:current_video_id, video.id) + |> assign(:current_vmaf_target, Reencodarr.Rules.vmaf_target(video))} + + _ -> + {:error, :unknown_worker_session} + end + end + + defp resume_active_video( + %{assigns: %{worker_id: worker_id}} = socket, + video_id, + phase + ) do + with %Media.Video{state: :crf_searching, crf_search_worker_id: dispatch_id} = video + when is_binary(dispatch_id) <- Media.get_video(video_id), + true <- dispatch_id == worker_dispatch_id(socket), + false <- assigned_to_other_worker?(worker_id, video_id), + {:ok, _session} <- WorkerSessions.assign_video(worker_id, video_id, phase) do + {:ok, + socket + |> assign(:current_video_id, video_id) + |> assign(:current_vmaf_target, Reencodarr.Rules.vmaf_target(video))} + else + _ -> {:error, :unknown_worker_session} + end + end + + defp assigned_to_other_worker?(worker_id, video_id) do + WorkerSessions.list() + |> Enum.any?(&(&1.server_worker_id != worker_id and &1.active_video_id == video_id)) + end + + defp shutdown_reason?(:shutdown), do: true + defp shutdown_reason?({:shutdown, _reason}), do: true + defp shutdown_reason?(_reason), do: false + + defp worker_dispatch_id(socket), + do: socket.assigns[:client_worker_id] || socket.assigns.worker_id + + defp persist_crf_results(video, results) do + Enum.reduce_while(results, {:ok, []}, fn result, {:ok, acc} -> + params = CrfSearch.build_crf_search_args(video, result_target(video, result)) + + attrs = + result + |> Map.put(:video_id, video.id) + |> Map.update(:params, params, fn + [] -> params + nil -> params + existing_params -> existing_params + end) + |> Map.delete(:chosen) + + case Media.upsert_vmaf(attrs) do + {:ok, :skipped} -> + {:cont, {:ok, [result | acc]}} + + {:ok, vmaf} -> + Events.broadcast_event(:crf_search_vmaf_result, vmaf_to_event(vmaf)) + {:cont, {:ok, [result | acc]}} + + {:error, _reason} -> + {:halt, {:error, :invalid_crf_search_result}} + end + end) + |> case do + {:ok, persisted_results} -> {:ok, Enum.reverse(persisted_results)} + {:error, _reason} = error -> error + end + end + + defp result_target(video, result), + do: Map.get(result, :target) || Reencodarr.Rules.vmaf_target(video) + + defp finalize_chosen_result_from_report(worker_id, socket, video, results) do + chosen_crf = chosen_crf_from_results(results) + + case chosen_crf do + nil -> + {:reply, {:ok, WorkerProtocol.event_ack("crf_search_result")}, socket} + + crf -> + mark_reported_chosen_crf(worker_id, socket, video, crf) + end + end + + defp chosen_crf_from_results(results) do + Enum.find_value(results, fn result -> + if Map.get(result, :chosen, false), do: Map.get(result, :crf) + end) + end + + defp mark_reported_chosen_crf(worker_id, socket, video, crf) do + case Media.mark_vmaf_as_chosen(video.id, crf) do + {:ok, _} -> + reply_after_successful_crf_search_result(worker_id, socket, video) + + {:error, _reason} -> + _ = + Media.record_video_failure(video, :crf_search, :validation, + code: "no_chosen_vmaf", + message: "CRF search reported a chosen CRF but no matching VMAF row was found", + context: %{video_id: video.id, chosen_crf: crf} + ) + + _ = Media.mark_as_failed(video) + + {:reply, {:error, WorkerProtocol.error(:invalid_crf_search_result)}, + clear_assigned_video(worker_id, socket)} + end + end + + defp reply_after_successful_crf_search_result(worker_id, socket, video) do + case finalize_successful_crf_search(worker_id, socket, video) do + {:ok, socket} -> + {:reply, {:ok, WorkerProtocol.event_ack("crf_search_result")}, socket} + + {:error, reason, socket} -> + {:reply, {:error, WorkerProtocol.error(reason)}, socket} + end + end + + defp finish_successful_crf_search(worker_id, socket, video, chosen_crf) do + cond do + is_number(chosen_crf) -> + case Media.mark_vmaf_as_chosen(video.id, chosen_crf) do + {:ok, _vmaf_id} -> + finalize_successful_crf_search(worker_id, socket, video) + + {:error, reason} -> + fail_invalid_successful_crf_search(worker_id, socket, video, reason) + end + + Media.chosen_vmaf_exists?(video) -> + finalize_successful_crf_search(worker_id, socket, video) + + true -> + fail_invalid_successful_crf_search(worker_id, socket, video, :no_chosen_vmaf) + end + end + + defp finalize_successful_crf_search(worker_id, socket, video) do + case Media.mark_as_crf_searched(video) do + {:ok, _video} -> + _ = Media.resolve_crf_search_failures(video.id) + {:ok, clear_assigned_video(worker_id, socket)} + + {:error, reason} -> + fail_invalid_successful_crf_search(worker_id, socket, video, reason) + end + end + + defp fail_invalid_successful_crf_search(worker_id, socket, video, reason) do + _ = + Media.record_video_failure(video, :crf_search, :validation, + code: "invalid_successful_crf_search", + message: "CRF search completed successfully but could not choose a VMAF", + context: %{video_id: video.id, reason: inspect(reason)} + ) + + _ = Media.mark_as_failed(video) + {:error, :invalid_crf_search_result, clear_assigned_video(worker_id, socket)} + end + + defp finish_cancelled_crf_search(worker_id, socket, video, _reason) do + _ = Media.mark_as_analyzed(video) + {:ok, clear_assigned_video(worker_id, socket)} + end + + defp clear_assigned_video(worker_id, socket) do + _ = WorkerSessions.clear_video(worker_id) + + assign(socket, :current_video_id, nil) + |> assign(:current_vmaf_target, nil) + |> assign(:transfer_io_device, nil) + |> assign(:transfer_path, nil) + |> assign(:transfer_id, nil) + |> assign(:transfer_chunk_size_bytes, nil) + |> assign(:transfer_total_bytes, nil) + |> assign(:transfer_total_chunks, nil) + |> assign(:transfer_bytes_sent, nil) + |> assign(:transfer_chunk_index, nil) + |> assign(:transfer_waiting_for_ack, nil) + |> assign(:transfer_started_sent, nil) + |> assign(:transfer_complete_pending, nil) + |> assign(:transfer_last_progress_at, nil) + |> assign(:transfer_last_progress_bytes, nil) + end + + defp mark_crf_search_active(worker_id, video_id) do + _ = WorkerSessions.set_crf_search_progress(worker_id, %{video_id: video_id}) + :ok + end + + defp open_transfer_stream( + %{assigns: %{current_video_id: video_id, transfer_path: path}} = socket + ) do + case File.open(path, [:read, :binary]) do + {:ok, io_device} -> + socket = + socket + |> assign(:transfer_io_device, io_device) + |> assign(:transfer_started_sent, false) + |> assign(:transfer_complete_pending, false) + |> assign(:transfer_last_progress_at, nil) + |> assign(:transfer_last_progress_bytes, nil) + + {:ok, socket} + + {:error, reason} -> + socket = handle_transfer_failure(socket, video_id, reason) + {:error, socket} + end + end + + defp push_transfer_start(%{assigns: %{current_video_id: video_id}} = socket) do + video = Media.get_video(video_id) + + if is_nil(video) do + {:noreply, handle_transfer_failure(socket, video_id, :enoent)} + else + _ = + WorkerSessions.record_transfer_progress( + socket.assigns.worker_id, + initial_transfer_progress(socket, video) + ) + + socket = + socket + |> assign(:transfer_started_sent, true) + + push( + socket, + "transfer_started", + WorkerProtocol.transfer_started( + video, + socket.assigns.transfer_id, + socket.assigns.transfer_chunk_size_bytes, + socket.assigns.transfer_total_bytes, + socket.assigns.transfer_total_chunks + ) + ) + + send(self(), :stream_transfer_chunk) + {:noreply, socket} + end + end + + defp initial_transfer_progress(socket, video) do + %WorkerProtocol.TransferProgress{ + job_id: socket.assigns.transfer_id, + video_id: video.id, + transfer_id: socket.assigns.transfer_id, + filename: Path.basename(video.path), + percent: 0.0, + bytes_sent: 0, + total_bytes: socket.assigns.transfer_total_bytes, + bytes_per_second: nil, + eta: nil, + chunk_index: 0, + total_chunks: socket.assigns.transfer_total_chunks + } + end + + defp read_transfer_chunk(%{assigns: %{current_video_id: video_id}} = socket, io_device) do + video = Media.get_video(video_id) + + if is_nil(video) do + close_transfer_stream(io_device) + {:noreply, socket} + else + case IO.binread(io_device, socket.assigns.transfer_chunk_size_bytes) do + chunk when is_binary(chunk) -> + chunk_index = socket.assigns.transfer_chunk_index + bytes_sent = socket.assigns.transfer_bytes_sent + byte_size(chunk) + + push( + socket, + "transfer_chunk", + WorkerProtocol.transfer_chunk( + video, + socket.assigns.transfer_id, + chunk_index, + socket.assigns.transfer_total_chunks, + bytes_sent, + socket.assigns.transfer_total_bytes, + chunk + ) + ) + + {:noreply, + socket + |> assign(:transfer_bytes_sent, bytes_sent) + |> assign(:transfer_chunk_index, chunk_index + 1) + |> assign(:transfer_waiting_for_ack, true) + |> assign( + :transfer_complete_pending, + bytes_sent >= socket.assigns.transfer_total_bytes + )} + + :eof -> + {:noreply, complete_transfer(socket, video, io_device)} + + {:error, reason} -> + close_transfer_stream(io_device) + {:noreply, handle_transfer_failure(socket, video_id, reason)} + end + end + end + + defp complete_transfer(socket, video, io_device) do + close_transfer_stream(io_device) + + push( + socket, + "transfer_complete", + WorkerProtocol.transfer_complete( + video, + socket.assigns.transfer_id, + socket.assigns.transfer_total_bytes, + socket.assigns.transfer_total_chunks + ) + ) + + socket + |> assign(:transfer_io_device, nil) + |> assign(:transfer_path, nil) + |> assign(:transfer_id, nil) + |> assign(:transfer_chunk_size_bytes, nil) + |> assign(:transfer_total_bytes, nil) + |> assign(:transfer_total_chunks, nil) + |> assign(:transfer_bytes_sent, nil) + |> assign(:transfer_chunk_index, nil) + |> assign(:transfer_waiting_for_ack, nil) + |> assign(:transfer_started_sent, nil) + |> assign(:transfer_complete_pending, nil) + |> assign(:transfer_last_progress_at, nil) + |> assign(:transfer_last_progress_bytes, nil) + end + + defp fill_transfer_rate(socket, progress) do + now = System.monotonic_time(:millisecond) + + progress = + progress + |> maybe_fill_bytes_per_second(socket, now) + |> maybe_fill_eta() + + socket = + socket + |> assign(:transfer_last_progress_at, now) + |> assign(:transfer_last_progress_bytes, progress.bytes_sent) + + {progress, socket} + end + + defp maybe_fill_bytes_per_second(%{bytes_per_second: rate} = progress, _socket, _now) + when is_integer(rate) and rate >= 0, + do: progress + + defp maybe_fill_bytes_per_second( + %{bytes_sent: bytes_sent} = progress, + %{ + assigns: %{ + transfer_last_progress_at: last_at, + transfer_last_progress_bytes: last_bytes + } + }, + now + ) + when is_integer(bytes_sent) and is_integer(last_bytes) and is_integer(last_at) and + bytes_sent >= last_bytes and now > last_at do + elapsed_ms = now - last_at + bytes_delta = bytes_sent - last_bytes + + if bytes_delta > 0 do + %{progress | bytes_per_second: div(bytes_delta * 1_000, elapsed_ms)} + else + progress + end + end + + defp maybe_fill_bytes_per_second(progress, _socket, _now), do: progress + + defp maybe_fill_eta(%{eta: eta} = progress) when is_integer(eta) and eta >= 0, + do: progress + + defp maybe_fill_eta(%{bytes_per_second: rate, bytes_sent: sent, total_bytes: total} = progress) + when is_integer(rate) and rate > 0 and is_integer(sent) and is_integer(total) and + total > sent do + %{progress | eta: ceil((total - sent) / rate)} + end + + defp maybe_fill_eta(progress), do: progress + + defp handle_transfer_failure(socket, video_id, reason) do + case Media.get_video(video_id) do + nil -> + clear_assigned_video(socket.assigns.worker_id, socket) + + video -> + message = format_file_error(reason) + + _ = + Media.record_video_failure(video, :crf_search, :file_access, + message: "Transfer failed: #{message}", + context: %{ + transfer_id: socket.assigns[:transfer_id], + path: socket.assigns[:transfer_path] + } + ) + + socket = clear_assigned_video(socket.assigns.worker_id, socket) + + _ = + push( + socket, + "transfer_failed", + WorkerProtocol.transfer_failed(video, Integer.to_string(video.id), message) + ) + + socket + end + end + + defp close_transfer_stream(nil), do: :ok + defp close_transfer_stream(io_device), do: File.close(io_device) + + defp maybe_send_next_transfer_chunk( + %{ + assigns: %{ + transfer_id: transfer_id, + transfer_bytes_sent: bytes_sent, + transfer_waiting_for_ack: true, + transfer_complete_pending: complete_pending? + } + } = socket, + %{transfer_id: transfer_id, bytes_sent: acknowledged_bytes} + ) + when is_integer(bytes_sent) and is_integer(acknowledged_bytes) and + acknowledged_bytes >= bytes_sent do + if complete_pending? do + send(self(), :complete_transfer) + else + send(self(), :stream_transfer_chunk) + end + + assign(socket, :transfer_waiting_for_ack, false) + end + + defp maybe_send_next_transfer_chunk(socket, _progress), do: socket + + defp format_file_error(reason) do + reason |> :file.format_error() |> List.to_string() + end + + defp total_chunks(0, _chunk_size_bytes), do: 0 + + defp total_chunks(total_bytes, chunk_size_bytes), + do: div(total_bytes + chunk_size_bytes - 1, chunk_size_bytes) + + defp vmaf_to_event(%Reencodarr.Media.Vmaf{} = vmaf) do + %{ + video_id: vmaf.video_id, + crf: vmaf.crf, + score: vmaf.score, + percent: vmaf.percent, + size: vmaf.size, + time: vmaf.time, + params: vmaf.params || [] + } + end +end diff --git a/lib/reencodarr_web/channels/worker_socket.ex b/lib/reencodarr_web/channels/worker_socket.ex new file mode 100644 index 00000000..9ae2d72e --- /dev/null +++ b/lib/reencodarr_web/channels/worker_socket.ex @@ -0,0 +1,32 @@ +defmodule ReencodarrWeb.WorkerSocket do + @moduledoc """ + Authenticated websocket socket for ab-av1 worker clients. + """ + + use Phoenix.Socket + + alias Reencodarr.AbAv1.WorkerConfig + alias Reencodarr.AbAv1.WorkerProtocol + + channel WorkerProtocol.crf_search_topic(), ReencodarrWeb.WorkerChannel + + @impl true + def connect(%{"token" => token}, socket, _connect_info) when is_binary(token) do + with true <- WorkerConfig.enabled?(), + {:ok, configured_token} when is_binary(configured_token) <- + Application.fetch_env(:reencodarr, :worker_token), + true <- Plug.Crypto.secure_compare(configured_token, token) do + {:ok, assign(socket, :worker_id, worker_id())} + else + _ -> :error + end + end + + def connect(_params, _socket, _connect_info), do: :error + + @impl true + def id(%{assigns: %{worker_id: worker_id}}), do: "worker_socket:#{worker_id}" + def id(_socket), do: nil + + defp worker_id, do: "worker-" <> Base.encode16(:crypto.strong_rand_bytes(8), case: :lower) +end diff --git a/lib/reencodarr_web/components/crf_search_components.ex b/lib/reencodarr_web/components/crf_search_components.ex new file mode 100644 index 00000000..1d971213 --- /dev/null +++ b/lib/reencodarr_web/components/crf_search_components.ex @@ -0,0 +1,376 @@ +defmodule ReencodarrWeb.CrfSearchComponents do + @moduledoc "Shared CRF search display components." + + use Phoenix.Component + + alias Reencodarr.Formatters + alias ReencodarrWeb.ChartHelpers + + @service_status_styles %{ + running: "bg-green-100 text-green-800", + paused: "bg-yellow-100 text-yellow-800", + processing: "bg-blue-100 text-blue-800", + pausing: "bg-orange-100 text-orange-800", + idle: "bg-cyan-100 text-cyan-800", + checking: "bg-gray-100 text-gray-600 dashboard-soft-pulse", + stopped: "bg-red-100 text-red-800", + unknown: "bg-gray-100 text-gray-800" + } + + @service_status_labels %{ + running: "Running", + paused: "Paused", + processing: "Processing", + pausing: "Pausing", + idle: "Idle", + checking: "Checking...", + stopped: "Stopped", + unknown: "Unknown" + } + + attr :results, :list, required: true + attr :target_vmaf, :integer, required: true + attr :testing_crf, :float, default: nil + + def crf_search_chart(assigns) do + scores = Enum.map(assigns.results, & &1.score) + + vmaf_min = + min(assigns.target_vmaf - 3, Enum.min(scores, fn -> assigns.target_vmaf - 3 end) - 1) + + vmaf_max = + max(assigns.target_vmaf + 3, Enum.max(scores, fn -> assigns.target_vmaf + 3 end) + 1) + + {crf_min, crf_max} = ChartHelpers.crf_range_from_results(assigns.results) + + dots = + Enum.with_index(assigns.results, fn r, idx -> + %{ + x: ChartHelpers.crf_to_x(r.crf, crf_min, crf_max), + y: ChartHelpers.vmaf_to_y(r.score, vmaf_min, vmaf_max), + crf: r.crf, + score: r.score, + above: r.score >= assigns.target_vmaf, + is_latest: idx == length(assigns.results) - 1 + } + end) + + target_y = ChartHelpers.vmaf_to_y(assigns.target_vmaf, vmaf_min, vmaf_max) + + y_ticks = + for vmaf <- trunc(vmaf_min)..trunc(vmaf_max) do + %{value: vmaf, y: ChartHelpers.vmaf_to_y(vmaf, vmaf_min, vmaf_max)} + end + + x_ticks = + for crf <- ChartHelpers.generate_x_ticks(trunc(crf_min), trunc(crf_max)) do + %{value: crf, x: ChartHelpers.crf_to_x(crf, crf_min, crf_max)} + end + + assigns = + assign(assigns, + vmaf_min: vmaf_min, + vmaf_max: vmaf_max, + crf_min: crf_min, + crf_max: crf_max, + dots: dots, + target_y: target_y, + y_ticks: y_ticks, + x_ticks: x_ticks + ) + + ~H""" + + <%= for tick <- @y_ticks do %> + + <% end %> + + + + {@target_vmaf} + + + <%= for dot <- @dots do %> + + <%= if dot.is_latest do %> + + {Formatters.vmaf_score(dot.score, 1)} + + <% end %> + <% end %> + + <%= if @testing_crf do %> + + + CRF {Formatters.crf(@testing_crf)} + + <% end %> + + <%= for tick <- @y_ticks do %> + + {tick.value} + + <% end %> + + <%= for tick <- @x_ticks do %> + + {tick.value} + + <% end %> + + + + + """ + end + + attr :video, :map, required: true + attr :results, :list, required: true + attr :sample, :map, required: true + attr :progress, :any, default: :none + attr :queue_count, :integer, default: 0 + attr :queue_items, :list, default: [] + attr :status, :atom, required: true + attr :show_controls, :boolean, default: true + attr :show_queue, :boolean, default: true + attr :show_empty_chart, :boolean, default: false + attr :suspend_event, :string, default: "suspend_crf_search" + attr :resume_event, :string, default: "resume_crf_search" + attr :fail_event, :string, default: "fail_crf_search" + attr :worker_id, :string, default: nil + + def crf_search_panel(assigns) do + ~H""" +
+
+

CRF Search

+ + {service_status_text(@status)} + +
+ + <%= if @video do %> +
+
+
{@video.filename}
+
+ {Formatters.file_size(@video.video_size)} + {@video.width}x{@video.height} + <%= if @video.hdr do %> + HDR + <% end %> + Target: {@video.target_vmaf} VMAF +
+
+ + <%= if @sample do %> +
+ Sample {@sample.sample_num}/{@sample.total_samples} - CRF {@sample.crf} +
+ <% end %> + + <%= if @progress != :none && Map.get(@progress, :percent) != nil do %> +
+
+
+
+
+
+ {@progress.percent}% + <%= if @progress[:fps] do %> + {@progress.fps} fps + <% end %> + <%= if @progress[:eta] do %> + ETA: {@progress.eta} + <% end %> +
+
+ <% end %> + + <.active_job_controls + :if={@show_controls} + status={@status} + suspend_event={@suspend_event} + resume_event={@resume_event} + fail_event={@fail_event} + worker_id={@worker_id} + /> + + <%= if @show_empty_chart or length(@results) > 0 or @sample do %> +
+ <.crf_search_chart + results={@results} + target_vmaf={@video.target_vmaf} + testing_crf={@sample && @sample.crf} + /> + + <%= if length(@results) > 0 do %> +
+ <%= for result <- @results do %> +
+ + CRF {Formatters.crf(result.crf)} -> {Formatters.vmaf_score(result.score, 1)} VMAF + + {if result[:percent], do: "#{result.percent}%", else: "-"} +
+ <% end %> +
+ <% else %> +
+ <%= if @sample do %> + Sampling CRF {Formatters.crf(@sample.crf)}... waiting for first completed VMAF result. + <% else %> + Waiting for first CRF sample. + <% end %> +
+ <% end %> +
+ <% end %> +
+ <% else %> +
+ Queue: {@queue_count} + <%= if @status == :idle do %> + - Idle + <% end %> +
+ + <%= if @show_controls and @status == :paused do %> +
+ <.active_job_controls + status={@status} + suspend_event={@suspend_event} + resume_event={@resume_event} + fail_event={@fail_event} + worker_id={@worker_id} + /> +
+ <% end %> + <% end %> + + <%= if @show_queue and length(@queue_items) > 0 do %> +
+
Next up ({@queue_count}):
+ <%= for video <- Enum.take(@queue_items, 5) do %> +
+ +
{Path.basename(video.path)}
+
+ <% end %> +
+ <% end %> +
+ """ + end + + attr :status, :atom, required: true + attr :suspend_event, :string, required: true + attr :resume_event, :string, required: true + attr :fail_event, :string, required: true + attr :worker_id, :string, default: nil + + defp active_job_controls(assigns) do + ~H""" +
+ <%= if @status == :paused do %> + + <% else %> + + <% end %> + | + +
+ """ + end + + defp service_status_class(status), + do: @service_status_styles[status] || @service_status_styles.unknown + + defp service_status_text(status), + do: @service_status_labels[status] || @service_status_labels.unknown +end diff --git a/lib/reencodarr_web/components/layouts/app.html.heex b/lib/reencodarr_web/components/layouts/app.html.heex index fda0a927..75a5f76b 100644 --- a/lib/reencodarr_web/components/layouts/app.html.heex +++ b/lib/reencodarr_web/components/layouts/app.html.heex @@ -48,6 +48,12 @@ > Failures + <.link + navigate={~p"/workers"} + class="rounded px-3 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-800 hover:text-white" + > + Workers + <.link navigate={~p"/videos"} class="rounded px-3 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-800 hover:text-white" @@ -94,6 +100,12 @@ > Failures + <.link + navigate={~p"/workers"} + class="rounded px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-gray-700 hover:text-white sm:px-3 sm:py-2 sm:text-sm" + > + Workers + <.link navigate={~p"/videos"} class="rounded px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-gray-700 hover:text-white sm:px-3 sm:py-2 sm:text-sm" diff --git a/lib/reencodarr_web/controllers/worker_file_controller.ex b/lib/reencodarr_web/controllers/worker_file_controller.ex new file mode 100644 index 00000000..2e5b6d3b --- /dev/null +++ b/lib/reencodarr_web/controllers/worker_file_controller.ex @@ -0,0 +1,33 @@ +defmodule ReencodarrWeb.WorkerFileController do + @moduledoc false + + use ReencodarrWeb, :controller + + alias Reencodarr.AbAv1.WorkerConfig + alias Reencodarr.Media + + def show(conn, %{"id" => id}) do + with :ok <- authorize(conn), + {video_id, ""} <- Integer.parse(id), + %Media.Video{path: path} <- Media.get_video(video_id), + true <- File.regular?(path) do + conn + |> put_resp_content_type("application/octet-stream") + |> send_file(200, path) + else + {:error, :unauthorized} -> send_resp(conn, 401, "unauthorized") + _ -> send_resp(conn, 404, "not found") + end + end + + defp authorize(conn) do + with ["Bearer " <> provided] <- get_req_header(conn, "authorization"), + token when is_binary(token) <- WorkerConfig.transfer_token(), + true <- byte_size(provided) == byte_size(token), + true <- Plug.Crypto.secure_compare(provided, token) do + :ok + else + _ -> {:error, :unauthorized} + end + end +end diff --git a/lib/reencodarr_web/endpoint.ex b/lib/reencodarr_web/endpoint.ex index d2c5d1b7..aa9586dc 100644 --- a/lib/reencodarr_web/endpoint.ex +++ b/lib/reencodarr_web/endpoint.ex @@ -18,6 +18,12 @@ defmodule ReencodarrWeb.Endpoint do # Socket for embedded LiveViews (like Broadway Dashboard in iframe) socket "/embed/live", Phoenix.LiveView.Socket, websocket: true, longpoll: true + # ab-av1 workers connect through the main Phoenix endpoint on the same + # host/port as the rest of the app. + socket "/workers/socket", ReencodarrWeb.WorkerSocket, + websocket: true, + longpoll: false + # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phx.digest diff --git a/lib/reencodarr_web/live/dashboard_live.ex b/lib/reencodarr_web/live/dashboard_live.ex index 02e2b9c0..2f1d6556 100644 --- a/lib/reencodarr_web/live/dashboard_live.ex +++ b/lib/reencodarr_web/live/dashboard_live.ex @@ -19,6 +19,7 @@ defmodule ReencodarrWeb.DashboardLive do alias Reencodarr.Media.VideoQueries import ReencodarrWeb.ChartComponents + import ReencodarrWeb.CrfSearchComponents require Logger @@ -51,6 +52,8 @@ defmodule ReencodarrWeb.DashboardLive do sync_progress: 0, service_type: nil, page_title: nil, + worker_token_state: worker_token_state(), + worker_socket_url: worker_socket_url(), # New dashboard stats stats: Reencodarr.Media.get_default_stats(), stats_display: stats_display(Reencodarr.Media.get_default_stats()), @@ -375,297 +378,6 @@ defmodule ReencodarrWeb.DashboardLive do """ end - # CRF Search Scatter Plot (SVG visualization) - attr :results, :list, required: true - attr :target_vmaf, :integer, required: true - attr :testing_crf, :float, default: nil - - defp crf_search_chart(assigns) do - alias ReencodarrWeb.ChartHelpers - - # Calculate VMAF y-axis range from target ± results - scores = Enum.map(assigns.results, & &1.score) - - vmaf_min = - min(assigns.target_vmaf - 3, Enum.min(scores, fn -> assigns.target_vmaf - 3 end) - 1) - - vmaf_max = - max(assigns.target_vmaf + 3, Enum.max(scores, fn -> assigns.target_vmaf + 3 end) + 1) - - # Dynamic CRF axis range from actual results - {crf_min, crf_max} = ChartHelpers.crf_range_from_results(assigns.results) - - # Pre-compute dot positions - dots = - Enum.with_index(assigns.results, fn r, idx -> - %{ - x: ChartHelpers.crf_to_x(r.crf, crf_min, crf_max), - y: ChartHelpers.vmaf_to_y(r.score, vmaf_min, vmaf_max), - crf: r.crf, - score: r.score, - above: r.score >= assigns.target_vmaf, - is_latest: idx == length(assigns.results) - 1 - } - end) - - # Calculate target line y-coordinate - target_y = ChartHelpers.vmaf_to_y(assigns.target_vmaf, vmaf_min, vmaf_max) - - # Y-axis tick labels (every 1 VMAF) - y_ticks = - for vmaf <- trunc(vmaf_min)..trunc(vmaf_max) do - %{value: vmaf, y: ChartHelpers.vmaf_to_y(vmaf, vmaf_min, vmaf_max)} - end - - # X-axis tick labels (dynamic based on actual CRF range) - x_ticks = - for crf <- ChartHelpers.generate_x_ticks(trunc(crf_min), trunc(crf_max)) do - %{value: crf, x: ChartHelpers.crf_to_x(crf, crf_min, crf_max)} - end - - assigns = - assign(assigns, - vmaf_min: vmaf_min, - vmaf_max: vmaf_max, - crf_min: crf_min, - crf_max: crf_max, - dots: dots, - target_y: target_y, - y_ticks: y_ticks, - x_ticks: x_ticks - ) - - ~H""" - - - <%= for tick <- @y_ticks do %> - - <% end %> - - - - - {@target_vmaf} - - - - <%= for dot <- @dots do %> - - <%= if dot.is_latest do %> - - {Formatters.vmaf_score(dot.score, 1)} - - <% end %> - <% end %> - - - <%= if @testing_crf do %> - - - CRF {Formatters.crf(@testing_crf)} - - <% end %> - - - <%= for tick <- @y_ticks do %> - - {tick.value} - - <% end %> - - - <%= for tick <- @x_ticks do %> - - {tick.value} - - <% end %> - - - - - - """ - end - - # SVG coordinate helpers delegated to ChartHelpers (dynamic CRF range) - - # Row 2: CRF Search Panel Component - attr :video, :map, required: true - attr :results, :list, required: true - attr :sample, :map, required: true - attr :progress, :any, required: true - attr :queue_count, :integer, required: true - attr :queue_items, :list, required: true - attr :status, :atom, required: true - - defp crf_search_panel(assigns) do - ~H""" -
-
-

CRF Search

- - {service_status_text(@status)} - -
- - <%= if @video do %> - -
- -
-
{@video.filename}
-
- {Formatters.file_size(@video.video_size)} - {@video.width}x{@video.height} - <%= if @video.hdr do %> - HDR - <% end %> - Target: {@video.target_vmaf} VMAF -
-
- - - <%= if @sample do %> -
- Sample {@sample.sample_num}/{@sample.total_samples} — CRF {@sample.crf} -
- <% end %> - <.active_job_controls - status={@status} - suspend_event="suspend_crf_search" - resume_event="resume_crf_search" - fail_event="fail_crf_search" - /> - - - <%= if length(@results) > 0 or @sample do %> -
- <.crf_search_chart - results={@results} - target_vmaf={@video.target_vmaf} - testing_crf={@sample && @sample.crf} - /> - - - <%= if length(@results) > 0 do %> -
- <%= for result <- @results do %> -
- - CRF {Formatters.crf(result.crf)} → {Formatters.vmaf_score(result.score, 1)} VMAF - - {if result[:percent], do: "#{result.percent}%", else: "—"} -
- <% end %> -
- <% else %> -
- Sampling CRF {Formatters.crf(@sample.crf)}... waiting for first completed VMAF result. -
- <% end %> -
- <% end %> -
- <% else %> - -
- Queue: {@queue_count} - <%= if @status == :idle do %> - • Idle - <% end %> -
- - <%= if @status == :paused do %> -
- <.active_job_controls - status={@status} - suspend_event="suspend_crf_search" - resume_event="resume_crf_search" - fail_event="fail_crf_search" - /> -
- <% end %> - <% end %> - - - <%= if length(@queue_items) > 0 do %> -
-
Next up ({@queue_count}):
- <%= for video <- Enum.take(@queue_items, 5) do %> -
- -
{Path.basename(video.path)}
-
- <% end %> -
- <% end %> -
- """ - end - # Row 2: Encoding Panel Component attr :video, :map, required: true attr :vmaf, :map, required: true @@ -986,49 +698,103 @@ defmodule ReencodarrWeb.DashboardLive do attr :syncing, :boolean, required: true attr :sync_progress, :integer, required: true attr :service_type, :atom, required: true + attr :worker_token_state, :any, required: true + attr :worker_socket_url, :string, required: true defp sync_controls(assigns) do ~H""" -
-
-

Media Library Sync

- -
- - +
+
+
+

Media Library Sync

+ +
+ + +
+ + <%= if @syncing do %> +
+
+
+
+
+
+ Syncing {@service_type}... {@sync_progress}% +
+
+ <% end %>
- <%= if @syncing do %> -
-
-
+
+
+

Worker WebSocket

+ ab-av1 +
+ +
+
+
URL
+
+ {@worker_socket_url}?token=<worker-token> +
+
+ +
+
Token
+
+ <%= case @worker_token_state do %> + <% {:ok, token, _fingerprint} -> %> + {token} + <% :error -> %> + not configured + <% end %> +
+
+ +
+
Token fingerprint
+
+ <%= case @worker_token_state do %> + <% {:ok, _token, fingerprint} -> %> + {fingerprint} + <% :error -> %> + not configured + <% end %>
-
- Syncing {@service_type}... {@sync_progress}% + +
+
Worker sessions
+ <.link + navigate={~p"/workers"} + class="rounded-full border border-cyan-900 bg-cyan-950 px-3 py-1 text-xs text-cyan-300 hover:border-cyan-700 hover:text-white" + > + Open workers +
- <% end %> +
""" end @@ -1048,6 +814,21 @@ defmodule ReencodarrWeb.DashboardLive do defp format_number(_), do: "—" + defp worker_token_state do + case Application.get_env(:reencodarr, :worker_token) do + token when is_binary(token) -> + digest = + :crypto.hash(:sha256, token) + |> Base.encode16(case: :lower) + |> String.slice(0, 12) + + {:ok, token, "sha256:#{digest}"} + + _ -> + :error + end + end + defp format_completed(stats) do total = stats.total_videos || 0 encoded = stats.encoded || 0 @@ -1168,7 +949,6 @@ defmodule ReencodarrWeb.DashboardLive do video={@crf_search_video} results={@crf_search_results} sample={@crf_search_sample} - progress={@crf_progress} queue_count={@queue_counts.crf_searcher} queue_items={@queue_items.crf_searcher} status={@service_status.crf_searcher} @@ -1224,6 +1004,8 @@ defmodule ReencodarrWeb.DashboardLive do syncing={@syncing} sync_progress={@sync_progress} service_type={@service_type} + worker_token_state={@worker_token_state} + worker_socket_url={@worker_socket_url} />
@@ -1385,6 +1167,13 @@ defmodule ReencodarrWeb.DashboardLive do Process.send_after(self(), :update_dashboard_data, 5_000) end + defp worker_socket_url do + ReencodarrWeb.Endpoint.url() + |> String.replace_prefix("https://", "wss://") + |> String.replace_prefix("http://", "ws://") + |> Kernel.<>("/workers/socket/websocket") + end + defp stats_display(nil) do %{ total_videos: "—", diff --git a/lib/reencodarr_web/live/workers_live.ex b/lib/reencodarr_web/live/workers_live.ex new file mode 100644 index 00000000..ad337a61 --- /dev/null +++ b/lib/reencodarr_web/live/workers_live.ex @@ -0,0 +1,409 @@ +defmodule ReencodarrWeb.WorkersLive do + @moduledoc """ + LiveView for connected ab-av1 workers and their current status. + """ + + use ReencodarrWeb, :live_view + + alias Reencodarr.AbAv1.WorkerSessions + alias Reencodarr.Dashboard.Events + alias Reencodarr.Formatters + alias Reencodarr.Media + alias Reencodarr.Rules + + import ReencodarrWeb.CrfSearchComponents + + @refresh_interval 5_000 + + @impl true + def mount(_params, _session, socket) do + socket = assign(socket, :workers, WorkerSessions.list()) + + if connected?(socket) do + Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel()) + Process.send_after(self(), :refresh_workers, @refresh_interval) + end + + {:ok, socket} + end + + @impl true + def handle_info(:refresh_workers, socket) do + Process.send_after(self(), :refresh_workers, @refresh_interval) + {:noreply, assign(socket, :workers, WorkerSessions.list())} + end + + @impl true + def handle_info({:worker_sessions_updated, %{sessions: sessions}}, socket) do + {:noreply, assign(socket, :workers, sessions)} + end + + def handle_info({_event, _data}, socket), do: {:noreply, socket} + + @impl true + def handle_event("pause_worker_crf_search", %{"worker-id" => worker_id}, socket) do + control_worker(socket, worker_id, :pause, "Worker pause requested") + end + + def handle_event("resume_worker_crf_search", %{"worker-id" => worker_id}, socket) do + control_worker(socket, worker_id, :resume, "Worker resume requested") + end + + def handle_event("stop_worker_crf_search", %{"worker-id" => worker_id}, socket) do + control_worker(socket, worker_id, :stop, "Worker stop requested") + end + + @impl true + def render(assigns) do + ~H""" +
+
+
+
+

ab-av1

+

Workers

+

+ Connected worker sessions and their current job state. +

+
+ +
+
+ Connected {length(@workers)} +
+ <.link + navigate={~p"/"} + class="rounded-full border border-gray-800 bg-gray-900 px-3 py-1 text-xs text-gray-300 hover:border-cyan-700 hover:text-white" + > + Dashboard + +
+
+ + <%= if @workers == [] do %> +
+ No workers connected. +
+ <% else %> +
+ <%= for worker <- @workers do %> +
+ + +
+ <%= case worker_phase(worker) do %> + <% :receiving_input -> %> + <.transfer_panel + worker={worker} + video={active_video(worker)} + title="Receiving Input" + /> + <% :input_ready -> %> + <.transfer_panel + worker={worker} + video={active_video(worker)} + title="Input Ready" + /> + <% :crf_searching -> %> + <.crf_search_panel + video={worker_crf_video(worker)} + results={worker_crf_results(worker)} + sample={worker_crf_sample(worker)} + progress={worker.crf_search_progress || :none} + status={worker_crf_status(worker)} + show_controls={true} + show_queue={false} + show_empty_chart={true} + suspend_event="pause_worker_crf_search" + resume_event="resume_worker_crf_search" + fail_event="stop_worker_crf_search" + worker_id={worker.server_worker_id} + /> + <% :idle -> %> + <.idle_panel /> + <% end %> +
+
+ <% end %> +
+ <% end %> +
+
+ """ + end + + defp idle_panel(assigns) do + ~H""" +
+

Idle

+
Waiting for work.
+
+ """ + end + + defp control_worker(socket, worker_id, action, message) do + Phoenix.PubSub.broadcast( + Reencodarr.PubSub, + ReencodarrWeb.WorkerChannel.worker_control_topic(worker_id), + {:worker_control, action} + ) + + {:noreply, put_flash(socket, :info, message)} + end + + defp transfer_panel(assigns) do + ~H""" +
+
+

{@title}

+ + {format_number(@worker.transfer_progress && @worker.transfer_progress.percent)}% + +
+ + <%= if @video do %> +
+
{Path.basename(@video.path)}
+
+ {Formatters.file_size(@video.size)} + {format_dimensions(@video)} + <%= if @video.hdr do %> + HDR + <% end %> + Target: {Rules.vmaf_target(@video)} VMAF +
+
+ <% end %> + + <%= if @worker.transfer_progress do %> +
+
+
+
+
+ +
+ {format_transfer_bytes(@worker.transfer_progress)} + <%= if chunk_progress = transfer_chunk_progress(@worker.transfer_progress) do %> + Chunk {chunk_progress} + <% end %> + {format_throughput(Map.get(@worker.transfer_progress, :bytes_per_second))} + ETA {format_eta(Map.get(@worker.transfer_progress, :eta))} +
+
+ <% else %> +
Waiting for work.
+ <% end %> +
+ """ + end + + defp worker_status(worker) do + case worker_phase(worker) do + :idle -> "Idle" + :receiving_input -> "Receiving input" + :input_ready -> "Input ready" + :crf_searching -> "CRF search" + end + end + + defp worker_crf_status(%{phase: :crf_searching}), do: :processing + + defp worker_crf_status(_worker), do: :idle + + defp worker_phase(%{phase: phase}) + when phase in [:idle, :receiving_input, :input_ready, :crf_searching], + do: phase + + defp worker_phase(%{active_video_id: nil}), do: :idle + + defp worker_phase(%{transfer_progress: progress}) when not is_nil(progress), + do: :receiving_input + + defp worker_phase(%{active_video_id: video_id}) when is_integer(video_id), do: :crf_searching + defp worker_phase(_worker), do: :idle + + defp worker_crf_video(worker) do + case active_video(worker) do + %Media.Video{} = video -> + %{ + video_id: video.id, + filename: Path.basename(video.path), + video_size: video.size, + width: video.width, + height: video.height, + hdr: video.hdr, + target_vmaf: Rules.vmaf_target(video) + } + + nil -> + nil + end + end + + defp worker_crf_results(worker) do + case active_video_id(worker) do + nil -> + [] + + video_id -> + video_id + |> Media.get_vmafs_for_video() + |> Enum.sort_by(& &1.crf) + |> Enum.map(fn vmaf -> + %{crf: vmaf.crf, score: vmaf.score, percent: vmaf.percent} + end) + end + end + + defp worker_crf_sample(%{ + crf_search_progress: %{crf: crf, sample_num: sample_num, total_samples: total_samples} + }) + when is_number(crf) and is_integer(sample_num) and is_integer(total_samples) do + %{crf: crf, sample_num: sample_num, total_samples: total_samples} + end + + defp worker_crf_sample(_worker), do: nil + + defp active_video(worker) do + case active_video_id(worker) do + nil -> nil + video_id -> Media.get_video(video_id) + end + end + + defp active_video_id(%{active_video_id: video_id}) when is_integer(video_id), do: video_id + defp active_video_id(%{crf_search_progress: %{video_id: video_id}}), do: video_id + defp active_video_id(%{transfer_progress: %{video_id: video_id}}), do: video_id + defp active_video_id(_worker), do: nil + + defp worker_cpu(%{resource_usage: %{cpu_percent: cpu_percent}}) when is_number(cpu_percent), + do: "#{format_number(cpu_percent)}%" + + defp worker_cpu(_worker), do: "-" + + defp worker_memory(%{resource_usage: %{memory_bytes: memory_bytes} = usage}) + when is_integer(memory_bytes) do + case Map.get(usage, :memory_total_bytes) do + total when is_integer(total) and total > 0 -> + "#{Formatters.file_size(memory_bytes)} / #{Formatters.file_size(total)}" + + _ -> + Formatters.file_size(memory_bytes) + end + end + + defp worker_memory(_worker), do: "-" + + defp worker_disk(%{resource_usage: %{disk_free_bytes: disk_free_bytes} = usage}) + when is_integer(disk_free_bytes) do + case Map.get(usage, :disk_total_bytes) do + total when is_integer(total) and total > 0 -> + "#{Formatters.file_size(disk_free_bytes)} free / #{Formatters.file_size(total)}" + + _ -> + "#{Formatters.file_size(disk_free_bytes)} free" + end + end + + defp worker_disk(_worker), do: "-" + + defp worker_resource_usage?(%{resource_usage: usage}) when is_map(usage), + do: map_size(usage) > 0 + + defp worker_resource_usage?(_worker), do: false + + defp format_number(number) when is_integer(number), do: Integer.to_string(number) + + defp format_number(number) when is_float(number) do + :erlang.float_to_binary(number, decimals: 1) + end + + defp format_number(nil), do: "-" + + defp progress_width(percent) when is_number(percent) do + percent + |> max(0) + |> min(100) + |> format_number() + |> Kernel.<>("%") + end + + defp progress_width(_percent), do: "0%" + + defp format_dimensions(%{width: width, height: height}) + when is_integer(width) and is_integer(height), + do: "#{width}x#{height}" + + defp format_dimensions(_video), do: "unknown resolution" + + defp format_throughput(bytes_per_second) + when is_integer(bytes_per_second) and bytes_per_second >= 0 do + "#{Formatters.file_size(bytes_per_second)}/s" + end + + defp format_throughput(_), do: "-" + + defp format_eta(seconds) when is_integer(seconds) and seconds >= 0, do: "#{seconds}s" + defp format_eta(_), do: "-" + + defp format_transfer_bytes(bytes_sent, total_bytes) + when is_integer(bytes_sent) and bytes_sent >= 0 and is_integer(total_bytes) and + total_bytes > 0 do + "#{Formatters.file_size(bytes_sent)} / #{Formatters.file_size(total_bytes)}" + end + + defp format_transfer_bytes(bytes_sent, _total_bytes) + when is_integer(bytes_sent) and bytes_sent >= 0, + do: Formatters.file_size(bytes_sent) + + defp format_transfer_bytes(_bytes_sent, _total_bytes), do: "-" + + defp format_transfer_bytes(%{bytes_sent: bytes_sent, total_bytes: total_bytes}), + do: format_transfer_bytes(bytes_sent, total_bytes) + + defp transfer_chunk_progress(%{chunk_index: chunk_index, total_chunks: total_chunks}) + when is_integer(chunk_index) and is_integer(total_chunks) and total_chunks > 0 do + "#{chunk_index + 1} / #{total_chunks}" + end + + defp transfer_chunk_progress(_progress), do: nil + + defp status_badge_class(worker) do + case worker_phase(worker) do + :idle -> + "inline-flex rounded-full border border-emerald-900 bg-emerald-950 px-2 py-1 text-xs font-medium text-emerald-300" + + _active -> + "inline-flex rounded-full border border-cyan-900 bg-cyan-950 px-2 py-1 text-xs font-medium text-cyan-300" + end + end +end diff --git a/lib/reencodarr_web/router.ex b/lib/reencodarr_web/router.ex index aa2840e6..4326f337 100644 --- a/lib/reencodarr_web/router.ex +++ b/lib/reencodarr_web/router.ex @@ -30,6 +30,7 @@ defmodule ReencodarrWeb.Router do live_session :default, layout: {ReencodarrWeb.Layouts, :app} do live "/", DashboardLive, :index + live "/workers", WorkersLive, :index live "/broadway", BroadwayLive, :index live "/bad-files", BadFilesLive, :index live "/failures", FailuresLive, :index @@ -60,6 +61,10 @@ defmodule ReencodarrWeb.Router do post "/webhooks/radarr", RadarrWebhookController, :radarr end + scope "/workers", ReencodarrWeb do + get "/files/:id", WorkerFileController, :show + end + # Other scopes may use custom stacks. # scope "/api", ReencodarrWeb do # pipe_through :api diff --git a/nix/dev-shell.nix b/nix/dev-shell.nix index bd1b58da..78146b4b 100644 --- a/nix/dev-shell.nix +++ b/nix/dev-shell.nix @@ -72,6 +72,11 @@ in export MIX_OS_DEPS_COMPILE_PARTITION_COUNT=$(( $(nproc) / 2 )) export ERL_AFLAGS="-kernel shell_history enabled" export SECRET_KEY_BASE="WEWsPGIpK/OgJA2ZcwzsgZxWKSAp35IsqWPYsvSUmm5awBUGpvsVOcG2kkDteXR1" + export REENCODARR_WORKER_TOKEN="$(printf '%s' "''${SECRET_KEY_BASE}:ab-av1-worker-token" \ + | ${pkgs.openssl}/bin/openssl dgst -sha256 -binary \ + | ${pkgs.coreutils}/bin/base64 -w 0 \ + | ${pkgs.coreutils}/bin/tr '+/' '-_' \ + | ${pkgs.coreutils}/bin/tr -d '=')" export COMPOSE_BAKE=true export GPG_TTY=$(tty) diff --git a/nix/module.nix b/nix/module.nix index caeb048e..2a20c339 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -30,6 +30,15 @@ ${lib.optionalString (cfg.secretKeyBaseFile != null) '' export SECRET_KEY_BASE="$(< "$CREDENTIALS_DIRECTORY/secret_key_base")" ''} + if [ -z "''${REENCODARR_WORKER_TOKEN:-}" ] && [ -n "''${SECRET_KEY_BASE:-}" ]; then + export REENCODARR_WORKER_TOKEN="$( + printf '%s' "''${SECRET_KEY_BASE}:ab-av1-worker-token" \ + | ${pkgs.openssl}/bin/openssl dgst -sha256 -binary \ + | ${pkgs.coreutils}/bin/base64 -w 0 \ + | ${pkgs.coreutils}/bin/tr '+/' '-_' \ + | ${pkgs.coreutils}/bin/tr -d '=' + )" + fi ''; startScript = pkgs.writeShellScript "reencodarr-start" '' diff --git a/priv/repo/migrations/20260708000000_add_crf_search_worker_id_to_videos.exs b/priv/repo/migrations/20260708000000_add_crf_search_worker_id_to_videos.exs new file mode 100644 index 00000000..569eb9c0 --- /dev/null +++ b/priv/repo/migrations/20260708000000_add_crf_search_worker_id_to_videos.exs @@ -0,0 +1,11 @@ +defmodule Reencodarr.Repo.Migrations.AddCrfSearchWorkerIdToVideos do + use Ecto.Migration + + def change do + alter table(:videos) do + add :crf_search_worker_id, :string + end + + create index(:videos, [:state, :crf_search_worker_id]) + end +end diff --git a/test/reencodarr/ab_av1/crf_search_vmaf_retry_test.exs b/test/reencodarr/ab_av1/crf_search_vmaf_retry_test.exs index 8701edee..ab24855b 100644 --- a/test/reencodarr/ab_av1/crf_search_vmaf_retry_test.exs +++ b/test/reencodarr/ab_av1/crf_search_vmaf_retry_test.exs @@ -97,7 +97,9 @@ defmodule Reencodarr.AbAv1.CrfSearchVmafRetryTest do send(test_pid, {:open_port_call, count, args}) # Spawn a script that outputs lines then exits with failure - script = Enum.map_join(output_lines, "; ", &"echo '#{&1}'") <> "; exit 1" + # Keep the port alive briefly so CrfSearch can finish its startup call + # chain before the mocked failure process exits. + script = Enum.map_join(output_lines, "; ", &"echo '#{&1}'") <> "; sleep 0.1; exit 1" port = Port.open({:spawn, "sh -c \"#{script}\""}, [:exit_status, :binary, {:line, 1024}]) {:ok, port} end) diff --git a/test/reencodarr/ab_av1/worker_config_test.exs b/test/reencodarr/ab_av1/worker_config_test.exs new file mode 100644 index 00000000..8b0ac16a --- /dev/null +++ b/test/reencodarr/ab_av1/worker_config_test.exs @@ -0,0 +1,23 @@ +defmodule Reencodarr.AbAv1.WorkerConfigTest do + use ExUnit.Case, async: false + + alias Reencodarr.AbAv1.WorkerConfig + + test "can enable and disable distributed workers" do + previous = Application.get_env(:reencodarr, :distributed_worker_enabled) + + try do + assert :ok = WorkerConfig.enable() + assert WorkerConfig.enabled?() + + assert :ok = WorkerConfig.disable() + refute WorkerConfig.enabled?() + after + if is_nil(previous) do + Application.delete_env(:reencodarr, :distributed_worker_enabled) + else + Application.put_env(:reencodarr, :distributed_worker_enabled, previous) + end + end + end +end diff --git a/test/reencodarr/ab_av1/worker_job_state_machine_test.exs b/test/reencodarr/ab_av1/worker_job_state_machine_test.exs new file mode 100644 index 00000000..3fdddd54 --- /dev/null +++ b/test/reencodarr/ab_av1/worker_job_state_machine_test.exs @@ -0,0 +1,123 @@ +defmodule Reencodarr.AbAv1.WorkerJobStateMachineTest do + use ExUnit.Case, async: true + + alias Reencodarr.AbAv1.WorkerJobStateMachine + + test "complete transfer progress moves the worker to input ready" do + session = session() + + assert {:ok, session} = WorkerJobStateMachine.assign_video(session, 123, :receiving_input) + assert session.phase == :receiving_input + assert session.active_video_id == 123 + + assert {:ok, session} = + WorkerJobStateMachine.set_transfer_progress(session, %{ + video_id: 123, + percent: 100.0 + }) + + assert session.phase == :input_ready + assert session.transfer_progress.percent == 100.0 + + assert {:ok, session} = WorkerJobStateMachine.finish_transfer(session) + assert session.phase == :input_ready + assert session.active_video_id == 123 + assert session.transfer_progress.percent == 100.0 + end + + test "CRF progress wins over stale transfer progress" do + session = + session() + |> Map.merge(%{ + phase: :receiving_input, + active_video_id: 123, + transfer_progress: %{video_id: 123, percent: 100.0} + }) + + assert {:ok, session} = + WorkerJobStateMachine.set_crf_search_progress(session, %{ + video_id: 123, + percent: 75.0 + }) + + assert session.phase == :crf_searching + assert session.active_video_id == 123 + assert is_nil(session.transfer_progress) + assert session.crf_search_progress.percent == 75.0 + end + + test "progress cannot move an active worker to a different video" do + session = + session() + |> Map.merge(%{ + phase: :receiving_input, + active_video_id: 123, + transfer_progress: %{video_id: 123, percent: 50.0} + }) + + assert {:error, :invalid_worker_phase} = + WorkerJobStateMachine.set_transfer_progress(session, %{ + video_id: 456, + percent: 75.0 + }) + + assert {:error, :invalid_worker_phase} = + WorkerJobStateMachine.set_crf_search_progress(session, %{ + video_id: 456, + percent: 10.0 + }) + end + + test "transfer progress for the active video moves CRF search back to receiving input" do + session = + session() + |> Map.merge(%{ + phase: :crf_searching, + active_video_id: 123, + crf_search_progress: %{video_id: 123, percent: 25.0} + }) + + assert {:ok, session} = + WorkerJobStateMachine.set_transfer_progress(session, %{ + video_id: 123, + percent: 25.0 + }) + + assert session.phase == :receiving_input + assert session.active_video_id == 123 + assert session.transfer_progress.percent == 25.0 + assert is_nil(session.crf_search_progress) + end + + test "clearing active work is the only transition that makes the worker idle" do + session = + session() + |> Map.merge(%{ + phase: :crf_searching, + active_video_id: 123, + crf_search_progress: %{video_id: 123, percent: 75.0} + }) + + assert {:ok, session} = WorkerJobStateMachine.clear_video(session) + assert session.phase == :idle + assert is_nil(session.active_video_id) + assert is_nil(session.crf_search_progress) + end + + defp session do + %{ + server_worker_id: "worker-server-1", + client_worker_id: "worker-client-1", + version: "0.11.4", + protocol_version: 1, + capabilities: %{"crf_search" => true}, + phase: :idle, + active_video_id: nil, + transfer_progress: nil, + crf_search_progress: nil, + resource_usage: nil, + connected_at: DateTime.utc_now(), + last_seen_at: DateTime.utc_now() + } + end +end diff --git a/test/reencodarr/ab_av1/worker_protocol_test.exs b/test/reencodarr/ab_av1/worker_protocol_test.exs new file mode 100644 index 00000000..75d82418 --- /dev/null +++ b/test/reencodarr/ab_av1/worker_protocol_test.exs @@ -0,0 +1,331 @@ +defmodule Reencodarr.AbAv1.WorkerProtocolTest do + use ExUnit.Case, async: false + + alias Reencodarr.AbAv1.WorkerProtocol + alias Reencodarr.AbAv1.WorkerProtocol.Announcement + alias Reencodarr.AbAv1.WorkerProtocol.Completion + alias Reencodarr.AbAv1.WorkerProtocol.CrfSearchProgress + alias Reencodarr.AbAv1.WorkerProtocol.CrfSearchResult + alias Reencodarr.AbAv1.WorkerProtocol.FailureReport + alias Reencodarr.AbAv1.WorkerProtocol.TransferProgress + + test "rejects invalid announcement payloads" do + assert {:error, :invalid_announcement} = WorkerProtocol.parse_announcement(%{}) + + assert {:error, :invalid_announcement} = + WorkerProtocol.parse_announcement(%{"worker_id" => "x"}) + end + + test "parses a worker announcement into a typed struct" do + assert {:ok, + %Announcement{ + worker_id: "abav1-dev", + protocol_version: 1, + version: "0.10.0", + capabilities: %{"crf_search" => true} + }} = + WorkerProtocol.parse_announcement(%{ + "worker_id" => "abav1-dev", + "protocol_version" => 1, + "version" => "0.10.0", + "capabilities" => %{"crf_search" => true} + }) + end + + test "maps protocol errors to wire payloads" do + assert WorkerProtocol.error(:unauthorized) == %{reason: "unauthorized"} + assert WorkerProtocol.error(:unsupported_event) == %{reason: "unsupported_event"} + end + + test "parses transfer progress into a typed payload" do + assert {:ok, + %TransferProgress{ + job_id: "job-1", + video_id: 123, + transfer_id: "job-1", + filename: "movie.mkv", + percent: 42.5, + bytes_sent: 1_048_576, + total_bytes: 2_097_152, + bytes_per_second: 524_288, + eta: 12, + chunk_index: 3, + total_chunks: 8 + }} = + WorkerProtocol.parse_transfer_progress(%{ + "video_id" => 123, + "job_id" => "job-1", + "percent" => 42.5, + "filename" => "movie.mkv", + "received_bytes" => 1_048_576, + "expected_bytes" => 2_097_152, + "bytes_per_second" => 524_288, + "eta" => 12, + "chunk_index" => 3, + "total_chunks" => 8 + }) + end + + test "parses CRF search progress into a typed payload" do + assert {:ok, + %CrfSearchProgress{ + video_id: 123, + percent: 78.5, + filename: "movie.mkv", + eta: 42, + fps: 23.97, + crf: 24.0, + sample_num: 2, + total_samples: 8 + }} = + WorkerProtocol.parse_crf_search_progress(%{ + "video_id" => 123, + "percent" => 78.5, + "filename" => "movie.mkv", + "eta" => 42, + "fps" => 23.97, + "crf" => 24.0, + "sample_num" => 2, + "total_samples" => 8 + }) + end + + test "parses heartbeat resource usage" do + assert %{ + cpu_percent: 87.5, + memory_bytes: 1_073_741_824, + memory_total_bytes: 4_294_967_296, + disk_free_bytes: 536_870_912_000, + disk_total_bytes: 1_099_511_627_776 + } = + WorkerProtocol.parse_resource_usage(%{ + "cpu_percent" => 87.5, + "memory_rss_bytes" => 1_073_741_824, + "total_memory_bytes" => 4_294_967_296, + "free_disk_bytes" => 536_870_912_000, + "total_disk_bytes" => 1_099_511_627_776 + }) + + assert is_nil(WorkerProtocol.parse_resource_usage(%{})) + end + + test "parses structured CRF results and completion payloads" do + assert {:ok, + %CrfSearchResult{ + video_id: 123, + results: [ + %{ + crf: 28, + score: 95.4, + percent: 94, + size: "512 MB", + time: 3600, + params: ["--preset", "4"], + chosen: true + } + ] + }} = + WorkerProtocol.parse_crf_search_result(%{ + "video_id" => 123, + "crf" => 28, + "score" => 95.4, + "percent" => 94, + "size" => "512 MB", + "time" => 3600, + "params" => ["--preset", "4"], + "chosen" => true + }) + + assert {:ok, %Completion{video_id: 123, result: :ok, chosen_crf: 28}} = + WorkerProtocol.parse_completion(%{ + "video_id" => 123, + "result" => "ok", + "chosen_crf" => 28 + }) + + assert {:ok, + %CrfSearchResult{ + results: [%{chosen: false}] + }} = + WorkerProtocol.parse_crf_search_result(%{ + "video_id" => 123, + "crf" => 28, + "score" => 95.4, + "percent" => 94, + "chosen" => false + }) + + assert {:ok, + %CrfSearchResult{ + video_id: 123, + results: [ + %{ + crf: 31.5, + score: 96.2, + percent: 42.5, + size: "123456", + time: 88 + } + ] + }} = + WorkerProtocol.parse_crf_search_result(%{ + "job_id" => "job-123", + "video_id" => 123, + "source_name" => "movie.mkv", + "crf" => 31.5, + "vmaf_score" => 96.2, + "xpsnr_score" => nil, + "predicted_encode_size" => 123_456, + "encode_percent" => 42.5, + "predicted_encode_time_secs" => 87.5, + "from_cache" => false + }) + + assert {:ok, + %FailureReport{ + retriable: false + }} = + WorkerProtocol.parse_failure_report(%{ + "video_id" => 123, + "stage" => "crf_search", + "category" => "timeout", + "message" => "timed out", + "retriable" => false + }) + end + + test "parses typed failure reports" do + assert {:ok, + %FailureReport{ + video_id: 123, + stage: :crf_search, + category: :timeout, + message: "timed out", + code: "EXIT_137", + context: %{node: "worker@host"}, + retriable: true, + stderr_excerpt: "ab-av1 timed out" + }} = + WorkerProtocol.parse_failure_report(%{ + "video_id" => 123, + "stage" => "crf_search", + "category" => "timeout", + "message" => "timed out", + "code" => "EXIT_137", + "context" => %{node: "worker@host"}, + "retriable" => true, + "stderr_excerpt" => "ab-av1 timed out" + }) + end + + test "builds a job_assigned payload from the claimed video" do + payload = + WorkerProtocol.work_assigned( + %Reencodarr.Media.Video{ + id: 123, + path: "/videos/movie.mkv", + size: 987_654 + }, + 96.5 + ) + + assert %{ + status: "job_assigned", + job_id: "123", + video_id: 123, + source_name: "movie.mkv", + size_bytes: 987_654, + chunk_size_bytes: 134_217_728, + target_vmaf: 96.5, + crf_search_args: crf_search_args + } = payload + + assert [ + "crf-search", + "--input", + "/videos/movie.mkv", + "--min-vmaf", + "96.5" | _rest + ] = crf_search_args + + assert "--temp-dir" in crf_search_args + end + + test "includes configured worker transfer URL in job payloads" do + previous_base_url = Application.get_env(:reencodarr, :worker_transfer_base_url) + previous_token = Application.get_env(:reencodarr, :worker_transfer_token) + Application.put_env(:reencodarr, :worker_transfer_base_url, "http://10.0.0.10:4000/") + Application.put_env(:reencodarr, :worker_transfer_token, "transfer-token") + + on_exit(fn -> + restore_env(:worker_transfer_base_url, previous_base_url) + restore_env(:worker_transfer_token, previous_token) + end) + + video = %Reencodarr.Media.Video{id: 123, path: "/videos/movie.mkv", size: 987_654} + + assert %{transfer: transfer} = WorkerProtocol.work_assigned(video, 96.5) + + assert transfer == %{ + url: "http://10.0.0.10:4000/workers/files/123", + auth: %{ + scheme: "bearer", + header: "authorization", + value: "Bearer transfer-token" + } + } + + assert %{transfer: ^transfer} = + WorkerProtocol.work_assigned(video, 96.5) + + assert %{transfer: ^transfer} = + WorkerProtocol.work_in_progress(video, 96.5) + end + + defp restore_env(key, nil), do: Application.delete_env(:reencodarr, key) + defp restore_env(key, value), do: Application.put_env(:reencodarr, key, value) + + test "builds binary transfer chunk frames with ordered metadata and raw data" do + chunk = "raw video bytes" + + assert {:binary, frame} = + WorkerProtocol.transfer_chunk( + %Reencodarr.Media.Video{id: 123}, + "transfer-123", + 7, + 9, + 1_024, + 2_048, + chunk + ) + + assert {:ok, + %{ + video_id: 123, + transfer_id: "transfer-123", + chunk_index: 7, + total_chunks: 9, + bytes_sent: 1_024, + total_bytes: 2_048, + crc32: crc32, + data: ^chunk + }} = WorkerProtocol.parse_transfer_chunk_frame(frame) + + assert crc32 == :erlang.crc32(chunk) + end + + test "reads chunk size from configuration" do + previous = Application.get_env(:reencodarr, :worker_chunk_size_bytes) + Application.put_env(:reencodarr, :worker_chunk_size_bytes, 2_097_152) + + try do + assert WorkerProtocol.chunk_size_bytes() == 2_097_152 + after + if is_nil(previous) do + Application.delete_env(:reencodarr, :worker_chunk_size_bytes) + else + Application.put_env(:reencodarr, :worker_chunk_size_bytes, previous) + end + end + end +end diff --git a/test/reencodarr/ab_av1/worker_sessions_test.exs b/test/reencodarr/ab_av1/worker_sessions_test.exs new file mode 100644 index 00000000..71404242 --- /dev/null +++ b/test/reencodarr/ab_av1/worker_sessions_test.exs @@ -0,0 +1,401 @@ +defmodule Reencodarr.AbAv1.WorkerSessionsTest do + use Reencodarr.DataCase, async: false + + alias Reencodarr.AbAv1.WorkerSessions + alias Reencodarr.Dashboard.Events + alias Reencodarr.Diagnostics + alias Reencodarr.Fixtures + alias Reencodarr.Media + + setup do + WorkerSessions.reset() + :ok + end + + test "rejects duplicate worker ids" do + assert {:ok, _session} = WorkerSessions.register(worker_session_attrs()) + + assert {:error, :duplicate_worker_id} = + WorkerSessions.register(worker_session_attrs(client_worker_id: "worker-client-2")) + end + + test "returns unknown worker session for missing ids" do + assert {:error, :unknown_worker_session} = WorkerSessions.touch("missing-worker") + assert {:error, :unknown_worker_session} = WorkerSessions.assign_video("missing-worker", 123) + assert {:error, :unknown_worker_session} = WorkerSessions.clear_video("missing-worker") + assert {:error, :unknown_worker_session} = WorkerSessions.finish_transfer("missing-worker") + end + + test "expires stale worker sessions" do + assert {:ok, _session} = WorkerSessions.register(worker_session_attrs()) + + assert [_session] = WorkerSessions.list() + + assert {:ok, expired_sessions} = WorkerSessions.expire_stale(0) + assert Enum.map(expired_sessions, & &1.client_worker_id) == ["worker-client-1"] + assert WorkerSessions.list() == [] + end + + test "expires stale active sessions by requeueing only in-progress videos" do + {:ok, active_video} = Fixtures.video_fixture(%{state: :crf_searching}) + + {:ok, dispatched_video} = + Fixtures.video_fixture(%{state: :crf_searching, crf_search_worker_id: "worker-client-3"}) + + {:ok, completed_video} = Fixtures.video_fixture(%{state: :crf_searched}) + _vmaf = Fixtures.vmaf_fixture(%{video_id: completed_video.id, crf: 28.0, score: 96.4}) + assert {:ok, _} = Media.mark_vmaf_as_chosen(completed_video.id, 28.0) + + assert {:ok, _session} = + WorkerSessions.register(worker_session_attrs(server_worker_id: "worker-server-1")) + + assert {:ok, _session} = + WorkerSessions.register( + worker_session_attrs( + server_worker_id: "worker-server-2", + client_worker_id: "worker-client-2" + ) + ) + + assert {:ok, _session} = + WorkerSessions.register( + worker_session_attrs( + server_worker_id: "worker-server-3", + client_worker_id: "worker-client-3" + ) + ) + + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-1", active_video.id) + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-2", completed_video.id) + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-3", dispatched_video.id) + + assert {:ok, [_session_one, _session_two, _session_three]} = WorkerSessions.expire_stale(0) + assert Media.get_video(active_video.id).state == :analyzed + assert Media.get_video(dispatched_video.id).state == :crf_searching + assert Media.get_video(completed_video.id).state == :crf_searched + assert WorkerSessions.list() == [] + end + + test "timer-driven stale expiry removes old sessions" do + previous_timeout = Application.get_env(:reencodarr, :worker_session_timeout_seconds) + Application.put_env(:reencodarr, :worker_session_timeout_seconds, 0) + + on_exit(fn -> + Application.put_env(:reencodarr, :worker_session_timeout_seconds, previous_timeout) + end) + + Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel()) + assert {:ok, _session} = WorkerSessions.register(worker_session_attrs()) + + send(Process.whereis(WorkerSessions), :expire_stale) + assert_receive {:worker_sessions_updated, %{sessions: []}} + + assert WorkerSessions.list() == [] + end + + test "includes worker sessions in diagnostics output" do + assert {:ok, _session} = WorkerSessions.register(worker_session_attrs()) + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-1", video.id) + + output = Diagnostics.processes() + + assert output =~ "Worker Sessions:" + assert output =~ "worker-client-1" + assert output =~ "protocol=1" + assert output =~ "version=0.10.0" + assert output =~ "phase=crf_searching" + assert output =~ "video_state=analyzed" + end + + test "tracks an assigned video on the session" do + assert {:ok, _session} = WorkerSessions.register(worker_session_attrs()) + + assert {:ok, session} = WorkerSessions.assign_video("worker-server-1", 123) + assert session.active_video_id == 123 + assert session.phase == :crf_searching + + [listed_session] = WorkerSessions.list() + assert listed_session.active_video_id == 123 + assert listed_session.phase == :crf_searching + end + + test "complete transfer progress moves to input ready without clearing the active video" do + assert {:ok, _session} = WorkerSessions.register(worker_session_attrs()) + + assert {:ok, session} = WorkerSessions.assign_video("worker-server-1", 123, :receiving_input) + assert session.active_video_id == 123 + assert session.phase == :receiving_input + + assert {:ok, session} = + WorkerSessions.set_transfer_progress("worker-server-1", %{ + job_id: "job-1", + video_id: 123, + transfer_id: "job-1", + filename: "sample.mkv", + percent: 100.0, + bytes_sent: 10_485_760, + total_bytes: 10_485_760 + }) + + assert session.active_video_id == 123 + assert session.phase == :input_ready + assert session.transfer_progress.percent == 100.0 + + assert {:ok, session} = WorkerSessions.finish_transfer("worker-server-1") + assert session.active_video_id == 123 + assert session.phase == :input_ready + assert session.transfer_progress.percent == 100.0 + end + + test "rejects progress for a different active video" do + assert {:ok, _session} = WorkerSessions.register(worker_session_attrs()) + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-1", 123, :receiving_input) + + assert {:error, :invalid_worker_phase} = + WorkerSessions.set_transfer_progress("worker-server-1", %{ + video_id: 456, + percent: 25.0 + }) + + assert {:error, :invalid_worker_phase} = + WorkerSessions.set_crf_search_progress("worker-server-1", %{ + video_id: 456, + percent: 25.0 + }) + + session = WorkerSessions.get("worker-server-1") + assert session.active_video_id == 123 + assert session.phase == :receiving_input + assert is_nil(session.transfer_progress) + assert is_nil(session.crf_search_progress) + end + + test "keeps CRF sample metadata when later progress omits it" do + assert {:ok, _session} = WorkerSessions.register(worker_session_attrs()) + + assert {:ok, session} = + WorkerSessions.set_crf_search_progress("worker-server-1", %{ + video_id: 123, + percent: 10.0, + fps: 24.0, + crf: 28.0, + sample_num: 3, + total_samples: 5 + }) + + assert session.crf_search_progress.crf == 28.0 + assert session.crf_search_progress.sample_num == 3 + assert session.crf_search_progress.total_samples == 5 + + assert {:ok, session} = + WorkerSessions.set_crf_search_progress("worker-server-1", %{ + video_id: 123, + percent: 25.0, + fps: 25.0, + crf: nil, + sample_num: nil, + total_samples: nil + }) + + assert session.crf_search_progress.percent == 25.0 + assert session.crf_search_progress.fps == 25.0 + assert session.crf_search_progress.crf == 28.0 + assert session.crf_search_progress.sample_num == 3 + assert session.crf_search_progress.total_samples == 5 + end + + test "touches session when transfer progress is reported" do + assert {:ok, _session} = WorkerSessions.register(worker_session_attrs()) + + initial_session = WorkerSessions.get("worker-server-1") + initial_last_seen = initial_session.last_seen_at + + Process.sleep(1_100) + + assert {:ok, session} = + WorkerSessions.set_transfer_progress("worker-server-1", %{ + job_id: "job-1", + video_id: 123, + transfer_id: "job-1", + filename: "sample.mkv", + percent: 25.0, + bytes_sent: 2_621_440, + total_bytes: 10_485_760, + bytes_per_second: 1_048_576, + eta: 15, + chunk_index: 2, + total_chunks: 8 + }) + + assert DateTime.compare(session.last_seen_at, initial_last_seen) == :gt + end + + test "touches session when CRF search progress is reported" do + assert {:ok, _session} = WorkerSessions.register(worker_session_attrs()) + + initial_session = WorkerSessions.get("worker-server-1") + initial_last_seen = initial_session.last_seen_at + + Process.sleep(1_100) + + assert {:ok, session} = + WorkerSessions.set_crf_search_progress("worker-server-1", %{ + video_id: 123, + percent: 25.0, + fps: 24.0, + crf: 28.0, + sample_num: 3, + total_samples: 5 + }) + + assert DateTime.compare(session.last_seen_at, initial_last_seen) == :gt + end + + test "replaces reconnecting client sessions without dropping active state" do + {:ok, video} = Fixtures.video_fixture(%{state: :crf_searching}) + + assert {:ok, _session} = + WorkerSessions.register( + worker_session_attrs( + server_worker_id: "worker-server-1", + client_worker_id: "worker-client-1" + ) + ) + + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-1", video.id) + + assert {:ok, session} = + WorkerSessions.set_crf_search_progress("worker-server-1", %{ + video_id: video.id, + percent: 25.0, + fps: 24.0, + crf: 28.0, + sample_num: 2, + total_samples: 5 + }) + + assert session.active_video_id == video.id + assert session.phase == :crf_searching + + assert {:ok, session} = + WorkerSessions.register( + worker_session_attrs( + server_worker_id: "worker-server-2", + client_worker_id: "worker-client-1" + ) + ) + + assert session.server_worker_id == "worker-server-2" + assert session.client_worker_id == "worker-client-1" + assert session.active_video_id == video.id + assert session.phase == :crf_searching + assert session.crf_search_progress.video_id == video.id + assert session.crf_search_progress.sample_num == 2 + assert is_nil(WorkerSessions.get("worker-server-1")) + end + + test "does not preserve completed active state when replacing reconnecting sessions" do + {:ok, video} = Fixtures.video_fixture(%{state: :crf_searched}) + _vmaf = Fixtures.vmaf_fixture(%{video_id: video.id, crf: 28.0, score: 96.4}) + assert {:ok, _} = Media.mark_vmaf_as_chosen(video.id, 28.0) + + assert {:ok, _session} = + WorkerSessions.register( + worker_session_attrs( + server_worker_id: "worker-server-1", + client_worker_id: "worker-client-1" + ) + ) + + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-1", video.id) + + assert {:ok, _session} = + WorkerSessions.set_crf_search_progress("worker-server-1", %{ + video_id: video.id, + percent: 100.0, + fps: 24.0 + }) + + assert {:ok, session} = + WorkerSessions.register( + worker_session_attrs( + server_worker_id: "worker-server-2", + client_worker_id: "worker-client-1" + ) + ) + + assert is_nil(session.active_video_id) + assert session.phase == :idle + assert is_nil(session.crf_search_progress) + assert Media.get_video(video.id).state == :crf_searched + end + + test "cancels and drains active distributed work" do + {:ok, video_one} = Fixtures.video_fixture(%{state: :analyzed}) + {:ok, video_two} = Fixtures.video_fixture(%{state: :analyzed}) + + assert {:ok, _session} = + WorkerSessions.register(worker_session_attrs(server_worker_id: "worker-server-1")) + + assert {:ok, _session} = + WorkerSessions.register( + worker_session_attrs( + server_worker_id: "worker-server-2", + client_worker_id: "worker-client-2" + ) + ) + + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-1", video_one.id) + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-2", video_two.id) + + assert {:ok, _session} = WorkerSessions.cancel("worker-server-1") + assert Media.get_video(video_one.id).state == :analyzed + + assert {:ok, drained_sessions} = WorkerSessions.drain() + assert Enum.map(drained_sessions, & &1.server_worker_id) == ["worker-server-2"] + assert Media.get_video(video_two.id).state == :analyzed + assert WorkerSessions.list() == [] + end + + test "cancel and drain do not reset completed videos" do + {:ok, video_one} = Fixtures.video_fixture(%{state: :crf_searched}) + {:ok, video_two} = Fixtures.video_fixture(%{state: :crf_searched}) + _vmaf_one = Fixtures.vmaf_fixture(%{video_id: video_one.id, crf: 28.0, score: 96.4}) + _vmaf_two = Fixtures.vmaf_fixture(%{video_id: video_two.id, crf: 28.0, score: 96.4}) + assert {:ok, _} = Media.mark_vmaf_as_chosen(video_one.id, 28.0) + assert {:ok, _} = Media.mark_vmaf_as_chosen(video_two.id, 28.0) + + assert {:ok, _session} = + WorkerSessions.register(worker_session_attrs(server_worker_id: "worker-server-1")) + + assert {:ok, _session} = + WorkerSessions.register( + worker_session_attrs( + server_worker_id: "worker-server-2", + client_worker_id: "worker-client-2" + ) + ) + + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-1", video_one.id) + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-2", video_two.id) + + assert {:ok, _session} = WorkerSessions.cancel("worker-server-1") + assert Media.get_video(video_one.id).state == :crf_searched + + assert {:ok, [_session]} = WorkerSessions.drain() + assert Media.get_video(video_two.id).state == :crf_searched + assert WorkerSessions.list() == [] + end + + defp worker_session_attrs(overrides \\ []) do + %{ + server_worker_id: "worker-server-1", + client_worker_id: "worker-client-1", + protocol_version: 1, + version: "0.10.0", + capabilities: %{"crf_search" => true} + } + |> Map.merge(Map.new(overrides)) + end +end diff --git a/test/reencodarr/broadway_producers_test.exs b/test/reencodarr/broadway_producers_test.exs index 895b1ad6..06388415 100644 --- a/test/reencodarr/broadway_producers_test.exs +++ b/test/reencodarr/broadway_producers_test.exs @@ -116,6 +116,53 @@ defmodule Reencodarr.BroadwayProducersTest do assert is_list(videos) end + test "atomic claim transitions video to :crf_searching" do + expect_crf_available() + + {:ok, video} = video_fixture(%{state: :analyzed}) + + state = init_state(CrfProducer) + {:noreply, videos, _state2} = CrfProducer.handle_demand(1, state) + + assert Enum.count(videos) == 1 + assert hd(videos).id == video.id + + refreshed = Reencodarr.Repo.get!(Reencodarr.Media.Video, video.id) + assert refreshed.state == :crf_searching + end + + test "atomic claim broadcasts video mutation for dashboard counts" do + Phoenix.PubSub.subscribe(Reencodarr.PubSub, "video_state_transitions") + expect_crf_available() + {:ok, video} = video_fixture(%{state: :analyzed}) + + state = init_state(CrfProducer) + {:noreply, [_claimed], _state2} = CrfProducer.handle_demand(1, state) + + assert_receive {:video_mutated, + %{ + action: :update, + old_video: %{id: old_id, state: :analyzed}, + new_video: %{id: new_id, state: :crf_searching} + }} + + assert old_id == video.id + assert new_id == video.id + end + + test "claimed CRF videos are not dispatched again" do + expect_crf_available() + + {:ok, _video} = video_fixture(%{state: :analyzed}) + + state = init_state(CrfProducer) + {:noreply, videos1, state2} = CrfProducer.handle_demand(1, state) + assert Enum.count(videos1) == 1 + + {:noreply, videos2, _state3} = CrfProducer.handle_demand(1, state2) + assert videos2 == [] + end + test "poll returns list when called" do {:ok, _video} = video_fixture(%{state: :analyzed}) @@ -168,4 +215,17 @@ defmodule Reencodarr.BroadwayProducersTest do assert {:noreply, [], ^state} = EncoderProducer.handle_info(:unknown, state) end end + + defp expect_crf_available do + :meck.new(Reencodarr.AbAv1.CrfSearch, [:passthrough]) + :meck.expect(Reencodarr.AbAv1.CrfSearch, :available?, fn -> :available end) + + on_exit(fn -> + try do + :meck.unload(Reencodarr.AbAv1.CrfSearch) + catch + :error, {:not_mocked, Reencodarr.AbAv1.CrfSearch} -> :ok + end + end) + end end diff --git a/test/reencodarr/media/orphan_reset_test.exs b/test/reencodarr/media/orphan_reset_test.exs index 001d42c2..211b7f35 100644 --- a/test/reencodarr/media/orphan_reset_test.exs +++ b/test/reencodarr/media/orphan_reset_test.exs @@ -22,6 +22,23 @@ defmodule Reencodarr.Media.OrphanResetTest do assert updated.state == :analyzed end + test "does not reset crf_searching videos dispatched to a worker" do + {:ok, video} = + Fixtures.video_fixture(%{ + path: "/test/worker_crf.mkv", + state: :crf_searching, + crf_search_worker_id: "worker-a", + video_codecs: ["h264"], + audio_codecs: ["aac"] + }) + + assert :ok = Media.reset_orphaned_crf_searching() + + updated = Media.get_video(video.id) + assert updated.state == :crf_searching + assert updated.crf_search_worker_id == "worker-a" + end + test "does not affect videos in other states" do {:ok, analyzed} = Fixtures.video_fixture(%{ diff --git a/test/reencodarr_web/channels/worker_channel_test.exs b/test/reencodarr_web/channels/worker_channel_test.exs new file mode 100644 index 00000000..c675a40c --- /dev/null +++ b/test/reencodarr_web/channels/worker_channel_test.exs @@ -0,0 +1,1373 @@ +defmodule ReencodarrWeb.WorkerChannelTest do + use ReencodarrWeb.ChannelCase, async: false + + alias Reencodarr.AbAv1.WorkerProtocol + alias Reencodarr.AbAv1.WorkerSessions + alias Reencodarr.Dashboard.Events + alias Reencodarr.Fixtures + alias Reencodarr.Media + alias ReencodarrWeb.WorkerChannel + alias ReencodarrWeb.WorkerSocket + import Reencodarr.TestHelpers + + describe "ab-av1 worker websocket" do + setup do + previous_enabled = Application.get_env(:reencodarr, :distributed_worker_enabled) + Application.put_env(:reencodarr, :distributed_worker_enabled, true) + WorkerSessions.reset() + + on_exit(fn -> + if is_nil(previous_enabled) do + Application.delete_env(:reencodarr, :distributed_worker_enabled) + else + Application.put_env(:reencodarr, :distributed_worker_enabled, previous_enabled) + end + end) + + :ok + end + + test "authenticates, announces capabilities, and returns no work" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert socket.assigns.worker_id =~ "worker-" + + assert {:ok, %{worker_id: worker_id}, socket} = + subscribe_and_join(socket, "workers:crf_search") + + assert worker_id == socket.assigns.worker_id + + assert_reply push(socket, "announce", %{ + "worker_id" => "abav1-dev", + "protocol_version" => 1, + "version" => "0.10.0", + "capabilities" => %{"crf_search" => true} + }), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), :ok, %{status: "no_work"} + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "assigns one queued video to a worker and marks it crf_searching" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{ + status: "job_assigned", + job_id: job_id, + video_id: assigned_video_id, + source_name: source_name, + size_bytes: size_bytes, + chunk_size_bytes: 134_217_728, + crf_search_args: crf_search_args + } + + assert job_id == Integer.to_string(video.id) + assert assigned_video_id == video.id + assert source_name == Path.basename(video.path) + assert size_bytes == video.size + assert "crf-search" in crf_search_args + assert "--input" in crf_search_args + assert video.path in crf_search_args + assert "--min-vmaf" in crf_search_args + assert "95" in crf_search_args + assert "--encoder" in crf_search_args + assert "svt-av1" in crf_search_args + assert Media.get_video(video.id).state == :crf_searching + assert Media.get_video(video.id).crf_search_worker_id == "worker-a" + assert [session] = WorkerSessions.list() + assert session.active_video_id == video.id + assert session.phase == :receiving_input + assert is_nil(session.transfer_progress) + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "rejects unsupported protocol versions" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(protocol_version: 99)), + :error, + %{reason: "unsupported_protocol_version", supported_protocol_versions: [1]} + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "replaces reconnecting worker sessions with the same client id" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + assert {:ok, socket1} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket1} = subscribe_and_join(socket1, "workers:crf_search") + server_worker_id1 = socket1.assigns.worker_id + + assert_reply push(socket1, "announce", announce_payload()), + :ok, + %{accepted: true, protocol_version: 1} + + assert {:ok, socket2} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket2} = subscribe_and_join(socket2, "workers:crf_search") + server_worker_id2 = socket2.assigns.worker_id + + assert_reply push(socket2, "announce", announce_payload()), + :ok, + %{accepted: true, protocol_version: 1} + + assert is_nil(WorkerSessions.get(server_worker_id1)) + assert WorkerSessions.get(server_worker_id2).client_worker_id == "abav1-dev" + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "does not hand the same queued video to two workers" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + + assert {:ok, socket1} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket1} = subscribe_and_join(socket1, "workers:crf_search") + + assert_reply push(socket1, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert {:ok, socket2} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket2} = subscribe_and_join(socket2, "workers:crf_search") + + assert_reply push(socket2, "announce", announce_payload(worker_id: "worker-b")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket1, "pull_work", %{}), + :ok, + %{status: "job_assigned", video_id: assigned_video_id} + + assert assigned_video_id == video.id + + assert_reply push(socket2, "pull_work", %{}), + :ok, + %{status: "no_work"} + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "tracks heartbeat last-seen for announced sessions" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload()), :ok, %{ + accepted: true, + protocol_version: 1 + } + + [session] = WorkerSessions.list() + connected_at = session.connected_at + first_seen_at = session.last_seen_at + + assert_reply push(socket, "heartbeat", %{ + "cpu_percent" => 87.5, + "memory_bytes" => 1_073_741_824, + "memory_total_bytes" => 4_294_967_296, + "disk_free_bytes" => 536_870_912_000, + "disk_total_bytes" => 1_099_511_627_776 + }), + :ok, + %{ + accepted: true, + last_seen_at: last_seen_at + } + + assert {:ok, parsed_last_seen_at, 0} = DateTime.from_iso8601(last_seen_at) + + [updated_session] = WorkerSessions.list() + assert updated_session.connected_at == connected_at + assert DateTime.compare(updated_session.last_seen_at, first_seen_at) in [:eq, :gt] + assert updated_session.last_seen_at == parsed_last_seen_at + assert updated_session.resource_usage.cpu_percent == 87.5 + assert updated_session.resource_usage.memory_bytes == 1_073_741_824 + assert updated_session.resource_usage.memory_total_bytes == 4_294_967_296 + assert updated_session.resource_usage.disk_free_bytes == 536_870_912_000 + assert updated_session.resource_usage.disk_total_bytes == 1_099_511_627_776 + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "tracks last-seen for pull_work requests on active sessions" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), :ok, %{ + accepted: true + } + + assert_reply push(socket, "pull_work", %{}), :ok, %{ + status: "job_assigned", + video_id: ^video_id + } + + [session] = WorkerSessions.list() + first_seen_at = session.last_seen_at + + Process.sleep(1_100) + + assert_reply push(socket, "pull_work", %{}), :ok, %{status: status, video_id: ^video_id} + assert status in ["job_assigned", "job_in_progress"] + + [updated_session] = WorkerSessions.list() + assert DateTime.compare(updated_session.last_seen_at, first_seen_at) == :gt + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "broadcasts transfer and CRF progress updates" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel()) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + server_worker_id = socket.assigns.worker_id + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "job_assigned", video_id: ^video_id} + + assert_reply push(socket, "transfer_progress", %{ + "video_id" => video_id, + "job_id" => "job-1", + "filename" => Path.basename(video.path), + "percent" => 25.5, + "received_bytes" => 2_621_440, + "expected_bytes" => 10_485_760, + "bytes_per_second" => 1_048_576, + "eta" => 15, + "chunk_index" => 2, + "total_chunks" => 8 + }), + :ok, + %{accepted: true, event: "transfer_progress"} + + assert_receive {:transfer_progress, + %{ + video_id: ^video_id, + transfer_id: "job-1", + job_id: "job-1", + filename: filename, + percent: 25.5, + bytes_sent: 2_621_440, + total_bytes: 10_485_760, + bytes_per_second: 1_048_576, + eta: 15, + chunk_index: 2, + total_chunks: 8 + }} + + assert filename == Path.basename(video.path) + + Process.sleep(10) + + assert_reply push(socket, "transfer_progress", %{ + "video_id" => video_id, + "job_id" => "job-1", + "filename" => Path.basename(video.path), + "percent" => 50.0, + "received_bytes" => 5_242_880, + "expected_bytes" => 10_485_760, + "chunk_index" => 3, + "total_chunks" => 8 + }), + :ok, + %{accepted: true, event: "transfer_progress"} + + assert_receive {:transfer_progress, + %{ + video_id: ^video_id, + bytes_sent: 5_242_880, + total_bytes: 10_485_760, + bytes_per_second: derived_rate, + eta: derived_eta + }} + + assert is_integer(derived_rate) + assert derived_rate > 0 + assert is_integer(derived_eta) + assert derived_eta >= 0 + + assert_reply push(socket, "crf_search_progress", %{ + "video_id" => video_id, + "percent" => 62.0, + "filename" => Path.basename(video.path), + "eta" => 90, + "fps" => 12.5, + "crf" => 28.0, + "sample_num" => 3, + "total_samples" => 8 + }), + :ok, + %{accepted: true, event: "crf_search_progress"} + + assert_receive {:crf_search_progress, + %{ + video_id: ^video_id, + percent: 62.0, + filename: filename, + eta: 90, + fps: 12.5, + crf: 28.0, + sample_num: 3, + total_samples: 8 + }} + + assert filename == Path.basename(video.path) + session = WorkerSessions.get(server_worker_id) + assert session.phase == :crf_searching + assert is_nil(session.transfer_progress) + assert session.crf_search_progress.percent == 62.0 + + Phoenix.PubSub.broadcast( + Reencodarr.PubSub, + WorkerChannel.worker_control_topic(server_worker_id), + {:worker_control, :pause} + ) + + assert_push "control", %{action: "pause", video_id: ^video_id} + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "accepts resumed CRF progress for already-searching video after reconnect" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + {:ok, video} = + Fixtures.video_fixture(%{state: :crf_searching, crf_search_worker_id: "worker-a"}) + + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + server_worker_id = socket.assigns.worker_id + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + session = WorkerSessions.get(server_worker_id) + assert is_nil(session.active_video_id) + + assert_reply push(socket, "crf_search_progress", %{ + "video_id" => video_id, + "percent" => 16.4, + "filename" => Path.basename(video.path), + "fps" => 24.0, + "crf" => 28.0, + "sample_num" => 2, + "total_samples" => 8 + }), + :ok, + %{accepted: true, event: "crf_search_progress"} + + session = WorkerSessions.get(server_worker_id) + assert session.active_video_id == video_id + assert session.crf_search_progress.sample_num == 2 + assert Media.get_video(video_id).state == :crf_searching + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "does not restart transfer when reconnected worker asks for work already in progress" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + {:ok, video} = + Fixtures.video_fixture(%{state: :crf_searching, crf_search_worker_id: "worker-a"}) + + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + server_worker_id = socket.assigns.worker_id + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "crf_search_progress", %{ + "video_id" => video_id, + "percent" => 12.5, + "filename" => Path.basename(video.path), + "fps" => 24.0, + "crf" => 28.0, + "sample_num" => 1, + "total_samples" => 8 + }), + :ok, + %{accepted: true, event: "crf_search_progress"} + + assert {:ok, session} = + WorkerSessions.set_transfer_progress(server_worker_id, %{ + job_id: Integer.to_string(video_id), + video_id: video_id, + transfer_id: Integer.to_string(video_id), + filename: Path.basename(video.path), + percent: 100.0, + bytes_sent: 8, + total_bytes: 8 + }) + + assert session.phase == :input_ready + assert session.active_video_id == video_id + assert is_nil(session.crf_search_progress) + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{ + status: "job_in_progress", + video_id: ^video_id, + target_vmaf: 95, + crf_search_args: crf_search_args + } + + assert "crf-search" in crf_search_args + assert "--input" in crf_search_args + assert video.path in crf_search_args + assert "--min-vmaf" in crf_search_args + assert "95" in crf_search_args + + refute_push "transfer_started", _, 50 + refute_push "transfer_chunk", _, 50 + + session = WorkerSessions.get(server_worker_id) + assert session.active_video_id == video_id + assert Media.get_video(video_id).state == :crf_searching + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "reattaches dispatched work when reconnecting worker asks for work" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + with_temp_file("abcdefgh", ".mkv", fn path -> + {:ok, video} = + Fixtures.video_fixture(%{ + path: path, + size: 8, + state: :crf_searching, + crf_search_worker_id: "worker-a" + }) + + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "job_in_progress", video_id: ^video_id} + + assert Media.get_video(video_id).state == :crf_searching + assert Media.get_video_failures(video_id) == [] + assert WorkerSessions.get(socket.assigns.worker_id).phase == :crf_searching + refute_push "transfer_started", _, 50 + end) + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "resends dispatched work when reconnecting worker reports missing input" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + with_temp_file("abcdefgh", ".mkv", fn path -> + {:ok, video} = + Fixtures.video_fixture(%{ + path: path, + size: 8, + state: :crf_searching, + crf_search_worker_id: "worker-a" + }) + + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{"input_missing" => true}), + :ok, + %{ + status: "job_assigned", + video_id: ^video_id, + crf_search_args: crf_search_args + } + + assert "crf-search" in crf_search_args + assert_push "transfer_started", %{status: "transfer_started", video_id: ^video_id} + end) + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "returns in-progress work when an assigned worker asks again" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "job_assigned", video_id: ^video_id, crf_search_args: assigned_args} + + assert_reply push(socket, "crf_search_progress", %{ + "video_id" => video_id, + "percent" => 12.5, + "filename" => Path.basename(video.path), + "fps" => 24.0, + "crf" => 28.0, + "sample_num" => 1, + "total_samples" => 8 + }), + :ok, + %{accepted: true, event: "crf_search_progress"} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{ + status: "job_in_progress", + video_id: ^video_id, + crf_search_args: in_progress_args + } + + assert assigned_args == in_progress_args + assert video.path in in_progress_args + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "resends the input when a reconnecting worker still has transfer progress" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + with_temp_file("abcdefgh", ".mkv", fn path -> + {:ok, video} = + Fixtures.video_fixture(%{ + state: :analyzed, + path: path, + size: 8 + }) + + video_id = video.id + + assert {:ok, _session} = + WorkerSessions.register(%{ + server_worker_id: "worker-server-1", + client_worker_id: "worker-a", + protocol_version: 1, + version: "0.10.0", + capabilities: %{"crf_search" => true} + }) + + assert {:ok, _session} = + WorkerSessions.assign_video("worker-server-1", video_id, :receiving_input) + + assert {:ok, _session} = + WorkerSessions.set_transfer_progress("worker-server-1", %{ + job_id: Integer.to_string(video_id), + transfer_id: Integer.to_string(video_id), + filename: Path.basename(video.path), + percent: 50.0, + bytes_sent: 4, + total_bytes: 8, + bytes_per_second: 256, + eta: 2, + chunk_index: 1, + total_chunks: 2 + }) + + assert {:ok, socket2} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket2} = subscribe_and_join(socket2, "workers:crf_search") + + assert_reply push(socket2, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket2, "pull_work", %{}), + :ok, + %{ + status: "job_assigned", + video_id: ^video_id, + chunk_size_bytes: chunk_size_bytes + } + + assert_push "transfer_started", %{ + status: "transfer_started", + video_id: ^video_id, + transfer_id: transfer_id, + chunk_size_bytes: ^chunk_size_bytes + } + + assert transfer_id == Integer.to_string(video_id) + end) + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "reports transfer failure instead of silently waiting when resend input is unavailable" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + missing_path = + Path.join(System.tmp_dir!(), "missing-worker-input-#{System.unique_integer()}.mkv") + + {:ok, video} = + Fixtures.video_fixture(%{ + state: :crf_searching, + path: missing_path, + size: 8, + crf_search_worker_id: "worker-a" + }) + + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{"input_missing" => true}), + :ok, + %{status: "job_assigned", video_id: ^video_id} + + assert_push "transfer_failed", %{ + status: "transfer_failed", + video_id: ^video_id, + transfer_id: transfer_id + } + + assert transfer_id == Integer.to_string(video_id) + refute_push "transfer_started", _, 50 + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "reattaches dispatched work when the session state is gone" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + with_temp_file("abcdefgh", ".mkv", fn path -> + {:ok, video} = + Fixtures.video_fixture(%{ + path: path, + size: 8, + state: :crf_searching, + crf_search_worker_id: "worker-dispatch-gone" + }) + + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push( + socket, + "announce", + announce_payload(worker_id: "worker-dispatch-gone") + ), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "job_in_progress", video_id: ^video_id} + + assert Media.get_video(video_id).state == :crf_searching + assert WorkerSessions.get(socket.assigns.worker_id).phase == :crf_searching + refute_push "transfer_started", _, 50 + end) + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "streams assigned media in ordered chunks with integrity data" do + token = "test-worker-token" + previous_chunk_size = Application.get_env(:reencodarr, :worker_chunk_size_bytes) + Application.put_env(:reencodarr, :worker_token, token) + Application.put_env(:reencodarr, :worker_chunk_size_bytes, 4) + + on_exit(fn -> + if is_nil(previous_chunk_size) do + Application.delete_env(:reencodarr, :worker_chunk_size_bytes) + else + Application.put_env(:reencodarr, :worker_chunk_size_bytes, previous_chunk_size) + end + end) + + chunk_size = 4 + content = "abcdefgh" + content_size = byte_size(content) + first_chunk = binary_part(content, 0, chunk_size) + second_chunk = binary_part(content, chunk_size, content_size - chunk_size) + first_crc32 = :erlang.crc32(first_chunk) + second_crc32 = :erlang.crc32(second_chunk) + + with_temp_file(content, ".mkv", fn path -> + {:ok, video} = + Fixtures.video_fixture(%{ + path: path, + size: content_size, + state: :analyzed + }) + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{ + status: "job_assigned", + video_id: assigned_video_id, + chunk_size_bytes: ^chunk_size + } + + assert assigned_video_id == video.id + + assert_push "transfer_started", %{ + status: "transfer_started", + video_id: ^assigned_video_id, + transfer_id: transfer_id, + size_bytes: content_size, + chunk_size_bytes: ^chunk_size, + total_bytes: content_size, + total_chunks: 2 + } + + session = WorkerSessions.get(socket.assigns.worker_id) + assert session.active_video_id == assigned_video_id + assert session.phase == :receiving_input + assert session.transfer_progress.video_id == assigned_video_id + assert session.transfer_progress.percent == 0.0 + assert session.transfer_progress.bytes_sent == 0 + assert session.transfer_progress.total_bytes == content_size + assert session.transfer_progress.chunk_index == 0 + assert session.transfer_progress.total_chunks == 2 + + assert_push "transfer_chunk", {:binary, first_frame} + + assert {:ok, + %{ + video_id: ^assigned_video_id, + transfer_id: ^transfer_id, + chunk_index: 0, + total_chunks: 2, + bytes_sent: ^chunk_size, + total_bytes: ^content_size, + crc32: ^first_crc32, + data: ^first_chunk + }} = WorkerProtocol.parse_transfer_chunk_frame(first_frame) + + refute_push "transfer_chunk", _, 50 + + assert_reply push(socket, "transfer_progress", %{ + "job_id" => transfer_id, + "transfer_id" => transfer_id, + "video_id" => assigned_video_id, + "percent" => 50.0, + "received_bytes" => chunk_size, + "expected_bytes" => content_size, + "chunk_index" => 0, + "total_chunks" => 2 + }), + :ok, + %{accepted: true, event: "transfer_progress"} + + assert_push "transfer_chunk", {:binary, second_frame} + + assert {:ok, + %{ + video_id: ^assigned_video_id, + transfer_id: ^transfer_id, + chunk_index: 1, + total_chunks: 2, + bytes_sent: ^content_size, + total_bytes: ^content_size, + crc32: ^second_crc32, + data: ^second_chunk + }} = WorkerProtocol.parse_transfer_chunk_frame(second_frame) + + refute_push "transfer_complete", _, 50 + + assert_reply push(socket, "transfer_progress", %{ + "job_id" => transfer_id, + "transfer_id" => transfer_id, + "video_id" => assigned_video_id, + "percent" => 100.0, + "received_bytes" => content_size, + "expected_bytes" => content_size, + "chunk_index" => 1, + "total_chunks" => 2 + }), + :ok, + %{accepted: true, event: "transfer_progress"} + + session = WorkerSessions.get(socket.assigns.worker_id) + assert session.active_video_id == assigned_video_id + assert session.phase == :input_ready + assert session.transfer_progress.video_id == assigned_video_id + assert session.transfer_progress.bytes_sent == content_size + assert session.transfer_progress.total_bytes == content_size + assert session.transfer_progress.chunk_index == 1 + assert session.transfer_progress.total_chunks == 2 + assert session.transfer_progress.percent == 100.0 + + assert_push "transfer_complete", %{ + status: "transfer_complete", + video_id: ^assigned_video_id, + transfer_id: ^transfer_id, + total_bytes: ^content_size, + total_chunks: 2 + } + + session = WorkerSessions.get(socket.assigns.worker_id) + assert session.active_video_id == assigned_video_id + assert session.phase == :input_ready + assert session.transfer_progress.video_id == assigned_video_id + end) + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "submits structured CRF results and completes the job" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel()) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "job_assigned", video_id: ^video_id} + + assert_reply push(socket, "crf_search_result", %{ + "video_id" => video_id, + "results" => [ + %{ + "crf" => 26, + "score" => 94.1, + "percent" => 93.0, + "size" => "600 MB", + "params" => ["--preset", "4"] + }, + %{ + "crf" => 28, + "score" => 96.4, + "percent" => 95.0, + "size" => "520 MB", + "params" => ["--preset", "4"], + "chosen" => true + } + ] + }), + :ok, + %{accepted: true, event: "crf_search_result"} + + assert_receive {:crf_search_vmaf_result, %{video_id: ^video_id, crf: 26.0, score: 94.1}} + assert_receive {:crf_search_vmaf_result, %{video_id: ^video_id, crf: 28.0, score: 96.4}} + + vmafs = Media.get_vmafs_for_video(video_id) + assert Enum.any?(vmafs, &(&1.crf == 26.0 and &1.score == 94.1)) + assert Enum.any?(vmafs, &(&1.crf == 28.0 and &1.score == 96.4)) + assert WorkerSessions.get(socket.assigns.worker_id).active_video_id == nil + + assert_reply push(socket, "crf_search_completed", %{ + "video_id" => video_id, + "result" => "ok", + "chosen_crf" => 28 + }), + :ok, + %{accepted: true, event: "crf_search_completed"} + + assert Media.get_video(video_id).state == :crf_searched + assert Media.get_video(video_id).chosen_vmaf_id != nil + assert WorkerSessions.get(socket.assigns.worker_id).active_video_id == nil + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "no_work"} + + assert_reply push(socket, "crf_search_result", %{ + "video_id" => video_id, + "results" => [ + %{ + "crf" => 28, + "score" => 96.4, + "percent" => 95.0, + "chosen" => true + } + ] + }), + :ok, + %{accepted: true, event: "crf_search_result"} + + assert WorkerSessions.get(socket.assigns.worker_id).active_video_id == nil + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "clears stale active work when a worker asks after the video completed elsewhere" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "job_assigned", video_id: ^video_id} + + _vmaf = Fixtures.vmaf_fixture(%{video_id: video_id, crf: 28.0, score: 96.4}) + assert {:ok, _} = Media.mark_vmaf_as_chosen(video_id, 28.0) + assert {:ok, _} = Media.mark_as_crf_searched(Media.get_video(video_id)) + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "no_work"} + + assert WorkerSessions.get(socket.assigns.worker_id).active_video_id == nil + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "uses an already chosen VMAF when completion omits chosen_crf" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel()) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "job_assigned", video_id: ^video_id} + + assert_reply push(socket, "crf_search_result", %{ + "video_id" => video_id, + "results" => [ + %{ + "crf" => 24, + "score" => 94.2, + "percent" => 90.0, + "chosen" => true + } + ] + }), + :ok, + %{accepted: true, event: "crf_search_result"} + + assert_reply push(socket, "crf_search_completed", %{ + "video_id" => video_id, + "result" => "ok" + }), + :ok, + %{accepted: true, event: "crf_search_completed"} + + assert Media.get_video(video_id).state == :crf_searched + assert Media.get_video(video_id).chosen_vmaf_id != nil + assert WorkerSessions.get(socket.assigns.worker_id).active_video_id == nil + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "persists completion VMAF results before completing the job" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel()) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "job_assigned", video_id: ^video_id} + + assert_reply push(socket, "crf_search_completed", %{ + "video_id" => video_id, + "result" => "ok", + "results" => [ + %{ + "crf" => 13, + "vmaf_score" => 90.74086, + "encode_percent" => 108.0, + "predicted_encode_size" => 5_230_000_000, + "predicted_encode_time_secs" => 5160 + }, + %{ + "crf" => 12, + "vmaf_score" => 94.86, + "encode_percent" => 8.0, + "predicted_encode_size" => 263_590_000, + "predicted_encode_time_secs" => 1260, + "chosen" => true + } + ] + }), + :ok, + %{accepted: true, event: "crf_search_completed"} + + assert_receive {:crf_search_vmaf_result, %{video_id: ^video_id, crf: 13.0, score: 90.74086}} + assert_receive {:crf_search_vmaf_result, %{video_id: ^video_id, crf: 12.0, score: 94.86}} + + vmafs = Media.get_vmafs_for_video(video_id) + assert Enum.any?(vmafs, &(&1.crf == 13.0 and &1.score == 90.74086)) + assert Enum.any?(vmafs, &(&1.crf == 12.0 and &1.score == 94.86)) + + video = Media.get_video(video_id) + assert video.state == :crf_searched + assert video.chosen_vmaf_id != nil + assert WorkerSessions.get(socket.assigns.worker_id).active_video_id == nil + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "fails a completed CRF search when nothing was chosen" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "job_assigned", video_id: ^video_id} + + assert_reply push(socket, "crf_search_result", %{ + "video_id" => video_id, + "results" => [ + %{ + "crf" => 24, + "score" => 94.2, + "percent" => 90.0 + } + ] + }), + :ok, + %{accepted: true, event: "crf_search_result"} + + assert_reply push(socket, "crf_search_completed", %{ + "video_id" => video_id, + "result" => "ok" + }), + :error, + %{reason: "invalid_crf_search_result"} + + assert Media.get_video(video_id).state == :failed + assert is_nil(Media.get_video(video_id).chosen_vmaf_id) + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "no_work"} + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "accepts a CRF result for already dispatched work before the session is rebuilt" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel()) + + {:ok, video} = + Fixtures.video_fixture(%{ + state: :crf_searching, + crf_search_worker_id: "worker-a", + video_codecs: ["h264"], + audio_codecs: ["aac"] + }) + + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "crf_search_result", %{ + "job_id" => Integer.to_string(video_id), + "video_id" => video_id, + "source_name" => Path.basename(video.path), + "crf" => 31.0, + "vmaf_score" => 96.2, + "predicted_encode_size" => 123_456, + "encode_percent" => 42.5, + "predicted_encode_time_secs" => 87.5, + "from_cache" => false + }), + :ok, + %{accepted: true, event: "crf_search_result"} + + assert_receive {:crf_search_vmaf_result, %{video_id: ^video_id, crf: 31.0, score: 96.2}} + + assert Enum.any?( + Media.get_vmafs_for_video(video_id), + &(&1.crf == 31.0 and &1.score == 96.2) + ) + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "records typed failures and cancels active work" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel()) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + video_id = video.id + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "job_assigned", video_id: ^video_id} + + assert_reply push(socket, "video_failed", %{ + "video_id" => video_id, + "stage" => "crf_search", + "category" => "timeout", + "message" => "timed out", + "code" => "EXIT_137", + "context" => %{"node" => "worker@host"}, + "retriable" => true, + "stderr_excerpt" => "ab-av1 timed out" + }), + :ok, + %{accepted: true, event: "video_failed"} + + assert_receive {:video_failed, + %{ + video_id: ^video_id, + stage: :crf_search, + category: :timeout, + message: "timed out" + }} + + assert Media.get_video(video_id).state == :failed + assert [_failure] = Media.get_video_failures(video_id) + + {:ok, cancelled_video} = Fixtures.video_fixture(%{state: :analyzed}) + cancelled_video_id = cancelled_video.id + + assert {:ok, cancel_socket} = connect(WorkerSocket, %{"token" => token}) + + assert {:ok, _join_payload, cancel_socket} = + subscribe_and_join(cancel_socket, "workers:crf_search") + + assert_reply push(cancel_socket, "announce", announce_payload(worker_id: "worker-b")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(cancel_socket, "pull_work", %{}), + :ok, + %{status: "job_assigned", video_id: ^cancelled_video_id} + + assert_reply push(cancel_socket, "crf_search_completed", %{ + "video_id" => cancelled_video_id, + "result" => "cancelled" + }), + :ok, + %{accepted: true, event: "crf_search_completed"} + + assert_receive {:crf_search_completed, %{video_id: ^cancelled_video_id, result: :cancelled}} + + assert Media.get_video(cancelled_video_id).state == :analyzed + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "keeps worker-dispatched active work when the worker disconnects" do + token = "test-worker-token" + Application.put_env(:reencodarr, :worker_token, token) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + + assert {:ok, socket} = connect(WorkerSocket, %{"token" => token}) + assert {:ok, _join_payload, socket} = subscribe_and_join(socket, "workers:crf_search") + + assert_reply push(socket, "announce", announce_payload(worker_id: "worker-a")), + :ok, + %{accepted: true, protocol_version: 1} + + assert_reply push(socket, "pull_work", %{}), + :ok, + %{status: "job_assigned", video_id: assigned_video_id} + + assert assigned_video_id == video.id + assert Media.get_video(video.id).state == :crf_searching + + Process.unlink(socket.channel_pid) + assert :ok = close(socket) + + refreshed = Media.get_video(video.id) + assert refreshed.state == :crf_searching + assert refreshed.crf_search_worker_id == "worker-a" + assert WorkerSessions.list() == [] + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "rejects missing or invalid tokens" do + Application.put_env(:reencodarr, :worker_token, "test-worker-token") + + assert :error = connect(WorkerSocket, %{}) + assert :error = connect(WorkerSocket, %{"token" => "wrong"}) + after + Application.delete_env(:reencodarr, :worker_token) + end + + test "rejects worker connections when distributed workers are disabled" do + previous_enabled = Application.get_env(:reencodarr, :distributed_worker_enabled) + previous_token = Application.get_env(:reencodarr, :worker_token) + + on_exit(fn -> + if is_nil(previous_enabled) do + Application.delete_env(:reencodarr, :distributed_worker_enabled) + else + Application.put_env(:reencodarr, :distributed_worker_enabled, previous_enabled) + end + + if is_nil(previous_token) do + Application.delete_env(:reencodarr, :worker_token) + else + Application.put_env(:reencodarr, :worker_token, previous_token) + end + end) + + Application.put_env(:reencodarr, :distributed_worker_enabled, false) + Application.put_env(:reencodarr, :worker_token, "test-worker-token") + + assert :error = connect(WorkerSocket, %{"token" => "test-worker-token"}) + end + + test "rejects invalid topics without crashing" do + assert {:error, %{reason: "unauthorized"}} = + WorkerChannel.join("workers:other", %{}, %{assigns: %{worker_id: "worker-1"}}) + after + Application.delete_env(:reencodarr, :worker_token) + end + end + + defp announce_payload(overrides \\ []) do + override_map = + overrides + |> Enum.into(%{}) + |> Map.new(fn + {key, value} when is_atom(key) -> {Atom.to_string(key), value} + pair -> pair + end) + + Map.merge( + %{ + "worker_id" => "abav1-dev", + "protocol_version" => 1, + "version" => "0.10.0", + "capabilities" => %{"crf_search" => true} + }, + override_map + ) + end +end diff --git a/test/reencodarr_web/controllers/worker_file_controller_test.exs b/test/reencodarr_web/controllers/worker_file_controller_test.exs new file mode 100644 index 00000000..c72dae1f --- /dev/null +++ b/test/reencodarr_web/controllers/worker_file_controller_test.exs @@ -0,0 +1,49 @@ +defmodule ReencodarrWeb.WorkerFileControllerTest do + use ReencodarrWeb.ConnCase, async: false + + test "requires worker bearer token", %{conn: conn} do + previous = Application.get_env(:reencodarr, :worker_token) + previous_transfer = Application.get_env(:reencodarr, :worker_transfer_token) + Application.put_env(:reencodarr, :worker_token, "test-token") + Application.delete_env(:reencodarr, :worker_transfer_token) + + on_exit(fn -> + restore_env(:worker_token, previous) + restore_env(:worker_transfer_token, previous_transfer) + end) + + conn = get(conn, ~p"/workers/files/123") + + assert conn.status == 401 + end + + test "sends the video file to authorized workers", %{conn: conn} do + previous = Application.get_env(:reencodarr, :worker_token) + previous_transfer = Application.get_env(:reencodarr, :worker_transfer_token) + Application.put_env(:reencodarr, :worker_transfer_token, "transfer-token") + + on_exit(fn -> + restore_env(:worker_token, previous) + restore_env(:worker_transfer_token, previous_transfer) + end) + + path = Path.join(System.tmp_dir!(), "reencodarr-worker-file-#{System.unique_integer()}.mkv") + File.write!(path, "video bytes") + on_exit(fn -> File.rm(path) end) + + {:ok, video} = Fixtures.video_fixture(%{path: path, size: byte_size("video bytes")}) + + conn = + conn + |> put_req_header("authorization", "Bearer transfer-token") + |> get(~p"/workers/files/#{video.id}") + + assert conn.status == 200 + assert conn.resp_body == "video bytes" + assert [content_type] = get_resp_header(conn, "content-type") + assert String.starts_with?(content_type, "application/octet-stream") + end + + defp restore_env(key, nil), do: Application.delete_env(:reencodarr, key) + defp restore_env(key, value), do: Application.put_env(:reencodarr, key, value) +end diff --git a/test/reencodarr_web/live/dashboard_v2_live_test.exs b/test/reencodarr_web/live/dashboard_v2_live_test.exs index 31daa867..19ccd0d5 100644 --- a/test/reencodarr_web/live/dashboard_v2_live_test.exs +++ b/test/reencodarr_web/live/dashboard_v2_live_test.exs @@ -7,7 +7,7 @@ defmodule ReencodarrWeb.DashboardLiveTest do - Button interactions without internal state checking - Event handling for service communication """ - use ReencodarrWeb.ConnCase, async: true + use ReencodarrWeb.ConnCase, async: false import Phoenix.LiveViewTest @@ -24,6 +24,7 @@ defmodule ReencodarrWeb.DashboardLiveTest do assert html =~ "Media Library Sync" assert html =~ "Sonarr" assert html =~ "Radarr" + assert html =~ "Open workers" assert html =~ ~s(id="dashboard-root") assert html =~ ~s(phx-hook="DashboardAnimations") assert html =~ ~s(id="dashboard-active-work") @@ -82,6 +83,27 @@ defmodule ReencodarrWeb.DashboardLiveTest do assert html =~ "initial-queue-preview.mkv" end + test "shows the worker websocket token when configured", %{conn: conn} do + previous_token = Application.get_env(:reencodarr, :worker_token) + Application.put_env(:reencodarr, :worker_token, "deploy-test-worker-token") + + on_exit(fn -> + if previous_token do + Application.put_env(:reencodarr, :worker_token, previous_token) + else + Application.delete_env(:reencodarr, :worker_token) + end + end) + + {:ok, _view, html} = live(conn, ~p"/") + expected_fingerprint = worker_token_fingerprint("deploy-test-worker-token") + + assert html =~ "Worker WebSocket" + assert html =~ "deploy-test-worker-token" + assert html =~ expected_fingerprint + assert html =~ "/workers/socket/websocket?token=" + end + test "includes chart data in the initial html response", %{conn: conn} do {:ok, video} = Fixtures.video_fixture(%{ @@ -494,4 +516,11 @@ defmodule ReencodarrWeb.DashboardLiveTest do assert html =~ "Unknown sync service" end end + + defp worker_token_fingerprint(token) do + :crypto.hash(:sha256, token) + |> Base.encode16(case: :lower) + |> String.slice(0, 12) + |> then(&"sha256:#{&1}") + end end diff --git a/test/reencodarr_web/live/workers_live_test.exs b/test/reencodarr_web/live/workers_live_test.exs new file mode 100644 index 00000000..2759047a --- /dev/null +++ b/test/reencodarr_web/live/workers_live_test.exs @@ -0,0 +1,248 @@ +defmodule ReencodarrWeb.WorkersLiveTest do + use ReencodarrWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Reencodarr.AbAv1.WorkerSessions + alias Reencodarr.Fixtures + + setup do + WorkerSessions.reset() + :ok + end + + test "renders an empty state when no workers are connected", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/workers") + + assert html =~ "Workers" + assert html =~ "No workers connected." + assert html =~ ~s(href="/workers") + end + + test "shows the transfer panel when a restarted worker is receiving input", %{conn: conn} do + {:ok, _session} = + WorkerSessions.register(%{ + server_worker_id: "worker-server-1", + client_worker_id: "worker-client-1", + protocol_version: 1, + version: "0.10.0", + capabilities: %{"crf_search" => true} + }) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + + assert {:ok, view, html} = live(conn, ~p"/workers") + assert html =~ "worker-client-1" + assert html =~ "Idle" + refute html =~ "Receiving Input" + + assert {:ok, _session} = + WorkerSessions.assign_video("worker-server-1", video.id, :receiving_input) + + assert {:ok, _session} = + WorkerSessions.touch("worker-server-1", %{ + cpu_percent: 87.5, + memory_bytes: 1_073_741_824, + memory_total_bytes: 4_294_967_296, + disk_free_bytes: 536_870_912_000, + disk_total_bytes: 1_099_511_627_776 + }) + + assert {:ok, _session} = + WorkerSessions.set_transfer_progress("worker-server-1", %{ + job_id: "job-1", + transfer_id: "job-1", + filename: Path.basename(video.path), + percent: 25.5, + bytes_sent: 2_621_440, + total_bytes: 10_485_760, + bytes_per_second: 1_048_576, + eta: 15, + chunk_index: 2, + total_chunks: 8 + }) + + send(view.pid, {:worker_sessions_updated, %{sessions: WorkerSessions.list()}}) + html = render(view) + assert html =~ "Receiving input" + assert html =~ "Receiving Input" + refute html =~ "CRF Search" + assert html =~ Path.basename(video.path) + assert html =~ "Target: 95 VMAF" + assert html =~ "CPU 87.5%" + assert html =~ "Mem 1.0 GiB / 4.0 GiB" + assert html =~ "Disk 500.0 GiB free / 1.0 TiB" + assert html =~ "2.5 MiB / 10.0 MiB" + assert html =~ "Chunk 3 / 8" + assert html =~ "25.5%" + assert html =~ "ETA 15s" + refute html =~ "CRF 28.0 -> 95.4 VMAF" + end + + test "shows HTTP transfer progress without chunk text", %{conn: conn} do + {:ok, _session} = + WorkerSessions.register(%{ + server_worker_id: "worker-server-http", + client_worker_id: "worker-client-http", + protocol_version: 1, + version: "0.10.0", + capabilities: %{"crf_search" => true} + }) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + + assert {:ok, _session} = + WorkerSessions.assign_video("worker-server-http", video.id, :receiving_input) + + assert {:ok, _session} = + WorkerSessions.set_transfer_progress("worker-server-http", %{ + job_id: "job-http", + transfer_id: "job-http", + filename: Path.basename(video.path), + percent: 50.0, + bytes_sent: 117_440_512, + total_bytes: 234_881_024, + bytes_per_second: 122_683_392, + eta: 1, + chunk_index: 0, + total_chunks: 0 + }) + + {:ok, view, _html} = live(conn, ~p"/workers") + send(view.pid, {:worker_sessions_updated, %{sessions: WorkerSessions.list()}}) + + html = render(view) + assert html =~ "Receiving Input" + assert html =~ "112.0 MiB / 224.0 MiB" + assert html =~ "117.0 MiB/s" + assert html =~ "ETA 1s" + refute html =~ "Chunk" + end + + test "transfer progress wins over stale crf progress in the worker card", %{conn: conn} do + {:ok, _session} = + WorkerSessions.register(%{ + server_worker_id: "worker-server-stale-crf", + client_worker_id: "worker-client-stale-crf", + protocol_version: 1, + version: "0.10.0", + capabilities: %{"crf_search" => true} + }) + + {:ok, video} = Fixtures.video_fixture(%{state: :crf_searching}) + + assert {:ok, _session} = + WorkerSessions.assign_video("worker-server-stale-crf", video.id) + + assert {:ok, _session} = + WorkerSessions.set_crf_search_progress("worker-server-stale-crf", %{ + video_id: video.id, + percent: 62.0, + fps: 12.5, + eta: 90, + crf: 28.0, + sample_num: 3, + total_samples: 8 + }) + + assert {:ok, _session} = + WorkerSessions.set_transfer_progress("worker-server-stale-crf", %{ + job_id: Integer.to_string(video.id), + transfer_id: Integer.to_string(video.id), + video_id: video.id, + filename: Path.basename(video.path), + percent: 10.0, + bytes_sent: 1_048_576, + total_bytes: 10_485_760 + }) + + {:ok, view, _html} = live(conn, ~p"/workers") + send(view.pid, {:worker_sessions_updated, %{sessions: WorkerSessions.list()}}) + + html = render(view) + assert html =~ "Receiving Input" + refute html =~ "CRF Search" + refute html =~ "Sample 3/8 - CRF 28.0" + end + + test "renders the crf search panel when only crf search is active", %{conn: conn} do + {:ok, _session} = + WorkerSessions.register(%{ + server_worker_id: "worker-server-2", + client_worker_id: "worker-client-2", + protocol_version: 1, + version: "0.10.0", + capabilities: %{"crf_search" => true} + }) + + {:ok, video} = Fixtures.video_fixture(%{state: :analyzed}) + + assert {:ok, _session} = WorkerSessions.assign_video("worker-server-2", video.id) + + assert {:ok, _session} = + WorkerSessions.set_crf_search_progress("worker-server-2", %{ + video_id: video.id, + percent: 62.0, + fps: 12.5, + eta: 90, + crf: 28.0, + sample_num: 3, + total_samples: 8 + }) + + Fixtures.vmaf_fixture(%{ + video_id: video.id, + crf: 28.0, + score: 95.4, + percent: 93.0 + }) + + {:ok, view, _html} = live(conn, ~p"/workers") + send(view.pid, {:worker_sessions_updated, %{sessions: WorkerSessions.list()}}) + + html = render(view) + assert html =~ "CRF Search" + assert html =~ "Sample 3/8 - CRF 28.0" + assert html =~ "CRF 28.0 -> 95.4 VMAF" + refute html =~ "Receiving Input" + end + + test "shows input ready after input transfer finishes before CRF progress", %{conn: conn} do + {:ok, _session} = + WorkerSessions.register(%{ + server_worker_id: "worker-server-3", + client_worker_id: "worker-client-3", + protocol_version: 1, + version: "0.10.0", + capabilities: %{"crf_search" => true} + }) + + {:ok, video} = Fixtures.video_fixture(%{state: :crf_searching}) + + assert {:ok, _session} = + WorkerSessions.assign_video("worker-server-3", video.id, :receiving_input) + + assert {:ok, _session} = + WorkerSessions.set_transfer_progress("worker-server-3", %{ + job_id: Integer.to_string(video.id), + video_id: video.id, + transfer_id: Integer.to_string(video.id), + filename: Path.basename(video.path), + percent: 100.0, + bytes_sent: video.size, + total_bytes: video.size + }) + + assert {:ok, _session} = WorkerSessions.finish_transfer("worker-server-3") + + {:ok, view, _html} = live(conn, ~p"/workers") + send(view.pid, {:worker_sessions_updated, %{sessions: WorkerSessions.list()}}) + + html = render(view) + assert html =~ "worker-client-3" + assert html =~ "Input ready" + assert html =~ "Input Ready" + refute html =~ "CRF Search" + refute html =~ "-%" + end +end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex new file mode 100644 index 00000000..ed2cc9dd --- /dev/null +++ b/test/support/channel_case.ex @@ -0,0 +1,20 @@ +defmodule ReencodarrWeb.ChannelCase do + @moduledoc false + + use ExUnit.CaseTemplate + + using do + quote do + @endpoint ReencodarrWeb.Endpoint + + import Phoenix.ChannelTest + + import ReencodarrWeb.ChannelCase + end + end + + setup tags do + Reencodarr.DataCase.setup_sandbox(tags) + :ok + end +end