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/config/config.exs b/config/config.exs index ee4a9ec6..ab9602bb 100644 --- a/config/config.exs +++ b/config/config.exs @@ -68,24 +68,29 @@ config :reencodarr, ReencodarrWeb.Endpoint, # Configure esbuild (the version is required) config :esbuild, - version: "0.17.11", + version: "0.28.1", reencodarr: [ args: - ~w(js/app.js --bundle --target=es2020 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --legal-comments=none), + ~w(js/app.js --bundle --target=es2020 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --legal-comments=none --alias:@=.), cd: Path.expand("../assets", __DIR__), - env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + env: %{ + "NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Mix.Project.build_path()], ":") + } ] # Configure tailwind (the version is required) config :tailwind, - version: "3.4.3", + version: "3.4.19", reencodarr: [ args: ~w( --config=tailwind.config.js --input=css/app.css --output=../priv/static/assets/app.css ), - cd: Path.expand("../assets", __DIR__) + cd: Path.expand("../assets", __DIR__), + env: %{ + "NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Mix.Project.build_path()], ":") + } ] # Configures Elixir's Logger 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..0ccdc7ea 100644 --- a/lib/reencodarr/media.ex +++ b/lib/reencodarr/media.ex @@ -350,30 +350,85 @@ 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", bad_file_order_by(statuses)) + |> 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 + ["asc"] + else + ["desc", "desc"] + end + end + + defp bad_file_order_by(statuses) do + if Enum.all?(statuses, &(&1 in @resolved_bad_file_issue_statuses)) do + ["id"] + else + ["updated_at", "id"] + 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 +617,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 +932,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 bad_file_kind_filter(query, "all"), 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, 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 maybe_filter_bad_file_issue_service(query, _service), do: query + 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_kind(query, "all"), 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, 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_kind_param(kind) when kind in ~w(all audio manual), do: kind + defp bad_file_kind_param(_kind), do: "all" + + 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 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 failure_normalize_search(search) when is_binary(search), do: String.trim(search) + defp failure_normalize_search(_search), do: "" + + 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 - defp maybe_filter_bad_file_issue_kind(query, _kind), do: query + 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, ""), 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_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 failure_join_filters(query, "all", "all"), do: query - 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 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_filter_bad_file_issue_search(query, _search), do: query + defp failure_stage_filter(query, "all"), do: query - defp maybe_limit_bad_file_issues(query, limit) when is_integer(limit) and limit > 0 do - from i in query, limit: ^limit + 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_limit_bad_file_issues(query, _limit), do: query + defp failure_category_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_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 maybe_offset_bad_file_issues(query, _offset), do: query + defp video_failure_stage_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 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 +1276,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`. @@ -2408,6 +2688,43 @@ defmodule Reencodarr.Media do service_type = Keyword.get(opts, :service_type, nil) |> normalize_video_filter(:service_type) hdr = Keyword.get(opts, :hdr, nil) + with search when is_binary(search) <- search, + fts_query when not is_nil(fts_query) <- search_to_fts_query(search) do + list_videos_search_paginated( + page, + per_page, + state_filter, + fts_query, + sort_by, + sort_dir, + service_type, + hdr + ) + else + _ -> + list_videos_paginated_query( + page, + per_page, + state_filter, + search, + sort_by, + sort_dir, + service_type, + hdr + ) + end + end + + defp list_videos_paginated_query( + page, + per_page, + state_filter, + search, + sort_by, + sort_dir, + service_type, + hdr + ) do filters = build_flop_filters(state_filter, service_type) flop_params = %{ @@ -2508,6 +2825,130 @@ defmodule Reencodarr.Media do defp maybe_put_count(opts, count) when is_integer(count), do: Keyword.put(opts, :count, count) defp maybe_put_count(opts, _count), do: opts + defp list_videos_search_paginated( + page, + per_page, + state_filter, + fts_query, + sort_by, + sort_dir, + service_type, + hdr + ) do + {sort_column, sort_direction, sort_atom, direction_atom} = + video_search_sort(sort_by, sort_dir) + + {where_sql, params} = + video_search_where(state_filter, service_type, hdr, ["videos_search MATCH ?"], [fts_query]) + + total_count = + Repo.query!( + "SELECT count(*) FROM videos_search JOIN videos v ON v.id = videos_search.rowid #{where_sql}", + params + ).rows + |> List.first() + |> List.first() + + offset = (page - 1) * per_page + + ids = + Repo.query!( + """ + SELECT v.id + FROM videos_search + JOIN videos v ON v.id = videos_search.rowid + #{where_sql} + ORDER BY #{sort_column} #{sort_direction}, v.id #{sort_direction} + LIMIT ? OFFSET ? + """, + params ++ [per_page, offset] + ).rows + |> List.flatten() + + videos_by_id = + Video + |> where([v], v.id in ^ids) + |> select([v], struct(v, ^@video_list_fields)) + |> Repo.all() + |> Map.new(&{&1.id, &1}) + + videos = Enum.map(ids, &Map.fetch!(videos_by_id, &1)) + {videos, video_search_meta(page, per_page, total_count, sort_atom, direction_atom)} + end + + defp video_search_sort(sort_by, sort_dir) do + sort_columns = %{ + path: "v.path", + state: "v.state", + size: "v.size", + updated_at: "v.updated_at", + width: "v.width", + bitrate: "v.bitrate", + priority: "v.priority" + } + + {sort_atom, sort_column} = + if Map.has_key?(sort_columns, sort_by) do + {sort_by, Map.fetch!(sort_columns, sort_by)} + else + {:updated_at, "v.updated_at"} + end + + direction_atom = if sort_dir == :asc, do: :asc, else: :desc + sort_direction = if direction_atom == :asc, do: "ASC", else: "DESC" + + {sort_column, sort_direction, sort_atom, direction_atom} + end + + defp video_search_where(state_filter, service_type, hdr, clauses, params) do + {clauses, params} = + if is_nil(state_filter) do + {clauses, params} + else + {["v.state = ?" | clauses], [Atom.to_string(state_filter) | params]} + end + + {clauses, params} = + if is_nil(service_type) do + {clauses, params} + else + {["v.service_type = ?" | clauses], [Atom.to_string(service_type) | params]} + end + + clauses = + case hdr do + true -> ["v.hdr IS NOT NULL" | clauses] + false -> ["v.hdr IS NULL" | clauses] + _ -> clauses + end + + {"WHERE " <> Enum.join(Enum.reverse(clauses), " AND "), Enum.reverse(params)} + end + + defp video_search_meta(page, per_page, total_count, sort_by, sort_dir) do + total_pages = if total_count == 0, do: 0, else: div(total_count + per_page - 1, per_page) + + %Flop.Meta{ + schema: Video, + current_page: page, + page_size: per_page, + total_count: total_count, + total_pages: total_pages, + previous_page: if(page > 1, do: page - 1), + next_page: if(page < total_pages, do: page + 1), + has_previous_page?: page > 1, + has_next_page?: page < total_pages, + flop: %Flop{ + offset: (page - 1) * per_page, + limit: per_page, + page: page, + page_size: per_page, + order_by: [sort_by], + order_directions: [sort_dir] + } + } + end + defp find_video_state(state_filter) when is_atom(state_filter) do Enum.find(VideoStateMachine.valid_states(), &(&1 == state_filter)) end @@ -2547,15 +2988,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 +3001,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..24ab33cd 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,311 @@ 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""" +
| + File + | ++ Reason + | ++ Status + | ++ Actions + | +
|---|
Recent resolved issues are loaded on demand.
+ +{@tracked_count} tracked
-| - File - | -- Reason - | -- Status - | -- Actions - | -
|---|
- Recent resolved issues are loaded on demand. -
- -| - File - | -- Reason - | -- Status - | -- Actions - | -
|---|
Loading failure data...
+ Retry all failed videos whose unresolved failures include the selected code by sending them back to analysis. +
++ <%= if @search_term != "" do %> + No failed videos match your search criteria + <% else %> + All videos are processing successfully + <% end %> +
+- Retry all failed videos whose unresolved failures include the selected code by sending them back to analysis. -
-- <%= if @search_term != "" do %> - No failed videos match your search criteria +
{format_command_output(
- Map.get(latest.system_context, "full_output")
- )}
- {format_command_output(
+ Map.get(latest.system_context, "full_output")
+ )}
+ {@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} + /> +{@total} total
+Loading...
+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 - -|
+ <%= if length(@videos) > 0 do %>
-
-
-
-
-
-
-
-
- <% else %>
- <%= if @loading do %>
-
-
- Loading... -Refreshing results... + <% end %> + |
+ <.col_header
+ col={:path}
+ label="File"
+ sort_by={@sort_by}
+ sort_dir={@sort_dir}
+ class="w-full"
+ />
+ <.col_header col={:state} label="State" sort_by={@sort_by} sort_dir={@sort_dir} />
+ <.col_header col={:size} label="Size" sort_by={@sort_by} sort_dir={@sort_dir} />
+ <.col_header
+ col={:updated_at}
+ label="Updated"
+ sort_by={@sort_by}
+ sort_dir={@sort_dir}
+ />
+ + Actions + | +||||
|---|---|---|---|---|---|
| - <%= if length(@videos) > 0 do %> - - <% end %> - | - <.col_header - col={:path} - label="File" - sort_by={@sort_by} - sort_dir={@sort_dir} - class="w-full" - /> - <.col_header col={:state} label="State" sort_by={@sort_by} sort_dir={@sort_dir} /> - <.col_header col={:size} label="Size" sort_by={@sort_by} sort_dir={@sort_dir} /> - <.col_header - col={:updated_at} - label="Updated" - sort_by={@sort_by} - sort_dir={@sort_dir} - /> -- Actions - | -||||
|---|---|---|---|---|---|
| - - | -
- {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)} - | -
-
-
-
- <%= if queueable_video?(video) do %>
-
- <% end %>
- <%= if queueable_video?(video) and season_directory(video.path) do %>
-
- <% end %>
- <%= if fail_action_video?(video) do %>
-
- <% end %>
-
- <%= if video.state in [:failed, :encoded, :crf_searched, :analyzed] do %>
-
- <% end %>
-
-
-
- <%= if video.id in @expanded_bad_forms do %>
-
-
-
-
-
-
- |
-
| - No videos match the current filters. - | -|||||