diff --git a/lib/pinchflat/media/media.ex b/lib/pinchflat/media/media.ex index d58f657..d81fde2 100644 --- a/lib/pinchflat/media/media.ex +++ b/lib/pinchflat/media/media.ex @@ -30,7 +30,6 @@ defmodule Pinchflat.Media do """ def list_cullable_media_items do MediaQuery.new() - |> MediaQuery.join_sources() |> MediaQuery.with_media_filepath() |> MediaQuery.with_passed_retention_period() |> MediaQuery.with_no_culling_prevention() @@ -42,34 +41,15 @@ defmodule Pinchflat.Media do pending means the `media_filepath` is `nil` AND the media_item 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. Returns [%MediaItem{}, ...]. """ - def list_pending_media_items_for(%Source{} = source, opts \\ []) do - limit = Keyword.get(opts, :limit, nil) - source = Repo.preload(source, :media_profile) - + def list_pending_media_items_for(%Source{} = source) do MediaQuery.new() |> MediaQuery.for_source(source) - |> matching_download_criteria_for(source) - |> 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) + |> MediaQuery.with_media_pending_download() |> Repo.all() end @@ -87,7 +67,7 @@ defmodule Pinchflat.Media do MediaQuery.new() |> MediaQuery.with_id(media_item.id) - |> matching_download_criteria_for(media_item.source) + |> MediaQuery.with_media_pending_download() |> Repo.exists?() end @@ -234,13 +214,4 @@ defmodule Pinchflat.Media do |> Enum.filter(&is_binary/1) |> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1) 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 diff --git a/lib/pinchflat/media/media_query.ex b/lib/pinchflat/media/media_query.ex index 7b7a3c7..6751c28 100644 --- a/lib/pinchflat/media/media_query.ex +++ b/lib/pinchflat/media/media_query.ex @@ -3,13 +3,10 @@ defmodule Pinchflat.Media.MediaQuery do Query helpers for the Media context. These methods are made to be one-ish liners used - to compose queries for media items. Each method should - strive to do _one_ thing. These don't need to be tested - as they are just building blocks for other functionality + 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. - - 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 @@ -37,13 +34,14 @@ defmodule Pinchflat.Media.MediaQuery do end def with_passed_retention_period(query) do - where( - query, - [mi, sources], + query + |> require_assoc(:source) + |> where( + [mi, source], fragment( "IFNULL(?, 0) > 0 AND DATETIME('now', '-' || ? || ' day') > ?", - sources.retention_period_days, - sources.retention_period_days, + source.retention_period_days, + source.retention_period_days, mi.media_downloaded_at ) ) @@ -69,20 +67,23 @@ defmodule Pinchflat.Media.MediaQuery do where(query, [mi], is_nil(mi.media_filepath)) end - def with_upload_date_after(query, nil), do: query - - def with_upload_date_after(query, date) do - where(query, [mi], mi.upload_date >= ^date) + def with_upload_date_after_source_cutoff(query) do + query + |> require_assoc(:source) + |> where([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date) end def with_no_prevented_download(query) do where(query, [mi], mi.prevent_download == false) end - def matching_title_regex(query, nil), do: query - - def matching_title_regex(query, regex) do - where(query, [mi], fragment("regexp_like(?, ?)", mi.title, ^regex)) + def matching_source_title_regex(query) do + query + |> require_assoc(:source) + |> where( + [mi, source], + is_nil(source.title_filter_regex) or fragment("regexp_like(?, ?)", mi.title, source.title_filter_regex) + ) end def matching_search_term(query, nil), do: query @@ -103,44 +104,55 @@ defmodule Pinchflat.Media.MediaQuery do ) end - # NOTE: this method breaks the contract set by other methods in that it - # takes a media_profile struct instead of taking just the attributes it - # cares about. Consider refactoring but low priority. - def with_format_preference(query, media_profile) do - mapped_struct = Map.from_struct(media_profile) + def with_format_matching_profile_preference(query) do + query + |> require_assoc(:media_profile) + |> where( + 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 = - Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic -> - case {attr, media_profile} do - {{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} -> - dynamic( - [mi], - ^dynamic and (mi.livestream == true or mi.short_form_content == true) - ) + def with_media_pending_download(query) do + query + |> with_no_prevented_download() + |> with_no_media_filepath() + |> with_upload_date_after_source_cutoff() + |> with_format_matching_profile_preference() + |> matching_source_title_regex() + end - # Technically redundant, but makes the other clauses easier to parse - # (redundant because this condition is the same as the condition above, just flipped) - {{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} -> - dynamic + defp require_assoc(query, identifier) do + if has_named_binding?(query, identifier) do + query + else + do_require_assoc(query, identifier) + end + end - {{:shorts_behaviour, :only}, _} -> - dynamic([mi], ^dynamic and mi.short_form_content == true) + defp do_require_assoc(query, :source) do + from(mi in query, join: s in assoc(mi, :source), as: :source) + end - {{:livestream_behaviour, :only}, _} -> - dynamic([mi], ^dynamic and mi.livestream == true) - - {{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only -> - 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) + defp do_require_assoc(query, :media_profile) do + query + |> require_assoc(:source) + |> join(:inner, [mi, source], mp in assoc(source, :media_profile), as: :media_profile) end end diff --git a/lib/pinchflat/podcasts/podcast_helpers.ex b/lib/pinchflat/podcasts/podcast_helpers.ex index 33e328e..3353070 100644 --- a/lib/pinchflat/podcasts/podcast_helpers.ex +++ b/lib/pinchflat/podcasts/podcast_helpers.ex @@ -5,7 +5,7 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do """ alias Pinchflat.Repo - alias Pinchflat.Media + alias Pinchflat.Media.MediaQuery alias Pinchflat.Metadata.MediaMetadata alias Pinchflat.Metadata.SourceMetadata @@ -25,8 +25,11 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do def persisted_media_items_for(source, opts \\ []) do limit = Keyword.get(opts, :limit, 500) - source - |> Media.list_downloaded_media_items_for(limit: limit) + MediaQuery.new() + |> 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) end diff --git a/lib/pinchflat/sources/sources_query.ex b/lib/pinchflat/sources/sources_query.ex new file mode 100644 index 0000000..81bd449 --- /dev/null +++ b/lib/pinchflat/sources/sources_query.ex @@ -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 diff --git a/lib/pinchflat_web/components/custom_components/tab_components.ex b/lib/pinchflat_web/components/custom_components/tab_components.ex index f8c5a71..fda08bd 100644 --- a/lib/pinchflat_web/components/custom_components/tab_components.ex +++ b/lib/pinchflat_web/components/custom_components/tab_components.ex @@ -34,7 +34,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do <%= render_slot(@tab_append) %> -