[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:
parent
22439e0273
commit
e751c65424
12 changed files with 146 additions and 42 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue