[Enhancement] Improve handling of large media collections (#272)

* Added compound index for updated media item table for performance

* Improved large number display on homepage

* Improved UI around large numbers

* Centered the content of homepage data cards

* Renamed migration
This commit is contained in:
Kieran 2024-05-29 12:38:03 -07:00 committed by GitHub
parent 22439e0273
commit e751c65424
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 146 additions and 42 deletions

View file

@ -2,7 +2,8 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
@moduledoc false @moduledoc false
use Phoenix.Component use Phoenix.Component
alias PinchflatWeb.CoreComponents import PinchflatWeb.CoreComponents
import PinchflatWeb.CustomComponents.TextComponents
@doc """ @doc """
Renders a table component with the given rows and columns. Renders a table component with the given rows and columns.
@ -76,11 +77,13 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
phx-click={@page_number != 1 && "page_change"} phx-click={@page_number != 1 && "page_change"}
phx-value-direction="dec" phx-value-direction="dec"
> >
<CoreComponents.icon name="hero-chevron-left" /> <.icon name="hero-chevron-left" />
</span> </span>
</li> </li>
<li> <li>
<span class="mx-2">Page <%= @page_number %> of <%= @total_pages %></span> <span class="mx-2">
Page <.localized_number number={@page_number} /> of <.localized_number number={@total_pages} />
</span>
</li> </li>
<li> <li>
<span <span
@ -92,7 +95,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
phx-click={@page_number != @total_pages && "page_change"} phx-click={@page_number != @total_pages && "page_change"}
phx-value-direction="inc" phx-value-direction="inc"
> >
<CoreComponents.icon name="hero-chevron-right" /> <.icon name="hero-chevron-right" />
</span> </span>
</li> </li>
</ul> </ul>

View file

@ -94,4 +94,28 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
<time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time> <time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time>
""" """
end end
@doc """
Renders a localized number using the Intl.NumberFormat API, falling back to the raw number if needed
"""
attr :number, :any, required: true
def localized_number(assigns) do
~H"""
<span x-data x-text={"Intl.NumberFormat().format(#{@number})"}><%= @number %></span>
"""
end
@doc """
Renders a word with a suffix if the count is not 1
"""
attr :word, :string, required: true
attr :count, :integer, required: true
attr :suffix, :string, default: "s"
def pluralize(assigns) do
~H"""
<%= @word %><%= if @count == 1, do: "", else: @suffix %>
"""
end
end end

View file

@ -5,9 +5,19 @@ defmodule PinchflatWeb.Pages.PageHTML do
embed_templates "page_html/*" embed_templates "page_html/*"
def readable_media_filesize(media_filesize) do attr :media_filesize, :integer, required: true
{num, suffix} = NumberUtils.human_byte_size(media_filesize, precision: 1)
"#{Float.round(num)} #{suffix}" def readable_media_filesize(assigns) do
{num, suffix} = NumberUtils.human_byte_size(assigns.media_filesize, precision: 2)
assigns =
Map.merge(assigns, %{
num: num,
suffix: suffix
})
~H"""
<.localized_number number={@num} /> <%= @suffix %>
"""
end end
end end

View file

@ -22,7 +22,9 @@ defmodule Pinchflat.Pages.HistoryTableLive do
<div> <div>
<span class="mb-4 flex items-center"> <span class="mb-4 flex items-center">
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" /> <.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
<span class="ml-2">Showing <%= length(@records) %> of <%= @total_record_count %></span> <span class="ml-2">
Showing <.localized_number number={length(@records)} /> of <.localized_number number={@total_record_count} />
</span>
</span> </span>
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.table rows={@records} table_class="text-white"> <.table rows={@records} table_class="text-white">

View file

@ -1,33 +1,39 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark"> <div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
<a href={~p"/media_profiles"} class="mt-4 flex flex-col items-center justify-center"> <a href={~p"/media_profiles"} class="flex flex-col items-center py-2">
<span class="text-md font-medium">Media Profile(s)</span> <span class="text-md font-medium">
Media <.pluralize count={@media_profile_count} word="Profile" />
</span>
<h4 class="text-title-md font-bold text-white"> <h4 class="text-title-md font-bold text-white">
<%= @media_profile_count %> <.localized_number number={@media_profile_count} />
</h4> </h4>
</a> </a>
</div> </div>
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark"> <div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
<a href={~p"/sources"} class="mt-4 flex flex-col items-center justify-center"> <a href={~p"/sources"} class="flex flex-col items-center py-2">
<span class="text-md font-medium">Source(s)</span> <span class="text-md font-medium">
<.pluralize count={@source_count} word="Source" />
</span>
<h4 class="text-title-md font-bold text-white"> <h4 class="text-title-md font-bold text-white">
<%= @source_count %> <.localized_number number={@source_count} />
</h4> </h4>
</a> </a>
</div> </div>
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark"> <div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
<span class="mt-4 flex flex-col items-center justify-center"> <span class="flex flex-col items-center py-2">
<span class="text-md font-medium">Downloaded Media</span> <span class="text-md font-medium">
<.pluralize count={@media_item_count} word="Download" />
</span>
<h4 class="text-title-md font-bold text-white"> <h4 class="text-title-md font-bold text-white">
<%= @media_item_count %> <.localized_number number={@media_item_count} />
</h4> </h4>
</span> </span>
</div> </div>
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark"> <div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
<span class="mt-4 flex flex-col items-center justify-center"> <span class="flex flex-col items-center py-2">
<span class="text-md font-medium">Library Size</span> <span class="text-md font-medium">Library Size</span>
<h4 class="text-title-md font-bold text-white"> <h4 class="text-title-md font-bold text-white">
<%= readable_media_filesize(@media_item_size) %> <.readable_media_filesize media_filesize={@media_item_size} />
</h4> </h4>
</span> </span>
</div> </div>

View file

@ -19,11 +19,12 @@
</.subtle_link> </.subtle_link>
</:col> </:col>
<:col :let={source} label="Type"><%= source.collection_type %></:col> <:col :let={source} label="Type"><%= source.collection_type %></:col>
<:col :let={source} label="Pending"><%= source.pending_count %></:col> <:col :let={source} label="Pending"><.localized_number number={source.pending_count} /></:col>
<:col :let={source} label="Downloaded"><%= source.downloaded_count %></:col> <:col :let={source} label="Downloaded"><.localized_number number={source.downloaded_count} /></:col>
<:col :let={source} label="Retention"> <:col :let={source} label="Retention">
<%= if source.retention_period_days && source.retention_period_days > 0 do %> <%= if source.retention_period_days && source.retention_period_days > 0 do %>
<%= source.retention_period_days %> day(s) <.localized_number number={source.retention_period_days} />
<.pluralize count={source.retention_period_days} word="day" />
<% else %> <% else %>
<span class="text-lg">∞</span> <span class="text-lg">∞</span>
<% end %> <% end %>

View file

@ -23,7 +23,9 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
<header class="flex justify-between items-center mb-4"> <header class="flex justify-between items-center mb-4">
<span class="flex items-center"> <span class="flex items-center">
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" /> <.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
<span class="ml-2">Showing <%= length(@records) %> of <%= @filtered_record_count %></span> <span class="ml-2">
Showing <.localized_number number={length(@records)} /> of <.localized_number number={@filtered_record_count} />
</span>
</span> </span>
<div class="bg-meta-4 rounded-md"> <div class="bg-meta-4 rounded-md">
<div class="relative"> <div class="relative">
@ -49,12 +51,12 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
<%= StringUtils.truncate(media_item.title, 50) %> <%= StringUtils.truncate(media_item.title, 50) %>
</.subtle_link> </.subtle_link>
</:col> </:col>
<:col :let={media_item} label="Upload Date">
<%= DateTime.to_date(media_item.uploaded_at) %>
</:col>
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?"> <:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?">
<.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} /> <.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} />
</:col> </:col>
<:col :let={media_item} label="Upload Date">
<%= DateTime.to_date(media_item.uploaded_at) %>
</:col>
<:col :let={media_item} label="" class="flex justify-end"> <:col :let={media_item} label="" class="flex justify-end">
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" /> <.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" />
</:col> </:col>
@ -117,14 +119,40 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
{:noreply, assign(socket, new_assigns)} {:noreply, assign(socket, new_assigns)}
end end
defp fetch_pagination_attributes(base_query, page, ""), do: fetch_pagination_attributes(base_query, page, nil)
defp fetch_pagination_attributes(base_query, page, nil) do
total_record_count = Repo.aggregate(base_query, :count, :id)
total_pages = max(ceil(total_record_count / @limit), 1)
page = NumberUtils.clamp(page, 1, total_pages)
records =
fetch_records(base_query, page)
|> order_by(desc: :uploaded_at)
|> Repo.all()
%{
page: page,
total_pages: total_pages,
records: records,
search_term: nil,
total_record_count: total_record_count,
filtered_record_count: total_record_count
}
end
defp fetch_pagination_attributes(base_query, page, search_term) do defp fetch_pagination_attributes(base_query, page, search_term) do
filtered_base_query = filter_base_query(base_query, search_term) filtered_base_query = filtered_base_query(base_query, search_term)
total_record_count = Repo.aggregate(base_query, :count, :id) total_record_count = Repo.aggregate(base_query, :count, :id)
filtered_record_count = Repo.aggregate(filtered_base_query, :count, :id) filtered_record_count = Repo.aggregate(filtered_base_query, :count, :id)
total_pages = max(ceil(filtered_record_count / @limit), 1) total_pages = max(ceil(filtered_record_count / @limit), 1)
page = NumberUtils.clamp(page, 1, total_pages) page = NumberUtils.clamp(page, 1, total_pages)
records = fetch_records(filtered_base_query, page)
records =
fetch_records(filtered_base_query, page)
|> order_by(desc: fragment("rank"), desc: :uploaded_at)
|> Repo.all()
%{ %{
page: page, page: page,
@ -142,39 +170,41 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
base_query base_query
|> limit(^@limit) |> limit(^@limit)
|> offset(^offset) |> offset(^offset)
|> Repo.all()
end end
defp generate_base_query(source, "pending") do defp generate_base_query(source, "pending") do
MediaQuery.new() MediaQuery.new()
|> select(^select_fields())
|> MediaQuery.require_assoc(:media_profile) |> MediaQuery.require_assoc(:media_profile)
|> MediaQuery.require_assoc(:media_items_search_index)
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending())) |> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|> order_by(desc: fragment("rank"), desc: :uploaded_at)
end end
defp generate_base_query(source, "downloaded") do defp generate_base_query(source, "downloaded") do
MediaQuery.new() MediaQuery.new()
|> MediaQuery.require_assoc(:media_items_search_index) |> select(^select_fields())
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded())) |> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|> order_by(desc: fragment("rank"), desc: :uploaded_at)
end end
defp generate_base_query(source, "other") do defp generate_base_query(source, "other") do
MediaQuery.new() MediaQuery.new()
|> select(^select_fields())
|> MediaQuery.require_assoc(:media_profile) |> MediaQuery.require_assoc(:media_profile)
|> MediaQuery.require_assoc(:media_items_search_index)
|> where( |> where(
^dynamic( ^dynamic(
^MediaQuery.for_source(source) and ^MediaQuery.for_source(source) and
(not (^MediaQuery.downloaded()) and not (^MediaQuery.pending())) (not (^MediaQuery.downloaded()) and not (^MediaQuery.pending()))
) )
) )
|> order_by(desc: fragment("rank"), desc: :uploaded_at)
end end
defp filter_base_query(base_query, search_term) do defp filtered_base_query(base_query, search_term) do
base_query base_query
|> MediaQuery.require_assoc(:media_items_search_index)
|> where(^MediaQuery.matches_search_term(search_term)) |> where(^MediaQuery.matches_search_term(search_term))
end end
# Selecting only what we need GREATLY speeds up queries on large tables
defp select_fields do
[:id, :title, :uploaded_at, :prevent_download]
end
end end

View file

@ -95,7 +95,8 @@
type="number" type="number"
label="Retention Period (days)" label="Retention Period (days)"
min="0" min="0"
help="Days between when media is *downloaded* and when it's deleted. Leave blank to keep media indefinitely" help="Days between when media is <em>downloaded</em> and when it's deleted. Leave blank to keep media indefinitely"
html_help={true}
/> />
<section x-show="advancedMode"> <section x-show="advancedMode">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

After

Width:  |  Height:  |  Size: 471 KiB

View file

@ -0,0 +1,8 @@
defmodule Pinchflat.Repo.Migrations.ModifyUploadDateIndex do
use Ecto.Migration
def change do
drop index("media_items", [:upload_date])
create index("media_items", [:uploaded_at])
end
end

View file

@ -0,0 +1,19 @@
defmodule Pinchflat.Repo.Migrations.AddIndexesForLargeCollections do
use Ecto.Migration
def change do
create index(
"media_items",
[
:source_id,
:media_filepath,
:uploaded_at,
:prevent_download,
:livestream,
:short_form_content,
:title
],
name: "media_items_pending_and_downloaded_index"
)
end
end

View file

@ -27,7 +27,7 @@ defmodule PinchflatWeb.Sources.MediaItemTableLiveTest do
{:ok, _view, html} = live_isolated(conn, MediaItemTableLive, session: create_session(source)) {:ok, _view, html} = live_isolated(conn, MediaItemTableLive, session: create_session(source))
assert html =~ "Showing 1 of 1" assert html =~ "Showing"
assert html =~ "Title" assert html =~ "Title"
assert html =~ media_item.title assert html =~ media_item.title
end end