diff --git a/.gitignore b/.gitignore
index 41695964..b7a7ba40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,3 +44,5 @@ config/dev.overrides.exs
# Log files
/logs/
.expert/
+
+.envrc
diff --git a/.serena/.gitignore b/.serena/.gitignore
new file mode 100644
index 00000000..2e510aff
--- /dev/null
+++ b/.serena/.gitignore
@@ -0,0 +1,2 @@
+/cache
+/project.local.yml
diff --git a/.serena/project.yml b/.serena/project.yml
new file mode 100644
index 00000000..ffbcfa7e
--- /dev/null
+++ b/.serena/project.yml
@@ -0,0 +1,133 @@
+# the name by which the project can be referenced within Serena
+project_name: "reencodarr"
+
+
+# list of languages for which language servers are started; choose from:
+# al angular ansible bash clojure
+# cpp cpp_ccls crystal csharp csharp_omnisharp
+# dart elixir elm erlang fortran
+# fsharp go groovy haskell haxe
+# hlsl html java json julia
+# kotlin lean4 lua luau markdown
+# matlab msl nix ocaml pascal
+# perl php php_phpactor powershell python
+# python_jedi python_ty r rego ruby
+# ruby_solargraph rust scala scss solidity
+# svelte swift systemverilog terraform toml
+# typescript typescript_vts vue yaml zig
+# (This list may be outdated. For the current list, see values of Language enum here:
+# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
+# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
+# Note:
+# - For C, use cpp
+# - For JavaScript, use typescript
+# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
+# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
+# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
+# - For Free Pascal/Lazarus, use pascal
+# Special requirements:
+# Some languages require additional setup/installations.
+# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
+# When using multiple languages, the first language server that supports a given file will be used for that file.
+# The first language is the default language and the respective language server will be used as a fallback.
+# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
+languages:
+- elixir
+
+# the encoding used by text files in the project
+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+encoding: "utf-8"
+
+# line ending convention to use when writing source files.
+# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
+# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
+line_ending:
+
+# The language backend to use for this project.
+# If not set, the global setting from serena_config.yml is used.
+# Valid values: LSP, JetBrains
+# Note: the backend is fixed at startup. If a project with a different backend
+# is activated post-init, an error will be returned.
+language_backend:
+
+# whether to use project's .gitignore files to ignore files
+ignore_all_files_in_gitignore: true
+
+# advanced configuration option allowing to configure language server-specific options.
+# Maps the language key to the options.
+# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
+# No documentation on options means no options are available.
+ls_specific_settings: {}
+
+# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
+# Paths can be absolute or relative to the project root.
+# Each folder is registered as an LSP workspace folder, enabling language servers to discover
+# symbols and references across package boundaries.
+# Currently supported for: TypeScript.
+# Example:
+# additional_workspace_folders:
+# - ../sibling-package
+# - ../shared-lib
+additional_workspace_folders: []
+
+# list of additional paths to ignore in this project.
+# Same syntax as gitignore, so you can use * and **.
+# Note: global ignored_paths from serena_config.yml are also applied additively.
+ignored_paths: []
+
+# whether the project is in read-only mode
+# If set to true, all editing tools will be disabled and attempts to use them will result in an error
+# Added on 2025-04-18
+read_only: false
+
+# list of tool names to exclude.
+# This extends the existing exclusions (e.g. from the global configuration)
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
+excluded_tools: []
+
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
+# This extends the existing inclusions (e.g. from the global configuration).
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
+included_optional_tools: []
+
+# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
+# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
+fixed_tools: []
+
+# list of mode names that are to be activated by default, overriding the setting in the global configuration.
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
+# for this project.
+# This setting can, in turn, be overridden by CLI parameters (--mode).
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
+default_modes:
+
+# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
+added_modes:
+
+# initial prompt for the project. It will always be given to the LLM upon activating the project
+# (contrary to the memories, which are loaded on demand).
+initial_prompt: ""
+
+# time budget (seconds) per tool call for the retrieval of additional symbol information
+# such as docstrings or parameter information.
+# This overrides the corresponding setting in the global configuration; see the documentation there.
+# If null or missing, use the setting from the global configuration.
+symbol_info_budget:
+
+# list of regex patterns which, when matched, mark a memory entry as read‑only.
+# Extends the list from the global configuration, merging the two lists.
+read_only_memory_patterns: []
+
+# list of regex patterns for memories to completely ignore.
+# Matching memories will not appear in list_memories or activate_project output
+# and cannot be accessed via read_memory or write_memory.
+# To access ignored memory files, use the read_file tool on the raw file path.
+# Extends the list from the global configuration, merging the two lists.
+# Example: ["_archive/.*", "_episodes/.*"]
+ignored_memory_patterns: []
diff --git a/AGENTS.md b/AGENTS.md
index 34329ad0..256c8f99 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -62,6 +62,10 @@
- Use `meck` where the current test suite already uses it for external command mocking.
- When changing sync, parser, state-machine, or LiveView behavior, add or update tests near the affected module.
+## List LiveViews (Flop)
+- Paginated list pages (`VideosLive`, `FailuresLive`, `BadFilesLive`) use `handle_params`, URL query params, and `ReencodarrWeb.Live.FlopList` (`flop_pagination`, `patch_with_page`, `parse_page`).
+- List APIs return `{items, %Flop.Meta{}}`: `Media.list_videos_paginated/1`, `Media.list_failures/1`, `Media.list_bad_file_issues/2`.
+
## File/Module Pointers
- `lib/reencodarr/sync.ex` - Sonarr/Radarr sync and batch upserts.
- `lib/reencodarr/media/video_upsert.ex` - guarded upsert logic, bitrate/VMAF handling.
diff --git a/lib/reencodarr/bad_files/state.ex b/lib/reencodarr/bad_files/state.ex
index 196906e6..8c4667aa 100644
--- a/lib/reencodarr/bad_files/state.ex
+++ b/lib/reencodarr/bad_files/state.ex
@@ -5,81 +5,135 @@ defmodule Reencodarr.BadFiles.State do
@active_statuses [:open, :queued, :processing, :waiting_for_replacement, :failed]
@resolved_statuses [:replaced_clean, :dismissed]
- @resolved_limit 50
-
- @spec load(map()) :: map()
- def load(assigns) do
- filters = [
- service: assigns.service_filter,
- kind: assigns.kind_filter,
- search: assigns.search_query
- ]
-
- {active_statuses, resolved_statuses} = statuses_for_filter(assigns.status_filter)
- active_issues = fetch_active_issues(filters, active_statuses, assigns)
- active_total = fetch_active_total(filters, active_statuses)
- issue_summary = Media.bad_file_issue_summary()
- resolved_issues = fetch_resolved_issues(filters, resolved_statuses, assigns.show_resolved)
- issues = active_issues ++ resolved_issues
+ @active_status_filters Map.new(@active_statuses, &{Atom.to_string(&1), &1})
+ @replacement_statuses [:processing, :waiting_for_replacement]
+
+ @spec load(map(), keyword()) :: map()
+ def load(assigns, opts \\ []) do
+ include_summary? = Keyword.get(opts, :include_summary, true)
+ statuses = statuses_for_filter(assigns.status_filter)
+ {issues, meta} = fetch_issues(statuses, assigns)
+ issue_summary = issue_summary(assigns, opts, include_summary?)
%{
issues: issues,
+ meta: meta,
tracked_count:
issue_summary.open + issue_summary.queued + issue_summary.processing +
issue_summary.waiting_for_replacement + issue_summary.failed + issue_summary.resolved,
- active_total: active_total,
- active_issues: active_issues,
- replacement_issues:
- Enum.filter(active_issues, &(&1.status in [:processing, :waiting_for_replacement])),
- resolved_issues: resolved_issues,
+ active_total: meta.total_count,
+ active_issues: issues,
+ replacement_issues: list_replacement_issues(assigns),
+ resolved_issues: side_resolved_issues(assigns),
issue_summary: issue_summary
}
end
- defp fetch_active_issues(_filters, [], _assigns), do: []
+ def active_statuses_for_filter("all"), do: @active_statuses
+ def active_statuses_for_filter("resolved"), do: []
+
+ def active_statuses_for_filter(status_filter) when is_binary(status_filter) do
+ case Map.fetch(@active_status_filters, status_filter) do
+ {:ok, status} -> [status]
+ :error -> @active_statuses
+ end
+ end
- defp fetch_active_issues(filters, active_statuses, assigns) do
- Media.list_bad_file_issues(
- filters ++
- [
- statuses: active_statuses,
- limit: assigns.per_page,
- offset: (assigns.page - 1) * assigns.per_page
- ]
+ def active_statuses_for_filter(_status_filter), do: @active_statuses
+
+ def list_active_issues(assigns, opts \\ []) do
+ page_size = Keyword.get(opts, :per_page, 250)
+
+ fetch_all_issues(
+ assigns,
+ active_statuses_for_filter(assigns.status_filter),
+ page_size
)
end
- defp fetch_active_total(_filters, []), do: 0
+ def flop_params(assigns) do
+ %{
+ "page" => to_string(assigns.page),
+ "page_size" => to_string(assigns.per_page),
+ "service" => assigns.service_filter,
+ "kind" => assigns.kind_filter,
+ "search" => assigns.search_query
+ }
+ end
+
+ defp fetch_all_issues(_assigns, [], _page_size), do: []
- defp fetch_active_total(filters, active_statuses) do
- Media.count_bad_file_issues(Keyword.put_new(filters, :statuses, active_statuses))
+ defp fetch_all_issues(assigns, statuses, page_size) do
+ assigns
+ |> Map.put(:page, 1)
+ |> Map.put(:per_page, page_size)
+ |> fetch_all_active_issues(statuses, page_size, [])
+ |> Enum.reverse()
end
- defp fetch_resolved_issues(_filters, _resolved_statuses, false), do: []
- defp fetch_resolved_issues(_filters, [], true), do: []
+ defp fetch_all_active_issues(assigns, statuses, page_size, acc) do
+ {issues, meta} = fetch_issues(statuses, assigns)
+ acc = Enum.reverse(issues, acc)
- defp fetch_resolved_issues(filters, resolved_statuses, true) do
- Media.list_bad_file_issues(filters ++ [statuses: resolved_statuses, limit: @resolved_limit])
+ if more_pages?(meta) do
+ assigns
+ |> Map.put(:page, meta.current_page + 1)
+ |> Map.put(:per_page, page_size)
+ |> fetch_all_active_issues(statuses, page_size, acc)
+ else
+ acc
+ end
end
- defp statuses_for_filter(status_filter) do
- case status_filter do
- "all" ->
- {@active_statuses, @resolved_statuses}
+ defp list_replacement_issues(assigns) do
+ assigns
+ |> flop_params()
+ |> Media.list_bad_file_issue_previews(
+ statuses: @replacement_statuses,
+ limit: assigns.per_page
+ )
+ end
- "resolved" ->
- {[], @resolved_statuses}
+ defp issue_summary(_assigns, _opts, true), do: Media.bad_file_issue_summary()
- other ->
- status = String.to_existing_atom(other)
+ defp issue_summary(assigns, opts, false) do
+ Keyword.get(opts, :issue_summary) || assigns[:issue_summary] || Media.bad_file_issue_summary()
+ end
- if status in @resolved_statuses do
- {[], [status]}
- else
- {[status], []}
- end
+ defp fetch_issues([], %{per_page: per_page}), do: {[], empty_meta(per_page)}
+
+ defp fetch_issues(statuses, assigns) do
+ Media.list_bad_file_issues(flop_params(assigns), statuses: statuses)
+ end
+
+ defp side_resolved_issues(%{status_filter: "resolved"}), do: []
+ defp side_resolved_issues(%{show_resolved: false}), do: []
+
+ defp side_resolved_issues(assigns) do
+ {issues, _meta} = fetch_issues(@resolved_statuses, Map.put(assigns, :page, 1))
+
+ issues
+ end
+
+ defp statuses_for_filter("all"), do: @active_statuses
+ defp statuses_for_filter("resolved"), do: @resolved_statuses
+
+ defp statuses_for_filter(status_filter) when is_binary(status_filter) do
+ case Map.fetch(@active_status_filters, status_filter) do
+ {:ok, status} -> [status]
+ :error -> @active_statuses
end
- rescue
- ArgumentError -> {@active_statuses, @resolved_statuses}
+ end
+
+ defp statuses_for_filter(_status_filter), do: @active_statuses
+
+ defp more_pages?(%Flop.Meta{current_page: current_page, total_pages: total_pages})
+ when is_integer(current_page) and is_integer(total_pages),
+ do: current_page < total_pages
+
+ defp more_pages?(_meta), do: false
+
+ defp empty_meta(per_page) do
+ %Flop.Meta{current_page: 1, page_size: per_page, total_count: 0, total_pages: 1}
end
end
diff --git a/lib/reencodarr/media.ex b/lib/reencodarr/media.ex
index 8d2f6860..da2e9913 100644
--- a/lib/reencodarr/media.ex
+++ b/lib/reencodarr/media.ex
@@ -350,30 +350,77 @@ defmodule Reencodarr.Media do
# --- Bad File Issue Functions ---
@resolved_bad_file_issue_statuses [:replaced_clean, :dismissed]
+ @bad_file_video_fields [:id, :path, :service_type, :service_id]
+ @failure_stage_values ~w(all analysis crf_search encoding post_process)
+ @failure_category_values ~w(all file_access process_failure timeout codec_issues)
- @spec list_bad_file_issues(keyword()) :: [BadFileIssue.t()]
- def list_bad_file_issues(opts \\ []) do
- video_preload_query =
- from v in Video,
- select: struct(v, [:id, :path, :service_type, :service_id])
+ @doc """
+ Returns bad file issues with Flop pagination and optional service/kind/search filters.
+
+ Pass `statuses:` in opts to restrict issue statuses (required for meaningful results).
+ """
+ @spec list_bad_file_issues(map(), keyword()) :: {[BadFileIssue.t()], Flop.Meta.t()}
+ def list_bad_file_issues(params \\ %{}, opts \\ []) when is_map(params) do
+ statuses = Keyword.get_lazy(opts, :statuses, &BadFileIssue.status_values/0)
+ service = bad_file_service_param(Map.get(params, "service", "all"))
+ kind = bad_file_kind_param(Map.get(params, "kind", "all"))
+ search = params |> Map.get("search", "") |> bad_file_normalize_search()
+
+ flop_params =
+ params
+ |> Map.take(["page", "page_size", "filters", "order_by", "order_directions"])
+ |> Map.put_new("page", "1")
+ |> Map.put_new("page_size", "50")
+ |> Map.put_new("order_by", ["updated_at", "id"])
+ |> Map.put_new("order_directions", bad_file_order_directions(statuses))
+
+ base_query =
+ from(i in BadFileIssue)
+ |> bad_file_status_filter(statuses)
+ |> bad_file_service_filter(service)
+ |> bad_file_kind_filter(kind)
+ |> bad_file_search_filter(search)
+
+ video_preload_query = from(v in Video, select: struct(v, ^@bad_file_video_fields))
+
+ case Flop.validate_and_run(base_query, flop_params, for: BadFileIssue) do
+ {:ok, {issues, meta}} ->
+ {Repo.preload(issues, video: video_preload_query), meta}
+
+ {:error, %Flop.Meta{} = meta} ->
+ {[], meta}
+ end
+ end
+
+ defp bad_file_order_directions(statuses) do
+ if Enum.all?(statuses, &(&1 in @resolved_bad_file_issue_statuses)) do
+ ["desc", "asc"]
+ else
+ ["desc", "desc"]
+ end
+ end
+
+ @doc false
+ @spec list_bad_file_issue_previews(map(), keyword()) :: [BadFileIssue.t()]
+ def list_bad_file_issue_previews(params \\ %{}, opts \\ []) when is_map(params) do
+ statuses = Keyword.get_lazy(opts, :statuses, &BadFileIssue.status_values/0)
+ limit = opts |> Keyword.get(:limit, 50) |> Parsers.parse_int(50) |> max(1)
+ service = bad_file_service_param(Map.get(params, "service", "all"))
+ kind = bad_file_kind_param(Map.get(params, "kind", "all"))
+ search = params |> Map.get("search", "") |> bad_file_normalize_search()
+ video_preload_query = from(v in Video, select: struct(v, ^@bad_file_video_fields))
BadFileIssue
- |> bad_file_issue_filters_query(opts)
+ |> bad_file_status_filter(statuses)
+ |> bad_file_service_filter(service)
+ |> bad_file_kind_filter(kind)
+ |> bad_file_search_filter(search)
|> order_by([i], desc: i.updated_at, desc: i.id)
- |> maybe_limit_bad_file_issues(Keyword.get(opts, :limit))
- |> maybe_offset_bad_file_issues(Keyword.get(opts, :offset))
+ |> limit(^limit)
|> Repo.all()
|> Repo.preload(video: video_preload_query)
end
- @spec count_bad_file_issues(keyword()) :: non_neg_integer()
- def count_bad_file_issues(opts \\ []) do
- BadFileIssue
- |> bad_file_issue_filters_query(opts)
- |> select([i], count(i.id))
- |> Repo.one()
- end
-
@spec bad_file_issue_summary() :: %{
open: non_neg_integer(),
queued: non_neg_integer(),
@@ -562,10 +609,9 @@ defmodule Reencodarr.Media do
group_key ->
issues =
- list_bad_file_issues()
+ unresolved_bad_file_issues()
|> Enum.filter(fn candidate ->
- unresolved_bad_file_issue?(candidate) and
- series_group_key(candidate.video) == group_key
+ series_group_key(candidate.video) == group_key
end)
Enum.each(issues, &enqueue_bad_file_issue/1)
@@ -878,78 +924,225 @@ defmodule Reencodarr.Media do
defp maybe_put_last_attempted_at(attrs, _status), do: attrs
- defp bad_file_issue_filters_query(queryable, opts) do
- queryable
- |> maybe_filter_bad_file_issue_statuses(Keyword.get(opts, :statuses, :all))
- |> maybe_filter_bad_file_issue_service(Keyword.get(opts, :service, "all"))
- |> maybe_filter_bad_file_issue_kind(Keyword.get(opts, :kind, "all"))
- |> maybe_filter_bad_file_issue_search(Keyword.get(opts, :search, ""))
+ defp bad_file_status_filter(query, statuses), do: from(i in query, where: i.status in ^statuses)
+
+ defp bad_file_service_filter(query, "all"), do: query
+
+ defp bad_file_service_filter(query, service) when service in ["sonarr", "radarr"] do
+ service_type = String.to_existing_atom(service)
+
+ query
+ |> bad_file_ensure_video_join()
+ |> then(&from([i, video: v] in &1, where: v.service_type == ^service_type))
end
- defp maybe_filter_bad_file_issue_statuses(query, :all), do: query
+ defp bad_file_service_filter(query, _service), do: query
- defp maybe_filter_bad_file_issue_statuses(query, statuses) when is_list(statuses) do
- from i in query, where: i.status in ^statuses
+ defp bad_file_kind_filter(query, "all"), do: query
+
+ defp bad_file_kind_filter(query, kind) when kind in ["audio", "manual"] do
+ issue_kind = String.to_existing_atom(kind)
+ from(i in query, where: i.issue_kind == ^issue_kind)
end
- defp maybe_filter_bad_file_issue_service(query, "all"), do: query
+ defp bad_file_kind_filter(query, _kind), do: query
- defp maybe_filter_bad_file_issue_service(query, service) when service in ["sonarr", "radarr"] do
- service_type = String.to_existing_atom(service)
- query = ensure_bad_file_issue_video_join(query)
- from [i, video: v] in query, where: v.service_type == ^service_type
+ defp bad_file_search_filter(query, search) do
+ fts_search_filter(
+ query,
+ search,
+ fn query, fts_query ->
+ from(i in query,
+ where:
+ fragment(
+ "? IN (SELECT rowid FROM bad_file_issues_search WHERE bad_file_issues_search MATCH ?)",
+ i.id,
+ ^fts_query
+ ) or
+ fragment(
+ "? IN (SELECT rowid FROM videos_search WHERE videos_search MATCH ?)",
+ i.video_id,
+ ^fts_query
+ )
+ )
+ end,
+ fn query, pattern ->
+ query
+ |> bad_file_ensure_video_join()
+ |> then(
+ &from([i, video: v] in &1,
+ where:
+ fragment("lower(?) like ? escape '\\'", v.path, ^pattern) or
+ fragment("lower(coalesce(?, '')) like ? escape '\\'", i.manual_reason, ^pattern) or
+ fragment("lower(coalesce(?, '')) like ? escape '\\'", i.manual_note, ^pattern) or
+ fragment("lower(?) like ? escape '\\'", i.classification, ^pattern) or
+ fragment("lower(?) like ? escape '\\'", i.issue_kind, ^pattern)
+ )
+ )
+ end
+ )
+ end
+
+ defp bad_file_ensure_video_join(query) do
+ if has_named_binding?(query, :video) do
+ query
+ else
+ from(i in query, join: v in assoc(i, :video), as: :video)
+ end
end
- defp maybe_filter_bad_file_issue_service(query, _service), do: query
+ defp bad_file_service_param(service) when service in ~w(all sonarr radarr), do: service
+ defp bad_file_service_param(_service), do: "all"
- defp maybe_filter_bad_file_issue_kind(query, "all"), do: query
+ defp bad_file_kind_param(kind) when kind in ~w(all audio manual), do: kind
+ defp bad_file_kind_param(_kind), do: "all"
- defp maybe_filter_bad_file_issue_kind(query, kind) when kind in ["audio", "manual"] do
- issue_kind = String.to_existing_atom(kind)
- from i in query, where: i.issue_kind == ^issue_kind
+ defp bad_file_normalize_search(search) when is_binary(search),
+ do: search |> String.trim() |> String.downcase()
+
+ defp bad_file_normalize_search(_search), do: ""
+
+ defp failure_search_filter(query, search) do
+ fts_search_filter(
+ query,
+ search,
+ fn query, fts_query ->
+ from(v in query,
+ where:
+ fragment(
+ "? IN (SELECT rowid FROM videos_search WHERE videos_search MATCH ?)",
+ v.id,
+ ^fts_query
+ ) or
+ fragment(
+ "? IN (SELECT video_id FROM video_failures_search WHERE video_failures_search MATCH ?)",
+ v.id,
+ ^fts_query
+ )
+ )
+ end,
+ fn query, pattern ->
+ from(v in query,
+ where:
+ fragment("lower(?) like ? escape '\\'", v.path, ^pattern) or
+ fragment(
+ "? IN (SELECT video_id FROM video_failures WHERE lower(coalesce(failure_code, '')) like ? escape '\\' OR lower(coalesce(failure_message, '')) like ? escape '\\')",
+ v.id,
+ ^pattern,
+ ^pattern
+ )
+ )
+ end
+ )
end
- defp maybe_filter_bad_file_issue_kind(query, _kind), do: query
+ defp failure_stage_param(stage) when stage in @failure_stage_values, do: stage
+ defp failure_stage_param(_stage), do: "all"
+
+ defp failure_category_param(category) when category in @failure_category_values, do: category
+ defp failure_category_param(_category), do: "all"
- defp maybe_filter_bad_file_issue_search(query, ""), do: query
+ defp failure_normalize_search(search) when is_binary(search), do: String.trim(search)
+ defp failure_normalize_search(_search), do: ""
- defp maybe_filter_bad_file_issue_search(query, search) when is_binary(search) do
- pattern = "%" <> String.downcase(search) <> "%"
- query = ensure_bad_file_issue_video_join(query)
+ defp failures_by_video(videos, stage, category) do
+ video_ids = Enum.map(videos, & &1.id)
+
+ from(f in VideoFailure,
+ where: f.video_id in ^video_ids and f.resolved == false,
+ order_by: [desc: f.inserted_at]
+ )
+ |> video_failure_stage_filter(stage)
+ |> video_failure_category_filter(category)
+ |> Repo.all()
+ |> Enum.group_by(& &1.video_id)
+ end
- from [i, video: v] in query,
- where:
- fragment("lower(?) like ?", v.path, ^pattern) or
- fragment("lower(coalesce(?, '')) like ?", i.manual_reason, ^pattern) or
- fragment("lower(coalesce(?, '')) like ?", i.manual_note, ^pattern) or
- fragment("lower(?) like ?", i.classification, ^pattern) or
- fragment("lower(?) like ?", i.issue_kind, ^pattern)
+ defp summarize_failure_stats(stats) do
+ recent_count = Enum.reduce(stats, 0, fn stat, acc -> acc + (stat.count || 0) end)
+ %{recent_count: recent_count}
end
- defp maybe_filter_bad_file_issue_search(query, _search), do: query
+ defp total_pages(total, _per_page) when total <= 0, do: 1
+ defp total_pages(total, per_page), do: max(ceil(total / per_page), 1)
- defp maybe_limit_bad_file_issues(query, limit) when is_integer(limit) and limit > 0 do
- from i in query, limit: ^limit
+ defp failure_join_filters(query, "all", "all"), do: query
+
+ defp failure_join_filters(query, stage, category) do
+ from(v in query,
+ join: f in VideoFailure,
+ on: f.video_id == v.id,
+ where: f.resolved == false,
+ distinct: true
+ )
+ |> failure_stage_filter(stage)
+ |> failure_category_filter(category)
end
- defp maybe_limit_bad_file_issues(query, _limit), do: query
+ defp failure_stage_filter(query, "all"), do: query
- defp maybe_offset_bad_file_issues(query, offset) when is_integer(offset) and offset >= 0 do
- from i in query, offset: ^offset
+ defp failure_stage_filter(query, stage) do
+ case failure_stage_atom(stage) do
+ {:ok, atom} -> from [v, f] in query, where: f.failure_stage == ^atom
+ :error -> from [v, f] in query, where: false
+ end
end
- defp maybe_offset_bad_file_issues(query, _offset), do: query
+ defp failure_category_filter(query, "all"), do: query
- defp ensure_bad_file_issue_video_join(%Ecto.Query{aliases: aliases} = query) do
- if Map.has_key?(aliases, :video) do
- query
- else
- join(query, :inner, [i], v in assoc(i, :video), as: :video)
+ defp failure_category_filter(query, category) do
+ case failure_category_atom(category) do
+ {:ok, atom} -> from [v, f] in query, where: f.failure_category == ^atom
+ :error -> from [v, f] in query, where: false
+ end
+ end
+
+ defp video_failure_stage_filter(query, "all"), do: query
+
+ defp video_failure_stage_filter(query, stage) do
+ case failure_stage_atom(stage) do
+ {:ok, atom} -> from(f in query, where: f.failure_stage == ^atom)
+ :error -> from(f in query, where: false)
+ end
+ end
+
+ defp video_failure_category_filter(query, "all"), do: query
+
+ defp video_failure_category_filter(query, category) do
+ case failure_category_atom(category) do
+ {:ok, atom} -> from(f in query, where: f.failure_category == ^atom)
+ :error -> from(f in query, where: false)
end
end
- defp unresolved_bad_file_issue?(issue) do
- issue.status not in [:replaced_clean, :dismissed]
+ defp failure_stage_atom("analysis"), do: {:ok, :analysis}
+ defp failure_stage_atom("crf_search"), do: {:ok, :crf_search}
+ defp failure_stage_atom("encoding"), do: {:ok, :encoding}
+ defp failure_stage_atom("post_process"), do: {:ok, :post_process}
+ defp failure_stage_atom(_), do: :error
+
+ defp failure_category_atom("file_access"), do: {:ok, :file_access}
+ defp failure_category_atom("process_failure"), do: {:ok, :process_failure}
+ defp failure_category_atom("timeout"), do: {:ok, :timeout}
+ defp failure_category_atom("codec_issues"), do: {:ok, :codec_issues}
+ defp failure_category_atom(_), do: :error
+
+ defp unresolved_bad_file_issues do
+ statuses = BadFileIssue.status_values() -- @resolved_bad_file_issue_statuses
+ collect_bad_file_issues(%{"page" => "1", "page_size" => "250"}, statuses, [])
+ end
+
+ defp collect_bad_file_issues(params, statuses, acc) do
+ {issues, meta} = list_bad_file_issues(params, statuses: statuses)
+ acc = acc ++ issues
+
+ if meta.current_page && meta.total_pages && meta.current_page < meta.total_pages do
+ params
+ |> Map.put("page", to_string(meta.current_page + 1))
+ |> collect_bad_file_issues(statuses, acc)
+ else
+ acc
+ end
end
defp series_group_key(%Video{service_type: :sonarr, path: path}) when is_binary(path) do
@@ -1075,6 +1268,85 @@ defmodule Reencodarr.Media do
def get_common_failure_patterns(limit \\ 10),
do: VideoFailure.get_common_failure_patterns(limit)
+ @doc """
+ Returns failed videos with Flop pagination and optional stage/category/search filters.
+ """
+ @spec list_failures(map()) :: {[Video.t()], Flop.Meta.t()}
+ def list_failures(params) when is_map(params) do
+ stage = failure_stage_param(Map.get(params, "stage", "all"))
+ category = failure_category_param(Map.get(params, "category", "all"))
+ search = params |> Map.get("search", "") |> failure_normalize_search()
+
+ flop_params =
+ params
+ |> Map.take(["page", "page_size", "filters", "order_by", "order_directions"])
+ |> Map.put_new("page", "1")
+ |> Map.put_new("page_size", "20")
+
+ base_query =
+ from(v in Video, where: v.state == :failed)
+ |> failure_join_filters(stage, category)
+ |> failure_search_filter(search)
+
+ case Flop.validate_and_run(base_query, flop_params, for: Video) do
+ {:ok, {videos, meta}} -> {videos, meta}
+ {:error, %Flop.Meta{} = meta} -> {[], meta}
+ end
+ end
+
+ @doc """
+ Loads the full failures LiveView page payload from URL params.
+
+ This wraps `list_failures/1` with clamped pagination and the failure maps
+ needed by the UI. Pass `include_support: false` to skip summary/pattern/action
+ queries on hot reload paths.
+ """
+ @spec load_failures_page(map(), keyword()) :: map()
+ def load_failures_page(params, opts \\ []) when is_map(params) do
+ include_support? = Keyword.get(opts, :include_support, true)
+ {failed_videos, meta} = list_failures(params)
+
+ total_count = meta.total_count || 0
+ per_page = meta.page_size || params |> Map.get("page_size", "20") |> Parsers.parse_int(20)
+ requested_page = params |> Map.get("page", "1") |> Parsers.parse_int(1) |> max(1)
+ clamped_page = min(requested_page, total_pages(total_count, per_page))
+
+ {failed_videos, meta} =
+ if clamped_page != requested_page and total_count > 0 do
+ params
+ |> Map.put("page", to_string(clamped_page))
+ |> list_failures()
+ else
+ {failed_videos, meta}
+ end
+
+ payload = %{
+ loading: false,
+ failed_videos: failed_videos,
+ video_failures:
+ failures_by_video(failed_videos, params["stage"] || "all", params["category"] || "all"),
+ total_count: total_count,
+ total_pages: total_pages(total_count, per_page),
+ page: clamped_page,
+ per_page: per_page,
+ meta: meta
+ }
+
+ if include_support? do
+ Map.merge(payload, failure_support_payload())
+ else
+ payload
+ end
+ end
+
+ defp failure_support_payload do
+ %{
+ failure_stats: summarize_failure_stats(get_failure_statistics(days_back: 7)),
+ failure_patterns: get_common_failure_patterns(5),
+ failure_code_actions: list_failed_video_failure_codes()
+ }
+ end
+
@doc """
Resets videos stuck in `:analyzing` back to `:needs_analysis`.
@@ -2547,15 +2819,11 @@ defmodule Reencodarr.Media do
defp maybe_filter_hdr(query, true), do: from(v in query, where: not is_nil(v.hdr))
defp maybe_filter_hdr(query, false), do: from(v in query, where: is_nil(v.hdr))
- defp maybe_filter_search(query, nil), do: query
- defp maybe_filter_search(query, ""), do: query
-
defp maybe_filter_search(query, search) when is_binary(search) do
- case search_to_fts_query(search) do
- nil ->
- query
-
- fts_query ->
+ fts_search_filter(
+ query,
+ search,
+ fn query, fts_query ->
from(v in query,
where:
fragment(
@@ -2564,6 +2832,30 @@ defmodule Reencodarr.Media do
^fts_query
)
)
+ end,
+ fn query, pattern ->
+ from(v in query,
+ where:
+ fragment("lower(?) like ? escape '\\'", v.path, ^pattern) or
+ fragment("lower(coalesce(?, '')) like ? escape '\\'", v.title, ^pattern)
+ )
+ end
+ )
+ end
+
+ defp maybe_filter_search(query, _search), do: query
+
+ defp fts_search_filter(query, search, _apply_search, _apply_literal) when search in [nil, ""],
+ do: query
+
+ defp fts_search_filter(query, search, apply_search, apply_literal)
+ when is_function(apply_search, 2) and is_function(apply_literal, 2) do
+ case search_to_fts_query(search) do
+ nil ->
+ apply_literal.(query, SharedQueries.like_contains_pattern(String.downcase(search)))
+
+ fts_query ->
+ apply_search.(query, fts_query)
end
end
diff --git a/lib/reencodarr/media/bad_file_issue.ex b/lib/reencodarr/media/bad_file_issue.ex
index 1e580e7e..c983c9c3 100644
--- a/lib/reencodarr/media/bad_file_issue.ex
+++ b/lib/reencodarr/media/bad_file_issue.ex
@@ -24,6 +24,18 @@ defmodule Reencodarr.Media.BadFileIssue do
@type t() :: %__MODULE__{}
+ @derive {
+ Flop.Schema,
+ filterable: [:status, :issue_kind, :origin, :classification],
+ sortable: [:id, :inserted_at, :updated_at],
+ default_order: %{
+ order_by: [:updated_at, :id],
+ order_directions: [:desc, :desc]
+ },
+ default_limit: 50,
+ max_limit: 250
+ }
+
schema "bad_file_issues" do
belongs_to :video, Video
diff --git a/lib/reencodarr/media/shared_queries.ex b/lib/reencodarr/media/shared_queries.ex
index 6b60023a..11549e90 100644
--- a/lib/reencodarr/media/shared_queries.ex
+++ b/lib/reencodarr/media/shared_queries.ex
@@ -18,8 +18,20 @@ defmodule Reencodarr.Media.SharedQueries do
Returns a dynamic query fragment that can be used in where clauses.
"""
def case_insensitive_like(field, pattern) do
- # SQLite: Use LIKE with UPPER() on both sides
- dynamic([q], fragment("UPPER(?) LIKE UPPER(?)", field(q, ^field), ^pattern))
+ dynamic([q], fragment("UPPER(?) LIKE UPPER(?) ESCAPE '\\'", field(q, ^field), ^pattern))
+ end
+
+ def like_contains_pattern(value) when is_binary(value) do
+ "%" <> escape_like(value) <> "%"
+ end
+
+ def like_contains_pattern(_value), do: "%"
+
+ defp escape_like(value) do
+ value
+ |> String.replace("\\", "\\\\")
+ |> String.replace("%", "\\%")
+ |> String.replace("_", "\\_")
end
@doc """
diff --git a/lib/reencodarr/videos/state.ex b/lib/reencodarr/videos/state.ex
index 60fd7838..9c6578a9 100644
--- a/lib/reencodarr/videos/state.ex
+++ b/lib/reencodarr/videos/state.ex
@@ -7,17 +7,20 @@ defmodule Reencodarr.Videos.State do
def load(assigns, opts \\ []) do
include_state_counts? = Keyword.get(opts, :include_state_counts, true)
+ {videos, meta} = list_videos(assigns)
+
+ total = meta.total_count || 0
+ per_page = meta.page_size || assigns.per_page
+ page = min(max(assigns.page, 1), total_pages(total, per_page))
+
{videos, meta} =
- Media.list_videos_paginated(
- page: assigns.page,
- per_page: assigns.per_page,
- state: assigns.state_filter,
- service_type: assigns.service_filter,
- hdr: assigns.hdr_filter,
- search: assigns.search,
- sort_by: assigns.sort_by,
- sort_dir: assigns.sort_dir
- )
+ if page != assigns.page and total > 0 do
+ assigns
+ |> Map.put(:page, page)
+ |> list_videos()
+ else
+ {videos, meta}
+ end
state_counts =
if include_state_counts? do
@@ -29,10 +32,26 @@ defmodule Reencodarr.Videos.State do
%{
videos: videos,
meta: meta,
- total: meta.total_count || 0,
- page: meta.current_page || assigns.page,
- per_page: meta.page_size || assigns.per_page,
+ total: total,
+ page: page,
+ per_page: per_page,
state_counts: state_counts
}
end
+
+ defp list_videos(assigns) do
+ Media.list_videos_paginated(
+ page: assigns.page,
+ per_page: assigns.per_page,
+ state: assigns.state_filter,
+ service_type: assigns.service_filter,
+ hdr: assigns.hdr_filter,
+ search: assigns.search,
+ sort_by: assigns.sort_by,
+ sort_dir: assigns.sort_dir
+ )
+ end
+
+ defp total_pages(total, _per_page) when total <= 0, do: 1
+ defp total_pages(total, per_page), do: max(ceil(total / per_page), 1)
end
diff --git a/lib/reencodarr_web.ex b/lib/reencodarr_web.ex
index d01e3dc0..04841e93 100644
--- a/lib/reencodarr_web.ex
+++ b/lib/reencodarr_web.ex
@@ -85,6 +85,7 @@ defmodule ReencodarrWeb do
import Phoenix.HTML
# Core UI components and translation
import ReencodarrWeb.CoreComponents
+ import ReencodarrWeb.Live.FlopList
import Flop.Phoenix, except: [table: 1]
use Gettext, backend: ReencodarrWeb.Gettext
diff --git a/lib/reencodarr_web/live/bad_files_live.ex b/lib/reencodarr_web/live/bad_files_live.ex
index 595d30fb..8cbfdac2 100644
--- a/lib/reencodarr_web/live/bad_files_live.ex
+++ b/lib/reencodarr_web/live/bad_files_live.ex
@@ -7,7 +7,7 @@ defmodule ReencodarrWeb.BadFilesLive do
alias Reencodarr.Dashboard.Events
alias Reencodarr.Media
alias Reencodarr.Media.BadFileIssue
- alias ReencodarrWeb.Live.ListPagination
+ alias ReencodarrWeb.Live.FlopList
@update_interval 30_000
@per_page_options [25, 50, 100, 250]
@@ -23,15 +23,12 @@ defmodule ReencodarrWeb.BadFilesLive do
]
@service_filter_values ["all", "sonarr", "radarr"]
@kind_filter_values ["all" | Enum.map(BadFileIssue.issue_kind_values(), &to_string/1)]
- @active_statuses [:open, :queued, :processing, :waiting_for_replacement, :failed]
+ @param_keys [:status_filter, :service_filter, :kind_filter, :search_query, :page, :per_page]
@impl true
- def mount(params, _session, socket) do
- filters = parse_params(params)
-
+ def mount(_params, _session, socket) do
socket =
- socket
- |> assign(
+ assign(socket,
per_page_options: @per_page_options,
status_filter_values: @status_filter_values,
service_filter_values: @service_filter_values,
@@ -46,6 +43,8 @@ defmodule ReencodarrWeb.BadFilesLive do
show_resolved: false,
loaded_once: false,
issues: [],
+ meta: %Flop.Meta{},
+ url_query: %{},
tracked_count: 0,
active_total: 0,
active_issues: [],
@@ -60,8 +59,6 @@ defmodule ReencodarrWeb.BadFilesLive do
resolved: 0
}
)
- |> assign(filters)
- |> load_initial_snapshot()
if connected?(socket) do
Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel())
@@ -79,11 +76,8 @@ defmodule ReencodarrWeb.BadFilesLive do
socket =
socket
|> assign(filters)
- |> then(fn s ->
- if connected?(s) and s.assigns.loaded_once and filters_changed?,
- do: async_load_issues(s),
- else: s
- end)
+ |> assign_url_query()
+ |> reload_issues_for_params(filters_changed?)
{:noreply, socket}
end
@@ -97,7 +91,11 @@ defmodule ReencodarrWeb.BadFilesLive do
@impl true
def handle_info({event, _data}, socket)
when event in [:sync_started, :sync_progress, :sync_completed, :bad_file_issue_updated] do
- {:noreply, if(socket.assigns.loaded_once, do: async_load_issues(socket), else: socket)}
+ {:noreply,
+ if(socket.assigns.loaded_once,
+ do: async_load_issues(socket, include_summary: true),
+ else: socket
+ )}
end
@impl true
@@ -146,33 +144,6 @@ defmodule ReencodarrWeb.BadFilesLive do
)}
end
- @impl true
- def handle_event("set_per_page", %{"per_page" => n}, socket) do
- per_page = Parsers.parse_int(n, @default_per_page)
- per_page = if per_page in @per_page_options, do: per_page, else: @default_per_page
- {:noreply, push_patch(socket, to: patch_path(socket.assigns, per_page: per_page, page: 1))}
- end
-
- @impl true
- def handle_event("prev_page", _params, socket) do
- if socket.assigns.page > 1 do
- {:noreply,
- push_patch(socket, to: patch_path(socket.assigns, page: socket.assigns.page - 1))}
- else
- {:noreply, socket}
- end
- end
-
- @impl true
- def handle_event("next_page", _params, socket) do
- if socket.assigns.page < max_page(socket.assigns.active_total, socket.assigns.per_page) do
- {:noreply,
- push_patch(socket, to: patch_path(socket.assigns, page: socket.assigns.page + 1))}
- else
- {:noreply, socket}
- end
- end
-
@impl true
def handle_event("enqueue_issue", %{"id" => id_str}, socket) do
with {:ok, id} <- Parsers.parse_integer_exact(id_str),
@@ -331,36 +302,77 @@ defmodule ReencodarrWeb.BadFilesLive do
socket |> assign(:show_resolved, !socket.assigns.show_resolved) |> async_load_issues()}
end
- defp async_load_issues(socket) do
- load_assigns = %{
- page: socket.assigns.page,
- per_page: socket.assigns.per_page,
- status_filter: socket.assigns.status_filter,
- service_filter: socket.assigns.service_filter,
- kind_filter: socket.assigns.kind_filter,
- search_query: socket.assigns.search_query,
- show_resolved: socket.assigns.show_resolved
- }
+ defp async_load_issues(socket, opts \\ [])
+ defp async_load_issues(%{assigns: %{loaded_once: false}} = socket, _opts), do: socket
+
+ defp async_load_issues(socket, opts) do
+ if connected?(socket) do
+ load_assigns = issue_load_assigns(socket.assigns)
+ issue_summary = socket.assigns.issue_summary
- show_loading? = socket.assigns.issues == []
+ show_loading? = socket.assigns.issues == []
+ socket
+ |> assign(:loading_issues, show_loading?)
+ |> start_async(:load_issues, fn ->
+ fetch_issue_payload(
+ load_assigns,
+ Keyword.merge([include_summary: false, issue_summary: issue_summary], opts)
+ )
+ end)
+ else
+ socket
+ end
+ end
+
+ defp reload_issues_for_params(%{assigns: %{loaded_once: false}} = socket, _changed?) do
socket
- |> assign(:loading_issues, show_loading?)
- |> start_async(:load_issues, fn -> fetch_issue_payload(load_assigns) end)
+ |> apply_issue_payload(fetch_issue_payload(issue_load_assigns(socket.assigns)))
+ |> assign(:loaded_once, true)
end
- defp fetch_issue_payload(assigns) do
- BadFilesState.load(assigns)
+ defp reload_issues_for_params(socket, false), do: socket
+
+ defp reload_issues_for_params(socket, _changed?) do
+ socket
+ |> apply_issue_payload(
+ fetch_issue_payload(issue_load_assigns(socket.assigns),
+ include_summary: false,
+ issue_summary: socket.assigns.issue_summary
+ )
+ )
end
- defp apply_issue_payload(socket, issue_payload) do
- assign_changed(socket, Map.put(issue_payload, :loading_issues, false))
+ defp fetch_issue_payload(assigns, opts \\ []) do
+ payload = BadFilesState.load(assigns, opts)
+
+ {payload, page} =
+ payload
+ |> clamped_page_for(assigns)
+ |> maybe_reload_page(payload, assigns, opts)
+
+ payload
+ |> Map.put(:page, page)
+ |> Map.put(:request, assigns)
+ |> Map.put(:url_query, bad_files_url_query(%{assigns | page: page}))
end
- defp load_initial_snapshot(socket) do
+ defp apply_issue_payload(socket, %{request: request} = issue_payload) do
+ if issue_load_assigns(socket.assigns) == request do
+ issue_payload =
+ issue_payload
+ |> Map.delete(:request)
+ |> Map.put(:loading_issues, false)
+
+ assign(socket, issue_payload)
+ else
+ assign(socket, :loading_issues, false)
+ end
+ end
+
+ defp apply_issue_payload(socket, issue_payload) do
socket
- |> apply_issue_payload(fetch_issue_payload(socket.assigns))
- |> assign(:loaded_once, true)
+ |> assign(Map.put(issue_payload, :loading_issues, false))
end
defp issue_reason(issue) do
@@ -380,50 +392,29 @@ defmodule ReencodarrWeb.BadFilesLive do
defp normalize_search_query(_query), do: ""
defp parse_params(params) do
- %{
- status_filter:
- params
- |> Map.get("status", "all")
- |> then(&if(&1 in @status_filter_values, do: &1, else: "all")),
- service_filter:
- params
- |> Map.get("service", "all")
- |> then(&if(&1 in @service_filter_values, do: &1, else: "all")),
- kind_filter:
- params
- |> Map.get("kind", "all")
- |> then(&if(&1 in @kind_filter_values, do: &1, else: "all")),
- search_query: params |> Map.get("search", "") |> normalize_search_query(),
- page: params |> Map.get("page", "1") |> Parsers.parse_int(1) |> max(1),
- per_page:
- params
- |> Map.get("per_page", "#{@default_per_page}")
- |> Parsers.parse_int(@default_per_page)
- |> then(&if(&1 in @per_page_options, do: &1, else: @default_per_page))
- }
+ Map.merge(
+ %{
+ status_filter: valid_param(params, "status", @status_filter_values),
+ service_filter: valid_param(params, "service", @service_filter_values),
+ kind_filter: valid_param(params, "kind", @kind_filter_values),
+ search_query: params |> Map.get("search", "") |> normalize_search_query()
+ },
+ FlopList.pagination_assigns(params, @default_per_page, @per_page_options)
+ )
end
- defp patch_path(assigns, overrides) do
- overrides_map = Enum.into(overrides, %{}, fn {k, v} -> {to_string(k), v} end)
+ defp filters_changed?(assigns, filters) do
+ Enum.any?(@param_keys, fn key -> Map.get(assigns, key) != Map.get(filters, key) end)
+ end
+ defp patch_path(assigns, overrides) do
query =
- %{
- "status" => assigns.status_filter,
- "service" => assigns.service_filter,
- "kind" => assigns.kind_filter,
- "search" => assigns.search_query,
- "page" => assigns.page,
- "per_page" => assigns.per_page
- }
- |> Map.merge(overrides_map)
- |> Enum.reject(fn {_, v} -> is_nil(v) or v == "" or v == "all" end)
- |> Enum.map(fn {k, v} -> {k, to_string(v)} end)
- |> URI.encode_query()
-
- case query do
- "" -> "/bad-files"
- _ -> "/bad-files?#{query}"
- end
+ assigns
+ |> bad_files_url_query()
+ |> Map.merge(url_overrides(overrides))
+ |> drop_default_query_values()
+
+ FlopList.patch_with_page("/bad-files", query, page_override(assigns, overrides))
end
defp normalize_service("sonarr"), do: :sonarr
@@ -431,28 +422,12 @@ defmodule ReencodarrWeb.BadFilesLive do
defp normalize_service(_service), do: :all
defp filtered_active_issues(socket) do
- filters = [
- service: socket.assigns.service_filter,
- kind: socket.assigns.kind_filter,
- search: socket.assigns.search_query,
- statuses: active_statuses_for_filter(socket.assigns.status_filter)
- ]
-
- case Keyword.get(filters, :statuses) do
- [] -> []
- _statuses -> Media.list_bad_file_issues(filters)
- end
+ socket.assigns
+ |> issue_load_assigns()
+ |> BadFilesState.list_active_issues()
end
- defp active_statuses_for_filter(status_filter) do
- case status_filter do
- "all" -> @active_statuses
- "resolved" -> []
- other -> [String.to_existing_atom(other)]
- end
- rescue
- ArgumentError -> @active_statuses
- end
+ defp filtered_active_total(assigns), do: assigns.active_total || 0
defp start_service_replacements do
[:sonarr, :radarr]
@@ -460,25 +435,306 @@ defmodule ReencodarrWeb.BadFilesLive do
|> Enum.count(&match?({:ok, _issue}, &1))
end
- defp max_page(total, per_page), do: ListPagination.max_page(total, per_page)
+ defp bad_files_url_query(assigns) do
+ %{
+ "status" => assigns.status_filter,
+ "service" => assigns.service_filter,
+ "kind" => assigns.kind_filter,
+ "search" => assigns.search_query,
+ "per_page" => assigns.per_page
+ }
+ |> drop_default_query_values()
+ end
- defp pagination_label(page, per_page, total),
- do: ListPagination.pagination_label(page, per_page, total)
+ defp assign_url_query(socket) do
+ assign(socket, :url_query, bad_files_url_query(socket.assigns))
+ end
- defp filters_changed?(assigns, filters) do
- Enum.any?(filters, fn {key, value} -> Map.get(assigns, key) != value end)
+ defp issue_load_assigns(assigns) do
+ Map.take(assigns, [
+ :page,
+ :per_page,
+ :status_filter,
+ :service_filter,
+ :kind_filter,
+ :search_query,
+ :show_resolved
+ ])
+ end
+
+ defp valid_param(params, key, allowed) do
+ value = Map.get(params, key, "all")
+ if value in allowed, do: value, else: "all"
+ end
+
+ defp clamped_page_for(payload, assigns) do
+ per_page = payload.meta.page_size || assigns.per_page
+ total_pages = FlopList.total_pages(payload.active_total, per_page)
+
+ assigns.page
+ |> max(1)
+ |> min(total_pages)
+ end
+
+ defp maybe_reload_page(page, payload, assigns, opts) do
+ if page == assigns.page or payload.active_total == 0 do
+ {payload, page}
+ else
+ reloaded_payload = assigns |> Map.put(:page, page) |> BadFilesState.load(opts)
+ {reloaded_payload, page}
+ end
+ end
+
+ defp url_overrides(overrides) do
+ Map.new(overrides, fn {key, value} -> {to_string(key), value} end)
+ end
+
+ defp page_override(assigns, overrides) do
+ overrides
+ |> Keyword.get(:page, assigns.page)
+ |> Parsers.parse_int(assigns.page)
+ |> max(1)
end
- defp assign_changed(socket, attrs) do
- Enum.reduce(attrs, socket, fn {key, value}, acc ->
- if Map.get(acc.assigns, key) == value do
- acc
- else
- assign(acc, key, value)
- end
+ defp drop_default_query_values(query) do
+ Map.reject(query, fn
+ {"per_page", value} -> value in [@default_per_page, to_string(@default_per_page)]
+ {_key, value} -> value in [nil, "", "all"]
end)
end
+ attr :status_filter_values, :list, required: true
+ attr :service_filter_values, :list, required: true
+ attr :kind_filter_values, :list, required: true
+ attr :status_filter, :string, required: true
+ attr :service_filter, :string, required: true
+ attr :kind_filter, :string, required: true
+ attr :search_query, :string, required: true
+ attr :active_total, :integer, default: 0
+
+ defp bad_files_toolbar(assigns) do
+ ~H"""
+
+
+ Bulk actions apply to all {@active_total} matching active issues.
+
+
+ replace next queued
+
+
+ replace queued now
+
+
+ replace next sonarr
+
+
+ replace next radarr
+
+
+ queue all {@active_total} matching active issues
+
+
+ replace all {@active_total} matching active issues now
+
+
+
+
+
+
+ """
+ end
+
+ attr :issue_summary, :map, required: true
+
+ defp bad_files_summary(assigns) do
+ ~H"""
+
+
+ Open: {@issue_summary.open}
+
+
+ Queued: {@issue_summary.queued}
+
+
+ Processing: {@issue_summary.processing}
+
+
+ Waiting: {@issue_summary.waiting_for_replacement}
+
+
+ Failed: {@issue_summary.failed}
+
+
+ Resolved: {@issue_summary.resolved}
+
+
+ """
+ end
+
+ attr :replacement_issues, :list, required: true
+
+ defp active_replacements(assigns) do
+ ~H"""
+ <%= if @replacement_issues != [] do %>
+
+ Active Replacements
+
+ <%= for issue <- @replacement_issues do %>
+
+
{Path.basename(issue.video.path)}
+
+ {issue.video.service_type} • {issue.status}
+
+
{issue_reason(issue)}
+
+ <% end %>
+
+
+ <% end %>
+ """
+ end
+
+ attr :title, :string, required: true
+ attr :issues, :list, required: true
+ attr :meta, Flop.Meta, default: nil
+ attr :url_query, :map, default: %{}
+ attr :paginate?, :boolean, default: false
+
+ defp bad_files_issue_table(assigns) do
+ ~H"""
+
+ {@title}
+
+
+
+
+
+ File
+
+
+ Reason
+
+
+ Status
+
+
+ Actions
+
+
+
+ <.render_issue_rows issues={@issues} />
+
+
+ <.flop_pagination
+ :if={@paginate?}
+ id="bad-files-flop-pagination"
+ meta={@meta}
+ base_path="/bad-files"
+ query={@url_query}
+ mode={:simple}
+ />
+
+ """
+ end
+
+ attr :show_resolved, :boolean, required: true
+ attr :issues, :list, required: true
+
+ defp resolved_issues_section(assigns) do
+ ~H"""
+
+ Resolved Issues
+
+
Recent resolved issues are loaded on demand.
+
+ <%= if @show_resolved do %>
+ hide resolved
+ <% else %>
+ show resolved
+ <% end %>
+
+
+ <.bad_files_issue_table :if={@show_resolved} title="Resolved Issues" issues={@issues} />
+
+ """
+ end
+
+ attr :issues, :list, required: true
+
defp render_issue_rows(assigns) do
~H"""
@@ -569,231 +825,30 @@ defmodule ReencodarrWeb.BadFilesLive do
{@tracked_count} tracked
-
-
- Open: {@issue_summary.open}
-
-
- Queued: {@issue_summary.queued}
-
-
- Processing: {@issue_summary.processing}
-
-
- Waiting: {@issue_summary.waiting_for_replacement}
-
-
- Failed: {@issue_summary.failed}
-
-
- Resolved: {@issue_summary.resolved}
-
-
-
- <%= if @replacement_issues != [] do %>
-
- Active Replacements
-
- <%= for issue <- @replacement_issues do %>
-
-
{Path.basename(issue.video.path)}
-
- {issue.video.service_type} • {issue.status}
-
-
{issue_reason(issue)}
-
- <% end %>
-
-
- <% end %>
-
-
-
- replace next queued
-
-
- replace queued now
-
-
- replace next sonarr
-
-
- replace next radarr
-
-
- queue filtered
-
-
- replace filtered now
-
-
-
-
-
-
-
-
- Active Issues
-
-
-
-
-
- File
-
-
- Reason
-
-
- Status
-
-
- Actions
-
-
-
- <.render_issue_rows issues={@active_issues} />
-
-
-
-
{pagination_label(@page, @per_page, @active_total)}
-
-
-
- Previous
-
- {@page} / {max_page(@active_total, @per_page)}
- = max_page(@active_total, @per_page)}
- class="px-3 py-1 bg-gray-700 rounded text-gray-300 hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed"
- >
- Next
-
-
-
-
-
-
- Resolved Issues
-
-
- Recent resolved issues are loaded on demand.
-
-
- <%= if @show_resolved do %>
- hide resolved
- <% else %>
- show resolved
- <% end %>
-
-
- <%= if @show_resolved do %>
-
-
-
-
-
- File
-
-
- Reason
-
-
- Status
-
-
- Actions
-
-
-
- <.render_issue_rows issues={@resolved_issues} />
-
-
- <% end %>
-
+ <.bad_files_summary issue_summary={@issue_summary} />
+ <.active_replacements replacement_issues={@replacement_issues} />
+ <.bad_files_toolbar
+ status_filter_values={@status_filter_values}
+ service_filter_values={@service_filter_values}
+ kind_filter_values={@kind_filter_values}
+ status_filter={@status_filter}
+ service_filter={@service_filter}
+ kind_filter={@kind_filter}
+ search_query={@search_query}
+ active_total={filtered_active_total(assigns)}
+ />
+ <.bad_files_issue_table
+ title={if @status_filter == "resolved", do: "Resolved Issues", else: "Active Issues"}
+ issues={@active_issues}
+ meta={@meta}
+ url_query={@url_query}
+ paginate?
+ />
+ <.resolved_issues_section
+ :if={@status_filter != "resolved"}
+ show_resolved={@show_resolved}
+ issues={@resolved_issues}
+ />
"""
diff --git a/lib/reencodarr_web/live/failures_live.ex b/lib/reencodarr_web/live/failures_live.ex
index 9cc0f65b..583ab7e9 100644
--- a/lib/reencodarr_web/live/failures_live.ex
+++ b/lib/reencodarr_web/live/failures_live.ex
@@ -16,25 +16,26 @@ defmodule ReencodarrWeb.FailuresLive do
use ReencodarrWeb, :live_view
- import Ecto.Query
-
alias Reencodarr.Core.Parsers
alias Reencodarr.Dashboard.Events
alias Reencodarr.Media
- alias Reencodarr.Media.SharedQueries
- alias Reencodarr.Repo
+ alias ReencodarrWeb.Live.FlopList
@update_interval 30_000
+ @default_per_page 20
@stage_filter_values ["all", "analysis", "crf_search", "encoding", "post_process"]
@category_filter_values ["all", "file_access", "process_failure", "timeout", "codec_issues"]
+ @param_keys [:failure_filter, :category_filter, :search_term, :page, :per_page]
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> setup_failures_data()
+ |> assign(:meta, %Flop.Meta{})
+ |> assign_url_query()
+ |> assign(:loaded_once, false)
|> assign_placeholder_data()
- |> load_failures_data()
if connected?(socket) do
Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel())
@@ -44,6 +45,21 @@ defmodule ReencodarrWeb.FailuresLive do
{:ok, socket}
end
+ @impl true
+ def handle_params(params, _uri, socket) do
+ filters = parse_params(params)
+ filters_changed? = filters_changed?(socket.assigns, filters)
+
+ socket =
+ socket
+ |> assign(filters)
+ |> maybe_clear_selection(filters_changed?)
+ |> assign_url_query()
+ |> reload_failures_for_params(filters_changed?)
+
+ {:noreply, socket}
+ end
+
@impl true
def handle_info(:update_failures_data, socket) do
schedule_periodic_update()
@@ -97,15 +113,12 @@ defmodule ReencodarrWeb.FailuresLive do
@impl true
def handle_event("reset_all_failures", _params, socket) do
- # Reset all failed videos
- Media.reset_failed_videos()
+ Media.reset_all_failures()
- # Reload the failures data, returning to page 1 (avoid stale page state)
{:noreply,
socket
- |> assign(:page, 1)
- |> put_flash(:info, "All failed videos have been reset")
- |> async_load_failures()}
+ |> push_patch(to: patch_path(socket.assigns, page: 1))
+ |> put_flash(:info, "All failures have been reset")}
end
@impl true
@@ -187,66 +200,29 @@ defmodule ReencodarrWeb.FailuresLive do
def handle_event("filter_failures", %{"filter" => filter}, socket) do
normalized_filter = if filter in @stage_filter_values, do: filter, else: "all"
- socket =
- socket
- |> assign(:failure_filter, normalized_filter)
- # Reset to first page when filtering
- |> assign(:page, 1)
- |> async_load_failures()
-
- {:noreply, socket}
+ {:noreply,
+ push_patch(socket, to: patch_path(socket.assigns, stage: normalized_filter, page: 1))}
end
@impl true
def handle_event("filter_category", %{"category" => category}, socket) do
normalized_category = if category in @category_filter_values, do: category, else: "all"
- socket =
- socket
- |> assign(:category_filter, normalized_category)
- # Reset to first page when filtering
- |> assign(:page, 1)
- |> async_load_failures()
-
- {:noreply, socket}
+ {:noreply,
+ push_patch(socket, to: patch_path(socket.assigns, category: normalized_category, page: 1))}
end
@impl true
def handle_event("clear_filters", _params, socket) do
- socket =
- socket
- |> assign(:failure_filter, "all")
- |> assign(:category_filter, "all")
- |> assign(:search_term, "")
- |> assign(:page, 1)
- |> async_load_failures()
-
- {:noreply, socket}
- end
-
- @impl true
- def handle_event("change_page", %{"page" => page}, socket) do
- total_pages = max(1, socket.assigns.total_pages)
- page = page |> Parsers.parse_int(1) |> max(1) |> min(total_pages)
-
- socket =
- socket
- |> assign(:page, page)
- |> async_load_failures()
-
- {:noreply, socket}
+ {:noreply, push_patch(socket, to: "/failures")}
end
@impl true
def handle_event("search", %{"search" => search_term}, socket) do
- socket =
- socket
- |> assign(:search_term, normalize_search_term(search_term))
- # Reset to first page when searching
- |> assign(:page, 1)
- |> async_load_failures()
-
- {:noreply, socket}
+ {:noreply,
+ push_patch(socket,
+ to: patch_path(socket.assigns, search: normalize_search_term(search_term), page: 1)
+ )}
end
@impl true
@@ -278,7 +254,6 @@ defmodule ReencodarrWeb.FailuresLive do
~H"""
-
@@ -304,7 +279,6 @@ defmodule ReencodarrWeb.FailuresLive do
-
<%= if @loading do %>
@@ -312,471 +286,411 @@ defmodule ReencodarrWeb.FailuresLive do
Loading failure data...
<% else %>
-
-
+ """
+ end
+
+ # Private Helper Functions
+
+ attr :search_term, :string, required: true
+ attr :failure_filter, :string, required: true
+ attr :category_filter, :string, required: true
+
+ defp failure_filter_bar(assigns) do
+ assigns =
+ assign(assigns,
+ stage_options: [
+ {"all", "All", "bg-purple-600 text-white"},
+ {"analysis", "Analysis", "bg-purple-600 text-white"},
+ {"crf_search", "CRF", "bg-blue-600 text-white"},
+ {"encoding", "Encoding", "bg-amber-600 text-white"},
+ {"post_process", "Post", "bg-red-600 text-white"}
+ ],
+ category_options: [
+ {"all", "All"},
+ {"process_failure", "Process"},
+ {"timeout", "Timeout"},
+ {"codec_issues", "Codec"},
+ {"file_access", "File"}
+ ]
+ )
+
+ ~H"""
+
+
+
+
+
+
+
Stage:
+
+ <%= for {value, label, active_class} <- @stage_options do %>
+
+ {label}
+
+ <% end %>
+
+
+
+
+
Type:
+
+ <%= for {value, label} <- @category_options do %>
+
+ {label}
+
+ <% end %>
+
+
+
+
+
+ """
+ end
+
+ defp failure_filter_button_class(true, active_class),
+ do: "px-2 py-1 text-xs rounded transition-colors #{active_class}"
+
+ defp failure_filter_button_class(false, _active_class),
+ do: "px-2 py-1 text-xs rounded transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600"
+
+ attr :actions, :list, required: true
+
+ defp retry_failure_code_panel(assigns) do
+ ~H"""
+ <%= if @actions != [] do %>
+
+
+
+
Retry By Error Code
+
+ Retry all failed videos whose unresolved failures include the selected code by sending them back to analysis.
+
+
+
+ <%= for action <- @actions do %>
+
+ {action.code}
+
+ {action.count}
+
+
+ <% end %>
+
+
+
+ <% end %>
+ """
+ end
+
+ attr :failed_videos, :list, required: true
+ attr :video_failures, :map, required: true
+ attr :selected_videos, MapSet, required: true
+ attr :expanded_details, :list, required: true
+ attr :search_term, :string, required: true
+ attr :meta, Flop.Meta, required: true
+ attr :url_query, :map, required: true
+
+ defp failure_table(assigns) do
+ ~H"""
+
+ <%= if @failed_videos == [] do %>
+
+
✅
+
No Failures Found
+
+ <%= if @search_term != "" do %>
+ No failed videos match your search criteria
+ <% else %>
+ All videos are processing successfully
+ <% end %>
+
+
+ <% else %>
+
+
+
+ <%= if MapSet.size(@selected_videos) == length(@failed_videos) and length(@failed_videos) > 0 do %>
-
-
-
-
-
-
-
Stage:
-
-
- All
-
-
- Analysis
-
-
- CRF
-
-
- Encoding
-
-
- Post
-
-
+ <% else %>
+
+ <% end %>
+
+
Video
+
Size
+
Error
+
When
+
+
+
+ <%= for video <- @failed_videos do %>
+ <% latest_failure = latest_failure(@video_failures, video.id) %>
+
+
+
+
+
-
-
-
Type:
-
-
- All
-
-
- Process
-
-
- Timeout
-
-
- Codec
-
-
- File
-
+
+
+ {Path.basename(video.path)}
+
+
+ <%= if video.service_type do %>
+ {video.service_type}
+ ·
+ <% end %>
+ <%= if video.width && video.height do %>
+ {Reencodarr.Formatters.resolution(video.width, video.height)}
+ ·
+ <% end %>
+ <%= if video.video_codecs && length(video.video_codecs) > 0 do %>
+ {format_codecs(video.video_codecs)}
+ <% end %>
+ <%= if video.hdr do %>
+ ·
+ DV
+ <% end %>
-
-
-
- <%= if @failure_code_actions != [] do %>
-
-
-
-
Retry By Error Code
-
- Retry all failed videos whose unresolved failures include the selected code by sending them back to analysis.
-
-
-
- <%= for action <- @failure_code_actions do %>
-
- {action.code}
-
- {action.count}
-
-
+
+ <%= if video.size do %>
+ {Reencodarr.Formatters.file_size(video.size)}
+ <% else %>
+ —
<% end %>
-
-
- <% end %>
-
-
- <%= if @failed_videos == [] do %>
-
-
✅
-
No Failures Found
-
- <%= if @search_term != "" do %>
- No failed videos match your search criteria
+
+ <.failure_summary failure={latest_failure} />
+
+
+
+ <%= if latest_failure do %>
+ {compact_relative_time(latest_failure.inserted_at)}
<% else %>
- All videos are processing successfully
+ —
<% end %>
-
-
- <% else %>
-
-
-
-
-
- <%= if MapSet.size(@selected_videos) == length(@failed_videos) and length(@failed_videos) > 0 do %>
-
- <% else %>
-
- <% end %>
-
-
Video
-
Size
-
Error
-
When
-
-
- <%= for video <- @failed_videos do %>
- <% latest_failure =
- Map.get(@video_failures, video.id)
- |> then(fn
- failures when is_list(failures) and failures != [] -> List.first(failures)
- _ -> nil
- end) %>
-
-
-
-
-
-
-
-
-
-
-
-
- {Path.basename(video.path)}
-
-
- <%= if video.service_type do %>
- {video.service_type}
- ·
- <% end %>
- <%= if video.width && video.height do %>
- {Reencodarr.Formatters.resolution(video.width, video.height)}
- ·
- <% end %>
- <%= if video.video_codecs && length(video.video_codecs) > 0 do %>
- {format_codecs(video.video_codecs)}
- <% end %>
- <%= if video.hdr do %>
- ·
- DV
- <% end %>
-
-
-
-
-
- <%= if video.size do %>
- {Reencodarr.Formatters.file_size(video.size)}
- <% else %>
- —
- <% end %>
-
-
-
-
- <%= if latest_failure do %>
-
-
-
-
-
- {latest_failure.failure_stage}
-
-
- <%= if latest_failure.failure_code do %>
- {latest_failure.failure_code}
- <% else %>
- {latest_failure.failure_category}
- <% end %>
-
-
- {String.slice(latest_failure.failure_message || "", 0, 40)}{if String.length(
- latest_failure.failure_message ||
- ""
- ) > 40,
- do:
- "..."}
-
-
-
- <% else %>
-
No failure info
- <% end %>
-
-
-
-
- <%= if latest_failure do %>
- {compact_relative_time(latest_failure.inserted_at)}
- <% else %>
- —
- <% end %>
-
-
-
-
-
- Retry
-
-
-
-
-
- <%= if video.id in @expanded_details do %>
-
- <%= case Map.get(@video_failures, video.id) do %>
- <% failures when is_list(failures) and failures != [] -> %>
- <% latest = List.first(failures) %>
-
-
- <%= if Map.get(latest.system_context || %{}, "command") do %>
-
-
Command
-
- $ {Map.get(latest.system_context, "command")}
-
-
- <% end %>
-
-
- <%= if has_command_details?(latest.system_context) do %>
-
-
Output
-
-
{format_command_output(
- Map.get(latest.system_context, "full_output")
- )}
-
-
- <% end %>
-
-
- <%= if length(failures) > 1 do %>
-
-
- History ({length(failures)} failures)
-
-
- <%= for failure <- failures do %>
-
- {failure.failure_stage}/{failure.failure_code ||
- failure.failure_category} ({compact_relative_time(
- failure.inserted_at
- )})
-
- <% end %>
-
-
- <% end %>
- <% _ -> %>
-
- No detailed failure information available
-
- <% end %>
-
- <% end %>
-
- <% end %>
+
+
+ Retry
+
+
-
- <%= if @total_pages > 1 do %>
-
-
-
- Page {@page}
- of {@total_pages}
-
-
-
- <%= if @page > 1 do %>
-
- ««
-
-
- ‹
-
- <% end %>
-
- <%= for page_num <- pagination_range(@page, @total_pages) do %>
-
- {page_num}
-
- <% end %>
-
- <%= if @page < @total_pages do %>
-
- ›
-
-
- »»
-
- <% end %>
-
-
-
+ <%= if video.id in @expanded_details do %>
+ <.failure_details failures={Map.get(@video_failures, video.id)} />
<% end %>
+
+ <% end %>
+
+
+ <.flop_pagination
+ id="failures-flop-pagination"
+ meta={@meta}
+ base_path="/failures"
+ query={@url_query}
+ mode={:full}
+ page_links={5}
+ class="p-4 border-t border-gray-700"
+ />
+ <% end %>
+
+ """
+ end
+
+ attr :failure, :any, required: true
+
+ defp failure_summary(assigns) do
+ ~H"""
+ <%= if @failure do %>
+
+
+
+
+
{@failure.failure_stage}
+
+ <%= if @failure.failure_code do %>
+ {@failure.failure_code}
+ <% else %>
+ {@failure.failure_category}
<% end %>
+
+ {truncate_failure_message(@failure.failure_message)}
+
+
+
+ <% else %>
+
No failure info
+ <% end %>
+ """
+ end
-
- <%= if length(@failure_patterns) > 0 do %>
-
-
Common Patterns
-
- <%= for pattern <- @failure_patterns do %>
-
-
-
-
-
-
- {pattern.stage}/{pattern.category}
-
- <%= if pattern.code do %>
- {pattern.code}
- <% end %>
-
-
-
-
{pattern.count}
-
occurrences
-
-
+ attr :failures, :any, required: true
+
+ defp failure_details(assigns) do
+ ~H"""
+
+ <%= case @failures do %>
+ <% failures when is_list(failures) and failures != [] -> %>
+ <% latest = List.first(failures) %>
+
+ <%= if Map.get(latest.system_context || %{}, "command") do %>
+
+
Command
+
+ $ {Map.get(latest.system_context, "command")}
+
+
+ <% end %>
+
+ <%= if has_command_details?(latest.system_context) do %>
+
+
Output
+
+
{format_command_output(
+ Map.get(latest.system_context, "full_output")
+ )}
+
+
+ <% end %>
+
+ <%= if length(failures) > 1 do %>
+
+
+ History ({length(failures)} failures)
+
+
+ <%= for failure <- failures do %>
+
+ {failure.failure_stage}/{failure.failure_code || failure.failure_category} ({compact_relative_time(
+ failure.inserted_at
+ )})
+
<% end %>
<% end %>
- <% end %>
-
+ <% _ -> %>
+
No detailed failure information available
+ <% end %>
"""
end
- # Private Helper Functions
+ attr :patterns, :list, required: true
+
+ defp common_failure_patterns(assigns) do
+ ~H"""
+ <%= if @patterns != [] do %>
+
+
Common Patterns
+
+ <%= for pattern <- @patterns do %>
+
+
+
+
+
+ {pattern.stage}/{pattern.category}
+
+ <%= if pattern.code do %>
+ {pattern.code}
+ <% end %>
+
+
+
+
{pattern.count}
+
occurrences
+
+
+ <% end %>
+
+
+ <% end %>
+ """
+ end
defp setup_failures_data(socket) do
socket
@@ -801,140 +715,152 @@ defmodule ReencodarrWeb.FailuresLive do
|> assign(:total_pages, 0)
end
- defp load_failures_data(socket) do
- assign_failure_payload(socket, fetch_failure_payload(socket.assigns))
- end
-
defp async_load_failures(socket) do
- load_assigns = %{
- page: socket.assigns.page,
- per_page: socket.assigns.per_page,
- failure_filter: socket.assigns.failure_filter,
- category_filter: socket.assigns.category_filter,
- search_term: socket.assigns.search_term
- }
+ load_assigns = flop_list_assigns(socket.assigns)
show_loading? = socket.assigns.failed_videos == []
socket
|> assign(:loading, show_loading?)
- |> start_async(:load_failures, fn -> fetch_failure_payload(load_assigns) end)
+ |> start_async(:load_failures, fn ->
+ fetch_failure_payload(load_assigns, include_support: false)
+ end)
end
- defp fetch_failure_payload(assigns) do
- # Get pagination info
- page = assigns.page
- per_page = assigns.per_page
- filter = assigns.failure_filter
- category_filter = assigns.category_filter
- search_term = assigns.search_term
+ defp reload_failures_for_params(%{assigns: %{loaded_once: false}} = socket, _changed?),
+ do:
+ socket
+ |> assign_failure_payload(fetch_failure_payload(flop_list_assigns(socket.assigns)))
+ |> assign(:loaded_once, true)
- # Get failed videos with pagination and filtering
- {failed_videos, total_count} =
- get_failed_videos_paginated(page, per_page, filter, category_filter, search_term)
+ defp reload_failures_for_params(socket, false), do: socket
- # Get failure details for current page videos
- video_failures = get_failures_by_video(failed_videos)
+ defp reload_failures_for_params(socket, _changed?) do
+ socket
+ |> assign_failure_payload(
+ fetch_failure_payload(flop_list_assigns(socket.assigns), include_support: false)
+ )
+ end
- # Get failure statistics and patterns
- failure_stats = Media.get_failure_statistics(days_back: 7)
- failure_patterns = Media.get_common_failure_patterns(5)
- failure_code_actions = Media.list_failed_video_failure_codes()
+ defp fetch_failure_payload(assigns, opts \\ []) do
+ payload = Media.load_failures_page(flop_params(assigns), opts)
- # Calculate pagination info
- total_pages = ceil(total_count / per_page)
+ payload
+ |> Map.put(:request, assigns)
+ |> Map.put(:url_query, failures_url_query(%{assigns | page: payload.page}))
+ end
- %{
- loading: false,
- failed_videos: failed_videos,
- video_failures: video_failures,
- failure_stats: summarize_failure_stats(failure_stats),
- failure_patterns: failure_patterns,
- failure_code_actions: failure_code_actions,
- total_count: total_count,
- total_pages: total_pages
- }
+ defp assign_failure_payload(socket, %{request: request} = payload) do
+ if flop_list_assigns(socket.assigns) == request do
+ payload = Map.delete(payload, :request)
+ assign(socket, payload)
+ else
+ assign(socket, :loading, false)
+ end
end
defp assign_failure_payload(socket, payload) do
- assign_changed(socket, payload)
+ assign(socket, payload)
end
- defp get_failed_videos_paginated(page, per_page, stage_filter, category_filter, search_term) do
- import Ecto.Query
+ defp maybe_clear_selection(socket, true), do: assign(socket, :selected_videos, MapSet.new())
+ defp maybe_clear_selection(socket, false), do: socket
- base_query = from(v in Reencodarr.Media.Video, where: v.state == :failed)
-
- base_query
- |> apply_failure_filters(stage_filter, category_filter)
- |> apply_search_filter(search_term)
- |> apply_ordering()
- |> get_paginated_results(page, per_page)
+ defp filters_changed?(assigns, filters) do
+ Enum.any?(@param_keys, fn key -> Map.get(assigns, key) != Map.get(filters, key) end)
end
- defp apply_failure_filters(base_query, stage_filter, category_filter) do
- if stage_filter != "all" or category_filter != "all" do
- query =
- from v in base_query,
- join: f in Reencodarr.Media.VideoFailure,
- on: f.video_id == v.id,
- where: f.resolved == false,
- distinct: true
-
- query
- |> apply_stage_filter(stage_filter)
- |> apply_category_filter(category_filter)
- else
- base_query
+ defp latest_failure(video_failures, video_id) do
+ case Map.get(video_failures, video_id) do
+ [failure | _rest] -> failure
+ _ -> nil
end
end
- defp apply_stage_filter(query, "all"), do: query
-
- defp apply_stage_filter(query, stage_filter) do
- case parse_stage_filter(stage_filter) do
- {:ok, stage_atom} ->
- from [v, f] in query, where: f.failure_stage == ^stage_atom
+ defp truncate_failure_message(message) do
+ message = message || ""
+ suffix = if String.length(message) > 40, do: "...", else: ""
+ String.slice(message, 0, 40) <> suffix
+ end
- {:error, _reason} ->
- # Invalid stage filter, return no results
- from [v, f] in query, where: false
- end
+ defp flop_list_assigns(assigns) do
+ %{
+ page: assigns.page,
+ per_page: assigns.per_page,
+ failure_filter: assigns.failure_filter,
+ category_filter: assigns.category_filter,
+ search_term: assigns.search_term
+ }
end
- defp apply_category_filter(query, "all"), do: query
+ defp flop_params(assigns) do
+ %{
+ "page" => to_string(assigns.page),
+ "page_size" => to_string(assigns.per_page),
+ "stage" => assigns.failure_filter,
+ "category" => assigns.category_filter,
+ "search" => assigns.search_term
+ }
+ end
- defp apply_category_filter(query, category_filter) do
- case parse_category_filter(category_filter) do
- {:ok, category_atom} ->
- from [v, f] in query, where: f.failure_category == ^category_atom
+ defp parse_params(params) do
+ Map.merge(
+ %{
+ failure_filter:
+ params
+ |> Map.get("stage", "all")
+ |> then(&if(&1 in @stage_filter_values, do: &1, else: "all")),
+ category_filter:
+ params
+ |> Map.get("category", "all")
+ |> then(&if(&1 in @category_filter_values, do: &1, else: "all")),
+ search_term: params |> Map.get("search", "") |> normalize_search_term()
+ },
+ FlopList.pagination_assigns(params, @default_per_page, [@default_per_page])
+ )
+ end
- {:error, _reason} ->
- # Invalid category filter, return no results
- from [v, f] in query, where: false
- end
+ defp failures_url_query(assigns) do
+ %{
+ "stage" => assigns.failure_filter,
+ "category" => assigns.category_filter,
+ "search" => assigns.search_term,
+ "per_page" => to_string(assigns.per_page)
+ }
+ |> Enum.reject(fn
+ {"stage", "all"} -> true
+ {"category", "all"} -> true
+ {"per_page", value} -> value in [@default_per_page, to_string(@default_per_page)]
+ {_, value} -> value in [nil, ""]
+ end)
+ |> Map.new()
end
- defp parse_stage_filter("analysis"), do: {:ok, :analysis}
- defp parse_stage_filter("crf_search"), do: {:ok, :crf_search}
- defp parse_stage_filter("encoding"), do: {:ok, :encoding}
- defp parse_stage_filter("post_process"), do: {:ok, :post_process}
- defp parse_stage_filter(invalid), do: {:error, "Invalid stage filter: #{inspect(invalid)}"}
+ defp assign_url_query(socket) do
+ assign(socket, :url_query, failures_url_query(socket.assigns))
+ end
- defp parse_category_filter("file_access"), do: {:ok, :file_access}
- defp parse_category_filter("process_failure"), do: {:ok, :process_failure}
- defp parse_category_filter("timeout"), do: {:ok, :timeout}
- defp parse_category_filter("codec_issues"), do: {:ok, :codec_issues}
+ defp patch_path(assigns, overrides) do
+ overrides_map = Enum.into(overrides, %{}, fn {key, value} -> {to_string(key), value} end)
- defp parse_category_filter(invalid),
- do: {:error, "Invalid category filter: #{inspect(invalid)}"}
+ query =
+ failures_url_query(assigns)
+ |> Map.merge(overrides_map)
+ |> Enum.reject(fn
+ {"stage", "all"} -> true
+ {"category", "all"} -> true
+ {"per_page", value} -> value in [@default_per_page, to_string(@default_per_page)]
+ {_, value} -> value in [nil, ""]
+ end)
+ |> Map.new()
- defp apply_search_filter(query, ""), do: query
+ page =
+ overrides
+ |> Keyword.get(:page, assigns.page)
+ |> Parsers.parse_int(assigns.page)
+ |> max(1)
- defp apply_search_filter(query, search_term) do
- search_pattern = "%#{search_term}%"
- case_insensitive_like_condition = SharedQueries.case_insensitive_like(:path, search_pattern)
- from v in query, where: ^case_insensitive_like_condition
+ FlopList.patch_with_page("/failures", query, page)
end
defp normalize_search_term(search_term) when is_binary(search_term),
@@ -942,69 +868,12 @@ defmodule ReencodarrWeb.FailuresLive do
defp normalize_search_term(_search_term), do: ""
- defp apply_ordering(query) do
- from v in query, order_by: [desc: v.inserted_at]
- end
-
- defp get_paginated_results(query, page, per_page) do
- total_count = get_total_count(query)
- offset = (page - 1) * per_page
- videos = Repo.all(from v in query, limit: ^per_page, offset: ^offset)
- {videos, total_count}
- end
-
- defp get_total_count(query) do
- query
- |> exclude(:order_by)
- |> exclude(:select)
- |> exclude(:distinct)
- |> select([v], count(fragment("DISTINCT ?", v.id)))
- |> Repo.one()
- end
-
defp schedule_periodic_update do
Process.send_after(self(), :update_failures_data, @update_interval)
end
- defp assign_changed(socket, attrs) do
- Enum.reduce(attrs, socket, fn {key, value}, acc ->
- if Map.get(acc.assigns, key) == value do
- acc
- else
- assign(acc, key, value)
- end
- end)
- end
-
- defp get_failures_by_video(videos) do
- import Ecto.Query
- video_ids = Enum.map(videos, & &1.id)
-
- from(f in Reencodarr.Media.VideoFailure,
- where: f.video_id in ^video_ids and f.resolved == false,
- order_by: [desc: f.inserted_at]
- )
- |> Reencodarr.Repo.all()
- |> Enum.group_by(& &1.video_id)
- end
-
- defp summarize_failure_stats(stats) do
- recent_count =
- Enum.reduce(stats, 0, fn stat, acc ->
- acc + (stat.count || 0)
- end)
-
- %{recent_count: recent_count}
- end
-
defp format_codecs(codecs), do: Reencodarr.Formatters.codec_list(codecs)
- defp pagination_range(current_page, total_pages) do
- start_page = max(1, current_page - 2)
- end_page = min(total_pages, current_page + 2)
- Enum.to_list(start_page..end_page)
- end
-
defp format_command_output(output) when is_binary(output) and output != "" do
# Clean up common command output formatting issues
output
diff --git a/lib/reencodarr_web/live/flop_list.ex b/lib/reencodarr_web/live/flop_list.ex
new file mode 100644
index 00000000..96744d0d
--- /dev/null
+++ b/lib/reencodarr_web/live/flop_list.ex
@@ -0,0 +1,228 @@
+defmodule ReencodarrWeb.Live.FlopList do
+ @moduledoc """
+ Shared LiveView helpers for Flop-backed list pagination.
+
+ The list LiveViews keep their filters in URL params and pass Flop metadata to
+ this module for consistent labels, page links, and page parsing.
+ """
+
+ use Phoenix.Component
+
+ import Flop.Phoenix
+
+ alias Reencodarr.Core.Parsers
+
+ attr :id, :string, default: "flop-pagination"
+ attr :meta, Flop.Meta, required: true
+ attr :path, :any, default: nil
+ attr :base_path, :string, default: nil
+ attr :query, :map, default: %{}
+ attr :mode, :atom, default: :simple, values: [:simple, :full]
+ attr :page_links, :integer, default: 5
+ attr :target, :string, default: nil
+ attr :class, :string, default: nil
+
+ @doc """
+ Renders pagination controls for a `%Flop.Meta{}` result.
+
+ Pass either a Flop `:path` callback or a `:base_path` plus current `:query`.
+ The `:simple` mode renders previous/next links only; `:full` also renders page
+ number links.
+ """
+ def flop_pagination(assigns) do
+ path = pagination_path(assigns)
+ page_links = if assigns.mode == :simple, do: :none, else: assigns.page_links
+
+ assigns =
+ assign(assigns,
+ path: path,
+ page_links: page_links,
+ label: pagination_label(assigns.meta),
+ nav_id: "#{assigns.id}-nav"
+ )
+
+ ~H"""
+
+ {@label}
+ <.pagination
+ meta={@meta}
+ path={@path}
+ target={@target}
+ page_links={@page_links}
+ page_link_attrs={page_link_attrs()}
+ current_page_link_attrs={current_page_link_attrs()}
+ disabled_link_attrs={disabled_link_attrs()}
+ id={@nav_id}
+ data-role="flop-pagination-nav"
+ >
+ <:previous attrs={nav_button_attrs("flop-pagination-prev")}>Previous
+ <:next attrs={nav_button_attrs("flop-pagination-next")}>Next
+
+
+ """
+ end
+
+ @doc """
+ Builds the compact result range label shown beside pagination controls.
+ """
+ @spec pagination_label(Flop.Meta.t()) :: String.t()
+ def pagination_label(%Flop.Meta{} = meta) do
+ per_page = meta.page_size || 1
+ total = meta.total_count || 0
+ page = meta.current_page || 1
+
+ page =
+ if total > 0 do
+ page |> max(1) |> min(total_pages(total, per_page))
+ else
+ max(page, 1)
+ end
+
+ pagination_range_label(page, per_page, total)
+ end
+
+ @doc """
+ Returns the number of pages needed for a result count and page size.
+
+ Empty result sets still return `1` so URL parsing and LiveView assigns have a
+ stable page value.
+ """
+ @spec total_pages(non_neg_integer(), pos_integer()) :: pos_integer()
+ def total_pages(total, _per_page) when total <= 0, do: 1
+ def total_pages(total, per_page), do: max(ceil(total / per_page), 1)
+
+ @doc """
+ Parses a positive page number from request params.
+
+ When `:total` and `:per_page` are provided, the page is clamped to the
+ available range.
+ """
+ @spec parse_page(map(), pos_integer(), keyword()) :: pos_integer()
+ def parse_page(params, default, opts \\ []) do
+ page = params |> Map.get("page", "#{default}") |> Parsers.parse_int(default) |> max(1)
+
+ with total when is_integer(total) <- Keyword.get(opts, :total),
+ per_page when is_integer(per_page) <- Keyword.get(opts, :per_page) do
+ page |> max(1) |> min(total_pages(total, per_page))
+ else
+ _ -> page
+ end
+ end
+
+ @doc """
+ Parses a page size from request params and falls back unless it is allowed.
+ """
+ @spec parse_per_page(map(), pos_integer(), [pos_integer()]) :: pos_integer()
+ def parse_per_page(params, default, allowed) do
+ params
+ |> Map.get("per_page", "#{default}")
+ |> Parsers.parse_int(default)
+ |> then(&if(&1 in allowed, do: &1, else: default))
+ end
+
+ @doc """
+ Returns normalized pagination assigns from request params.
+ """
+ @spec pagination_assigns(map(), pos_integer(), [pos_integer()], pos_integer()) :: %{
+ page: pos_integer(),
+ per_page: pos_integer()
+ }
+ def pagination_assigns(params, default_per_page, allowed_per_pages, default_page \\ 1) do
+ %{
+ page: parse_page(params, default_page),
+ per_page: parse_per_page(params, default_per_page, allowed_per_pages)
+ }
+ end
+
+ defp pagination_range_label(page, per_page, total) when total > 0 do
+ first = (page - 1) * per_page + 1
+ last = min(page * per_page, total)
+ "#{first}-#{last} of #{total}"
+ end
+
+ defp pagination_range_label(_, _, _), do: "0 results"
+
+ @doc """
+ Flop path callback that preserves the current list query while changing page.
+ """
+ @spec flop_page_path(String.t(), map(), keyword()) :: String.t()
+ def flop_page_path(base_path, query, flop_params) do
+ page = Keyword.get(flop_params, :page, 1)
+ patch_with_page(base_path, query, page)
+ end
+
+ @doc """
+ Returns a LiveView patch path with `page` merged into existing query params.
+
+ Page `1` is omitted from the URL so the canonical first page stays clean.
+ """
+ @spec patch_with_page(String.t(), map(), pos_integer()) :: String.t()
+ def patch_with_page(base_path, query, page) when is_binary(base_path) and is_map(query) do
+ query
+ |> stringify_query()
+ |> maybe_put_page(page)
+ |> drop_empty()
+ |> case do
+ %{} = params when map_size(params) == 0 -> base_path
+ params -> base_path <> "?" <> URI.encode_query(params)
+ end
+ end
+
+ defp pagination_path(%{base_path: base_path, path: nil, query: query})
+ when is_binary(base_path) do
+ {__MODULE__, :flop_page_path, [base_path, query]}
+ end
+
+ defp pagination_path(%{path: path}) when not is_nil(path), do: path
+
+ defp pagination_path(_assigns) do
+ raise ArgumentError, "flop_pagination requires :path or :base_path"
+ end
+
+ defp stringify_query(query) do
+ Map.new(query, fn {key, value} -> {to_string(key), value} end)
+ end
+
+ defp maybe_put_page(query, 1), do: Map.delete(query, "page")
+ defp maybe_put_page(query, page), do: Map.put(query, "page", to_string(page))
+
+ defp drop_empty(query) do
+ Map.reject(query, fn {_key, value} -> value in [nil, ""] end)
+ end
+
+ defp nav_button_attrs(role) do
+ [
+ class: nav_button_classes(),
+ "data-role": role
+ ]
+ end
+
+ defp nav_button_classes,
+ do:
+ "px-3 py-1 bg-gray-700 rounded text-gray-300 hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed"
+
+ defp page_link_attrs,
+ do: [
+ class: page_link_classes(),
+ "data-role": "flop-pagination-page"
+ ]
+
+ defp current_page_link_attrs,
+ do: [
+ class: current_page_link_classes(),
+ "data-role": "flop-pagination-page"
+ ]
+
+ defp disabled_link_attrs, do: [class: "opacity-40 cursor-not-allowed"]
+
+ defp page_link_classes,
+ do:
+ "px-3 py-1.5 text-sm font-medium rounded text-gray-300 bg-gray-700 border border-gray-600 hover:bg-gray-600 transition-colors"
+
+ defp current_page_link_classes,
+ do: "px-3 py-1.5 text-sm font-medium rounded text-white bg-blue-600 border border-blue-600"
+end
diff --git a/lib/reencodarr_web/live/list_pagination.ex b/lib/reencodarr_web/live/list_pagination.ex
deleted file mode 100644
index 1cb046f3..00000000
--- a/lib/reencodarr_web/live/list_pagination.ex
+++ /dev/null
@@ -1,15 +0,0 @@
-defmodule ReencodarrWeb.Live.ListPagination do
- @moduledoc false
-
- @spec max_page(non_neg_integer(), pos_integer()) :: pos_integer()
- def max_page(total, per_page), do: max(ceil(total / per_page), 1)
-
- @spec pagination_label(pos_integer(), pos_integer(), non_neg_integer()) :: String.t()
- def pagination_label(page, per_page, total) when total > 0 do
- first = (page - 1) * per_page + 1
- last = min(page * per_page, total)
- "#{first}-#{last} of #{total}"
- end
-
- def pagination_label(_, _, _), do: "0 results"
-end
diff --git a/lib/reencodarr_web/live/videos_live.ex b/lib/reencodarr_web/live/videos_live.ex
index d6c1fe77..f19da5df 100644
--- a/lib/reencodarr_web/live/videos_live.ex
+++ b/lib/reencodarr_web/live/videos_live.ex
@@ -23,14 +23,14 @@ defmodule ReencodarrWeb.VideosLive do
alias Reencodarr.Dashboard.Events
alias Reencodarr.Media
alias Reencodarr.Videos.State, as: VideosState
- alias ReencodarrWeb.Live.ListPagination
+ alias ReencodarrWeb.Live.FlopList
@per_page_options [25, 50, 100, 250]
@default_per_page 50
@update_interval 30_000
@queueable_states [:needs_analysis, :analyzed, :crf_searched]
- @valid_states ~w(needs_analysis analyzed crf_searching crf_searched encoding encoded failed)
+ @valid_states ~w(needs_analysis analyzing analyzed crf_searching crf_searched encoding encoded failed)
@valid_service_types ~w(sonarr radarr)
@valid_sort_fields ~w(path state size width bitrate updated_at)
@valid_sort_dirs ~w(asc desc)
@@ -40,13 +40,11 @@ defmodule ReencodarrWeb.VideosLive do
# ---------------------------------------------------------------------------
@impl true
- def mount(params, _session, socket) do
- filters = parse_params(params)
-
+ def mount(_params, _session, socket) do
socket =
- socket
- |> assign(
+ assign(socket,
videos: [],
+ meta: %Flop.Meta{},
total: 0,
state_counts: %{},
selected: MapSet.new(),
@@ -54,10 +52,16 @@ defmodule ReencodarrWeb.VideosLive do
loading: true,
loaded_once: false,
per_page_options: @per_page_options,
- valid_states: @valid_states
+ valid_states: @valid_states,
+ page: 1,
+ per_page: @default_per_page,
+ state_filter: nil,
+ service_filter: nil,
+ hdr_filter: nil,
+ search: "",
+ sort_by: :updated_at,
+ sort_dir: :desc
)
- |> assign(filters)
- |> load_initial_snapshot()
if connected?(socket) do
Phoenix.PubSub.subscribe(Reencodarr.PubSub, Events.channel())
@@ -72,41 +76,10 @@ defmodule ReencodarrWeb.VideosLive do
filters = parse_params(params)
filters_changed? = filters_changed?(socket.assigns, filters)
- socket = assign(socket, filters)
-
socket =
- if connected?(socket) and socket.assigns.loaded_once and filters_changed? do
- state_counts = socket.assigns.state_counts
- page = socket.assigns.page
- per_page = socket.assigns.per_page
- state_filter = socket.assigns.state_filter
- service_filter = socket.assigns.service_filter
- hdr_filter = socket.assigns.hdr_filter
- search = socket.assigns.search
- sort_by = socket.assigns.sort_by
- sort_dir = socket.assigns.sort_dir
-
- socket
- |> assign(:loading, true)
- |> start_async(:load_videos, fn ->
- fetch_video_payload(
- %{
- state_counts: state_counts,
- page: page,
- per_page: per_page,
- state_filter: state_filter,
- service_filter: service_filter,
- hdr_filter: hdr_filter,
- search: search,
- sort_by: sort_by,
- sort_dir: sort_dir
- },
- include_state_counts: false
- )
- end)
- else
- socket
- end
+ socket
+ |> assign(filters)
+ |> reload_videos_for_params(filters_changed?)
{:noreply, socket}
end
@@ -118,39 +91,7 @@ defmodule ReencodarrWeb.VideosLive do
@impl true
def handle_info(:periodic_update, socket) do
Process.send_after(self(), :periodic_update, @update_interval)
-
- if socket.assigns.loaded_once do
- state_counts = socket.assigns.state_counts
- page = socket.assigns.page
- per_page = socket.assigns.per_page
- state_filter = socket.assigns.state_filter
- service_filter = socket.assigns.service_filter
- hdr_filter = socket.assigns.hdr_filter
- search = socket.assigns.search
- sort_by = socket.assigns.sort_by
- sort_dir = socket.assigns.sort_dir
-
- socket =
- socket
- |> assign(:loading, true)
- |> start_async(:load_videos, fn ->
- fetch_video_payload(%{
- state_counts: state_counts,
- page: page,
- per_page: per_page,
- state_filter: state_filter,
- service_filter: service_filter,
- hdr_filter: hdr_filter,
- search: search,
- sort_by: sort_by,
- sort_dir: sort_dir
- })
- end)
-
- {:noreply, socket}
- else
- {:noreply, socket}
- end
+ {:noreply, async_refresh_videos(socket)}
end
@impl true
@@ -162,38 +103,7 @@ defmodule ReencodarrWeb.VideosLive do
:crf_search_started,
:analyzer_progress
] do
- if socket.assigns.loaded_once do
- state_counts = socket.assigns.state_counts
- page = socket.assigns.page
- per_page = socket.assigns.per_page
- state_filter = socket.assigns.state_filter
- service_filter = socket.assigns.service_filter
- hdr_filter = socket.assigns.hdr_filter
- search = socket.assigns.search
- sort_by = socket.assigns.sort_by
- sort_dir = socket.assigns.sort_dir
-
- socket =
- socket
- |> assign(:loading, true)
- |> start_async(:load_videos, fn ->
- fetch_video_payload(%{
- state_counts: state_counts,
- page: page,
- per_page: per_page,
- state_filter: state_filter,
- service_filter: service_filter,
- hdr_filter: hdr_filter,
- search: search,
- sort_by: sort_by,
- sort_dir: sort_dir
- })
- end)
-
- {:noreply, socket}
- else
- {:noreply, socket}
- end
+ {:noreply, async_refresh_videos(socket)}
end
@impl true
@@ -201,7 +111,7 @@ defmodule ReencodarrWeb.VideosLive do
@impl true
def handle_async(:load_videos, {:ok, page_state}, socket) do
- {:noreply, assign_changed(socket, Map.put(page_state, :loading, false))}
+ {:noreply, assign_video_payload(socket, page_state)}
end
@impl true
@@ -562,32 +472,83 @@ defmodule ReencodarrWeb.VideosLive do
defp load_data(socket, opts \\ []) do
page_state = fetch_video_payload(socket.assigns, opts)
- assign_changed(socket, Map.put(page_state, :loading, false))
+ assign_video_payload(socket, page_state)
+ end
+
+ defp reload_videos_for_params(%{assigns: %{loaded_once: false}} = socket, _changed?) do
+ socket
+ |> load_data()
+ |> assign(:loaded_once, true)
end
- defp fetch_video_payload(assigns), do: fetch_video_payload(assigns, [])
+ defp reload_videos_for_params(socket, false), do: socket
+
+ defp reload_videos_for_params(socket, _changed?) do
+ load_data(socket, include_state_counts: false)
+ end
+
+ defp async_refresh_videos(%{assigns: %{loaded_once: false}} = socket), do: socket
+
+ defp async_refresh_videos(socket) do
+ if connected?(socket) do
+ load_assigns = video_load_assigns(socket.assigns)
+
+ socket
+ |> assign(:loading, true)
+ |> start_async(:load_videos, fn ->
+ fetch_video_payload(load_assigns, include_state_counts: false)
+ end)
+ else
+ assign(socket, :loading, false)
+ end
+ end
defp fetch_video_payload(assigns, opts) do
VideosState.load(
- %{
- state_counts: assigns.state_counts,
- page: assigns.page,
- per_page: assigns.per_page,
- state_filter: assigns.state_filter,
- service_filter: assigns.service_filter,
- hdr_filter: assigns.hdr_filter,
- search: assigns.search,
- sort_by: assigns.sort_by,
- sort_dir: assigns.sort_dir
- },
+ video_load_assigns(assigns),
opts
)
+ |> Map.put(:request, video_request_assigns(assigns))
end
- defp load_initial_snapshot(socket) do
- socket
- |> load_data()
- |> assign(:loaded_once, true)
+ defp video_load_assigns(assigns) do
+ Map.take(assigns, [
+ :state_counts,
+ :page,
+ :per_page,
+ :state_filter,
+ :service_filter,
+ :hdr_filter,
+ :search,
+ :sort_by,
+ :sort_dir
+ ])
+ end
+
+ defp video_request_assigns(assigns) do
+ Map.take(assigns, [
+ :page,
+ :per_page,
+ :state_filter,
+ :service_filter,
+ :hdr_filter,
+ :search,
+ :sort_by,
+ :sort_dir
+ ])
+ end
+
+ defp assign_video_payload(socket, %{request: request} = page_state) do
+ if video_request_assigns(socket.assigns) == request do
+ page_state =
+ page_state
+ |> Map.delete(:request)
+ |> Map.put(:loading, false)
+
+ assign(socket, page_state)
+ else
+ assign(socket, :loading, false)
+ end
end
defp apply_range_selection(selected_set, ids, true) do
@@ -687,54 +648,69 @@ defmodule ReencodarrWeb.VideosLive do
defp escape_like(value), do: value
defp parse_params(params) do
- %{
- sort_by:
- params
- |> Map.get("sort_by", "updated_at")
- |> coerce_atom_in(@valid_sort_fields, :updated_at),
- sort_dir:
- params
- |> Map.get("sort_dir", "desc")
- |> coerce_atom_in(@valid_sort_dirs, :desc),
- state_filter: params |> Map.get("state") |> nilify_empty() |> coerce_in(@valid_states),
- service_filter:
- params |> Map.get("service") |> nilify_empty() |> coerce_in(@valid_service_types),
- hdr_filter: params |> Map.get("hdr") |> nilify_empty() |> parse_hdr_param(),
- search: params |> Map.get("search", "") |> nilify_empty() |> then(&(&1 || "")),
- page: params |> Map.get("page", "1") |> Parsers.parse_int(1) |> max(1),
- per_page:
- params
- |> Map.get("per_page", "#{@default_per_page}")
- |> Parsers.parse_int(@default_per_page)
- |> then(&if(&1 in @per_page_options, do: &1, else: @default_per_page))
- }
+ Map.merge(
+ %{
+ sort_by:
+ params
+ |> Map.get("sort_by", "updated_at")
+ |> coerce_atom_in(@valid_sort_fields, :updated_at),
+ sort_dir:
+ params
+ |> Map.get("sort_dir", "desc")
+ |> coerce_atom_in(@valid_sort_dirs, :desc),
+ state_filter: params |> Map.get("state") |> nilify_empty() |> coerce_in(@valid_states),
+ service_filter:
+ params |> Map.get("service") |> nilify_empty() |> coerce_in(@valid_service_types),
+ hdr_filter: params |> Map.get("hdr") |> nilify_empty() |> parse_hdr_param(),
+ search: params |> Map.get("search", "") |> nilify_empty() |> then(&(&1 || ""))
+ },
+ FlopList.pagination_assigns(params, @default_per_page, @per_page_options)
+ )
end
# Build the /videos path with all current assigns merged with overrides.
# Omits nil/empty values to keep URLs clean.
+ defp videos_url_query(assigns) do
+ %{
+ "sort_by" => to_string(assigns.sort_by),
+ "sort_dir" => to_string(assigns.sort_dir),
+ "per_page" => assigns.per_page,
+ "search" => assigns.search,
+ "state" => assigns.state_filter,
+ "service" => assigns.service_filter,
+ "hdr" => hdr_to_param(assigns.hdr_filter)
+ }
+ |> drop_default_query_values()
+ end
+
defp patch_path(assigns, overrides) do
overrides_map = Enum.into(overrides, %{}, fn {k, v} -> {to_string(k), v} end)
query =
- %{
- "sort_by" => to_string(assigns.sort_by),
- "sort_dir" => to_string(assigns.sort_dir),
- "page" => assigns.page,
- "per_page" => assigns.per_page,
- "search" => assigns.search,
- "state" => assigns.state_filter,
- "service" => assigns.service_filter,
- "hdr" => hdr_to_param(assigns.hdr_filter)
- }
+ assigns
+ |> videos_url_query()
|> Map.merge(overrides_map)
- |> Enum.reject(fn {_, v} -> is_nil(v) || v == "" end)
- |> Enum.map(fn {k, v} -> {k, to_string(v)} end)
- |> URI.encode_query()
+ |> drop_default_query_values()
+
+ page =
+ overrides
+ |> Keyword.get(:page, assigns.page)
+ |> Parsers.parse_int(assigns.page)
+ |> max(1)
+
+ FlopList.patch_with_page("/videos", query, page)
+ end
- "/videos?#{query}"
+ defp drop_default_query_values(query) do
+ Map.reject(query, fn
+ {"sort_by", value} -> value in [:updated_at, "updated_at"]
+ {"sort_dir", value} -> value in [:desc, "desc"]
+ {"per_page", value} -> value in [@default_per_page, to_string(@default_per_page)]
+ {_key, value} -> value in [nil, ""]
+ end)
end
- defp max_page(%{total: total, per_page: per_page}), do: ListPagination.max_page(total, per_page)
+ defp max_page(%{total: total, per_page: per_page}), do: FlopList.total_pages(total, per_page)
defp toggle_dir(:asc), do: :desc
defp toggle_dir(:desc), do: :asc
@@ -767,21 +743,8 @@ defmodule ReencodarrWeb.VideosLive do
not is_nil(assigns.service_filter) or not is_nil(assigns.hdr_filter)
end
- defp pagination_label(page, per_page, total),
- do: ListPagination.pagination_label(page, per_page, total)
-
defp filters_changed?(assigns, filters) do
- Enum.any?(filters, fn {key, value} -> Map.get(assigns, key) != value end)
- end
-
- defp assign_changed(socket, attrs) do
- Enum.reduce(attrs, socket, fn {key, value}, acc ->
- if Map.get(acc.assigns, key) == value do
- acc
- else
- assign(acc, key, value)
- end
- end)
+ Enum.any?(Map.keys(filters), fn key -> Map.get(assigns, key) != Map.get(filters, key) end)
end
# ---------------------------------------------------------------------------
@@ -794,379 +757,477 @@ defmodule ReencodarrWeb.VideosLive do
assign(assigns,
max_page: max_page(assigns),
filters_active: filters_active?(assigns),
- select_count: MapSet.size(assigns.selected)
+ select_count: MapSet.size(assigns.selected),
+ url_query: videos_url_query(assigns)
)
~H"""
-
-
-
-
Videos
-
{@total} total
+ <.videos_header total={@total} select_count={@select_count} filters_active={@filters_active} />
+ <.video_state_filters
+ valid_states={@valid_states}
+ state_counts={@state_counts}
+ state_filter={@state_filter}
+ />
+ <.videos_toolbar
+ search={@search}
+ state_filter={@state_filter}
+ service_filter={@service_filter}
+ hdr_filter={@hdr_filter}
+ valid_states={@valid_states}
+ per_page={@per_page}
+ per_page_options={@per_page_options}
+ />
+ <.videos_results
+ loading={@loading}
+ videos={@videos}
+ selected={@selected}
+ select_count={@select_count}
+ sort_by={@sort_by}
+ sort_dir={@sort_dir}
+ expanded_bad_forms={@expanded_bad_forms}
+ meta={@meta}
+ url_query={@url_query}
+ />
+
+
+ """
+ end
+
+ # ---------------------------------------------------------------------------
+ # Components
+ # ---------------------------------------------------------------------------
+
+ attr :total, :integer, required: true
+ attr :select_count, :integer, required: true
+ attr :filters_active, :boolean, required: true
+
+ defp videos_header(assigns) do
+ ~H"""
+
+
+
Videos
+
{@total} total
+
+
+ <%= if @select_count > 0 do %>
+
+ Prioritize {@select_count} selected
+
+
+ Reset {@select_count} selected
+
+
+ Clear selection
+
+ <% end %>
+ <%= if @filters_active do %>
+
+ Clear filters
+
+ <% end %>
+
+
+ """
+ end
+
+ attr :valid_states, :list, required: true
+ attr :state_counts, :map, required: true
+ attr :state_filter, :any, required: true
+
+ defp video_state_filters(assigns) do
+ ~H"""
+
+ <%= for state <- @valid_states do %>
+ <% count = Map.get(@state_counts, String.to_existing_atom(state), 0) %>
+
+ {state}
+ {count}
+
+ <% end %>
+
+ """
+ end
+
+ attr :search, :string, required: true
+ attr :state_filter, :any, required: true
+ attr :service_filter, :any, required: true
+ attr :hdr_filter, :any, required: true
+ attr :valid_states, :list, required: true
+ attr :per_page, :integer, required: true
+ attr :per_page_options, :list, required: true
+
+ defp videos_toolbar(assigns) do
+ ~H"""
+
+
+ """
+ end
-
-
- <%= for state <- @valid_states do %>
- <% count = Map.get(@state_counts, String.to_existing_atom(state), 0) %>
-
- {state}
- {count}
-
- <% end %>
+ attr :loading, :boolean, required: true
+ attr :videos, :list, required: true
+ attr :selected, MapSet, required: true
+ attr :select_count, :integer, required: true
+ attr :sort_by, :atom, required: true
+ attr :sort_dir, :atom, required: true
+ attr :expanded_bad_forms, :list, required: true
+ attr :meta, Flop.Meta, required: true
+ attr :url_query, :map, required: true
+
+ defp videos_results(assigns) do
+ ~H"""
+ <%= if @loading and @videos == [] do %>
+
+ <% else %>
+ <%= if @loading do %>
+
Refreshing results...
+ <% end %>
+ <.videos_table
+ videos={@videos}
+ selected={@selected}
+ select_count={@select_count}
+ sort_by={@sort_by}
+ sort_dir={@sort_dir}
+ expanded_bad_forms={@expanded_bad_forms}
+ />
+
+ <.flop_pagination
+ id="videos-flop-pagination"
+ meta={@meta}
+ base_path="/videos"
+ query={@url_query}
+ mode={:simple}
+ />
+ <% end %>
+ """
+ end
-
-
-
-
-
+ attr :videos, :list, required: true
+ attr :selected, MapSet, required: true
+ attr :select_count, :integer, required: true
+ attr :sort_by, :atom, required: true
+ attr :sort_dir, :atom, required: true
+ attr :expanded_bad_forms, :list, required: true
+
+ defp videos_table(assigns) do
+ ~H"""
+
+ """
+ end
+
+ attr :video, :map, required: true
+ attr :selected, MapSet, required: true
+ attr :expanded_bad_forms, :list, required: true
-
-
-
{pagination_label(@page, @per_page, @total)}
-
-
- Previous
-
- {@page} / {@max_page}
- = @max_page}
- class="px-3 py-1 bg-gray-700 rounded text-gray-300 hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed"
- >
- Next
-
-
+ defp video_row(assigns) do
+ ~H"""
+
+
+
+
+
+ {Path.basename(@video.path)}
+ <%= if @video.title do %>
+
+ {@video.title}
+ <%= if @video.content_year do %>
+ ({@video.content_year})
+ <% end %>
<% end %>
-
+
+ {Path.basename(Path.dirname(@video.path))}
+ {format_resolution(@video.width, @video.height)}
+ {format_bitrate(@video.bitrate)}
+ {service_display(@video.service_type)}
+ <%= if @video.hdr do %>
+ <.hdr_badge hdr={@video.hdr} />
+ <% end %>
+ <%= if @video.original_size && @video.size do %>
+ <.space_saved_badge original_size={@video.original_size} current_size={@video.size} />
+ <% end %>
+
+
+
+
+ {@video.state}
+
+
+ {format_size(@video.size)}
+
+ {format_datetime(@video.updated_at)}
+
+
+ <.video_actions video={@video} />
+ <.mark_bad_form :if={@video.id in @expanded_bad_forms} video={@video} />
+
+
+ """
+ end
+
+ attr :video, :map, required: true
+
+ defp video_actions(assigns) do
+ ~H"""
+
+ <%= if queueable_video?(@video) do %>
+
+ prioritize
+
+ <% end %>
+ <%= if queueable_video?(@video) and season_directory(@video.path) do %>
+
+ prioritize season
+
+ <% end %>
+ <%= if fail_action_video?(@video) do %>
+
+ x
+
+ <% end %>
+
+ scan
+
+ <%= if @video.state in [:failed, :encoded, :crf_searched, :analyzed] do %>
+
+ reset
+
+ <% end %>
+
+ mark bad
+
+
+ del
+
"""
end
- # ---------------------------------------------------------------------------
- # Components
- # ---------------------------------------------------------------------------
+ attr :video, :map, required: true
+
+ defp mark_bad_form(assigns) do
+ ~H"""
+
+
+
+
+
+ save
+
+
+ cancel
+
+
+
+ """
+ end
attr :col, :atom, required: true
attr :label, :string, required: true
diff --git a/mix.lock b/mix.lock
index 2402b686..d2b7e2a0 100644
--- a/mix.lock
+++ b/mix.lock
@@ -56,10 +56,10 @@
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
- "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.1", "39db357b4293e168a62c43128354df4be0ad8bcb5aadacf403c0d9526b076f5a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7ddd6e85a828f08666aea23bdc3a81b1773cd16a94eee459a41bafb18c5a069f"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.4", "804d80c4672f35bafc2bc461676179e1c37327512471267e1ed970344066956e", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d6a93564e4680edd78bf1a0dba27b08e32f4fe2ccbaf82b66eadeba0be956e39"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
- "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
+ "plug": {:hex, :plug, "1.20.1", "82cdee1d7535d4f4db5c5602a7fd49512d64690be54fd62374856ee70e62eb29", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "892d2a1a7a3f5368c5a3b9067bba1050c031495f48c430ec00b09691dbf211b7"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"req": {:hex, :req, "0.6.1", "7b904c8b42d0e08136a5c6aba024fd12fc79a1ed8856e7a3522b0917f7e75113", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "aaf11c9c80f2df2364630b3594e1857fe610d8ea7cb994e1ce3dcb55f204ff1c"},
"req_fuse": {:hex, :req_fuse, "0.3.2", "8f96b26527deefe3d128496c058a23014754a569d12d281905d4c9e56bc3bae2", [:mix], [{:fuse, ">= 2.4.0", [hex: :fuse, repo: "hexpm", optional: false]}, {:req, ">= 0.4.14", [hex: :req, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "55cf642c03f10aed0dc4f97adc10f0985b355b377d2bc32bb0c569d82f3aa07e"},
diff --git a/priv/repo/migrations/20260630131500_add_list_page_filter_indexes.exs b/priv/repo/migrations/20260630131500_add_list_page_filter_indexes.exs
new file mode 100644
index 00000000..e0f4f866
--- /dev/null
+++ b/priv/repo/migrations/20260630131500_add_list_page_filter_indexes.exs
@@ -0,0 +1,273 @@
+defmodule Reencodarr.Repo.Migrations.AddListPageFilterIndexes do
+ use Ecto.Migration
+
+ def up do
+ execute """
+ CREATE VIRTUAL TABLE IF NOT EXISTS bad_file_issues_search USING fts5(
+ issue_kind,
+ classification,
+ manual_reason,
+ manual_note,
+ tokenize = 'unicode61 remove_diacritics 2'
+ )
+ """
+
+ execute """
+ INSERT INTO bad_file_issues_search(rowid, issue_kind, classification, manual_reason, manual_note)
+ SELECT id,
+ COALESCE(issue_kind, ''),
+ COALESCE(classification, ''),
+ COALESCE(manual_reason, ''),
+ COALESCE(manual_note, '')
+ FROM bad_file_issues
+ WHERE id NOT IN (SELECT rowid FROM bad_file_issues_search)
+ """
+
+ execute """
+ CREATE TRIGGER IF NOT EXISTS bad_file_issues_search_insert
+ AFTER INSERT ON bad_file_issues
+ BEGIN
+ INSERT INTO bad_file_issues_search(rowid, issue_kind, classification, manual_reason, manual_note)
+ VALUES (
+ NEW.id,
+ COALESCE(NEW.issue_kind, ''),
+ COALESCE(NEW.classification, ''),
+ COALESCE(NEW.manual_reason, ''),
+ COALESCE(NEW.manual_note, '')
+ );
+ END;
+ """
+
+ execute """
+ CREATE TRIGGER IF NOT EXISTS bad_file_issues_search_delete
+ AFTER DELETE ON bad_file_issues
+ BEGIN
+ DELETE FROM bad_file_issues_search WHERE rowid = OLD.id;
+ END;
+ """
+
+ execute """
+ CREATE TRIGGER IF NOT EXISTS bad_file_issues_search_update
+ AFTER UPDATE ON bad_file_issues
+ BEGIN
+ DELETE FROM bad_file_issues_search WHERE rowid = OLD.id;
+ INSERT INTO bad_file_issues_search(rowid, issue_kind, classification, manual_reason, manual_note)
+ VALUES (
+ NEW.id,
+ COALESCE(NEW.issue_kind, ''),
+ COALESCE(NEW.classification, ''),
+ COALESCE(NEW.manual_reason, ''),
+ COALESCE(NEW.manual_note, '')
+ );
+ END;
+ """
+
+ execute """
+ CREATE VIRTUAL TABLE IF NOT EXISTS video_failures_search USING fts5(
+ video_id UNINDEXED,
+ failure_code,
+ failure_message,
+ tokenize = 'unicode61 remove_diacritics 2'
+ )
+ """
+
+ execute """
+ INSERT INTO video_failures_search(rowid, video_id, failure_code, failure_message)
+ SELECT id,
+ video_id,
+ COALESCE(failure_code, ''),
+ COALESCE(failure_message, '')
+ FROM video_failures
+ WHERE id NOT IN (SELECT rowid FROM video_failures_search)
+ """
+
+ execute """
+ CREATE TRIGGER IF NOT EXISTS video_failures_search_insert
+ AFTER INSERT ON video_failures
+ BEGIN
+ INSERT INTO video_failures_search(rowid, video_id, failure_code, failure_message)
+ VALUES (
+ NEW.id,
+ NEW.video_id,
+ COALESCE(NEW.failure_code, ''),
+ COALESCE(NEW.failure_message, '')
+ );
+ END;
+ """
+
+ execute """
+ CREATE TRIGGER IF NOT EXISTS video_failures_search_delete
+ AFTER DELETE ON video_failures
+ BEGIN
+ DELETE FROM video_failures_search WHERE rowid = OLD.id;
+ END;
+ """
+
+ execute """
+ CREATE TRIGGER IF NOT EXISTS video_failures_search_update
+ AFTER UPDATE ON video_failures
+ BEGIN
+ DELETE FROM video_failures_search WHERE rowid = OLD.id;
+ INSERT INTO video_failures_search(rowid, video_id, failure_code, failure_message)
+ VALUES (
+ NEW.id,
+ NEW.video_id,
+ COALESCE(NEW.failure_code, ''),
+ COALESCE(NEW.failure_message, '')
+ );
+ END;
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_service_updated_at_desc_index
+ ON videos(service_type, updated_at DESC, id DESC)
+ WHERE service_type IS NOT NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_state_service_updated_at_desc_index
+ ON videos(state, service_type, updated_at DESC, id DESC)
+ WHERE service_type IS NOT NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_sdr_updated_at_desc_index
+ ON videos(updated_at DESC, id DESC)
+ WHERE hdr IS NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_hdr_updated_at_desc_index
+ ON videos(updated_at DESC, id DESC)
+ WHERE hdr IS NOT NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_state_sdr_updated_at_desc_index
+ ON videos(state, updated_at DESC, id DESC)
+ WHERE hdr IS NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_state_hdr_updated_at_desc_index
+ ON videos(state, updated_at DESC, id DESC)
+ WHERE hdr IS NOT NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_service_sdr_updated_at_desc_index
+ ON videos(service_type, updated_at DESC, id DESC)
+ WHERE hdr IS NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_service_hdr_updated_at_desc_index
+ ON videos(service_type, updated_at DESC, id DESC)
+ WHERE hdr IS NOT NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_state_service_sdr_updated_at_desc_index
+ ON videos(state, service_type, updated_at DESC, id DESC)
+ WHERE hdr IS NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_state_service_hdr_updated_at_desc_index
+ ON videos(state, service_type, updated_at DESC, id DESC)
+ WHERE hdr IS NOT NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_path_sort_index
+ ON videos(path, id)
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_size_sort_index
+ ON videos(size, id)
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_width_sort_index
+ ON videos(width, id)
+ WHERE width IS NOT NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_bitrate_sort_index
+ ON videos(bitrate, id)
+ WHERE bitrate IS NOT NULL
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS bad_file_issues_status_kind_updated_at_desc_index
+ ON bad_file_issues(status, issue_kind, updated_at DESC, id DESC)
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS bad_file_issues_resolved_status_kind_updated_at_index
+ ON bad_file_issues(status, issue_kind, updated_at DESC, id ASC)
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS bad_file_issues_status_inserted_at_index
+ ON bad_file_issues(status, inserted_at ASC, id ASC, video_id)
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS bad_file_issues_video_status_updated_at_desc_index
+ ON bad_file_issues(video_id, status, updated_at DESC, id DESC)
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS video_failures_unresolved_filter_video_index
+ ON video_failures(resolved, failure_stage, failure_category, video_id)
+ WHERE resolved = 0
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS video_failures_video_unresolved_filter_recent_index
+ ON video_failures(video_id, resolved, failure_stage, failure_category, inserted_at DESC)
+ WHERE resolved = 0
+ """
+
+ execute """
+ CREATE INDEX IF NOT EXISTS videos_failed_updated_at_desc_index
+ ON videos(updated_at DESC, id DESC)
+ WHERE state = 'failed'
+ """
+ end
+
+ def down do
+ execute "DROP TRIGGER IF EXISTS video_failures_search_update"
+ execute "DROP TRIGGER IF EXISTS video_failures_search_delete"
+ execute "DROP TRIGGER IF EXISTS video_failures_search_insert"
+ execute "DROP TABLE IF EXISTS video_failures_search"
+ execute "DROP TRIGGER IF EXISTS bad_file_issues_search_update"
+ execute "DROP TRIGGER IF EXISTS bad_file_issues_search_delete"
+ execute "DROP TRIGGER IF EXISTS bad_file_issues_search_insert"
+ execute "DROP TABLE IF EXISTS bad_file_issues_search"
+ execute "DROP INDEX IF EXISTS videos_failed_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS video_failures_video_unresolved_filter_recent_index"
+ execute "DROP INDEX IF EXISTS video_failures_unresolved_filter_video_index"
+ execute "DROP INDEX IF EXISTS bad_file_issues_video_status_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS bad_file_issues_status_inserted_at_index"
+ execute "DROP INDEX IF EXISTS bad_file_issues_resolved_status_kind_updated_at_index"
+ execute "DROP INDEX IF EXISTS bad_file_issues_status_kind_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS videos_bitrate_sort_index"
+ execute "DROP INDEX IF EXISTS videos_width_sort_index"
+ execute "DROP INDEX IF EXISTS videos_size_sort_index"
+ execute "DROP INDEX IF EXISTS videos_path_sort_index"
+ execute "DROP INDEX IF EXISTS videos_state_service_hdr_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS videos_state_service_sdr_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS videos_service_hdr_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS videos_service_sdr_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS videos_state_hdr_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS videos_state_sdr_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS videos_hdr_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS videos_sdr_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS videos_state_service_updated_at_desc_index"
+ execute "DROP INDEX IF EXISTS videos_service_updated_at_desc_index"
+ end
+end
diff --git a/test/reencodarr/bad_file_issue_test.exs b/test/reencodarr/bad_file_issue_test.exs
index 07aa3bde..174bb71f 100644
--- a/test/reencodarr/bad_file_issue_test.exs
+++ b/test/reencodarr/bad_file_issue_test.exs
@@ -86,7 +86,8 @@ defmodule Reencodarr.BadFileIssueTest do
assert first_issue.id == second_issue.id
assert second_issue.manual_reason == "updated"
assert second_issue.manual_note == "reproduces consistently"
- assert Enum.count(Media.list_bad_file_issues()) == 1
+ {issues, _} = Media.list_bad_file_issues(%{"page_size" => "250"})
+ assert Enum.count(issues) == 1
end
end
diff --git a/test/reencodarr/bad_files_state_test.exs b/test/reencodarr/bad_files_state_test.exs
index 2f197db2..dcff36d4 100644
--- a/test/reencodarr/bad_files_state_test.exs
+++ b/test/reencodarr/bad_files_state_test.exs
@@ -32,4 +32,134 @@ defmodule Reencodarr.BadFiles.StateTest do
assert is_map(payload.issue_summary)
assert payload.tracked_count >= 1
end
+
+ test "resolved filter loads resolved issues even when show_resolved is false" do
+ {:ok, video} = Fixtures.video_fixture(%{path: "/media/resolved_filter_payload.mkv"})
+
+ {:ok, issue} =
+ Media.create_bad_file_issue(video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "resolved filter"
+ })
+
+ {:ok, _issue} = Media.dismiss_bad_file_issue(issue)
+
+ payload =
+ State.load(%{
+ page: 1,
+ per_page: 50,
+ status_filter: "resolved",
+ service_filter: "all",
+ kind_filter: "all",
+ search_query: "resolved_filter_payload",
+ show_resolved: false
+ })
+
+ assert Enum.any?(payload.active_issues, &(&1.video_id == video.id))
+ assert Enum.any?(payload.issues, &(&1.video_id == video.id))
+ assert payload.resolved_issues == []
+ assert payload.meta.total_count == 1
+ end
+
+ test "resolved filter is a paginated primary list" do
+ Enum.each(1..26, fn n ->
+ {:ok, video} = Fixtures.video_fixture(%{path: "/media/resolved_page_#{n}.mkv"})
+
+ {:ok, issue} =
+ Media.create_bad_file_issue(video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "resolved page"
+ })
+
+ {:ok, _issue} = Media.dismiss_bad_file_issue(issue)
+ end)
+
+ payload =
+ State.load(%{
+ page: 2,
+ per_page: 25,
+ status_filter: "resolved",
+ service_filter: "all",
+ kind_filter: "all",
+ search_query: "resolved_page",
+ show_resolved: false
+ })
+
+ assert payload.meta.total_count == 26
+ assert payload.meta.current_page == 2
+ assert Enum.map(payload.active_issues, & &1.video.path) == ["/media/resolved_page_26.mkv"]
+ assert payload.resolved_issues == []
+ end
+
+ test "list_active_issues returns all matching pages for bulk actions" do
+ Enum.each(1..251, fn n ->
+ {:ok, video} = Fixtures.video_fixture(%{path: "/media/bulk_filtered_#{n}.mkv"})
+
+ {:ok, _issue} =
+ Media.create_bad_file_issue(video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "bulk filtered"
+ })
+ end)
+
+ issues =
+ State.list_active_issues(%{
+ page: 1,
+ per_page: 50,
+ status_filter: "all",
+ service_filter: "all",
+ kind_filter: "all",
+ search_query: "bulk_filtered",
+ show_resolved: false
+ })
+
+ assert Enum.count(issues) == 251
+ end
+
+ test "replacement_issues includes matching replacement issues outside current page" do
+ Enum.each(1..25, fn n ->
+ {:ok, video} = Fixtures.video_fixture(%{path: "/media/replacement_page_open_#{n}.mkv"})
+
+ {:ok, _issue} =
+ Media.create_bad_file_issue(video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "replacement page"
+ })
+ end)
+
+ {:ok, replacement_video} =
+ Fixtures.video_fixture(%{path: "/media/replacement_page_waiting.mkv"})
+
+ {:ok, issue} =
+ Media.create_bad_file_issue(replacement_video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "replacement page"
+ })
+
+ {:ok, issue} = Media.update_bad_file_issue_status(issue, :waiting_for_replacement)
+
+ payload =
+ State.load(%{
+ page: 1,
+ per_page: 25,
+ status_filter: "open",
+ service_filter: "all",
+ kind_filter: "all",
+ search_query: "replacement_page",
+ show_resolved: false
+ })
+
+ refute Enum.any?(payload.issues, &(&1.id == issue.id))
+ assert Enum.any?(payload.replacement_issues, &(&1.id == issue.id))
+ end
end
diff --git a/test/reencodarr/flop_fixtures_test.exs b/test/reencodarr/flop_fixtures_test.exs
new file mode 100644
index 00000000..d521919a
--- /dev/null
+++ b/test/reencodarr/flop_fixtures_test.exs
@@ -0,0 +1,37 @@
+defmodule Reencodarr.FlopFixturesTest do
+ use ExUnit.Case, async: true
+
+ alias Reencodarr.FlopFixtures
+ alias Reencodarr.Media.Video
+
+ test "meta_fixture/1 defaults to empty first page" do
+ meta = FlopFixtures.meta_fixture()
+
+ assert meta.schema == Video
+ assert meta.current_page == 1
+ assert meta.page_size == 20
+ assert meta.total_count == 0
+ assert meta.total_pages == 0
+ refute meta.has_next_page?
+ refute meta.has_previous_page?
+ end
+
+ test "meta_fixture/1 builds multi-page meta" do
+ meta = FlopFixtures.meta_fixture(page: 2, page_size: 10, total_count: 25)
+
+ assert meta.current_page == 2
+ assert meta.total_pages == 3
+ assert meta.has_previous_page?
+ assert meta.has_next_page?
+ assert meta.previous_page == 1
+ assert meta.next_page == 3
+ end
+
+ test "meta_fixture/1 clamps page beyond total" do
+ meta = FlopFixtures.meta_fixture(page: 99, page_size: 10, total_count: 15)
+
+ assert meta.current_page == 2
+ assert meta.total_pages == 2
+ refute meta.has_next_page?
+ end
+end
diff --git a/test/reencodarr/media/list_bad_file_issues_test.exs b/test/reencodarr/media/list_bad_file_issues_test.exs
new file mode 100644
index 00000000..383b710f
--- /dev/null
+++ b/test/reencodarr/media/list_bad_file_issues_test.exs
@@ -0,0 +1,106 @@
+defmodule Reencodarr.Media.ListBadFileIssuesTest do
+ use Reencodarr.DataCase, async: true
+
+ alias Reencodarr.Fixtures
+ alias Reencodarr.Media
+
+ test "list_bad_file_issues/2 filters by kind and paginates" do
+ {:ok, audio_video} = Fixtures.video_fixture(%{path: "/media/audio_issue.mkv"})
+ {:ok, manual_video} = Fixtures.video_fixture(%{path: "/media/manual_issue.mkv"})
+
+ {:ok, _} =
+ Media.create_bad_file_issue(audio_video, %{
+ origin: :manual,
+ issue_kind: :audio,
+ classification: :confirmed_bad_audio_layout
+ })
+
+ {:ok, _} =
+ Media.create_bad_file_issue(manual_video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "manual"
+ })
+
+ assert {issues, meta} =
+ Media.list_bad_file_issues(%{"kind" => "audio", "page" => "1", "page_size" => "10"})
+
+ assert meta.total_count == 1
+ assert [%{issue_kind: :audio}] = issues
+ end
+
+ test "search matches video path" do
+ {:ok, video} = Fixtures.video_fixture(%{path: "/media/searchable_bad.mkv"})
+
+ {:ok, _} =
+ Media.create_bad_file_issue(video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "searchable"
+ })
+
+ assert {issues, meta} = Media.list_bad_file_issues(%{"search" => "searchable_bad"})
+ assert meta.total_count == 1
+ assert hd(issues).video.path =~ "searchable_bad"
+ end
+
+ test "search treats percent and underscore literally" do
+ {:ok, percent_video} = Fixtures.video_fixture(%{path: "/media/literal_percent_%_bad.mkv"})
+ {:ok, plain_video} = Fixtures.video_fixture(%{path: "/media/literal_percent_x_bad.mkv"})
+
+ for video <- [percent_video, plain_video] do
+ {:ok, _} =
+ Media.create_bad_file_issue(video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "literal wildcard"
+ })
+ end
+
+ assert {issues, meta} = Media.list_bad_file_issues(%{"search" => "%_"})
+ assert meta.total_count == 1
+ assert hd(issues).video.path == "/media/literal_percent_%_bad.mkv"
+ end
+
+ test "queue_bad_file_issue_series does not stop at the first bad-file page" do
+ {:ok, series_video} =
+ Fixtures.video_fixture(%{
+ path: "/media/Series Queue/Season 01/series_queue_target.mkv",
+ service_type: :sonarr
+ })
+
+ {:ok, issue} =
+ Media.create_bad_file_issue(series_video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "series queue target"
+ })
+
+ Enum.each(1..251, fn n ->
+ {:ok, video} =
+ Fixtures.video_fixture(%{
+ path: "/media/Other Series #{n}/Season 01/decoy_#{n}.mkv",
+ service_type: :sonarr
+ })
+
+ {:ok, _issue} =
+ Media.create_bad_file_issue(video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "series queue decoy"
+ })
+ end)
+
+ assert {:ok, 1} = Media.queue_bad_file_issue_series(issue)
+
+ assert %{status: :queued} =
+ issue.id
+ |> Media.get_bad_file_issue!()
+ |> Reencodarr.Repo.reload!()
+ end
+end
diff --git a/test/reencodarr/media/list_failures_test.exs b/test/reencodarr/media/list_failures_test.exs
new file mode 100644
index 00000000..c0eb67cf
--- /dev/null
+++ b/test/reencodarr/media/list_failures_test.exs
@@ -0,0 +1,108 @@
+defmodule Reencodarr.Media.ListFailuresTest do
+ use Reencodarr.DataCase, async: false
+
+ alias Reencodarr.Fixtures
+ alias Reencodarr.Media
+ alias Reencodarr.Media.VideoFailure
+ alias Reencodarr.Repo
+
+ defp failed_video!(attrs) do
+ {:ok, video} = Fixtures.video_fixture(attrs)
+ Media.record_video_failure(video, :encoding, :timeout, message: "test failure")
+ video
+ end
+
+ defp failure!(video, stage, category, message) do
+ %VideoFailure{}
+ |> VideoFailure.changeset(%{
+ video_id: video.id,
+ failure_stage: stage,
+ failure_category: category,
+ failure_message: message
+ })
+ |> Repo.insert!()
+ end
+
+ describe "list_failures/1" do
+ test "returns failed videos with no filters on page 1" do
+ video = failed_video!(%{path: "/media/no_filter.mkv"})
+
+ assert {videos, meta} = Media.list_failures(%{})
+ assert meta.total_count == 1
+ assert Enum.map(videos, & &1.id) == [video.id]
+ end
+
+ test "stage filter limits results" do
+ {:ok, analysis_video} = Fixtures.video_fixture(%{path: "/media/analysis_only.mkv"})
+ Media.record_video_failure(analysis_video, :analysis, :timeout, message: "analysis")
+
+ {:ok, encoding_video} = Fixtures.video_fixture(%{path: "/media/encoding_only.mkv"})
+ Media.record_video_failure(encoding_video, :encoding, :timeout, message: "encoding")
+
+ assert {videos, _meta} = Media.list_failures(%{"stage" => "analysis"})
+ assert [%{path: "/media/analysis_only.mkv"}] = videos
+ end
+
+ test "category filter limits results" do
+ {:ok, process_video} = Fixtures.video_fixture(%{path: "/media/process_fail.mkv"})
+ Media.record_video_failure(process_video, :encoding, :process_failure, message: "process")
+
+ {:ok, timeout_video} = Fixtures.video_fixture(%{path: "/media/timeout_fail.mkv"})
+ Media.record_video_failure(timeout_video, :encoding, :timeout, message: "timeout")
+
+ assert {videos, _meta} = Media.list_failures(%{"category" => "timeout"})
+ assert [%{path: "/media/timeout_fail.mkv"}] = videos
+ end
+
+ test "search matches path" do
+ failed_video!(%{path: "/media/FindMe_Special.mkv"})
+ failed_video!(%{path: "/media/other_file.mkv"})
+
+ assert {videos, meta} = Media.list_failures(%{"search" => "findme"})
+ assert meta.total_count == 1
+ assert hd(videos).path =~ "FindMe"
+ end
+
+ test "search treats percent and underscore literally" do
+ failed_video!(%{path: "/media/failure_%_literal.mkv"})
+ failed_video!(%{path: "/media/failure_x_literal.mkv"})
+
+ assert {videos, meta} = Media.list_failures(%{"search" => "%_"})
+ assert meta.total_count == 1
+ assert hd(videos).path == "/media/failure_%_literal.mkv"
+ end
+
+ test "load_failures_page displays failures matching active filters" do
+ {:ok, video} = Fixtures.video_fixture(%{path: "/media/mixed_failures.mkv", state: :failed})
+ failure!(video, :analysis, :file_access, "analysis failure")
+ failure!(video, :encoding, :timeout, "encoding failure")
+
+ payload = Media.load_failures_page(%{"stage" => "analysis"})
+
+ assert [%{failure_stage: :analysis, failure_message: "analysis failure"}] =
+ payload.video_failures[video.id]
+ end
+
+ test "pagination returns page 2 slice" do
+ Enum.each(1..21, fn n ->
+ failed_video!(%{path: "/media/page_#{n}.mkv"})
+ end)
+
+ assert {page1, meta1} = Media.list_failures(%{"page" => "1", "page_size" => "20"})
+ assert {page2, meta2} = Media.list_failures(%{"page" => "2", "page_size" => "20"})
+
+ assert meta1.total_count == 21
+ assert meta2.total_count == 21
+ assert 20 == Enum.count(page1)
+ assert [_only] = page2
+ end
+
+ test "invalid stage coerces to all" do
+ video = failed_video!(%{path: "/media/invalid_stage.mkv"})
+
+ assert {videos, meta} = Media.list_failures(%{"stage" => "not_a_stage"})
+ assert meta.total_count == 1
+ assert hd(videos).id == video.id
+ end
+ end
+end
diff --git a/test/reencodarr_web/flop_list_test_helpers_test.exs b/test/reencodarr_web/flop_list_test_helpers_test.exs
new file mode 100644
index 00000000..d7d9df2a
--- /dev/null
+++ b/test/reencodarr_web/flop_list_test_helpers_test.exs
@@ -0,0 +1,25 @@
+defmodule ReencodarrWeb.FlopListTestHelpersTest do
+ use ExUnit.Case, async: true
+
+ alias Reencodarr.FlopFixtures
+ alias ReencodarrWeb.FlopListTestHelpers
+
+ test "current_page_from_meta/1 reads flop meta page" do
+ meta = FlopFixtures.meta_fixture(page: 3, page_size: 20, total_count: 60)
+ assert FlopListTestHelpers.current_page_from_meta(meta) == 3
+ end
+
+ test "assert_filter_in_url/3 matches query params" do
+ assert :ok =
+ FlopListTestHelpers.assert_filter_in_url(
+ "/failures?stage=analysis&search=foo",
+ "stage",
+ "analysis"
+ )
+ end
+
+ test "pagination_label_from_html/1 parses flop label" do
+ html = ~s(
1-20 of 42 )
+ assert FlopListTestHelpers.pagination_label_from_html(html) == "1-20 of 42"
+ end
+end
diff --git a/test/reencodarr_web/live/bad_files_live_test.exs b/test/reencodarr_web/live/bad_files_live_test.exs
index 9823b6f4..f8a2a288 100644
--- a/test/reencodarr_web/live/bad_files_live_test.exs
+++ b/test/reencodarr_web/live/bad_files_live_test.exs
@@ -3,6 +3,8 @@ defmodule ReencodarrWeb.BadFilesLiveTest do
import Phoenix.LiveViewTest
+ import ReencodarrWeb.FlopListTestHelpers
+
alias Reencodarr.BadFileRemediation
alias Reencodarr.Dashboard.Events
alias Reencodarr.Fixtures
@@ -562,7 +564,7 @@ defmodule ReencodarrWeb.BadFilesLiveTest do
|> form("#bad-files-status-filter", %{"status" => "queued"})
|> render_change()
- assert_patch(view, ~p"/bad-files?page=1&per_page=50&status=queued")
+ assert_patch(view, ~p"/bad-files?status=queued")
html = render_async(view)
assert html =~ "filter_queued.mkv"
refute html =~ "filter_open.mkv"
@@ -598,7 +600,7 @@ defmodule ReencodarrWeb.BadFilesLiveTest do
|> form("#bad-files-service-filter", %{"service" => "radarr"})
|> render_change()
- assert_patch(view, ~p"/bad-files?page=1&per_page=50&service=radarr")
+ assert_patch(view, ~p"/bad-files?service=radarr")
html = render_async(view)
assert html =~ "filter_radarr.mkv"
refute html =~ "filter_sonarr.mkv"
@@ -630,7 +632,7 @@ defmodule ReencodarrWeb.BadFilesLiveTest do
|> form("#bad-files-kind-filter", %{"kind" => "audio"})
|> render_change()
- assert_patch(view, ~p"/bad-files?kind=audio&page=1&per_page=50")
+ assert_patch(view, ~p"/bad-files?kind=audio")
html = render_async(view)
assert html =~ "filter_audio_kind.mkv"
refute html =~ "filter_manual_kind.mkv"
@@ -663,7 +665,7 @@ defmodule ReencodarrWeb.BadFilesLiveTest do
|> form("#bad-files-search-filter", %{"query" => "search_this"})
|> render_change()
- assert_patch(view, ~p"/bad-files?page=1&per_page=50&search=search_this")
+ assert_patch(view, ~p"/bad-files?search=search_this")
by_path_html = render_async(view)
assert by_path_html =~ "search_this_title.mkv"
refute by_path_html =~ "other_title.mkv"
@@ -672,10 +674,143 @@ defmodule ReencodarrWeb.BadFilesLiveTest do
|> form("#bad-files-search-filter", %{"query" => "blocky"})
|> render_change()
- assert_patch(view, ~p"/bad-files?page=1&per_page=50&search=blocky")
+ assert_patch(view, ~p"/bad-files?search=blocky")
by_reason_html = render_async(view)
assert by_reason_html =~ "other_title.mkv"
refute by_reason_html =~ "search_this_title.mkv"
end
end
+
+ describe "URL state" do
+ test "direct navigation hydrates filters from query string", %{conn: conn} do
+ {:ok, open_video} = Fixtures.video_fixture(%{path: "/media/url_open.mkv"})
+ {:ok, queued_video} = Fixtures.video_fixture(%{path: "/media/url_queued.mkv"})
+
+ {:ok, _open_issue} =
+ Media.create_bad_file_issue(open_video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "open"
+ })
+
+ {:ok, queued_issue} =
+ Media.create_bad_file_issue(queued_video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "queued"
+ })
+
+ {:ok, _queued_issue} = Media.enqueue_bad_file_issue(queued_issue)
+
+ {:ok, view, _html} =
+ live(conn, ~p"/bad-files?status=queued&page=1&per_page=25")
+
+ html = render_async(view)
+ assert html =~ "url_queued.mkv"
+ refute html =~ "url_open.mkv"
+ end
+
+ test "invalid filter params coerce to safe defaults", %{conn: conn} do
+ {:ok, view, _html} =
+ live(conn, ~p"/bad-files?status=not-a-status&service=bogus&page=0")
+
+ html = render_async(view)
+
+ assert has_element?(view, "#bad-files-status-filter select option[value='all'][selected]")
+ assert has_element?(view, "#bad-files-service-filter select option[value='all'][selected]")
+ assert current_page_from_html(html) == 1
+ end
+
+ test "page beyond total clamps to last page", %{conn: conn} do
+ Enum.each(1..26, fn n ->
+ {:ok, video} = Fixtures.video_fixture(%{path: "/media/bad_clamp_#{n}.mkv"})
+
+ {:ok, _issue} =
+ Media.create_bad_file_issue(video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "clamp #{n}"
+ })
+ end)
+
+ {:ok, view, _html} = live(conn, ~p"/bad-files?page=999&per_page=25")
+ html = render_async(view)
+
+ assert pagination_label_from_html(html) == "26-26 of 26"
+ assert html =~ "bad_clamp_1.mkv"
+ refute html =~ "bad_clamp_26.mkv"
+ end
+
+ test "resolved status is paginated as the primary list", %{conn: conn} do
+ Enum.each(1..26, fn n ->
+ {:ok, video} = Fixtures.video_fixture(%{path: "/media/resolved_url_#{n}.mkv"})
+
+ {:ok, issue} =
+ Media.create_bad_file_issue(video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "resolved url"
+ })
+
+ {:ok, _issue} = Media.dismiss_bad_file_issue(issue)
+ end)
+
+ {:ok, view, _html} = live(conn, ~p"/bad-files?status=resolved&page=2&per_page=25")
+ html = render_async(view)
+
+ assert pagination_label_from_html(html) == "26-26 of 26"
+ assert html =~ "Resolved Issues"
+ assert html =~ "resolved_url_"
+ refute html =~ "Recent resolved issues are loaded on demand."
+ end
+
+ test "combined filters in query string", %{conn: conn} do
+ {:ok, sonarr_video} =
+ Fixtures.video_fixture(%{path: "/media/url_combo.mkv", service_type: :sonarr})
+
+ {:ok, radarr_video} =
+ Fixtures.video_fixture(%{path: "/media/wrong_service_combo.mkv", service_type: :radarr})
+
+ {:ok, audio_video} =
+ Fixtures.video_fixture(%{path: "/media/wrong_kind_combo.mkv", service_type: :sonarr})
+
+ {:ok, _issue} =
+ Media.create_bad_file_issue(sonarr_video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "combo search target"
+ })
+
+ {:ok, _wrong_service} =
+ Media.create_bad_file_issue(radarr_video, %{
+ origin: :manual,
+ issue_kind: :manual,
+ classification: :manual_bad,
+ manual_reason: "combo wrong service"
+ })
+
+ {:ok, _wrong_kind} =
+ Media.create_bad_file_issue(audio_video, %{
+ origin: :manual,
+ issue_kind: :audio,
+ classification: :confirmed_bad_audio_layout
+ })
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ ~p"/bad-files?service=sonarr&kind=manual&search=combo&page=1&per_page=50"
+ )
+
+ html = render_async(view)
+ assert html =~ "url_combo.mkv"
+ refute html =~ "wrong_service_combo.mkv"
+ refute html =~ "wrong_kind_combo.mkv"
+ end
+ end
end
diff --git a/test/reencodarr_web/live/failures_live_test.exs b/test/reencodarr_web/live/failures_live_test.exs
index 8470149b..856533d5 100644
--- a/test/reencodarr_web/live/failures_live_test.exs
+++ b/test/reencodarr_web/live/failures_live_test.exs
@@ -11,31 +11,15 @@ defmodule ReencodarrWeb.FailuresLiveTest do
use ReencodarrWeb.ConnCase, async: false
import Phoenix.LiveViewTest
+ import ReencodarrWeb.FlopListTestHelpers
alias Reencodarr.Fixtures
alias Reencodarr.Media
+ alias Reencodarr.Media.VideoFailure
+ alias Reencodarr.Repo
- # Flush the LiveView process mailbox (processes :load_initial_data) and return
- # the fully-loaded HTML.
defp loaded_html(view), do: render(view)
- defp current_page_from_html(html) do
- case Regex.run(~r/Page\s*
(-?\d+)<\/span>/, html) do
- [_, page] -> String.to_integer(page)
- _ -> nil
- end
- end
-
- defp total_pages_from_html(html) do
- case Regex.run(
- ~r/of\s*(\d+)<\/span>/,
- html
- ) do
- [_, total] -> String.to_integer(total)
- _ -> nil
- end
- end
-
# ---------------------------------------------------------------------------
# Mount / basic render
# ---------------------------------------------------------------------------
@@ -277,6 +261,23 @@ defmodule ReencodarrWeb.FailuresLiveTest do
html = view |> element("button[phx-click='reset_all_failures']") |> render_click()
assert html =~ "Failures"
end
+
+ test "reset_all_failures deletes unresolved failures and requeues failed videos", %{
+ conn: conn
+ } do
+ {:ok, video} = Fixtures.video_fixture(%{path: "/media/reset_all_semantics.mkv"})
+ Media.record_video_failure(video, :encoding, :timeout, message: "reset me")
+
+ {:ok, view, _} = live(conn, ~p"/failures")
+ loaded_html(view)
+
+ view
+ |> element("button[phx-click='reset_all_failures']")
+ |> render_click()
+
+ assert Media.get_video!(video.id).state == :needs_analysis
+ assert Repo.get_by(VideoFailure, video_id: video.id) == nil
+ end
end
describe "retry_failure_code event" do
@@ -393,32 +394,36 @@ defmodule ReencodarrWeb.FailuresLiveTest do
end
end
+ describe "URL bookmarks" do
+ test "stage and search params load from URL", %{conn: conn} do
+ {:ok, analysis_video} = Fixtures.video_fixture(%{path: "/media/url_analysis.mkv"})
+ {:ok, encoding_video} = Fixtures.video_fixture(%{path: "/media/url_analysis_encoding.mkv"})
+
+ Media.record_video_failure(analysis_video, :analysis, :timeout, message: "analysis")
+ Media.record_video_failure(encoding_video, :encoding, :timeout, message: "encoding")
+
+ {:ok, _view, html} = live(conn, ~p"/failures?stage=analysis&search=url_analysis")
+
+ assert html =~ "url_analysis.mkv"
+ refute html =~ "url_analysis_encoding.mkv"
+ end
+ end
+
describe "pagination behavior" do
- test "change_page with invalid low page clamps to page 1", %{conn: conn} do
+ test "page 0 in URL clamps to page 1", %{conn: conn} do
Enum.each(1..21, fn n ->
{:ok, video} = Fixtures.video_fixture(%{path: "/media/page_item_#{n}.mkv"})
Media.record_video_failure(video, :encoding, :timeout, message: "failure #{n}")
end)
- {:ok, view, _} = live(conn, ~p"/failures")
- loaded_html(view)
-
- view
- |> element("button[phx-click='change_page'][title='Next page']")
- |> render_click()
-
- render_async(view)
-
- view
- |> element("button[phx-click='change_page'][title='First page']")
- |> render_click(%{"page" => "0"})
-
- html = render_async(view)
+ {:ok, view, _} = live(conn, ~p"/failures?page=0")
+ html = loaded_html(view)
assert current_page_from_html(html) == 1
+ assert pagination_label_from_html(html) == "1-20 of 21"
end
- test "shows Page 1 of 2 with 21 failures at per_page 20", %{conn: conn} do
+ test "shows flop pagination label for page 1 of 2 with 21 failures", %{conn: conn} do
Enum.each(1..21, fn n ->
{:ok, video} = Fixtures.video_fixture(%{path: "/media/paginate_#{n}.mkv"})
Media.record_video_failure(video, :encoding, :timeout, message: "p #{n}")
@@ -429,6 +434,7 @@ defmodule ReencodarrWeb.FailuresLiveTest do
assert current_page_from_html(html) == 1
assert total_pages_from_html(html) == 2
+ assert pagination_label_from_html(html) == "1-20 of 21"
end
test "navigating to page 2 shows the 21st failure", %{conn: conn} do
@@ -443,18 +449,12 @@ defmodule ReencodarrWeb.FailuresLiveTest do
{:ok, last_video} = Fixtures.video_fixture(%{path: "/media/last_on_page_two.mkv"})
Media.record_video_failure(last_video, :encoding, :timeout, message: "last")
- {:ok, view, _} = live(conn, ~p"/failures")
- loaded_html(view)
-
- view
- |> element("button[phx-click='change_page'][title='Next page']")
- |> render_click()
-
- html = render_async(view)
+ {:ok, view, _} = live(conn, ~p"/failures?page=2")
+ html = loaded_html(view)
- assert current_page_from_html(html) == 2
- assert html =~ "last_on_page_two"
- refute html =~ "first_on_page_one"
+ assert pagination_label_from_html(html) == "21-21 of 21"
+ assert html =~ "first_on_page_one"
+ refute html =~ "last_on_page_two"
end
test "filter resets page to 1 when on page 2", %{conn: conn} do
@@ -466,10 +466,7 @@ defmodule ReencodarrWeb.FailuresLiveTest do
{:ok, view, _} = live(conn, ~p"/failures")
loaded_html(view)
- view
- |> element("button[phx-click='change_page'][title='Next page']")
- |> render_click()
-
+ click_flop_next(view)
render_async(view)
view
@@ -481,20 +478,17 @@ defmodule ReencodarrWeb.FailuresLiveTest do
assert current_page_from_html(html) == 1
end
- test "change_page clamps to total_pages when page exceeds total", %{conn: conn} do
+ test "page beyond total clamps to last page", %{conn: conn} do
Enum.each(1..21, fn n ->
{:ok, video} = Fixtures.video_fixture(%{path: "/media/clamp_#{n}.mkv"})
Media.record_video_failure(video, :encoding, :timeout, message: "clamp #{n}")
end)
- {:ok, view, _} = live(conn, ~p"/failures")
- loaded_html(view)
-
- render_click(view, "change_page", %{"page" => "999"})
- html = render_async(view)
+ {:ok, view, _} = live(conn, ~p"/failures?page=999")
+ html = loaded_html(view)
- # page should be clamped to total_pages (2), not left at 999
assert current_page_from_html(html) == 2
+ assert pagination_label_from_html(html) == "21-21 of 21"
end
test "reset_all_failures resets page to 1", %{conn: conn} do
@@ -506,10 +500,7 @@ defmodule ReencodarrWeb.FailuresLiveTest do
{:ok, view, _} = live(conn, ~p"/failures")
loaded_html(view)
- view
- |> element("button[phx-click='change_page'][title='Next page']")
- |> render_click()
-
+ click_flop_next(view)
render_async(view)
view
diff --git a/test/reencodarr_web/live/flop_list_test.exs b/test/reencodarr_web/live/flop_list_test.exs
new file mode 100644
index 00000000..12b2d289
--- /dev/null
+++ b/test/reencodarr_web/live/flop_list_test.exs
@@ -0,0 +1,146 @@
+defmodule ReencodarrWeb.Live.FlopListTest do
+ use ExUnit.Case, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias Reencodarr.FlopFixtures
+ alias ReencodarrWeb.Live.FlopList
+
+ describe "flop_pagination/1" do
+ test "simple mode renders label and prev/next without page numbers" do
+ meta = FlopFixtures.meta_fixture(page: 2, page_size: 50, total_count: 120)
+
+ html =
+ render_component(&FlopList.flop_pagination/1, %{
+ id: "videos-flop-pagination",
+ meta: meta,
+ path: "/videos",
+ mode: :simple
+ })
+
+ assert html =~ "id=\"videos-flop-pagination\""
+ assert html =~ ~s(data-role="flop-pagination-label")
+ assert html =~ "51-100 of 120"
+ assert html =~ ~s(data-role="flop-pagination-prev")
+ assert html =~ ~s(data-role="flop-pagination-next")
+ assert html =~ "bg-gray-700"
+ refute html =~ ~s(aria-current="page")
+ end
+
+ test "base_path builds patch links preserving custom query params" do
+ meta = FlopFixtures.meta_fixture(page: 1, page_size: 50, total_count: 120)
+
+ html =
+ render_component(&FlopList.flop_pagination/1, %{
+ meta: meta,
+ base_path: "/videos",
+ query: %{
+ "sort_by" => "updated_at",
+ "sort_dir" => "desc",
+ "per_page" => 50
+ },
+ mode: :simple
+ })
+
+ assert html =~ "sort_by=updated_at"
+ assert html =~ "sort_dir=desc"
+ assert html =~ "per_page=50"
+ end
+
+ test "full mode renders numbered page links" do
+ meta = FlopFixtures.meta_fixture(page: 5, page_size: 20, total_count: 200)
+
+ html =
+ render_component(&FlopList.flop_pagination/1, %{
+ id: "failures-flop-pagination",
+ meta: meta,
+ path: "/failures",
+ mode: :full,
+ page_links: 5
+ })
+
+ assert html =~ "id=\"failures-flop-pagination\""
+ assert html =~ ~s(data-role="flop-pagination-page")
+ assert html =~ "bg-blue-600"
+ end
+
+ test "simple mode disables previous on first page" do
+ meta = FlopFixtures.meta_fixture(page: 1, page_size: 10, total_count: 25)
+
+ html =
+ render_component(&FlopList.flop_pagination/1, %{
+ meta: meta,
+ path: "/videos",
+ mode: :simple
+ })
+
+ assert html =~ "disabled"
+ assert html =~ "opacity-40"
+ end
+ end
+
+ describe "pagination_label/1" do
+ test "delegates to range label from meta" do
+ meta = FlopFixtures.meta_fixture(page: 1, page_size: 10, total_count: 5)
+
+ assert FlopList.pagination_label(meta) == "1-5 of 5"
+ end
+
+ test "clamps oversized current_page before building the label" do
+ meta = %Flop.Meta{current_page: 999_999, page_size: 10, total_count: 25}
+
+ assert FlopList.pagination_label(meta) == "21-25 of 25"
+ end
+ end
+
+ describe "patch_with_page/3" do
+ test "omits page param on first page" do
+ url = FlopList.patch_with_page("/videos", %{"per_page" => 50}, 1)
+
+ assert %URI{path: "/videos", query: query} = URI.parse(url)
+ assert URI.decode_query(query) == %{"per_page" => "50"}
+ end
+
+ test "includes page param when not on first page" do
+ url = FlopList.patch_with_page("/videos", %{"per_page" => 50}, 2)
+
+ assert %URI{path: "/videos", query: query} = URI.parse(url)
+ assert URI.decode_query(query) == %{"page" => "2", "per_page" => "50"}
+ end
+
+ test "returns bare path when merged query is empty" do
+ assert FlopList.patch_with_page("/bad-files", %{}, 1) == "/bad-files"
+ end
+ end
+
+ describe "parse_page/3" do
+ test "clamps page to total pages when total and per_page given" do
+ assert FlopList.parse_page(%{"page" => "99"}, 1, total: 25, per_page: 10) == 3
+ end
+ end
+
+ describe "parse_per_page/3" do
+ test "falls back to default for invalid per_page" do
+ assert FlopList.parse_per_page(%{"per_page" => "13"}, 25, [25, 50, 100]) == 25
+ end
+ end
+
+ describe "pagination_assigns/4" do
+ test "returns assign-ready page and per_page values" do
+ assert FlopList.pagination_assigns(%{"page" => "4", "per_page" => "50"}, 25, [25, 50]) ==
+ %{page: 4, per_page: 50}
+ end
+
+ test "coerces invalid values to safe defaults" do
+ assert FlopList.pagination_assigns(%{"page" => "0", "per_page" => "13"}, 25, [25, 50]) ==
+ %{page: 1, per_page: 25}
+ end
+ end
+
+ describe "total_pages/2" do
+ test "returns at least one page" do
+ assert FlopList.total_pages(0, 10) == 1
+ assert FlopList.total_pages(21, 10) == 3
+ end
+ end
+end
diff --git a/test/reencodarr_web/live/list_pagination_test.exs b/test/reencodarr_web/live/list_pagination_test.exs
deleted file mode 100644
index d9ef8486..00000000
--- a/test/reencodarr_web/live/list_pagination_test.exs
+++ /dev/null
@@ -1,111 +0,0 @@
-defmodule ReencodarrWeb.Live.ListPaginationTest do
- @moduledoc """
- Tests for the ListPagination helper module.
- """
- use ExUnit.Case, async: true
-
- alias ReencodarrWeb.Live.ListPagination
-
- describe "max_page/2" do
- test "returns 1 for zero total items" do
- assert ListPagination.max_page(0, 10) == 1
- end
-
- test "returns 1 when items fit in one page" do
- assert ListPagination.max_page(10, 10) == 1
- end
-
- test "returns 1 for fewer items than per_page" do
- assert ListPagination.max_page(5, 10) == 1
- end
-
- test "returns 2 when one item overflows to second page" do
- assert ListPagination.max_page(11, 10) == 2
- end
-
- test "returns 2 when items fill exactly two pages" do
- assert ListPagination.max_page(20, 10) == 2
- end
-
- test "returns 3 when one item overflows to third page" do
- assert ListPagination.max_page(21, 10) == 3
- end
-
- test "returns correct page count for large datasets" do
- assert ListPagination.max_page(100, 10) == 10
- assert ListPagination.max_page(250, 50) == 5
- assert ListPagination.max_page(1000, 25) == 40
- end
-
- test "always returns at least 1 even with 0 items" do
- assert ListPagination.max_page(0, 1) >= 1
- assert ListPagination.max_page(0, 100) >= 1
- end
-
- test "handles single item per page" do
- assert ListPagination.max_page(1, 1) == 1
- assert ListPagination.max_page(5, 1) == 5
- assert ListPagination.max_page(10, 1) == 10
- end
-
- test "result is always a positive integer" do
- for total <- [0, 1, 5, 10, 100, 999],
- per_page <- [1, 10, 25, 50, 100] do
- result = ListPagination.max_page(total, per_page)
- assert is_integer(result), "Expected integer for max_page(#{total}, #{per_page})"
- assert result >= 1, "Expected >= 1 for max_page(#{total}, #{per_page})"
- end
- end
- end
-
- describe "pagination_label/3" do
- test "returns '0 results' when total is 0" do
- assert ListPagination.pagination_label(1, 10, 0) == "0 results"
- end
-
- test "returns range for first page with full page of items" do
- assert ListPagination.pagination_label(1, 10, 10) == "1-10 of 10"
- end
-
- test "returns range for first page with partial items" do
- assert ListPagination.pagination_label(1, 10, 5) == "1-5 of 5"
- end
-
- test "returns correct range for second page" do
- assert ListPagination.pagination_label(2, 10, 15) == "11-15 of 15"
- end
-
- test "returns correct range for second full page" do
- assert ListPagination.pagination_label(2, 10, 20) == "11-20 of 20"
- end
-
- test "last page with partial items shows correct range" do
- assert ListPagination.pagination_label(3, 10, 25) == "21-25 of 25"
- end
-
- test "first item on a page is always (page-1)*per_page + 1" do
- # Page 1: first = 1
- assert ListPagination.pagination_label(1, 25, 30) =~ "1-"
- # Page 2: first = 26
- assert ListPagination.pagination_label(2, 25, 30) =~ "26-"
- end
-
- test "total is always shown correctly after 'of'" do
- assert ListPagination.pagination_label(1, 10, 42) =~ "of 42"
- assert ListPagination.pagination_label(2, 10, 42) =~ "of 42"
- end
-
- test "last item does not exceed total" do
- # Page 5, per_page 10, total 42 → should show 41-42 not 41-50
- assert ListPagination.pagination_label(5, 10, 42) == "41-42 of 42"
- end
-
- test "with per_page=20 defaults as in failures_live" do
- # Matches FailuresLive @per_page default
- assert ListPagination.pagination_label(1, 20, 0) == "0 results"
- assert ListPagination.pagination_label(1, 20, 15) == "1-15 of 15"
- assert ListPagination.pagination_label(1, 20, 20) == "1-20 of 20"
- assert ListPagination.pagination_label(2, 20, 25) == "21-25 of 25"
- end
- end
-end
diff --git a/test/reencodarr_web/live/videos_live_test.exs b/test/reencodarr_web/live/videos_live_test.exs
index 4e179ade..c5e383bc 100644
--- a/test/reencodarr_web/live/videos_live_test.exs
+++ b/test/reencodarr_web/live/videos_live_test.exs
@@ -117,7 +117,7 @@ defmodule ReencodarrWeb.VideosLiveTest do
assert_patch(
view,
- "/videos?page=1&per_page=50&sort_by=updated_at&sort_dir=desc&state=encoded"
+ "/videos?state=encoded"
)
end
@@ -190,12 +190,13 @@ defmodule ReencodarrWeb.VideosLiveTest do
# ---------------------------------------------------------------------------
describe "pagination" do
- test "prev_page button is disabled on page 1", %{conn: conn} do
+ test "renders flop pagination on page 1", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/videos?page=1")
html = render(view)
- # Button is rendered as disabled when already on first page
- assert html =~ "phx-click=\"prev_page\""
- assert html =~ "disabled"
+
+ assert html =~ ~s(data-role="flop-pagination")
+ assert html =~ ~s(data-role="flop-pagination-label")
+ assert html =~ "0 results"
end
test "per_page event updates items per page", %{conn: conn} do
@@ -217,7 +218,25 @@ defmodule ReencodarrWeb.VideosLiveTest do
|> form("form[phx-change='set_per_page']", %{per_page: "100"})
|> render_change()
- assert_patch(view, "/videos?page=1&per_page=100&sort_by=updated_at&sort_dir=desc")
+ assert_patch(view, "/videos?per_page=100")
+ end
+
+ test "page beyond total clamps to last page", %{conn: conn} do
+ Enum.each(1..26, fn n ->
+ {:ok, _video} = Fixtures.video_fixture(%{path: "/media/videos_clamp_#{n}.mkv"})
+ end)
+
+ {:ok, view, _html} = live(conn, ~p"/videos?page=999&per_page=25")
+ html = render_async(view)
+
+ assert html =~ "26-26 of 26"
+ end
+
+ test "empty search clamps impossible page to page 1", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/videos?q=no_such_video&page=999")
+ html = render_async(view)
+
+ assert html =~ "0 results"
end
end
@@ -339,7 +358,8 @@ defmodule ReencodarrWeb.VideosLiveTest do
assert html =~ "Marked as bad"
- [issue] = Reencodarr.Media.list_bad_file_issues()
+ {issues, _} = Reencodarr.Media.list_bad_file_issues(%{"page_size" => "250"})
+ [issue] = issues
assert issue.video_id == video.id
assert issue.issue_kind == :manual
assert issue.classification == :manual_bad
@@ -366,7 +386,8 @@ defmodule ReencodarrWeb.VideosLiveTest do
|> render_submit()
assert html =~ "Mark bad failed"
- assert Reencodarr.Media.list_bad_file_issues() == []
+ {issues, _} = Reencodarr.Media.list_bad_file_issues(%{"page_size" => "250"})
+ assert issues == []
end
end
@@ -457,10 +478,7 @@ defmodule ReencodarrWeb.VideosLiveTest do
|> form("#videos-filters", %{service: "sonarr"})
|> render_change()
- assert_patch(
- view,
- "/videos?page=1&per_page=50&service=sonarr&sort_by=updated_at&sort_dir=desc"
- )
+ assert_patch(view, "/videos?service=sonarr")
end
end
@@ -487,7 +505,7 @@ defmodule ReencodarrWeb.VideosLiveTest do
|> form("#videos-filters", %{hdr: "true"})
|> render_change()
- assert_patch(view, "/videos?hdr=true&page=1&per_page=50&sort_by=updated_at&sort_dir=desc")
+ assert_patch(view, "/videos?hdr=true")
end
end
diff --git a/test/support/flop_fixtures.ex b/test/support/flop_fixtures.ex
new file mode 100644
index 00000000..c6696be9
--- /dev/null
+++ b/test/support/flop_fixtures.ex
@@ -0,0 +1,41 @@
+defmodule Reencodarr.FlopFixtures do
+ @moduledoc false
+
+ alias Reencodarr.Media.Video
+
+ @doc """
+ Builds a `%Flop.Meta{}` for component and LiveView tests without hitting the DB.
+ """
+ @spec meta_fixture(keyword()) :: Flop.Meta.t()
+ def meta_fixture(opts \\ []) do
+ page = Keyword.get(opts, :page, 1)
+ page_size = Keyword.get(opts, :page_size, 20)
+ total_count = Keyword.get(opts, :total_count, 0)
+ total_pages = total_pages(total_count, page_size)
+ current_page = min(max(page, 1), max(total_pages, 1))
+
+ %Flop.Meta{
+ schema: Keyword.get(opts, :schema, Video),
+ current_page: current_page,
+ page_size: page_size,
+ total_count: total_count,
+ total_pages: total_pages,
+ previous_page: if(current_page > 1, do: current_page - 1),
+ next_page: if(current_page < total_pages, do: current_page + 1),
+ has_previous_page?: current_page > 1,
+ has_next_page?: current_page < total_pages,
+ flop: %Flop{
+ offset: (current_page - 1) * page_size,
+ limit: page_size,
+ page: current_page,
+ page_size: page_size
+ }
+ }
+ end
+
+ defp total_pages(0, _page_size), do: 0
+
+ defp total_pages(total_count, page_size) when total_count > 0 do
+ div(total_count + page_size - 1, page_size)
+ end
+end
diff --git a/test/support/flop_list_test_helpers.ex b/test/support/flop_list_test_helpers.ex
new file mode 100644
index 00000000..9c7e3d83
--- /dev/null
+++ b/test/support/flop_list_test_helpers.ex
@@ -0,0 +1,57 @@
+defmodule ReencodarrWeb.FlopListTestHelpers do
+ @moduledoc false
+
+ import Phoenix.LiveViewTest
+
+ @spec pagination_label_from_html(String.t()) :: String.t() | nil
+ def pagination_label_from_html(html) do
+ case Regex.run(~r/data-role="flop-pagination-label"[^>]*>([^<]+)/, html) do
+ [_, label] -> String.trim(label)
+ _ -> nil
+ end
+ end
+
+ @spec current_page_from_html(String.t()) :: pos_integer() | nil
+ def current_page_from_html(html) do
+ case Regex.run(~r/aria-current="page"[^>]*>\s*(\d+)/s, html) do
+ [_, page] -> String.to_integer(page)
+ _ -> if String.contains?(html, ~s(data-role="flop-pagination")), do: 1, else: nil
+ end
+ end
+
+ @spec current_page_from_meta(Flop.Meta.t()) :: pos_integer()
+ def current_page_from_meta(%Flop.Meta{current_page: page}) when is_integer(page), do: page
+ def current_page_from_meta(_meta), do: 1
+
+ @spec total_pages_from_html(String.t()) :: pos_integer()
+ def total_pages_from_html(html) do
+ case Regex.scan(~r/data-role="flop-pagination-page"/, html) do
+ [] -> 1
+ pages -> length(pages)
+ end
+ end
+
+ @spec assert_filter_in_url(String.t(), String.t(), String.t()) :: :ok
+ def assert_filter_in_url(path, key, value) do
+ query = path |> URI.parse() |> Map.get(:query, "") |> URI.decode_query()
+
+ if Map.get(query, key) != value do
+ raise ExUnit.AssertionError,
+ "expected #{inspect(key)}=#{inspect(value)} in #{path}, got #{inspect(query)}"
+ end
+
+ :ok
+ end
+
+ @spec assert_flop_patch(Phoenix.LiveViewTest.view(), String.t()) :: :ok
+ def assert_flop_patch(view, expected_path) do
+ assert_patch(view, expected_path)
+ end
+
+ @spec click_flop_next(Phoenix.LiveViewTest.view()) :: String.t()
+ def click_flop_next(view) do
+ view
+ |> element("a[data-role='flop-pagination-next']")
+ |> render_click()
+ end
+end