[Enhancement] Improve ordering of models (#164)

* [WIP] ordering app queries

* Refactored media queries to be self-contained
This commit is contained in:
Kieran 2024-04-03 17:26:46 -07:00 committed by GitHub
parent 4b12764f45
commit e55bcaddd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 160 additions and 136 deletions

View file

@ -30,7 +30,6 @@ defmodule Pinchflat.Media do
""" """
def list_cullable_media_items do def list_cullable_media_items do
MediaQuery.new() MediaQuery.new()
|> MediaQuery.join_sources()
|> MediaQuery.with_media_filepath() |> MediaQuery.with_media_filepath()
|> MediaQuery.with_passed_retention_period() |> MediaQuery.with_passed_retention_period()
|> MediaQuery.with_no_culling_prevention() |> MediaQuery.with_no_culling_prevention()
@ -42,34 +41,15 @@ defmodule Pinchflat.Media do
pending means the `media_filepath` is `nil` AND the media_item pending means the `media_filepath` is `nil` AND the media_item
matches the format selection rules of the parent media_profile. matches the format selection rules of the parent media_profile.
See `matching_download_criteria_for` but tl;dr is it _may_ filter based See `where_pending` but tl;dr is it _may_ filter based
on shorts livestreams depending on the media_profile settings. on shorts livestreams depending on the media_profile settings.
Returns [%MediaItem{}, ...]. Returns [%MediaItem{}, ...].
""" """
def list_pending_media_items_for(%Source{} = source, opts \\ []) do def list_pending_media_items_for(%Source{} = source) do
limit = Keyword.get(opts, :limit, nil)
source = Repo.preload(source, :media_profile)
MediaQuery.new() MediaQuery.new()
|> MediaQuery.for_source(source) |> MediaQuery.for_source(source)
|> matching_download_criteria_for(source) |> MediaQuery.with_media_pending_download()
|> Repo.maybe_limit(limit)
|> Repo.all()
end
@doc """
Returns a list of downloaded media_items for a given source.
Returns [%MediaItem{}, ...].
"""
def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do
limit = Keyword.get(opts, :limit, nil)
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> Repo.maybe_limit(limit)
|> Repo.all() |> Repo.all()
end end
@ -87,7 +67,7 @@ defmodule Pinchflat.Media do
MediaQuery.new() MediaQuery.new()
|> MediaQuery.with_id(media_item.id) |> MediaQuery.with_id(media_item.id)
|> matching_download_criteria_for(media_item.source) |> MediaQuery.with_media_pending_download()
|> Repo.exists?() |> Repo.exists?()
end end
@ -234,13 +214,4 @@ defmodule Pinchflat.Media do
|> Enum.filter(&is_binary/1) |> Enum.filter(&is_binary/1)
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1) |> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
end end
defp matching_download_criteria_for(query, source_with_preloads) do
query
|> MediaQuery.with_no_prevented_download()
|> MediaQuery.with_no_media_filepath()
|> MediaQuery.with_upload_date_after(source_with_preloads.download_cutoff_date)
|> MediaQuery.with_format_preference(source_with_preloads.media_profile)
|> MediaQuery.matching_title_regex(source_with_preloads.title_filter_regex)
end
end end

View file

@ -3,13 +3,10 @@ defmodule Pinchflat.Media.MediaQuery do
Query helpers for the Media context. Query helpers for the Media context.
These methods are made to be one-ish liners used These methods are made to be one-ish liners used
to compose queries for media items. Each method should to compose queries. Each method should strive to do
strive to do _one_ thing. These don't need to be tested _one_ thing. These don't need to be tested as
as they are just building blocks for other functionality they are just building blocks for other functionality
which, itself, will be tested. which, itself, will be tested.
ALSO, this is me trying something new. If I like it,
I'll refactor other contexts to use this pattern.
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
@ -37,13 +34,14 @@ defmodule Pinchflat.Media.MediaQuery do
end end
def with_passed_retention_period(query) do def with_passed_retention_period(query) do
where( query
query, |> require_assoc(:source)
[mi, sources], |> where(
[mi, source],
fragment( fragment(
"IFNULL(?, 0) > 0 AND DATETIME('now', '-' || ? || ' day') > ?", "IFNULL(?, 0) > 0 AND DATETIME('now', '-' || ? || ' day') > ?",
sources.retention_period_days, source.retention_period_days,
sources.retention_period_days, source.retention_period_days,
mi.media_downloaded_at mi.media_downloaded_at
) )
) )
@ -69,20 +67,23 @@ defmodule Pinchflat.Media.MediaQuery do
where(query, [mi], is_nil(mi.media_filepath)) where(query, [mi], is_nil(mi.media_filepath))
end end
def with_upload_date_after(query, nil), do: query def with_upload_date_after_source_cutoff(query) do
query
def with_upload_date_after(query, date) do |> require_assoc(:source)
where(query, [mi], mi.upload_date >= ^date) |> where([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date)
end end
def with_no_prevented_download(query) do def with_no_prevented_download(query) do
where(query, [mi], mi.prevent_download == false) where(query, [mi], mi.prevent_download == false)
end end
def matching_title_regex(query, nil), do: query def matching_source_title_regex(query) do
query
def matching_title_regex(query, regex) do |> require_assoc(:source)
where(query, [mi], fragment("regexp_like(?, ?)", mi.title, ^regex)) |> where(
[mi, source],
is_nil(source.title_filter_regex) or fragment("regexp_like(?, ?)", mi.title, source.title_filter_regex)
)
end end
def matching_search_term(query, nil), do: query def matching_search_term(query, nil), do: query
@ -103,44 +104,55 @@ defmodule Pinchflat.Media.MediaQuery do
) )
end end
# NOTE: this method breaks the contract set by other methods in that it def with_format_matching_profile_preference(query) do
# takes a media_profile struct instead of taking just the attributes it query
# cares about. Consider refactoring but low priority. |> require_assoc(:media_profile)
def with_format_preference(query, media_profile) do |> where(
mapped_struct = Map.from_struct(media_profile) fragment("""
CASE
WHEN shorts_behaviour = 'only' AND livestream_behaviour = 'only' THEN
livestream = true OR short_form_content = true
WHEN shorts_behaviour = 'only' THEN
short_form_content = true
WHEN livestream_behaviour = 'only' THEN
livestream = true
WHEN shorts_behaviour = 'exclude' AND livestream_behaviour = 'exclude' THEN
short_form_content = false AND livestream = false
WHEN shorts_behaviour = 'exclude' THEN
short_form_content = false
WHEN livestream_behaviour = 'exclude' THEN
livestream = false
ELSE
true
END
""")
)
end
finders = def with_media_pending_download(query) do
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic -> query
case {attr, media_profile} do |> with_no_prevented_download()
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} -> |> with_no_media_filepath()
dynamic( |> with_upload_date_after_source_cutoff()
[mi], |> with_format_matching_profile_preference()
^dynamic and (mi.livestream == true or mi.short_form_content == true) |> matching_source_title_regex()
) end
# Technically redundant, but makes the other clauses easier to parse defp require_assoc(query, identifier) do
# (redundant because this condition is the same as the condition above, just flipped) if has_named_binding?(query, identifier) do
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} -> query
dynamic else
do_require_assoc(query, identifier)
end
end
{{:shorts_behaviour, :only}, _} -> defp do_require_assoc(query, :source) do
dynamic([mi], ^dynamic and mi.short_form_content == true) from(mi in query, join: s in assoc(mi, :source), as: :source)
end
{{:livestream_behaviour, :only}, _} -> defp do_require_assoc(query, :media_profile) do
dynamic([mi], ^dynamic and mi.livestream == true) query
|> require_assoc(:source)
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only -> |> join(:inner, [mi, source], mp in assoc(source, :media_profile), as: :media_profile)
dynamic([mi], ^dynamic and mi.short_form_content == false)
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
# return records with livestream: false
dynamic([mi], ^dynamic and mi.livestream == false)
_ ->
dynamic
end
end)
where(query, ^finders)
end end
end end

View file

@ -5,7 +5,7 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
""" """
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media.MediaQuery
alias Pinchflat.Metadata.MediaMetadata alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Metadata.SourceMetadata alias Pinchflat.Metadata.SourceMetadata
@ -25,8 +25,11 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
def persisted_media_items_for(source, opts \\ []) do def persisted_media_items_for(source, opts \\ []) do
limit = Keyword.get(opts, :limit, 500) limit = Keyword.get(opts, :limit, 500)
source MediaQuery.new()
|> Media.list_downloaded_media_items_for(limit: limit) |> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> Repo.maybe_limit(limit)
|> Repo.all()
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end) |> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
end end

View file

@ -0,0 +1,31 @@
defmodule Pinchflat.Sources.SourcesQuery do
@moduledoc """
Query helpers for the Sources context.
These methods are made to be one-ish liners used
to compose queries. Each method should strive to do
_one_ thing. These don't need to be tested as
they are just building blocks for other functionality
which, itself, will be tested.
"""
import Ecto.Query, warn: false
alias Pinchflat.Sources.Source
# Prefixes:
# - for_* - belonging to a certain record
# - join_* - for joining on a certain record
# - with_* - for filtering based on full, concrete attributes
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
#
# Suffixes:
# - _for - the arg passed is an association record
def new do
Source
end
def for_media_profile(query, media_profile) do
where(query, [s], s.media_profile_id == ^media_profile.id)
end
end

View file

@ -34,7 +34,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
<%= render_slot(@tab_append) %> <%= render_slot(@tab_append) %>
</div> </div>
</header> </header>
<div class="mt-4 min-h-96"> <div class="mt-4 min-h-60">
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed"> <div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
<%= render_slot(tab) %> <%= render_slot(tab) %>
</div> </div>

View file

@ -1,12 +1,19 @@
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
import Ecto.Query, warn: false
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Profiles alias Pinchflat.Profiles
alias Pinchflat.Sources.SourcesQuery
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
def index(conn, _params) do def index(conn, _params) do
media_profiles = Profiles.list_media_profiles() media_profiles =
MediaProfile
|> order_by(asc: :name)
|> Repo.all()
render(conn, :index, media_profiles: media_profiles) render(conn, :index, media_profiles: media_profiles)
end end
@ -32,12 +39,15 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
end end
def show(conn, %{"id" => id}) do def show(conn, %{"id" => id}) do
media_profile = media_profile = Profiles.get_media_profile!(id)
id
|> Profiles.get_media_profile!()
|> Repo.preload(:sources)
render(conn, :show, media_profile: media_profile) sources =
SourcesQuery.new()
|> SourcesQuery.for_media_profile(media_profile)
|> order_by(asc: :custom_name)
|> Repo.all()
render(conn, :show, media_profile: media_profile, sources: sources)
end end
def edit(conn, %{"id" => id}) do def edit(conn, %{"id" => id}) do

View file

@ -50,7 +50,7 @@
</div> </div>
</:tab> </:tab>
<:tab title="Sources"> <:tab title="Sources">
<.table rows={@media_profile.sources} table_class="text-black dark:text-white"> <.table rows={@sources} table_class="text-black dark:text-white">
<:col :let={source} label="Name"> <:col :let={source} label="Name">
<.subtle_link href={~p"/sources/#{source.id}"}> <.subtle_link href={~p"/sources/#{source.id}"}>
<%= source.custom_name || source.collection_name %> <%= source.custom_name || source.collection_name %>

View file

@ -2,7 +2,7 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media.MediaQuery
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Podcasts.RssFeedBuilder alias Pinchflat.Podcasts.RssFeedBuilder
alias Pinchflat.Podcasts.PodcastHelpers alias Pinchflat.Podcasts.PodcastHelpers
@ -20,10 +20,15 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
def feed_image(conn, %{"uuid" => uuid}) do def feed_image(conn, %{"uuid" => uuid}) do
source = Repo.get_by!(Source, uuid: uuid) source = Repo.get_by!(Source, uuid: uuid)
# This provides a fallback image if the source has none.
# We only need one since we're using the internal metadata image which # This is used to fetch a fallback cover image
# we know exists. # if the source doesn't have any usable images
media_items = Media.list_downloaded_media_items_for(source, limit: 1) media_items =
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> Repo.maybe_limit(1)
|> Repo.all()
case PodcastHelpers.select_cover_image(source, media_items) do case PodcastHelpers.select_cover_image(source, media_items) do
{:error, _} -> {:error, _} ->

View file

@ -4,17 +4,21 @@ defmodule PinchflatWeb.Sources.SourceController do
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Profiles alias Pinchflat.MediaQuery
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery alias Pinchflat.Media.MediaQuery
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.SlowIndexing.SlowIndexingHelpers
def index(conn, _params) do def index(conn, _params) do
sources = Repo.preload(Sources.list_sources(), :media_profile) sources =
Source
|> order_by(asc: :custom_name)
|> Repo.all()
|> Repo.preload(:media_profile)
render(conn, :index, sources: sources) render(conn, :index, sources: sources)
end end
@ -56,8 +60,21 @@ defmodule PinchflatWeb.Sources.SourceController do
|> Tasks.list_tasks_for(nil, [:executing, :available, :scheduled, :retryable]) |> Tasks.list_tasks_for(nil, [:executing, :available, :scheduled, :retryable])
|> Repo.preload(:job) |> Repo.preload(:job)
pending_media = Media.list_pending_media_items_for(source, limit: 100) pending_media =
downloaded_media = Media.list_downloaded_media_items_for(source, limit: 100) MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_pending_download()
|> order_by(desc: :id)
|> limit(100)
|> Repo.all()
downloaded_media =
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> order_by(desc: :id)
|> limit(100)
|> Repo.all()
render(conn, :show, render(conn, :show,
source: source, source: source,
@ -129,7 +146,9 @@ defmodule PinchflatWeb.Sources.SourceController do
end end
defp media_profiles do defp media_profiles do
Profiles.list_media_profiles() MediaProfile
|> order_by(asc: :name)
|> Repo.all()
end end
defp total_downloaded_for(source) do defp total_downloaded_for(source) do

View file

@ -149,14 +149,6 @@ defmodule Pinchflat.MediaTest do
assert Media.list_pending_media_items_for(source) == [] assert Media.list_pending_media_items_for(source) == []
end end
test "optionally accepts a limit" do
source = source_fixture()
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
assert Media.list_pending_media_items_for(source, limit: 1) == [media_item]
assert Media.list_pending_media_items_for(source, limit: 0) == []
end
end end
describe "list_pending_media_items_for/1 when testing shorts" do describe "list_pending_media_items_for/1 when testing shorts" do
@ -335,25 +327,6 @@ defmodule Pinchflat.MediaTest do
end end
end end
describe "list_downloaded_media_items_for/1" do
test "returns only media items with a media_filepath" do
source = source_fixture()
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: "/video/#{Faker.File.file_name(:video)}"})
assert Media.list_downloaded_media_items_for(source) == [media_item]
end
test "optionally accepts a limit" do
source = source_fixture()
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: "/video/#{Faker.File.file_name(:video)}"})
assert Media.list_downloaded_media_items_for(source, limit: 1) == [media_item]
assert Media.list_downloaded_media_items_for(source, limit: 0) == []
end
end
describe "pending_download?/1" do describe "pending_download?/1" do
test "returns true when the media hasn't been downloaded" do test "returns true when the media hasn't been downloaded" do
media_item = media_item_fixture(%{media_filepath: nil}) media_item = media_item_fixture(%{media_filepath: nil})