Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9053fff0c | |||
| 70c1f97625 | |||
| 332485cdaf | |||
| e55bcaddd0 | |||
| 4b12764f45 | |||
| b872c5c20b |
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 """
|
||||||
|
|||||||
@@ -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)} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user