Compare commits
8 Commits
v2024.12.29
...
v2025.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| d8fdba8f6d | |||
| e4c186eacb | |||
| e150355874 | |||
| 967e21a8a1 | |||
| 9185f075ca | |||
| 09d1653f4b | |||
| 115f675ae3 | |||
| f51b219860 |
@@ -152,6 +152,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
case {behaviour, categories} do
|
case {behaviour, categories} do
|
||||||
{_, []} -> []
|
{_, []} -> []
|
||||||
{:remove, _} -> [sponsorblock_remove: Enum.join(categories, ",")]
|
{:remove, _} -> [sponsorblock_remove: Enum.join(categories, ",")]
|
||||||
|
{:mark, _} -> [sponsorblock_mark: Enum.join(categories, ",")]
|
||||||
{:disabled, _} -> []
|
{:disabled, _} -> []
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -200,6 +201,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
source = media_item_with_preloads.source
|
source = media_item_with_preloads.source
|
||||||
|
|
||||||
%{
|
%{
|
||||||
|
"media_item_id" => to_string(media_item_with_preloads.id),
|
||||||
|
"source_id" => to_string(source.id),
|
||||||
|
"media_profile_id" => to_string(source.media_profile_id),
|
||||||
"source_custom_name" => source.custom_name,
|
"source_custom_name" => source.custom_name,
|
||||||
"source_collection_id" => source.collection_id,
|
"source_collection_id" => source.collection_id,
|
||||||
"source_collection_name" => source.collection_name,
|
"source_collection_name" => source.collection_name,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||||||
field :embed_metadata, :boolean, default: false
|
field :embed_metadata, :boolean, default: false
|
||||||
|
|
||||||
field :download_nfo, :boolean, default: false
|
field :download_nfo, :boolean, default: false
|
||||||
field :sponsorblock_behaviour, Ecto.Enum, values: [:disabled, :remove], default: :disabled
|
field :sponsorblock_behaviour, Ecto.Enum, values: [:disabled, :mark, :remove], default: :disabled
|
||||||
field :sponsorblock_categories, {:array, :string}, default: []
|
field :sponsorblock_categories, {:array, :string}, default: []
|
||||||
# NOTE: these do NOT speed up indexing - the indexer still has to go
|
# NOTE: these do NOT speed up indexing - the indexer still has to go
|
||||||
# through the entire collection to determine if a media is a short or
|
# through the entire collection to determine if a media is a short or
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ defmodule Pinchflat.Settings.Setting do
|
|||||||
field :apprise_version, :string
|
field :apprise_version, :string
|
||||||
field :apprise_server, :string
|
field :apprise_server, :string
|
||||||
field :youtube_api_key, :string
|
field :youtube_api_key, :string
|
||||||
|
field :route_token, :string
|
||||||
|
|
||||||
field :video_codec_preference, :string
|
field :video_codec_preference, :string
|
||||||
field :audio_codec_preference, :string
|
field :audio_codec_preference, :string
|
||||||
|
|||||||
@@ -79,21 +79,21 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
case {source.index_frequency_minutes, source.last_indexed_at} do
|
case {source.index_frequency_minutes, source.last_indexed_at} do
|
||||||
{index_freq, _} when index_freq > 0 ->
|
{index_freq, _} when index_freq > 0 ->
|
||||||
# If the indexing is on a schedule simply run indexing and reschedule
|
# If the indexing is on a schedule simply run indexing and reschedule
|
||||||
perform_indexing_and_notification(source)
|
perform_indexing_and_notification(source, was_forced: args["force"])
|
||||||
maybe_enqueue_fast_indexing_task(source)
|
maybe_enqueue_fast_indexing_task(source)
|
||||||
reschedule_indexing(source)
|
reschedule_indexing(source)
|
||||||
|
|
||||||
{_, nil} ->
|
{_, nil} ->
|
||||||
# If the source has never been indexed, index it once
|
# If the source has never been indexed, index it once
|
||||||
# even if it's not meant to reschedule
|
# even if it's not meant to reschedule
|
||||||
perform_indexing_and_notification(source)
|
perform_indexing_and_notification(source, was_forced: args["force"])
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
# 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 (unless forced)
|
# perform a no-op (unless forced)
|
||||||
if args["force"] do
|
if args["force"] do
|
||||||
perform_indexing_and_notification(source)
|
perform_indexing_and_notification(source, was_forced: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
@@ -103,11 +103,11 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp perform_indexing_and_notification(source) do
|
defp perform_indexing_and_notification(source, indexing_opts) do
|
||||||
apprise_server = Settings.get!(:apprise_server)
|
apprise_server = Settings.get!(:apprise_server)
|
||||||
|
|
||||||
SourceNotifications.wrap_new_media_notification(apprise_server, source, fn ->
|
SourceNotifications.wrap_new_media_notification(apprise_server, source, fn ->
|
||||||
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source, indexing_opts)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
Many of these methods are made to be kickoff or be consumed by workers.
|
Many of these methods are made to be kickoff or be consumed by workers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
@@ -14,6 +16,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.YtDlp.MediaCollection
|
alias Pinchflat.YtDlp.MediaCollection
|
||||||
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
alias Pinchflat.SlowIndexing.FileFollowerServer
|
alias Pinchflat.SlowIndexing.FileFollowerServer
|
||||||
alias Pinchflat.Downloading.DownloadOptionBuilder
|
alias Pinchflat.Downloading.DownloadOptionBuilder
|
||||||
@@ -22,13 +25,19 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Starts tasks for indexing a source's media regardless of the source's indexing
|
Kills old indexing tasks and starts a new task to index the media collection.
|
||||||
frequency. It's assumed the caller will check for indexing frequency.
|
|
||||||
|
The job is delayed based on the source's `index_frequency_minutes` setting unless
|
||||||
|
one of the following is true:
|
||||||
|
- The `force` option is set to true
|
||||||
|
- The source has never been indexed before
|
||||||
|
- The source has been indexed before, but the last indexing job was more than
|
||||||
|
`index_frequency_minutes` ago
|
||||||
|
|
||||||
Returns {:ok, %Task{}}
|
Returns {:ok, %Task{}}
|
||||||
"""
|
"""
|
||||||
def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do
|
def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do
|
||||||
job_offset_seconds = calculate_job_offset_seconds(source)
|
job_offset_seconds = if job_args[:force], do: 0, else: calculate_job_offset_seconds(source)
|
||||||
|
|
||||||
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
||||||
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker", include_executing: true)
|
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker", include_executing: true)
|
||||||
@@ -52,8 +61,8 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
@doc """
|
@doc """
|
||||||
Given a media source, creates (indexes) the media by creating media_items for each
|
Given a media source, creates (indexes) the media by creating media_items for each
|
||||||
media ID in the source. Afterward, kicks off a download task for each pending media
|
media ID in the source. Afterward, kicks off a download task for each pending media
|
||||||
item belonging to the source. You can't tell me the method name isn't descriptive!
|
item belonging to the source. Returns a list of media items or changesets
|
||||||
Returns a list of media items or changesets (if the media item couldn't be created).
|
(if the media item couldn't be created).
|
||||||
|
|
||||||
Indexing is slow and usually returns a list of all media data at once for record creation.
|
Indexing is slow and usually returns a list of all media data at once for record creation.
|
||||||
To help with this, we use a file follower to watch the file that yt-dlp writes to
|
To help with this, we use a file follower to watch the file that yt-dlp writes to
|
||||||
@@ -61,23 +70,33 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
clarity to the user experience. This has a few things to be aware of which are documented
|
clarity to the user experience. This has a few things to be aware of which are documented
|
||||||
below in the file watcher setup method.
|
below in the file watcher setup method.
|
||||||
|
|
||||||
|
Additionally, in the case of a repeat index we create a download archive file that
|
||||||
|
contains some media IDs that we've indexed in the past. Note that this archive doesn't
|
||||||
|
contain the most recent IDs but rather a subset of IDs that are offset by some amount.
|
||||||
|
Practically, this means that we'll re-index a small handful of media that we've recently
|
||||||
|
indexed, but this is a good thing since it'll let us pick up on any recent changes to the
|
||||||
|
most recent media items.
|
||||||
|
|
||||||
|
We don't create a download archive for playlists (only channels), nor do we create one if
|
||||||
|
the indexing was forced by the user.
|
||||||
|
|
||||||
NOTE: downloads are only enqueued if the source is set to download media. Downloads are
|
NOTE: downloads are only enqueued if the source is set to download media. Downloads are
|
||||||
also enqueued for ALL pending media items, not just the ones that were indexed in this
|
also enqueued for ALL pending media items, not just the ones that were indexed in this
|
||||||
job run. This should ensure that any stragglers are caught if, for some reason, they
|
job run. This should ensure that any stragglers are caught if, for some reason, they
|
||||||
weren't enqueued or somehow got de-queued.
|
weren't enqueued or somehow got de-queued.
|
||||||
|
|
||||||
Since indexing returns all media data EVERY TIME, we that that opportunity to update
|
Available options:
|
||||||
indexing metadata for media items that have already been created.
|
- `was_forced`: Whether the indexing was forced by the user
|
||||||
|
|
||||||
Returns [%MediaItem{} | %Ecto.Changeset{}]
|
Returns [%MediaItem{} | %Ecto.Changeset{}]
|
||||||
"""
|
"""
|
||||||
def index_and_enqueue_download_for_media_items(%Source{} = source) do
|
def index_and_enqueue_download_for_media_items(%Source{} = source, opts \\ []) do
|
||||||
# The media_profile is needed to determine the quality options to _then_ determine a more
|
# The media_profile is needed to determine the quality options to _then_ determine a more
|
||||||
# accurate predicted filepath
|
# accurate predicted filepath
|
||||||
source = Repo.preload(source, [:media_profile])
|
source = Repo.preload(source, [:media_profile])
|
||||||
# See the method definition below for more info on how file watchers work
|
# See the method definition below for more info on how file watchers work
|
||||||
# (important reading if you're not familiar with it)
|
# (important reading if you're not familiar with it)
|
||||||
{:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source)
|
{:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source, opts)
|
||||||
# Reload because the source may have been updated during the (long-running) indexing process
|
# Reload because the source may have been updated during the (long-running) indexing process
|
||||||
# and important settings like `download_media` may have changed.
|
# and important settings like `download_media` may have changed.
|
||||||
source = Repo.reload!(source)
|
source = Repo.reload!(source)
|
||||||
@@ -109,14 +128,16 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
# It attempts a graceful shutdown of the file follower after the indexing is done,
|
# It attempts a graceful shutdown of the file follower after the indexing is done,
|
||||||
# but the FileFollowerServer will also stop itself if it doesn't see any activity
|
# but the FileFollowerServer will also stop itself if it doesn't see any activity
|
||||||
# for a sufficiently long time.
|
# for a sufficiently long time.
|
||||||
defp setup_file_watcher_and_kickoff_indexing(source) do
|
defp setup_file_watcher_and_kickoff_indexing(source, opts) do
|
||||||
|
was_forced = Keyword.get(opts, :was_forced, false)
|
||||||
{:ok, pid} = FileFollowerServer.start_link()
|
{:ok, pid} = FileFollowerServer.start_link()
|
||||||
|
|
||||||
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
|
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
|
||||||
|
|
||||||
command_opts =
|
command_opts =
|
||||||
[output: DownloadOptionBuilder.build_output_path_for(source)] ++
|
[output: DownloadOptionBuilder.build_output_path_for(source)] ++
|
||||||
DownloadOptionBuilder.build_quality_options_for(source)
|
DownloadOptionBuilder.build_quality_options_for(source) ++
|
||||||
|
build_download_archive_options(source, was_forced)
|
||||||
|
|
||||||
runner_opts = [file_listener_handler: handler, use_cookies: source.use_cookies]
|
runner_opts = [file_listener_handler: handler, use_cookies: source.use_cookies]
|
||||||
result = MediaCollection.get_media_attributes_for_collection(source.original_url, command_opts, runner_opts)
|
result = MediaCollection.get_media_attributes_for_collection(source.original_url, command_opts, runner_opts)
|
||||||
@@ -166,4 +187,57 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
|
|
||||||
max(0, index_frequency_seconds - offset_seconds)
|
max(0, index_frequency_seconds - offset_seconds)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The download archive file works in tandem with --break-on-existing to stop
|
||||||
|
# yt-dlp once we've hit media items we've already indexed. But we generate
|
||||||
|
# this list with a bit of an offset so we do intentionally re-scan some media
|
||||||
|
# items to pick up any recent changes (see `get_media_items_for_download_archive`).
|
||||||
|
#
|
||||||
|
# From there, we format the media IDs in the way that yt-dlp expects (ie: "<extractor> <media_id>")
|
||||||
|
# and return the filepath to the caller.
|
||||||
|
defp create_download_archive_file(source) do
|
||||||
|
tmpfile = FilesystemUtils.generate_metadata_tmpfile(:txt)
|
||||||
|
|
||||||
|
archive_contents =
|
||||||
|
source
|
||||||
|
|> get_media_items_for_download_archive()
|
||||||
|
|> Enum.map_join("\n", fn media_item -> "youtube #{media_item.media_id}" end)
|
||||||
|
|
||||||
|
case File.write(tmpfile, archive_contents) do
|
||||||
|
:ok -> tmpfile
|
||||||
|
err -> err
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sorting by `uploaded_at` is important because we want to re-index the most recent
|
||||||
|
# media items first but there is no guarantee of any correlation between ID and uploaded_at.
|
||||||
|
#
|
||||||
|
# The offset is important because we want to re-index some media items that we've
|
||||||
|
# recently indexed to pick up on any changes. The limit is because we want this mechanism
|
||||||
|
# to work even if, for example, the video we were using as a stopping point was deleted.
|
||||||
|
# It's not a perfect system, but it should do well enough.
|
||||||
|
#
|
||||||
|
# The chosen limit and offset are arbitary, independent, and vibes-based. Feel free to
|
||||||
|
# tweak as-needed
|
||||||
|
defp get_media_items_for_download_archive(source) do
|
||||||
|
MediaQuery.new()
|
||||||
|
|> where(^MediaQuery.for_source(source))
|
||||||
|
|> order_by(desc: :uploaded_at)
|
||||||
|
|> limit(50)
|
||||||
|
|> offset(20)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
# The download archive isn't useful for playlists (since those are ordered arbitrarily)
|
||||||
|
# and we don't want to use it if the indexing was forced by the user. In other words,
|
||||||
|
# only create an archive for channels that are being indexed as part of their regular
|
||||||
|
# indexing schedule
|
||||||
|
defp build_download_archive_options(%Source{collection_type: :playlist}, _was_forced), do: []
|
||||||
|
defp build_download_archive_options(_source, true), do: []
|
||||||
|
|
||||||
|
defp build_download_archive_options(source, _was_forced) do
|
||||||
|
archive_file = create_download_archive_file(source)
|
||||||
|
|
||||||
|
[:break_on_existing, download_archive: archive_file]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -45,8 +45,20 @@ defmodule Pinchflat.Utils.FilesystemUtils do
|
|||||||
Returns binary()
|
Returns binary()
|
||||||
"""
|
"""
|
||||||
def generate_metadata_tmpfile(type) do
|
def generate_metadata_tmpfile(type) do
|
||||||
|
filename = StringUtils.random_string(64)
|
||||||
|
# This "namespacing" is more to help with development since things get
|
||||||
|
# weird in my editor when there are thousands of files in a single directory
|
||||||
|
first_two = String.slice(filename, 0..1)
|
||||||
|
second_two = String.slice(filename, 2..3)
|
||||||
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
filepath = Path.join([tmpfile_directory, "#{StringUtils.random_string(64)}.#{type}"])
|
|
||||||
|
filepath =
|
||||||
|
Path.join([
|
||||||
|
tmpfile_directory,
|
||||||
|
first_two,
|
||||||
|
second_two,
|
||||||
|
"#{filename}.#{type}"
|
||||||
|
])
|
||||||
|
|
||||||
:ok = write_p!(filepath, "")
|
:ok = write_p!(filepath, "")
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
|
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
|
||||||
|
|
||||||
case CliUtils.wrap_cmd(command, formatted_command_opts, stderr_to_stdout: true) do
|
case CliUtils.wrap_cmd(command, formatted_command_opts, stderr_to_stdout: true) do
|
||||||
{_, 0} ->
|
# yt-dlp exit codes:
|
||||||
|
# 0 = Everything is successful
|
||||||
|
# 100 = yt-dlp must restart for update to complete
|
||||||
|
# 101 = Download cancelled by --max-downloads etc
|
||||||
|
# 2 = Error in user-provided options
|
||||||
|
# 1 = Any other error
|
||||||
|
{_, status} when status in [0, 101] ->
|
||||||
# IDEA: consider deleting the file after reading it. It's in the tmp dir, so it's not
|
# IDEA: consider deleting the file after reading it. It's in the tmp dir, so it's not
|
||||||
# a huge deal, but it's still a good idea to clean up after ourselves.
|
# a huge deal, but it's still a good idea to clean up after ourselves.
|
||||||
# (even on error? especially on error?)
|
# (even on error? especially on error?)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
Returns {:ok, [map()]} | {:error, any, ...}.
|
Returns {:ok, [map()]} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def get_media_attributes_for_collection(url, command_opts \\ [], addl_opts \\ []) do
|
def get_media_attributes_for_collection(url, command_opts \\ [], addl_opts \\ []) do
|
||||||
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
|
|
||||||
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
|
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
|
||||||
# the first video has not released yet (ie: is a premier). We don't care about
|
# the first video has not released yet (ie: is a premier). We don't care about
|
||||||
# available formats since we're just getting the media details
|
# available formats since we're just getting the media details
|
||||||
@@ -39,7 +38,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
file_listener_handler.(output_filepath)
|
file_listener_handler.(output_filepath)
|
||||||
end
|
end
|
||||||
|
|
||||||
case runner.run(url, action, all_command_opts, output_template, runner_opts) do
|
case backend_runner().run(url, action, all_command_opts, output_template, runner_opts) do
|
||||||
{:ok, output} ->
|
{:ok, output} ->
|
||||||
parsed_lines =
|
parsed_lines =
|
||||||
output
|
output
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
def friendly_sponsorblock_options do
|
def friendly_sponsorblock_options do
|
||||||
[
|
[
|
||||||
{"Disabled (default)", "disabled"},
|
{"Disabled (default)", "disabled"},
|
||||||
|
{"Mark Segments as Chapters", "mark"},
|
||||||
{"Remove Segments", "remove"}
|
{"Remove Segments", "remove"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
@@ -56,7 +57,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def media_center_custom_output_template_options do
|
def media_center_custom_output_template_options do
|
||||||
%{
|
[
|
||||||
season_by_year__episode_by_date: "<code>Season YYYY/sYYYYeMMDD</code>",
|
season_by_year__episode_by_date: "<code>Season YYYY/sYYYYeMMDD</code>",
|
||||||
season_by_year__episode_by_date_and_index:
|
season_by_year__episode_by_date_and_index:
|
||||||
"same as the above but it handles dates better. <strong>This is the recommended option</strong>",
|
"same as the above but it handles dates better. <strong>This is the recommended option</strong>",
|
||||||
@@ -64,11 +65,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
"<code>Season 1/s01eXX</code> where <code>XX</code> is the video's position in the playlist. Only recommended for playlists (not channels) that don't change",
|
"<code>Season 1/s01eXX</code> where <code>XX</code> is the video's position in the playlist. Only recommended for playlists (not channels) that don't change",
|
||||||
static_season__episode_by_date:
|
static_season__episode_by_date:
|
||||||
"<code>Season 1/s01eYYMMDD</code>. Recommended for playlists that might change or where order isn't important"
|
"<code>Season 1/s01eYYMMDD</code>. Recommended for playlists that might change or where order isn't important"
|
||||||
}
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def other_custom_output_template_options do
|
def other_custom_output_template_options do
|
||||||
%{
|
[
|
||||||
upload_day: nil,
|
upload_day: nil,
|
||||||
upload_month: nil,
|
upload_month: nil,
|
||||||
upload_year: nil,
|
upload_year: nil,
|
||||||
@@ -84,8 +85,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
season_episode_index_from_date:
|
season_episode_index_from_date:
|
||||||
"the upload date formatted as <code>sYYYYeMMDDII</code> where <code>II</code> is an index to prevent date collisions",
|
"the upload date formatted as <code>sYYYYeMMDDII</code> where <code>II</code> is an index to prevent date collisions",
|
||||||
media_playlist_index:
|
media_playlist_index:
|
||||||
"the place of the media item in the playlist. Do not use with channels. May not work if the playlist is updated"
|
"the place of the media item in the playlist. Do not use with channels. May not work if the playlist is updated",
|
||||||
}
|
media_item_id: "the ID of the media item in Pinchflat's database",
|
||||||
|
source_id: "the ID of the source in Pinchflat's database",
|
||||||
|
media_profile_id: "the ID of the media profile in Pinchflat's database"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def common_output_template_options do
|
def common_output_template_options do
|
||||||
|
|||||||
@@ -40,11 +40,13 @@ defmodule PinchflatWeb.Sources.SourceHTML do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def rss_feed_url(conn, source) do
|
def rss_feed_url(conn, source) do
|
||||||
|
# NOTE: The reason for this concatenation is to avoid what appears to be a bug in Phoenix
|
||||||
|
# See: https://github.com/phoenixframework/phoenix/issues/6033
|
||||||
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
|
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
|
||||||
end
|
end
|
||||||
|
|
||||||
def opml_feed_url(conn) do
|
def opml_feed_url(conn) do
|
||||||
url(conn, ~p"/sources/opml") <> ".xml"
|
url(conn, ~p"/sources/opml.xml?#{[route_token: Settings.get!(:route_token)]}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def output_path_template_override_placeholders(media_profiles) do
|
def output_path_template_override_placeholders(media_profiles) do
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||||
<:option>
|
<:option>
|
||||||
<span
|
<.link href={rss_feed_url(@conn, @source)} x-data="{ copied: false }" x-on:click={~s"
|
||||||
x-data="{ copied: false }"
|
$event.preventDefault();
|
||||||
x-on:click={"
|
|
||||||
copyWithCallbacks(
|
copyWithCallbacks(
|
||||||
'#{rss_feed_url(@conn, @source)}',
|
'#{rss_feed_url(@conn, @source)}',
|
||||||
() => copied = true,
|
() => copied = true,
|
||||||
() => copied = false
|
() => copied = false
|
||||||
)
|
)
|
||||||
"}
|
"}>
|
||||||
>
|
|
||||||
Copy RSS Feed
|
Copy RSS Feed
|
||||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||||
</span>
|
</.link>
|
||||||
</:option>
|
</:option>
|
||||||
<:option>
|
<:option>
|
||||||
<span x-data="{ copied: false }" x-on:click={~s"
|
<span x-data="{ copied: false }" x-on:click={~s"
|
||||||
@@ -56,7 +54,7 @@
|
|||||||
<.link
|
<.link
|
||||||
href={~p"/sources/#{@source}/force_index"}
|
href={~p"/sources/#{@source}/force_index"}
|
||||||
method="post"
|
method="post"
|
||||||
data-confirm="Are you sure you want to force an index of this source? This isn't normally needed."
|
data-confirm="Are you sure you want index all content from this source? This isn't normally needed."
|
||||||
>
|
>
|
||||||
Force Index
|
Force Index
|
||||||
</.link>
|
</.link>
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
|
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
|
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
|
||||||
<nav>
|
<nav class="flex items-center justify-between gap-6">
|
||||||
<.button color="bg-transparent" x-data="{ copied: false }" x-on:click={~s"
|
<.link href={opml_feed_url(@conn)} x-data="{ copied: false }" x-on:click={~s"
|
||||||
|
$event.preventDefault();
|
||||||
copyWithCallbacks(
|
copyWithCallbacks(
|
||||||
'#{opml_feed_url(@conn)}',
|
'#{opml_feed_url(@conn)}',
|
||||||
() => copied = true,
|
() => copied = true,
|
||||||
() => copied = false
|
() => copied = false
|
||||||
)
|
)
|
||||||
"}>
|
"}>
|
||||||
Copy OPML Feed
|
Copy OPML <span class="hidden sm:inline">Feed</span>
|
||||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||||
</.button>
|
</.link>
|
||||||
<.link href={~p"/sources/new"}>
|
<.link href={~p"/sources/new"}>
|
||||||
<.button color="bg-primary" rounding="rounded-lg">
|
<.button color="bg-primary" rounding="rounded-lg">
|
||||||
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
|
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ defmodule PinchflatWeb.Endpoint do
|
|||||||
Phoenix.Controller.put_router_url(conn, new_base_url)
|
Phoenix.Controller.put_router_url(conn, new_base_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Some podcast clients require file extensions, and others still will _add_
|
||||||
|
# file extensions to XML files if they don't have them. This plug removes
|
||||||
|
# the extension from the path so that the correct route is matched, regardless
|
||||||
|
# of the provided extension.
|
||||||
|
#
|
||||||
|
# This has the downside of in-app generated verified routes not working with
|
||||||
|
# extensions so this behaviour may change in the future.
|
||||||
defp strip_trailing_extension(%{path_info: []} = conn, _opts), do: conn
|
defp strip_trailing_extension(%{path_info: []} = conn, _opts), do: conn
|
||||||
|
|
||||||
defp strip_trailing_extension(conn, _opts) do
|
defp strip_trailing_extension(conn, _opts) do
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
defmodule PinchflatWeb.Plugs do
|
||||||
|
@moduledoc """
|
||||||
|
Custom plugs for PinchflatWeb.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use PinchflatWeb, :router
|
||||||
|
alias Pinchflat.Settings
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
If the `expose_feed_endpoints` setting is true, this plug does nothing. Otherwise, it calls `basic_auth/2`.
|
||||||
|
"""
|
||||||
|
def maybe_basic_auth(conn, opts) do
|
||||||
|
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
basic_auth(conn, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
If the `basic_auth_username` and `basic_auth_password` settings are set, this plug calls `Plug.BasicAuth.basic_auth/3`.
|
||||||
|
"""
|
||||||
|
def basic_auth(conn, _opts) do
|
||||||
|
username = Application.get_env(:pinchflat, :basic_auth_username)
|
||||||
|
password = Application.get_env(:pinchflat, :basic_auth_password)
|
||||||
|
|
||||||
|
if credential_set?(username) && credential_set?(password) do
|
||||||
|
Plug.BasicAuth.basic_auth(conn, username: username, password: password, realm: "Pinchflat")
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Removes the `x-frame-options` header from the response to allow the page to be embedded in an iframe.
|
||||||
|
"""
|
||||||
|
def allow_iframe_embed(conn, _opts) do
|
||||||
|
delete_resp_header(conn, "x-frame-options")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
If the `route_token` query parameter matches the `route_token` setting, this plug does nothing.
|
||||||
|
Otherwise, it sends a 401 response.
|
||||||
|
"""
|
||||||
|
def token_protected_route(%{query_params: %{"route_token" => route_token}} = conn, _opts) do
|
||||||
|
if Settings.get!(:route_token) == route_token do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
send_unauthorized(conn)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_protected_route(conn, _opts) do
|
||||||
|
send_unauthorized(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp credential_set?(credential) do
|
||||||
|
credential && credential != ""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_unauthorized(conn) do
|
||||||
|
conn
|
||||||
|
|> send_resp(:unauthorized, "Unauthorized")
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
defmodule PinchflatWeb.Router do
|
defmodule PinchflatWeb.Router do
|
||||||
use PinchflatWeb, :router
|
use PinchflatWeb, :router
|
||||||
|
import PinchflatWeb.Plugs
|
||||||
import Phoenix.LiveDashboard.Router
|
import Phoenix.LiveDashboard.Router
|
||||||
|
|
||||||
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
|
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
|
||||||
@@ -19,16 +20,17 @@ defmodule PinchflatWeb.Router do
|
|||||||
plug :accepts, ["json"]
|
plug :accepts, ["json"]
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :feeds do
|
scope "/", PinchflatWeb do
|
||||||
plug :maybe_basic_auth
|
pipe_through [:maybe_basic_auth, :token_protected_route]
|
||||||
|
|
||||||
|
# has to match before /sources/:id
|
||||||
|
get "/sources/opml", Podcasts.PodcastController, :opml_feed
|
||||||
end
|
end
|
||||||
|
|
||||||
# Routes in here _may not be_ protected by basic auth. This is necessary for
|
# Routes in here _may not be_ protected by basic auth. This is necessary for
|
||||||
# media streaming to work for RSS podcast feeds.
|
# media streaming to work for RSS podcast feeds.
|
||||||
scope "/", PinchflatWeb do
|
scope "/", PinchflatWeb do
|
||||||
pipe_through :feeds
|
pipe_through :maybe_basic_auth
|
||||||
# has to match before /sources/:id
|
|
||||||
get "/sources/opml", Podcasts.PodcastController, :opml_feed
|
|
||||||
|
|
||||||
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
|
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
|
||||||
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
|
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
|
||||||
@@ -76,31 +78,4 @@ defmodule PinchflatWeb.Router do
|
|||||||
metrics: PinchflatWeb.Telemetry,
|
metrics: PinchflatWeb.Telemetry,
|
||||||
ecto_repos: [Pinchflat.Repo]
|
ecto_repos: [Pinchflat.Repo]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_basic_auth(conn, opts) do
|
|
||||||
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
|
|
||||||
conn
|
|
||||||
else
|
|
||||||
basic_auth(conn, opts)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp basic_auth(conn, _opts) do
|
|
||||||
username = Application.get_env(:pinchflat, :basic_auth_username)
|
|
||||||
password = Application.get_env(:pinchflat, :basic_auth_password)
|
|
||||||
|
|
||||||
if credential_set?(username) && credential_set?(password) do
|
|
||||||
Plug.BasicAuth.basic_auth(conn, username: username, password: password, realm: "Pinchflat")
|
|
||||||
else
|
|
||||||
conn
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp credential_set?(credential) do
|
|
||||||
credential && credential != ""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp allow_iframe_embed(conn, _opts) do
|
|
||||||
delete_resp_header(conn, "x-frame-options")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :pinchflat,
|
app: :pinchflat,
|
||||||
version: "2024.12.29",
|
version: "2025.1.3",
|
||||||
elixir: "~> 1.17",
|
elixir: "~> 1.17",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 442 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddRouteTokenToSettings do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:settings) do
|
||||||
|
add :route_token, :string, null: false, default: "tmp-token"
|
||||||
|
end
|
||||||
|
|
||||||
|
execute "UPDATE settings SET route_token = gen_random_uuid();", "SELECT 1;"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -287,7 +287,19 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
assert {:sponsorblock_remove, "sponsor,intro"} in res
|
assert {:sponsorblock_remove, "sponsor,intro"} in res
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not include :sponsorblock_remove option without categories", %{media_item: media_item} do
|
test "includes :sponsorblock_mark option when specified", %{media_item: media_item} do
|
||||||
|
media_item =
|
||||||
|
update_media_profile_attribute(media_item, %{
|
||||||
|
sponsorblock_behaviour: :mark,
|
||||||
|
sponsorblock_categories: ["sponsor", "intro"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
|
||||||
|
assert {:sponsorblock_mark, "sponsor,intro"} in res
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not include any sponsorblock option without categories", %{media_item: media_item} do
|
||||||
media_item =
|
media_item =
|
||||||
update_media_profile_attribute(media_item, %{
|
update_media_profile_attribute(media_item, %{
|
||||||
sponsorblock_behaviour: :remove,
|
sponsorblock_behaviour: :remove,
|
||||||
@@ -296,9 +308,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
|
|
||||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
|
||||||
refute {:sponsorblock_remove, ""} in res
|
refute Keyword.has_key?(res, :sponsorblock_remove)
|
||||||
refute {:sponsorblock_remove, []} in res
|
refute Keyword.has_key?(res, :sponsorblock_mark)
|
||||||
refute :sponsorblock_remove in res
|
refute :sponsorblock_remove in res
|
||||||
|
refute :sponsorblock_mark in res
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not include any sponsorblock options when disabled", %{media_item: media_item} do
|
test "does not include any sponsorblock options when disabled", %{media_item: media_item} do
|
||||||
@@ -307,9 +320,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
|
|
||||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
|
||||||
refute {:sponsorblock_remove, ""} in res
|
refute Keyword.has_key?(res, :sponsorblock_remove)
|
||||||
refute {:sponsorblock_remove, []} in res
|
refute Keyword.has_key?(res, :sponsorblock_mark)
|
||||||
refute :sponsorblock_remove in res
|
refute :sponsorblock_remove in res
|
||||||
|
refute :sponsorblock_mark in res
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
defmodule Pinchflat.SlowIndexing.FileFollowerServerTest do
|
defmodule Pinchflat.SlowIndexing.FileFollowerServerTest do
|
||||||
use Pinchflat.DataCase
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
alias alias Pinchflat.Utils.FilesystemUtils
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.SlowIndexing.FileFollowerServer
|
alias Pinchflat.SlowIndexing.FileFollowerServer
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
|||||||
@@ -57,25 +57,51 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it indexes the source if it should be indexed" do
|
test "indexes the source if it should be indexed" do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
source = source_fixture(index_frequency_minutes: 10)
|
source = source_fixture(index_frequency_minutes: 10)
|
||||||
|
|
||||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it indexes the source no matter what if the source has never been indexed before" do
|
test "indexes the source no matter what if the source has never been indexed before" do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: nil)
|
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: nil)
|
||||||
|
|
||||||
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
|
test "indexes the source no matter what if the 'force' arg is passed" do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
|
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
|
||||||
|
|
||||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true})
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
|
test "doesn't use a download archive if the index has been forced" do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
|
||||||
|
refute :break_on_existing in opts
|
||||||
|
refute Keyword.has_key?(opts, :download_archive)
|
||||||
|
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
|
source =
|
||||||
|
source_fixture(collection_type: :channel, index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
|
||||||
|
|
||||||
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
|
||||||
expect(YtDlpRunnerMock, :run, 0, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
expect(YtDlpRunnerMock, :run, 0, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
||||||
{:ok, ""}
|
{:ok, ""}
|
||||||
end)
|
end)
|
||||||
@@ -85,7 +111,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it does not reschedule if the source shouldn't be indexed" do
|
test "does not reschedule if the source shouldn't be indexed" do
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
||||||
|
|
||||||
source = source_fixture(index_frequency_minutes: -1)
|
source = source_fixture(index_frequency_minutes: -1)
|
||||||
@@ -94,7 +120,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
refute_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
|
refute_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it kicks off a download job for each pending media item" do
|
test "kicks off a download job for each pending media item" do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
||||||
{:ok, source_attributes_return_fixture()}
|
{:ok, source_attributes_return_fixture()}
|
||||||
end)
|
end)
|
||||||
@@ -105,7 +131,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
assert length(all_enqueued(worker: MediaDownloadWorker)) == 3
|
assert length(all_enqueued(worker: MediaDownloadWorker)) == 3
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it starts a job for any pending media item even if it's from another run" do
|
test "starts a job for any pending media item even if it's from another run" do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
||||||
{:ok, source_attributes_return_fixture()}
|
{:ok, source_attributes_return_fixture()}
|
||||||
end)
|
end)
|
||||||
@@ -117,7 +143,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
assert length(all_enqueued(worker: MediaDownloadWorker)) == 4
|
assert length(all_enqueued(worker: MediaDownloadWorker)) == 4
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it does not kick off a job for media items that could not be saved" do
|
test "does not kick off a job for media items that could not be saved" do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
||||||
{:ok, source_attributes_return_fixture()}
|
{:ok, source_attributes_return_fixture()}
|
||||||
end)
|
end)
|
||||||
@@ -130,7 +156,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
assert length(all_enqueued(worker: MediaDownloadWorker))
|
assert length(all_enqueued(worker: MediaDownloadWorker))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it reschedules the job based on the index frequency" do
|
test "reschedules the job based on the index frequency" do
|
||||||
source = source_fixture(index_frequency_minutes: 10)
|
source = source_fixture(index_frequency_minutes: 10)
|
||||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||||
|
|
||||||
@@ -141,7 +167,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it creates a task for the rescheduled job" do
|
test "creates a task for the rescheduled job" do
|
||||||
source = source_fixture(index_frequency_minutes: 10)
|
source = source_fixture(index_frequency_minutes: 10)
|
||||||
|
|
||||||
task_count_fetcher = fn ->
|
task_count_fetcher = fn ->
|
||||||
@@ -153,7 +179,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it creates a future task for fast indexing if appropriate" do
|
test "creates a future task for fast indexing if appropriate" do
|
||||||
source = source_fixture(index_frequency_minutes: 10, fast_index: true)
|
source = source_fixture(index_frequency_minutes: 10, fast_index: true)
|
||||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||||
|
|
||||||
@@ -164,7 +190,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it deletes existing fast indexing tasks if a new one is created" do
|
test "deletes existing fast indexing tasks if a new one is created" do
|
||||||
source = source_fixture(index_frequency_minutes: 10, fast_index: true)
|
source = source_fixture(index_frequency_minutes: 10, fast_index: true)
|
||||||
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
|
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
|
||||||
task = task_fixture(source_id: source.id, job_id: job.id)
|
task = task_fixture(source_id: source.id, job_id: job.id)
|
||||||
@@ -174,14 +200,14 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it does not create a task for fast indexing otherwise" do
|
test "does not create a task for fast indexing otherwise" do
|
||||||
source = source_fixture(index_frequency_minutes: 10, fast_index: false)
|
source = source_fixture(index_frequency_minutes: 10, fast_index: false)
|
||||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||||
|
|
||||||
refute_enqueued(worker: FastIndexingWorker)
|
refute_enqueued(worker: FastIndexingWorker)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it creates the basic media_item records" do
|
test "creates the basic media_item records" do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
||||||
{:ok, source_attributes_return_fixture()}
|
{:ok, source_attributes_return_fixture()}
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||||
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
|
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
|
||||||
|
|
||||||
|
setup do
|
||||||
|
{:ok, %{source: source_fixture()}}
|
||||||
|
end
|
||||||
|
|
||||||
describe "kickoff_indexing_task/3" do
|
describe "kickoff_indexing_task/3" do
|
||||||
test "schedules a job" do
|
test "schedules a job" do
|
||||||
source = source_fixture(index_frequency_minutes: 1)
|
source = source_fixture(index_frequency_minutes: 1)
|
||||||
@@ -53,6 +57,16 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert_in_delta DateTime.diff(job.scheduled_at, DateTime.utc_now(), :second), 0, 1
|
assert_in_delta DateTime.diff(job.scheduled_at, DateTime.utc_now(), :second), 0, 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "schedules a job immediately if the user is forcing an index" do
|
||||||
|
source = source_fixture(index_frequency_minutes: 30, last_indexed_at: now_minus(5, :minutes))
|
||||||
|
|
||||||
|
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source, %{force: true})
|
||||||
|
|
||||||
|
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
|
||||||
|
|
||||||
|
assert_in_delta DateTime.diff(job.scheduled_at, DateTime.utc_now(), :second), 0, 1
|
||||||
|
end
|
||||||
|
|
||||||
test "creates and attaches a task" do
|
test "creates and attaches a task" do
|
||||||
source = source_fixture(index_frequency_minutes: 1)
|
source = source_fixture(index_frequency_minutes: 1)
|
||||||
|
|
||||||
@@ -123,12 +137,6 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "delete_indexing_tasks/2" do
|
describe "delete_indexing_tasks/2" do
|
||||||
setup do
|
|
||||||
source = source_fixture()
|
|
||||||
|
|
||||||
{:ok, %{source: source}}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "deletes slow indexing tasks for the source", %{source: source} do
|
test "deletes slow indexing tasks for the source", %{source: source} do
|
||||||
{:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id}))
|
{:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id}))
|
||||||
_task = task_fixture(source_id: source.id, job_id: job.id)
|
_task = task_fixture(source_id: source.id, job_id: job.id)
|
||||||
@@ -172,13 +180,13 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "index_and_enqueue_download_for_media_items/1" do
|
describe "index_and_enqueue_download_for_media_items/2" do
|
||||||
setup do
|
setup do
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
|
||||||
{:ok, source_attributes_return_fixture()}
|
{:ok, source_attributes_return_fixture()}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
{:ok, [source: source_fixture()]}
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
test "creates a media_item record for each media ID returned", %{source: source} do
|
test "creates a media_item record for each media ID returned", %{source: source} do
|
||||||
@@ -315,11 +323,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "index_and_enqueue_download_for_media_items/1 when testing file watcher" do
|
describe "index_and_enqueue_download_for_media_items/2 when testing file watcher" do
|
||||||
setup do
|
|
||||||
{:ok, [source: source_fixture()]}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "creates a new media item for everything already in the file", %{source: source} do
|
test "creates a new media item for everything already in the file", %{source: source} do
|
||||||
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
|
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
|
||||||
|
|
||||||
@@ -446,4 +450,62 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
assert [] = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
assert [] = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "index_and_enqueue_download_for_media_items when testing the download archive" do
|
||||||
|
test "a download archive is used if the source is a channel", %{source: source} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
|
||||||
|
assert :break_on_existing in opts
|
||||||
|
assert Keyword.has_key?(opts, :download_archive)
|
||||||
|
|
||||||
|
{:ok, source_attributes_return_fixture()}
|
||||||
|
end)
|
||||||
|
|
||||||
|
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a download archive is not used if the source is not a channel" do
|
||||||
|
source = source_fixture(%{collection_type: :playlist})
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
|
||||||
|
refute :break_on_existing in opts
|
||||||
|
refute Keyword.has_key?(opts, :download_archive)
|
||||||
|
|
||||||
|
{:ok, source_attributes_return_fixture()}
|
||||||
|
end)
|
||||||
|
|
||||||
|
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a download archive is not used if the index has been forced to run" do
|
||||||
|
source = source_fixture(%{collection_type: :channel})
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
|
||||||
|
refute :break_on_existing in opts
|
||||||
|
refute Keyword.has_key?(opts, :download_archive)
|
||||||
|
|
||||||
|
{:ok, source_attributes_return_fixture()}
|
||||||
|
end)
|
||||||
|
|
||||||
|
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source, was_forced: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the download archive is formatted correctly and contains the right video", %{source: source} do
|
||||||
|
media_items =
|
||||||
|
1..21
|
||||||
|
|> Enum.map(fn n ->
|
||||||
|
media_item_fixture(%{source_id: source.id, uploaded_at: now_minus(n, :days)})
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
|
||||||
|
archive_file = Keyword.get(opts, :download_archive)
|
||||||
|
last_media_item = List.last(media_items)
|
||||||
|
|
||||||
|
assert File.read!(archive_file) == "youtube #{last_media_item.media_id}"
|
||||||
|
|
||||||
|
{:ok, source_attributes_return_fixture()}
|
||||||
|
end)
|
||||||
|
|
||||||
|
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
|
|||||||
assert {:ok, _output} = Runner.run(@media_url, :foo, [], "")
|
assert {:ok, _output} = Runner.run(@media_url, :foo, [], "")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "considers a 101 exit code as being successful" do
|
||||||
|
wrap_executable("/app/test/support/scripts/yt-dlp-mocks/101_exit_code.sh", fn ->
|
||||||
|
assert {:ok, _output} = Runner.run(@media_url, :foo, [], "")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
test "includes the media url as the first argument" do
|
test "includes the media url as the first argument" do
|
||||||
assert {:ok, output} = Runner.run(@media_url, :foo, [:ignore_errors], "")
|
assert {:ok, output} = Runner.run(@media_url, :foo, [:ignore_errors], "")
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,34 @@ defmodule PinchflatWeb.PodcastControllerTest do
|
|||||||
import Pinchflat.MediaFixtures
|
import Pinchflat.MediaFixtures
|
||||||
import Pinchflat.SourcesFixtures
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Settings
|
||||||
|
|
||||||
describe "opml_feed" do
|
describe "opml_feed" do
|
||||||
test "renders the XML document", %{conn: conn} do
|
test "renders the XML document", %{conn: conn} do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
|
route_token = Settings.get!(:route_token)
|
||||||
|
|
||||||
conn = get(conn, ~p"/sources/opml" <> ".xml")
|
conn = get(conn, ~p"/sources/opml.xml?#{[route_token: route_token]}")
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
assert {"content-type", "application/opml+xml; charset=utf-8"} in conn.resp_headers
|
assert {"content-type", "application/opml+xml; charset=utf-8"} in conn.resp_headers
|
||||||
assert {"content-disposition", "inline"} in conn.resp_headers
|
assert {"content-disposition", "inline"} in conn.resp_headers
|
||||||
assert conn.resp_body =~ ~s"http://www.example.com/sources/#{source.uuid}/feed.xml"
|
assert conn.resp_body =~ ~s"http://www.example.com/sources/#{source.uuid}/feed.xml"
|
||||||
assert conn.resp_body =~ "text=\"Cool and good internal name!\""
|
assert conn.resp_body =~ "text=\"#{source.custom_name}\""
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 401 if the route token is incorrect", %{conn: conn} do
|
||||||
|
conn = get(conn, ~p"/sources/opml.xml?route_token=incorrect")
|
||||||
|
|
||||||
|
assert conn.status == 401
|
||||||
|
assert conn.resp_body == "Unauthorized"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 401 if the route token is missing", %{conn: conn} do
|
||||||
|
conn = get(conn, ~p"/sources/opml.xml")
|
||||||
|
|
||||||
|
assert conn.status == 401
|
||||||
|
assert conn.resp_body == "Unauthorized"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
defmodule PinchflatWeb.PlugsTest do
|
||||||
|
use PinchflatWeb.ConnCase
|
||||||
|
|
||||||
|
alias PinchflatWeb.Plugs
|
||||||
|
alias Pinchflat.Settings
|
||||||
|
|
||||||
|
describe "maybe_basic_auth/2" do
|
||||||
|
setup do
|
||||||
|
old_username = Application.get_env(:pinchflat, :basic_auth_username)
|
||||||
|
old_password = Application.get_env(:pinchflat, :basic_auth_password)
|
||||||
|
old_expose_feed_endpoints = Application.get_env(:pinchflat, :expose_feed_endpoints)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, old_username)
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, old_password)
|
||||||
|
Application.put_env(:pinchflat, :expose_feed_endpoints, old_expose_feed_endpoints)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses basic auth when expose_feed_endpoints is false" do
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, "user")
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
||||||
|
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
|
||||||
|
|
||||||
|
conn = Plugs.maybe_basic_auth(build_conn(), [])
|
||||||
|
|
||||||
|
assert conn.status == 401
|
||||||
|
assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers
|
||||||
|
end
|
||||||
|
|
||||||
|
test "supplying the correct username and password allows access" do
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, "user")
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
||||||
|
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
|
||||||
|
|
||||||
|
encoded_auth = Plug.BasicAuth.encode_basic_auth("user", "pass")
|
||||||
|
|
||||||
|
conn =
|
||||||
|
build_conn()
|
||||||
|
|> put_req_header("authorization", encoded_auth)
|
||||||
|
|> Plugs.maybe_basic_auth([])
|
||||||
|
|
||||||
|
# nil here means the response is unset, but that's good. It just means we're moving to the next stage
|
||||||
|
assert conn.status == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not use basic auth when expose_feed_endpoints is true" do
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, "user")
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
||||||
|
Application.put_env(:pinchflat, :expose_feed_endpoints, true)
|
||||||
|
|
||||||
|
conn = Plugs.maybe_basic_auth(build_conn(), [])
|
||||||
|
|
||||||
|
assert conn.status == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not use basic auth when username/password aren't set" do
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, nil)
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, nil)
|
||||||
|
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
|
||||||
|
|
||||||
|
conn = Plugs.maybe_basic_auth(build_conn(), [])
|
||||||
|
|
||||||
|
# nil here means the response is unset, but that's good. It just means we're moving to the next stage
|
||||||
|
assert conn.status == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "basic_auth/2" do
|
||||||
|
setup do
|
||||||
|
old_username = Application.get_env(:pinchflat, :basic_auth_username)
|
||||||
|
old_password = Application.get_env(:pinchflat, :basic_auth_password)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, old_username)
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, old_password)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses basic auth when both username and password are set", %{conn: conn} do
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, "user")
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
||||||
|
|
||||||
|
conn = Plugs.basic_auth(conn, [])
|
||||||
|
|
||||||
|
assert conn.status == 401
|
||||||
|
assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers
|
||||||
|
end
|
||||||
|
|
||||||
|
test "providing the username and password allows access", %{conn: conn} do
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, "user")
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_req_header("authorization", Plug.BasicAuth.encode_basic_auth("user", "pass"))
|
||||||
|
|> Plugs.basic_auth([])
|
||||||
|
|
||||||
|
# nil here means the response is unset, but that's good. It just means we're moving to the next stage
|
||||||
|
assert conn.status == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not use basic auth when either username or password is not set", %{conn: conn} do
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, nil)
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
||||||
|
|
||||||
|
conn = Plugs.basic_auth(conn, [])
|
||||||
|
|
||||||
|
assert conn.status == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "treats empty strings as not being set when using basic auth", %{conn: conn} do
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, "")
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
||||||
|
|
||||||
|
conn = Plugs.basic_auth(conn, [])
|
||||||
|
|
||||||
|
assert conn.status == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "allow_iframe_embed/2" do
|
||||||
|
test "deletes the x-frame-options header", %{conn: conn} do
|
||||||
|
conn = put_resp_header(conn, "x-frame-options", "DENY")
|
||||||
|
assert ["DENY"] = get_resp_header(conn, "x-frame-options")
|
||||||
|
|
||||||
|
conn = Plugs.allow_iframe_embed(conn, [])
|
||||||
|
|
||||||
|
assert [] = get_resp_header(conn, "x-frame-options")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "token_protected_route/2" do
|
||||||
|
test "allows access when the route token is correct", %{conn: conn} do
|
||||||
|
route_token = Settings.get!(:route_token)
|
||||||
|
conn = %{conn | query_params: %{"route_token" => route_token}}
|
||||||
|
|
||||||
|
conn = Plugs.token_protected_route(conn, [])
|
||||||
|
|
||||||
|
# nil here means the response is unset, but that's good. It just means we're moving to the next stage
|
||||||
|
assert conn.status == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not allow access when the route token is incorrect", %{conn: conn} do
|
||||||
|
conn = %{conn | query_params: %{"route_token" => "incorrect"}}
|
||||||
|
|
||||||
|
conn = Plugs.token_protected_route(conn, [])
|
||||||
|
|
||||||
|
assert conn.status == 401
|
||||||
|
assert conn.resp_body == "Unauthorized"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not allow access when the route token is missing", %{conn: conn} do
|
||||||
|
conn = %{conn | query_params: %{}}
|
||||||
|
|
||||||
|
conn = Plugs.token_protected_route(conn, [])
|
||||||
|
|
||||||
|
assert conn.status == 401
|
||||||
|
assert conn.resp_body == "Unauthorized"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
defmodule PinchflatWeb.RoutingTest do
|
|
||||||
use PinchflatWeb.ConnCase
|
|
||||||
|
|
||||||
import Pinchflat.SourcesFixtures
|
|
||||||
|
|
||||||
describe "basic_auth plug" do
|
|
||||||
setup do
|
|
||||||
old_username = Application.get_env(:pinchflat, :basic_auth_username)
|
|
||||||
old_password = Application.get_env(:pinchflat, :basic_auth_password)
|
|
||||||
|
|
||||||
on_exit(fn ->
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_username, old_username)
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_password, old_password)
|
|
||||||
end)
|
|
||||||
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
test "it uses basic auth when both username and password are set", %{conn: conn} do
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_username, "user")
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
|
||||||
|
|
||||||
conn = get(conn, "/")
|
|
||||||
|
|
||||||
assert conn.status == 401
|
|
||||||
assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers
|
|
||||||
end
|
|
||||||
|
|
||||||
test "providing the username and password allows access", %{conn: conn} do
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_username, "user")
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
|
||||||
|
|
||||||
conn =
|
|
||||||
conn
|
|
||||||
|> put_req_header("authorization", Plug.BasicAuth.encode_basic_auth("user", "pass"))
|
|
||||||
|> get("/")
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
end
|
|
||||||
|
|
||||||
test "it does not use basic auth when either username or password is not set", %{conn: conn} do
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_username, nil)
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
|
||||||
|
|
||||||
conn = get(conn, "/")
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
end
|
|
||||||
|
|
||||||
test "it treats empty strings as not being set when using basic auth", %{conn: conn} do
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_username, "")
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
|
||||||
|
|
||||||
conn = get(conn, "/")
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "maybe_basic_auth plug" do
|
|
||||||
setup do
|
|
||||||
old_username = Application.get_env(:pinchflat, :basic_auth_username)
|
|
||||||
old_password = Application.get_env(:pinchflat, :basic_auth_password)
|
|
||||||
old_expose_feed_endpoints = Application.get_env(:pinchflat, :expose_feed_endpoints)
|
|
||||||
|
|
||||||
source = source_fixture()
|
|
||||||
|
|
||||||
on_exit(fn ->
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_username, old_username)
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_password, old_password)
|
|
||||||
Application.put_env(:pinchflat, :expose_feed_endpoints, old_expose_feed_endpoints)
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:ok, source: source}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "uses basic auth when expose_feed_endpoints is false", %{source: source} do
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_username, "user")
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
|
||||||
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
|
|
||||||
|
|
||||||
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
|
|
||||||
|
|
||||||
assert conn.status == 401
|
|
||||||
assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not use basic auth when expose_feed_endpoints is true", %{source: source} do
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_username, "user")
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
|
||||||
Application.put_env(:pinchflat, :expose_feed_endpoints, true)
|
|
||||||
|
|
||||||
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not use basic auth when username/password aren't set", %{source: source} do
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_username, nil)
|
|
||||||
Application.put_env(:pinchflat, :basic_auth_password, nil)
|
|
||||||
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
|
|
||||||
|
|
||||||
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
exit 101
|
||||||
@@ -12,3 +12,5 @@ sed -i "s/version: \"$VERSION\"/version: \"$DATE\"/g" mix.exs
|
|||||||
|
|
||||||
# Run checks to ensure it's a valid mix.exs file
|
# Run checks to ensure it's a valid mix.exs file
|
||||||
mix check
|
mix check
|
||||||
|
|
||||||
|
echo "Version bumped successfully to $DATE"
|
||||||
|
|||||||
Reference in New Issue
Block a user