Compare commits

..

9 Commits

Author SHA1 Message Date
Kieran Eglin 1994ea5b08 Added apprise to runtime 2024-04-09 14:07:49 -07:00
Kieran Eglin f9e4e44b0c bumped version 2024-04-09 13:45:38 -07:00
Kieran f2ee3d77a2 Added more custom source attributes to output template (#172) 2024-04-09 10:24:07 -07:00
Kieran Eglin 7fc70da14a updated help text for fast indexing 2024-04-09 09:51:45 -07:00
Kieran 8a0ae89bc0 [Enhancement] Add Apprise support (#170)
* [WIP] add settings sidebar entry and placeholder page

* [WIP] added placeholder UI and logic for settings form

* Added column and UI for apprise server

* Add some tests

* Added placeholder command runner for apprise

* [WIP] Adding apprise package

* Added apprise command runner

* Hooked up apprise notification module

* Ensured apprise was running in verbose mode

* Updated wording of apprise notification

* Added apprise to README
2024-04-09 09:45:39 -07:00
Kieran Eglin a9f40ed843 Bumped version 2024-04-06 13:28:06 -07:00
Kieran f27323ffa3 [Ehnacement] More gracefully handle Sponsorblock failures (#169)
* Updated downloader and runner to handle sponsorblock failures more gracefully

* stopped download worker from running if a media item is preventing download
2024-04-06 13:23:36 -07:00
Kieran 81b49f55bf [Bugfix] Properly escape NFO files (#168)
* Properly escaped NFO file contents

* Added an NFO backfill worker

* Added a try-catch to the backfill since I _really_ don't want failures to halt app boot
2024-04-06 11:48:28 -07:00
Kieran 24875eaeac [Housekeeping] Refactor settings model (#165)
* [WIP] renamed current settings module and tables to have backup suffix

* Created new settings table, schema, and context

* Migrated from old settings module to new one

* Removed settings backup modules

* Added some tests and docs
2024-04-04 12:43:17 -07:00
63 changed files with 1207 additions and 342 deletions
+1
View File
@@ -48,6 +48,7 @@ If it doesn't work for your use case, please make a feature request! You can als
- Uses a novel approach to download new content more quickly than other apps - Uses a novel approach to download new content more quickly than other apps
- Supports downloading audio content - Supports downloading audio content
- Custom rules for handling YouTube Shorts and livestreams - Custom rules for handling YouTube Shorts and livestreams
- Apprise support for notifications
- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media)) - Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
- Advanced options like setting cutoff dates and filtering by title - Advanced options like setting cutoff dates and filtering by title
- Reliable hands-off operation - Reliable hands-off operation
+2
View File
@@ -12,7 +12,9 @@ config :pinchflat,
generators: [timestamp_type: :utc_datetime], generators: [timestamp_type: :utc_datetime],
# Specifying backend data here makes mocking and local testing SUPER easy # Specifying backend data here makes mocking and local testing SUPER easy
yt_dlp_executable: System.find_executable("yt-dlp"), yt_dlp_executable: System.find_executable("yt-dlp"),
apprise_executable: System.find_executable("apprise"),
yt_dlp_runner: Pinchflat.YtDlp.CommandRunner, yt_dlp_runner: Pinchflat.YtDlp.CommandRunner,
apprise_runner: Pinchflat.Notifications.CommandRunner,
media_directory: "/downloads", media_directory: "/downloads",
# The user may or may not store metadata for their needs, but the app will always store its copy # The user may or may not store metadata for their needs, but the app will always store its copy
metadata_directory: "/config/metadata", metadata_directory: "/config/metadata",
+1
View File
@@ -57,6 +57,7 @@ if config_env() == :prod do
config :pinchflat, config :pinchflat,
yt_dlp_executable: System.find_executable("yt-dlp"), yt_dlp_executable: System.find_executable("yt-dlp"),
apprise_executable: System.find_executable("apprise"),
media_directory: "/downloads", media_directory: "/downloads",
metadata_directory: metadata_path, metadata_directory: metadata_path,
extras_directory: extras_path, extras_directory: extras_path,
+1
View File
@@ -3,6 +3,7 @@ import Config
config :pinchflat, config :pinchflat,
# Specifying backend data here makes mocking and local testing SUPER easy # Specifying backend data here makes mocking and local testing SUPER easy
yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]), yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
apprise_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
media_directory: Path.join([System.tmp_dir!(), "test", "media"]), media_directory: Path.join([System.tmp_dir!(), "test", "media"]),
metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]), metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]),
tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"]), tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"]),
+14 -8
View File
@@ -5,15 +5,10 @@ ARG DEV_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEB
FROM ${DEV_IMAGE} FROM ${DEV_IMAGE}
# Set the locale deets
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
# Install debian packages # Install debian packages
RUN apt-get update -qq RUN apt-get update -qq
RUN apt-get install -y inotify-tools ffmpeg curl git openssh-client \ RUN apt-get install -y inotify-tools ffmpeg curl git openssh-client \
python3 python3-pip python3-setuptools python3-wheel python3-dev python3 python3-pip python3-setuptools python3-wheel python3-dev locales
# Install nodejs # Install nodejs
RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
@@ -25,9 +20,20 @@ RUN npm install -g yarn
RUN mix local.hex --force RUN mix local.hex --force
RUN mix local.rebar --force RUN mix local.rebar --force
# Download YT-DLP # Download and update YT-DLP
# NOTE: If you're seeing weird issues, consider using the FFMPEG released by yt-dlp # NOTE: If you're seeing weird issues, consider using the FFMPEG released by yt-dlp
RUN python3 -m pip install -U --pre yt-dlp --break-system-packages RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
RUN chmod a+rx /usr/local/bin/yt-dlp
RUN yt-dlp -U
# Download Apprise
RUN python3 -m pip install -U apprise --break-system-packages
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
# Create app directory and copy the Elixir projects into it. # Create app directory and copy the Elixir projects into it.
WORKDIR /app WORKDIR /app
+70
View File
@@ -0,0 +1,70 @@
defmodule Pinchflat.Boot.NfoBackfillWorker do
@moduledoc false
use Oban.Worker,
queue: :local_metadata,
# This should have it running once _ever_ (until the job is pruned, anyway)
# NOTE: remove within the next month
unique: [period: :infinity, states: Oban.Job.states()],
tags: ["media_item", "media_metadata", "local_metadata", "data_backfill"]
import Ecto.Query, warn: false
require Logger
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
alias Pinchflat.Metadata.NfoBuilder
alias Pinchflat.Metadata.MetadataFileHelpers
@doc """
Runs a one-off backfill job to regenerate NFO files for media items that have
both an NFO file and a metadata file. This is needed because NFO files weren't
escaping characters properly so we need to regenerate them.
This job will only run once as long as I remove it before the jobs are pruned in a month.
Returns :ok
"""
@impl Oban.Worker
def perform(%Oban.Job{}) do
Logger.info("Running NFO backfill worker")
media_items = get_media_items_to_backfill()
Enum.each(media_items, fn media_item ->
nfo_exists = File.exists?(media_item.nfo_filepath)
metadata_exists = File.exists?(media_item.metadata.metadata_filepath)
if nfo_exists && metadata_exists do
Logger.info("NFO and metadata exist for media item #{media_item.id} - proceeding")
regenerate_nfo_for_media_item(media_item)
end
end)
:ok
end
defp get_media_items_to_backfill do
from(m in MediaItem, where: not is_nil(m.nfo_filepath))
|> Repo.all()
|> Repo.preload([:metadata, source: :media_profile])
end
defp regenerate_nfo_for_media_item(media_item) do
try do
case MetadataFileHelpers.read_compressed_metadata(media_item.metadata.metadata_filepath) do
{:ok, metadata} ->
Media.update_media_item(media_item, %{
nfo_filepath: NfoBuilder.build_and_store_for_media_item(media_item.nfo_filepath, metadata)
})
_err ->
Logger.error("Failed to read metadata for media item #{media_item.id}")
end
rescue
e -> Logger.error("Unknown error regenerating NFO file for MI ##{media_item.id}: #{inspect(e)}")
end
end
end
+5 -1
View File
@@ -7,6 +7,9 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
Phoenix supervision tree. Phoenix supervision tree.
""" """
alias Pinchflat.Repo
alias Pinchflat.Boot.NfoBackfillWorker
# restart: :temporary means that this process will never be restarted (ie: will run once and then die) # restart: :temporary means that this process will never be restarted (ie: will run once and then die)
use GenServer, restart: :temporary use GenServer, restart: :temporary
import Ecto.Query, warn: false import Ecto.Query, warn: false
@@ -26,7 +29,8 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
""" """
@impl true @impl true
def init(state) do def init(state) do
# Empty for now, keeping because tasks _will_ be added in future Repo.insert_unique_job(NfoBackfillWorker.new(%{}))
{:ok, state} {:ok, state}
end end
end end
+13 -6
View File
@@ -14,7 +14,6 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.YtDlp.CommandRunner
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Filesystem.FilesystemHelpers
def start_link(opts \\ []) do def start_link(opts \\ []) do
@@ -56,17 +55,25 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
filepath = Path.join(base_dir, "cookies.txt") filepath = Path.join(base_dir, "cookies.txt")
if !File.exists?(filepath) do if !File.exists?(filepath) do
Logger.info("Cookies does not exist - creating it") Logger.info("yt-dlp cookie file does not exist - creating it")
FilesystemHelpers.write_p!(filepath, "") FilesystemHelpers.write_p!(filepath, "")
end end
end end
defp apply_default_settings do defp apply_default_settings do
{:ok, yt_dlp_version} = CommandRunner.version() {:ok, yt_dlp_version} = yt_dlp_runner().version()
{:ok, apprise_version} = apprise_runner().version()
Settings.fetch!(:onboarding, true) Settings.set(yt_dlp_version: yt_dlp_version)
Settings.fetch!(:pro_enabled, false) Settings.set(apprise_version: apprise_version)
Settings.set!(:yt_dlp_version, yt_dlp_version) end
defp yt_dlp_runner do
Application.get_env(:pinchflat, :yt_dlp_runner)
end
defp apprise_runner do
Application.get_env(:pinchflat, :apprise_runner)
end end
end end
@@ -144,6 +144,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
defp output_options_map(source) do defp output_options_map(source) do
%{ %{
"source_custom_name" => source.custom_name, "source_custom_name" => source.custom_name,
"source_collection_id" => source.collection_id,
"source_collection_name" => source.collection_name,
"source_collection_type" => source.collection_type "source_collection_type" => source.collection_type
} }
end end
@@ -40,8 +40,8 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|> 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 or media item is set to not download media, perform a no-op unless forced
if media_item.source.download_media || args["force"] do if (media_item.source.download_media && !media_item.prevent_download) || args["force"] do
download_media_and_schedule_jobs(media_item) download_media_and_schedule_jobs(media_item)
else else
:ok :ok
@@ -58,9 +58,10 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
{:ok, updated_media_item} {:ok, updated_media_item}
err -> {:recovered, _} ->
Logger.error("Failed to download media for media item #{media_item.id}: #{inspect(err)}") {:error, :retry}
{:error, _message} ->
{:error, :download_failed} {:error, :download_failed}
end end
end end
+66 -21
View File
@@ -5,12 +5,15 @@ defmodule Pinchflat.Downloading.MediaDownloader do
to download the media with the desired options. to download the media with the desired options.
""" """
require Logger
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Metadata.NfoBuilder alias Pinchflat.Metadata.NfoBuilder
alias Pinchflat.Metadata.MetadataParser alias Pinchflat.Metadata.MetadataParser
alias Pinchflat.Metadata.MetadataFileHelpers alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Filesystem.FilesystemHelpers
alias Pinchflat.Downloading.DownloadOptionBuilder alias Pinchflat.Downloading.DownloadOptionBuilder
alias Pinchflat.YtDlp.Media, as: YtDlpMedia alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@@ -27,33 +30,69 @@ defmodule Pinchflat.Downloading.MediaDownloader do
Returns {:ok, %MediaItem{}} | {:error, any, ...any} Returns {:ok, %MediaItem{}} | {:error, any, ...any}
""" """
def download_for_media_item(%MediaItem{} = media_item) do def download_for_media_item(%MediaItem{} = media_item) do
item_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile]) output_filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
case download_with_options(media_item.original_url, item_with_preloads) do case download_with_options(media_item.original_url, media_with_preloads, output_filepath) do
{:ok, parsed_json} -> {:ok, parsed_json} ->
parsed_attrs = update_media_item_from_parsed_json(media_with_preloads, parsed_json)
parsed_json
|> MetadataParser.parse_for_media_item()
|> Map.merge(%{
media_downloaded_at: DateTime.utc_now(),
nfo_filepath: determine_nfo_filepath(item_with_preloads, parsed_json),
metadata: %{
# IDEA: might be worth kicking off a job for this since thumbnail fetching
# could fail and I want to handle that in isolation
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, parsed_json),
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item, parsed_json)
}
})
# Don't forgor to use preloaded associations or updates to {:error, message, _exit_code} ->
# associations won't work! Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}")
Media.update_media_item(item_with_preloads, parsed_attrs)
if String.contains?(to_string(message), recoverable_errors()) do
attempt_update_media_item(media_with_preloads, output_filepath)
{:recovered, message}
else
{:error, message}
end
err -> err ->
err Logger.error("Unknown error downloading media item ##{media_with_preloads.id}: #{inspect(err)}")
{:error, "Unknown error: #{inspect(err)}"}
end end
end end
defp attempt_update_media_item(media_with_preloads, output_filepath) do
with {:ok, contents} <- File.read(output_filepath),
{:ok, parsed_json} <- Phoenix.json_library().decode(contents) do
Logger.info("""
Recovery from yt-dlp error seems possible. Updating media item ##{media_with_preloads.id}
with parsed JSON from partial download attempt. Full download will be re-attemted in future
anyway
""")
update_media_item_from_parsed_json(media_with_preloads, parsed_json)
else
err ->
Logger.error("Unable to recover error for media item ##{media_with_preloads.id}: #{inspect(err)}")
{:error, :retry_failed}
end
end
defp update_media_item_from_parsed_json(media_with_preloads, parsed_json) do
parsed_attrs =
parsed_json
|> MetadataParser.parse_for_media_item()
|> Map.merge(%{
media_downloaded_at: DateTime.utc_now(),
nfo_filepath: determine_nfo_filepath(media_with_preloads, parsed_json),
metadata: %{
# IDEA: might be worth kicking off a job for this since thumbnail fetching
# could fail and I want to handle that in isolation
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_with_preloads, parsed_json),
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_with_preloads, parsed_json)
}
})
# Don't forgor to use preloaded associations or updates to
# associations won't work!
Media.update_media_item(media_with_preloads, parsed_attrs)
end
defp determine_nfo_filepath(media_item, parsed_json) do defp determine_nfo_filepath(media_item, parsed_json) do
if media_item.source.media_profile.download_nfo do if media_item.source.media_profile.download_nfo do
filepath = Path.rootname(parsed_json["filepath"]) <> ".nfo" filepath = Path.rootname(parsed_json["filepath"]) <> ".nfo"
@@ -64,9 +103,15 @@ defmodule Pinchflat.Downloading.MediaDownloader do
end end
end end
defp download_with_options(url, item_with_preloads) do defp download_with_options(url, item_with_preloads, output_filepath) do
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads) {:ok, options} = DownloadOptionBuilder.build(item_with_preloads)
YtDlpMedia.download(url, options) YtDlpMedia.download(url, options, output_filepath: output_filepath)
end
defp recoverable_errors do
[
"Unable to communicate with SponsorBlock"
]
end end
end end
@@ -25,7 +25,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
function starts individual indexing tasks for each new media item. I think it does function starts individual indexing tasks for each new media item. I think it does
make sense grammatically, but I could see how that's confusing. make sense grammatically, but I could see how that's confusing.
Returns :ok Returns [binary()] where each binary is the media ID of a new media item.
""" """
def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source) {:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source)
@@ -37,6 +37,8 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
MediaIndexingWorker.kickoff_with_task(source, url) MediaIndexingWorker.kickoff_with_task(source, url)
end) end)
new_media_ids
end end
@doc """ @doc """
@@ -11,8 +11,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
alias __MODULE__ alias __MODULE__
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Settings
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.FastIndexingHelpers alias Pinchflat.FastIndexing.FastIndexingHelpers
alias Pinchflat.Notifications.SourceNotifications
@doc """ @doc """
Starts the source fast indexing worker and creates a task for the source. Starts the source fast indexing worker and creates a task for the source.
@@ -37,8 +39,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
source = Sources.get_source!(source_id) source = Sources.get_source!(source_id)
if source.fast_index do if source.fast_index do
FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source) perform_indexing_and_notification(source)
reschedule_indexing(source) reschedule_indexing(source)
else else
:ok :ok
@@ -48,6 +49,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker 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
apprise_server = Settings.get!(:apprise_server)
new_media_items = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
SourceNotifications.send_new_media_notification(apprise_server, source, length(new_media_items))
end
defp reschedule_indexing(source) do defp reschedule_indexing(source) do
next_run_in = Source.fast_index_frequency() * 60 next_run_in = Source.fast_index_frequency() * 60
+11 -9
View File
@@ -4,6 +4,8 @@ defmodule Pinchflat.Metadata.NfoBuilder do
use by Kodi/Jellyfin and other media center software. use by Kodi/Jellyfin and other media center software.
""" """
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
alias Pinchflat.Metadata.MetadataFileHelpers alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Filesystem.FilesystemHelpers
@@ -42,12 +44,12 @@ defmodule Pinchflat.Metadata.NfoBuilder do
""" """
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?> <?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<episodedetails> <episodedetails>
<title>#{metadata["title"]}</title> <title>#{safe(metadata["title"])}</title>
<showtitle>#{metadata["uploader"]}</showtitle> <showtitle>#{safe(metadata["uploader"])}</showtitle>
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid> <uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
<plot>#{metadata["description"]}</plot> <plot>#{safe(metadata["description"])}</plot>
<aired>#{upload_date}</aired> <aired>#{safe(upload_date)}</aired>
<season>#{upload_date.year}</season> <season>#{safe(upload_date.year)}</season>
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode> <episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
<genre>YouTube</genre> <genre>YouTube</genre>
</episodedetails> </episodedetails>
@@ -58,9 +60,9 @@ defmodule Pinchflat.Metadata.NfoBuilder do
""" """
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?> <?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<tvshow> <tvshow>
<title>#{metadata["title"]}</title> <title>#{safe(metadata["title"])}</title>
<plot>#{metadata["description"]}</plot> <plot>#{safe(metadata["description"])}</plot>
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid> <uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
<genre>YouTube</genre> <genre>YouTube</genre>
</tvshow> </tvshow>
""" """
@@ -0,0 +1,12 @@
defmodule Pinchflat.Notifications.AppriseCommandRunner do
@moduledoc """
A behaviour for running CLI commands against a notification backend (apprise).
Used so we can implement Mox for testing without actually running the
apprise command.
"""
@callback run(binary(), keyword()) :: :ok | {:error, binary()}
@callback run(List.t(), keyword()) :: :ok | {:error, binary()}
@callback version() :: {:ok, binary()} | {:error, binary()}
end
@@ -0,0 +1,65 @@
defmodule Pinchflat.Notifications.CommandRunner do
@moduledoc """
Runs apprise commands using the `System.cmd/3` function
"""
require Logger
alias Pinchflat.Utils.CliUtils
alias Pinchflat.Utils.FunctionUtils
alias Pinchflat.Notifications.AppriseCommandRunner
@behaviour AppriseCommandRunner
@doc """
Runs an apprise command and returns the string output.
Can take a single server string or a list of servers as well as additional
arguments to pass to the command.
Returns {:ok, binary()} | {:error, :no_servers} | {:error, binary()}
"""
@impl AppriseCommandRunner
def run(nil, _), do: {:error, :no_servers}
def run("", _), do: {:error, :no_servers}
def run([], _), do: {:error, :no_servers}
def run(endpoints, command_opts) do
endpoints = List.wrap(endpoints)
default_opts = [:verbose]
parsed_opts = CliUtils.parse_options(default_opts ++ command_opts)
Logger.info("[apprise] called with: #{Enum.join(parsed_opts ++ endpoints, " ")}")
{output, return_code} = System.cmd(backend_executable(), parsed_opts ++ endpoints)
Logger.info("[apprise] response: #{output}")
case return_code do
0 -> {:ok, String.trim(output)}
_ -> {:error, String.trim(output)}
end
end
@doc """
Returns the version of apprise as a string.
Returns {:ok, binary()} | {:error, binary()}
"""
@impl AppriseCommandRunner
def version do
case System.cmd(backend_executable(), ["--version"]) do
{output, 0} ->
output
|> String.split(~r{\r?\n})
|> List.first()
|> String.replace("Apprise", "")
|> String.trim()
|> FunctionUtils.wrap_ok()
{output, _} ->
{:error, output}
end
end
defp backend_executable do
Application.get_env(:pinchflat, :apprise_executable)
end
end
@@ -0,0 +1,77 @@
defmodule Pinchflat.Notifications.SourceNotifications do
@moduledoc """
Contains utilities for sending notifications about sources
"""
require Logger
alias Pinchflat.Repo
alias Pinchflat.Media.MediaQuery
@doc """
Wraps a function that may change the number of pending or downloaded
media items for a source, sending an apprise notification if
the count changes.
Returns the return value of the provided function
"""
def wrap_new_media_notification(servers, source, func) do
before_count = relevant_media_item_count(source)
retval = func.()
after_count = relevant_media_item_count(source)
send_new_media_notification(servers, source, after_count - before_count)
retval
end
@doc """
Sends a notification if the count of new media items has changed
Returns :ok
"""
def send_new_media_notification(_, _, count) when count <= 0, do: :ok
def send_new_media_notification(servers, source, changed_count) do
opts = [
title: "[Pinchflat] New media found",
body: "Found #{changed_count} new media item(s) for #{source.custom_name}. Downloading them now"
]
case backend_runner().run(servers, opts) do
{:ok, _} ->
Logger.info("Sent new media notification for source #{source.id}")
{:error, :no_servers} ->
Logger.info("No notification servers provided for source #{source.id}")
{:error, err} ->
Logger.error("Failed to send new media notification for source #{source.id}: #{err}")
end
:ok
end
defp relevant_media_item_count(source) do
pending_media_item_count(source) + downloaded_media_item_count(source)
end
defp pending_media_item_count(source) do
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_pending_download()
|> Repo.aggregate(:count)
end
defp downloaded_media_item_count(source) do
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> Repo.aggregate(:count)
end
defp backend_runner do
# This approach lets us mock the command for testing
Application.get_env(:pinchflat, :apprise_runner)
end
end
+2 -8
View File
@@ -5,6 +5,8 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
@datetime_format "%a, %d %b %Y %H:%M:%S %z" @datetime_format "%a, %d %b %Y %H:%M:%S %z"
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
alias Pinchflat.Utils.DatetimeUtils alias Pinchflat.Utils.DatetimeUtils
alias Pinchflat.Podcasts.PodcastHelpers alias Pinchflat.Podcasts.PodcastHelpers
alias PinchflatWeb.Router.Helpers, as: Routes alias PinchflatWeb.Router.Helpers, as: Routes
@@ -94,14 +96,6 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
""" """
end end
defp safe(nil), do: ""
defp safe(value) do
value
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
defp generate_self_link(url_base, source) do defp generate_self_link(url_base, source) do
Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml") Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml")
end end
+21 -9
View File
@@ -1,24 +1,36 @@
defmodule Pinchflat.Settings.Setting do defmodule Pinchflat.Settings.Setting do
@moduledoc """ @moduledoc """
A Setting is a key-value pair with a datatype used to track user-level settings. The Setting schema.
""" """
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
schema "settings" do @allowed_fields [
field :name, :string :onboarding,
field :value, :string :pro_enabled,
field :datatype, Ecto.Enum, values: ~w(boolean string integer float)a :yt_dlp_version,
:apprise_version,
:apprise_server
]
timestamps(type: :utc_datetime) @required_fields ~w(
onboarding
pro_enabled
)a
schema "settings" do
field :onboarding, :boolean, default: true
field :pro_enabled, :boolean, default: false
field :yt_dlp_version, :string
field :apprise_version, :string
field :apprise_server, :string
end end
@doc false @doc false
def changeset(setting, attrs) do def changeset(setting, attrs) do
setting setting
|> cast(attrs, [:name, :value, :datatype]) |> cast(attrs, @allowed_fields)
|> validate_required([:name, :value, :datatype]) |> validate_required(@required_fields)
|> unique_constraint([:name])
end end
end end
+50 -64
View File
@@ -2,94 +2,80 @@ defmodule Pinchflat.Settings do
@moduledoc """ @moduledoc """
The Settings context. The Settings context.
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Repo
alias Pinchflat.Settings.Setting alias Pinchflat.Settings.Setting
@doc """ @doc """
Returns the list of settings. Returns the only setting record. It _should_ be impossible
to create or delete this record, so it's assertive about
assuming it's the only one.
Returns [%Setting{}, ...] Returns %Setting{}
""" """
def list_settings do def record do
Repo.all(Setting) Setting
|> limit(1)
|> Repo.one()
end end
@doc """ @doc """
Creates or updates a setting, returning the parsed value. Updates the setting record.
Raises if an unsupported datatype is used. Optionally allows
specifying the datatype.
Returns value in type of `Ecto.Enum.mappings(Setting, :datatype)` Returns {:ok, %Setting{}} | {:error, %Ecto.Changeset{}}
""" """
def set!(name, value) do def update_setting(%Setting{} = setting, attrs) do
set!(name, value, infer_datatype(value)) setting
|> Setting.changeset(attrs)
|> Repo.update()
end end
def set!(name, value, datatype) do @doc """
# Only create if doesn't exist Updates a setting, returning the new value.
case Repo.get_by(Setting, name: to_string(name)) do Is setup to take a keyword list argument so you
nil -> create_setting!(name, value, datatype) can call it like `Settings.set(onboarding: true)`
setting -> update_setting!(setting, value, datatype)
Returns {:ok, value} | {:error, :invalid_key} | {:error, %Ecto.Changeset{}}
"""
def set([{attr, value}]) do
record()
|> update_setting(%{attr => value})
|> case do
{:ok, %{^attr => _}} -> {:ok, value}
{:ok, _} -> {:error, :invalid_key}
{:error, changeset} -> {:error, changeset}
end end
end end
@doc """ @doc """
Gets the parsed value of a setting. Raises if the setting does not exist. Gets the value of a setting.
Returns value in type of `Ecto.Enum.mappings(Setting, :datatype)` Returns {:ok, value} | {:error, :invalid_key}
"""
def get(name) do
case Map.fetch(record(), name) do
{:ok, value} -> {:ok, value}
:error -> {:error, :invalid_key}
end
end
@doc """
Gets the value of a setting, raising if it doesn't exist.
Returns value
""" """
def get!(name) do def get!(name) do
Setting case get(name) do
|> Repo.get_by!(name: to_string(name)) {:ok, value} -> value
|> read_setting() {:error, _} -> raise "Setting `#{name}` not found"
end
@doc """
Attempts to find a setting by name or creates a setting with value
if one doesn't exist, returning the parsed value. Optionally allows
specifying the datatype.
Returns value in type of `Ecto.Enum.mappings(Setting, :datatype)`
"""
def fetch!(name, value) do
fetch!(name, value, infer_datatype(value))
end
def fetch!(name, value, datatype) do
case Repo.get_by(Setting, name: to_string(name)) do
nil -> create_setting!(name, value, datatype)
setting -> read_setting(setting)
end end
end end
defp change_setting(setting, attrs) do @doc """
Returns `%Ecto.Changeset{}`
"""
def change_setting(%Setting{} = setting, attrs \\ %{}) do
Setting.changeset(setting, attrs) Setting.changeset(setting, attrs)
end end
defp create_setting!(name, value, datatype) do
%Setting{}
|> change_setting(%{name: to_string(name), value: to_string(value), datatype: datatype})
|> Repo.insert!()
|> read_setting()
end
defp update_setting!(setting, value, datatype) do
setting
|> change_setting(%{value: to_string(value), datatype: datatype})
|> Repo.update!()
|> read_setting()
end
defp read_setting(%{value: value, datatype: :string}), do: value
defp read_setting(%{value: value, datatype: :boolean}), do: value in ["true", "t", "1"]
defp read_setting(%{value: value, datatype: :integer}), do: String.to_integer(value)
defp read_setting(%{value: value, datatype: :float}), do: String.to_float(value)
defp infer_datatype(value) when is_boolean(value), do: :boolean
defp infer_datatype(value) when is_integer(value), do: :integer
defp infer_datatype(value) when is_float(value), do: :float
defp infer_datatype(value) when is_binary(value), do: :string
end end
@@ -11,9 +11,11 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
alias __MODULE__ alias __MODULE__
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Settings
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.FastIndexingWorker alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.SlowIndexing.SlowIndexingHelpers
alias Pinchflat.Notifications.SourceNotifications
@doc """ @doc """
Starts the source slow indexing worker and creates a task for the source. Starts the source slow indexing worker and creates a task for the source.
@@ -78,21 +80,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
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source) perform_indexing_and_notification(source)
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
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source) perform_indexing_and_notification(source)
: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
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source) perform_indexing_and_notification(source)
end end
:ok :ok
@@ -102,6 +104,14 @@ 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
apprise_server = Settings.get!(:apprise_server)
SourceNotifications.wrap_new_media_notification(apprise_server, source, fn ->
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end)
end
defp reschedule_indexing(source) do defp reschedule_indexing(source) do
next_run_in = source.index_frequency_minutes * 60 next_run_in = source.index_frequency_minutes * 60
@@ -60,7 +60,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
def index_and_enqueue_download_for_media_items(%Source{} = source) do def index_and_enqueue_download_for_media_items(%Source{} = source) do
# 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} = get_media_attributes_for_collection_and_setup_file_watcher(source) {:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source)
# 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)
@@ -84,15 +84,15 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
# lines (ie: you should gracefully fail if you can't parse a line). # lines (ie: you should gracefully fail if you can't parse a line).
# #
# This works in-tandem with the normal (blocking) media indexing behaviour. When # This works in-tandem with the normal (blocking) media indexing behaviour. When
# the `get_media_attributes_for_collection` method completes it'll return the FULL result to # the `setup_file_watcher_and_kickoff_indexing` method completes it'll return the
# the caller for parsing. Ideally, every item in the list will have already # FULL result to the caller for parsing. Ideally, every item in the list will have already
# been processed by the file follower, but if not, the caller handles creation # been processed by the file follower, but if not, the caller handles creation
# of any media items that were missed/initially failed. # of any media items that were missed/initially failed.
# #
# 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 get_media_attributes_for_collection_and_setup_file_watcher(source) do defp setup_file_watcher_and_kickoff_indexing(source) do
{: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
+48
View File
@@ -0,0 +1,48 @@
defmodule Pinchflat.Utils.CliUtils do
@moduledoc """
Utility methods for working with CLI executables
"""
alias Pinchflat.Utils.StringUtils
@doc """
Parses a list of command options into a list of strings suitable for passing to
`System.cmd/3`.
We want to satisfy the following behaviours:
1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
2. If the key is a string, assume we want it as-is and don't convert it
3. If the key is accompanied by a value, append the value to the list
4. If the key is not accompanied by a value, assume it's a flag and PREpend it to the list
Returns [binary()]
"""
def parse_options(command_opts) do
command_opts
|> List.wrap()
|> Enum.reduce([], &parse_option/2)
end
defp parse_option({k, v}, acc) when is_atom(k) do
stringified_key = StringUtils.to_kebab_case(Atom.to_string(k))
parse_option({"--#{stringified_key}", v}, acc)
end
defp parse_option({k, v}, acc) when is_binary(k) do
acc ++ [k, to_string(v)]
end
defp parse_option(arg, acc) when is_atom(arg) do
stringified_arg =
arg
|> Atom.to_string()
|> StringUtils.to_kebab_case()
parse_option("--#{stringified_arg}", acc)
end
defp parse_option(arg, acc) when is_binary(arg) do
acc ++ [arg]
end
end
+17
View File
@@ -0,0 +1,17 @@
defmodule Pinchflat.Utils.XmlUtils do
@moduledoc """
Utility methods for working with XML documents
"""
@doc """
Escapes invalid XML characters in a string
Returns binary()
"""
def safe(value) do
value
|> to_string()
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
end
+19 -37
View File
@@ -5,11 +5,11 @@ defmodule Pinchflat.YtDlp.CommandRunner do
require Logger require Logger
alias Pinchflat.Utils.StringUtils alias Pinchflat.Utils.CliUtils
alias Pinchflat.YtDlp.YtDlpCommandRunner
alias Pinchflat.Filesystem.FilesystemHelpers, as: FSUtils alias Pinchflat.Filesystem.FilesystemHelpers, as: FSUtils
alias Pinchflat.YtDlp.BackendCommandRunner
@behaviour BackendCommandRunner @behaviour YtDlpCommandRunner
@doc """ @doc """
Runs a yt-dlp command and returns the string output. Saves the output to Runs a yt-dlp command and returns the string output. Saves the output to
@@ -23,16 +23,16 @@ defmodule Pinchflat.YtDlp.CommandRunner do
Returns {:ok, binary()} | {:error, output, status}. Returns {:ok, binary()} | {:error, output, status}.
""" """
@impl BackendCommandRunner @impl YtDlpCommandRunner
def run(url, command_opts, output_template, addl_opts \\ []) do def run(url, command_opts, output_template, addl_opts \\ []) do
# This approach lets us mock the command for testing # This approach lets us mock the command for testing
command = backend_executable() command = backend_executable()
# These must stay in exactly this order, hence why I'm giving it its own variable. # These must stay in exactly this order, hence why I'm giving it its own variable.
# Also, can't use RAM file since yt-dlp needs a concrete filepath. # Also, can't use RAM file since yt-dlp needs a concrete filepath.
output_filepath = Keyword.get(addl_opts, :output_filepath, FSUtils.generate_metadata_tmpfile(:json)) output_filepath = generate_output_filepath(addl_opts)
print_to_file_opts = [{:print_to_file, output_template}, output_filepath] print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
cookie_opts = build_cookie_options() cookie_opts = build_cookie_options()
formatted_command_opts = [url] ++ parse_options(command_opts ++ print_to_file_opts ++ cookie_opts) formatted_command_opts = [url] ++ CliUtils.parse_options(command_opts ++ print_to_file_opts ++ cookie_opts)
Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}") Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
@@ -48,7 +48,12 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end end
end end
@impl BackendCommandRunner @doc """
Returns the version of yt-dlp as a string
Returns {:ok, binary()} | {:error, binary()}
"""
@impl YtDlpCommandRunner
def version do def version do
command = backend_executable() command = backend_executable()
@@ -61,6 +66,13 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end end
end end
defp generate_output_filepath(addl_opts) do
case Keyword.get(addl_opts, :output_filepath) do
nil -> FSUtils.generate_metadata_tmpfile(:json)
path -> path
end
end
defp build_cookie_options do defp build_cookie_options do
base_dir = Application.get_env(:pinchflat, :extras_directory) base_dir = Application.get_env(:pinchflat, :extras_directory)
cookie_file = Path.join(base_dir, "cookies.txt") cookie_file = Path.join(base_dir, "cookies.txt")
@@ -74,36 +86,6 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end end
end end
# We want to satisfy the following behaviours:
#
# 1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
# 2. If the key is a string, assume we want it as-is and don't convert it
# 3. If the key is accompanied by a value, append the value to the list
# 4. If the key is not accompanied by a value, assume it's a flag and PREpend it to the list
defp parse_options(command_opts) do
Enum.reduce(command_opts, [], &parse_option/2)
end
defp parse_option({k, v}, acc) when is_atom(k) do
stringified_key = StringUtils.to_kebab_case(Atom.to_string(k))
parse_option({"--#{stringified_key}", v}, acc)
end
defp parse_option({k, v}, acc) when is_binary(k) do
acc ++ [k, to_string(v)]
end
defp parse_option(arg, acc) when is_atom(arg) do
stringified_arg = StringUtils.to_kebab_case(Atom.to_string(arg))
parse_option("--#{stringified_arg}", acc)
end
defp parse_option(arg, acc) when is_binary(arg) do
acc ++ [arg]
end
defp backend_executable do defp backend_executable do
Application.get_env(:pinchflat, :yt_dlp_executable) Application.get_env(:pinchflat, :yt_dlp_executable)
end end
+2 -2
View File
@@ -35,10 +35,10 @@ defmodule Pinchflat.YtDlp.Media do
Returns {:ok, map()} | {:error, any, ...}. Returns {:ok, map()} | {:error, any, ...}.
""" """
def download(url, command_opts \\ []) do def download(url, command_opts \\ [], addl_opts \\ []) do
opts = [:no_simulate] ++ command_opts opts = [:no_simulate] ++ command_opts
with {:ok, output} <- backend_runner().run(url, opts, "after_move:%()j"), with {:ok, output} <- backend_runner().run(url, opts, "after_move:%()j", addl_opts),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do {:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, parsed_json} {:ok, parsed_json}
else else
@@ -1,4 +1,4 @@
defmodule Pinchflat.YtDlp.BackendCommandRunner do defmodule Pinchflat.YtDlp.YtDlpCommandRunner do
@moduledoc """ @moduledoc """
A behaviour for running CLI commands against a downloader backend (yt-dlp). A behaviour for running CLI commands against a downloader backend (yt-dlp).
@@ -247,6 +247,7 @@ defmodule PinchflatWeb.CoreComponents do
attr :label_suffix, :string, default: nil attr :label_suffix, :string, default: nil
attr :value, :any attr :value, :any
attr :help, :string, default: nil attr :help, :string, default: nil
attr :html_help, :boolean, default: false
attr :type, :string, attr :type, :string,
default: "text", default: "text",
@@ -298,7 +299,7 @@ defmodule PinchflatWeb.CoreComponents do
<%= @label %> <%= @label %>
<span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span> <span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
</label> </label>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
""" """
@@ -325,7 +326,7 @@ defmodule PinchflatWeb.CoreComponents do
</label> </label>
</div> </div>
</section> </section>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
""" """
@@ -356,7 +357,7 @@ defmodule PinchflatWeb.CoreComponents do
> >
</div> </div>
</div> </div>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
</div> </div>
@@ -387,7 +388,7 @@ defmodule PinchflatWeb.CoreComponents do
</select> </select>
<%= render_slot(@inner_block) %> <%= render_slot(@inner_block) %>
</div> </div>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
""" """
@@ -411,7 +412,7 @@ defmodule PinchflatWeb.CoreComponents do
]} ]}
{@rest} {@rest}
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea> ><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
""" """
@@ -438,7 +439,7 @@ defmodule PinchflatWeb.CoreComponents do
]} ]}
{@rest} {@rest}
/> />
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error> <.error :for={msg <- @errors}><%= msg %></.error>
</div> </div>
""" """
@@ -24,6 +24,7 @@
<.sidebar_item icon="hero-home" text="Home" href={~p"/"} /> <.sidebar_item icon="hero-home" text="Home" href={~p"/"} />
<.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} /> <.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} />
<.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} /> <.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} />
<.sidebar_item icon="hero-cog-6-tooth" text="Settings" href={~p"/settings"} />
</ul> </ul>
</div> </div>
</nav> </nav>
@@ -38,12 +39,7 @@
target="_blank" target="_blank"
href="https://github.com/kieraneglin/pinchflat/wiki" href="https://github.com/kieraneglin/pinchflat/wiki"
/> />
<.sidebar_item <.sidebar_item icon="hero-cog" text="Github" target="_blank" href="https://github.com/kieraneglin/pinchflat" />
icon="hero-code-bracket"
text="Github"
target="_blank"
href="https://github.com/kieraneglin/pinchflat"
/>
<li> <li>
<span <span
class={[ class={[
@@ -30,7 +30,7 @@ defmodule Pinchflat.UpgradeButtonLive do
|> String.downcase() |> String.downcase()
if normalized_text == "got it!" do if normalized_text == "got it!" do
Settings.set!(:pro_enabled, true) Settings.set(pro_enabled: true)
{:noreply, update(socket, :button_disabled, fn _ -> false end)} {:noreply, update(socket, :button_disabled, fn _ -> false end)}
else else
@@ -59,6 +59,9 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
upload_year: nil, upload_year: nil,
upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD", upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD",
source_custom_name: "the name of the sources that use this profile", source_custom_name: "the name of the sources that use this profile",
source_collection_id: "the YouTube ID of the sources that use this profile",
source_collection_name:
"the YouTube name of the sources that use this profile (often the same as source_custom_name)",
source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'", source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'",
artist_name: "the name of the artist with fallbacks to other uploader fields" artist_name: "the name of the artist with fallbacks to other uploader fields"
} }
@@ -11,7 +11,7 @@ defmodule PinchflatWeb.Pages.PageController do
done_onboarding = params["onboarding"] == "0" done_onboarding = params["onboarding"] == "0"
force_onboarding = params["onboarding"] == "1" force_onboarding = params["onboarding"] == "1"
if done_onboarding, do: Settings.set!(:onboarding, false) if done_onboarding, do: Settings.set(onboarding: false)
if force_onboarding || Settings.get!(:onboarding) do if force_onboarding || Settings.get!(:onboarding) do
render_onboarding_page(conn) render_onboarding_page(conn)
@@ -30,7 +30,7 @@ defmodule PinchflatWeb.Pages.PageController do
end end
defp render_onboarding_page(conn) do defp render_onboarding_page(conn) do
Settings.set!(:onboarding, true) Settings.set(onboarding: true)
conn conn
|> render(:onboarding_checklist, |> render(:onboarding_checklist,
@@ -0,0 +1,26 @@
defmodule PinchflatWeb.Settings.SettingController do
use PinchflatWeb, :controller
alias Pinchflat.Settings
def show(conn, _params) do
setting = Settings.record()
changeset = Settings.change_setting(setting)
render(conn, "show.html", changeset: changeset)
end
def update(conn, %{"setting" => setting_params}) do
setting = Settings.record()
case Settings.update_setting(setting, setting_params) do
{:ok, _} ->
conn
|> put_flash(:info, "Settings updated successfully.")
|> redirect(to: ~p"/settings")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "show.html", changeset: changeset)
end
end
end
@@ -0,0 +1,20 @@
defmodule PinchflatWeb.Settings.SettingHTML do
use PinchflatWeb, :html
embed_templates "setting_html/*"
@doc """
Renders a setting form.
"""
attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true
def setting_form(assigns)
def apprise_server_help do
url = "https://github.com/caronc/apprise/wiki/URLBasics"
classes = "underline decoration-bodydark decoration-1 hover:decoration-white"
~s(Server endpoint for Apprise notifications when new media is found. See <a href="#{url}" class="#{classes}" target="_blank">Apprise docs</a> for more information)
end
end
@@ -0,0 +1,21 @@
<.simple_form :let={f} for={@changeset} action={@action}>
<.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below.
</.error>
<h3 class="mt-8 text-2xl text-black dark:text-white">
Notification Settings
</h3>
<.input
field={f[:apprise_server]}
type="text"
label="Apprise Server"
help={apprise_server_help()}
html_help={true}
inputclass="font-mono text-sm"
placeholder="https://discordapp.com/api/webhooks/{WebhookID}/{WebhookToken}"
/>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Settings</.button>
</.simple_form>
@@ -0,0 +1,12 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
<div class="flex gap-3 items-center">
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Settings
</h2>
</div>
</div>
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
<div class="max-w-full overflow-x-auto">
<.setting_form changeset={@changeset} action={~p"/settings"} />
</div>
</div>
@@ -56,7 +56,7 @@
type="toggle" type="toggle"
label="Use Fast Indexing" label="Use Fast Indexing"
label_suffix="(pro)" label_suffix="(pro)"
help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info" help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. Does not work with private playlists. See below for more info"
x-init=" x-init="
// `enabled` is the data attribute that the toggle uses internally // `enabled` is the data attribute that the toggle uses internally
fastIndexingEnabled = enabled fastIndexingEnabled = enabled
+1
View File
@@ -30,6 +30,7 @@ defmodule PinchflatWeb.Router do
resources "/media_profiles", MediaProfiles.MediaProfileController resources "/media_profiles", MediaProfiles.MediaProfileController
resources "/search", Searches.SearchController, only: [:show], singleton: true resources "/search", Searches.SearchController, only: [:show], singleton: true
resources "/settings", Settings.SettingController, only: [:show, :update], singleton: true
resources "/sources", Sources.SourceController do resources "/sources", Sources.SourceController do
post "/force_download", Sources.SourceController, :force_download post "/force_download", Sources.SourceController, :force_download
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "0.1.8", version: "0.1.10",
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,
@@ -0,0 +1,7 @@
defmodule Pinchflat.Repo.Migrations.RenameSettingsTable do
use Ecto.Migration
def change do
rename table(:settings), to: table(:settings_backup)
end
end
@@ -0,0 +1,29 @@
defmodule Pinchflat.Repo.Migrations.CreateNewSettings do
use Ecto.Migration
def up do
create table(:settings) do
add :onboarding, :boolean, default: true, null: false
add :pro_enabled, :boolean, default: false, null: false
add :yt_dlp_version, :string
end
# Make an initial record because this will be the only one ever inserted
execute "INSERT INTO settings (onboarding, pro_enabled, yt_dlp_version) VALUES (true, false, NULL)"
# Set the value of onboarding to the previous version set in `settings_backup`
execute """
UPDATE settings
SET onboarding = COALESCE((SELECT value = 'true' FROM settings_backup WHERE name = 'onboarding'), true)
"""
execute """
UPDATE settings
SET pro_enabled = COALESCE((SELECT value = 'true' FROM settings_backup WHERE name = 'pro_enabled'), false)
"""
end
def down do
drop table(:settings)
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddAppriseServersToSettings do
use Ecto.Migration
def change do
alter table(:settings) do
add :apprise_server, :string
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddAppriseVersionToSettings do
use Ecto.Migration
def change do
alter table(:settings) do
add :apprise_version, :string
end
end
end
+4 -2
View File
@@ -79,7 +79,7 @@ ARG PORT=8945
RUN apt-get update -y RUN apt-get update -y
RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
ffmpeg curl git openssh-client nano ffmpeg curl git openssh-client nano python3 python3-pip
RUN apt-get clean && rm -f /var/lib/apt/lists/*_* RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
# Download and update YT-DLP # Download and update YT-DLP
@@ -87,9 +87,11 @@ RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o
RUN chmod a+rx /usr/local/bin/yt-dlp RUN chmod a+rx /usr/local/bin/yt-dlp
RUN yt-dlp -U RUN yt-dlp -U
# Download Apprise
RUN python3 -m pip install -U apprise --break-system-packages
# Set the locale # Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG en_US.UTF-8 ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8 ENV LC_ALL en_US.UTF-8
@@ -1,25 +1,65 @@
defmodule Pinchflat.Boot.PreJobStartupTasksTest do defmodule Pinchflat.Boot.PreJobStartupTasksTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Mox
import Pinchflat.JobFixtures
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.Settings.Setting
alias Pinchflat.Boot.PreJobStartupTasks alias Pinchflat.Boot.PreJobStartupTasks
describe "apply_default_settings" do setup do
setup do stub(YtDlpRunnerMock, :version, fn -> {:ok, "1"} end)
Repo.delete_all(Setting) stub(AppriseRunnerMock, :version, fn -> {:ok, "2"} end)
:ok :ok
end
describe "reset_executing_jobs" do
test "resets executing jobs" do
job = job_fixture()
Repo.update_all(Oban.Job, set: [state: "executing"])
assert Repo.reload!(job).state == "executing"
PreJobStartupTasks.init(%{})
assert Repo.reload!(job).state == "retryable"
end
end
describe "create_blank_cookie_file" do
test "creates a blank cookie file" do
base_dir = Application.get_env(:pinchflat, :extras_directory)
filepath = Path.join(base_dir, "cookies.txt")
File.rm(filepath)
refute File.exists?(filepath)
PreJobStartupTasks.init(%{})
assert File.exists?(filepath)
end
end
describe "apply_default_settings" do
test "sets yt_dlp version" do
Settings.set(yt_dlp_version: nil)
refute Settings.get!(:yt_dlp_version)
PreJobStartupTasks.init(%{})
assert Settings.get!(:yt_dlp_version)
end end
test "sets default settings" do test "sets apprise version" do
assert_raise Ecto.NoResultsError, fn -> Settings.get!(:onboarding) end Settings.set(apprise_version: nil)
assert_raise Ecto.NoResultsError, fn -> Settings.get!(:pro_enabled) end
PreJobStartupTasks.start_link() refute Settings.get!(:apprise_version)
assert Settings.get!(:onboarding) PreJobStartupTasks.init(%{})
refute Settings.get!(:pro_enabled)
assert Settings.get!(:apprise_version)
end end
end end
end end
@@ -4,6 +4,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
import Mox import Mox
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
alias Pinchflat.Media
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Filesystem.FilesystemHelpers
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
@@ -55,7 +56,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
describe "perform/1" do describe "perform/1" do
test "it saves attributes to the media_item", %{media_item: media_item} do test "it saves attributes to the media_item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -65,7 +66,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "it saves the metadata to the media_item", %{media_item: media_item} do test "it saves the metadata to the media_item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -82,7 +83,19 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "it sets the job to retryable if the download fails", %{media_item: media_item} do test "it sets the job to retryable if the download fails", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "error"} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "error"} end)
Oban.Testing.with_testing_mode(:inline, fn ->
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id}))
assert job.state == "retryable"
end)
end
test "sets the job to retryable if the download failed and was retried", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, "Unable to communicate with SponsorBlock", 1}
end)
Oban.Testing.with_testing_mode(:inline, fn -> Oban.Testing.with_testing_mode(:inline, fn ->
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id})) {:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id}))
@@ -92,29 +105,38 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end end
test "it ensures error are returned in a 2-item tuple", %{media_item: media_item} do test "it ensures error are returned in a 2-item tuple", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "error", 1} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "error", 1} end)
assert {:error, :download_failed} = perform_job(MediaDownloadWorker, %{id: media_item.id}) assert {:error, :download_failed} = perform_job(MediaDownloadWorker, %{id: media_item.id})
end end
test "it does not download if the source is set to not download", %{media_item: media_item} do test "it does not download if the source is set to not download", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> :ok end) expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl -> :ok end)
Sources.update_source(media_item.source, %{download_media: false}) Sources.update_source(media_item.source, %{download_media: false})
perform_job(MediaDownloadWorker, %{id: media_item.id}) perform_job(MediaDownloadWorker, %{id: media_item.id})
end end
test "does not download if the media item is set to not download", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl -> :ok end)
Media.update_media_item(media_item, %{prevent_download: true})
perform_job(MediaDownloadWorker, %{id: media_item.id})
end
test "downloads anyway if forced", %{media_item: media_item} do test "downloads anyway if forced", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> :ok end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> :ok end)
Sources.update_source(media_item.source, %{download_media: false}) Sources.update_source(media_item.source, %{download_media: false})
Media.update_media_item(media_item, %{prevent_download: true})
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true}) perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end 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, _addl ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
FilesystemHelpers.write_p!(metadata["filepath"], "test") FilesystemHelpers.write_p!(metadata["filepath"], "test")
@@ -25,9 +25,11 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
describe "download_for_media_item/3" do describe "download_for_media_item/3" do
test "it calls the backend runner", %{media_item: media_item} do test "it calls the backend runner", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn url, _opts, ot -> expect(YtDlpRunnerMock, :run, fn url, _opts, ot, addl ->
assert url == media_item.original_url assert url == media_item.original_url
assert ot == "after_move:%()j" assert ot == "after_move:%()j"
assert [{:output_filepath, filepath}] = addl
assert is_binary(filepath)
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -36,7 +38,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end end
test "it saves the metadata filepath to the database", %{media_item: media_item} do test "it saves the metadata filepath to the database", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -47,18 +49,56 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/maxresdefault.jpg" assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/maxresdefault.jpg"
end end
test "errors are passed through", %{media_item: media_item} do test "non-recoverable errors are passed through", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, :some_error} {:error, :some_error, 1}
end) end)
assert {:error, :some_error} = MediaDownloader.download_for_media_item(media_item) assert {:error, :some_error} = MediaDownloader.download_for_media_item(media_item)
end end
test "unknown errors are passed through", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, :some_error}
end)
assert {:error, message} = MediaDownloader.download_for_media_item(media_item)
assert message == "Unknown error: {:error, :some_error}"
end
end
describe "download_for_media_item/3 when testing retries" do
test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock"
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, message, 1}
end)
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
end
test "attempts to update the media item on recoverable errors", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock"
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
[{:output_filepath, filepath}] = addl
File.write(filepath, render_metadata(:media_metadata))
{:error, message, 1}
end)
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
media_item = Repo.reload(media_item)
assert DateTime.diff(DateTime.utc_now(), media_item.media_downloaded_at) < 2
assert String.ends_with?(media_item.media_filepath, ".mkv")
end
end end
describe "download_for_media_item/3 when testing media_item attributes" do describe "download_for_media_item/3 when testing media_item attributes" do
setup do setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -100,7 +140,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end end
test "it extracts the thumbnail_filepath", %{media_item: media_item} do test "it extracts the thumbnail_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
thumbnail_filepath = thumbnail_filepath =
@@ -124,7 +164,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end end
test "it extracts the metadata_filepath", %{media_item: media_item} do test "it extracts the metadata_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
infojson_filepath = metadata["infojson_filename"] infojson_filepath = metadata["infojson_filename"]
@@ -143,7 +183,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
describe "download_for_media_item/3 when testing NFO generation" do describe "download_for_media_item/3 when testing NFO generation" do
setup do setup do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -24,7 +24,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
test "enqueues a new worker for each new media_id in the source's RSS feed", %{source: source} do test "enqueues a new worker for each new media_id in the source's RSS feed", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
assert :ok = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source) assert [_] = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
assert [worker] = all_enqueued(worker: MediaIndexingWorker) assert [worker] = all_enqueued(worker: MediaIndexingWorker)
assert worker.args["id"] == source.id assert worker.args["id"] == source.id
@@ -35,10 +35,16 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
media_item_fixture(source_id: source.id, media_id: "test_1") media_item_fixture(source_id: source.id, media_id: "test_1")
assert :ok = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source) assert [] = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
refute_enqueued(worker: MediaIndexingWorker) refute_enqueued(worker: MediaIndexingWorker)
end end
test "returns the IDs of the found media items", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
assert ["test_1"] = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
end
end end
describe "index_and_enqueue_download_for_media_item/2" do describe "index_and_enqueue_download_for_media_item/2" do
@@ -4,6 +4,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
import Mox import Mox
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
alias Pinchflat.Settings
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.FastIndexingWorker alias Pinchflat.FastIndexing.FastIndexingWorker
@@ -74,4 +75,28 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
assert :ok = perform_job(FastIndexingWorker, %{id: 0}) assert :ok = perform_job(FastIndexingWorker, %{id: 0})
end end
end end
describe "perform/1 when testing notifications" do
setup do
Settings.set(apprise_server: "server_1")
:ok
end
test "sends a notification if new media was found" do
source = source_fixture(fast_index: true)
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(AppriseRunnerMock, :run, fn servers, opts ->
assert "server_1" = servers
assert is_binary(Keyword.get(opts, :title))
assert is_binary(Keyword.get(opts, :body))
{:ok, ""}
end)
perform_job(FastIndexingWorker, %{id: source.id})
end
end
end end
@@ -30,6 +30,21 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>)) assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>") assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
end end
test "escapes invalid characters", %{filepath: filepath} do
metadata = %{
"title" => "hello' & <world>",
"uploader" => "uploader",
"id" => "id",
"description" => "description",
"upload_date" => "20210101"
}
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
nfo = File.read!(result)
assert String.contains?(nfo, "hello&#39; &amp; &lt;world&gt;")
end
end end
describe "build_and_store_for_source/2" do describe "build_and_store_for_source/2" do
@@ -46,5 +61,18 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>)) assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>") assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
end end
test "escapes invalid characters", %{filepath: filepath} do
metadata = %{
"title" => "hello' & <world>",
"description" => "description",
"id" => "id"
}
result = NfoBuilder.build_and_store_for_source(filepath, metadata)
nfo = File.read!(result)
assert String.contains?(nfo, "hello&#39; &amp; &lt;world&gt;")
end
end end
end end
@@ -0,0 +1,65 @@
defmodule Pinchflat.Notifications.CommandRunnerTest do
use ExUnit.Case, async: true
alias Pinchflat.Notifications.CommandRunner, as: Runner
@original_executable Application.compile_env(:pinchflat, :apprise_executable)
setup do
on_exit(&reset_executable/0)
end
describe "run/2" do
test "returns :ok when the command succeeds" do
assert {:ok, _} = Runner.run("server_1", [])
end
test "includes the servers as the first argument" do
assert {:ok, output} = Runner.run(["server_1", "server_2"], [])
assert String.contains?(output, "server_1 server_2")
end
test "lets you pass a single server as a string" do
assert {:ok, output} = Runner.run("server_1", [])
assert String.contains?(output, "server_1")
end
test "passes all arguments to the command" do
assert {:ok, output} = Runner.run("server_1", ["--dry-run"])
assert String.contains?(output, "--dry-run")
end
test "returns the output when the command fails" do
wrap_executable("/bin/false", fn ->
assert {:error, ""} = Runner.run("server_1", [])
end)
end
test "returns a relevant error if no servers are provided" do
assert {:error, :no_servers} = Runner.run(nil, [])
assert {:error, :no_servers} = Runner.run("", [])
assert {:error, :no_servers} = Runner.run([], [])
end
end
describe "version/0" do
test "adds the version arg" do
assert {:ok, output} = Runner.version()
assert String.contains?(output, "--version")
end
end
defp wrap_executable(new_executable, fun) do
Application.put_env(:pinchflat, :apprise_executable, new_executable)
fun.()
reset_executable()
end
def reset_executable do
Application.put_env(:pinchflat, :apprise_executable, @original_executable)
end
end
@@ -0,0 +1,100 @@
defmodule Pinchflat.Notifications.SourceNotificationsTest do
use Pinchflat.DataCase
import Mox
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
alias Pinchflat.Notifications.SourceNotifications
@apprise_servers ["server_1", "server_2"]
setup :verify_on_exit!
describe "wrap_new_media_notification/3" do
test "sends a notification when the pending count changes" do
source = source_fixture()
expect(AppriseRunnerMock, :run, fn servers, opts ->
assert servers == @apprise_servers
assert opts == [
title: "[Pinchflat] New media found",
body: "Found 1 new media item(s) for #{source.custom_name}. Downloading them now"
]
{:ok, ""}
end)
SourceNotifications.wrap_new_media_notification(@apprise_servers, source, fn ->
media_item_fixture(%{source_id: source.id, media_filepath: nil})
end)
end
test "sends a notification when the downloaded count changes" do
source = source_fixture()
expect(AppriseRunnerMock, :run, fn servers, opts ->
assert servers == @apprise_servers
assert opts == [
title: "[Pinchflat] New media found",
body: "Found 1 new media item(s) for #{source.custom_name}. Downloading them now"
]
{:ok, ""}
end)
SourceNotifications.wrap_new_media_notification(@apprise_servers, source, fn ->
media_item_fixture(%{source_id: source.id, media_filepath: "file.mp4"})
end)
end
test "does not send a notification when the count does not change" do
source = source_fixture()
expect(AppriseRunnerMock, :run, 0, fn _, _ -> {:ok, ""} end)
SourceNotifications.wrap_new_media_notification(@apprise_servers, source, fn ->
media_item_fixture(%{source_id: source.id, prevent_download: true, media_filepath: nil})
end)
end
test "returns the value of the function" do
source = source_fixture()
expect(AppriseRunnerMock, :run, 0, fn _, _ -> {:ok, ""} end)
retval = SourceNotifications.wrap_new_media_notification(@apprise_servers, source, fn -> "value" end)
assert retval == "value"
end
end
describe "send_new_media_notification/3" do
test "sends a notification when count is positive" do
source = source_fixture()
expect(AppriseRunnerMock, :run, fn servers, opts ->
assert servers == @apprise_servers
assert opts == [
title: "[Pinchflat] New media found",
body: "Found 1 new media item(s) for #{source.custom_name}. Downloading them now"
]
{:ok, ""}
end)
:ok = SourceNotifications.send_new_media_notification(@apprise_servers, source, 1)
end
test "does not send a notification when count not positive" do
source = source_fixture()
expect(AppriseRunnerMock, :run, 0, fn _, _ -> {:ok, ""} end)
:ok = SourceNotifications.send_new_media_notification(@apprise_servers, source, 0)
:ok = SourceNotifications.send_new_media_notification(@apprise_servers, source, -1)
end
end
end
+46 -73
View File
@@ -9,100 +9,73 @@ defmodule Pinchflat.SettingsTest do
# are always created on app boot (including in the test env), # are always created on app boot (including in the test env),
# so we can't treat these like a clean slate. # so we can't treat these like a clean slate.
describe "list_settings/0" do setup do
test "returns all settings" do # Ensure we have a clean slate
Settings.set!("foo", "bar") Settings.set(onboarding: false)
results = Settings.list_settings() Settings.set(pro_enabled: false)
Settings.set(yt_dlp_version: nil)
assert Enum.all?(results, fn setting -> match?(%Setting{}, setting) end) :ok
end
describe "record/0" do
test "returns the only setting" do
assert %Setting{} = Settings.record()
end end
end end
describe "set/2" do describe "update_setting/2" do
test "creates a new setting if one does not exist" do test "updates the setting" do
original = Repo.aggregate(Setting, :count, :id) setting = Settings.record()
Settings.set!("foo", "bar")
assert Repo.aggregate(Setting, :count, :id) == original + 1
end
test "updates an existing setting if one exists" do assert {:ok, false} = Settings.get(:onboarding)
Settings.set!("foo", "bar") assert {:ok, %Setting{}} = Settings.update_setting(setting, %{onboarding: true})
original = Repo.aggregate(Setting, :count, :id) assert {:ok, true} = Settings.get(:onboarding)
Settings.set!("foo", "baz")
assert Repo.aggregate(Setting, :count, :id) == original
assert Settings.get!("foo") == "baz"
end
test "returns the parsed value" do
assert Settings.set!("foo", true) == true
assert Settings.set!("foo", false) == false
assert Settings.set!("foo", 123) == 123
assert Settings.set!("foo", 12.34) == 12.34
assert Settings.set!("foo", "bar") == "bar"
end
test "allows for atom keys" do
assert Settings.set!(:foo, "bar") == "bar"
end
test "blows up when an unsupported datatype is used" do
assert_raise FunctionClauseError, fn ->
Settings.set!("foo", nil)
end
end end
end end
describe "set/3" do describe "set/1" do
test "allows manual specification of datatype" do test "updates the setting" do
assert Settings.set!("foo", "true", :boolean) == true assert {:ok, true} = Settings.set(onboarding: true)
assert Settings.set!("foo", "false", :boolean) == false assert {:ok, true} = Settings.get(:onboarding)
assert Settings.set!("foo", "123", :integer) == 123 end
assert Settings.set!("foo", "12.34", :float) == 12.34
test "returns an error if the setting key doesn't exist" do
assert {:error, :invalid_key} = Settings.set(foo: "bar")
end
test "returns an error if the setting value is invalid" do
assert {:error, %Ecto.Changeset{}} = Settings.set(onboarding: "bar")
end end
end end
describe "get/1" do describe "get/1" do
test "returns the value of the setting" do test "returns the setting value" do
Settings.set!("str", "bar") assert {:ok, false} = Settings.get(:onboarding)
Settings.set!("bool", true)
Settings.set!("int", 123)
Settings.set!("float", 12.34)
assert Settings.get!("str") == "bar"
assert Settings.get!("bool") == true
assert Settings.get!("int") == 123
assert Settings.get!("float") == 12.34
end end
test "allows for atom keys" do test "returns an error if the setting key doesn't exist" do
Settings.set!("str", "bar") assert {:error, :invalid_key} = Settings.get(:foo)
assert Settings.get!(:str) == "bar" end
end
describe "get!/1" do
test "returns the setting value" do
assert Settings.get!(:onboarding) == false
end end
test "blows up when the setting does not exist" do test "raises an error if the setting key doesn't exist" do
assert_raise Ecto.NoResultsError, fn -> assert_raise RuntimeError, "Setting `foo` not found", fn ->
Settings.get!("foo") Settings.get!(:foo)
end end
end end
end end
describe "fetch/2" do describe "change_setting/2" do
test "creates a setting if one doesn't exist" do test "returns a changeset" do
original = Repo.aggregate(Setting, :count, :id) setting = Settings.record()
assert Settings.fetch!("foo", "bar") == "bar"
assert Repo.aggregate(Setting, :count, :id) == original + 1
end
test "returns an existing setting if one does exist" do assert %Ecto.Changeset{} = Settings.change_setting(setting, %{onboarding: true})
Settings.set!("foo", "bar")
assert Settings.fetch!("foo", "baz") == "bar"
end
end
describe "fetch/3" do
test "allows manual specification of datatype" do
assert Settings.fetch!("foo", "true", :boolean) == true
end end
end end
end end
@@ -7,6 +7,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Settings
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.FastIndexingWorker alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
@@ -51,6 +52,12 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end end
describe "perform/1" do describe "perform/1" do
setup do
stub(AppriseRunnerMock, :run, fn _, _ -> {:ok, ""} end)
:ok
end
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)
@@ -210,4 +217,30 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
assert :ok = perform_job(MediaCollectionIndexingWorker, %{id: 0}) assert :ok = perform_job(MediaCollectionIndexingWorker, %{id: 0})
end end
end end
describe "perform/1 when testing apprise notifications" do
setup do
Settings.set(apprise_server: "server_1")
:ok
end
test "sends a notification if new media was found" do
source = source_fixture()
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()}
end)
expect(AppriseRunnerMock, :run, fn servers, opts ->
assert "server_1" = servers
assert is_binary(Keyword.get(opts, :title))
assert is_binary(Keyword.get(opts, :body))
{:ok, ""}
end)
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
end
end
end end
+23
View File
@@ -0,0 +1,23 @@
defmodule Pinchflat.Utils.CliUtilsTest do
use ExUnit.Case, async: true
alias Pinchflat.Utils.CliUtils
describe "parse_options/1" do
test "it converts symbol k-v arg keys to kebab case" do
assert ["--buffer-size", "1024"] = CliUtils.parse_options(buffer_size: 1024)
end
test "it keeps string k-v arg keys untouched" do
assert ["--under_score", "1024"] = CliUtils.parse_options({"--under_score", 1024})
end
test "it converts symbol arg keys to kebab case" do
assert ["--ignore-errors"] = CliUtils.parse_options(:ignore_errors)
end
test "it keeps string arg keys untouched" do
assert ["-v"] = CliUtils.parse_options("-v")
end
end
end
+16
View File
@@ -0,0 +1,16 @@
defmodule Pinchflat.Utils.XmlUtilsTest do
use ExUnit.Case, async: true
alias Pinchflat.Utils.XmlUtils
describe "safe/1" do
test "escapes invalid characters" do
assert XmlUtils.safe("hello' & <world>") == "hello&#39; &amp; &lt;world&gt;"
end
test "converts input to string" do
assert XmlUtils.safe(42) == "42"
assert XmlUtils.safe(nil) == ""
end
end
end
@@ -17,31 +17,6 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
assert {:ok, _output} = Runner.run(@media_url, [], "") assert {:ok, _output} = Runner.run(@media_url, [], "")
end end
test "it converts symbol k-v arg keys to kebab case" do
assert {:ok, output} = Runner.run(@media_url, [buffer_size: 1024], "")
assert String.contains?(output, "--buffer-size 1024")
end
test "it keeps string k-v arg keys untouched" do
assert {:ok, output} = Runner.run(@media_url, [{"--under_score", 1024}], "")
assert String.contains?(output, "--under_score 1024")
end
test "it converts symbol arg keys to kebab case" do
assert {:ok, output} = Runner.run(@media_url, [:ignore_errors], "")
assert String.contains?(output, "--ignore-errors")
end
test "it keeps string arg keys untouched" do
assert {:ok, output} = Runner.run(@media_url, ["-v"], "")
assert String.contains?(output, "-v")
refute String.contains?(output, "--v")
end
test "it includes the media url as the first argument" do test "it includes the media url as the first argument" do
assert {:ok, output} = Runner.run(@media_url, [:ignore_errors], "") assert {:ok, output} = Runner.run(@media_url, [:ignore_errors], "")
+7 -5
View File
@@ -11,9 +11,10 @@ defmodule Pinchflat.YtDlp.MediaTest do
describe "download/2" do describe "download/2" do
test "it calls the backend runner with the expected arguments" do test "it calls the backend runner with the expected arguments" do
expect(YtDlpRunnerMock, :run, fn @media_url, opts, ot -> expect(YtDlpRunnerMock, :run, fn @media_url, opts, ot, addl ->
assert [:no_simulate] = opts assert [:no_simulate] = opts
assert "after_move:%()j" = ot assert "after_move:%()j" = ot
assert addl == []
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -22,17 +23,18 @@ defmodule Pinchflat.YtDlp.MediaTest do
end end
test "it passes along additional options" do test "it passes along additional options" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, addl ->
assert [:no_simulate, :custom_arg] = opts assert [:no_simulate, :custom_arg] = opts
assert [addl_arg: true] = addl
{:ok, "{}"} {:ok, "{}"}
end) end)
assert {:ok, _} = Media.download(@media_url, [:custom_arg]) assert {:ok, _} = Media.download(@media_url, [:custom_arg], addl_arg: true)
end end
test "it parses and returns the generated file as JSON" do test "it parses and returns the generated file as JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -41,7 +43,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end end
test "it returns errors" do test "it returns errors" do
expect(YtDlpRunnerMock, :run, fn _url, _opt, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opt, _ot, _addl ->
{:error, "something"} {:error, "something"}
end) end)
@@ -16,7 +16,7 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
@invalid_attrs %{name: nil, output_path_template: nil} @invalid_attrs %{name: nil, output_path_template: nil}
setup do setup do
Settings.set!(:onboarding, false) Settings.set(onboarding: false)
:ok :ok
end end
@@ -35,7 +35,7 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
end end
test "renders correct layout when onboarding", %{conn: conn} do test "renders correct layout when onboarding", %{conn: conn} do
Settings.set!(:onboarding, true) Settings.set(onboarding: true)
conn = get(conn, ~p"/media_profiles/new") conn = get(conn, ~p"/media_profiles/new")
refute html_response(conn, 200) =~ "MENU" refute html_response(conn, 200) =~ "MENU"
@@ -59,14 +59,14 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
end end
test "redirects to onboarding when onboarding", %{conn: conn} do test "redirects to onboarding when onboarding", %{conn: conn} do
Settings.set!(:onboarding, true) Settings.set(onboarding: true)
conn = post(conn, ~p"/media_profiles", media_profile: @create_attrs) conn = post(conn, ~p"/media_profiles", media_profile: @create_attrs)
assert redirected_to(conn) == ~p"/?onboarding=1" assert redirected_to(conn) == ~p"/?onboarding=1"
end end
test "renders correct layout on error when onboarding", %{conn: conn} do test "renders correct layout on error when onboarding", %{conn: conn} do
Settings.set!(:onboarding, true) Settings.set(onboarding: true)
conn = post(conn, ~p"/media_profiles", media_profile: @invalid_attrs) conn = post(conn, ~p"/media_profiles", media_profile: @invalid_attrs)
refute html_response(conn, 200) =~ "MENU" refute html_response(conn, 200) =~ "MENU"
@@ -10,7 +10,7 @@ defmodule PinchflatWeb.PageControllerTest do
end end
test "displays the onboarding page when onboarding is forced", %{conn: conn} do test "displays the onboarding page when onboarding is forced", %{conn: conn} do
Settings.set!(:onboarding, false) Settings.set(onboarding: false)
conn = get(conn, ~p"/?onboarding=1") conn = get(conn, ~p"/?onboarding=1")
assert html_response(conn, 200) =~ "Welcome to Pinchflat" assert html_response(conn, 200) =~ "Welcome to Pinchflat"
@@ -25,7 +25,7 @@ defmodule PinchflatWeb.PageControllerTest do
end end
test "displays the home page when not onboarding", %{conn: conn} do test "displays the home page when not onboarding", %{conn: conn} do
Settings.set!(:onboarding, false) Settings.set(onboarding: false)
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "MENU" assert html_response(conn, 200) =~ "MENU"
@@ -0,0 +1,23 @@
defmodule PinchflatWeb.SettingControllerTest do
use PinchflatWeb.ConnCase
describe "show settings" do
test "renders the page", %{conn: conn} do
conn = get(conn, ~p"/settings")
assert html_response(conn, 200) =~ "Settings"
end
end
describe "update settings" do
test "saves and redirects when data is valid", %{conn: conn} do
update_attrs = %{apprise_server: "test://server"}
conn = put(conn, ~p"/settings", setting: update_attrs)
assert redirected_to(conn) == ~p"/settings"
conn = get(conn, ~p"/settings")
assert html_response(conn, 200) =~ update_attrs[:apprise_server]
end
end
end
@@ -13,7 +13,7 @@ defmodule PinchflatWeb.SourceControllerTest do
setup do setup do
media_profile = media_profile_fixture() media_profile = media_profile_fixture()
Settings.set!(:onboarding, false) Settings.set(onboarding: false)
{ {
:ok, :ok,
@@ -47,7 +47,7 @@ defmodule PinchflatWeb.SourceControllerTest do
end end
test "renders correct layout when onboarding", %{conn: conn} do test "renders correct layout when onboarding", %{conn: conn} do
Settings.set!(:onboarding, true) Settings.set(onboarding: true)
conn = get(conn, ~p"/sources/new") conn = get(conn, ~p"/sources/new")
refute html_response(conn, 200) =~ "MENU" refute html_response(conn, 200) =~ "MENU"
@@ -74,14 +74,14 @@ defmodule PinchflatWeb.SourceControllerTest do
test "redirects to onboarding when onboarding", %{conn: conn, create_attrs: create_attrs} do test "redirects to onboarding when onboarding", %{conn: conn, create_attrs: create_attrs} do
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/3) expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/3)
Settings.set!(:onboarding, true) Settings.set(onboarding: true)
conn = post(conn, ~p"/sources", source: create_attrs) conn = post(conn, ~p"/sources", source: create_attrs)
assert redirected_to(conn) == ~p"/?onboarding=1" assert redirected_to(conn) == ~p"/?onboarding=1"
end end
test "renders correct layout on error when onboarding", %{conn: conn, invalid_attrs: invalid_attrs} do test "renders correct layout on error when onboarding", %{conn: conn, invalid_attrs: invalid_attrs} do
Settings.set!(:onboarding, true) Settings.set(onboarding: true)
conn = post(conn, ~p"/sources", source: invalid_attrs) conn = post(conn, ~p"/sources", source: invalid_attrs)
refute html_response(conn, 200) =~ "MENU" refute html_response(conn, 200) =~ "MENU"
+4 -1
View File
@@ -1,6 +1,9 @@
Mox.defmock(YtDlpRunnerMock, for: Pinchflat.YtDlp.BackendCommandRunner) Mox.defmock(YtDlpRunnerMock, for: Pinchflat.YtDlp.YtDlpCommandRunner)
Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock) Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock)
Mox.defmock(AppriseRunnerMock, for: Pinchflat.Notifications.AppriseCommandRunner)
Application.put_env(:pinchflat, :apprise_runner, AppriseRunnerMock)
Mox.defmock(HTTPClientMock, for: Pinchflat.HTTP.HTTPBehaviour) Mox.defmock(HTTPClientMock, for: Pinchflat.HTTP.HTTPBehaviour)
Application.put_env(:pinchflat, :http_client, HTTPClientMock) Application.put_env(:pinchflat, :http_client, HTTPClientMock)