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""" + + """ + 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""" +
ab-av1
++ Connected worker sessions and their current job state. +
+