Compare commits

...

6 Commits

Author SHA1 Message Date
Kieran Eglin d9053fff0c Bumped version 2024-04-04 08:47:37 -07:00
Kieran Eglin 70c1f97625 re-added removal of root password 2024-04-03 17:36:52 -07:00
Kieran Eglin 332485cdaf Improved docs 2024-04-03 17:30:13 -07:00
Kieran e55bcaddd0 [Enhancement] Improve ordering of models (#164)
* [WIP] ordering app queries

* Refactored media queries to be self-contained
2024-04-03 17:26:46 -07:00
Kieran 4b12764f45 [Housekeeping] Upgraded Tailwind to 3.4.3 (#163)
* Upgraded Tailwind

* Enabled asset compression in prod
2024-04-03 14:57:45 -07:00
Kieran b872c5c20b [Enhancement] Allow manual indexing/downloading (#162)
* Added controller actions and UI for forcing index and download actions

* Added forcing of downloads for media items
2024-04-03 14:21:10 -07:00
30 changed files with 503 additions and 222 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ config :esbuild,
# Configure tailwind (the version is required) # Configure tailwind (the version is required)
config :tailwind, config :tailwind,
version: "3.3.2", version: "3.4.3",
default: [ default: [
args: ~w( args: ~w(
--config=tailwind.config.js --config=tailwind.config.js
@@ -19,27 +19,29 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}} Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
""" """
def kickoff_with_task(media_item, opts \\ []) do def kickoff_with_task(media_item, job_args \\ %{}, job_opts \\ []) do
%{id: media_item.id} %{id: media_item.id}
|> MediaDownloadWorker.new(opts) |> Map.merge(job_args)
|> MediaDownloadWorker.new(job_opts)
|> Tasks.create_job_with_task(media_item) |> Tasks.create_job_with_task(media_item)
end end
@doc """ @doc """
For a given media item, download the media alongside any options. For a given media item, download the media alongside any options.
Does not download media if its source is set to not download media. Does not download media if its source is set to not download media
(unless forced).
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any} Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
""" """
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => media_item_id}}) do def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
media_item = media_item =
media_item_id media_item_id
|> Media.get_media_item!() |> Media.get_media_item!()
|> Repo.preload(:source) |> Repo.preload(:source)
# If the source is set to not download media, perform a no-op # If the source is set to not download media, perform a no-op
if media_item.source.download_media do if media_item.source.download_media || args["force"] do
download_media_and_schedule_jobs(media_item) download_media_and_schedule_jobs(media_item)
else else
:ok :ok
+6 -36
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()
@@ -40,36 +39,16 @@ defmodule Pinchflat.Media do
@doc """ @doc """
Returns a list of pending media_items for a given source, where Returns a list of pending media_items for a given source, where
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 satisfies `MediaQuery.with_media_pending_download`. You
should really check out that function if you need to know more
See `matching_download_criteria_for` but tl;dr is it _may_ filter based because it has a lot going on.
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 +66,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 +213,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
+67 -55
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
finders = WHEN shorts_behaviour = 'only' AND livestream_behaviour = 'only' THEN
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic -> livestream = true OR short_form_content = true
case {attr, media_profile} do WHEN shorts_behaviour = 'only' THEN
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} -> short_form_content = true
dynamic( WHEN livestream_behaviour = 'only' THEN
[mi], livestream = true
^dynamic and (mi.livestream == true or mi.short_form_content == 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
""")
) )
# 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
{{:shorts_behaviour, :only}, _} ->
dynamic([mi], ^dynamic and mi.short_form_content == true)
{{: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
end)
where(query, ^finders) 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
defp require_assoc(query, identifier) do
if has_named_binding?(query, identifier) do
query
else
do_require_assoc(query, identifier)
end
end
defp do_require_assoc(query, :source) do
from(mi in query, join: s in assoc(mi, :source), as: :source)
end
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
end end
+6 -3
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
@@ -20,9 +20,10 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}} Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
""" """
def kickoff_with_task(source, opts \\ []) do def kickoff_with_task(source, job_args \\ %{}, job_opts \\ []) do
%{id: source.id} %{id: source.id}
|> MediaCollectionIndexingWorker.new(opts) |> Map.merge(job_args)
|> MediaCollectionIndexingWorker.new(job_opts)
|> Tasks.create_job_with_task(source) |> Tasks.create_job_with_task(source)
end end
@@ -30,8 +31,8 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
The ID is that of a source _record_, not a YouTube channel/playlist ID. Indexes The ID is that of a source _record_, not a YouTube channel/playlist ID. Indexes
the provided source, kicks off downloads for each new MediaItem, and the provided source, kicks off downloads for each new MediaItem, and
reschedules the job to run again in the future. It will ALWAYS index a source reschedules the job to run again in the future. It will ALWAYS index a source
if it's never been indexed before, but rescheduling is determined by the if it's never been indexed before or if `force` is set to `true`, but rescheduling
`index_frequency_minutes` field. is determined by the `index_frequency_minutes` field.
README: Re-scheduling here works a little different than you may expect. README: Re-scheduling here works a little different than you may expect.
The reschedule time is relative to the time the job has actually _completed_. The reschedule time is relative to the time the job has actually _completed_.
@@ -71,7 +72,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
Returns :ok | {:ok, %Task{}} Returns :ok | {:ok, %Task{}}
""" """
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => source_id}}) do def perform(%Oban.Job{args: %{"id" => source_id} = args}) do
source = Sources.get_source!(source_id) source = Sources.get_source!(source_id)
case {source.index_frequency_minutes, source.last_indexed_at} do case {source.index_frequency_minutes, source.last_indexed_at} do
@@ -89,7 +90,11 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
_ -> _ ->
# If the source HAS been indexed and is not meant to reschedule, # If the source HAS been indexed and is not meant to reschedule,
# perform a no-op # perform a no-op (unless forced)
if args["force"] do
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end
:ok :ok
end end
rescue rescue
@@ -27,12 +27,12 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
Returns {:ok, %Task{}}. Returns {:ok, %Task{}}.
""" """
def kickoff_indexing_task(%Source{} = source) do def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker") Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker") Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker")
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker") Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
MediaCollectionIndexingWorker.kickoff_with_task(source) MediaCollectionIndexingWorker.kickoff_with_task(source, job_args, job_opts)
end end
@doc """ @doc """
+31
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
@@ -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"> <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>
@@ -6,6 +6,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.MediaDownloadWorker
def show(conn, %{"id" => id}) do def show(conn, %{"id" => id}) do
media_item = media_item =
@@ -47,6 +48,15 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|> redirect(to: ~p"/sources/#{media_item.source_id}") |> redirect(to: ~p"/sources/#{media_item.source_id}")
end end
def force_download(conn, %{"media_item_id" => id}) do
media_item = Media.get_media_item!(id)
{:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, %{force: true})
conn
|> put_flash(:info, "Download task enqueued.")
|> redirect(to: ~p"/sources/#{media_item.source_id}/media/#{media_item}")
end
# See here for details on streaming files and range requests: # See here for details on streaming files and range requests:
# https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/ # https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
# #
@@ -0,0 +1,32 @@
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}/force_download"}
method="post"
data-confirm="Are you sure you force a download of this media?"
>
Force Download
</.link>
</:option>
<:option>
<div class="h-px w-full bg-bodydark2"></div>
</:option>
<:option>
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"}
method="delete"
data-confirm="Are you sure you want to delete all files for this media item? This cannot be undone."
>
Delete Files
</.link>
</:option>
<:option>
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?prevent_download=true"}
method="delete"
data-confirm="Are you sure you want to delete all files for this media item and prevent it from re-downloading in the future? This cannot be undone."
>
Delete and Ignore
</.link>
</:option>
</.button_dropdown>
@@ -17,7 +17,7 @@
field={f[:prevent_download]} field={f[:prevent_download]}
type="toggle" type="toggle"
label="Prevent Download" label="Prevent Download"
help="Checking excludes this media item from being downloaded" help="Checking excludes this media item from automatic download. Download can still be manually forced"
/> />
<.input <.input
@@ -20,26 +20,7 @@
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.tabbed_layout> <.tabbed_layout>
<:tab_append> <:tab_append>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50"> <.actions_dropdown media_item={@media_item} />
<:option>
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"}
method="delete"
data-confirm="Are you sure you want to delete all files for this media item? This cannot be undone."
>
Delete Files
</.link>
</:option>
<:option>
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?prevent_download=true"}
method="delete"
data-confirm="Are you sure you want to delete all files for this media item and prevent it from re-downloading in the future? This cannot be undone."
>
Delete and Ignore
</.link>
</:option>
</.button_dropdown>
</:tab_append> </:tab_append>
<:tab title="Attributes"> <:tab title="Attributes">
@@ -53,9 +34,9 @@
<h3 class="font-bold text-xl">Attributes</h3> <h3 class="font-bold text-xl">Attributes</h3>
<section> <section>
<strong>Source:</strong> <strong>Source:</strong>
<.inline_link href={~p"/sources/#{@media_item.source_id}"}> <.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
<%= @media_item.source.custom_name %> <%= @media_item.source.custom_name %>
</.inline_link> </.subtle_link>
</section> </section>
<.list_items_from_map map={Map.from_struct(@media_item)} /> <.list_items_from_map map={Map.from_struct(@media_item)} />
@@ -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
@@ -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 %>
@@ -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, _} ->
@@ -4,15 +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.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
@@ -49,9 +55,26 @@ defmodule PinchflatWeb.Sources.SourceController do
def show(conn, %{"id" => id}) do def show(conn, %{"id" => id}) do
source = Repo.preload(Sources.get_source!(id), :media_profile) source = Repo.preload(Sources.get_source!(id), :media_profile)
pending_tasks = Repo.preload(Tasks.list_pending_tasks_for(source), :job) pending_tasks =
pending_media = Media.list_pending_media_items_for(source, limit: 100) source
downloaded_media = Media.list_downloaded_media_items_for(source, limit: 100) |> Tasks.list_tasks_for(nil, [:executing, :available, :scheduled, :retryable])
|> Repo.preload(:job)
pending_media =
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,
@@ -104,12 +127,30 @@ defmodule PinchflatWeb.Sources.SourceController do
|> redirect(to: ~p"/sources") |> redirect(to: ~p"/sources")
end end
def force_download(conn, %{"source_id" => id}) do
source = Sources.get_source!(id)
DownloadingHelpers.enqueue_pending_download_tasks(source)
conn
|> put_flash(:info, "Forced download of pending media items.")
|> redirect(to: ~p"/sources/#{source}")
end
def force_index(conn, %{"source_id" => id}) do
source = Sources.get_source!(id)
SlowIndexingHelpers.kickoff_indexing_task(source, %{force: true})
conn
|> put_flash(:info, "Index enqueued.")
|> redirect(to: ~p"/sources/#{source}")
end
defp media_profiles do defp media_profiles do
Profiles.list_media_profiles() MediaProfile
|> order_by(asc: :name)
|> Repo.all()
end end
# NOTE: should move this out of the controller
# once I finally add some query fragment layer
defp total_downloaded_for(source) do defp total_downloaded_for(source) do
MediaQuery.new() MediaQuery.new()
|> MediaQuery.for_source(source) |> MediaQuery.for_source(source)
@@ -0,0 +1,55 @@
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<span
x-data="{ copied: false }"
x-on:click={"
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
copied = true
setTimeout(() => copied = false, 4000)
"}
>
Copy RSS Feed
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</span>
</:option>
<:option :if={@source.download_media}>
<.link
href={~p"/sources/#{@source}/force_download"}
method="post"
data-confirm="Are you sure you want to force a download of all *pending* media items? This isn't normally needed."
>
Force Download
</.link>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}/force_index"}
method="post"
data-confirm="Are you sure you want to force an index of this source? This isn't normally needed."
>
Force Index
</.link>
</:option>
<:option>
<div class="h-px w-full bg-bodydark2"></div>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}"}
method="delete"
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
>
Delete Source
</.link>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
Delete Source + Files
</.link>
</:option>
</.button_dropdown>
@@ -20,43 +20,7 @@
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.tabbed_layout> <.tabbed_layout>
<:tab_append> <:tab_append>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50"> <.actions_dropdown source={@source} conn={@conn} />
<:option>
<span
x-data="{ copied: false }"
x-on:click={"
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
copied = true
setTimeout(() => copied = false, 4000)
"}
>
Copy RSS Feed
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</span>
</:option>
<:option>
<div class="h-px w-full bg-bodydark2"></div>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}"}
method="delete"
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
>
Delete Source
</.link>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
Delete Source + Files
</.link>
</:option>
</.button_dropdown>
</:tab_append> </:tab_append>
<:tab title="Attributes"> <:tab title="Attributes">
@@ -64,9 +28,9 @@
<h3 class="font-bold text-lg">Attributes</h3> <h3 class="font-bold text-lg">Attributes</h3>
<section> <section>
<strong>Media Profile:</strong> <strong>Media Profile:</strong>
<.inline_link href={~p"/media_profiles/#{@source.media_profile_id}"}> <.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
<%= @source.media_profile.name %> <%= @source.media_profile.name %>
</.inline_link> </.subtle_link>
</section> </section>
<.list_items_from_map map={Map.from_struct(@source)} /> <.list_items_from_map map={Map.from_struct(@source)} />
+1 -1
View File
@@ -20,7 +20,7 @@ defmodule PinchflatWeb.Endpoint do
plug Plug.Static, plug Plug.Static,
at: "/", at: "/",
from: :pinchflat, from: :pinchflat,
gzip: false, gzip: Mix.env() == :prod,
only: PinchflatWeb.static_paths() only: PinchflatWeb.static_paths()
# Code reloading can be explicitly enabled under the # Code reloading can be explicitly enabled under the
+6 -1
View File
@@ -32,7 +32,12 @@ defmodule PinchflatWeb.Router do
resources "/search", Searches.SearchController, only: [:show], singleton: true resources "/search", Searches.SearchController, only: [:show], singleton: true
resources "/sources", Sources.SourceController do resources "/sources", Sources.SourceController do
resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete] post "/force_download", Sources.SourceController, :force_download
post "/force_index", Sources.SourceController, :force_index
resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete] do
post "/force_download", MediaItems.MediaItemController, :force_download
end
end end
end end
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "0.1.7", version: "0.1.8",
elixir: "~> 1.16", elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
+11
View File
@@ -108,6 +108,17 @@ EXPOSE ${PORT}
# Only copy the final release from the build stage # Only copy the final release from the build stage
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./ COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
# NEVER do this if you're running in an environment where you don't trust the user
# (ie: most environments). This is only acceptable in a self-hosted environment.
# The user could just run the whole container as root and bypass this anyway so
# it's not a huge deal.
# This removes the root password to allow users to assume root if needed. This is
# preferrable to running the whole container as root so that the files/directories
# created by the app aren't owned by root and are therefore easier for other users
# and processes to interact with. If you want to just run the whole container as
# root, use --user 0:0 or something.
RUN passwd -d root
# If using an environment that doesn't automatically reap zombie processes, it is # If using an environment that doesn't automatically reap zombie processes, it is
# advised to add an init process such as tini via `apt-get install` # advised to add an init process such as tini via `apt-get install`
# above and adding an entrypoint. See https://github.com/krallin/tini for details # above and adding an entrypoint. See https://github.com/krallin/tini for details
@@ -34,6 +34,23 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
assert {:ok, task} = MediaDownloadWorker.kickoff_with_task(media_item) assert {:ok, task} = MediaDownloadWorker.kickoff_with_task(media_item)
assert task.media_item_id == media_item.id assert task.media_item_id == media_item.id
end end
test "can be called with additional job arguments", %{media_item: media_item} do
job_args = %{"force" => true}
assert {:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, job_args)
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id, "force" => true})
end
test "can be called with additional job options", %{media_item: media_item} do
job_opts = [max_attempts: 5]
assert {:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, %{}, job_opts)
[job] = all_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
assert job.max_attempts == 5
end
end end
describe "perform/1" do describe "perform/1" do
@@ -88,6 +105,14 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
perform_job(MediaDownloadWorker, %{id: media_item.id}) perform_job(MediaDownloadWorker, %{id: media_item.id})
end end
test "downloads anyway if forced", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> :ok end)
Sources.update_source(media_item.source, %{download_media: false})
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end
test "it saves the file's size to the database", %{media_item: media_item} do test "it saves the file's size to the database", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
-27
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})
@@ -14,6 +14,42 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
setup :verify_on_exit! setup :verify_on_exit!
describe "kickoff_with_task/3" do
setup do
source = source_fixture(index_frequency_minutes: 10)
{:ok, %{source: source}}
end
test "starts the worker", %{source: source} do
assert [] = all_enqueued(worker: MediaCollectionIndexingWorker)
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source)
assert [_] = all_enqueued(worker: MediaCollectionIndexingWorker)
end
test "attaches a task", %{source: source} do
assert {:ok, task} = MediaCollectionIndexingWorker.kickoff_with_task(source)
assert task.source_id == source.id
end
test "can be called with additional job arguments", %{source: source} do
job_args = %{"force" => true}
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source, job_args)
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id, "force" => true})
end
test "can be called with additional job options", %{source: source} do
job_opts = [max_attempts: 5]
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source, %{}, job_opts)
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
assert job.max_attempts == 5
end
end
describe "perform/1" do describe "perform/1" do
test "it indexes the source if it should be indexed" do test "it indexes the source if it should be indexed" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
@@ -31,6 +67,14 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
end end
test "it indexes the source no matter what if the 'force' arg is passed" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true})
end
test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
@@ -18,7 +18,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
setup :verify_on_exit! setup :verify_on_exit!
describe "kickoff_indexing_task/1" do describe "kickoff_indexing_task/3" do
test "it schedules a job" do test "it schedules a job" do
source = source_fixture(index_frequency_minutes: 1) source = source_fixture(index_frequency_minutes: 1)
@@ -64,6 +64,25 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end end
test "can be called with additional job arguments" do
source = source_fixture(index_frequency_minutes: 1)
job_args = %{"force" => true}
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source, job_args)
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id, "force" => true})
end
test "can be called with additional job options" do
source = source_fixture(index_frequency_minutes: 1)
job_opts = [max_attempts: 5]
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source, %{}, job_opts)
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
assert job.max_attempts == 5
end
end end
describe "index_and_enqueue_download_for_media_items/1" do describe "index_and_enqueue_download_for_media_items/1" do
@@ -4,6 +4,7 @@ defmodule PinchflatWeb.MediaItemControllerTest do
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Downloading.MediaDownloadWorker
describe "show media" do describe "show media" do
setup [:create_media_item] setup [:create_media_item]
@@ -87,6 +88,31 @@ defmodule PinchflatWeb.MediaItemControllerTest do
end end
end end
describe "force_download" do
test "enqueues download task", %{conn: conn} do
media_item = media_item_fixture()
assert [] = all_enqueued(worker: MediaDownloadWorker)
post(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}/force_download")
assert [_] = all_enqueued(worker: MediaDownloadWorker)
end
test "forces a download even if one wouldn't normally run", %{conn: conn} do
media_item = media_item_fixture(%{media_filepath: nil})
post(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}/force_download")
assert [_] = all_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id, "force" => true})
end
test "redirects to the show page", %{conn: conn} do
media_item = media_item_fixture()
conn = post(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}/force_download")
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}/media/#{media_item.id}"
end
end
describe "streaming media" do describe "streaming media" do
test "returns 404 if the media isn't found", %{conn: conn} do test "returns 404 if the media isn't found", %{conn: conn} do
media_item = media_item_fixture() media_item = media_item_fixture()
@@ -8,6 +8,8 @@ defmodule PinchflatWeb.SourceControllerTest do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
setup do setup do
media_profile = media_profile_fixture() media_profile = media_profile_fixture()
@@ -160,6 +162,59 @@ defmodule PinchflatWeb.SourceControllerTest do
end end
end end
describe "force_download" do
test "enqueues pending download tasks", %{conn: conn} do
source = source_fixture()
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
assert [] = all_enqueued(worker: MediaDownloadWorker)
post(conn, ~p"/sources/#{source.id}/force_download")
assert [_] = all_enqueued(worker: MediaDownloadWorker)
end
test "redirects to the source page", %{conn: conn} do
source = source_fixture()
conn = post(conn, ~p"/sources/#{source.id}/force_download")
assert redirected_to(conn) == ~p"/sources/#{source.id}"
end
end
describe "force_index" do
test "forces an index", %{conn: conn} do
source = source_fixture()
assert [] = all_enqueued(worker: MediaCollectionIndexingWorker)
post(conn, ~p"/sources/#{source.id}/force_index")
assert [_] = all_enqueued(worker: MediaCollectionIndexingWorker)
end
test "forces an index even if one wouldn't normally run", %{conn: conn} do
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
post(conn, ~p"/sources/#{source.id}/force_index")
assert [job] = all_enqueued(worker: MediaCollectionIndexingWorker)
assert job.args == %{"id" => source.id, "force" => true}
end
test "deletes pending indexing tasks", %{conn: conn} do
source = source_fixture()
{:ok, task} = MediaCollectionIndexingWorker.kickoff_with_task(source)
job = Repo.preload(task, :job).job
assert job.state == "available"
post(conn, ~p"/sources/#{source.id}/force_index")
assert Repo.reload!(job).state == "cancelled"
end
test "redirects to the source page", %{conn: conn} do
source = source_fixture()
conn = post(conn, ~p"/sources/#{source.id}/force_index")
assert redirected_to(conn) == ~p"/sources/#{source.id}"
end
end
defp create_source(_) do defp create_source(_) do
source = source_fixture() source = source_fixture()
media_item = media_item_with_attachments(%{source_id: source.id}) media_item = media_item_with_attachments(%{source_id: source.id})
+2
View File
@@ -21,8 +21,10 @@ defmodule PinchflatWeb.ConnCase do
quote do quote do
# The default endpoint for testing # The default endpoint for testing
@endpoint PinchflatWeb.Endpoint @endpoint PinchflatWeb.Endpoint
alias Pinchflat.Repo
use PinchflatWeb, :verified_routes use PinchflatWeb, :verified_routes
use Oban.Testing, repo: Repo
# Import conveniences for testing with connections # Import conveniences for testing with connections
import Plug.Conn import Plug.Conn