Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1994ea5b08 | |||
| f9e4e44b0c | |||
| f2ee3d77a2 | |||
| 7fc70da14a | |||
| 8a0ae89bc0 | |||
| a9f40ed843 | |||
| f27323ffa3 | |||
| 81b49f55bf | |||
| 24875eaeac | |||
| d9053fff0c | |||
| 70c1f97625 | |||
| 332485cdaf | |||
| e55bcaddd0 | |||
| 4b12764f45 | |||
| b872c5c20b |
@@ -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
|
||||||
|
|||||||
+3
-1
@@ -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",
|
||||||
@@ -85,7 +87,7 @@ config :esbuild,
|
|||||||
|
|
||||||
# Configure tailwind (the version is required)
|
# Configure tailwind (the version is required)
|
||||||
config :tailwind,
|
config :tailwind,
|
||||||
version: "3.3.2",
|
version: "3.4.3",
|
||||||
default: [
|
default: [
|
||||||
args: ~w(
|
args: ~w(
|
||||||
--config=tailwind.config.js
|
--config=tailwind.config.js
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -19,27 +19,29 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
|
|
||||||
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
def kickoff_with_task(media_item, opts \\ []) do
|
def kickoff_with_task(media_item, job_args \\ %{}, job_opts \\ []) do
|
||||||
%{id: media_item.id}
|
%{id: media_item.id}
|
||||||
|> MediaDownloadWorker.new(opts)
|
|> Map.merge(job_args)
|
||||||
|
|> MediaDownloadWorker.new(job_opts)
|
||||||
|> Tasks.create_job_with_task(media_item)
|
|> Tasks.create_job_with_task(media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
For a given media item, download the media alongside any options.
|
For a given media item, download the media alongside any options.
|
||||||
Does not download media if its source is set to not download media.
|
Does not download media if its source is set to not download media
|
||||||
|
(unless forced).
|
||||||
|
|
||||||
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
|
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => media_item_id}}) do
|
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
||||||
media_item =
|
media_item =
|
||||||
media_item_id
|
media_item_id
|
||||||
|> Media.get_media_item!()
|
|> Media.get_media_item!()
|
||||||
|> Repo.preload(:source)
|
|> Repo.preload(:source)
|
||||||
|
|
||||||
# If the source is set to not download media, perform a no-op
|
# If the source or media item is set to not download media, perform a no-op unless forced
|
||||||
if media_item.source.download_media 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
|
||||||
@@ -56,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ defmodule Pinchflat.Media do
|
|||||||
"""
|
"""
|
||||||
def list_cullable_media_items do
|
def list_cullable_media_items do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.join_sources()
|
|
||||||
|> MediaQuery.with_media_filepath()
|
|> MediaQuery.with_media_filepath()
|
||||||
|> MediaQuery.with_passed_retention_period()
|
|> MediaQuery.with_passed_retention_period()
|
||||||
|> MediaQuery.with_no_culling_prevention()
|
|> MediaQuery.with_no_culling_prevention()
|
||||||
@@ -40,36 +39,16 @@ defmodule Pinchflat.Media do
|
|||||||
@doc """
|
@doc """
|
||||||
Returns a list of pending media_items for a given source, where
|
Returns a list of pending media_items for a given source, where
|
||||||
pending means the `media_filepath` is `nil` AND the media_item
|
pending means the `media_filepath` is `nil` AND the media_item
|
||||||
matches the format selection rules of the parent media_profile.
|
matches satisfies `MediaQuery.with_media_pending_download`. You
|
||||||
|
should really check out that function if you need to know more
|
||||||
See `matching_download_criteria_for` but tl;dr is it _may_ filter based
|
because it has a lot going on.
|
||||||
on shorts livestreams depending on the media_profile settings.
|
|
||||||
|
|
||||||
Returns [%MediaItem{}, ...].
|
Returns [%MediaItem{}, ...].
|
||||||
"""
|
"""
|
||||||
def list_pending_media_items_for(%Source{} = source, opts \\ []) do
|
def list_pending_media_items_for(%Source{} = source) do
|
||||||
limit = Keyword.get(opts, :limit, nil)
|
|
||||||
source = Repo.preload(source, :media_profile)
|
|
||||||
|
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> MediaQuery.for_source(source)
|
||||||
|> matching_download_criteria_for(source)
|
|> MediaQuery.with_media_pending_download()
|
||||||
|> Repo.maybe_limit(limit)
|
|
||||||
|> Repo.all()
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns a list of downloaded media_items for a given source.
|
|
||||||
|
|
||||||
Returns [%MediaItem{}, ...].
|
|
||||||
"""
|
|
||||||
def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do
|
|
||||||
limit = Keyword.get(opts, :limit, nil)
|
|
||||||
|
|
||||||
MediaQuery.new()
|
|
||||||
|> MediaQuery.for_source(source)
|
|
||||||
|> MediaQuery.with_media_filepath()
|
|
||||||
|> Repo.maybe_limit(limit)
|
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -87,7 +66,7 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.with_id(media_item.id)
|
|> MediaQuery.with_id(media_item.id)
|
||||||
|> matching_download_criteria_for(media_item.source)
|
|> MediaQuery.with_media_pending_download()
|
||||||
|> Repo.exists?()
|
|> Repo.exists?()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -234,13 +213,4 @@ defmodule Pinchflat.Media do
|
|||||||
|> Enum.filter(&is_binary/1)
|
|> Enum.filter(&is_binary/1)
|
||||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp matching_download_criteria_for(query, source_with_preloads) do
|
|
||||||
query
|
|
||||||
|> MediaQuery.with_no_prevented_download()
|
|
||||||
|> MediaQuery.with_no_media_filepath()
|
|
||||||
|> MediaQuery.with_upload_date_after(source_with_preloads.download_cutoff_date)
|
|
||||||
|> MediaQuery.with_format_preference(source_with_preloads.media_profile)
|
|
||||||
|> MediaQuery.matching_title_regex(source_with_preloads.title_filter_regex)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
Query helpers for the Media context.
|
Query helpers for the Media context.
|
||||||
|
|
||||||
These methods are made to be one-ish liners used
|
These methods are made to be one-ish liners used
|
||||||
to compose queries for media items. Each method should
|
to compose queries. Each method should strive to do
|
||||||
strive to do _one_ thing. These don't need to be tested
|
_one_ thing. These don't need to be tested as
|
||||||
as they are just building blocks for other functionality
|
they are just building blocks for other functionality
|
||||||
which, itself, will be tested.
|
which, itself, will be tested.
|
||||||
|
|
||||||
ALSO, this is me trying something new. If I like it,
|
|
||||||
I'll refactor other contexts to use this pattern.
|
|
||||||
"""
|
"""
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
@@ -37,13 +34,14 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def with_passed_retention_period(query) do
|
def with_passed_retention_period(query) do
|
||||||
where(
|
query
|
||||||
query,
|
|> require_assoc(:source)
|
||||||
[mi, sources],
|
|> where(
|
||||||
|
[mi, source],
|
||||||
fragment(
|
fragment(
|
||||||
"IFNULL(?, 0) > 0 AND DATETIME('now', '-' || ? || ' day') > ?",
|
"IFNULL(?, 0) > 0 AND DATETIME('now', '-' || ? || ' day') > ?",
|
||||||
sources.retention_period_days,
|
source.retention_period_days,
|
||||||
sources.retention_period_days,
|
source.retention_period_days,
|
||||||
mi.media_downloaded_at
|
mi.media_downloaded_at
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -69,20 +67,23 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
where(query, [mi], is_nil(mi.media_filepath))
|
where(query, [mi], is_nil(mi.media_filepath))
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_upload_date_after(query, nil), do: query
|
def with_upload_date_after_source_cutoff(query) do
|
||||||
|
query
|
||||||
def with_upload_date_after(query, date) do
|
|> require_assoc(:source)
|
||||||
where(query, [mi], mi.upload_date >= ^date)
|
|> where([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_no_prevented_download(query) do
|
def with_no_prevented_download(query) do
|
||||||
where(query, [mi], mi.prevent_download == false)
|
where(query, [mi], mi.prevent_download == false)
|
||||||
end
|
end
|
||||||
|
|
||||||
def matching_title_regex(query, nil), do: query
|
def matching_source_title_regex(query) do
|
||||||
|
query
|
||||||
def matching_title_regex(query, regex) do
|
|> require_assoc(:source)
|
||||||
where(query, [mi], fragment("regexp_like(?, ?)", mi.title, ^regex))
|
|> where(
|
||||||
|
[mi, source],
|
||||||
|
is_nil(source.title_filter_regex) or fragment("regexp_like(?, ?)", mi.title, source.title_filter_regex)
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def matching_search_term(query, nil), do: query
|
def matching_search_term(query, nil), do: query
|
||||||
@@ -103,44 +104,55 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# NOTE: this method breaks the contract set by other methods in that it
|
def with_format_matching_profile_preference(query) do
|
||||||
# takes a media_profile struct instead of taking just the attributes it
|
query
|
||||||
# cares about. Consider refactoring but low priority.
|
|> require_assoc(:media_profile)
|
||||||
def with_format_preference(query, media_profile) do
|
|> where(
|
||||||
mapped_struct = Map.from_struct(media_profile)
|
fragment("""
|
||||||
|
CASE
|
||||||
|
WHEN shorts_behaviour = 'only' AND livestream_behaviour = 'only' THEN
|
||||||
|
livestream = true OR short_form_content = true
|
||||||
|
WHEN shorts_behaviour = 'only' THEN
|
||||||
|
short_form_content = true
|
||||||
|
WHEN livestream_behaviour = 'only' THEN
|
||||||
|
livestream = true
|
||||||
|
WHEN shorts_behaviour = 'exclude' AND livestream_behaviour = 'exclude' THEN
|
||||||
|
short_form_content = false AND livestream = false
|
||||||
|
WHEN shorts_behaviour = 'exclude' THEN
|
||||||
|
short_form_content = false
|
||||||
|
WHEN livestream_behaviour = 'exclude' THEN
|
||||||
|
livestream = false
|
||||||
|
ELSE
|
||||||
|
true
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
finders =
|
def with_media_pending_download(query) do
|
||||||
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic ->
|
query
|
||||||
case {attr, media_profile} do
|
|> with_no_prevented_download()
|
||||||
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} ->
|
|> with_no_media_filepath()
|
||||||
dynamic(
|
|> with_upload_date_after_source_cutoff()
|
||||||
[mi],
|
|> with_format_matching_profile_preference()
|
||||||
^dynamic and (mi.livestream == true or mi.short_form_content == true)
|
|> matching_source_title_regex()
|
||||||
)
|
end
|
||||||
|
|
||||||
# Technically redundant, but makes the other clauses easier to parse
|
defp require_assoc(query, identifier) do
|
||||||
# (redundant because this condition is the same as the condition above, just flipped)
|
if has_named_binding?(query, identifier) do
|
||||||
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} ->
|
query
|
||||||
dynamic
|
else
|
||||||
|
do_require_assoc(query, identifier)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
{{:shorts_behaviour, :only}, _} ->
|
defp do_require_assoc(query, :source) do
|
||||||
dynamic([mi], ^dynamic and mi.short_form_content == true)
|
from(mi in query, join: s in assoc(mi, :source), as: :source)
|
||||||
|
end
|
||||||
|
|
||||||
{{:livestream_behaviour, :only}, _} ->
|
defp do_require_assoc(query, :media_profile) do
|
||||||
dynamic([mi], ^dynamic and mi.livestream == true)
|
query
|
||||||
|
|> require_assoc(:source)
|
||||||
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only ->
|
|> join(:inner, [mi, source], mp in assoc(source, :media_profile), as: :media_profile)
|
||||||
dynamic([mi], ^dynamic and mi.short_form_content == false)
|
|
||||||
|
|
||||||
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
|
|
||||||
# return records with livestream: false
|
|
||||||
dynamic([mi], ^dynamic and mi.livestream == false)
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
dynamic
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
where(query, ^finders)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,7 +5,7 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media.MediaQuery
|
||||||
alias Pinchflat.Metadata.MediaMetadata
|
alias Pinchflat.Metadata.MediaMetadata
|
||||||
alias Pinchflat.Metadata.SourceMetadata
|
alias Pinchflat.Metadata.SourceMetadata
|
||||||
|
|
||||||
@@ -25,8 +25,11 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
|||||||
def persisted_media_items_for(source, opts \\ []) do
|
def persisted_media_items_for(source, opts \\ []) do
|
||||||
limit = Keyword.get(opts, :limit, 500)
|
limit = Keyword.get(opts, :limit, 500)
|
||||||
|
|
||||||
source
|
MediaQuery.new()
|
||||||
|> Media.list_downloaded_media_items_for(limit: limit)
|
|> MediaQuery.for_source(source)
|
||||||
|
|> MediaQuery.with_media_filepath()
|
||||||
|
|> Repo.maybe_limit(limit)
|
||||||
|
|> Repo.all()
|
||||||
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,18 +11,21 @@ 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.
|
||||||
|
|
||||||
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
def kickoff_with_task(source, opts \\ []) do
|
def kickoff_with_task(source, job_args \\ %{}, job_opts \\ []) do
|
||||||
%{id: source.id}
|
%{id: source.id}
|
||||||
|> MediaCollectionIndexingWorker.new(opts)
|
|> Map.merge(job_args)
|
||||||
|
|> MediaCollectionIndexingWorker.new(job_opts)
|
||||||
|> Tasks.create_job_with_task(source)
|
|> Tasks.create_job_with_task(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -30,8 +33,8 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
The ID is that of a source _record_, not a YouTube channel/playlist ID. Indexes
|
The ID is that of a source _record_, not a YouTube channel/playlist ID. Indexes
|
||||||
the provided source, kicks off downloads for each new MediaItem, and
|
the provided source, kicks off downloads for each new MediaItem, and
|
||||||
reschedules the job to run again in the future. It will ALWAYS index a source
|
reschedules the job to run again in the future. It will ALWAYS index a source
|
||||||
if it's never been indexed before, but rescheduling is determined by the
|
if it's never been indexed before or if `force` is set to `true`, but rescheduling
|
||||||
`index_frequency_minutes` field.
|
is determined by the `index_frequency_minutes` field.
|
||||||
|
|
||||||
README: Re-scheduling here works a little different than you may expect.
|
README: Re-scheduling here works a little different than you may expect.
|
||||||
The reschedule time is relative to the time the job has actually _completed_.
|
The reschedule time is relative to the time the job has actually _completed_.
|
||||||
@@ -71,25 +74,29 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||||||
Returns :ok | {:ok, %Task{}}
|
Returns :ok | {:ok, %Task{}}
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => source_id}}) do
|
def perform(%Oban.Job{args: %{"id" => source_id} = args}) do
|
||||||
source = Sources.get_source!(source_id)
|
source = Sources.get_source!(source_id)
|
||||||
|
|
||||||
case {source.index_frequency_minutes, source.last_indexed_at} do
|
case {source.index_frequency_minutes, source.last_indexed_at} do
|
||||||
{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
|
# perform a no-op (unless forced)
|
||||||
|
if args["force"] do
|
||||||
|
perform_indexing_and_notification(source)
|
||||||
|
end
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
@@ -97,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
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
|
|
||||||
Returns {:ok, %Task{}}.
|
Returns {:ok, %Task{}}.
|
||||||
"""
|
"""
|
||||||
def kickoff_indexing_task(%Source{} = source) do
|
def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do
|
||||||
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
||||||
Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker")
|
Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker")
|
||||||
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
|
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
|
||||||
|
|
||||||
MediaCollectionIndexingWorker.kickoff_with_task(source)
|
MediaCollectionIndexingWorker.kickoff_with_task(source, job_args, job_opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
defmodule Pinchflat.Sources.SourcesQuery do
|
||||||
|
@moduledoc """
|
||||||
|
Query helpers for the Sources context.
|
||||||
|
|
||||||
|
These methods are made to be one-ish liners used
|
||||||
|
to compose queries. Each method should strive to do
|
||||||
|
_one_ thing. These don't need to be tested as
|
||||||
|
they are just building blocks for other functionality
|
||||||
|
which, itself, will be tested.
|
||||||
|
"""
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
|
alias Pinchflat.Sources.Source
|
||||||
|
|
||||||
|
# Prefixes:
|
||||||
|
# - for_* - belonging to a certain record
|
||||||
|
# - join_* - for joining on a certain record
|
||||||
|
# - with_* - for filtering based on full, concrete attributes
|
||||||
|
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
||||||
|
#
|
||||||
|
# Suffixes:
|
||||||
|
# - _for - the arg passed is an association record
|
||||||
|
|
||||||
|
def new do
|
||||||
|
Source
|
||||||
|
end
|
||||||
|
|
||||||
|
def for_media_profile(query, media_profile) do
|
||||||
|
where(query, [s], s.media_profile_id == ^media_profile.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
-1
@@ -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>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
|||||||
<%= render_slot(@tab_append) %>
|
<%= render_slot(@tab_append) %>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="mt-4">
|
<div class="mt-4 min-h-60">
|
||||||
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
|
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
|
||||||
<%= render_slot(tab) %>
|
<%= render_slot(tab) %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
|||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
|
||||||
def show(conn, %{"id" => id}) do
|
def show(conn, %{"id" => id}) do
|
||||||
media_item =
|
media_item =
|
||||||
@@ -47,6 +48,15 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
|||||||
|> redirect(to: ~p"/sources/#{media_item.source_id}")
|
|> redirect(to: ~p"/sources/#{media_item.source_id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def force_download(conn, %{"media_item_id" => id}) do
|
||||||
|
media_item = Media.get_media_item!(id)
|
||||||
|
{:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, %{force: true})
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Download task enqueued.")
|
||||||
|
|> redirect(to: ~p"/sources/#{media_item.source_id}/media/#{media_item}")
|
||||||
|
end
|
||||||
|
|
||||||
# See here for details on streaming files and range requests:
|
# See here for details on streaming files and range requests:
|
||||||
# https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
|
# https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}/force_download"}
|
||||||
|
method="post"
|
||||||
|
data-confirm="Are you sure you force a download of this media?"
|
||||||
|
>
|
||||||
|
Force Download
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<div class="h-px w-full bg-bodydark2"></div>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"}
|
||||||
|
method="delete"
|
||||||
|
data-confirm="Are you sure you want to delete all files for this media item? This cannot be undone."
|
||||||
|
>
|
||||||
|
Delete Files
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?prevent_download=true"}
|
||||||
|
method="delete"
|
||||||
|
data-confirm="Are you sure you want to delete all files for this media item and prevent it from re-downloading in the future? This cannot be undone."
|
||||||
|
>
|
||||||
|
Delete and Ignore
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
</.button_dropdown>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
field={f[:prevent_download]}
|
field={f[:prevent_download]}
|
||||||
type="toggle"
|
type="toggle"
|
||||||
label="Prevent Download"
|
label="Prevent Download"
|
||||||
help="Checking excludes this media item from being downloaded"
|
help="Checking excludes this media item from automatic download. Download can still be manually forced"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
|
|||||||
@@ -20,26 +20,7 @@
|
|||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<.tabbed_layout>
|
<.tabbed_layout>
|
||||||
<:tab_append>
|
<:tab_append>
|
||||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
<.actions_dropdown media_item={@media_item} />
|
||||||
<:option>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete all files for this media item? This cannot be undone."
|
|
||||||
>
|
|
||||||
Delete Files
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?prevent_download=true"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete all files for this media item and prevent it from re-downloading in the future? This cannot be undone."
|
|
||||||
>
|
|
||||||
Delete and Ignore
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
</.button_dropdown>
|
|
||||||
</:tab_append>
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Attributes">
|
<:tab title="Attributes">
|
||||||
@@ -53,9 +34,9 @@
|
|||||||
<h3 class="font-bold text-xl">Attributes</h3>
|
<h3 class="font-bold text-xl">Attributes</h3>
|
||||||
<section>
|
<section>
|
||||||
<strong>Source:</strong>
|
<strong>Source:</strong>
|
||||||
<.inline_link href={~p"/sources/#{@media_item.source_id}"}>
|
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
|
||||||
<%= @media_item.source.custom_name %>
|
<%= @media_item.source.custom_name %>
|
||||||
</.inline_link>
|
</.subtle_link>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Profiles
|
alias Pinchflat.Profiles
|
||||||
|
alias Pinchflat.Sources.SourcesQuery
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
media_profiles = Profiles.list_media_profiles()
|
media_profiles =
|
||||||
|
MediaProfile
|
||||||
|
|> order_by(asc: :name)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
render(conn, :index, media_profiles: media_profiles)
|
render(conn, :index, media_profiles: media_profiles)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -32,12 +39,15 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show(conn, %{"id" => id}) do
|
def show(conn, %{"id" => id}) do
|
||||||
media_profile =
|
media_profile = Profiles.get_media_profile!(id)
|
||||||
id
|
|
||||||
|> Profiles.get_media_profile!()
|
|
||||||
|> Repo.preload(:sources)
|
|
||||||
|
|
||||||
render(conn, :show, media_profile: media_profile)
|
sources =
|
||||||
|
SourcesQuery.new()
|
||||||
|
|> SourcesQuery.for_media_profile(media_profile)
|
||||||
|
|> order_by(asc: :custom_name)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
render(conn, :show, media_profile: media_profile, sources: sources)
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit(conn, %{"id" => id}) do
|
def edit(conn, %{"id" => id}) do
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Sources">
|
<:tab title="Sources">
|
||||||
<.table rows={@media_profile.sources} table_class="text-black dark:text-white">
|
<.table rows={@sources} table_class="text-black dark:text-white">
|
||||||
<:col :let={source} label="Name">
|
<:col :let={source} label="Name">
|
||||||
<.subtle_link href={~p"/sources/#{source.id}"}>
|
<.subtle_link href={~p"/sources/#{source.id}"}>
|
||||||
<%= source.custom_name || source.collection_name %>
|
<%= source.custom_name || source.collection_name %>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
|||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media.MediaQuery
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Podcasts.RssFeedBuilder
|
alias Pinchflat.Podcasts.RssFeedBuilder
|
||||||
alias Pinchflat.Podcasts.PodcastHelpers
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
@@ -20,10 +20,15 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
|||||||
|
|
||||||
def feed_image(conn, %{"uuid" => uuid}) do
|
def feed_image(conn, %{"uuid" => uuid}) do
|
||||||
source = Repo.get_by!(Source, uuid: uuid)
|
source = Repo.get_by!(Source, uuid: uuid)
|
||||||
# This provides a fallback image if the source has none.
|
|
||||||
# We only need one since we're using the internal metadata image which
|
# This is used to fetch a fallback cover image
|
||||||
# we know exists.
|
# if the source doesn't have any usable images
|
||||||
media_items = Media.list_downloaded_media_items_for(source, limit: 1)
|
media_items =
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.for_source(source)
|
||||||
|
|> MediaQuery.with_media_filepath()
|
||||||
|
|> Repo.maybe_limit(1)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
case PodcastHelpers.select_cover_image(source, media_items) do
|
case PodcastHelpers.select_cover_image(source, media_items) do
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -4,15 +4,21 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Profiles
|
alias Pinchflat.MediaQuery
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaQuery
|
alias Pinchflat.Media.MediaQuery
|
||||||
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
|
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
sources = Repo.preload(Sources.list_sources(), :media_profile)
|
sources =
|
||||||
|
Source
|
||||||
|
|> order_by(asc: :custom_name)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Repo.preload(:media_profile)
|
||||||
|
|
||||||
render(conn, :index, sources: sources)
|
render(conn, :index, sources: sources)
|
||||||
end
|
end
|
||||||
@@ -49,9 +55,26 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
def show(conn, %{"id" => id}) do
|
def show(conn, %{"id" => id}) do
|
||||||
source = Repo.preload(Sources.get_source!(id), :media_profile)
|
source = Repo.preload(Sources.get_source!(id), :media_profile)
|
||||||
|
|
||||||
pending_tasks = Repo.preload(Tasks.list_pending_tasks_for(source), :job)
|
pending_tasks =
|
||||||
pending_media = Media.list_pending_media_items_for(source, limit: 100)
|
source
|
||||||
downloaded_media = Media.list_downloaded_media_items_for(source, limit: 100)
|
|> Tasks.list_tasks_for(nil, [:executing, :available, :scheduled, :retryable])
|
||||||
|
|> Repo.preload(:job)
|
||||||
|
|
||||||
|
pending_media =
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.for_source(source)
|
||||||
|
|> MediaQuery.with_media_pending_download()
|
||||||
|
|> order_by(desc: :id)
|
||||||
|
|> limit(100)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
downloaded_media =
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.for_source(source)
|
||||||
|
|> MediaQuery.with_media_filepath()
|
||||||
|
|> order_by(desc: :id)
|
||||||
|
|> limit(100)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
render(conn, :show,
|
render(conn, :show,
|
||||||
source: source,
|
source: source,
|
||||||
@@ -104,12 +127,30 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
|> redirect(to: ~p"/sources")
|
|> redirect(to: ~p"/sources")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def force_download(conn, %{"source_id" => id}) do
|
||||||
|
source = Sources.get_source!(id)
|
||||||
|
DownloadingHelpers.enqueue_pending_download_tasks(source)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Forced download of pending media items.")
|
||||||
|
|> redirect(to: ~p"/sources/#{source}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def force_index(conn, %{"source_id" => id}) do
|
||||||
|
source = Sources.get_source!(id)
|
||||||
|
SlowIndexingHelpers.kickoff_indexing_task(source, %{force: true})
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Index enqueued.")
|
||||||
|
|> redirect(to: ~p"/sources/#{source}")
|
||||||
|
end
|
||||||
|
|
||||||
defp media_profiles do
|
defp media_profiles do
|
||||||
Profiles.list_media_profiles()
|
MediaProfile
|
||||||
|
|> order_by(asc: :name)
|
||||||
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
# NOTE: should move this out of the controller
|
|
||||||
# once I finally add some query fragment layer
|
|
||||||
defp total_downloaded_for(source) do
|
defp total_downloaded_for(source) do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> MediaQuery.for_source(source)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||||
|
<:option>
|
||||||
|
<span
|
||||||
|
x-data="{ copied: false }"
|
||||||
|
x-on:click={"
|
||||||
|
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
|
||||||
|
copied = true
|
||||||
|
setTimeout(() => copied = false, 4000)
|
||||||
|
"}
|
||||||
|
>
|
||||||
|
Copy RSS Feed
|
||||||
|
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||||
|
</span>
|
||||||
|
</:option>
|
||||||
|
<:option :if={@source.download_media}>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@source}/force_download"}
|
||||||
|
method="post"
|
||||||
|
data-confirm="Are you sure you want to force a download of all *pending* media items? This isn't normally needed."
|
||||||
|
>
|
||||||
|
Force Download
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@source}/force_index"}
|
||||||
|
method="post"
|
||||||
|
data-confirm="Are you sure you want to force an index of this source? This isn't normally needed."
|
||||||
|
>
|
||||||
|
Force Index
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<div class="h-px w-full bg-bodydark2"></div>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@source}"}
|
||||||
|
method="delete"
|
||||||
|
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
|
||||||
|
>
|
||||||
|
Delete Source
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@source}?delete_files=true"}
|
||||||
|
method="delete"
|
||||||
|
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
|
||||||
|
class="mt-5 md:mt-0"
|
||||||
|
>
|
||||||
|
Delete Source + Files
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
</.button_dropdown>
|
||||||
@@ -20,43 +20,7 @@
|
|||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<.tabbed_layout>
|
<.tabbed_layout>
|
||||||
<:tab_append>
|
<:tab_append>
|
||||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
<.actions_dropdown source={@source} conn={@conn} />
|
||||||
<:option>
|
|
||||||
<span
|
|
||||||
x-data="{ copied: false }"
|
|
||||||
x-on:click={"
|
|
||||||
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
|
|
||||||
copied = true
|
|
||||||
setTimeout(() => copied = false, 4000)
|
|
||||||
"}
|
|
||||||
>
|
|
||||||
Copy RSS Feed
|
|
||||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
|
||||||
</span>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<div class="h-px w-full bg-bodydark2"></div>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@source}"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
|
|
||||||
>
|
|
||||||
Delete Source
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
<:option>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@source}?delete_files=true"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
|
|
||||||
class="mt-5 md:mt-0"
|
|
||||||
>
|
|
||||||
Delete Source + Files
|
|
||||||
</.link>
|
|
||||||
</:option>
|
|
||||||
</.button_dropdown>
|
|
||||||
</:tab_append>
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Attributes">
|
<:tab title="Attributes">
|
||||||
@@ -64,9 +28,9 @@
|
|||||||
<h3 class="font-bold text-lg">Attributes</h3>
|
<h3 class="font-bold text-lg">Attributes</h3>
|
||||||
<section>
|
<section>
|
||||||
<strong>Media Profile:</strong>
|
<strong>Media Profile:</strong>
|
||||||
<.inline_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
<.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
||||||
<%= @source.media_profile.name %>
|
<%= @source.media_profile.name %>
|
||||||
</.inline_link>
|
</.subtle_link>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<.list_items_from_map map={Map.from_struct(@source)} />
|
<.list_items_from_map map={Map.from_struct(@source)} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ defmodule PinchflatWeb.Endpoint do
|
|||||||
plug Plug.Static,
|
plug Plug.Static,
|
||||||
at: "/",
|
at: "/",
|
||||||
from: :pinchflat,
|
from: :pinchflat,
|
||||||
gzip: false,
|
gzip: Mix.env() == :prod,
|
||||||
only: PinchflatWeb.static_paths()
|
only: PinchflatWeb.static_paths()
|
||||||
|
|
||||||
# Code reloading can be explicitly enabled under the
|
# Code reloading can be explicitly enabled under the
|
||||||
|
|||||||
@@ -30,9 +30,15 @@ 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
|
||||||
resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete]
|
post "/force_download", Sources.SourceController, :force_download
|
||||||
|
post "/force_index", Sources.SourceController, :force_index
|
||||||
|
|
||||||
|
resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete] do
|
||||||
|
post "/force_download", MediaItems.MediaItemController, :force_download
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :pinchflat,
|
app: :pinchflat,
|
||||||
version: "0.1.7",
|
version: "0.1.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
|
||||||
+15
-2
@@ -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
|
||||||
@@ -108,6 +110,17 @@ EXPOSE ${PORT}
|
|||||||
# Only copy the final release from the build stage
|
# Only copy the final release from the build stage
|
||||||
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
|
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
|
||||||
|
|
||||||
|
# NEVER do this if you're running in an environment where you don't trust the user
|
||||||
|
# (ie: most environments). This is only acceptable in a self-hosted environment.
|
||||||
|
# The user could just run the whole container as root and bypass this anyway so
|
||||||
|
# it's not a huge deal.
|
||||||
|
# This removes the root password to allow users to assume root if needed. This is
|
||||||
|
# preferrable to running the whole container as root so that the files/directories
|
||||||
|
# created by the app aren't owned by root and are therefore easier for other users
|
||||||
|
# and processes to interact with. If you want to just run the whole container as
|
||||||
|
# root, use --user 0:0 or something.
|
||||||
|
RUN passwd -d root
|
||||||
|
|
||||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||||
# advised to add an init process such as tini via `apt-get install`
|
# advised to add an init process such as tini via `apt-get install`
|
||||||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,11 +35,28 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||||||
assert {:ok, task} = MediaDownloadWorker.kickoff_with_task(media_item)
|
assert {:ok, task} = MediaDownloadWorker.kickoff_with_task(media_item)
|
||||||
assert task.media_item_id == media_item.id
|
assert task.media_item_id == media_item.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job arguments", %{media_item: media_item} do
|
||||||
|
job_args = %{"force" => true}
|
||||||
|
|
||||||
|
assert {:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, job_args)
|
||||||
|
|
||||||
|
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id, "force" => true})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job options", %{media_item: media_item} do
|
||||||
|
job_opts = [max_attempts: 5]
|
||||||
|
|
||||||
|
assert {:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item, %{}, job_opts)
|
||||||
|
|
||||||
|
[job] = all_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
|
||||||
|
assert job.max_attempts == 5
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "perform/1" do
|
describe "perform/1" do
|
||||||
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)
|
||||||
|
|
||||||
@@ -48,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)
|
||||||
|
|
||||||
@@ -65,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}))
|
||||||
@@ -75,21 +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
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> :ok end)
|
||||||
|
|
||||||
|
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})
|
||||||
|
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
|
||||||
|
|||||||
@@ -149,14 +149,6 @@ defmodule Pinchflat.MediaTest do
|
|||||||
|
|
||||||
assert Media.list_pending_media_items_for(source) == []
|
assert Media.list_pending_media_items_for(source) == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "optionally accepts a limit" do
|
|
||||||
source = source_fixture()
|
|
||||||
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
|
||||||
|
|
||||||
assert Media.list_pending_media_items_for(source, limit: 1) == [media_item]
|
|
||||||
assert Media.list_pending_media_items_for(source, limit: 0) == []
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_pending_media_items_for/1 when testing shorts" do
|
describe "list_pending_media_items_for/1 when testing shorts" do
|
||||||
@@ -335,25 +327,6 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_downloaded_media_items_for/1" do
|
|
||||||
test "returns only media items with a media_filepath" do
|
|
||||||
source = source_fixture()
|
|
||||||
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
|
||||||
media_item = media_item_fixture(%{source_id: source.id, media_filepath: "/video/#{Faker.File.file_name(:video)}"})
|
|
||||||
|
|
||||||
assert Media.list_downloaded_media_items_for(source) == [media_item]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "optionally accepts a limit" do
|
|
||||||
source = source_fixture()
|
|
||||||
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
|
||||||
media_item = media_item_fixture(%{source_id: source.id, media_filepath: "/video/#{Faker.File.file_name(:video)}"})
|
|
||||||
|
|
||||||
assert Media.list_downloaded_media_items_for(source, limit: 1) == [media_item]
|
|
||||||
assert Media.list_downloaded_media_items_for(source, limit: 0) == []
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "pending_download?/1" do
|
describe "pending_download?/1" do
|
||||||
test "returns true when the media hasn't been downloaded" do
|
test "returns true when the media hasn't been downloaded" do
|
||||||
media_item = media_item_fixture(%{media_filepath: nil})
|
media_item = media_item_fixture(%{media_filepath: nil})
|
||||||
|
|||||||
@@ -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' & <world>")
|
||||||
|
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' & <world>")
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -14,7 +15,49 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
|
|
||||||
setup :verify_on_exit!
|
setup :verify_on_exit!
|
||||||
|
|
||||||
|
describe "kickoff_with_task/3" do
|
||||||
|
setup do
|
||||||
|
source = source_fixture(index_frequency_minutes: 10)
|
||||||
|
|
||||||
|
{:ok, %{source: source}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "starts the worker", %{source: source} do
|
||||||
|
assert [] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
||||||
|
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source)
|
||||||
|
assert [_] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "attaches a task", %{source: source} do
|
||||||
|
assert {:ok, task} = MediaCollectionIndexingWorker.kickoff_with_task(source)
|
||||||
|
assert task.source_id == source.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job arguments", %{source: source} do
|
||||||
|
job_args = %{"force" => true}
|
||||||
|
|
||||||
|
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source, job_args)
|
||||||
|
|
||||||
|
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id, "force" => true})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job options", %{source: source} do
|
||||||
|
job_opts = [max_attempts: 5]
|
||||||
|
|
||||||
|
assert {:ok, _} = MediaCollectionIndexingWorker.kickoff_with_task(source, %{}, job_opts)
|
||||||
|
|
||||||
|
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
|
||||||
|
assert job.max_attempts == 5
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "perform/1" do
|
describe "perform/1" do
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -31,6 +74,14 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
|||||||
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it indexes the source no matter what if the 'force' arg is passed" do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
||||||
|
|
||||||
|
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
|
||||||
|
|
||||||
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true})
|
||||||
|
end
|
||||||
|
|
||||||
test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
|
test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
|
||||||
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
||||||
|
|
||||||
@@ -166,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
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
|
|
||||||
setup :verify_on_exit!
|
setup :verify_on_exit!
|
||||||
|
|
||||||
describe "kickoff_indexing_task/1" do
|
describe "kickoff_indexing_task/3" do
|
||||||
test "it schedules a job" do
|
test "it schedules a job" do
|
||||||
source = source_fixture(index_frequency_minutes: 1)
|
source = source_fixture(index_frequency_minutes: 1)
|
||||||
|
|
||||||
@@ -64,6 +64,25 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
|
|||||||
|
|
||||||
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job arguments" do
|
||||||
|
source = source_fixture(index_frequency_minutes: 1)
|
||||||
|
job_args = %{"force" => true}
|
||||||
|
|
||||||
|
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source, job_args)
|
||||||
|
|
||||||
|
assert_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id, "force" => true})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can be called with additional job options" do
|
||||||
|
source = source_fixture(index_frequency_minutes: 1)
|
||||||
|
job_opts = [max_attempts: 5]
|
||||||
|
|
||||||
|
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source, %{}, job_opts)
|
||||||
|
|
||||||
|
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
|
||||||
|
assert job.max_attempts == 5
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "index_and_enqueue_download_for_media_items/1" do
|
describe "index_and_enqueue_download_for_media_items/1" do
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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' & <world>"
|
||||||
|
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], "")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ defmodule PinchflatWeb.MediaItemControllerTest do
|
|||||||
import Pinchflat.MediaFixtures
|
import Pinchflat.MediaFixtures
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
|
||||||
describe "show media" do
|
describe "show media" do
|
||||||
setup [:create_media_item]
|
setup [:create_media_item]
|
||||||
@@ -87,6 +88,31 @@ defmodule PinchflatWeb.MediaItemControllerTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "force_download" do
|
||||||
|
test "enqueues download task", %{conn: conn} do
|
||||||
|
media_item = media_item_fixture()
|
||||||
|
|
||||||
|
assert [] = all_enqueued(worker: MediaDownloadWorker)
|
||||||
|
post(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}/force_download")
|
||||||
|
assert [_] = all_enqueued(worker: MediaDownloadWorker)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "forces a download even if one wouldn't normally run", %{conn: conn} do
|
||||||
|
media_item = media_item_fixture(%{media_filepath: nil})
|
||||||
|
|
||||||
|
post(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}/force_download")
|
||||||
|
assert [_] = all_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id, "force" => true})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects to the show page", %{conn: conn} do
|
||||||
|
media_item = media_item_fixture()
|
||||||
|
|
||||||
|
conn = post(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}/force_download")
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}/media/#{media_item.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "streaming media" do
|
describe "streaming media" do
|
||||||
test "returns 404 if the media isn't found", %{conn: conn} do
|
test "returns 404 if the media isn't found", %{conn: conn} do
|
||||||
media_item = media_item_fixture()
|
media_item = media_item_fixture()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,10 +8,12 @@ defmodule PinchflatWeb.SourceControllerTest do
|
|||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Settings
|
alias Pinchflat.Settings
|
||||||
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
media_profile = media_profile_fixture()
|
media_profile = media_profile_fixture()
|
||||||
Settings.set!(:onboarding, false)
|
Settings.set(onboarding: false)
|
||||||
|
|
||||||
{
|
{
|
||||||
:ok,
|
:ok,
|
||||||
@@ -45,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"
|
||||||
@@ -72,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"
|
||||||
@@ -160,6 +162,59 @@ defmodule PinchflatWeb.SourceControllerTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "force_download" do
|
||||||
|
test "enqueues pending download tasks", %{conn: conn} do
|
||||||
|
source = source_fixture()
|
||||||
|
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
||||||
|
|
||||||
|
assert [] = all_enqueued(worker: MediaDownloadWorker)
|
||||||
|
post(conn, ~p"/sources/#{source.id}/force_download")
|
||||||
|
assert [_] = all_enqueued(worker: MediaDownloadWorker)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects to the source page", %{conn: conn} do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
conn = post(conn, ~p"/sources/#{source.id}/force_download")
|
||||||
|
assert redirected_to(conn) == ~p"/sources/#{source.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "force_index" do
|
||||||
|
test "forces an index", %{conn: conn} do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
assert [] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
||||||
|
post(conn, ~p"/sources/#{source.id}/force_index")
|
||||||
|
assert [_] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "forces an index even if one wouldn't normally run", %{conn: conn} do
|
||||||
|
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
|
||||||
|
|
||||||
|
post(conn, ~p"/sources/#{source.id}/force_index")
|
||||||
|
assert [job] = all_enqueued(worker: MediaCollectionIndexingWorker)
|
||||||
|
assert job.args == %{"id" => source.id, "force" => true}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "deletes pending indexing tasks", %{conn: conn} do
|
||||||
|
source = source_fixture()
|
||||||
|
{:ok, task} = MediaCollectionIndexingWorker.kickoff_with_task(source)
|
||||||
|
job = Repo.preload(task, :job).job
|
||||||
|
|
||||||
|
assert job.state == "available"
|
||||||
|
post(conn, ~p"/sources/#{source.id}/force_index")
|
||||||
|
assert Repo.reload!(job).state == "cancelled"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects to the source page", %{conn: conn} do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
conn = post(conn, ~p"/sources/#{source.id}/force_index")
|
||||||
|
assert redirected_to(conn) == ~p"/sources/#{source.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp create_source(_) do
|
defp create_source(_) do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
media_item = media_item_with_attachments(%{source_id: source.id})
|
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ defmodule PinchflatWeb.ConnCase do
|
|||||||
quote do
|
quote do
|
||||||
# The default endpoint for testing
|
# The default endpoint for testing
|
||||||
@endpoint PinchflatWeb.Endpoint
|
@endpoint PinchflatWeb.Endpoint
|
||||||
|
alias Pinchflat.Repo
|
||||||
|
|
||||||
use PinchflatWeb, :verified_routes
|
use PinchflatWeb, :verified_routes
|
||||||
|
use Oban.Testing, repo: Repo
|
||||||
|
|
||||||
# Import conveniences for testing with connections
|
# Import conveniences for testing with connections
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user